wandres.dev
COLECCIONES · Vec, HashMap, BTreeMap

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.

⏱ 20 min

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.

🎯 Al terminar esta lección sabrás
  • Entender la jerarquía de memoria, la línea de caché y el prefetch.
  • Explicar por qué un Vec contiguo 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 n pequeñ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.

ℹ️
Array de estructuras frente a estructura de arrays

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.

💡
Mide, no supongas: el perfil manda

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.

El rendimiento no vive en la cuenta de operaciones, vive en la distancia a la memoria

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.

📝
Lo esencial de rendimiento y layout

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.

⚔️ Piensa en líneas de caché
  1. 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é.
  2. Razona por qué, en el experimento de Stroustrup, un Vec bate a una lista enlazada al insertar en medio, pese a que el Vec debe desplazar elementos.
  3. Escribe un MapaMini sobre Vec con búsqueda lineal y argumenta para qué rango de tamaños esperarías que gane a un HashMap.
  4. Recorre la tabla de costes y, para cada colección, di qué esconde su “nota práctica” que la O grande no muestra.
  5. Diseña un pequeño benchmark con criterion que compare buscar en un Vec lineal frente a un HashMap para n igual a 8, 64 y 1024, y predice en cuál cambia el ganador.