Como implementamos o WebAssembly no Yandex.Maps e por que deixamos o JavaScript

Meu nome é Valery Shavel, sou da equipe de desenvolvimento do mecanismo de vetores Yandex.Maps. Recentemente, implementamos a tecnologia WebAssembly no mecanismo. Abaixo, mostrarei por que a escolhemos, quais resultados obtivemos e como você pode usar essa tecnologia em seu projeto.



No Yandex.Maps, um mapa vetorial consiste em peças chamadas blocos. De fato, o bloco é a área indexada do mapa. Como o mapa é vetorial, cada bloco contém muitas primitivas geométricas. Os blocos vêm do servidor para o cliente codificado e, antes da exibição, é necessário processar todas as primitivas. Às vezes, leva uma quantidade considerável de tempo. Um bloco pode conter mais de 2 mil polilinhas e polígonos.



Ao processar primitivas, o desempenho é mais importante. Se o bloco não for preparado com rapidez suficiente, o usuário o verá tarde demais e os próximos blocos serão atrasados ​​na fila. Para acelerar o processamento, decidimos experimentar a relativamente nova tecnologia WebAssembly (Wasm) .

Usando o WebAssembly no Maps


Agora, a maior parte do processamento de primitivas ocorre no encadeamento em segundo plano (Web Worker), que vive uma vida separada. Isso é feito para descarregar o thread principal o máximo possível. Assim, quando o código para mostrar o cartão estiver incorporado na página de serviço, o que pode adicionar uma carga significativa, haverá menos freios. A desvantagem é que você precisa configurar corretamente as mensagens entre o thread principal e o Web Worker.

A parte do processamento que ocorre no encadeamento em segundo plano consiste essencialmente em duas etapas:

  1. O formato protobuf que vem do servidor é decodificado .
  2. Geometrias são geradas e gravadas em buffers.

Na segunda etapa, os buffers de vértice e índice para WebGL são formados . Esses buffers são usados ​​ao renderizar da seguinte maneira. Um buffer de vértice contém para cada vértice seus parâmetros, necessários para determinar sua posição na tela em um determinado momento. Um buffer de índice consiste em triplos de índices. Cada triplo significa que um triângulo com vértices do buffer de vértice nos índices indicados deve ser exibido na tela. Portanto, o primitivo deve ser dividido em triângulos, o que também pode ser uma tarefa demorada:



Obviamente, durante a segunda etapa, existem muitas manipulações de memória e cálculos matemáticos, porque para a renderização correta de primitivas, você precisa de muitas informações sobre cada vértice da primitiva:



Não ficamos satisfeitos com o desempenho do nosso código JavaScript. Nesse momento, todos começaram a escrever sobre o WebAssembly, a tecnologia estava em constante evolução e aprimoramento. Depois de ler a pesquisa, sugerimos que o Wasm pudesse acelerar nossas operações. Embora não tivéssemos certeza absoluta disso: era difícil encontrar dados sobre o uso do Wasm em um projeto tão grande.

Wasm também é um pouco pior que o TypeScript:

  1. Precisamos inicializar um módulo especial com as funções que precisamos. Isso pode causar um atraso antes que essa funcionalidade comece a funcionar.
  2. O tamanho do código-fonte ao compilar Wasm é muito maior que no TS.
  3. Os desenvolvedores precisam oferecer suporte a uma versão alternativa da execução do código, que também é escrita em uma linguagem atípica para o frontend.

No entanto, apesar de tudo isso, arriscamos reescrever parte do nosso código usando Wasm.

Informações gerais sobre o WebAssembly




Wasm é um formato binário; Você pode compilar idiomas diferentes e executar o código em um navegador. Geralmente, esse código pré-compilado é mais rápido que o JavaScript clássico. O código no formato WebAssembly não tem acesso aos elementos DOM da página e, como regra, é usado para executar tarefas computacionais demoradas no cliente.

Escolhemos o C ++ como a linguagem compilada, pois é bastante conveniente e rápido.

Para compilar C ++ no WebAssembly, usamos o emscripten . Depois de instalá-lo e adicioná-lo a um projeto C ++, para obter o módulo, você precisa escrever o arquivo principal do projeto de uma certa maneira. Por exemplo, pode ser assim:

#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 seguir, descreverei como você pode usar esse código no seu projeto TypeScript.

No código, definimos a estrutura Point e a mapeamos para a interface Point no TypeScript, na qual haverá dois campos - x e y, eles correspondem aos campos da estrutura.

Além disso, se queremos retornar o contêiner de vetor padrão de C ++ para TypeScript, precisamos registrá-lo para o tipo Point. Em TypeScript, a interface com as funções necessárias corresponderá a ela.

E, finalmente, o código mostra como registrar sua função para chamá-la do TypeScript pelo nome correspondente.

