Introducción: Números grandes en un mundo pequeño
Imagina: estás escribiendo un algoritmo criptográfico, una simulación de procesos físicos o simplemente calculando la distancia a la galaxia más cercana en nanómetros. Y de repente te das cuenta de que el estándar i64 (de -9 quintillones a 9 quintillones) te queda catastróficamente corto. En Rust hay un salvavidas: el tipo i128. Pero, ¿cómo funciona en un procesador que físicamente no puede trabajar con números de 128 bits? ¿Acaso es magia?
Spoiler: no hay magia, hay ingeniería genial. En este artículo analizaremos cómo Rust (o más bien, LLVM bajo el capó) emula i128 en una arquitectura de 64 bits. Descubrirás por qué no es tan lento como parece y cómo escribir código eficiente con estos números gigantescos. No es teoría árida: recorreremos el camino desde la pregunta hasta la aplicación real con ejemplos.
1. Realidad del hardware: por qué 128 bits son un desafío
Los procesadores modernos de 64 bits (x86-64, ARM64) tienen registros de propósito general de 64 bits. Este es su tamaño de palabra "nativo". Para trabajar con un número de 128 bits, el procesador debe realizar varias operaciones sobre dos mitades de 64 bits: la baja (low) y la alta (high).
En Rust, el tipo i128 (y su hermano sin signo u128) no es un tipo hardware, sino un tipo agregado. Se almacena en memoria como dos palabras de 64 bits contiguas. El compilador (a través de LLVM) genera código que procesa estas palabras como una sola unidad.
Un ejemplo simple:
fn main() { let big: i128 = 123456789012345678901234567890; println!("{}", big);}A nivel de ensamblador, esto se convertirá en una secuencia de instrucciones que trabajan con dos registros de 64 bits (por ejemplo, rax y rdx). LLVM sabe cómo "unir" estas operaciones de manera muy eficiente.
2. Anatomía de la suma: cómo Rust suma i128
Veamos la operación más básica: la suma de dos i128. Escribamos una función simple y miremos bajo el capó con cargo-show-asm o Compiler Explorer.
fn add_big(a: i128, b: i128) -> i128 { a + b}Compilemos para x86-64 (sin optimizaciones, para que sea más claro). LLVM generará aproximadamente este pseudocódigo:
- Cargar los 64 bits bajos de
aen el registrorax, los altos enrdx. - Cargar los 64 bits bajos de
benrbx, los altos enrcx. - Sumar las partes bajas:
add rax, rbx— el resultado enrax, la bandera de acarreo (CF) se establece si hay desbordamiento. - Sumar las partes altas teniendo en cuenta el acarreo:
adc rdx, rcx—adc(add with carry) suma 1 si la bandera CF estaba establecida. - Resultado: 64 bits bajos en
rax, 64 bits altos enrdx.
Esto es la emulación. Dos instrucciones en lugar de una. Para la multiplicación y la división, el código será más complejo, pero el principio es el mismo: dividir en operaciones de 64 bits.
3. ¿Y el rendimiento? Mitos y realidad
Muchos piensan que i128 es el doble de lento que i64. Esto no es del todo cierto. Sí, cada operación se convierte en 2-3 instrucciones en lugar de una. Pero los procesadores modernos tienen:
- Segmentación (pipeline) y superescalaridad — pueden ejecutar varias instrucciones por ciclo.
- Predicción de saltos — especialmente importante para las comparaciones.
- Cachés grandes — el trabajo con memoria no se resiente, ya que
i128ocupa solo 16 bytes.
Prueba práctica (Rust, aritmética simple en un bucle):
use std::time::Instant;
fn main() { let n = 100_000_000; // i64 let start = Instant::now(); let m