Aller au contenu principal

Grandes valeurs, petites pauses : le découpage des gros objets dans Zaris

· 7 minutes de lecture
Clustron Team
Ingénierie des systèmes distribués

Le découpage des gros objets maintient les grandes valeurs hors du LOH

Il y a un nombre que tout ingénieur performance .NET finit par mémoriser : 85 000 octets. Toute allocation unique au-delà d'environ cette taille ne va pas sur le tas normal des petits objets — elle va sur le Large Object Heap (LOH). Le LOH est une autre bête. Il n'est collecté que lors d'un ramasse-miettes gen-2 (complet), et par défaut il n'est pas compacté. Cette combinaison convient à un gros tampon que vous allouez une fois et conservez. Elle ne convient pas à un store en mémoire à faible latence qui écrit et réécrit à répétition de grandes valeurs.

Imaginez la charge de travail : gros blobs sérialisés, grands documents JSON, images, charges utiles de protocole — stockés, mis à jour, remplacés, encore et encore. Chacune de ces grandes écritures dépose un objet neuf sur le LOH. Comme le LOH n'est pas compacté, les emplacements libérés ne fusionnent pas en espace contigu ; ils se fragmentent. Et comme en récupérer la moindre partie implique un ramasse-miettes gen-2 complet, les pauses s'allongent et surviennent plus souvent précisément à mesure que l'ensemble de travail grossit. Pour un store dont tout l'argumentaire est la faible latence, c'est le mauvais type de surprise.

Zaris propose une fonctionnalité optionnelle pour contourner entièrement le LOH pour ces valeurs. Elle est désactivée par défaut, et cet article porte sur ce qu'elle fait, comment elle fonctionne, et — tout aussi important — quand vous devriez et ne devriez pas l'activer.

L'idée : ne jamais allouer le gros objet​

Lorsque le découpage des gros objets est activé, Zaris cesse de stocker une grande valeur simple sous forme d'un unique byte[] contigu. Il scinde plutôt la valeur en un tableau en escalier de plus petits morceaux — un byte[][] où chaque morceau se situe confortablement sous le seuil LOH de ~85 Ko.

La conséquence est tout l'intérêt : aucun morceau n'est assez grand pour être un gros objet, donc aucun d'eux n'atterrit sur le LOH. La valeur vit entièrement sur le tas normal des petits objets, qui est compacté et est collecté lors de passes gen-0/gen-1 bon marché bien plus souvent que gen-2. Vous avez pris une valeur qui était une source récurrente de fragmentation du LOH et de pression gen-2 et l'avez transformée en trafic de tas ordinaire et bien élevé.

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)

Les lectures ne paient presque rien pour cela. Le réassemblage à la lecture est une passe unique : Zaris recoud les morceaux dans la valeur contiguë que l'appelant attend, de sorte que le code appelant voit exactement la valeur qu'il a stockée. La disposition découpée est un détail de stockage interne, pas quelque chose qui fuit dans votre API.

Le reporting mémoire reste honnête​

Une inquiétude légitime : si une valeur est secrètement stockée sous forme d'une douzaine de sous-tableaux plus la comptabilité pour les suivre, le reporting mémoire devient-il soudain confus ? Non. Zaris continue de reporter la taille logique d'une valeur — ce que vous avez réellement stocké — et non la disposition physique découpée. Une valeur de 400 Ko se lit comme une valeur de 400 Ko dans votre comptabilité. Le découpage est une tactique de ramasse-miettes, pas un changement comptable, et nous l'avons délibérément gardé invisible aux chiffres que vous raisonnez.

L'activer​

Deux boutons de configuration contrôlent la fonctionnalité :

{
"memory": {
"chunking": {
"enabled": true
},
"lohThresholdBytes": 85000
}
}
  • memory.chunking.enabled active le comportement. Laissé à sa valeur par défaut, Zaris stocke les valeurs de la façon contiguë ordinaire.
  • memory.lohThresholdBytes définit la taille à laquelle une valeur est découpée. Réglez-la au niveau ou en dessous de la frontière LOH du runtime pour que tout ce qui est assez grand pour devenir un gros objet soit scindé avant de le pouvoir.

En termes de client natif, rien ne change dans vos appels — vous écrivez une grande valeur de la même manière que toujours, et le découpage (s'il est activé) se produit en dessous :

// 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");

Une mise en garde honnête sur le périmètre​

C'est un outil ciblé, et il vaut la peine d'être précis sur ses contours. Le découpage vise les grandes valeurs d'octets simples. Un byte[][] en escalier survit proprement au chemin de stockage, ce qui est exactement pourquoi les grandes charges utiles d'octets simples sont le point idéal. Ce n'est pas un schéma de compression polyvalent — les octets ne sont pas rendus plus petits, ils sont simplement disposés différemment — et ce n'est pas un changement dans la façon dont les objets typés personnalisés sont traités. Voyez-le comme un instrument spécifique pour un problème spécifique : la pression sur le ramasse-miettes due à des valeurs chaudes, grandes et simples. Saisissez-le quand c'est votre problème, et laissez-le dans le tiroir sinon.

Pourquoi il est désactivé par défaut​

Si c'est utile, pourquoi ne pas toujours l'activer ? Parce que la plupart des valeurs dans la plupart des stores sont petites — bien en deçà du seuil LOH — et pour ces valeurs le découpage ne fait rien d'utile. Ce qu'il ajoute est bien réel : scinder à l'écriture, suivre les morceaux et les recoudre à la lecture coûtent tous un peu de CPU et un peu de comptabilité par valeur. Ce surcoût vaut trivialement la peine d'être payé pour une valeur véritablement grande que vous réécrivez constamment, et c'est pur gaspillage pour un token de session de 200 octets.

Zaris en fait donc un choix délibéré plutôt qu'une valeur par défaut silencieuse. L'activer est une déclaration : « ce déploiement a des valeurs chaudes et grandes, et je préfère échanger un peu de comptabilité par valeur contre moins de pauses gen-2 et aucune fragmentation du LOH. » C'est un excellent échange pour un cache de grands agrégats sérialisés ou un store d'images. C'est un mauvais échange pour un store rempli de petites clés, raison pour laquelle le défaut suppose le cas courant.

Quand l'activer​

Saisissez le découpage des gros objets quand ces deux conditions sont vraies :

  • Vos valeurs sont véritablement grandes — régulièrement au niveau ou au-dessus du seuil LOH de ~85 Ko (gros blobs, grand JSON, images, snapshots sérialisés).
  • Elles sont fréquemment mises à jour — des valeurs chaudes qui sont réécrites, pas écrites-une-fois-et-oubliées. C'est là que la fragmentation du LOH et la pression gen-2 s'accumulent réellement.

Si vous constatez des pauses gen-2 qui s'allongent et une taille de LOH qui augmente sur une charge de travail correspondant à cette description, activez memory.chunking.enabled et réglez memory.lohThresholdBytes pour maintenir les valeurs hors du LOH. Si vos valeurs sont petites, laissez-le désactivé — vous ne feriez que payer de la comptabilité pour un problème que vous n'avez pas. Comme la plupart des bons boutons de performance, sa valeur réside entièrement dans le fait de savoir quand ne pas l'activer.