Cómo depurar fugas de memoria en WebAssembly con Emscripten

Si bien JavaScript es bastante tolerante a la limpieza de lo que sucede después, los lenguajes estáticos definitivamente no...

Squoosh.app es una AWP que ilustra la medida en que los diferentes códecs de imagen y configuración pueden mejorar el tamaño del archivo de imagen sin afectar significativamente la calidad. Sin embargo, también es una demostración técnica en la que se muestra cómo puedes tomar bibliotecas escritas en C++ o Rust y llevarlas a la Web.

Poder transferir código desde ecosistemas existentes es increíblemente valioso, pero existen algunas diferencias clave entre esos lenguajes estáticos y JavaScript. Una de ellas está en sus diferentes enfoques sobre la administración de la memoria.

Si bien JavaScript es bastante tolerante a la limpieza de lo que sucede después, estos lenguajes estáticos definitivamente no lo son. Debes solicitar de manera explícita una nueva memoria asignada y debes asegurarte de devolverla después y nunca volver a usarla. Si eso no sucede, se producirán fugas que, en realidad, sucede con bastante frecuencia. Veamos cómo puedes depurar esas fugas de memoria y, lo que es aún mejor, cómo puedes diseñar tu código para evitarlas la próxima vez.

Patrón sospechoso

Recientemente, mientras empecé a trabajar en Squoosh, noté un patrón interesante en wrappers de códecs de C++. Echemos un vistazo a un wrapper ImageQuant como ejemplo (reducido para mostrar solo las partes de creación y desasignación de objetos):

liq_attr* attr;
liq_image* image;
liq_result* res;
uint8_t* result;

RawImage quantize(std::string rawimage,
                  int image_width,
                  int image_height,
                  int num_colors,
                  float dithering) {
  const uint8_t* image_buffer = (uint8_t*)rawimage.c_str();
  int size = image_width * image_height;

  attr = liq_attr_create();
  image = liq_image_create_rgba(attr, image_buffer, image_width, image_height, 0);
  liq_set_max_colors(attr, num_colors);
  liq_image_quantize(image, attr, &res);
  liq_set_dithering_level(res, dithering);
  uint8_t* image8bit = (uint8_t*)malloc(size);
  result = (uint8_t*)malloc(size * 4);

  // …

  free(image8bit);
  liq_result_destroy(res);
  liq_image_destroy(image);
  liq_attr_destroy(attr);

  return {
    val(typed_memory_view(image_width * image_height * 4, result)),
    image_width,
    image_height
  };
}

void free_result() {
  free(result);
}

JavaScript (bueno, TypeScript):

export async function process(data: ImageData, opts: QuantizeOptions) {
  if (!emscriptenModule) {
    emscriptenModule = initEmscriptenModule(imagequant, wasmUrl);
  }
  const module = await emscriptenModule;

  const result = module.quantize(/* … */);

  module.free_result();

  return new ImageData(
    new Uint8ClampedArray(result.view),
    result.width,
    result.height
  );
}

¿Detectas algún problema? Sugerencia: Es uso después gratuito, pero en JavaScript.

En Emscripten, typed_memory_view muestra un Uint8Array de JavaScript respaldado por el búfer de memoria WebAssembly (Wasm), con byteOffset y byteLength configurados en el puntero y la longitud determinados. Lo principal es que esta es una vista de TypedArray en un búfer de memoria de WebAssembly, en lugar de una copia de los datos perteneciente a JavaScript.

Cuando llamamos a free_result desde JavaScript, esta, a su vez, llama a una función C estándar free a fin de marcar esta memoria como disponible para cualquier asignación futura, lo que significa que los datos a los que apunta nuestra vista Uint8Array se pueden reemplazar por datos arbitrarios con cualquier llamada futura a Wasm.

O bien, alguna implementación de free podría incluso decidir completar cero en la memoria liberada de inmediato. La clase free que usa Emscripten no puede hacer eso, pero nos apoyamos en detalles de implementación que no se pueden garantizar.

O bien, incluso si se conserva la memoria detrás del puntero, es posible que la asignación nueva necesite aumentar la memoria de WebAssembly. Cuando se aumenta WebAssembly.Memory a través de la API de JavaScript o de la instrucción memory.grow correspondiente, se invalida el ArrayBuffer existente y, de forma transitiva, cualquier vista respaldada por este.

