memra_engine/pp.rs
1//! M2 pipeline-parallel N-stage runtime (generalizes the M1 2-stage seam).
2//!
3//! Door: `MEMRA_PP_STAGES=N` (default OFF — unset/0/1 = no behavior change anywhere).
4//! Stage map: N stages over the trunk layers with N-1 cuts. `MEMRA_PP_SPLITS=c1,..,cN-1`
5//! sets the cuts explicitly (strictly increasing, in (0, n_layers)); `MEMRA_PP_SPLIT=<i>`
6//! is the N=2 back-compat spelling; default = even split (cut s = s*n_layers/N).
7//! Placement: `MEMRA_PP_DEVICES=d0,..,dN-1` maps stage s to device ds (default: all on
8//! the primary engine's device).
9//!
10//! M1 history (increments 1-2, merged + hardened on the 8x box 2026-08-02): seam + gate
11//! single-device; then real transport — per-stage streams/events, device placement,
12//! peer-copy boundary (M0: cudaMemcpyPeerAsync beats NCCL 2.8x at PP activation sizes),
13//! per-context PDL module caches, default-mempool peer grants. All five r3 gates PASS
14//! bit-identical (receipts ~/receipts/m1-pp2/ on darklanes-bench).
15//!
16//! M2 increment 1 (this file): N-STAGE GENERALIZATION — `Pp2Rt` becomes `PpNRt`:
17//! - `stages`: Vec of per-stage execution homes (device, context, stream, remote Engine);
18//! - `boundaries`: N-1 boundary runtimes, each with TWO persistent double-buffered slots
19//! (ev_tx/ev_rx per slot) and its own overlap step counter; transport is selected PER
20//! BOUNDARY (dtod same-device / cudaMemcpyPeerAsync cross-device);
21//! - peer + default-mempool access is granted between EVERY distinct pair of devices in
22//! use (stage devices + the primary): stage kernels may dereference the primary's
23//! weights (bring-up placement) and stage-0's pos_d, and each boundary peer-copies.
24//!
25//! M2 increment 2 (weight sharding): the loader uploads each stage's layer range THROUGH
26//! that stage's engine (`layer_engine`), so weights land on the device that runs them —
27//! the bring-up peer-read placement dies. `output_norm` + lm head load through the LAST
28//! stage's engine; the embed table stays host-side with stage 0. Split-plane/f16 decode
29//! mirrors are built per layer through the owning stage's engine too (the rp4 mirrors ARE
30//! the decode weights on the q8 path — leaving them on dev0 would fake the kill).
31//! Rollback seam: `MEMRA_PP_SHARD=0` = M1 bring-up placement (all weights on primary,
32//! remote stages peer-read).
33//!
34//! M2 increment 3 (deferred readback — the pipelining seed): `PendingLogits` — the eager
35//! decode arm can END a step without the logits D2H (`decode_step_h_ppn_deferred`): the
36//! logits stay device-resident with a completion event; `wait()` drains them through a
37//! DEDICATED readback stream (waits the event, copies, syncs) so tokens t+1.. keep
38//! enqueuing on the stage streams while token t drains. Per-token math is fully
39//! event-ordered (same slots, same ev_tx/ev_rx chain) — scheduling changes, math does
40//! not; the pipelined replay arm of `ppn-gate` proves bit-identity per step.
41//!
42//! Ownership across a boundary (unchanged from M1):
43//! - hidden state [n_embd] f32 is the ONLY tensor that crosses;
44//! - KV/linear-attn cache entries are per-layer: stage s exclusively owns cache state
45//! for its layer range (and, under MEMRA_PP_DEVICES, allocates it on its device);
46//! - position/rope state is the scalar `cache.pos` snapshot taken once per step, uploaded
47//! on stage-0's stream BEFORE the first TX event — every later stage's wait chain
48//! transitively orders it (stage s waits boundary s-1's ev_tx, which was recorded after
49//! stage s-1's work, which waited boundary s-2's ev_tx, ... back to stage 0);
50//! - the embed table lives with stage 0, output_norm + lm head with the last stage.
51//!
52//! THE MULTI-STREAM LAW (why this is safe with cudarc event tracking disabled): all
53//! cross-stage bytes flow through the persistent boundary slots, ordered by ev_tx/ev_rx;
54//! per-stage scratch is allocated AND freed on that stage's stream (stream-ordered); the
55//! async mem pool runs with opportunistic reuse OFF + internal dependencies ON
56//! (memra-runtime), so a block freed on stream A and reused on stream B carries a
57//! driver-inserted dependency. Weights are load-time state no stage stream can precede,
58//! and the step's terminal logits readback (sync D2H, or PendingLogits' event-ordered
59//! readback stream) drains the last stage, whose TX-wait chain transitively drains all.
60//!
61//! Scope: plain eager decode only (generic arm N-stage; gemma4 arm 2-stage). NOT wired:
62//! batch/dc/graph/spec loops and the gemma4-E4B eager arm.
63//!
64//! CORRECTION (pp2-hardening 2026-08-06): this header used to add "(`warn_unwired_once`
65//! fires)" to that list, which was wrong. `warn_unwired_once` has exactly two call sites
66//! and BOTH are gemma4-specific (decode.rs, hybrid_forward.rs) — the batch/dc/graph/spec
67//! loops never warned. Worse, the batched loop did not merely run unsplit: it walked the
68//! whole trunk on the primary stream and, under a sharded cross-device placement,
69//! peer-read every remote stage's weights each step — 28x slower at B=1 with all three
70//! `decode-batch-gate` gates PASSING (peer reads are byte-exact, so only perf broke).
71//! `decode_step_batch` now FAILS CLOSED in that regime via `pp_sharded_cross_device()`
72//! (`MEMRA_PP_ALLOW_UNSPLIT_BATCH=1` = measurement override). "Unwired" for dc/graph/spec
73//! still means "runs unsplit, silently" — audit each before trusting it on a pair.
74
75use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
76use std::sync::{Arc, Mutex, OnceLock};
77
78use cudarc::driver::{CudaContext, CudaEvent, CudaSlice, CudaStream};
79
80use crate::Engine;
81
82/// Returns the stage fence iff the ppN door is open: `MEMRA_PP_STAGES=N` (N >= 2) with a
83/// valid cut list. The fence has N+1 entries: `[0, c1, .., cN-1, n_layers]`; stage s runs
84/// layers `[fence[s], fence[s+1])`. Reads the environment on every call (gates toggle the
85/// door in-process); the cost is a few getenv per decode step, eager-loop noise.
86pub fn pp_cuts(n_layers: usize) -> Option<Vec<usize>> {
87 let n_st: usize = match std::env::var("MEMRA_PP_STAGES") {
88 Ok(v) if v.is_empty() || v == "0" || v == "1" => return None,
89 Ok(v) => match v.parse::<usize>() {
90 Ok(n) => n,
91 Err(_) => {
92 warn_bad_once(&format!("MEMRA_PP_STAGES={v} unparseable; door stays OFF"));
93 return None;
94 }
95 },
96 Err(_) => return None,
97 };
98 if n_st < 2 || n_st > n_layers {
99 warn_bad_once(&format!(
100 "MEMRA_PP_STAGES={n_st} outside [2, n_layers={n_layers}]; door stays OFF"
101 ));
102 return None;
103 }
104 let mut fence = Vec::with_capacity(n_st + 1);
105 fence.push(0usize);
106 if let Ok(s) = std::env::var("MEMRA_PP_SPLITS") {
107 let parts: Result<Vec<usize>, _> =
108 s.split(',').map(|p| p.trim().parse::<usize>()).collect();
109 match parts {
110 Ok(cuts) if cuts.len() == n_st - 1 => fence.extend(cuts),
111 _ => {
112 warn_bad_once(&format!(
113 "MEMRA_PP_SPLITS={s} invalid (want {} comma-separated cuts); door stays OFF",
114 n_st - 1
115 ));
116 return None;
117 }
118 }
119 } else if let Ok(v) = std::env::var("MEMRA_PP_SPLIT") {
120 // N=2 back-compat spelling. With N>2 a single split is ambiguous — fail the door
121 // loudly rather than guess (a silent even-split would fake a gate config).
122 if n_st != 2 {
123 warn_bad_once(&format!(
124 "MEMRA_PP_SPLIT={v} set with MEMRA_PP_STAGES={n_st}; use MEMRA_PP_SPLITS \
125 for N>2 — door stays OFF"
126 ));
127 return None;
128 }
129 match v.parse::<usize>() {
130 Ok(c) => fence.push(c),
131 Err(_) => {
132 warn_bad_once(&format!("MEMRA_PP_SPLIT={v} unparseable; door stays OFF"));
133 return None;
134 }
135 }
136 } else {
137 for s in 1..n_st {
138 fence.push(s * n_layers / n_st);
139 }
140 }
141 fence.push(n_layers);
142 for w in fence.windows(2) {
143 if w[0] >= w[1] {
144 warn_bad_once(&format!(
145 "pp stage fence {fence:?} not strictly increasing over [0, {n_layers}]; \
146 door stays OFF"
147 ));
148 return None;
149 }
150 }
151 Some(fence)
152}
153
154/// N=2 back-compat view of the door (the gemma4 arm and `pp2-gate` are 2-stage): `Some(cut)`
155/// iff the door is open with EXACTLY two stages.
156pub fn pp2_split(n_layers: usize) -> Option<usize> {
157 pp_cuts(n_layers).filter(|f| f.len() == 3).map(|f| f[1])
158}
159
160/// The stage that owns layer `il` under `fence` (see `pp_cuts`).
161pub fn stage_of(fence: &[usize], il: usize) -> usize {
162 debug_assert!(fence.len() >= 2);
163 match fence[1..fence.len() - 1].binary_search(&il) {
164 // fence[1..][k] == il means il is the FIRST layer of stage k+1
165 Ok(k) => k + 1,
166 Err(k) => k,
167 }
168}
169
170/// MEMRA_PP_STREAMS=0: rollback to the increment-1 same-stream seam (boundary = two plain
171/// dtod copies on the ambient compute stream, no per-stage streams/events/devices).
172pub fn pp2_streams_off() -> bool {
173 matches!(std::env::var("MEMRA_PP_STREAMS").as_deref(), Ok("0"))
174}
175
176/// True iff the ppN door would put TWO OR MORE stage streams on ONE device (devices
177/// unset = all stages on the primary; or an explicit placement with a repeated device).
178/// The deferred-readback (pipelined) arm is REFUSED in this regime: the 2026-08-02 x20
179/// soak record — singledev pipelined 13/20 PASS default, 7 failures each diverging at a
180/// different step (timing-race signature); MEMRA_PDL=0 went 20/20 on one soak but a
181/// second same-config soak on the auto-gated build failed 2/20 (n2) and battery-4 failed
182/// n4 — so PDL narrows the window without closing it, and the true root cause (same
183/// Engine kernels concurrent on two streams of one device) is NOT fixed by any flag yet.
184/// Cross-device pipelined (one stage stream per device) is 23/23 clean post-fix. Refuse
185/// loudly rather than return silently-wrong logits. Env-only read (callable pre-runtime).
186pub fn pp_multi_stream_same_device() -> bool {
187 let stages_open = std::env::var("MEMRA_PP_STAGES")
188 .map(|v| v.parse::<usize>().map(|n| n >= 2).unwrap_or(false))
189 .unwrap_or(false);
190 let devices = std::env::var("MEMRA_PP_DEVICES").ok().filter(|v| !v.is_empty());
191 if (!stages_open && devices.is_none()) || pp2_streams_off() {
192 return false;
193 }
194 match devices {
195 None => true, // door open, no placement: every stage stream lands on the primary
196 Some(s) => {
197 let mut v: Vec<&str> = s.split(',').map(|p| p.trim()).collect();
198 let n = v.len();
199 v.sort_unstable();
200 v.dedup();
201 v.len() < n // repeated device = shared-device streams
202 }
203 }
204}
205
206/// True iff the ppN door is open AND the placement spans 2+ DISTINCT devices AND the
207/// per-stage sharded loader is on — i.e. some layers' weights live on a device other than
208/// the primary. Any path that walks the WHOLE trunk on one stream in this regime reads
209/// those weights over PCIe every step. Env-only read (callable pre-runtime).
210///
211/// Measured cost of doing that (pp2-hardening 2026-08-06, 2x RTX PRO 6000, PCIe Gen5 x16
212/// P2P, decode-batch-bench q9, N=5 interleaved, `research/pp2-hardening-20260806`):
213/// **B=1 7.4 vs 208.9 tok/s (28x), B=4 29.8 vs 491.3 (16.5x), B=8 47.4 vs 657.0 (13.9x)**.
214/// The same sweep with `MEMRA_PP_SHARD=0` (weights all home) returns 178.5/491.1/656.6 —
215/// identical to the single-device door-open arm — so the entire cliff is the peer read,
216/// not the door and not the placement plumbing. Exactness is NOT the issue: peer reads
217/// return identical bytes and every `decode-batch-gate` gate PASSED on this config, which
218/// is precisely why it needs a refusal rather than a gate.
219pub fn pp_sharded_cross_device() -> bool {
220 let stages_open = std::env::var("MEMRA_PP_STAGES")
221 .map(|v| v.parse::<usize>().map(|n| n >= 2).unwrap_or(false))
222 .unwrap_or(false);
223 // MEMRA_PP_STREAMS=0 (2026-08-06, pp2-batch): the same-stream rollback seam ALSO turns
224 // the sharded loader off — `layer_engine` returns the primary engine whenever
225 // `pp2_streams_off()`, and `new_cache` skips `Cache::new_ppn` on the same condition. So
226 // in that regime every weight and every cache is home on the primary and an unsplit walk
227 // peer-reads NOTHING. Without this term the guard refused that config too: a spurious
228 // refusal of a placement that is sound and full-speed. Found wiring the batched pp arm.
229 if !stages_open || pp_shard_off() || pp2_streams_off() {
230 return false;
231 }
232 match pp2_devices_env() {
233 None => false, // no placement: every stage is the primary device, nothing remote
234 Some(s) => {
235 let mut v: Vec<&str> = s.split(',').map(|p| p.trim()).collect();
236 v.sort_unstable();
237 v.dedup();
238 v.len() >= 2
239 }
240 }
241}
242
243/// The shared fail-closed guard for EVERY decode path that has no pp stage split.
244/// Returns `Err` iff `pp_sharded_cross_device()` — i.e. the caller would walk the whole
245/// trunk on one stream while some layers' weights live on another device, peer-reading
246/// them every step. `path` names the refusing function so the operator knows which loop
247/// they hit; `alt` names the working alternative for that loop.
248///
249/// One helper rather than four copies because the audit found FOUR paths with the same
250/// hole (`decode_step_batch`, `decode_step_dc`, the graph capture that wraps dc, and
251/// `decode_step_t*` verify), and a per-path copy is how one gets missed on the next
252/// addition. Override: `MEMRA_PP_ALLOW_UNSPLIT_BATCH=1` (one door for all of them —
253/// they are the same measurement question).
254pub fn refuse_unsplit_if_remote(path: &str, alt: &str) -> Result<(), Box<dyn std::error::Error>> {
255 if pp_sharded_cross_device()
256 && std::env::var("MEMRA_PP_ALLOW_UNSPLIT_BATCH").as_deref() != Ok("1")
257 {
258 return Err(format!(
259 "{path}: refused with the ppN door open across 2+ devices — this path has no pp \
260 stage split, so it would walk ALL layers on one stream and peer-read every \
261 remote stage's weights each step (measured 28x slower at B=1, 13.9x at B=8 on \
262 a PRO 6000 pair over PCIe Gen5 x16 P2P; research/pp2-hardening-20260806). \
263 Exactness is unaffected — peer reads return identical bytes and the exactness \
264 gates PASS on this config — which is exactly why it must refuse instead of \
265 being caught by a gate. Fixes, in order: {alt}; or MEMRA_PP_SHARD=0 (all \
266 weights home on the primary — full speed, forfeits the capacity PP-2 exists \
267 for); or close the pp door. MEMRA_PP_ALLOW_UNSPLIT_BATCH=1 overrides for \
268 measurement."
269 )
270 .into());
271 }
272 Ok(())
273}
274
275/// MEMRA_BATCH_PP=0: rollback/A-B seam for the BATCHED stage split (pp2-batch 2026-08-06).
276/// Default ON — with the ppN door open the batched decode step takes its own stage split
277/// (`decode_step_batch_ppn`) exactly as the eager step does. Setting 0 sends the batched
278/// path back through the unsplit body, which under a sharded cross-device placement is
279/// then caught by `refuse_unsplit_if_remote` (the 28x peer-read regime) rather than run
280/// silently. Exists so the bit-identity gate can A/B split vs unsplit IN ONE PROCESS
281/// against the same loaded weights — read per step, never memoized, for that reason.
282pub fn batch_pp_on() -> bool {
283 std::env::var("MEMRA_BATCH_PP").as_deref() != Ok("0")
284}
285
286/// MEMRA_PRIME_PP=0: rollback/A-B seam for the PRIME (chunked prefill) stage split
287/// (lane/pp-leverb 2026-08-08). Default ON — with the ppN door open the chunked prime takes
288/// its own per-stage range walk exactly as the eager/batched/verify steps do. Setting 0 sends
289/// prime back through the unsplit whole-trunk walk. NOTE: unlike batch/dc/graph/spec, prime
290/// keeps NO `refuse_unsplit_if_remote` — its unsplit walk over a sharded placement is the
291/// measured 22% amortized peer-read tax (research/pp-prefill-20260807 anatomy: m=4096
292/// amortizes the weight reads), not the decode 28x cliff, and the unsplit walk IS the
293/// split-vs-unsplit gate's reference arm (`prime-split-gate`), so it must stay callable.
294/// Read per call, never memoized (the gate A/Bs both arms in one process).
295pub fn prime_pp_on() -> bool {
296 std::env::var("MEMRA_PRIME_PP").as_deref() != Ok("0")
297}
298
299/// SPLIT-LIVENESS COUNTER for the prime stage split: bumped ONCE per prime chunk that
300/// actually executed the per-stage walk. The `prime-split-gate` requires this to ADVANCE
301/// during its split arm — bit-identity of two identical UNSPLIT walks is vacuous, so a gate
302/// that only compared bits would go green while the walker doesn't exist. With the counter,
303/// the gate is RED until the walker lands (the tickinv35 pattern: the gate exists and fails
304/// before the mechanism does). Relaxed ordering: single-threaded host issue, count-only.
305pub static PRIME_SPLIT_CHUNKS: AtomicUsize = AtomicUsize::new(0);
306
307/// Read the split-liveness counter (gate-side).
308pub fn prime_split_chunks() -> usize {
309 PRIME_SPLIT_CHUNKS.load(Ordering::Relaxed)
310}
311
312/// MEMRA_SPEC_PP=0: rollback/A-B seam for the SPEC VERIFY stage split (pp2-spec 2026-08-06).
313/// Default ON — with the ppN door open the verify forward (`decode_step_t_core_ppn`) takes its
314/// own stage split exactly as the eager and batched steps do. Setting 0 sends verify back through
315/// the unsplit trunk walk, which under a sharded cross-device placement is then caught by
316/// `refuse_unsplit_if_remote` (the 28x peer-read regime) rather than running silently. Exists so
317/// the bit-identity gate can A/B split vs unsplit IN ONE PROCESS against the same loaded weights
318/// — read per verify call, never memoized, for that reason.
319pub fn spec_pp_on() -> bool {
320 std::env::var("MEMRA_SPEC_PP").as_deref() != Ok("0")
321}
322
323/// MEMRA_PP_OVERLAP=1: alternate the double-buffered boundary slots per step (the
324/// pipelining seed). Default OFF — scheduling structure only, never math. Read per step
325/// so gates can A/B in-process.
326pub fn pp2_overlap() -> bool {
327 matches!(std::env::var("MEMRA_PP_OVERLAP").as_deref(), Ok("1"))
328}
329
330/// M2 increment 2 rollback seam: MEMRA_PP_SHARD=0 = the M1 bring-up placement (all
331/// weights upload through the primary engine; remote stages peer-read). Default ON —
332/// under MEMRA_PP_DEVICES each stage's layer range uploads through its own engine.
333pub fn pp_shard_off() -> bool {
334 matches!(std::env::var("MEMRA_PP_SHARD").as_deref(), Ok("0"))
335}
336
337/// Raw `MEMRA_PP_DEVICES` (parsed/validated at PpNRt build — a bad string must fail the
338/// decode step loudly, never silently fall back to same-device and fake a gate PASS).
339fn pp2_devices_env() -> Option<String> {
340 std::env::var("MEMRA_PP_DEVICES").ok().filter(|v| !v.is_empty())
341}
342
343static WARNED_BAD: AtomicBool = AtomicBool::new(false);
344fn warn_bad_once(msg: &str) {
345 if !WARNED_BAD.swap(true, Ordering::Relaxed) {
346 eprintln!("[pp] {msg}");
347 }
348}
349
350static WARNED_UNWIRED: AtomicBool = AtomicBool::new(false);
351/// One-time notice when the door is set but the executing path has no pp arm
352/// (M2 wires the generic eager decode at any N and the gemma4 eager arm at N=2).
353pub fn warn_unwired_once(path: &str) {
354 let open = std::env::var("MEMRA_PP_STAGES")
355 .map(|v| !v.is_empty() && v != "0" && v != "1")
356 .unwrap_or(false);
357 if open && !WARNED_UNWIRED.swap(true, Ordering::Relaxed) {
358 eprintln!(
359 "[pp] MEMRA_PP_STAGES set but `{path}` has no pp arm at this N; running unsplit"
360 );
361 }
362}
363
364// ======================================================================================
365// PpNRt: the M2 transport runtime (per-stage streams, per-boundary events + slots)
366// ======================================================================================
367
368/// One pipeline stage's execution home: device, context, launch stream, and (for a stage
369/// remote to the primary engine's device) a dedicated Engine in that device's primary
370/// context (CUmodules are per-context).
371pub struct StageRt {
372 pub dev: usize,
373 pub ctx: Arc<CudaContext>,
374 pub stream: Arc<CudaStream>,
375 /// `Some` only when `dev` differs from the primary engine's device.
376 engine: Option<Engine>,
377}
378
379/// One boundary slot: a persistent RX-side buffer + its TX/RX completion events.
380/// PERSISTENT because the buffer is written by the TX stage's stream and read by the RX
381/// stage's: a per-step alloc/free would enqueue the free on ONE stream while the other
382/// might still be reading (the cross-stream free hazard) — a never-freed slot cannot race.
383struct BoundarySlot {
384 buf: Mutex<Option<CudaSlice<f32>>>,
385 /// Recorded on the TX stage's stream after the TX copy; RX waits on it. Created in
386 /// the TX stage's context (cuEventRecord requires event ctx == stream ctx).
387 ev_tx: CudaEvent,
388 /// Recorded on the RX stage's stream after the RX copy; the NEXT TX into this slot
389 /// waits on it (write-after-read guard). Created in the RX stage's context. Waiting
390 /// on a never-recorded event is a defined no-op, so step 0 needs no special case.
391 ev_rx: CudaEvent,
392}
393
394/// Boundary b sits between stage b (TX) and stage b+1 (RX). Two slots, alternating per
395/// step under MEMRA_PP_OVERLAP=1 (each boundary counts its own steps — a decode step
396/// crosses every boundary exactly once, so the counters stay in lockstep).
397struct BoundaryRt {
398 slots: [BoundarySlot; 2],
399 step: AtomicUsize,
400 /// true iff stage b and stage b+1 live on different devices (peer transport).
401 cross: bool,
402}
403
404pub struct PpNRt {
405 stages: Vec<StageRt>,
406 boundaries: Vec<BoundaryRt>,
407 /// true iff ANY boundary crosses devices.
408 cross_any: bool,
409 /// Dedicated readback stream in the LAST stage's context (deferred logits D2H —
410 /// waiting there instead of on the compute stream keeps later tokens enqueuable).
411 readback: Arc<CudaStream>,
412}
413
414/// M1 name kept alive for external callers (`pp-transport-smoke`, receipts, docs).
415pub type Pp2Rt = PpNRt;
416
417static RTN: OnceLock<Result<PpNRt, String>> = OnceLock::new();
418
419impl PpNRt {
420 /// The process-wide transport runtime, built on first use against the primary engine.
421 /// The stage count + device map freeze at first build (one config per process — gates
422 /// run one placement per invocation). Build errors are sticky and loud.
423 pub fn get(e: &Engine) -> Result<&'static PpNRt, Box<dyn std::error::Error>> {
424 RTN.get_or_init(|| Self::build(e).map_err(|err| err.to_string()))
425 .as_ref()
426 .map_err(|s| -> Box<dyn std::error::Error> { s.clone().into() })
427 }
428
429 fn build(e: &Engine) -> Result<PpNRt, Box<dyn std::error::Error>> {
430 let primary_dev = e.ctx().ordinal();
431 // Stage count: MEMRA_PP_DEVICES length wins when set (it IS the placement);
432 // else MEMRA_PP_STAGES; else 2 (the M1 default — pp-transport-smoke runs doorless).
433 let devices: Vec<usize> = match pp2_devices_env() {
434 Some(s) => {
435 let parts: Result<Vec<usize>, _> =
436 s.split(',').map(|p| p.trim().parse::<usize>()).collect();
437 match parts {
438 Ok(v) if v.len() >= 2 => v,
439 _ => {
440 return Err(format!(
441 "MEMRA_PP_DEVICES={s} unparseable (want <d0>,..,<dN-1> e.g. 0,1,2,3)"
442 )
443 .into())
444 }
445 }
446 }
447 None => {
448 let n_st = std::env::var("MEMRA_PP_STAGES")
449 .ok()
450 .and_then(|v| v.parse::<usize>().ok())
451 .filter(|&n| n >= 2)
452 .unwrap_or(2);
453 vec![primary_dev; n_st]
454 }
455 };
456 if let Ok(v) = std::env::var("MEMRA_PP_STAGES") {
457 if let Ok(n) = v.parse::<usize>() {
458 if n >= 2 && n != devices.len() {
459 return Err(format!(
460 "MEMRA_PP_DEVICES lists {} devices but MEMRA_PP_STAGES={n} — \
461 refusing an ambiguous placement",
462 devices.len()
463 )
464 .into());
465 }
466 }
467 }
468 let n_st = devices.len();
469 let cross_any = devices.iter().any(|&d| d != devices[0]);
470
471 // Every distinct device pair in use must peer-access BOTH ways: boundaries copy
472 // between consecutive stages, stage kernels may dereference primary-device weights
473 // (bring-up placement / MEMRA_PP_SHARD=0) and stage-0's pos_d upload.
474 let mut used: Vec<usize> = devices.clone();
475 used.push(primary_dev);
476 used.sort_unstable();
477 used.dedup();
478 if used.len() > 1 {
479 let n = cudarc::driver::result::device::get_count()? as usize;
480 for &d in &used {
481 if d >= n {
482 return Err(format!(
483 "MEMRA_PP_DEVICES={devices:?} but only {n} CUDA device(s) present"
484 )
485 .into());
486 }
487 }
488 for &a in &used {
489 for &b in &used {
490 if a == b {
491 continue;
492 }
493 let da = cudarc::driver::result::device::get(a as i32)?;
494 let db = cudarc::driver::result::device::get(b as i32)?;
495 let mut can: i32 = 0;
496 unsafe {
497 cudarc::driver::sys::cuDeviceCanAccessPeer(&mut can, da, db).result()?;
498 }
499 if can == 0 {
500 return Err(format!(
501 "device {a} cannot peer-access device {b} (cuDeviceCanAccessPeer=0); \
502 ppN cross-device needs P2P — refusing a silently-staged path"
503 )
504 .into());
505 }
506 }
507 }
508 }
509
510 // PER-STAGE ENGINE ISOLATION (2026-08-02 singledev pipelined find): Engine owns
511 // lazily-grown SHARED scratch pools (fa_part_pool, fa_vf16_scratch, argmax
512 // partials, ...) that are stable-pointer by design — safe on one stream, a data
513 // race the moment two stage streams run concurrently through the SAME Engine
514 // (deferred readback, >=2 tokens in flight: token t+1's stage-0 fa memsets the
515 // partials while token t's stage-s fa still reads them — the nondeterministic
516 // all-logits divergence; cross-device arms were immune because remote stages
517 // already got their own Engine). Every stage s>0 gets its OWN Engine even on the
518 // primary device: same CUcontext (primary retain), so the per-context CUmodule
519 // cache makes it cheap; scratch pools are per-Engine, so stages never share.
520 // Stage 0 keeps the primary engine (single-threaded host issue: the only
521 // concurrent user of `e` during a pp walk is stage 0 itself).
522 let mk_stage = |dev: usize, s: usize| -> Result<StageRt, Box<dyn std::error::Error>> {
523 if dev == primary_dev && s == 0 {
524 let ctx = e.ctx().clone();
525 let stream = ctx.new_stream()?;
526 Ok(StageRt { dev, ctx, stream, engine: None })
527 } else {
528 let eng = Engine::new(dev)?;
529 let ctx = eng.ctx().clone();
530 let stream = ctx.new_stream()?;
531 Ok(StageRt { dev, ctx, stream, engine: Some(eng) })
532 }
533 };
534 let mut stages = Vec::with_capacity(n_st);
535 for (s, &d) in devices.iter().enumerate() {
536 stages.push(mk_stage(d, s)?);
537 }
538
539 if used.len() > 1 {
540 // A context per distinct device (first stage that lives there; the primary's
541 // context for the primary device).
542 let ctx_of = |d: usize| -> &Arc<CudaContext> {
543 if d == primary_dev {
544 e.ctx()
545 } else {
546 &stages.iter().find(|s| s.dev == d).unwrap().ctx
547 }
548 };
549 // Enable peer access BOTH ways for every distinct pair (idempotent;
550 // ALREADY_ENABLED is success).
551 for &a in &used {
552 for &b in &used {
553 if a == b {
554 continue;
555 }
556 ctx_of(a).bind_to_thread()?;
557 let rc = unsafe {
558 cudarc::driver::sys::cuCtxEnablePeerAccess(ctx_of(b).cu_ctx(), 0)
559 };
560 use cudarc::driver::sys::cudaError_enum as E;
561 if rc != E::CUDA_SUCCESS && rc != E::CUDA_ERROR_PEER_ACCESS_ALREADY_ENABLED {
562 return Err(format!(
563 "cuCtxEnablePeerAccess(dev{a} -> dev{b}) failed: {rc:?}"
564 )
565 .into());
566 }
567 }
568 }
569 // MEM-POOL access grant (8x box 2026-08-02, M1 cross-device fix #2):
570 // cuCtxEnablePeerAccess does NOT map STREAM-ORDERED POOL allocations, and every
571 // engine buffer/weight goes through the device default pool (cuMemAllocAsync via
572 // cudarc; memra-runtime configures that pool). A stage kernel dereferencing
573 // another device's weights — or a boundary peer TX writing the RX slot — needs
574 // cuMemPoolSetAccess on the OWNING device's default pool for the ACCESSING
575 // device; without it the first remote dereference is CUDA_ERROR_ILLEGAL_ADDRESS
576 // (reported at the next API call in the poisoned context). Grant all pairs.
577 for &owner in &used {
578 for &accessor in &used {
579 if owner == accessor {
580 continue;
581 }
582 let dev = cudarc::driver::result::device::get(owner as i32)?;
583 let mut pool: cudarc::driver::sys::CUmemoryPool = std::ptr::null_mut();
584 unsafe {
585 cudarc::driver::sys::cuDeviceGetDefaultMemPool(&mut pool, dev).result()?;
586 }
587 let desc = cudarc::driver::sys::CUmemAccessDesc {
588 location: cudarc::driver::sys::CUmemLocation {
589 type_: cudarc::driver::sys::CUmemLocationType::CU_MEM_LOCATION_TYPE_DEVICE,
590 id: accessor as i32,
591 },
592 flags: cudarc::driver::sys::CUmemAccess_flags::CU_MEM_ACCESS_FLAGS_PROT_READWRITE,
593 };
594 let rc = unsafe { cudarc::driver::sys::cuMemPoolSetAccess(pool, &desc, 1) };
595 if rc != cudarc::driver::sys::cudaError_enum::CUDA_SUCCESS {
596 return Err(format!(
597 "cuMemPoolSetAccess(dev{owner} pool -> dev{accessor}) failed: {rc:?}"
598 )
599 .into());
600 }
601 }
602 }
603 // MEM-POOL access grant (8x box 2026-08-02, cross-device fix #2):
604 // cuCtxEnablePeerAccess does NOT map STREAM-ORDERED POOL allocations, and every
605 // engine buffer/weight goes through the device default pool (cuMemAllocAsync via
606 // cudarc; memra-runtime configures that pool). A stage-1 kernel dereferencing
607 // dev0 weights — or the stage-0 peer TX writing dev1's RX slot — needs
608 // cuMemPoolSetAccess on the OWNING device's default pool for the ACCESSING
609 // device; without it the first remote dereference is CUDA_ERROR_ILLEGAL_ADDRESS
610 // (reported at the next API call in the poisoned context). Grant both ways.
611 for (owner, accessor) in [(stages[0].dev, stages[1].dev), (stages[1].dev, stages[0].dev)] {
612 let dev = cudarc::driver::result::device::get(owner as i32)?;
613 let mut pool: cudarc::driver::sys::CUmemoryPool = std::ptr::null_mut();
614 unsafe {
615 cudarc::driver::sys::cuDeviceGetDefaultMemPool(&mut pool, dev).result()?;
616 }
617 let desc = cudarc::driver::sys::CUmemAccessDesc {
618 location: cudarc::driver::sys::CUmemLocation {
619 type_: cudarc::driver::sys::CUmemLocationType::CU_MEM_LOCATION_TYPE_DEVICE,
620 id: accessor as i32,
621 },
622 flags: cudarc::driver::sys::CUmemAccess_flags::CU_MEM_ACCESS_FLAGS_PROT_READWRITE,
623 };
624 let rc = unsafe { cudarc::driver::sys::cuMemPoolSetAccess(pool, &desc, 1) };
625 if rc != cudarc::driver::sys::cudaError_enum::CUDA_SUCCESS {
626 return Err(format!(
627 "cuMemPoolSetAccess(dev{owner} pool -> dev{accessor}) failed: {rc:?}"
628 )
629 .into());
630 }
631 }
632 // restore the primary context for the caller's subsequent work
633 e.ctx().bind_to_thread()?;
634 eprintln!(
635 "[pp] cross-device transport: {} (cudaMemcpyPeerAsync per cross boundary; \
636 peer + default-pool access granted all pairs over {used:?}; weight home: {})",
637 devices
638 .iter()
639 .enumerate()
640 .map(|(s, d)| format!("stage{s}=dev{d}"))
641 .collect::<Vec<_>>()
642 .join(" "),
643 if pp_shard_off() {
644 format!("dev{primary_dev} (MEMRA_PP_SHARD=0 bring-up placement)")
645 } else {
646 "per-stage (sharded loader)".to_string()
647 }
648 );
649 }
650
651 let mk_slot = |tx: &StageRt, rx: &StageRt| -> Result<BoundarySlot, Box<dyn std::error::Error>> {
652 Ok(BoundarySlot {
653 buf: Mutex::new(None),
654 ev_tx: tx.ctx.new_event(None)?,
655 ev_rx: rx.ctx.new_event(None)?,
656 })
657 };
658 let mut boundaries = Vec::with_capacity(n_st - 1);
659 for b in 0..n_st - 1 {
660 let (tx, rx) = (&stages[b], &stages[b + 1]);
661 boundaries.push(BoundaryRt {
662 slots: [mk_slot(tx, rx)?, mk_slot(tx, rx)?],
663 step: AtomicUsize::new(0),
664 cross: tx.dev != rx.dev,
665 });
666 }
667 let readback = stages[n_st - 1].ctx.new_stream()?;
668 Ok(PpNRt { stages, boundaries, cross_any, readback })
669 }
670
671 pub fn n_stages(&self) -> usize {
672 self.stages.len()
673 }
674
675 /// True iff any boundary crosses devices (transport = cudaMemcpyPeerAsync there).
676 pub fn cross_device(&self) -> bool {
677 self.cross_any
678 }
679
680 /// The engine a stage's subgraph must run through: the primary engine when the stage
681 /// lives on the primary device, else the stage's own (remote-context) engine.
682 pub fn engine<'a>(&'a self, s: usize, primary: &'a Engine) -> &'a Engine {
683 self.stages[s].engine.as_ref().unwrap_or(primary)
684 }
685
686 /// Enter stage `s`: until the guard drops, every engine op on this thread launches on
687 /// the stage's stream (memra_runtime ambient-stream override).
688 pub fn enter(&self, s: usize) -> memra_runtime::StreamOverride {
689 memra_runtime::push_stream_override(self.stages[s].stream.clone())
690 }
691
692 /// Boundary TX at boundary `b` (call within the stage-`b` scope; `x` = the
693 /// materialized [n] residual): wait for the slot's previous RX (write-after-read
694 /// guard), copy `x` into the slot's persistent buffer via the boundary's transport on
695 /// stage-b's stream (the owning-stream/publication law), record ev_tx. Returns the
696 /// slot index for the paired rx().
697 ///
698 /// `n` is the PAYLOAD ELEMENT COUNT, not a fixed model constant: the eager arm passes
699 /// `n_embd` (one row), the batched arm passes `b_n * n_embd` (B stacked rows, the
700 /// [B, n_embd] boundary). The slot buffer is GROW-ONLY and the transport moves exactly
701 /// the first `n` elements — batched serving changes B every tick (chunk fill), and a
702 /// realloc-on-every-size-change would host-sync the RX stream per width change (see the
703 /// SLOT FIRST-USE ORDERING note below for why each allocation needs that sync). Growing
704 /// to the high-water mark makes the syncs O(distinct widths) instead of O(width changes).
705 pub fn tx(&self, b: usize, x: &CudaSlice<f32>, n: usize)
706 -> Result<usize, Box<dyn std::error::Error>> {
707 assert_eq!(x.len(), n, "pp tx: residual length mismatch");
708 let bd = &self.boundaries[b];
709 let slot_idx = if pp2_overlap() {
710 bd.step.fetch_add(1, Ordering::Relaxed) % 2
711 } else {
712 0
713 };
714 let sl = &bd.slots[slot_idx];
715 let s_tx = &self.stages[b].stream;
716 s_tx.wait(&sl.ev_rx)?;
717 let mut guard = sl.buf.lock().unwrap();
718 if guard.as_ref().map(|bf| bf.len() < n).unwrap_or(true) {
719 // allocated on the RX stage's stream: the buffer lives on the RX device.
720 let s_rx = &self.stages[b + 1].stream;
721 *guard = Some(s_rx.alloc_zeros::<f32>(n)?);
722 // SLOT FIRST-USE ORDERING (2026-08-02 pipelined-gate find): the lazy alloc's
723 // pool-alloc + memset enqueue on the RX stream; the TX copy below issues on
724 // the TX stream, and on a slot's FIRST use ev_rx has never been recorded —
725 // nothing orders them. With >=2 tokens in flight the RX stream is still busy
726 // with the previous token, the memset lands AFTER the TX copy, and the
727 // boundary residual is zeroed (window=1 passed, window>=2 failed at the
728 // slot-1 first-use step; -overlap arms passed because the synchronous serial
729 // arm pre-warmed both slots). Host-sync the RX stream once per slot
730 // allocation — at most 2*(N-1) one-time syncs per process, all during prime.
731 s_rx.synchronize()?;
732 }
733 let buf = guard.as_mut().unwrap();
734 if !bd.cross {
735 s_tx.memcpy_dtod(x, buf)?;
736 } else {
737 // cudaMemcpyPeerAsync (M0: 2.8x NCCL at PP activation sizes), issued on the
738 // publishing TX stream with explicit src/dst contexts.
739 use cudarc::driver::{DevicePtr, DevicePtrMut};
740 let (sp, _g0) = x.device_ptr(s_tx);
741 let (dp, _g1) = buf.device_ptr_mut(s_tx);
742 self.stages[b].ctx.bind_to_thread()?;
743 unsafe {
744 cudarc::driver::result::memcpy_peer_async(
745 self.stages[b + 1].ctx.cu_ctx(),
746 dp,
747 self.stages[b].ctx.cu_ctx(),
748 sp,
749 n * std::mem::size_of::<f32>(),
750 s_tx.cu_stream(),
751 )?;
752 }
753 }
754 sl.ev_tx.record(s_tx)?;
755 Ok(slot_idx)
756 }
757
758 /// Boundary RX at boundary `b` (call within the stage-`b+1` scope): wait on the slot's
759 /// ev_tx, copy the boundary buffer into a fresh working buffer (dtod on the RX stream —
760 /// local on the RX device in both transports), record ev_rx. The returned buffer is
761 /// RX-stage-owned: allocated, consumed, and eventually freed on that stage's stream.
762 pub fn rx(&self, b: usize, slot_idx: usize, n: usize)
763 -> Result<CudaSlice<f32>, Box<dyn std::error::Error>> {
764 let sl = &self.boundaries[b].slots[slot_idx];
765 let s_rx = &self.stages[b + 1].stream;
766 s_rx.wait(&sl.ev_tx)?;
767 let guard = sl.buf.lock().unwrap();
768 let buf = guard.as_ref().expect("pp rx before tx");
769 assert!(buf.len() >= n, "pp rx: slot holds {} < requested {n}", buf.len());
770 // uninit working buffer (fully overwritten by the copy), allocated explicitly on
771 // the stage stream so rx() is correct even outside an enter() scope.
772 let mut work = unsafe { s_rx.alloc::<f32>(n)? };
773 // Slice the slot to the payload: the buffer is grow-only (see tx), so at a narrower
774 // width it is LONGER than `work` and cudarc's memcpy_dtod (dst.len() >= src.len())
775 // would assert. The paired tx wrote exactly these first n elements.
776 s_rx.memcpy_dtod(&buf.slice(0..n), &mut work)?;
777 sl.ev_rx.record(s_rx)?;
778 Ok(work)
779 }
780
781 /// PUBLISH a DEVICE-RESIDENT result off the last stage to the caller's stream
782 /// (lane/pp2-spec 2026-08-06).
783 ///
784 /// Every ppN body before this one returned HOST values — `decode_step_h_ppn` and
785 /// `decode_step_batch_ppn` both `dtoh` inside the last-stage scope, and a dtoh on the
786 /// producing stream is self-ordering. The verify trunk is the FIRST ppN body whose
787 /// contract is device-resident output (`decode_step_t_h_emb_dev` exists precisely so the
788 /// accept walk argmaxes on-device instead of moving T x n_vocab f32 per round), and
789 /// device slices carry no stream affinity: the caller resumes on the PRIMARY stream and
790 /// dereferences buffers whose producing kernels are still queued on the last stage's
791 /// stream. Nothing orders them.
792 ///
793 /// Why this only ever failed on ONE device: with stages on separate devices the caller's
794 /// first touch is a cross-device copy that the driver orders against the source context,
795 /// and the readback path syncs. Two streams on the SAME device genuinely overlap, so the
796 /// primary stream reads a buffer whose matmul has not run — nondeterministic garbage
797 /// (measured: NaN, 3155.677, and 2.87e-5 where the reference had -2.0048926), and it
798 /// poisons the NEXT arm in the same process because the corrupted KV persists. This is
799 /// the same class as the SLOT FIRST-USE ORDERING find above, one level up: there the
800 /// unordered pair was alloc-memset vs TX copy, here it is stage-N compute vs the
801 /// caller's consumer.
802 ///
803 /// Fix = the boundary law applied to the exit: record an event on the producing stage
804 /// stream, make the caller's stream wait on it. Event-wait, not a device sync, so the
805 /// stage streams keep running for the deferred-readback arm. Call INSIDE the last-stage
806 /// scope, after the last enqueue, with the caller's (pre-`enter`) stream.
807 pub fn publish_to(&self, s: usize, dst: &Arc<CudaStream>)
808 -> Result<(), Box<dyn std::error::Error>> {
809 let st = &self.stages[s];
810 // Same stream (STREAMS=0 rollback, or a caller already on the stage stream): the
811 // stream orders itself; recording+waiting would be a no-op with a stray event.
812 if Arc::ptr_eq(&st.stream, dst) {
813 return Ok(());
814 }
815 let ev = st.ctx.new_event(None)?;
816 ev.record(&st.stream)?;
817 dst.wait(&ev)?;
818 Ok(())
819 }
820
821 /// REVERSE PUBLICATION (#87 root cause, lane/pp2spec-crash 2026-08-07): order every
822 /// STAGE stream behind the CALLER's stream — the mirror of `publish_to`.
823 ///
824 /// `publish_to` orders caller READS behind stage COMPUTE. Nothing ordered the other
825 /// direction: buffers ALLOCATED on a stage stream (the verify's returned logits/hidden,
826 /// the VerifyCkpt stashes) are CONSUMED by kernels the caller enqueues on the PRIMARY
827 /// stream, and when they drop, cudarc enqueues `free_async` on the ALLOCATING (stage)
828 /// stream. With event tracking elided (the decode-path default) the drop carries no
829 /// read-guard, so the pool can hand the block to the NEXT stage-stream allocation and
830 /// its writes overwrite memory the queued primary-stream consumer has not read yet.
831 /// Measured (research/pp2spec-crash-20260807): the spec round-seed read 13/4096 NaN =
832 /// the uninitialized-bits signature (P(NaN|random u32) ~ 1/256), clean by host re-read
833 /// time — a read-before-write race, fatal via the argmax-sentinel -> embed_gather MMU
834 /// fault, and gated on c>=2 because a backed-up primary stream widens the window.
835 ///
836 /// Fix law: before a ppN body enqueues NEW stage-stream work (allocations that may
837 /// reuse freed blocks), every stage stream waits the caller's stream at its current
838 /// point. All primary consumers of the previous round's stage-allocated buffers are
839 /// enqueued by then (single host thread), so reuse-writes land strictly after them.
840 /// Call at ppN-body ENTRY with the pre-`enter` caller stream. Door-shut configs never
841 /// build a PpNRt, so single-card behavior is untouched.
842 pub fn fence_stages_behind(&self, src: &Arc<CudaStream>)
843 -> Result<(), Box<dyn std::error::Error>> {
844 let ev = src.context().new_event(None)?;
845 ev.record(src)?;
846 for st in &self.stages {
847 if Arc::ptr_eq(&st.stream, src) {
848 continue;
849 }
850 st.stream.wait(&ev)?;
851 }
852 Ok(())
853 }
854
855 /// Deferred readback: record a fresh completion event on the LAST stage's stream
856 /// (call after the step's logits matmul has been enqueued there).
857 pub fn record_done(&self) -> Result<CudaEvent, Box<dyn std::error::Error>> {
858 let last = &self.stages[self.stages.len() - 1];
859 let ev = last.ctx.new_event(None)?;
860 ev.record(&last.stream)?;
861 Ok(ev)
862 }
863
864 /// The dedicated readback stream (last stage's context).
865 pub fn readback_stream(&self) -> &Arc<CudaStream> {
866 &self.readback
867 }
868}
869
870/// M2 increment 3: a step's logits, still device-resident on the LAST stage. `wait()`
871/// orders the readback stream behind the step's completion event, copies, and syncs —
872/// tokens enqueued after this step keep running on the stage streams while the caller
873/// drains token t. Dropping without waiting is safe (buffers free stream-ordered).
874pub struct PendingLogits {
875 logits: CudaSlice<f32>,
876 ev: CudaEvent,
877 rb: Arc<CudaStream>,
878}
879
880impl PendingLogits {
881 pub fn new(logits: CudaSlice<f32>, ev: CudaEvent, rb: Arc<CudaStream>) -> Self {
882 PendingLogits { logits, ev, rb }
883 }
884
885 /// Blocks until this step's logits are computed, returns them host-side. Only this
886 /// step's work is waited on (event-ordered) — NOT later tokens already enqueued on
887 /// the stage streams.
888 pub fn wait(self) -> Result<Vec<f32>, Box<dyn std::error::Error>> {
889 self.rb.wait(&self.ev)?;
890 let host = self.rb.clone_dtoh(&self.logits)?;
891 self.rb.synchronize()?;
892 // logits drop AFTER the sync: the D2H has fully completed, so the stream-ordered
893 // free on the compute stream cannot race the copy.
894 Ok(host)
895 }
896}
897
898/// Stage-owned cache allocation door: when the ppN door is open AND `MEMRA_PP_DEVICES`
899/// is set (placement plumbing), each layer's cache is allocated by its OWNING stage's
900/// engine — on one device this is byte-for-byte today's allocation (gated); cross-device
901/// it puts each stage's KV on that stage's HBM. Door shut or devices unset: plain
902/// `Cache::new` (zero behavior change). Trailing MTP/NextN layers (beyond the trunk)
903/// map to the LAST stage.
904pub fn new_cache(e: &Engine, cfg: &memra_gguf::config::ModelConfig, max_ctx: usize)
905 -> Result<crate::cache::Cache, Box<dyn std::error::Error>> {
906 let n_trunk = (cfg.n_layer - cfg.nextn_predict_layers) as usize;
907 if let Some(fence) = pp_cuts(n_trunk) {
908 if pp2_devices_env().is_some() && !pp2_streams_off() {
909 let rt = PpNRt::get(e)?;
910 let n_st = fence.len() - 1;
911 assert_eq!(
912 rt.n_stages(), n_st,
913 "PpNRt stage count {} != fence stages {n_st}", rt.n_stages()
914 );
915 // #87 REVERSE PUBLICATION at ADMISSION (lane/pp2spec-crash): this is the one
916 // stage-stream allocation site OUTSIDE the ppN step bodies — a NEW session's
917 // KV alloc_zeros enqueue on the STAGE streams, and their pool blocks can be
918 // reuse of buffers freed from ANOTHER session's in-flight verify whose
919 // primary-stream reads are still queued (the c=2 residual: exactly one trap
920 // per admission collision, round 0, after the step-body fences landed).
921 // Order the stage streams behind the caller before the memsets can clobber.
922 // Anatomy: `PpNRt::fence_stages_behind`.
923 rt.fence_stages_behind(&e.stream())?;
924 let devs: Vec<&dyn memra_kv::KvDev> =
925 (0..n_st).map(|s| rt.engine(s, e) as &dyn memra_kv::KvDev).collect();
926 let cache = crate::cache::Cache::new_ppn(&devs, &fence, cfg, max_ctx)?;
927 sync_stages_after_load(e, n_trunk)?;
928 return Ok(cache);
929 }
930 if !pp2_streams_off() {
931 // CACHE BIRTH BARRIER (2026-08-02 pipelined-arm residual race): with the door
932 // open but no device placement, Cache::new's alloc_zeros memsets enqueue on
933 // the PRIMARY worker stream while the first KV appends / recurrent-state
934 // reads run on the per-stage streams — no event orders them, and under
935 // deferred readback the stage streams are hot immediately (a memset tail
936 // can zero an already-appended KV row; intermittent, ~1-in-3 gate FAIL).
937 // One context-sync per cache creation kills the class.
938 let cache = crate::cache::Cache::new(e, cfg, max_ctx)?;
939 sync_stages_after_load(e, n_trunk)?;
940 return Ok(cache);
941 }
942 }
943 crate::cache::Cache::new(e, cfg, max_ctx)
944}
945
946/// M2 increment 2 LOAD BARRIER: weight uploads and decode-mirror builds enqueue on the
947/// loading engines' WORKER streams; the first consumer launches on a DIFFERENT stream
948/// with no load->decode event — the door-off reference walk on the primary worker
949/// stream (sharded load: remote builds still in flight), or a fresh per-stage stream.
950/// The 2026-08-02 gate finds (n2-dev01 step-0 168k-logit graze; split5 ref=0.0 head —
951/// a half-built rp4 mirror — poisoning step-0 KV and every later step): one
952/// context-wide synchronize per stage at load end kills the class. No-op when the door
953/// is shut at load (single-stream load+decode is ordered by the stream itself).
954pub fn sync_stages_after_load(e: &Engine, n_trunk: usize)
955 -> Result<(), Box<dyn std::error::Error>> {
956 if pp2_streams_off() || pp_cuts(n_trunk).is_none() {
957 return Ok(());
958 }
959 let rt = PpNRt::get(e)?;
960 for s in 0..rt.n_stages() {
961 rt.stages[s].ctx.bind_to_thread()?;
962 unsafe {
963 cudarc::driver::sys::cuCtxSynchronize().result()?;
964 }
965 }
966 e.ctx().bind_to_thread()?;
967 unsafe {
968 cudarc::driver::sys::cuCtxSynchronize().result()?;
969 }
970 Ok(())
971}
972
973/// M2 increment 2 (weight sharding): the engine that should UPLOAD layer `il`'s weights
974/// (and build its decode mirrors) — the owning stage's engine when the door is open with
975/// device placement and sharding not rolled back; else the primary. `il >= n_trunk`
976/// (MTP/NextN blocks) maps to the last stage. The head (output_norm + lm head) belongs
977/// to the last trunk layer's stage — call with `il = n_trunk - 1`.
978pub fn layer_engine<'a>(e: &'a Engine, n_trunk: usize, il: usize)
979 -> Result<&'a Engine, Box<dyn std::error::Error>> {
980 if pp_shard_off() || pp2_devices_env().is_none() || pp2_streams_off() {
981 return Ok(e);
982 }
983 let Some(fence) = pp_cuts(n_trunk) else { return Ok(e) };
984 let rt = PpNRt::get(e)?;
985 let s = stage_of(&fence, il.min(n_trunk - 1));
986 Ok(rt.engine(s, e))
987}