El problema fundamental que aborda este artículo es la brecha de rendimiento entre la velocidad de procesamiento de la CPU y la latencia de acceso a la memoria principal (DRAM). Las CPUs modernas pueden ejecutar cientos de instrucciones en el tiempo que tarda una sola lectura de DRAM, lo que significa que un procesador que parece estar al 100% de utilización puede, en realidad, estar inactivo la mayor parte del tiempo esperando datos de la memoria. Este fenómeno es particularmente crítico en cargas de trabajo intensivas en datos, como las operaciones GROUP BY en bases de datos analíticas, donde el acceso aleatorio a grandes estructuras de datos puede anular los beneficios de las cachés de la CPU.

La relevancia de este problema ha crecido con el aumento de la capacidad de procesamiento de las CPUs y la persistencia de las latencias de DRAM. Los sistemas distribuidos y las bases de datos a escala de hyperscaler dependen de la eficiencia del procesamiento de datos en cada nodo. Ignorar la jerarquía de memoria y la localidad de caché lleva a arquitecturas subóptimas que no escalan linealmente con los recursos de CPU, resultando en un uso ineficiente del hardware y mayores costos operativos. La optimización de la interacción CPU-memoria es, por tanto, una piedra angular en el diseño de sistemas de alto rendimiento.

Arquitectura del Sistema

El artículo analiza la operación GROUP BY en el contexto de un motor de base de datos, Pivot. Inicialmente, se describe una implementación 'ingenua' que utiliza una HashTable para almacenar agregaciones. En esta arquitectura, cada fila procesada implica una búsqueda y actualización en la HashTable. Si el número de grupos únicos es grande (millones), la HashTable excede el tamaño de las cachés de la CPU (L1, L2, L3), lo que provoca que casi cada acceso a la tabla resulte en un 'cache miss' y una lectura costosa desde DRAM. Esto se manifiesta como un alto porcentaje de tiempo de CPU 'stalled' esperando memoria.

Para mitigar este problema, se introduce una arquitectura optimizada basada en Radix Partitioning, un patrón común en algoritmos de procesamiento de datos. Este enfoque divide la operación GROUP BY en dos fases. La Fase 1 realiza un 'partitioning' de los datos de entrada: las claves de grupo se hashean y, utilizando los bits más significativos del hash (e.g., los 12 bits superiores para 4096 particiones), se distribuyen en un conjunto de 'SlabVecs'. Un SlabVec es una estructura de datos que gestiona bloques de memoria pre-asignados, reduciendo la variabilidad de acceso. La Fase 2 procesa cada partición de forma independiente. 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, las HashTables locales son mucho más propensas a caber en las cachés de la CPU, lo que reduce drásticamente los 'cache misses' y los accesos a DRAM. Este diseño, aunque realiza más operaciones computacionales (hashing y dos pasadas por los datos), es más rápido porque convierte costosos accesos a DRAM en operaciones de CPU mucho más rápidas.

Flujo de GROUP BY Naive

  1. 1 Lectura de Fila Procesar una fila del conjunto de datos.
  2. 2 Calcular Clave Extraer o calcular la clave de agrupación.
  3. 3 Acceso a HashTable Buscar la clave en una HashTable global.
  4. 4 Cache Miss (DRAM) Frecuente acceso a DRAM debido a HashTable grande.
  5. 5 Actualizar Agregación Incrementar contador o sumar valor.
  6. 6 Repetir Continuar con la siguiente fila.