Permíteme usar la consola de Herramientas para desarrolladores (o Node.js) para demostrar este comportamiento:

> memory = new WebAssembly.Memory({ initial: 1 })
Memory {}

> view = new Uint8Array(memory.buffer, 42, 10)
Uint8Array(10) [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
// ^ all good, we got a 10 bytes long view at address 42

> view.buffer
ArrayBuffer(65536) {}
// ^ its buffer is the same as the one used for WebAssembly memory
//   (the size of the buffer is 1 WebAssembly "page" == 64KB)

> memory.grow(1)
1
// ^ let's say we grow Wasm memory by +1 page to fit some new data

> view
Uint8Array []
// ^ our original view is no longer valid and looks empty!

> view.buffer
ArrayBuffer(0) {}
// ^ its buffer got invalidated as well and turned into an empty one

Por último, incluso si no volvemos a llamar explícitamente a Wasm entre free_result y new Uint8ClampedArray, en algún momento podríamos agregar compatibilidad con varios subprocesos a nuestros códecs. En ese caso, podría ser un subproceso completamente diferente que reemplace los datos justo antes de que podamos clonarlos.

Buscando errores de memoria

Por si acaso, decidí profundizar y verificar si este código muestra algún problema en la práctica. Esta parece una oportunidad perfecta para probar la nueva compatibilidad con desinfectantes de Emscripten que se agregó el año pasado y que se presentó en nuestra charla de WebAssembly en la Cumbre de desarrolladores de Chrome:

En este caso, nos interesa AddressSanitizer, que puede detectar varios problemas relacionados con el puntero y la memoria. Para usarlo, debemos volver a compilar nuestro códec con -fsanitize=address:

emcc \
  --bind \
  ${OPTIMIZE} \
  --closure 1 \
  -s ALLOW_MEMORY_GROWTH=1 \
  -s MODULARIZE=1 \
  -s 'EXPORT_NAME="imagequant"' \
  -I node_modules/libimagequant \
  -o ./imagequant.js \
  --std=c++11 \
  imagequant.cpp \
  -fsanitize=address \
  node_modules/libimagequant/libimagequant.a

Esto habilitará automáticamente las verificaciones de seguridad del puntero, pero también queremos encontrar posibles fugas de memoria. Debido a que usamos ImageQuant como biblioteca en lugar de programa, no hay un "punto de salida" en el que Emscripten pueda validar automáticamente que se liberó toda la memoria.

En cambio, para esos casos, LeakSanitizer (que se incluye en AddressSanitizer) proporciona las funciones __lsan_do_leak_check y __lsan_do_recoverable_leak_check, que se pueden invocar de forma manual cada vez que se espere que se libere toda la memoria y podamos validar esa suposición. __lsan_do_leak_check está diseñado para usarse al final de una aplicación en ejecución, cuando deseas anular el proceso en caso de que se detecten fugas, mientras que __lsan_do_recoverable_leak_check es más adecuado para casos de uso de bibliotecas como el nuestro, cuando deseas imprimir fugas en la consola, pero mantener la aplicación en ejecución de todos modos.

Expongamos ese segundo ayudante a través de Embind para que podamos llamarlo desde JavaScript en cualquier momento:

#include <sanitizer/lsan_interface.h>

// …

void free_result() {
  free(result);
}

EMSCRIPTEN_BINDINGS(my_module) {
  function("zx_quantize", &zx_quantize);
  function("version", &version);
  function("free_result", &free_result);
  function("doLeakCheck", &__lsan_do_recoverable_leak_check);
}

Y, cuando terminemos de usar la imagen, la invocaremos desde JavaScript. Hacer esto desde JavaScript, en lugar de C++, ayuda a garantizar que se hayan cerrado todos los permisos y que todos los objetos temporales de C++ se hayan liberado cuando ejecutemos esas comprobaciones:

  // …

  const result = opts.zx
    ? module.zx_quantize(data.data, data.width, data.height, opts.dither)
    : module.quantize(data.data, data.width, data.height, opts.maxNumColors, opts.dither);

  module.free_result();
  module.doLeakCheck();

  return new ImageData(
    new Uint8ClampedArray(result.view),
    result.width,
    result.height
  );
}

Esto nos proporciona un informe como el siguiente en la consola:

