DEV Community

Emerson Vieira
Emerson Vieira

Posted on

React Native em Alta Performance: Expo Modules, C++ e SIMD (ARM NEON)

Você já sentiu que o JavaScript "pediu arrego" ao tentar processar algo pesado no React Native? Seja manipulando milhares de pixels em tempo real ou processando áudio, o loop do JS muitas vezes não é suficiente.

Neste artigo, vou mostrar como construí o Expo Chroma, um app que remove o fundo verde (Chroma Key) de imagens usando o Expo Modules SDK, C++ e instruções SIMD (ARM NEON) para alcançar uma performance que o JavaScript puro jamais conseguiria.

Repositório do Projeto: mensonones/ExpoChromaApp


O que é SIMD e por que ele importa?

Se você nunca ouviu falar de SIMD (Single Instruction, Multiple Data), pense nele como uma linha de montagem ultra-eficiente.

Historicamente, o conceito surgiu nos anos 70 com supercomputadores (como o lendário Cray-1), mas chegou aos nossos computadores e celulares nos anos 90 e 2000 (com tecnologias como MMX, SSE e AVX na Intel, e NEON na ARM).

Na programação convencional (Escalar), se você quer somar dois vetores de 4 números, você faz 4 somas separadas. Com SIMD, você carrega os dois vetores em registradores especiais e executa uma única instrução que soma todos os números de uma vez. É a base de quase tudo que exige performance hoje: processamento de vídeo, jogos 3D, criptografia e, claro, Inteligência Artificial.


O Coração do Projeto: C++ SIMD (ARM NEON)

O grande diferencial deste projeto é o uso de SIMD (Single Instruction, Multiple Data).

Em um processador comum, para remover o verde de 16 pixels, você faria 16 verificações sequenciais. Com ARM NEON (presente em quase todos os Androids e iPhones), processamos esses 16 pixels (64 bytes) simultaneamente em um único ciclo de CPU.

Por que exatamente 16 pixels?

A matemática é simples e fascinante:

  • Os registradores NEON têm 128 bits de largura.
  • Cada canal de cor (R, G, B ou A) é um uint8 (8 bits).
  • 128 / 8 = 16.

Isso significa que o tipo uint8x16_t que usamos no código é, literalmente, um vetor que carrega 16 valores de uma vez. Quando fazemos uma comparação, o hardware compara os 16 valores em paralelo no nível elétrico do silício.

Veja como fica o kernel em C++:

#include <arm_neon.h>

extern "C" void processChromaKey(uint8_t* data, size_t length) {
    size_t numPixels = length / 4;
    size_t i = 0;

    // Definimos o threshold fora do loop para máxima performance
    uint8x16_t threshold = vdupq_n_u8(30);

    for (; i + 15 < numPixels; i += 16) {
        uint8_t* ptr = data + (i * 4);

        // Carrega 16 pixels desintercalando os canais (R, G, B, A)
        uint8x16x4_t rgba = vld4q_u8(ptr);

        // Lógica: Verde > (Vermelho + 30) && Verde > (Azul + 30)
        uint8x16_t r_plus_th = vqaddq_u8(rgba.val[0], threshold);
        uint8x16_t b_plus_th = vqaddq_u8(rgba.val[2], threshold);

        uint8x16_t mask_r = vcgtq_u8(rgba.val[1], r_plus_th);
        uint8x16_t mask_b = vcgtq_u8(rgba.val[1], b_plus_th);
        uint8x16_t is_green = vandq_u8(mask_r, mask_b);

        // Zera o Alpha (torna transparente) onde for verde
        rgba.val[3] = vbicq_u8(rgba.val[3], is_green);

        // Salva de volta na memória
        vst4q_u8(ptr, rgba);
    }
    // ... fallback para pixels restantes
}
Enter fullscreen mode Exit fullscreen mode

Integrando com Kotlin (Android)

