A veces el rendimiento no mejora comprando servidores más grandes.

A veces mejora porque alguien mira una estructura de datos y pregunta: ¿por qué estamos pagando por bytes que nunca usamos?

Cloudflare acaba de publicar uno de esos casos que parecen pequeños a nivel de código y gigantes a escala de producción.

Su plataforma DNS Big Pineapple, que está detrás de servicios como 1.1.1.1, mantiene más de 250 mil millones de entradas DNS en caché en un momento dado. Después de una serie de optimizaciones en Rust, Cloudflare redujo la huella típica de cada entrada de 953 bytes a 420 bytes.

El efecto agregado fue enorme: aproximadamente 100 TB de memoria liberados en toda la flota, sin añadir servidores ni reemplazar hardware.

Y lo más interesante es que no cambiaron memoria por velocidad.

También consiguieron:

  • 43% más throughput de inserción;
  • 19% menos latencia de lookup;
  • una reducción de memoria residente p99 por instancia de aproximadamente 9.3 GB a 5.3 GB.

El caso es una lección excelente de ingeniería de sistemas porque muestra algo que suele quedar oculto en aplicaciones pequeñas: el costo real de una abstracción depende de cuántas veces la multiplicas.

Un byte que cuesta 250 GB

La escala cambia completamente la economía de una estructura de datos.

Si tienes 1,000 objetos, desperdiciar ocho bytes por objeto es irrelevante.

Si tienes 250 mil millones, deja de serlo.

Cloudflare lo resume con una cifra brutal: un solo byte innecesario por entrada representa más de 250 GB de RAM a escala de la flota.

Podemos pensarlo así:

1 byte × 250,000,000,000 entradas
≈ 250 GB

Por eso decisiones que normalmente consideraríamos detalles de implementación —un campo capacity, un puntero adicional, padding por alineación o una allocation independiente— se convierten en terabytes.

La optimización no fue un truco único. Fue una secuencia de cambios sobre cómo se representa una respuesta DNS una vez que entra en caché.

Primera idea: si un objeto ya no va a crecer, deja de pagar por capacidad

En Rust, Vec<T> mantiene tres datos principales:

pointer
length
capacity

String tiene un patrón parecido.

Ese campo capacity tiene sentido cuando el contenedor puede crecer. Permite agregar elementos sin realocar memoria en cada operación.

Pero una respuesta DNS cacheada por Cloudflare tiene una propiedad muy importante: después de insertarse, no cambia.

La capacidad extra deja de aportar valor.

Por eso Cloudflare sustituyó estructuras dinámicas por versiones de tamaño fijo como:

Vec<T>    -> Box<[T]>
String    -> Box<str>

Un Box<[T]> conoce el puntero y la longitud, pero no necesita reservar capacidad futura.

Solo eliminar ese overhead, junto con espacio reservado que nunca llegaba a utilizarse, permitió ahorrar más de 15 TB de RAM en toda la infraestructura.

La lección es mucho más general que Rust:

La estructura óptima para construir datos no siempre es la estructura óptima para conservarlos.

Un patrón muy útil es:

construir -> validar -> compactar -> congelar -> leer muchas veces

Durante la construcción puedes necesitar mutabilidad y capacidad adicional.

En el hot path de lectura, quizás no.

Segunda idea: tres listas pueden ser un solo buffer con offsets

Una respuesta DNS tiene varias secciones, entre ellas:

  • answer;
  • authority;
  • additional.

Una implementación natural es guardar cada sección como una lista independiente.

El problema es que cada lista necesita metadata: punteros, longitudes y posiblemente allocations separadas.

Cloudflare observó que esas secciones podían almacenarse juntas en una sola región de memoria y marcar dónde comienza cada una mediante pequeños offsets.

Conceptualmente:

antes

answers    -> [ ... ]
authority  -> [ ... ]
additional -> [ ... ]

se convierte en:

records -> [ answers | authority | additional ]
                      ^         ^
                    offset    offset

Como el número de records por sección cabe en un u16, esos offsets pueden ser muy pequeños.

Eso elimina estructuras auxiliares y reduce tanto metadata como allocations.

Cloudflare también agrupó varios booleanos en bitflags, aprovechando mejor el layout de la estructura y reduciendo padding de alineación.

Esta parte es importante: en sistemas de bajo nivel el tamaño de una estructura no siempre es simplemente la suma de sus campos.

El compilador puede insertar padding para mantener ciertos valores alineados en memoria.

Por eso eliminar un campo aparentemente pequeño puede producir un ahorro mayor de lo esperado.

Tercera idea: no almacenes información que ya tienes en otro sitio

Cada record DNS normalmente incluye un owner: el nombre de dominio al que pertenece.

Pero en una gran parte de las respuestas ese nombre coincide exactamente con el dominio que ya forma parte de la key de la caché.

Eso significa que la caché estaba almacenando repetidamente información que ya conocía.

Cloudflare cambió el modelo para que el owner sea opcional.

Conceptualmente:

owner: Option<Box<Name>>

Si el owner es igual al dominio consultado, no se almacena.