Flujo de GROUP BY con Radix Partitioning

  1. 1 Fase 1: Lectura de Fila Procesar una fila del conjunto de datos.
  2. 2 Fase 1: Hash y Partición Calcular hash de la clave y asignar a una de 4096 particiones (SlabVec).
  3. 3 Fase 1: Almacenar en Partición Añadir la clave y hash a la SlabVec correspondiente.
  4. 4 Fase 2: Iterar Particiones Una vez todas las filas procesadas, iterar sobre cada partición.
  5. 5 Fase 2: HashTable Local Crear una HashTable pequeña para la partición actual.
  6. 6 Fase 2: Agregación en Caché Agregar datos de la partición en la HashTable local (alta probabilidad de cac...
  7. 7 Fase 2: Consolidar Resultados Extraer resultados de la HashTable local.
  8. 8 Fase 2: Repetir Continuar con la siguiente partición.
CapaTecnologíaJustificación
compute AMD EPYC 9124 Genoa (CPU) Plataforma de ejecución para las operaciones de base de datos, donde se mide el impacto de la latencia de memoria y la eficiencia de la caché. vs AWS/GCP instances (no acceso a contadores de rendimiento de bajo nivel) 16 cores, 128 GB DDR5, single core pinned via taskset -c
storage DDR5 RAM Memoria principal del sistema, fuente de latencia significativa en comparación con las cachés de la CPU.
storage CPU Caches (L1, L2, L3) Cachés de hardware integradas en la CPU para reducir la latencia de acceso a datos frecuentemente utilizados. L1 cache ~64KB
data-processing HashTable Estructura de datos fundamental utilizada para la agregación de GROUP BY, tanto en la implementación ingenua como en las HashTables por partición.
data-processing Radix Partitioning Algoritmo de optimización para dividir el conjunto de datos en particiones más pequeñas, mejorando la localidad de caché para las operaciones de agregación. 4096 particiones basadas en los 12 bits superiores del hash de la clave.
data-processing SlabVec Estructura de datos similar a un vector, utilizada para almacenar las particiones de manera eficiente, evitando la variabilidad de memoria de los Vecs estándar. vs std::vec::Vec

Trade-offs

Ganancias
  • ▲ Reducción de latencia de GROUP BY
  • ▲ Reducción de accesos a DRAM
  • ▲ Aumento de la utilización efectiva de la CPU (retiring ops)
Costes
  • △ Mayor número de instrucciones de CPU (hashing, doble pasada)
  • △ Mayor 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(())
}
Implementación directa de GROUP BY donde cada clave se busca y actualiza en una única HashMap global. Ilustra el problema de los 'cache misses' cuando la tabla es grande.
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; // Top 12 bits for 4096 partitions
        self.partitions[partition].push(&mut self.allocator, (hash, key));
    }
    Ok(())
}
Código que muestra cómo se hashea una clave y se utiliza un subconjunto de bits del hash para determinar la partición a la que pertenece, almacenando la clave en un SlabVec.
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)))
}
Iteración sobre cada partición para crear una HashTable local y realizar la agregación. La clave de la HashTable es el hash, y se usa la clave original para resolver colisiones.

Fundamentos Teóricos

El problema de la latencia de memoria y la importancia de la localidad de caché ha sido un tema central en la investigación de arquitectura de computadoras y sistemas operativos desde las primeras CPUs con cachés. Conceptos como la 'jerarquía de memoria' y el 'principio de localidad' (temporal y espacial) son fundamentales y se remontan a los trabajos pioneros en diseño de computadores. El algoritmo de Radix Partitioning, aunque no es un 'paper' único, es una aplicación directa de estos principios, especialmente en el contexto de algoritmos de ordenamiento y agregación de datos. Se relaciona con técnicas como el Radix Sort, que también divide los datos en 'buckets' basados en dígitos o bits para procesarlos de manera más eficiente. La optimización de la localidad de caché es un tema recurrente en papers sobre bases de datos in-memory y sistemas de procesamiento analítico en línea (OLAP), donde la minimización de los 'cache misses' es crucial para el rendimiento. Los trabajos de Jim Gray y Michael Stonebraker, entre otros, en el diseño de sistemas de bases de datos de alto rendimiento, a menudo enfatizan la importancia de la interacción eficiente con el hardware subyacente.