Como dimensionar aplicativos WebAssembly multithread com mimalloc e WasmFS

Alon Zakai
Alon Zakai

Publicado em 30 de janeiro de 2025

Muitos aplicativos da WebAssembly na Web se beneficiam da multitarefa, da mesma forma que os aplicativos nativos. Várias linhas de execução permitem que mais trabalho seja realizado em paralelo e remova o trabalho pesado da linha de execução principal para evitar problemas de latência. Até recentemente, havia alguns problemas comuns que podiam acontecer com esses aplicativos multithread, relacionados a alocações e E/S. Felizmente, os recursos mais recentes do Emscripten podem ajudar muito com esses problemas. Este guia mostra como esses recursos podem levar a melhorias de velocidade de até 10 vezes ou mais em alguns casos.

Escalonamento

O gráfico a seguir mostra a escalação eficiente com vários threads em uma carga de trabalho pura de matemática (do benchmark que vamos usar neste artigo):

Um gráfico de linhas intitulado "Escala matemática" mostra a relação entre o número de núcleos (eixo x) e o tempo de execução em milissegundos (eixo y, identificado como

Isso mede a computação pura, algo que cada núcleo da CPU pode fazer por conta própria, de modo que a performance melhora com mais núcleos. Uma linha descendente de desempenho mais rápido é exatamente o que acontece com uma boa escalação. E mostra que a plataforma da Web pode executar código nativo multithread muito bem, apesar de usar workers da Web como base para o paralelismo, usando Wasm em vez de código nativo verdadeiro e outros detalhes que podem parecer menos ideais.

Gerenciamento de pilha: malloc/free

malloc e free são funções de biblioteca padrão essenciais em todas as linguagens de memória linear (por exemplo, C, C++, Rust e Zig) usadas para gerenciar toda a memória que não é totalmente estática ou na pilha. O Emscripten usa dlmalloc por padrão, que é uma implementação compacta, mas eficiente. Ele também oferece suporte a emmalloc, que é ainda mais compacto, mas mais lento em alguns casos. No entanto, o desempenho multithread de dlmalloc é limitado porque ele bloqueia cada malloc/free (porque há um único alocador global). Portanto, você pode encontrar contenção e lentidão se tiver muitas alocações em muitas linhas de execução ao mesmo tempo. Confira o que acontece quando você executa um comparativo de mercado extremamente pesado em malloc:

Um gráfico de linhas intitulado "dlmalloc scaling" mostra a relação entre o número de cores (eixo x) e o tempo de execução em milissegundos (eixo y, "lower is better"). A tendência indica que aumentar o número de núcleos resulta em um tempo de execução maior, com um aumento linear constante de 1 a 4 núcleos.

A performance não só não melhora com mais núcleos, como fica cada vez pior, já que cada linha de execução acaba esperando por longos períodos de tempo para a trava malloc. Esse é o pior caso possível, mas pode acontecer em cargas de trabalho reais se houver alocações suficientes.

mimalloc

Existem versões otimizadas para vários threads de dlmalloc, como ptmalloc3, que implementa uma instância de alocador separada por linha de execução, evitando a contenção. Vários outros alocadores existem com otimizações de várias linhas de execução, como jemalloc e tcmalloc. O Emscripten decidiu se concentrar no projeto mimalloc, que é um alocador bem projetado da Microsoft com portabilidade e desempenho muito bons. Use da seguinte maneira:

emcc -sMALLOC=mimalloc

Confira os resultados do comparativo de mercado malloc usando mimalloc:

Um gráfico de linhas intitulado "mimalloc scaling" mostra a relação entre o número de cores (eixo X) e o tempo de execução em milissegundos (eixo Y, "quanto menor, melhor"). A tendência indica que aumentar o número de núcleos reduz o tempo de execução, com uma queda acentuada de 1 para 2 núcleos e uma diminuição mais gradual de 2 para 4 núcleos.

Perfeito! Agora, a performance é dimensionada de forma eficiente, ficando cada vez mais rápida com cada núcleo.

Se você analisar cuidadosamente os dados de desempenho de núcleo único nos dois últimos gráficos, vai notar que dlmalloc levou 2.660 ms e mimalloc apenas 1.466, uma melhoria de velocidade de quase 2 vezes. Isso mostra que, mesmo em um aplicativo de linha única, é possível notar benefícios das otimizações mais sofisticadas de mimalloc, embora haja um custo no tamanho do código e no uso da memória. Por isso, dlmalloc continua sendo o padrão.

Arquivos e E/S

Muitos aplicativos precisam usar arquivos por vários motivos. Por exemplo, para carregar níveis em um jogo ou fontes em um editor de imagem. Até mesmo uma operação como printf usa o sistema de arquivos, porque ela imprime gravando dados em stdout.

Em aplicativos de linha única, isso geralmente não é um problema, e o Emscripten evita automaticamente a vinculação no suporte completo do sistema de arquivos se tudo o que você precisa é printf. No entanto, se você usar arquivos, o acesso ao sistema de arquivos com várias linhas de execução será complicado, já que o acesso a arquivos precisa ser sincronizado entre as linhas de execução. A implementação original do sistema de arquivos no Emscripten, chamada de "JS FS" porque foi implementada em JavaScript, usava o modelo simples de implementação do sistema de arquivos apenas na linha de execução principal. Sempre que outra linha de execução quiser acessar um arquivo, ela faz uma solicitação de proxy para a linha de execução principal. Isso significa que a outra linha de execução bloqueia em uma solicitação entre linhas de execução, que a linha de execução principal processa.

Esse modelo simples é ideal se apenas a linha de execução principal acessar arquivos, o que é um padrão comum. No entanto, se outras linhas de execução fizerem leituras e gravações, problemas vão acontecer. Primeiro, a linha de execução principal acaba fazendo trabalho para outras linhas, causando latência visível para o usuário. Em seguida, as linhas de execução em segundo plano acabam esperando que a linha de execução principal esteja livre para fazer o trabalho necessário. Assim, as coisas ficam mais lentas (ou, pior, você pode acabar em um deadlock se a linha de execução principal estiver aguardando essa linha de execução de worker).

WasmFS

Para corrigir esse problema, o Emscripten tem uma nova implementação de sistema de arquivos, WasmFS. O WasmFS é escrito em C++ e compilado para Wasm, ao contrário do sistema de arquivos original, que estava em JavaScript. O WasmFS oferece suporte ao acesso ao sistema de arquivos de várias linhas de execução com sobrecarga mínima, armazenando os arquivos na memória linear do Wasm, que é compartilhada entre todas as linhas de execução. Agora, todas as linhas de execução podem fazer E/S de arquivos com performance igual e, muitas vezes, podem até evitar o bloqueio umas das outras.

Um comparativo simples do sistema de arquivos mostra a enorme vantagem do WasmFS em comparação com o antigo FS do JS.

Um gráfico de barras intitulado "Desempenho do sistema de arquivos" compara o tempo de execução em milissegundos (eixo y, "quanto mais baixo, melhor") para o FS do JS e o WasmFS em duas categorias: linha de execução principal e pthread (eixo x). O FS do JS leva muito mais tempo no caso de pthread, enquanto o WasmFS permanece consistentemente baixo em ambos os casos.

Isso compara o código do sistema de arquivos em execução diretamente na linha de execução principal com a execução em uma única pthread. No antigo FS do JS, todas as operações do sistema de arquivos precisam ser usadas como proxy para a linha de execução principal, o que a torna uma ordem de magnitude mais lenta em uma pthread. Isso ocorre porque, em vez de apenas ler/gravar alguns bytes, o FS do JS faz comunicação entre linhas de execução, que envolve bloqueios, uma fila e espera. Por outro lado, o WasmFS pode acessar arquivos de qualquer linha de execução de forma igual, e o gráfico mostra que praticamente não há diferença entre a linha de execução principal e uma pthread. Como resultado, o WasmFS é 32 vezes mais rápido que o FS do JS em um pthread.

Há também uma diferença na linha de execução principal, em que o WasmFS é duas vezes mais rápido. Isso ocorre porque o FS do JS chama o JavaScript para cada operação do sistema de arquivos, o que o WasmFS evita. O WasmFS só usa JavaScript quando necessário (por exemplo, para usar uma API da Web), o que deixa a maioria dos arquivos WasmFS no Wasm. Além disso, mesmo quando o JavaScript é necessário, o WasmFS pode usar uma linha de execução auxiliar em vez da principal para evitar latência visível para o usuário. Por isso, você pode notar melhorias na velocidade ao usar o WasmFS, mesmo que seu aplicativo não seja multithread (ou se for multithread, mas usar arquivos apenas na linha de execução principal).

Use o WasmFS da seguinte maneira:

emcc -sWASMFS

O WasmFS é usado na produção e é considerado estável, mas ainda não oferece suporte a todos os recursos do antigo FS do JS. Por outro lado, ele inclui alguns novos recursos importantes, como suporte ao sistema de arquivos privado de origem (OPFS, que é altamente recomendado para armazenamento persistente). A menos que você precise de um recurso que ainda não foi transferido, a equipe do Emscripten recomenda o uso do WasmFS.

Conclusão

Se você tiver um aplicativo multithread que faz muitas alocações ou usa arquivos, poderá se beneficiar muito ao usar o WasmFS e/ou mimalloc. É simples tentar em um projeto Emscripten apenas recompilando com as flags descritas nesta postagem.

Você pode até mesmo testar esses recursos se não estiver usando linhas de execução: como mencionado anteriormente, as implementações mais modernas vêm com otimizações que são perceptíveis até mesmo em um único núcleo em alguns casos.