La gestión eficiente de la memoria es un problema fundamental en sistemas distribuidos a escala de hyperscaler, donde incluso un byte de sobrecarga por entrada puede traducirse en terabytes de memoria desperdiciada. Este artículo aborda cómo Cloudflare, con su plataforma Big Pineapple que gestiona billones de entradas de caché DNS, enfrentó este desafío. La tesis central es que la optimización meticulosa de las estructuras de datos en memoria, eliminando sobrecargas inherentes a los tipos de lenguaje de programación y aprovechando las características del formato de datos subyacente, puede generar ahorros masivos de recursos y mejoras de rendimiento simultáneas.
El problema se agrava en entornos como los servicios DNS con EDNS Client Subnet (ECS), donde la misma consulta puede tener múltiples respuestas cacheadas, aumentando la presión sobre la memoria. La solución no solo implica reducir el footprint de memoria, sino hacerlo sin sacrificar, e incluso mejorando, la latencia de búsqueda y el throughput de inserción, lo que requiere un entendimiento profundo de cómo el compilador y el sistema operativo gestionan la memoria y las cachés de CPU.
Arquitectura del Sistema
La caché de Big Pineapple almacena pares clave-valor. La CacheKey incluye qname, qtype, authenticated y tag. La CacheEntry contiene timestamp, inception, ttl, hits, y listas de answers, authority, additional y errors. Las optimizaciones se centraron en reducir la sobrecarga de estas estructuras en Rust.
Inicialmente, se reemplazaron los tipos Vec<T> y String por Box<[T]> y Box<str> respectivamente. Esto eliminó el campo de capacity (8 bytes) y el espacio de heap pre-reservado, ya que las entradas de caché son inmutables una vez almacenadas. Posteriormente, se consolidaron las listas answers, authority y additional en una única lista con offsets de 2 bytes (u16) para cada sección, en lugar de tres Box<[T]> separados (que implican 8 bytes de puntero y 8 bytes de longitud cada uno). Se optimizó el almacenamiento del owner de los registros DNS, utilizando Option<Box<Name>>. Para la mayoría de los registros donde el owner es idéntico al dominio consultado, se almacena None, infiriéndolo en tiempo de lectura y evitando una asignación de heap. Para los casos donde difiere (ej. CNAME), se usa Some con un puntero al nombre completo.
Finalmente, la optimización más significativa fue el cambio en cómo se almacenan los RecordData. En lugar de un enum con variantes que ocupaban el tamaño de la variante más grande (144 bytes para NAPTR), se boxearon las variantes grandes (Box<Txt>, Box<Naptr>, etc.) para que solo ocuparan un puntero de 8 bytes en el enum y su tamaño real en el heap. El paso final y más eficiente fue almacenar los datos de los registros como un único Box<[u8]> en formato de "wire format" (longitud de 2 bytes seguida de los bytes crudos). Esto eliminó la sobrecarga del enum y las asignaciones de heap individuales, mejorando la localidad de memoria. La construcción del buffer de datos se realiza en un scratchspace reutilizable, seguido de una única memcpy a un Box<[u8]> final, lo que optimiza las asignaciones de heap.
Flujo de Inserción de Entrada en Caché (Optimizado)
- 1 Consulta DNS Recibida La plataforma Big Pineapple recibe una consulta DNS.
- 2 Construcción CacheKey Se crea la clave de caché (qname, qtype, authenticated, tag).
- 3 Deserialización Respuesta DNS La respuesta DNS se deserializa parcialmente para extraer metadatos y registros.
- 4 Serialización a Scratchspace Los datos de los registros se serializan en formato de bytes crudos (longitud...
- 5 Asignación Box<[u8]> Se asigna un único Box<[u8]> y se copia el contenido del scratchspace.
- 6 Construcción CacheEntry Se crea la CacheEntry con Box<[u8]> para los registros y campos optimizados.
- 7 Almacenamiento en Caché La entrada se almacena en la caché, con posible desalojo de entradas antiguas.
| Capa | Tecnología | Justificación |
|---|---|---|
| storage | Custom DNS Cache | Almacena más de 250 mil millones de entradas DNS en memoria para el servicio 1.1.1.1 y otros servicios de Cloudflare. |
| compute | Rust | Lenguaje de programación utilizado para implementar la caché, permitiendo control de bajo nivel sobre la memoria y las estructuras de datos. vs C++, Go |
| compute | jemalloc | Asignador de memoria (allocator) utilizado por Rust en producción, optimizado para cargas de trabajo multihilo y con muchas asignaciones. vs System allocator (glibc's malloc), tcmalloc |
Trade-offs
Ganancias
- ▲ Reducción de footprint de memoria por entrada
- ▲ Reducción de asignaciones de memoria por entrada
- ▲ Aumento del throughput de inserción en caché
- ▲ Reducción de la latencia de búsqueda en caché
- ▲ Mejora de la localidad de memoria (CPU cache)
Costes
- △ Mayor complejidad en la lógica de acceso a registros (iteración secuencial en lugar de indexación aleatoria)
- △ Registros no auto-contenidos (el owner se infiere de la clave de caché)
- △ Costo de parsing para registros con nombres de dominio (CNAME, NS, MX, SOA) para aplicar compresión DNS
pub struct CacheEntry {
// ...
// pub answers: Vec<Record>,
pub answers: Box<[Record]>,
// ...
}pub enum RecordData {
A(Ipv4Addr),
Aaaa(Ipv6Addr),
Txt(Box<Txt>),
Naptr(Box<Naptr>),
Svcb(Box<Svcb>),
// ...
}// En lugar de Vec<RecordData> o Vec<Box<RecordData>>,
// se usa un único Box<[u8]> para almacenar los registros serializados.
pub struct CacheEntry {
// ...
pub records_data: Box<[u8]>,
// ...
}Fundamentos Teóricos
El problema de la gestión eficiente de la memoria y la optimización de estructuras de datos en sistemas de alto rendimiento tiene raíces profundas en la ciencia de la computación. Conceptos como la localidad de referencia, fundamentales para el rendimiento de las cachés de CPU, son explorados en trabajos clásicos sobre arquitectura de computadoras y diseño de algoritmos. La elección de Box<[T]> sobre Vec<T> para datos inmutables se alinea con principios de diseño de estructuras de datos compactas, minimizando la sobrecarga de metadatos. La compresión de nombres DNS, mencionada en RFC 1035, es un ejemplo de cómo los protocolos de red ya incorporan técnicas de optimización de espacio que pueden ser adaptadas para el almacenamiento en memoria.
La decisión de almacenar los registros en "wire format" es una aplicación directa del principio de "data-oriented design", donde la disposición de los datos en memoria se optimiza para el acceso y procesamiento secuencial, reduciendo la sobrecarga de punteros y mejorando la eficiencia de la caché. Este enfoque contrasta con el "object-oriented design" donde la encapsulación puede introducir indirecciones y fragmentación de memoria. La eliminación de padding y la compactación de campos booleanos en bitflags son técnicas bien conocidas en la optimización de estructuras de datos, a menudo discutidas en el contexto de la programación de sistemas y el diseño de compiladores para maximizar el uso del espacio de memoria.