Mi nombre es Valery Shavel, soy del equipo de desarrollo del motor de vectores Yandex.Maps. Recientemente, implementamos la tecnología WebAssembly en el motor. A continuación, le diré por qué lo elegimos, qué resultados obtuvimos y cómo puede usar esta tecnología en su proyecto.

En Yandex.Maps, un mapa vectorial consiste en piezas llamadas mosaicos. De hecho, el mosaico es el área indexada del mapa. El mapa es vectorial, por lo que cada mosaico contiene muchas primitivas geométricas. Los mosaicos provienen del servidor al cliente codificado, y antes de mostrarlos es necesario procesar todas las primitivas. A veces lleva una cantidad considerable de tiempo. Un mosaico puede contener más de 2 mil polilíneas y polígonos.

Al procesar primitivas, el rendimiento es lo más importante. Si el mosaico no se prepara lo suficientemente rápido, el usuario lo verá demasiado tarde y los siguientes mosaicos se retrasarán en la cola. Para acelerar el procesamiento, decidimos probar la tecnología relativamente nueva de
WebAssembly (Wasm) .
Uso de WebAssembly en Maps
Ahora, la mayor parte del procesamiento de primitivas tiene lugar en el hilo de fondo (Web Worker), que vive una vida separada. Esto se hace para descargar el hilo principal tanto como sea posible. Por lo tanto, cuando el código para mostrar la tarjeta está incrustado en la página de servicio, que puede agregar una carga significativa, habrá menos frenos. La desventaja es que necesita configurar correctamente la mensajería entre el hilo principal y el Web Worker.
La parte del procesamiento que se produce en el subproceso de fondo consta esencialmente de dos pasos:
- El formato de protobuf que viene del servidor está descodificado .
- Las geometrías se generan y escriben en buffers.
En el segundo paso,
se forman búferes de vértices e índices para
WebGL . Estas memorias intermedias se utilizan cuando se procesan de la siguiente manera. Un búfer de vértices contiene para cada vértice sus parámetros, que son necesarios para determinar su posición en la pantalla en un momento particular. Un búfer de índice consiste en triples de índices. Cada triple significa que el triángulo con los vértices del búfer de vértices debajo de los índices indicados debe mostrarse en la pantalla. Por lo tanto, lo primitivo debe dividirse en triángulos, que también puede ser una tarea que requiere mucho tiempo:

Obviamente, durante el segundo paso, se lleva a cabo una gran cantidad de manipulación de la memoria y cálculos matemáticos, porque para la representación correcta de las primitivas, se necesita mucha información sobre cada vértice de la primitiva:

No estábamos contentos con el rendimiento de nuestro código JavaScript. En este momento, todos comenzaron a escribir sobre WebAssembly, la tecnología estaba en constante evolución y mejora. Después de leer la investigación, sugerimos que Wasm podría acelerar nuestras operaciones. Aunque no estábamos completamente seguros de esto: resultó ser difícil encontrar datos sobre el uso de Wasm en un proyecto tan grande.
Wasm también es algo peor que TypeScript:
- Necesitamos inicializar un módulo especial con las funciones que necesitamos. Esto puede causar un retraso antes de que esta funcionalidad comience a funcionar.
- El tamaño del código fuente al compilar Wasm es mucho mayor que en TS.
- Los desarrolladores deben admitir una versión alternativa de la ejecución del código, que también está escrita en un lenguaje atípico para la interfaz.
Sin embargo, a pesar de todo esto, nos arriesgamos a reescribir parte de nuestro código usando Wasm.
WebAssembly Información general