Compile o arquivo usando emscripten e adicione o módulo resultante ao seu projeto TypeScript. Agora podemos escrever um arquivo d.ts genérico para um módulo emscripten arbitrário, no qual funções e tipos úteis são 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; } 

E podemos escrever o arquivo d.ts para o nosso 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; } 

Agora podemos escrever uma função que criará o Promise para a inicialização do módulo e a usaremos:

 import EmscriptenModule from 'emscripten_module'; import createPointModuleApi, {PointModule} from 'emscripten_point'; import * as pointModule from 'emscripten_point.wasm'; /** * Promisifies initialization of emscripten module. * * @param moduleUrl URL to wasm file, it could be encoded data URL. * @param moduleInitializer Escripten module factory, * see https://emscripten.org/docs/compiling/WebAssembly.html#compiler-output. */ 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 { // module itself is thenable, to prevent infinite promise resolution delete (<any>module).then; resolve(module); } }); }); } const initialization = initEmscriptenModule( 'data:application/wasm;base64,' + pointModule, createPointModuleApi ); 

Agora, para esta promessa, obtemos nosso módulo junto com a função de distância.

Infelizmente, você não pode depurar o código Wasm linha por linha no navegador. Portanto, é necessário escrever testes e executar código neles como C ++ normal, e você terá a oportunidade de depurar comodamente. No entanto, mesmo no navegador, você tem acesso ao fluxo cout padrão, que produzirá tudo para o console do navegador.

Um exemplo de projeto do artigo está disponível neste link , onde você pode ver as configurações para webpack.config e CMakeLists.

Resultados


Então, reescrevemos parte do nosso código e lançamos um experimento para considerar a análise de polígonos e polígonos. O diagrama mostra os resultados medianos de um bloco para Wasm e JavaScript:



Como resultado, obtivemos coeficientes relativos para cada métrica:



Como você pode ver no tempo de análise primitivo puro e no tempo de decodificação do bloco, o Wasm é quatro vezes mais rápido. Se você observar o tempo total de análise, a diferença também é significativa, mas ainda é um pouco menos. Isso se deve ao custo de transferir dados para o Wasm e coletar o resultado. Também é importante notar que nas primeiras peças o ganho geral é muito alto (nas primeiras dez - mais de cinco vezes). No entanto, o coeficiente relativo diminui para cerca de três.

Como resultado, tudo isso junto ajudou a reduzir o tempo de processamento de um bloco no encadeamento em segundo plano em 20-25%. Obviamente, essa diferença não é tão grande quanto as anteriores, mas você precisa entender que a análise de linhas e polígonos quebrados está longe de ser o processamento de todos os blocos.

Se falamos da necessidade de inicializar o módulo, por causa disso, cerca de metade dos usuários teve um atraso antes de analisar o primeiro bloco. O atraso médio é de 188 ms. O atraso ocorre apenas antes do primeiro bloco e a vitória na análise é constante, para que você possa aguentar uma pequena pausa no início e não considerá-lo um problema sério.

Outro lado negativo é o tamanho do arquivo de código-fonte. Código compactado compactado com gzip para todo o mecanismo de mapas vetoriais sem Wasm - 85 KB, com Wasm - 191 KB. Ao mesmo tempo, apenas a análise de linhas e retângulos quebrados é implementada no Wasm, e nem todas as primitivas que podem estar em um bloco. Além disso, para decodificar o protobuf, tive que escolher uma implementação de biblioteca em C puro, com uma implementação em C ++ que o tamanho era ainda maior. Essa diferença pode ser um pouco reduzida usando o sinalizador do compilador -Oz em vez de -O3 ao compilar C ++, mas ainda é significativo. Além disso, com essa substituição, perdemos produtividade.

No entanto, o tamanho da fonte não afetou significativamente a velocidade de inicialização do cartão. Wasm é pior apenas em dispositivos lentos, e a diferença é inferior a 2%. Mas o conjunto visível inicial de blocos vetoriais na implementação com Wasm foi mostrado aos usuários um pouco mais rápido do que com a implementação JS. Isso ocorre devido ao maior ganho nos primeiros blocos processados, enquanto o JS ainda não está otimizado.

Portanto, o Wasm agora é uma opção válida se você não se sentir confortável com o desempenho do código JavaScript. Ao mesmo tempo, você pode obter menos ganho de desempenho do que nós, ou não consegue. Isso se deve ao fato de que, às vezes, o próprio JavaScript funciona rapidamente, e no Wasm você precisa transferir dados e coletar o resultado.

Nossos mapas agora executam JavaScript regular. Isso se deve ao fato de que o ganho na análise não é tão grande no contexto geral e pelo fato de que apenas alguns tipos de primitivos são implementados no Wasm. Se isso mudar, talvez usaremos Wasm. Outro argumento poderoso contra isso é a complexidade de montagem e depuração: o suporte a um projeto em dois idiomas só faz sentido quando o ganho de desempenho vale a pena.

Source: https://habr.com/ru/post/pt475382/


All Articles