Ontem caí num post excelente do Daniel Lemire (se você curte performance e não conhece ele, pare tudo e vá ler). O texto é sobre C++ e alocadores de memória, mas enquanto lia, só conseguia pensar no impacto disso na New Architecture do React Native.
O resumo da ópera do Lemire é simples: malloc(n) quase nunca aloca n bytes.
Se você pede 30 bytes, o sistema operacional provavelmente vai te entregar 40, 48 ou 64. Existe um "imposto" de metadados e alinhamento (padding) que a gente esquece.
Não acredita? A Prova Real
Em JavaScript, a gente não vê isso porque a engine abstrai tudo. Mas em C++ (que roda por baixo dos seus TurboModules), podemos perguntar diretamente ao Sistema Operacional quanto ele realmente reservou.
Se você rodar esse snippet C++ simples num ambiente Linux (ou Android via shell):
#include <iostream>
#ifdef __APPLE__
#include <malloc/malloc.h>
#define GET_SIZE malloc_size
#else
#include <malloc.h>
#define GET_SIZE malloc_usable_size
#endif
int main() {
size_t pedido = 30; // Pedindo 30 bytes
void* ponteiro = malloc(pedido);
size_t recebido = GET_SIZE(ponteiro);
std::cout << "--- Prova do Overhead ---" << std::endl;
std::cout << "Você pediu: " << pedido << " bytes" << std::endl;
std::cout << "O SO alocou: " << recebido << " bytes" << std::endl;
std::cout << "Desperdício: " << (recebido - pedido) << " bytes ("
<< ((recebido - pedido) * 100.0 / recebido) << "%)" << std::endl;
free(ponteiro);
return 0;
}
O Resultado (no meu Linux):
Você pediu: 30 bytes
O SO alocou: 40 bytes
Desperdício: 10 bytes (25%)
Nota: No macOS/iOS, esse valor costuma pular para 48 bytes devido a regras diferentes de alinhamento.
Por que isso acontece?
- Header (Metadados): O sistema precisa de bytes "invisíveis" antes do seu dado para saber o tamanho do bloco na hora de dar o
free(). - Padding (Alinhamento): A CPU odeia ler endereços quebrados. O alocador arredonda tudo (geralmente para múltiplos de 8 ou 16 bytes) para garantir performance.
Agora, o que isso tem a ver com seu app? Tudo, especialmente agora que estamos movendo tanta lógica para JSI e Native Modules.
1. O Custo Oculto da Ponte (JSI)
Com a chegada dos TurboModules e JSI, a gente se acostumou a ouvir que a comunicação JS <-> Nativo ficou "instantânea" porque não tem mais a serialização da Bridge antiga.
Mas existe um custo de memória.
Quando você decide passar um array gigante de objetos pequenos do JS para o C++ (digamos, 10.000 itens de telemetria), cada objeto criado no lado nativo invoca o alocador do sistema.
Se cada item tem 30 bytes de dados úteis, você viu no teste acima que vai pagar por 40 bytes (ou mais). Multiplique isso por 100 mil objetos. Você está desperdiçando megabytes de RAM só com ar (padding) e metadados de controle.
A lição:
Se for passar dados massivos via JSI, evite "picotar" os dados. Prefira passar um único ArrayBuffer ou uma String longa e fazer o parse no C++. Uma alocação grande de 1MB tem um overhead percentual minúsculo (apenas alguns bytes de header) comparado a 50 mil alocações pequenas.
2. iOS e Android não concordam sobre memória
O artigo do Lemire mostra como diferentes sistemas operacionais têm estratégias diferentes.
- O Linux/Android tende a usar alocadores como Scudo ou jemalloc, que são eficientes mas ainda possuem overhead (como vimos nos 25% de desperdício acima).
- O iOS (baseado em Darwin) usa "zonas" e costuma ser ainda mais agressivo no arredondamento, às vezes pulando para múltiplos de 512 bytes em certas faixas.
A lição:
Não confie cegamente que o consumo de memória que você vê no Xcode Simulator será igual no Android ou num iPhone físico. O simulador roda sobre o alocador do macOS. Se você está trabalhando com manipulação de imagens ou buffers de áudio na unha, testar em dispositivo real é obrigatório. O que cabe na memória no Android pode estourar (OOM) no iOS por pura questão de como o SO arredonda os bytes.
3. A Regra das Potências de 2 (Power of 2)
Isso é velho, mas o post reforça. Se você aloca um buffer de imagem de 4097 bytes, é bem provável que o sistema te dê 8192 bytes (4KB x 2) ou algo próximo de 5KB, dependendo da página de memória. Você cruzou a linha invisível do alocador por 1 byte e pagou o dobro.
No React Native, isso afeta quem usa bibliotecas como react-native-blob-util ou manipuladores de stream.
A lição:
Sempre que estiver definindo tamanhos de chunks para upload ou leitura de arquivos, tente alinhar com potências de 2 (4096, 8192, 16384). É o jeito mais seguro de garantir que o que você pediu é (quase) o que o sistema alocou.
4. Contexto importante: quando se preocupar (e quando não)
Não precisa otimizar prematuramente:
- Aplicativos com pouca lógica nativa
- Componentes que não manipulam grandes volumes de dados
- Apps que já performam bem dentro dos limites de memória
Vale a pena investigar:
- Módulos nativos que processam listas com 10.000+ itens
- Manipulação de imagens/áudio/vídeo no lado nativo
- Apps com uso intensivo de JSI para transferência de dados
- Problemas recorrentes de Out Of Memory em apenas uma plataforma
Conclusão
Não precisa ficar paranoico e tentar gerenciar cada byte do seu app feito em React Native. O Hermes já faz muita mágica por nós para otimizar o heap do JavaScript.
O overhead de alocação é real, mas não é motivo para pânico. A chave está no equilíbrio:
Para 95% dos apps: O Hermes e a engine JavaScript já fazem otimizações excelentes. Foque em escrever código limpo e só otimize quando identificar problemas reais.
Para os 5% com lógica nativa crítica: Entenda como a memória funciona nas plataformas-alvo. Teste em dispositivos reais. Use estruturas de dados contíguas para grandes volumes.
Regra geral: Prefira uma alocação grande a muitas alocações pequenas. Este princípio simples resolve a maioria dos problemas de overhead.
Lembre-se: pedir memória ao sistema operacional é igual pedir comida em rodízio — sempre vem um pouco mais do que você pediu, e a conta chega no final.
Leu algo interessante sobre performance mobile recentemente? Deixa aí nos comentários.
Top comments (0)