Captura de pantalla de un mensaje

Hay algunas fugas pequeñas, pero el seguimiento de pila no es muy útil, ya que todos los nombres de las funciones están alterados. Hagamos una nueva compilación con información básica de depuración para preservarlos:

emcc \
  --bind \
  ${OPTIMIZE} \
  --closure 1 \
  -s ALLOW_MEMORY_GROWTH=1 \
  -s MODULARIZE=1 \
  -s 'EXPORT_NAME="imagequant"' \
  -I node_modules/libimagequant \
  -o ./imagequant.js \
  --std=c++11 \
  imagequant.cpp \
  -fsanitize=address \
  -g2 \
  node_modules/libimagequant/libimagequant.a

Esto se ve mucho mejor:

Captura de pantalla de un mensaje que dice “Fuga directa de 12 bytes” que proviene de una función GenericBindingType RawImage ::toWireType

Algunas partes del seguimiento de pila aún se ven oscuras, ya que apuntan a los componentes internos de Emscripten, pero se puede saber que la fuga proviene de una conversión de RawImage a "tipo de cable" (a un valor de JavaScript) de Embind. De hecho, cuando observamos el código, podemos ver que mostramos instancias de C++ RawImage en JavaScript, pero nunca las liberamos de ninguna de las partes.

Te recordamos que, por el momento, no hay una integración de recolección de elementos no utilizados entre JavaScript y WebAssembly, aunque se está desarrollando. En cambio, debes liberar manualmente la memoria y llamar a los destructores desde JavaScript una vez que hayas terminado con el objeto. En el caso específico de Embind, los documentos oficiales sugieren llamar a un método .delete() en clases de C++ expuestas:

El código JavaScript debe borrar de forma explícita cualquier controlador de objeto C++ que haya recibido; de lo contrario, el montón de Emscripten crecerá de forma indefinida.

var x = new Module.MyClass;
x.method();
x.delete();

De hecho, cuando lo hacemos en JavaScript para nuestra clase:

  // …

  const result = opts.zx
    ? module.zx_quantize(data.data, data.width, data.height, opts.dither)
    : module.quantize(data.data, data.width, data.height, opts.maxNumColors, opts.dither);

  module.free_result();
  result.delete();
  module.doLeakCheck();

  return new ImageData(
    new Uint8ClampedArray(result.view),
    result.width,
    result.height
  );
}

La fuga desaparece como se esperaba.

Descubrir más problemas con desinfectantes

La compilación de otros códecs de Squoosh con desinfectantes revela problemas similares y nuevos. Por ejemplo, tengo este error en las vinculaciones de MozJPEG:

Captura de pantalla de un mensaje

Aquí, no es una fuga, sino que escribimos en una memoria fuera de los límites asignados abrir.

Si profundizamos en el código de MozJPEG, descubrimos que el problema es que jpeg_mem_dest (la función que usamos para asignar un destino de memoria para JPEG) reutiliza los valores existentes de outbuffer y outsize cuando no son cero:

if (*outbuffer == NULL || *outsize == 0) {
  /* Allocate initial buffer */
  dest->newbuffer = *outbuffer = (unsigned char *) malloc(OUTPUT_BUF_SIZE);
  if (dest->newbuffer == NULL)
    ERREXIT1(cinfo, JERR_OUT_OF_MEMORY, 10);
  *outsize = OUTPUT_BUF_SIZE;
}

Sin embargo, lo invocamos sin inicializar ninguna de esas variables, lo que significa que MozJPEG escribe el resultado en una dirección de memoria potencialmente aleatoria que se almacenó en esas variables en el momento de la llamada.

uint8_t* output;
unsigned long size;
// …
jpeg_mem_dest(&cinfo, &output, &size);

Con la inicialización cero de ambas variables antes de la invocación, se soluciona el problema y, ahora, el código alcanza una comprobación de fuga de memoria. Afortunadamente, la verificación se aprueba de forma correcta, lo que indica que no tenemos ninguna fuga en este códec.

Problemas con el estado compartido

... ¿O nosotros?

Sabemos que nuestras vinculaciones de códecs almacenan parte del estado y los resultados en variables estáticas globales, y MozJPEG tiene algunas estructuras particularmente complicadas.

uint8_t* last_result;
struct jpeg_compress_struct cinfo;