Para que o C++ converse com o Android sem lentidão, usamos o JNI e DirectBuffers. Isso permite que o C++ manipule a memória do Bitmap do Android diretamente, sem criar cópias desnecessárias.

AsyncFunction("processImageBase64") { base64String: String ->
  // ... decodificação do bitmap

  // Aloca um DirectBuffer para acesso direto do C++
  val byteBuffer = ByteBuffer.allocateDirect(mutableBitmap.byteCount)
  mutableBitmap.copyPixelsToBuffer(byteBuffer)

  // Chama o kernel C++ otimizado
  nativeProcessImage(byteBuffer, mutableBitmap.byteCount)

  // ... retorna para o JS
}
Enter fullscreen mode Exit fullscreen mode

Resultados e Benchmarks

O benchmark mede o tempo de processamento bruto de um buffer de 8MB (equivalente a uma imagem 2K em RGBA). Isso isola a performance do algoritmo, ignorando custos de decodificação de arquivos ou renderização na tela.

Nos testes realizados em um dispositivo intermediário:

  • JS Puro (Hermes): ~320ms (estável entre 321ms e 326ms nas execuções seguintes)
  • C++ Nativo (Escalar): ~4ms (caindo para 2ms após o warm-up)
  • C++ Nativo (SIMD): ~2ms (caindo para 1ms após o warm-up)

O Fenômeno do "Warm-up": Por que a segunda vez é mais rápida?

Se você rodar o benchmark no app, notará algo curioso: a primeira execução é sempre mais lenta que as seguintes. Por exemplo, o C++ Escalar pode começar em 4ms e cair para 2ms, enquanto o SIMD cai de 2ms para 1ms.

Isso acontece por três motivos técnicos:

  1. CPU Frequency Scaling: Para economizar bateria, o Android mantém a CPU em "repouso". Quando o código pesado começa, o sistema leva alguns milissegundos para subir a frequência do processador ao máximo.
  2. CPU Cache: Na primeira vez, os dados saem da RAM (lenta). Nas execuções seguintes, os pixels já estão no Cache L1/L2 do processador, que é ordens de grandeza mais rápido.
  3. Branch Prediction: O processador "aprende" o padrão dos seus ifs. Após a primeira volta, ele já sabe prever quais pixels serão transparentes, evitando pausas no fluxo de execução (pipeline stalls).

Isso prova que:

  1. Mover para o Nativo já elimina o overhead do Garbage Collector e do motor JS, sendo 80x a 160x mais rápido que o Hermes.
  2. Usar SIMD extrai o máximo de poder do hardware, dobrando a performance do C++ comum ao processar dados em paralelo no nível do silício.

Conclusão

O React Native, através do ecossistema Expo, evoluiu para uma plataforma onde o limite não é mais o JavaScript. Se você precisa de performance extrema, não tenha medo de descer para o C++. O Expo Modules SDK torna essa ponte (ou melhor, esse JSI) muito mais simples de implementar.

O código completo deste projeto está disponível no GitHub: mensonones/ExpoChromaApp


A Arquitetura e Estrutura do Módulo

Para este projeto, criei um Expo Module local localizado em modules/expo-chroma/. Esta é a estrutura recomendada para manter o código nativo organizado:

  • cpp/: Contém o núcleo em C++ com os kernels SIMD.
  • android/ e ios/: Implementações nativas que expõem o C++ para cada plataforma via JNI/Swift.
  • src/: A camada TypeScript que define a API consumida pelo React Native.

Utilizamos a JSI (JavaScript Interface) do Expo Modules SDK. Isso permite que o JavaScript chame as funções nativas de forma síncrona e direta, eliminando o overhead da antiga "Bridge" e permitindo a passagem de buffers de memória de forma ultra-eficiente.


Gostou do conteúdo? Deixe um comentário sobre qual tarefa pesada você adoraria ver rodando nativamente no seu app! 🚀

Top comments (1)

Collapse
 
wlsf profile image
Willian Frantz

Isso ficou irado demais, obrigado por compartilhar meu querido!