Valores grandes, pausas pequeñas: fragmentación de objetos grandes en Zaris
Hay un número que todo ingeniero de rendimiento de .NET acaba memorizando: 85.000 bytes. Cualquier asignación individual en o por encima de aproximadamente ese tamaño no va al heap normal de objetos pequeños — va al Large Object Heap (LOH). El LOH es un animal diferente. Solo se recolecta durante una recolección de basura de gen-2 (completa), y por defecto no se compacta. Esa combinación está bien para un búfer grande que asignas una vez y conservas. No está bien para un almacén en memoria de baja latencia que escribe y sobrescribe repetidamente valores grandes.
Imagina la carga de trabajo: blobs serializados grandes, documentos JSON grandes, imágenes, payloads de protocolo — almacenados, actualizados, reemplazados, una y otra vez. Cada una de esas escrituras grandes deposita un objeto nuevo en el LOH. Como el LOH no se compacta, los huecos liberados no se fusionan de vuelta en espacio contiguo; se fragmentan. Y como recuperar algo de él implica una recolección completa de gen-2, las pausas se alargan y aterrizan con más frecuencia justo a medida que crece el working set. Para un almacén cuyo argumento entero es baja latencia, esa es la clase equivocada de sorpresa.
Zaris incluye una función opcional para esquivar el LOH por completo para estos valores. Está desactivada por defecto, y este post trata de lo que hace, cómo funciona, y — igual de importante — cuándo deberías y cuándo no deberías activarla.
La idea: nunca asignar el objeto grande
Cuando la fragmentación de objetos grandes está habilitada, Zaris deja de almacenar un valor grande simple como un único byte[] contiguo. En su lugar, divide el valor en un arreglo dentado de piezas más pequeñas — un byte[][] donde cada fragmento se sitúa cómodamente por debajo del umbral del LOH de ~85KB.
La consecuencia es todo el objetivo: ninguna pieza individual es lo bastante grande como para ser un objeto grande, así que ninguna de ellas aterriza en el LOH. El valor vive enteramente en el heap normal de objetos pequeños, que sí se compacta y sí se recolecta en pasadas baratas de gen-0/gen-1 mucho más a menudo que gen-2. Has tomado un valor que solía ser una fuente recurrente de fragmentación del LOH y presión de gen-2 y lo has convertido en tráfico de heap ordinario y bien portado.
without chunking: value ──▶ one 400 KB byte[] ──▶ Large Object Heap (gen-2 only, no compaction)
with chunking: value ──▶ byte[][] { 64KB, 64KB, 64KB, ... } ──▶ small-object heap (gen-0/1, compacted)
Las lecturas casi no pagan nada por esto. El reensamblado en la lectura es una única pasada: Zaris cose las piezas de vuelta en el valor contiguo que el llamador espera, de modo que el código que llama ve exactamente el valor que almacenó. El layout fragmentado es un detalle interno de almacenamiento, no algo que se filtre a tu API.
El reporte de memoria se mantiene honesto
Una preocupación justa: si un valor se almacena secretamente como una docena de subarreglos más la contabilidad para rastrearlos, ¿el reporte de memoria se vuelve de repente confuso? No. Zaris sigue reportando el tamaño lógico de un valor — lo que realmente almacenaste — no el layout fragmentado físico. Un valor de 400KB se lee como un valor de 400KB en tu contabilidad. La fragmentación es una táctica de recolección de basura, no un cambio contable, y deliberadamente la mantuvimos invisible para los números sobre los que razonas.
Activarla
Dos perillas de configuración controlan la función:
{
"memory": {
"chunking": {
"enabled": true
},
"lohThresholdBytes": 85000
}
}
memory.chunking.enabledactiva el comportamiento. Dejada en su valor por defecto, Zaris almacena los valores de la forma contigua ordinaria.memory.lohThresholdBytesfija el tamaño a partir del cual un valor se fragmenta. Fíjalo en o por debajo del límite del LOH del runtime para que cualquier cosa lo bastante grande como para convertirse en un objeto grande se divida antes de poder hacerlo.
En términos de cliente nativo, nada sobre tus llamadas cambia — escribes un valor grande de la misma forma que siempre, y la fragmentación (si está habilitada) ocurre por debajo:
// Same call whether or not chunking is enabled — the split happens in the store.
byte[] bigPayload = SerializeSnapshot(order); // e.g. ~400 KB
await client.PutAsync("snapshot:order:42", bigPayload);
// Read reassembles the pieces in a single pass; caller sees the whole value.
ReadResult<byte[]> result = await client.GetAsync<byte[]>("snapshot:order:42");
Una advertencia honesta sobre el alcance
Esta es una herramienta dirigida, y vale la pena ser preciso sobre sus límites. La fragmentación apunta a valores de bytes simples grandes. Un byte[][] dentado sobrevive el camino de almacenamiento limpiamente, que es exactamente por qué los payloads de bytes simples grandes son el punto óptimo. No es un esquema de compresión de propósito general — los bytes no se hacen más pequeños, solo se disponen de otra forma — y no es un cambio en cómo se manejan los objetos tipados personalizados. Piénsala como un instrumento específico para un problema específico: presión de GC de valores calientes, grandes y simples. Recurre a ella cuando ese sea tu problema, y déjala en el cajón en caso contrario.
Por qué está desactivada por defecto
Si ayuda, ¿por qué no dejarla siempre activada? Porque la mayoría de los valores en la mayoría de los almacenes son pequeños — muy por debajo del umbral del LOH — y para esos valores la fragmentación no hace nada útil. Lo que sí añade es real: dividir en la escritura, rastrear las piezas, y coserlas de vuelta en la lectura cuestan todo ello un poco de CPU y un poco de contabilidad por valor. Ese overhead vale trivialmente la pena pagarlo por un valor genuinamente grande que sobrescribes constantemente, y es puro desperdicio para un token de sesión de 200 bytes.
Así que Zaris lo hace una elección deliberada en lugar de un default silencioso. Activarla es una afirmación: "este despliegue tiene valores calientes y grandes, y prefiero intercambiar un poco de contabilidad por valor por menos pausas de gen-2 y sin fragmentación del LOH." Ese es un gran intercambio para una caché de agregados serializados grandes o un almacén de imágenes. Es un mal intercambio para un almacén lleno de claves pequeñas, que es por qué el default asume el caso común.
Cuándo activarla
Recurre a la fragmentación de objetos grandes cuando ambas cosas sean ciertas:
- Tus valores son genuinamente grandes — rutinariamente en o por encima del umbral del LOH de ~85KB (blobs grandes, JSON grande, imágenes, snapshots serializados).
- Se actualizan con frecuencia — valores calientes que se sobrescriben, no escritos-una-vez-y-olvidados. Aquí es donde la fragmentación del LOH y la presión de gen-2 realmente se acumulan.
Si estás viendo pausas de gen-2 que se alargan y un tamaño de LOH que sube en una carga de trabajo que coincide con esa descripción, activa memory.chunking.enabled y fija memory.lohThresholdBytes para mantener los valores fuera del LOH. Si tus valores son pequeños, déjala desactivada — solo estarías pagando contabilidad por un problema que no tienes. Como la mayoría de las buenas perillas de rendimiento, su valor está enteramente en saber cuándo no activarla.