val encode(std::string image_in, int image_width, int image_height, MozJpegOptions opts) {
  // …
}

¿Qué sucede si algunos de esos se inicializan de forma diferida en la primera ejecución y, luego, se reutilizan de forma inadecuada en ejecuciones futuras? En ese caso, una sola llamada con desinfectante no las informaría como problemáticas.

Probemos la imagen un par de veces haciendo clic de forma aleatoria en diferentes niveles de calidad en la IU. De hecho, ahora obtenemos el siguiente informe:

Captura de pantalla de un mensaje

262,144 bytes, parece que toda la imagen de muestra se filtró desde jpeg_finish_compress

Después de revisar los documentos y los ejemplos oficiales, resulta que jpeg_finish_compress no libera la memoria asignada por nuestra llamada a jpeg_mem_dest anterior; solo libera la estructura de compresión, aunque esa estructura ya conozca nuestro destino de memoria... Suspiro.

Para solucionar este problema, liberamos los datos de forma manual en la función free_result:

void free_result() {
  /* This is an important step since it will release a good deal of memory. */
  free(last_result);
  jpeg_destroy_compress(&cinfo);
}

Podría seguir buscando esos errores de memoria uno por uno, pero creo que a estas alturas está lo suficientemente claro como que el enfoque actual para la administración de la memoria genera algunos problemas sistemáticos desagradables.

El desinfectante puede detectar algunas de ellas de inmediato. Otros requieren trucos complejos para que los atrapen. Por último, hay problemas, como al comienzo de la publicación, que el limpiador no detecta en absoluto. Esto se debe a que el uso inadecuado real ocurre en JavaScript, y el limpiador no tiene visibilidad. Esos problemas solo se mostrarán en producción o después de cambios en el código aparentemente no relacionados en el futuro.

Compila un wrapper seguro

Demos un par de pasos atrás y, en su lugar, corrijamos todos estos problemas reestructurando el código de una manera más segura. Volveré a usar el wrapper ImageQuant como ejemplo, pero se aplican reglas de refactorización similares a todos los códecs y a otras bases de código similares.

En primer lugar, solucionemos el problema de usar después de la liberación desde el principio de la publicación. Para ello, debemos clonar los datos de la vista respaldada por WebAssembly antes de marcarla como libre en JavaScript:

  // …

  const result = /* … */;

  const imgData = new ImageData(
    new Uint8ClampedArray(result.view),
    result.width,
    result.height
  );

  module.free_result();
  result.delete();
  module.doLeakCheck();

  return new ImageData(
    new Uint8ClampedArray(result.view),
    result.width,
    result.height
  );
  return imgData;
}

Ahora, asegurémonos de no compartir ningún estado en variables globales entre invocaciones. Esto solucionará algunos de los problemas que ya vimos y facilitará el uso de nuestros códecs en un entorno de varios subprocesos en el futuro.

Para ello, refactorizamos el wrapper de C++ para asegurarnos de que cada llamada a la función administre sus propios datos mediante variables locales. Luego, podemos cambiar la firma de nuestra función free_result para aceptar el puntero hacia atrás:

liq_attr* attr;
liq_image* image;
liq_result* res;
uint8_t* result;

RawImage quantize(std::string rawimage,
                  int image_width,
                  int image_height,
                  int num_colors,
                  float dithering) {
  const uint8_t* image_buffer = (uint8_t*)rawimage.c_str();
  int size = image_width * image_height;

  attr = liq_attr_create();
  image = liq_image_create_rgba(attr, image_buffer, image_width, image_height, 0);
  liq_attr* attr = liq_attr_create();
  liq_image* image = liq_image_create_rgba(attr, image_buffer, image_width, image_height, 0);
  liq_set_max_colors(attr, num_colors);
  liq_result* res = nullptr;
  liq_image_quantize(image, attr, &res);
  liq_set_dithering_level(res, dithering);
  uint8_t* image8bit = (uint8_t*)malloc(size);
  result = (uint8_t*)malloc(size * 4);
  uint8_t* result = (uint8_t*)malloc(size * 4);

  // …
}

