El rendimiento de los sistemas de bases de datos modernos, especialmente en operaciones intensivas como GROUP BY, está fundamentalmente limitado por la latencia de acceso a la memoria principal (DRAM), no por la capacidad de cálculo bruta de la CPU. Aunque un procesador pueda reportar 100% de utilización, gran parte de ese tiempo puede estar en estado de 'stalling' (espera) mientras los datos se cargan desde DRAM, que es órdenes de magnitud más lenta que los registros de la CPU o las cachés L1/L2/L3.
Este problema se agrava en algoritmos que acceden a grandes conjuntos de datos de manera aleatoria, como una HashTable que no cabe en las cachés de la CPU. La solución no siempre reside en reducir el número de operaciones, sino en reestructurar los algoritmos para maximizar la localidad de referencia, asegurando que los datos necesarios estén en las cachés de la CPU el mayor tiempo posible. Esto se alinea con el principio de que 'CPU cycles are cheap, memory accesses are expensive'.
La relevancia de este problema ha crecido con la divergencia en la velocidad de mejora entre CPUs y DRAM, un fenómeno conocido como el 'Memory Wall'. Para cargas de trabajo analíticas que procesan volúmenes masivos de datos, optimizar la interacción con la jerarquía de memoria es tan crítico como la complejidad algorítmica teórica.
Arquitectura del Sistema
El artículo analiza la implementación de una operación GROUP BY en un motor de base de datos, Pivot. Inicialmente, se describe una aproximación 'ingenua' que utiliza una única HashTable global para almacenar las agregaciones. Para cada fila de entrada, se calcula una clave de grupo, se busca en la HashTable y se actualiza la agregación correspondiente. Cuando el número de grupos únicos (e.g., UserID) es muy grande (millones), esta HashTable excede el tamaño de las cachés de la CPU, resultando en un alto número de 'cache misses' y accesos directos a DRAM.
La optimización propuesta es una estrategia de Radix Partitioning de dos fases. En la Fase 1, los datos de entrada se dividen en un número predefinido de particiones (e.g., 4096) basadas en los bits superiores del hash de la clave de grupo. Cada partición es una SlabVec, una estructura de datos optimizada para evitar la variabilidad de Vecs estándar en la gestión de memoria. Esta fase solo implica hashing y escritura secuencial en las particiones. En la Fase 2, una vez que todos los datos han sido particionados, el sistema itera sobre cada partición individualmente. Para cada partición, se crea una HashTable local y se agregan los datos de esa partición. Dado que cada partición es significativamente más pequeña, su HashTable asociada tiene una alta probabilidad de caber en las cachés de la CPU, lo que reduce drásticamente los 'cache misses' durante la fase de agregación.
Esta arquitectura transforma un problema de acceso aleatorio a memoria a gran escala en múltiples problemas de acceso aleatorio a pequeña escala, cada uno con alta localidad de referencia. Aunque el número total de operaciones (hashing, inserciones en SlabVec, inserciones en HashTables) puede ser mayor, la reducción en la latencia de acceso a memoria compensa con creces el trabajo adicional de la CPU.
Flujo Naive de GROUP BY con HashTable
- 1 Leer Fila Procesar una fila de datos de entrada.
- 2 Calcular Clave Derivar la clave de grupo (e.g., UserID) para la fila.
- 3 Buscar en HashTable Intentar encontrar la clave en la HashTable global.
- 4 Cache Miss / DRAM Access Si la clave no está en caché, acceso lento a DRAM.
- 5 Actualizar Agregación Incrementar el contador o sumar el valor asociado a la clave.
- 6 Repetir Continuar con la siguiente fila hasta agotar el dataset.
Flujo de GROUP BY con Radix Partitioning
- 1 Fase 1: Particionamiento Para cada fila, calcular hash de la clave de grupo.
- 2 Asignar a Partición Usar bits superiores del hash para asignar la fila a una de 4096 `SlabVec`.
- 3 Escritura Secuencial Añadir la fila a la `SlabVec` de la partición, beneficiándose de localidad.
- 4 Fase 2: Agregación por Partición Iterar sobre cada una de las 4096 particiones.
- 5 Crear HashTable Local Para cada partición, inicializar una `HashTable` pequeña.
- 6 Agregar en Caché Procesar elementos de la partición en la `HashTable` local (alta probabilidad...
- 7 Consolidar Resultados Combinar los resultados de las `HashTable`s de las particiones.
| Capa | Tecnología | Justificación |
|---|---|---|
| compute | AMD EPYC 9124 Genoa (CPU) | Plataforma de ejecución para el motor de base de datos Pivot. Se utiliza para medir el rendimiento a bajo nivel, incluyendo contadores de rendimiento de CPU (perf stat). vs AWS/GCP instances (rechazadas por falta de acceso a contadores de rendimiento de bajo nivel) 16 cores, 128 GB DDR5. Proceso anclado a un solo core (`taskset -c 3`) |
| storage | DDR5 RAM | Memoria principal del sistema. El artículo se centra en la latencia de acceso a esta capa y cómo minimizarla. 128 GB |
| storage | CPU Caches (L1, L2, L3) | Cachés de hardware integradas en la CPU para reducir la latencia de acceso a datos. El objetivo de la optimización es maximizar el uso de estas cachés. L1 cache ~64KB, L3 cache hasta unos pocos cientos de MB |
| data-processing | HashTable | Estructura de datos fundamental utilizada para almacenar y agregar grupos. Su rendimiento es crítico y directamente afectado por la localidad de caché. |
| data-processing | SlabVec (custom data structure) | Estructura de datos similar a un `Vec` pero optimizada para la gestión de memoria en Pivot, utilizada para almacenar particiones de datos. Diseñada para reducir la variabilidad y mejorar la previsibilidad del acceso a memoria. vs std::vec::Vec |
| observability | perf stat (Linux tool) | Herramienta de profiling de Linux utilizada para medir contadores de rendimiento de hardware de la CPU, como el porcentaje de tiempo de CPU en espera de memoria (`backend_bound_memory`) y el porcentaje de operaciones retiradas (`retiring`), así como el número de cargas desde DRAM (`ls_dmnd_fills_from_sys.dram_io_near`). -C 3 -M backend_bound_memory,retiring |
Trade-offs
Ganancias
- ▲ Latencia de consulta (GROUP BY)
- ▲ Reducción de accesos a DRAM
- ▲ Utilización efectiva de la CPU
Costes
- △ Número total de instrucciones de CPU
- △ Complejidad algorítmica
fn consume(
&mut self,
batch: RecordBatch,
_sender: &mut dyn Sender<RecordBatch>,
) -> unary::Result<()> {
let group_keys = batch.column(self.key_column).as_primitive::<Int64Type>();
for &key in group_keys.values() {
*self.counts.entry(key).or_insert(0) += 1;
}
Ok(())
}fn consume(
&mut self,
batch: RecordBatch,
_sender: &mut dyn Sender<RecordBatch>,
) -> unary::Result<()> {
let keys = batch.column(self.key_column).as_primitive::<Int64Type>();
for &key in keys.values() {
let hash = self.hasher.hash_one(key);
let partition = (hash >> 52) as usize;
self.partitions[partition].push(&mut self.allocator, (hash, key));
}
Ok(())
}fn into_outputter(self) -> unary::Result<Option<Self::Outputter>> {
let mut groups = Vec::new();
for rows in &self.partitions {
let mut table: HashTable<(u64, i64, u64)> = HashTable::new(); // (hash, key, count)
rows.for_each(|(hash, key)| {
match table.entry(hash, |group| group.1 == key, |group| group.0) {
Entry::Occupied(mut group) => group.get_mut().2 += 1,
Entry::Vacant(slot) => { slot.insert((hash, key, 1)); }
}
});
groups.extend(table.into_iter().map(|(_, key, count)| (key, count)));
}
Ok(Some(Outputter::new(groups)))
}Fundamentos Teóricos
El problema de la latencia de memoria y la importancia de la localidad de referencia ha sido un tema central en la arquitectura de computadoras y la optimización de algoritmos desde hace décadas. El concepto de 'Memory Wall', popularizado por Wulf y McKee en 1995, describe la creciente brecha de rendimiento entre la velocidad de la CPU y la velocidad de acceso a la memoria. Este artículo es una demostración práctica de cómo este principio fundamental afecta el rendimiento de las bases de datos.
Algoritmos como el Radix Sort y Radix Join, de los cuales Radix Partitioning es un componente clave, han sido estudiados extensamente en la literatura académica por su eficiencia en el procesamiento de grandes volúmenes de datos en memoria. Estos algoritmos están diseñados explícitamente para explotar la jerarquía de memoria, transformando accesos aleatorios costosos en accesos secuenciales o localizados que se benefician de las cachés. Trabajos como los de Manegold et al. sobre 'Cache-Conscious Data Structures and Algorithms for Main-Memory Databases' (2002) o los de Kim et al. sobre 'Radix Join Revisited' (2009) profundizan en estas técnicas, demostrando cómo la reestructuración de los datos y el flujo de trabajo puede llevar a mejoras significativas en el rendimiento al reducir los 'cache misses'.