Wasm es un formato binario; Puede compilar diferentes idiomas y luego ejecutar el código en un navegador. A menudo, dicho código precompilado es más rápido que el JavaScript clásico. El código en formato WebAssembly no tiene acceso a los elementos DOM de la página y, como regla, se usa para realizar tareas computacionales laboriosas en el cliente.
Elegimos C ++ como el lenguaje compilado, ya que es bastante conveniente y rápido.
Para compilar C ++ en WebAssembly, utilizamos
emscripten . Después de instalarlo y agregarlo a un proyecto de C ++, para obtener el módulo, debe escribir el archivo del proyecto principal de cierta manera. Por ejemplo, podría verse así:
#include <emscripten/bind.h> #include <emscripten.h> #include <math.h> struct Point { double x; double y; }; double sqr(double x) { return x * x; } EMSCRIPTEN_BINDINGS(my_value_example) { emscripten::value_object<Point>("Point") .field("x", &Point::x) .field("y", &Point::y) ; emscripten::register_vector<Point>("vector<Point>"); emscripten::function("distance", emscripten::optional_override( [](Point point1, Point point2) { return sqrt(sqr(point1.x - point2.x) + sqr(point1.y - point2.y)) ; })); }
A continuación, describiré cómo puede usar este código en su proyecto TypeScript.
En el código, definimos la estructura Point y la asignamos a la interfaz Point en TypeScript, en la que habrá dos campos: x e y, corresponden a los campos de la estructura.
Además, si queremos devolver el contenedor de vectores estándar de C ++ a TypeScript, entonces debemos registrarlo para el tipo Point. Luego, en TypeScript, la interfaz con las funciones necesarias le corresponderá.
Y finalmente, el código muestra cómo registrar su función para llamarla desde TypeScript por el nombre correspondiente.
Compile el archivo usando emscripten y agregue el módulo resultante a su proyecto TypeScript. Ahora podemos escribir un archivo genérico d.ts para un módulo emscripten arbitrario, en el que las funciones y tipos útiles están predefinidos:
declare module "emscripten_module" { interface EmscriptenModule { readonly wasmMemory: WebAssembly.Memory; readonly HEAPU8: Uint8Array; readonly HEAPF64: Float64Array; locateFile: (path: string) => string; onRuntimeInitialized: () => void; _malloc: (size: size_t) => uintptr_t; _free: (addr: size_t) => uintptr_t; } export default EmscriptenModule; export type uintptr_t = number; export type size_t = number; }
Y podemos escribir el archivo d.ts para nuestro módulo:
declare module "emscripten_point" { import EmscriptenModule, {uintptr_t, size_t} from 'emscripten_module'; interface NativeObject { delete: () => void; } interface Vector<T> extends NativeObject { get(index: number): T; size(): number; } interface Point { readonly x: number; readonly y: number; } interface PointModule extends EmscriptenModule { distance: (point1: Point, point2: Point) => number; } type PointModuleUninitialized = Partial<PointModule>; export default function createModuleApi(Module: Partial<PointModule>): PointModule; }
Ahora podemos escribir una función que creará Promise para la inicialización del módulo y usarla:
import EmscriptenModule from 'emscripten_module'; import createPointModuleApi, {PointModule} from 'emscripten_point'; import * as pointModule from 'emscripten_point.wasm'; export default function initEmscriptenModule<ModuleT extends EmscriptenModule>( moduleUrl: string, moduleInitializer: (module: Partial<EmscriptenModule>) => ModuleT ): Promise<ModuleT> { return new Promise((resolve) => { const module = moduleInitializer({ locateFile: () => moduleUrl, onRuntimeInitialized: function (): void {
Ahora, para esta promesa, obtenemos nuestro módulo junto con la función de distancia.
Desafortunadamente, no puede depurar el código Wasm línea por línea en el navegador. Por lo tanto, es necesario escribir pruebas y ejecutar código en ellas como C ++ normal, entonces tendrá la oportunidad de una depuración conveniente. Sin embargo, incluso en el navegador, tiene acceso a la secuencia de cout estándar, que enviará todo a la consola del navegador.
Un ejemplo de proyecto del artículo está disponible a través de este
enlace , donde puede ver la configuración de webpack.config y CMakeLists.
Resultados
Entonces, reescribimos parte de nuestro código y lanzamos un experimento para considerar analizar polígonos y polígonos. El diagrama muestra los resultados medianos para un mosaico para Wasm y JavaScript:

Como resultado, obtuvimos tales coeficientes relativos para cada métrica:

Como puede ver en el tiempo de análisis primitivo puro y el tiempo de decodificación de mosaicos, Wasm es más de cuatro veces más rápido. Si observa el tiempo de análisis total, la diferencia también es significativa, pero aún es un poco menor. Esto se debe al costo de transferir datos a Wasm y recopilar el resultado. También vale la pena señalar que en las primeras fichas la ganancia general es muy alta (en las primeras diez, más de cinco veces). Sin embargo, entonces el coeficiente relativo disminuye a aproximadamente tres.
Como resultado, todo esto en conjunto ayudó a reducir el tiempo de procesamiento de un mosaico en el hilo de fondo en un 20-25%. Por supuesto, esta diferencia no es tan grande como las anteriores, pero debe comprender que analizar las líneas discontinuas y los polígonos está lejos de todo el procesamiento de mosaicos.
Si hablamos de la necesidad de inicializar el módulo, por eso, aproximadamente la mitad de los usuarios tuvieron un retraso antes de analizar el primer mosaico. El retraso medio es de 188 ms. El retraso ocurre solo antes del primer mosaico, y la victoria en el análisis es constante, por lo que puede soportar una pequeña pausa al comienzo y no considerarlo un problema grave.
Otro lado negativo es el tamaño del archivo de código fuente. Código minimizado comprimido con Gzip para todo el motor de mapas vectoriales sin Wasm - 85 KB, con Wasm - 191 KB. Al mismo tiempo, solo se implementa el análisis de líneas discontinuas y rectángulos en Wasm, y no todas las primitivas que pueden estar en un mosaico. Además, para decodificar protobuf, tuve que elegir una implementación de biblioteca en C puro, con una implementación de C ++, el tamaño era aún mayor. Esta diferencia puede reducirse en cierta medida utilizando el indicador del compilador -Oz en lugar de -O3 al compilar C ++, pero sigue siendo significativo. Además, con tal reemplazo, perdemos productividad.
Sin embargo, el tamaño de la fuente no afectó significativamente la velocidad de inicialización de la tarjeta. Wasm es peor solo en dispositivos lentos, y la diferencia es inferior al 2%. Pero el conjunto inicial visible de mosaicos vectoriales en la implementación con Wasm se mostró a los usuarios un poco más rápido que con la implementación JS. Esto se debe a la mayor ganancia en los primeros mosaicos procesados, mientras que JS aún no está optimizado.
Por lo tanto, Wasm ahora es una opción valiosa si no se siente cómodo con el rendimiento del código JavaScript. Al mismo tiempo, puede obtener menos ganancia de rendimiento que nosotros, o no puede obtenerlo en absoluto. Esto se debe al hecho de que a veces el propio JavaScript funciona bastante rápido, y en Wasm necesita transferir datos y recopilar el resultado.
Nuestros mapas ahora ejecutan JavaScript normal. Esto se debe al hecho de que la ganancia en el análisis no es tan grande en el contexto general, y al hecho de que solo se implementan algunos tipos de primitivas en Wasm. Si esto cambia, quizás usaremos Wasm. Otro argumento poderoso en contra de esto es la complejidad del ensamblaje y la depuración: apoyar un proyecto en dos idiomas tiene sentido solo cuando la ganancia de rendimiento lo vale.