void free_result() {
void free_result(uint8_t *result) {
  free(result);
}

Sin embargo, como ya estamos usando Embind en Emscripten para interactuar con JavaScript, también podríamos hacer que la API sea aún más segura ocultando los detalles de la administración de memoria de C++ por completo.

Para ello, muevamos la parte new Uint8ClampedArray(…) de JavaScript al lado de C++ con Embind. Luego, podemos usarlos para clonar los datos en la memoria de JavaScript antes de regresar de la función:

class RawImage {
 public:
  val buffer;
  int width;
  int height;

  RawImage(val b, int w, int h) : buffer(b), width(w), height(h) {}
};
thread_local const val Uint8ClampedArray = val::global("Uint8ClampedArray");

RawImage quantize(/* … */) {
val quantize(/* … */) {
  // …
  return {
    val(typed_memory_view(image_width * image_height * 4, result)),
    image_width,
    image_height
  };
  val js_result = Uint8ClampedArray.new_(typed_memory_view(
    image_width * image_height * 4,
    result
  ));
  free(result);
  return js_result;
}

Ten en cuenta que, con un solo cambio, nos aseguramos de que el array de bytes resultante sea propiedad de JavaScript y no esté respaldado por la memoria de WebAssembly, y también nos deshicimos del wrapper RawImage que se filtró anteriormente.

Ahora JavaScript ya no tiene que preocuparse por liberar datos y puede usar el resultado como cualquier otro objeto recolectado por elementos no utilizados:

  // …

  const result = /* … */;

  const imgData = new ImageData(
    new Uint8ClampedArray(result.view),
    result.width,
    result.height
  );

  module.free_result();
  result.delete();
  // module.doLeakCheck();

  return imgData;
  return new ImageData(result, result.width, result.height);
}

Esto también significa que ya no necesitamos una vinculación free_result personalizada en el lado de C++:

void free_result(uint8_t* result) {
  free(result);
}

EMSCRIPTEN_BINDINGS(my_module) {
  class_<RawImage>("RawImage")
      .property("buffer", &RawImage::buffer)
      .property("width", &RawImage::width)
      .property("height", &RawImage::height);

  function("quantize", &quantize);
  function("zx_quantize", &zx_quantize);
  function("version", &version);
  function("free_result", &free_result, allow_raw_pointers());
}

En general, nuestro código de wrapper se volvió más limpio y seguro al mismo tiempo.

Después de esto, realicé algunas mejoras menores en el código del wrapper de ImageQuant y repetié correcciones similares de administración de la memoria para otros códecs. Si deseas obtener más detalles, puedes ver la solicitud de extracción resultante aquí: Correcciones de memoria para códecs de C++.

Conclusiones

¿Qué lecciones podemos aprender y compartir de esta refactorización que podrían aplicarse a otras bases de código?

  • No uses vistas de memoria respaldadas por WebAssembly, sin importar el lenguaje a partir del que se compile, más allá de una sola invocación. No puedes confiar en que sobrevivirán por más tiempo, y no podrás detectar estos errores por medios convencionales, por lo que si necesitas almacenar los datos para más adelante, cópialos en JavaScript y almacénalos allí.
  • Si es posible, usa un lenguaje de administración de memoria seguro o, al menos, wrappers de tipo seguro, en lugar de operar directamente en punteros sin procesar. Esto no te evitará errores en el límite de JavaScript → WebAssembly, pero al menos reducirá la superficie para errores que contiene el código de lenguaje estático.
  • Sin importar el lenguaje que uses, ejecuta código con esterilizadores durante el desarrollo, ya que pueden ayudarte a detectar no solo problemas en el código de lenguaje estático, sino también algunos problemas en el límite de JavaScript → WebAssembly, como olvidar llamar a .delete() o pasar punteros no válidos desde JavaScript.
  • Si es posible, evita exponer datos y objetos no administrados de WebAssembly a JavaScript por completo. JavaScript es un lenguaje con recolección de elementos no utilizados, y la administración manual de la memoria no es común en él. Esto se puede considerar una fuga de abstracción del modelo de memoria del lenguaje en el que se compiló WebAssembly, y es fácil pasar por alto la administración incorrecta en una base de código de JavaScript.
  • Esto puede ser obvio, pero, como en cualquier otra base de código, evita almacenar el estado mutable en variables globales. No es recomendable que depures los problemas relacionados con la reutilización en varias invocaciones o incluso en subprocesos, por lo que se recomienda mantenerlo lo más autónomo posible.