Faire la course contre le refresh de la DRAM
Toutes les ~7,8 microsecondes, ton contrôleur DRAM arrête de servir les lectures et rafraîchit une rangée de cellules. Une lecture qui tombe dans cette fenêtre passe de ~80 ns à ~300-600 ns. C'est ça, la queue de ta tail latency, et c'est structurel : le refresh va avoir lieu, la seule question est de savoir si ta lecture rentre dedans.
L'astuce pour l'esquiver n'est pas de moi. Elle vient de LaurieWired, dont l'implémentation C++ originale vit sur LaurieWired/tailslayer. L'idée : répliquer tes données sur N channels DRAM indépendants, épingler un thread worker par cœur, et prendre la lecture qui finit en premier. Les plannings de refresh ne sont pas corrélés entre channels, donc à un instant donné il y a de bonnes chances qu'au moins un channel ne soit pas en train de se rafraîchir.
Je l'ai porté en Rust. Le repo est vimoppa/tailslayer-rs, sous Apache 2.0. Ce post parle de ce que le port m'a obligé à faire correctement, et de la seule section qui est vraiment difficile : transformer une adresse physique en index de channel sur AMD.
Le stall de 7,8 us
Le mécanisme est documenté dans la spec DDR4, je ne l'ai pas mesuré moi-même. L'intervalle de refresh (tREFI) est de ~7,8 us ; un refresh bloque le rank pendant ~350 ns sur DDR4. La bibliothèque encode ça sous forme de constantes :
// crates/tailslayer-rs-hw/src/mem/mod.rs
impl MemoryTech for Ddr4Tech {
fn refresh_interval_ns(&self) -> u64 { 7800 }
fn refresh_duration_ns(&self) -> u64 { 350 }
// ...
}Pour vérifier si le stall est réellement visible sur une machine donnée, il y a un binaire trefi-probe. Il flush une ligne de cache, la lit, chronomètre la lecture avec rdtsc, et cherche des pics périodiques :
// bins/trefi-probe/src/main.rs (trimmed)
unsafe fn timed_probe(addr: *const u8) -> u64 {
tailslayer_rs_hw::cache_flush(addr);
tailslayer_rs_hw::fence_full();
tailslayer_rs_hw::fence_load();
let t0 = tailslayer_rs_hw::read_timestamp();
core::ptr::read_volatile(addr);
let t1 = tailslayer_rs_hw::read_timestamp_end();
t1 - t0
}Ensuite il range les intervalles entre pics par rapport au tREFI attendu et à ses harmoniques (1T, 2T, 3T), trouve le pic de l'histogramme, et reporte de combien il a dérivé par rapport à l'intervalle attendu :
// bins/trefi-probe/src/main.rs (trimmed)
let peak_cyc = bin_lo + (peak_bin as f64 + 0.5) * bin_width;
let peak_us = peak_cyc / (tsc_ghz * 1000.0);
eprintln!("\n Histogram peak: {peak_cyc:.0} cycles ({peak_us:.2} us), count={peak_count}");
eprintln!(" Expected: {t:.0} cycles ({:.2} us)", cli.trefi_us);
eprintln!(" Deviation: {:.1}%", (peak_cyc - t).abs() / t * 100.0);La probe affiche ce que ton hardware produit. Je ne vais pas citer un pic ou un pourcentage de spike comme si c'était une propriété fixe du monde, parce que ça ne l'est pas. Lance-la sur ta machine et lis le verdict.
De l'adresse physique au channel
C'est la partie qui compte, et celle où le C++ original et le hashing d'adresse d'AMD ne sont pas d'accord.
Pour répliquer entre channels, il faut savoir sur quel channel tombe une adresse physique donnée. Sur Intel c'est un seul bit :
// crates/tailslayer-rs-hw/src/strategy/mod.rs
impl ChannelStrategy for BitExtract {
fn compute_channel(&self, phys_addr: u64) -> u32 {
((phys_addr >> self.channel_bit) & 1) as u32
}
fn num_channels(&self) -> usize { 2 }
fn min_channel_offset(&self) -> usize { 1 << self.channel_bit }
}Décalage, masque, terminé. Deux channels, sélectionnés par (en général) le bit 8.
AMD Zen ne fait pas ça. Chaque bit de sélection de channel est la parité de plein de bits d'adresse repliés ensemble par XOR. Le C++ original annonce le support d'AMD, mais il place le channel à un bit et un offset fixes au lieu de calculer le hash XOR. Sur une puce où le channel est réellement replié par XOR, l'extraction de bit fixe ne suit pas le vrai channel, donc deux répliques peuvent atterrir sur le même channel, ce qui ruine le hedge. Le désaccord est entre un modèle à offset fixe et celui d'AMD basé sur XOR, pas un bug net.
Le repli (fold) tient en une seule fonction, partagée entre le mapper de channel et le décomposeur d'adresse :
// crates/tailslayer-rs-hw/src/strategy/mod.rs
pub fn xor_fold(addr: u64, masks: &[u64]) -> u32 {
let mut result = 0u32;
for (i, mask) in masks.iter().enumerate() {
let bit = (addr & mask).count_ones() & 1;
result |= bit << i;
}
result
}Chaque masque donne un bit de sortie, popcount(addr & mask) mod 2. N masques donnent N bits, c'est-à-dire 2^N channels. La stratégie AMD enrobe ça avec un offset physique, parce qu'AMD remonte un bloc de l'espace d'adressage et qu'il faut le soustraire avant de hasher :
// crates/tailslayer-rs-hw/src/strategy/mod.rs
impl ChannelStrategy for XorHash {
fn compute_channel(&self, phys_addr: u64) -> u32 {
let addr = phys_addr.wrapping_sub(self.phys_offset);
xor_fold(addr, &self.masks)
}
fn num_channels(&self) -> usize { 1 << self.masks.len() }
}Le même fold pilote AddressDecomposer, qui va plus loin et découpe une adresse en channel, sub-channel et bank group pour le hedging DDR5 :
// crates/tailslayer-rs-hw/src/mem/addr.rs
pub fn decompose(&self, phys_addr: u64) -> DramAddress {
let addr = phys_addr.wrapping_sub(self.phys_offset);
DramAddress {
channel: xor_fold(addr, &self.channel_masks),
subchannel: xor_fold(addr, &self.subchannel_masks),
bank_group: xor_fold(addr, &self.bank_group_masks),
}
}Les masques sont tout l'enjeu, et ils ne sont pas de moi. Ils sortent de papiers de rétro-ingénierie : ZenHammer (USENIX Security '24) et Sudoku (DRAMSec '25). Voici du Zen 4, DDR5, deux channels, repris directement des tables de Sudoku :
// crates/tailslayer-rs-hw/src/channel.rs
// From Sudoku paper Table 2: Ryzen 9 7950X 2Ch-1DPC
// Channel masks: 0x0000000100 (bit 8), 0x0000080000 (bit 19)
Self::AmdZen4Ddr5_2ch => ChannelMapper::amd(
vec![0x100, 0x80000], // bits 8 and 19
0,
),Deux masques, donc deux bits de sélection de channel, donc quatre index de channel adressables. La mise en garde honnête est dans le code, pas enterrée dans une note de bas de page :
// crates/tailslayer-rs-hw/src/channel.rs
// 12 channels needs 4 mask bits (2^4=16 ⊇ 12).
// WARNING: estimated from Zen 4 patterns. Use --discover on real hardware.
Self::AmdZen5Ddr5_12ch => ChannelMapper::amd(vec![0x100, 0x80000, 0x200, 0x100000], 0),Ces hashs varient selon la version du BIOS, la population des DIMM et le SKU. Un profil correct sur un Ryzen 9 7950X est une supposition sur la carte suivante avec une mémoire différente. C'est la nature du mapping d'adresse rétro-ingénieré, et les masques Zen 5 sont ouvertement étiquetés comme estimés.
Le benchmark ne fait pas aveuglément confiance au profil. Avant de tourner, il traduit l'adresse virtuelle de chaque réplique via /proc/self/pagemap, calcule le channel, et s'arrête si deux répliques entrent en collision :
// bins/hedged-bench/src/main.rs (trimmed)
let phys_i = tailslayer_rs_hw::virt_to_phys(replicas[i] as usize)?;
let ch_i = config.channel_mapper.compute(phys_i);
// ... compare against every other replica ...
if ch_i == ch_j {
eprintln!("ERROR: Replicas {i} and {j} on same channel ({ch_i})!");
std::process::exit(1);
}Si le masque est faux pour ton hardware, tu le découvres ici, et pas après avoir collecté cinq millions d'échantillons bidons.
Ce que la réécriture en Rust a apporté
Le mécanisme est identique au C++. L'histoire de la sûreté, non.
L'original allouait des hugepages avec mmap et munmap, et les libérait à la main dans chaque chemin de sortie. Si tu en oublies un, tu fuis une page lockée de 1 Go. En Rust l'allocation est un type RAII, et Drop est le seul chemin de nettoyage :
// crates/tailslayer-rs-hw/src/lib.rs
impl Drop for HugePage {
fn drop(&mut self) {
unsafe {
libc::munmap(self.ptr as *mut libc::c_void, self.len);
}
}
}Il n'y a pas de « chemin de sortie » à oublier. La page est unmappée quand elle sort du scope, y compris sur la branche d'erreur à l'intérieur d'alloc lui-même, où mlock peut échouer après que mmap a réussi.
Un échec de mmap est un Result, pas un perror suivi d'un assert :
// crates/tailslayer-rs-hw/src/lib.rs (trimmed)
if ptr == libc::MAP_FAILED {
return Err(HwError::HugePageAlloc {
size,
source: std::io::Error::last_os_error(),
});
}Tu ne peux pas lire par accident depuis une page dont l'allocation a échoué. Le système de types ne te laissera pas atteindre le pointeur.
La sûreté entre threads est imposée à la compilation. HugePage est Send mais délibérément pas Sync, et le raisonnement est écrit juste à côté de l'unsafe impl :
// crates/tailslayer-rs-hw/src/lib.rs
// SAFETY: HugePage owns its allocation exclusively. It can be moved between
// threads (Send) because the raw pointer is valid for the struct's lifetime
// and munmap is called in Drop. NOT Sync: concurrent access to the pointer
// from multiple threads requires external synchronization.
unsafe impl Send for HugePage {}Le lecteur hedged joint tous ses threads worker dans Drop avant que la région soit libérée, donc les pointeurs bruts que tiennent les workers ne peuvent jamais survivre à la mémoire qu'ils pointent. Cet ordre est l'invariant sur lequel repose tout le design, et il est encodé dans le destructeur.
Tout l'assembleur inline et les syscalls vivent dans une seule crate, tailslayer-rs-hw. La bibliothèque au-dessus, tailslayer-rs, c'est surtout du Rust ordinaire, mais pas entièrement safe : elle garde le striping de pointeurs bruts dans replica.rs, un read_volatile dans reader.rs, et un unsafe impl Send/Sync. Donc de l'unsafe, il y en a des deux côtés de la frontière de la crate. Ce que la frontière t'achète est plus étroit : chaque instruction qui parle au hardware, l'asm et les syscalls, est d'un seul côté. Si tu veux auditer les parties qui touchent la machine, tu lis une seule crate ; si tu veux auditer tout l'unsafe, tu en lis deux.
Un binaire, deux architectures
La bibliothèque tourne sur x86_64 et aarch64 (Graviton, Ampere) depuis les mêmes sources. Les intrinsics spécifiques à l'architecture sont quatre fonctions derrière cfg, signatures identiques, instructions différentes.
x86_64 utilise rdtsc pour l'horloge, clflush pour évincer, lfence/mfence pour les barrières :
// crates/tailslayer-rs-hw/src/platform/x86_64.rs (trimmed)
pub unsafe fn read_timestamp() -> u64 {
let lo: u32; let hi: u32;
core::arch::asm!(
"lfence", "rdtsc",
out("eax") lo, out("edx") hi,
options(nostack, preserves_flags),
);
((hi as u64) << 32) | (lo as u64)
}
pub unsafe fn cache_flush(addr: *const u8) {
core::arch::asm!("clflush [{addr}]", addr = in(reg) addr,
options(nostack, preserves_flags));
}aarch64 utilise le timer générique cntvct_el0, dc civac pour clean-and-invalidate, et dmb/dsb pour les barrières :
// crates/tailslayer-rs-hw/src/platform/aarch64.rs (trimmed)
pub unsafe fn read_timestamp() -> u64 {
let val: u64;
core::arch::asm!(
"isb", "mrs {val}, cntvct_el0",
val = out(reg) val,
options(nostack, preserves_flags),
);
val
}
pub unsafe fn cache_flush(addr: *const u8) {
core::arch::asm!("dc civac, {addr}", addr = in(reg) addr,
options(nostack, preserves_flags));
}Le code de timed_probe et measurement_loop ci-dessus ne nomme jamais d'architecture. Il appelle read_timestamp, cache_flush, fence_full, et la bonne asm est compilée.
Pourquoi ça ne tournera pas sur la machine où je l'ai écrit
J'ai écrit ça sur un Mac Apple Silicon où ça ne peut pas tourner.
C'est Linux uniquement, par construction. La crate hardware s'ouvre sur compile_error!("tailslayer-rs-hw requires Linux"), donc ça ne compile même pas sur macOS. Au-delà de ça, il lui faut des pages de 1 Go via MAP_HUGETLB, /proc/self/pagemap pour la traduction virtuel-vers-physique, l'épinglage avec sched_setaffinity, et l'ordonnancement temps réel chrt -f 99. Rien de tout ça n'existe sur macOS.
Apple Silicon ferme les deux portes que toute la technique emprunte. D'abord, il n'y a pas d'adresses physiques en userspace : il n'y a pas d'équivalent de /proc/self/pagemap, et SIP garde le mapping physique dans le kernel. Sans adresse physique, le hash de channel n'a rien sur quoi calculer. Ensuite, l'interleave des channels et des banks LPDDR est propriétaire et n'a pas été rétro-ingénieré publiquement. Les travaux ZenHammer et Sudoku sur lesquels ce port s'appuie ne couvrent que la DDR Intel et AMD. Donc même avec une adresse physique, il n'y a aucun hash publié pour la mapper sur un channel.
Quelques autres trucs s'accumulent. Apple Silicon utilise des pages de base de 16 Ko, sans large pages ni superpages. Le timer userspace cntvct_el0 tourne à ~24 MHz (environ 41,7 ns par tick), trop grossier pour chronométrer une seule lecture. Et la LPDDR unifiée utilise le refresh par bank, qui adoucit déjà le stall que toute cette histoire poursuit.
Ce qui est logiquement sans hardware, c'est le calcul pur : les maths de channel, le décomposeur d'adresse, les tables de constantes DDR/LPDDR, et les environ 40 tests qui les exercent. Rien de tout ça ne touche la machine. Mais « sans hardware » ne veut pas dire « ça compile sur un Mac ». Ce compile_error! se déclenche sur n'importe quelle cible non-Linux, donc tel quel rien de la crate ne compile ici du tout, --no-default-features ou pas. Pour faire réellement tourner le cœur pur hors de Linux, il faudrait d'abord lever le gate Linux et régler les dépendances de test no_std. Tant que tu ne le fais pas, c'est sans hardware en principe et incompilable sur un Mac en pratique.
Tu pourrais aussi écrire une démo grossière, un petit programme à part que tu écris toi-même, pas la crate : mach_absolute_time (ou cntvct_el0) plus sys_dcache_flush() pour tracer une distribution de latence de lecture, au repos versus sous un stresseur de bande passante mémoire, ce qui montre qu'une queue existe. Ça ne peut pas faire de hedging conscient du channel, parce que tu ne peux pas savoir sur quel channel atterrit une adresse. C'est observer la queue de la DRAM sur Apple Silicon, pas la tuer.
Ce que je ne prétendrais pas
C'est un port. Le mécanisme est celui de LaurieWired, les masques AMD viennent des papiers, et les profils Zen 5 et Oxide sont des suppositions extrapolées que le code lui-même signale comme estimées. Si tu le lances sur un SKU que je n'ai pas vu, le hash de channel peut tout simplement être faux, et le contrôle de collision te le dira.
Ce que je construirais ensuite, c'est la partie que j'ai laissée sous forme de flags CLI plutôt que de code qui marche : un vrai --auto-detect qui dérive les masques sur la machine au lieu de les chercher dans une table, et un --verify qui prouve que les répliques sont sur des channels différents avec un test de timing plutôt que de faire confiance à /proc/self/pagemap plus un profil. Tant que ça n'existe pas, traite chaque profil comme une hypothèse que le hardware a le droit de rejeter.