Introdução: Números grandes em um mundo pequeno
Imagine: você está escrevendo um algoritmo criptográfico, uma simulação de processos físicos ou simplesmente calculando a distância até a galáxia mais próxima em nanômetros. E de repente percebe que o padrão i64 (de -9 quintilhões a 9 quintilhões) é catastroficamente insuficiente. No Rust, há uma tábua de salvação — o tipo i128. Mas como ele funciona em um processador que fisicamente não sabe trabalhar com números de 128 bits? Não é mágica?
Spoiler: não há mágica, há engenharia genial. Neste artigo, vamos analisar como o Rust (ou melhor, o LLVM sob o capô) emula i128 em uma arquitetura de 64 bits. Você descobrirá por que isso não é tão lento quanto parece e como escrever código eficiente com esses números gigantescos. Não é teoria seca — percorreremos o caminho da pergunta até a aplicação real com exemplos.
1. Realidade do hardware: por que 128 bits é um desafio
Os processadores modernos de 64 bits (x86-64, ARM64) possuem registradores de uso geral com tamanho de 64 bits. Este é o seu tamanho de palavra "nativo". Para trabalhar com um número de 128 bits, o processador deve executar várias operações sobre duas metades de 64 bits: a parte baixa (low) e a parte alta (high).
No Rust, o tipo i128 (e seu irmão sem sinal u128) não é um tipo de hardware, mas sim um tipo agregado. Ele é armazenado na memória como duas palavras de 64 bits adjacentes. O compilador (através do LLVM) gera código que processa essas palavras como uma unidade única.
Um exemplo simples:
fn main() { let big: i128 = 123456789012345678901234567890; println!("{}", big);}No nível do assembly, isso se transformará em uma sequência de instruções que trabalham com dois registradores de 64 bits (por exemplo, rax e rdx). O LLVM sabe como "colar" essas operações de forma muito eficiente.
2. Anatomia da adição: como o Rust soma i128
Vamos dar uma olhada na operação mais básica — a adição de dois i128. Escreveremos uma função simples e examinaremos sob o capô com cargo-show-asm ou Compiler Explorer.
fn add_big(a: i128, b: i128) -> i128 { a + b}Vamos compilar para x86-64 (sem otimizações, para ficar mais claro). O LLVM gerará aproximadamente este pseudocódigo:
- Carregar os 64 bits baixos de
ano registradorrax, os altos emrdx. - Carregar os 64 bits baixos de
bemrbx, os altos emrcx. - Somar as partes baixas:
add rax, rbx— resultado emrax, a flag de carry (CF) é definida se houve estouro. - Somar as partes altas considerando o carry:
adc rdx, rcx—adc(add with carry) adiciona 1 se a flag CF foi definida. - Resultado: 64 bits baixos em
rax, 64 bits altos emrdx.
Isso é a emulação. Duas instruções em vez de uma. Para multiplicação e divisão, o código será mais complexo, mas o princípio é o mesmo — divisão em operações de 64 bits.
3. E quanto ao desempenho? Mitos e realidade
Muitos pensam que i128 é duas vezes mais lento que i64. Isso não é totalmente verdade. Sim, cada operação se transforma em 2-3 instruções em vez de uma. Mas os processadores modernos possuem:
- Pipelines e superescalaridade — podem executar várias instruções por ciclo.
- Predição de desvios — especialmente importante para comparações.
- Grandes caches — o trabalho com a memória não sofre, pois
i128ocupa apenas 16 bytes.
Teste prático (Rust, aritmética simples em loop):
use std::time::Instant;
fn main() { let n = 100_000_000; // i64 let start = Instant::now(); let m