Rendimiento y layout: por qué Vec es cache-friendly y el coste real de cada operación
La complejidad asintótica no cuenta toda la verdad. Por qué un Vec contiguo vuela sobre la caché mientras una lista enlazada arrastra fallos de caché, qué es una línea de caché y el prefetch, la tabla del coste real de cada operación por colección, y por qué para n pequeño el escaneo lineal bate al hash.
Llevas todo el nivel viendo notaciones O grande: O(1) para Vec, O(log n) para BTreeMap, O(1) medio para HashMap. Es hora de la verdad incómoda: el asintótico no cuenta toda la historia. Dos operaciones “O(n)” pueden diferir en un factor de cien según cómo estén dispuestos los datos en memoria, porque el cuello de botella de un procesador moderno no es calcular, es esperar a la memoria. Esta lección cierra el nivel bajando al metal: qué es una línea de caché, por qué la contigüidad de Vec lo hace volar, y cuál es el coste real —no solo el asintótico— de cada operación de cada colección.
- Entender la jerarquía de memoria, la línea de caché y el prefetch.
- Explicar por qué un
Veccontiguo bate a una lista enlazada dispersa. - Leer la tabla del coste real de cada operación por colección.
- Reconocer cuándo el asintótico engaña y para
npequeño el escaneo lineal gana.
La jerarquía de memoria y la línea de caché
El modelo mental de “la RAM es rápida” es falso desde hace décadas. Un núcleo actual ejecuta una instrucción en una fracción de nanosegundo, pero traer un dato de la RAM principal tarda del orden de cien nanosegundos: cientos de ciclos de reloj parado. Para tapar ese abismo hay una jerarquía de cachés cada vez más pequeñas y rápidas entre el núcleo y la RAM.
| Nivel | Latencia aproximada | Tamaño típico |
|---|---|---|
| Registro | menos de 1 ciclo | decenas de valores |
| Caché L1 | ~4 ciclos | ~32 KB |
| Caché L2 | ~12 ciclos | cientos de KB |
| Caché L3 | ~40 ciclos | decenas de MB |
| RAM | ~200 ciclos | gigabytes |
La pieza clave es la línea de caché: la memoria no se trae byte a byte, sino en bloques de 64 bytes. Cuando lees un i32, el procesador arrastra a la caché los 64 bytes que lo rodean —dieciséis enteros de golpe—. Si tu siguiente acceso cae en esos mismos 64 bytes, es prácticamente gratis: ya está en L1. Si cae lejos, en una línea que no está cargada, pagas un fallo de caché y esperas la RAM. Encima, el prefetcher del hardware detecta patrones de acceso secuenciales y trae las líneas siguientes antes de que las pidas. Toda la partida del rendimiento se juega aquí: mantener los datos que usas juntos en pocas líneas, y recorrerlos en orden previsible.
Los arquitectos llaman a esto localidad, y tiene dos caras que conviene nombrar. La localidad espacial dice que si tocas una dirección, pronto tocarás las vecinas —y la caché, al traer líneas enteras y el prefetcher al adelantarse, apuestan por ello—. La localidad temporal dice que si tocas una dirección, pronto volverás a tocarla —y por eso la caché retiene lo reciente y descarta lo viejo—. Casi todo lo que vuelve rápida a una colección se reduce a explotar una de las dos: Vec es un monumento a la localidad espacial, y una caché de resultados lo es a la temporal. Escribir código veloz es, en buena medida, escribir código con buena localidad.
Vec frente a la lista enlazada
Aquí es donde la contigüidad de Vec deja de ser un detalle y se vuelve la diferencia entre volar y arrastrarse. Recorrer un Vec es el caso ideal para la máquina: los elementos están pegados, cada línea de caché trae los dieciséis siguientes, el prefetcher acierta todas sus predicciones y el bucle avanza casi sin esperar a la memoria. Una lista enlazada hace justo lo contrario: cada nodo se reservó por separado y puede vivir en cualquier rincón del heap, así que seguir cada puntero next es un salto a una dirección impredecible —pointer chasing— y, con alta probabilidad, un fallo de caché. Misma O(n) en la pizarra; en la máquina, un abismo.
flowchart TB subgraph Vec contiguo una linea de cache trae varios v0[10] --- v1[20] --- v2[30] --- v3[40] --- v4[50] end subgraph Lista enlazada nodos dispersos por el heap n0[10] -.salto.-> n1[40] n1 -.salto.-> n2[20] n2 -.salto.-> n3[50] end style v0 fill:#a6e3a1,color:#11111b style v1 fill:#a6e3a1,color:#11111b style v2 fill:#a6e3a1,color:#11111b style n0 fill:#f38ba8,color:#11111b style n1 fill:#f38ba8,color:#11111b style n2 fill:#f38ba8,color:#11111b
El resultado desafía la intuición de los libros de texto. El experimento célebre de Bjarne Stroustrup mide insertar en orden en mitad de una secuencia: la lista enlazada tiene la inserción “barata” O(1) una vez que sabes dónde, pero encontrar el sitio exige recorrerla saltando de nodo en nodo, y ese recorrido, plagado de fallos de caché, es tan lento que el Vec —que para insertar debe desplazar todo con memmove, en teoría más trabajo— gana por goleada para cualquier tamaño realista. La razón es que memmove sobre memoria contigua es justo lo que el hardware hace a máxima velocidad, mientras que el paseo por punteros de la lista es justo lo que peor se le da. La moraleja: la contigüidad casi siempre vence a la complejidad asintótica favorable cuando n no es astronómico.
La contigüidad tiene un segundo filo que la programación orientada a datos explota. Si guardas un Vec<Particula> donde cada Particula lleva posición, velocidad y color, pero tu bucle caliente solo toca la posición, cada línea de caché que traes viene cargada de velocidades y colores que no vas a usar: desperdicias ancho de banda de memoria. La alternativa —estructura de arrays— guarda un Vec por campo (posiciones, velocidades, colores), de modo que recorrer solo las posiciones llena cada línea de caché con puro dato útil. Mismo número de operaciones, muchísimos menos fallos de caché. Es la localidad llevada un paso más allá: no basta con que los datos sean contiguos, conviene que los datos que usas a la vez estén contiguos entre sí.
El coste real de cada operación
Con la caché en mente, esta es la tabla de referencia del nivel. La columna asintótica es la verdad matemática; el comentario es la verdad práctica que el asintótico esconde.
| Colección | Acceso o búsqueda | Inserción | Nota práctica |
|---|---|---|---|
Vec |
índice O(1) | final O(1) amortizado, medio O(n) | contigua, prefetch ideal |
VecDeque |
índice O(1) | ambos extremos O(1) amortizado | contigua circular, casi como Vec |
HashMap |
O(1) medio | O(1) medio | dispersa, coste fijo de hashear |
BTreeMap |
O(log n) | O(log n) | nodos anchos, cache-friendly |
BinaryHeap |
máximo O(1) | O(log n) | sobre un Vec, contigua |
HashSet |
O(1) medio | O(1) medio | como HashMap sin valor |
Fíjate en las columnas “nota práctica”: cuentan lo que la O grande calla. Un HashMap es O(1) medio, sí, pero cada operación paga el coste fijo de calcular un hash SipHash y un salto a una dirección dispersa —dos cosas que un Vec no hace—. Un BTreeMap es O(log n), peor en papel que el hash, pero sus nodos anchos y contiguos aprovechan la caché mucho mejor que la dispersión del hash. Los números asintóticos ordenan el comportamiento cuando n crece; para el tamaño que de verdad tiene tu colección, las notas de la derecha suelen pesar más.
Cuando el asintótico engaña: n pequeño
La consecuencia más contraintuitiva y más útil: para n pequeño, un escaneo lineal sobre un Vec bate a la búsqueda O(1) de un HashMap. Buscar una clave en un Vec de veinte pares es un recorrido O(n) de veinte comparaciones sobre memoria contigua —una sola línea de caché, prefetch perfecto, sin hashear nada—. La misma búsqueda en un HashMap es O(1), pero paga calcular la huella y saltar a un bucket disperso, y ese coste fijo, para veinte elementos, es mayor que las veinte comparaciones baratísimas del vector.
// Para pocos elementos, un Vec de pares suele batir a un HashMap.
struct MapaMini<K, V> {
pares: Vec<(K, V)>, // contiguo: cache-friendly
}
impl<K: PartialEq, V> MapaMini<K, V> {
fn get(&self, clave: &K) -> Option<&V> {
// Escaneo lineal O(n): rapidisimo para n pequeno, sin hashear.
self.pares.iter().find(|(k, _)| k == clave).map(|(_, v)| v)
}
}
El punto de cruce depende del hardware y del tipo, pero suele estar entre unas pocas decenas y un centenar de elementos: por debajo, el Vec lineal gana; por encima, el hash despega y la escala asintótica se impone. Por eso muchas bibliotecas de alto rendimiento ofrecen un SmallMap o VecMap que es un Vec por dentro. La lección no es “el hash es malo”, sino que la elección de colección depende del tamaño real, y ese tamaño solo lo conoces midiendo.
Todo lo anterior son heurísticas, no dogmas. El único árbitro del rendimiento es el benchmark sobre tus datos y tu máquina. En Rust, la herramienta estándar es criterion, que corre cada caso muchas veces, descarta el ruido y da intervalos de confianza. Antes de “optimizar” cambiando de colección, mide la actual; después de cambiar, vuelve a medir. La intuición sobre la caché te dice dónde mirar; solo el perfilador te dice si acertaste.
Durante décadas enseñamos algoritmos contando operaciones, como si cada acceso a memoria costara lo mismo y sumar fuera la unidad del tiempo. Ese modelo —el de la máquina de acceso aleatorio uniforme— era cierto cuando la RAM iba al ritmo del procesador, y hoy es una ficción que distorsiona nuestro juicio, porque entre el núcleo y la RAM se ha abierto un abismo de dos órdenes de magnitud que ninguna notación O grande captura. La consecuencia es profunda: el rendimiento real de un programa moderno rara vez depende de cuántas operaciones hace, sino de dónde están los datos que toca y en qué orden los toca. Un algoritmo con más operaciones sobre memoria contigua vence sistemáticamente a otro con menos operaciones pero disperso, porque el primero mantiene ocupada a la máquina y el segundo la deja parada, esperando líneas de caché que llegan de una RAM lejana. Aquí es donde la filosofía entera de las colecciones de Rust cobra sentido: la razón de que Vec sea la estructura por defecto, de que VecDeque sea un buffer circular y no una lista de nodos, de que el BTreeMap empaquete muchas claves por nodo, de que hasta el BinaryHeap se construya sobre un Vec, es una misma convicción de diseño: manten los datos juntos y recórrelos en orden, porque la contigüidad es la optimización que ninguna otra iguala. La LinkedList existe en std casi como advertencia, arrinconada, porque encarna justo lo contrario —máxima dispersión, máximo pointer chasing— y por eso casi nunca es la respuesta. Entender esto reordena tus prioridades como programador: dejas de contar operaciones en abstracto y empiezas a preguntarte por el layout, por si tu bucle interno recorre memoria pegada o salta por el heap, por cuántas líneas de caché toca de verdad. El asintótico sigue mandando cuando n tiende a infinito; pero tus datos no son infinitos, son unos miles, unos millones, y en ese régimen real la variable que decide es la distancia a la memoria. La complejidad te dice cómo escala un algoritmo; la caché te dice cuánto cuesta hoy. Un ingeniero maduro respeta ambas, pero sabe que la segunda es la que casi siempre paga la factura.
La memoria se trae en líneas de 64 bytes y la RAM está a ~200 ciclos del núcleo, así que el rendimiento lo decide la localidad, no la cuenta de operaciones. Vec y las colecciones contiguas —VecDeque, BinaryHeap, los nodos anchos de BTreeMap— aprovechan la línea de caché y el prefetch; la lista enlazada, dispersa, arrastra fallos de caché y suele perder pese a su O(1) de inserción. La tabla de costes tiene dos verdades: la asintótica y la práctica, y para n pequeño la práctica manda, hasta el punto de que un escaneo lineal sobre Vec bate a un HashMap. La regla final: elige por patrón de acceso y tamaño real, y mide con criterion en vez de suponer.
- Explica con tus palabras qué es una línea de caché de 64 bytes y por qué recorrer un
Vec<i32>en orden apenas produce fallos de caché. - Razona por qué, en el experimento de Stroustrup, un
Vecbate a una lista enlazada al insertar en medio, pese a que elVecdebe desplazar elementos. - Escribe un
MapaMinisobreVeccon búsqueda lineal y argumenta para qué rango de tamaños esperarías que gane a unHashMap. - Recorre la tabla de costes y, para cada colección, di qué esconde su “nota práctica” que la O grande no muestra.
- Diseña un pequeño benchmark con
criterionque compare buscar en unVeclineal frente a unHashMapparanigual a 8, 64 y 1024, y predice en cuál cambia el ganador.