Latence mémoire d'un serveur cloud : mesurée, pas devinée
En bref (vérifié le 2026-11-01) : Un article arrivé en une de Hacker News ce week-end soutient qu'un CPU affiché à 100% attend souvent simplement la RAM, et montre un GROUP BY devenu plus rapide en faisant plus de travail mais en touchant moins la mémoire. Nous avons testé la même idée sur le serveur cloud qui écrit ce blog : une lecture coûte environ 2 ns quand les données tiennent dans le L1 et environ 200 ns avec un jeu de travail de 256 Mo, à peu près cent fois plus. Pour qui loue des serveurs, cet écart compte plus que le nombre de cœurs du bon de commande. Voici le script, nos chiffres et ce qu'ils changent dans le choix entre cloud et dédié.
Source. "It's the memory latency, stupid" de Maor Kern (Pivot / Epsio Labs, publié le 2026-10-28), discuté sur Hacker News avec 175 points au 2026-11-01. Leurs chiffres sont repris tels que l'article les publie ; les nôtres, nous les avons mesurés.
Ce que l'article de Pivot a mesuré
Le test est une requête, GROUP BY UserID sur la table "hits" de ClickBench (environ 99 millions de lignes, environ 17 millions d'utilisateurs distincts), épinglée sur un cœur d'un serveur bare metal AMD EPYC 9124 avec 128 Go de DDR5. Une table de hachage simple de 17 millions de clés ne tient dans aucun cache, donc presque chaque recherche va en RAM. Découper d'abord les lignes en 4096 partitions, chacune avec une petite table de hachage, ajoute du travail mais garde chaque table en cache :
| Mesure (un cœur) | Table de hachage simple | Partitionnement radix |
|---|---|---|
| Durée, 1re / 2e exécution | 3.9 s / 3.6 s | 3.3 s / 2.7 s |
| Slots en attente de mémoire | 50.1% | 23.5% |
| Slots de travail utile | 16.1% | 32.2% |
| Lectures depuis la DRAM | 76.2 millions | 25.7 millions |
La version partitionnée exécute plus d'opérations (19.4 milliards contre 11.4 milliards) et finit pourtant la première. La version optimisée de l'auteur a tourné en 1.85 s sur un cœur et 0.17 s sur les 16. La leçon ne concerne pas une seule base de données : "CPU 100%" dans top dit que le cœur n'est pas inactif, pas qu'il calcule.
Mesurez vous-même : memlat.js
La méthode classique pour voir la latence est le pointer chase : un tableau où chaque case contient l'indice de la suivante, dans un ordre aléatoire, espacées d'une ligne de cache (64 octets). Chaque lecture dépend de la précédente, le CPU ne peut donc ni précharger ni les superposer, et le temps par saut est la latence du niveau où vit le jeu de travail. Notre machine de blog n'a pas de compilateur C, c'est donc du JavaScript pour Bun : le JIT transforme la boucle en quelques instructions machine, ce qui ajoute un peu à chaque chiffre. Comparez les paliers, pas les valeurs absolues. Enregistrez-le sous memlat.js :
// memlat.js - pointer-chase memory latency by working-set size (bun memlat.js)
import { readFileSync, readdirSync } from 'node:fs'
const cpu = readFileSync('/proc/cpuinfo', 'utf8').match(/model name\s*:\s*(.+)/)?.[1] ?? '?'
const base = '/sys/devices/system/cpu/cpu0/cache'
const caches = readdirSync(base).filter(d => d.startsWith('index')).map(d => {
const r = f => readFileSync(`${base}/${d}/${f}`, 'utf8').trim()
return `L${r('level')}${r('type') === 'Data' ? 'd' : r('type') === 'Instruction' ? 'i' : ''} ${r('size')}`
})
console.log(`CPU: ${cpu}\nCaches: ${caches.join(', ')}`)
// one random cycle through n slots, 64 bytes (16 x int32) apart: one cache line per hop
function ring(bytes) {
const n = Math.max(2, Math.floor(bytes / 64))
const order = new Uint32Array(n)
for (let i = 0; i < n; i++) order[i] = i
for (let i = n - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
const t = order[i]; order[i] = order[j]; order[j] = t
}
const a = new Int32Array(n * 16)
for (let i = 0; i < n; i++) a[order[i] * 16] = order[(i + 1) % n] * 16
return a
}
function chase(a, hops) {
let p = 0
for (let i = 0; i < hops; i++) p = a[p]
return p
}
const hops = 20_000_000
console.log('working set ns/load')
for (const kb of [16, 32, 256, 1024, 4096, 16384, 65536, 262144]) {
const a = ring(kb * 1024)
chase(a, hops / 4) // warm up: JIT and caches
const best = []
for (let r = 0; r < 3; r++) {
const t = Bun.nanoseconds()
const sink = chase(a, hops)
best.push((Bun.nanoseconds() - t) / hops)
if (sink < 0) console.log(sink)
}
const label = kb >= 1024 ? `${kb / 1024} MB` : `${kb} KB`
console.log(`${label.padStart(10)} ${Math.min(...best).toFixed(1).padStart(7)}`)
}
bun memlat.js
Résultat, exécuté sur un serveur DCXV Cloud avec Bun 1.4.4 le 2026-11-01 :
CPU: Intel(R) Xeon(R) CPU E5-2698 v4 @ 2.20GHz
Caches: L2 4096K, L1d 32K, L3 16384K, L1i 32K
working set ns/load
16 KB 2.0
32 KB 2.2
256 KB 12.5
1 MB 24.3
4 MB 122.2
16 MB 138.4
64 MB 164.7
256 MB 205.1
Une seconde exécution le même matin a varié d'au plus 2 ns jusqu'à 1 Mo et d'au plus 12 ns au-delà :
| Jeu de travail | ns par lecture, exécution 1 / 2 | Où il vit |
|---|---|---|
| 32 Ko | 2.2 / 2.4 | cache de données L1 |
| 256 Ko | 12.5 / 10.5 | L2, débordant sur le L3 |
| 1 Mo | 24.3 / 24.3 | L3 |
| 16 Mo | 138.4 / 137.2 | parcours des tables de pages, L3 et DRAM |
| 256 Mo | 205.1 / 193.5 | DRAM |
Ce que disent les chiffres
- L'invité ne voit pas les vrais caches. La VM annonce 4 Mo de L2 et 16 Mo de L3 ; Intel indique 50 Mo de cache pour le Xeon E5-2698 v4 (Intel ark, vérifié le 2026-11-01). L'hyperviseur présente sa propre topologie, et
lscpudans un serveur cloud lit celle-ci, pas le silicium. Mesurez le matériel au lieu de croire les étiquettes. - La falaise arrive bien avant que le cache soit plein. Entre 1 Mo et 4 Mo, la latence est multipliée par cinq, loin des 50 Mo. Avec des pages de 4 Ko, un parcours aléatoire de 4 Mo touche 1 024 pages, plus que le TLB n'en contient, et dans une VM un défaut de TLB parcourt les tables de pages de l'invité et de l'hôte ; le L3 est en outre partagé avec les autres cœurs du socket. Nous n'avons pas séparé ces causes. La conclusion pratique reste la même : dans une VM, prévoyez comme si votre jeu de travail rapide ne faisait que quelques mégaoctets.
- Plus de RAM n'est pas une RAM plus rapide. De 1 Mo à 256 Mo, le coût par lecture est multiplié par huit environ. Des gigaoctets en plus apportent de la capacité, pas de la vitesse ; ce qui accélère une tâche limitée par la latence, c'est un jeu chaud plus petit, comme le montre le partitionnement de Pivot.
Ce que cela change si vous louez des serveurs
- Les serveurs cloud conviennent à la plupart des applications web, API et petites bases : leurs données chaudes tiennent en cache, et ils attendent le réseau ou le disque bien avant la RAM. Dimensionnez-les sur l'usage mémoire mesuré, comme dans notre guide de dimensionnement des serveurs cloud.
- Les serveurs dédiés sont rentables quand un grand jeu de travail est lu au hasard toute la journée : analytique, grosses jointures de hachage, caches en mémoire, un serveur PostgreSQL dont les index ne tiennent plus en RAM. Vous avez tout le L3 et tous les canaux mémoire sans voisins, et vous pouvez activer les huge pages, qui réduisent les défauts de TLB. Configurez-en un sur la page serveur dédié.
- Avant toute mise à niveau, lancez le test sur le serveur que vous avez déjà et vérifiez si votre tâche lente est limitée par la latence. Doubler les cœurs n'aide pas un thread bloqué la moitié du temps. La mémoire a elle-même renchéri cette année, voir RAM serveur en 2026 ; la solution la moins chère est souvent un code qui en touche moins.
Testez avant d'acheter
Nos data centers Tier III de Prague et Covilha proposent des serveurs cloud et des serveurs dédiés. Lancez un serveur cloud, exécutez memlat.js à côté de votre propre charge et envoyez le résultat à sales@dcxv.com : nous vous aiderons à décider si un serveur dédié changerait les chiffres.