Cuando llega el momento de reconstruir la respuesta, se recupera desde la key de la caché.

Si realmente es diferente —por ejemplo, en determinados records asociados a un CNAME— entonces sí se conserva.

Este principio aparece por todas partes en sistemas grandes:

Si un dato puede derivarse de forma barata y determinista de información que ya está disponible en el hot path, almacenarlo otra vez puede ser una forma de deuda de memoria.

No significa que siempre debamos normalizar al máximo.

A veces duplicar datos reduce CPU, simplifica código o evita dependencias entre estructuras.

La decisión correcta depende del balance entre memoria, latencia y complejidad.

Cloudflare podía hacer esta optimización porque la key de la caché ya estaba disponible durante cada lookup.

Cuarta idea: el tamaño de un enum lo puede dictar su variante más grande

Aquí aparece uno de los detalles más interesantes de Rust.

Los records DNS pueden contener tipos de datos muy diferentes.

Un registro A necesita apenas una dirección IPv4 de 4 bytes.

Un AAAA necesita 16 bytes.

Pero otros records, como NAPTR, pueden ser mucho mayores.

Si todos esos tipos viven como variantes de un mismo enum, el enum necesita suficiente espacio para almacenar su variante más grande, además de su discriminante y el padding necesario.

El resultado era sorprendente: records muy pequeños podían terminar pagando una estructura cercana a 144 bytes.

Y A + AAAA representan más del 80% del tráfico utilizado por Cloudflare en sus mediciones.

Como paso intermedio, el equipo movió las variantes grandes al heap mediante Box:

pub enum RecordData {
    A(Ipv4Addr),
    Aaaa(Ipv6Addr),
    Txt(Box<Txt>),
    Naptr(Box<Naptr>),
    Svcb(Box<Svcb>),
}

Así las variantes pequeñas pueden permanecer inline mientras las grandes se representan mediante un puntero.

Esto reduce mucho el desperdicio provocado por el tamaño máximo del enum.

Pero crea otro problema.

Boxing ahorra espacio… pero puede empeorar locality

Mover datos al heap introduce allocations adicionales.

Y una allocation nunca es exactamente gratis.

El allocator necesita metadata y normalmente redondea el tamaño solicitado a determinados buckets.

Además, los objetos terminan dispersos por distintas regiones del heap.

Eso perjudica la localidad de memoria.

La CPU trabaja con cache lines. Cuando los datos necesarios están contiguos, una sola lectura desde memoria puede traer información útil para varias operaciones posteriores.

Cuando cada pieza vive detrás de un puntero diferente, aumenta la posibilidad de cache misses.

Así que Cloudflare llegó a una conclusión interesante:

boxear era mejor que desperdiciar 120 bytes por record, pero todavía no era el layout final ideal.

Quinta idea: guardar los records casi como bytes de red

La optimización final fue más agresiva.

En lugar de conservar cada record como una rica estructura de objetos Rust, Cloudflare empezó a serializar gran parte de los datos en un buffer compacto de bytes.

Conceptualmente:

CacheEntry
   |
   +-- metadata
   |
   +-- Box<[u8]>
       [record][record][record][record]

Esto aporta varias ventajas.

Primero, se necesita una sola allocation para los datos de los records.

Segundo, la información queda contigua en memoria.

Tercero, varios tipos de record pueden copiarse directamente desde ese buffer hacia la respuesta DNS sin reconstruir campo por campo su representación wire.

Cloudflare puede hacer esa copia directa para tipos como A, AAAA, TXT y varios records DNSSEC.

Solo los records que contienen nombres de dominio y requieren compresión DNS necesitan parsing adicional.

El cambio mejoró tanto memoria como rendimiento.

Solo esta etapa incrementó el throughput de inserción alrededor de 13% en los benchmarks internos.

Aquí aparece una idea importante para sistemas de alto rendimiento:

A veces la representación más eficiente en memoria se parece más al formato de transporte que al modelo de objetos del dominio.

Los modelos ricos son excelentes para trabajar con información.

Los buffers compactos son excelentes para conservar y mover enormes cantidades de información.

El resultado completo

Después de las cinco optimizaciones, Cloudflare publicó estas cifras:

MétricaAntesDespuésCambio
Huella neta por entrada953 B420 B-56%
Allocations por entrada1.1 KB461 B-58%
Inserciones625,000/s893,000/s+43%
Latencia de lookup828 ns670 ns-19%

En producción, la memoria residente p99 por instancia cayó aproximadamente de 9.3 GB a 5.3 GB.

El working set agregado de la flota terminó siendo alrededor de 100 TB menor.

Cloudflare calcula que esa cantidad de memoria equivale aproximadamente a la RAM de 130 servidores Gen 13 de su infraestructura.

Pero hay una precisión importante: no significa que Cloudflare apagó exactamente 130 máquinas.

La memoria liberada forma parte de una plataforma distribuida y se puede reutilizar para otra cosa.

De hecho, Cloudflare dice que planea reinvertirla aumentando la capacidad de sus caches, lo que debería elevar el hit rate y reducir consultas hacia servidores DNS autoritativos.

Por qué menos memoria también produjo más velocidad

En optimización solemos asumir que hay un intercambio inevitable:

menos memoria <-> más CPU

Pero no siempre ocurre.

En este caso, reducir memoria también significó:

  • menos allocations;
  • menos punteros que seguir;
  • menos metadata;
  • objetos más pequeños;
  • más datos útiles por cache line;
  • mejor localidad;
  • menos serialización durante los lookups.

La CPU moderna es extremadamente rápida cuando trabaja con datos que ya están en sus caches.

Es mucho más lenta cuando necesita esperar por memoria principal.

Por eso reducir la huella de datos puede aumentar la probabilidad de que el working set activo permanezca en niveles de caché más cercanos al procesador.

No solo estás ahorrando RAM.

Estás ayudando a la CPU a encontrar antes lo que necesita.

Qué significa esto para .NET

No hace falta operar 250 mil millones de entradas para aplicar estas ideas.

En .NET también existen estructuras optimizadas para fases distintas del ciclo de vida.

Por ejemplo, durante construcción puedes necesitar:

List<T>
Dictionary<TKey, TValue>
StringBuilder

Pero después de construir un objeto que será leído miles o millones de veces quizá tenga sentido convertirlo a representaciones más compactas:

List<T> -> T[]
Dictionary mutable -> estructura read-only/frozen
objetos -> structs cuando el layout lo justifica
múltiples buffers -> una región contigua

.NET dispone además de herramientas como Span<T>, Memory<T>, ArrayPool<T> y colecciones frozen que permiten diseñar hot paths con menos allocations.

La regla no es “usa arrays para todo”.

La regla es separar el modelo de construcción del modelo de ejecución cuando el perfil de acceso lo justifica.

Qué significa para Python

Python tiene un overhead por objeto considerablemente mayor que Rust.

Una estructura cómoda como:

{
    "host": "example.com",
    "ttl": 300,
    "type": "A",
    "address": "192.0.2.1",
}

puede ser perfecta para lógica de negocio, pero muy cara si necesitas mantener decenas de millones de ellas en memoria.

En cargas realmente sensibles a memoria, algunas alternativas son:

  • bytes o bytearray para datos compactos;
  • array y memoryview;
  • struct para layouts binarios;
  • dataclass(slots=True) para reducir overhead de atributos;
  • columnas o arrays contiguos en lugar de árboles de objetos;
  • formatos serializados compactos cuando los datos son mayormente read-only.

De nuevo, no se trata de sacrificar legibilidad antes de tener un problema.

Se trata de reconocer cuándo el modelo de objetos se ha convertido en parte del costo operativo.

La lección más importante: optimiza el lifecycle, no solo la estructura

Lo más valioso del caso de Cloudflare no es memorizar que Box<[T]> ocupa menos que Vec<T>.

Es entender por qué era válido cambiarlo.

El equipo sabía que una entrada tenía dos fases claramente distintas:

fase 1: construcción
- mutable
- necesita buffers de trabajo
- puede crecer

fase 2: caché
- read-only
- se consulta muchas veces
- no necesita capacidad adicional

Cuando reconoces esas fases, aparecen oportunidades para cambiar de representación.

Un objeto puede comenzar rico y flexible y terminar compacto e inmutable.

Ese patrón sirve para:

  • caches;
  • índices de búsqueda;
  • motores de reglas;
  • catálogos en memoria;
  • routing tables;
  • modelos de configuración;
  • pipelines de eventos;
  • sistemas de inferencia;
  • servidores de alta concurrencia.

No optimices 8 bytes si tienes 800 usuarios

También hay una lección contraria.

El caso Cloudflare puede producir la tentación de empezar a medir cada byte de cualquier aplicación.

Eso sería aprender la lección equivocada.

Estas optimizaciones tienen sentido porque existen simultáneamente:

  1. una estructura extremadamente repetida;
  2. una parte crítica del hot path;
  3. cientos de miles de millones de instancias;
  4. benchmarks reproducibles;
  5. mediciones reales de producción.

Primero se mide.

Después se identifica qué domina el costo.

Y solo entonces se acepta la complejidad adicional de una representación más compacta.

Una buena regla podría ser:

claridad primero
perfilado después
optimización donde la multiplicación la justifique

El byte no importa. La multiplicación sí.

Cloudflare no liberó 100 TB porque descubriera una instrucción mágica de Rust.

Lo consiguió acumulando pequeñas decisiones correctas:

  • eliminar capacidad que nunca se usa;
  • reducir listas y punteros;
  • dejar de almacenar datos redundantes;
  • evitar que casos raros definan el tamaño de los casos comunes;
  • compactar datos en buffers contiguos;
  • reutilizar representaciones cercanas al wire format;
  • medir cada cambio contra memoria y latencia.

A escala normal, algunas de estas decisiones ahorrarían kilobytes.

A escala Cloudflare, ahorraron terabytes.

Esa es probablemente la parte más útil del caso:

La eficiencia de una arquitectura no se entiende mirando cuánto cuesta una instancia. Se entiende mirando cuánto cuesta una instancia multiplicada por toda la escala del sistema.

Fuentes