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 by default; opt-in
21//! `MEMRA_PP_HOST_BOUNCE=1` uses pinned D2H + H2D instead);
22//! - the default peer transport grants peer + default-mempool access between EVERY distinct
23//! pair of devices in use. Host bounce skips those grants and requires sharded weights plus
24//! stage-local auxiliary buffers so no mapped peer read can bypass the bounced boundary.
25//!
26//! M2 increment 2 (weight sharding): the loader uploads each stage's layer range THROUGH
27//! that stage's engine (`layer_engine`), so weights land on the device that runs them —
28//! the bring-up peer-read placement dies. `output_norm` + lm head load through the LAST
29//! stage's engine; the embed table stays host-side with stage 0. Split-plane/f16 decode
30//! mirrors are built per layer through the owning stage's engine too (the rp4 mirrors ARE
31//! the decode weights on the q8 path — leaving them on dev0 would fake the kill).
32//! Rollback seam: `MEMRA_PP_SHARD=0` = M1 bring-up placement (all weights on primary,
33//! remote stages peer-read).
34//!
35//! M2 increment 3 (deferred readback — the pipelining seed): `PendingLogits` — the eager
36//! decode arm can END a step without the logits D2H (`decode_step_h_ppn_deferred`): the
37//! logits stay device-resident with a completion event; `wait()` drains them through a
38//! DEDICATED readback stream (waits the event, copies, syncs) so tokens t+1.. keep
39//! enqueuing on the stage streams while token t drains. Per-token math is fully
40//! event-ordered (same slots, same ev_tx/ev_rx chain) — scheduling changes, math does
41//! not; the pipelined replay arm of `ppn-gate` proves bit-identity per step.
42//!
43//! Ownership across a boundary (unchanged from M1):
44//! - hidden state [n_embd] f32 is the ONLY tensor that crosses;
45//! - KV/linear-attn cache entries are per-layer: stage s exclusively owns cache state
46//! for its layer range (and, under MEMRA_PP_DEVICES, allocates it on its device);
47//! - position/rope state is the scalar `cache.pos` snapshot taken once per step; every stage
48//! uploads its own position buffer on its own stream (no cross-device position pointer);
49//! - the embed table lives with stage 0, output_norm + lm head with the last stage.
50//!
51//! THE MULTI-STREAM LAW (why this is safe with cudarc event tracking disabled): all
52//! cross-stage bytes flow through the persistent boundary slots, ordered by ev_tx/ev_rx;
53//! per-stage scratch is allocated AND freed on that stage's stream (stream-ordered); the
54//! async mem pool runs with opportunistic reuse OFF + internal dependencies ON
55//! (memra-runtime), so a block freed on stream A and reused on stream B carries a
56//! driver-inserted dependency. Weights are load-time state no stage stream can precede,
57//! and the step's terminal logits readback (sync D2H, or PendingLogits' event-ordered
58//! readback stream) drains the last stage, whose TX-wait chain transitively drains all.
59//!
60//! Scope: plain eager decode only (generic arm N-stage; gemma4 arm 2-stage). NOT wired:
61//! batch/dc/graph/spec loops and the gemma4-E4B eager arm.
62//!
63//! CORRECTION (pp2-hardening 2026-08-06): this header used to add "(`warn_unwired_once`
64//! fires)" to that list, which was wrong. `warn_unwired_once` has exactly two call sites
65//! and BOTH are gemma4-specific (decode.rs, hybrid_forward.rs) — the batch/dc/graph/spec
66//! loops never warned. Worse, the batched loop did not merely run unsplit: it walked the
67//! whole trunk on the primary stream and, under a sharded cross-device placement,
68//! peer-read every remote stage's weights each step — 28x slower at B=1 with all three
69//! `decode-batch-gate` gates PASSING (peer reads are byte-exact, so only perf broke).
70//! `decode_step_batch` now FAILS CLOSED in that regime via `pp_sharded_cross_device()`
71//! (`MEMRA_PP_ALLOW_UNSPLIT_BATCH=1` = measurement override). "Unwired" for dc/graph/spec
72//! still means "runs unsplit, silently" — audit each before trusting it on a pair.
73
74use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
75use std::sync::{Arc, Mutex, OnceLock};
76
77use cudarc::driver::{CudaContext, CudaEvent, CudaSlice, CudaStream};
78
79use crate::Engine;
80
81/// Returns the stage fence iff the ppN door is open: `MEMRA_PP_STAGES=N` (N >= 2) with a
82/// valid cut list. The fence has N+1 entries: `[0, c1, .., cN-1, n_layers]`; stage s runs
83/// layers `[fence[s], fence[s+1])`. Reads the environment on every call (gates toggle the
84/// door in-process); the cost is a few getenv per decode step, eager-loop noise.
85pub fn pp_cuts(n_layers: usize) -> Option<Vec<usize>> {
86 let n_st: usize = match std::env::var("MEMRA_PP_STAGES") {
87 Ok(v) if v.is_empty() || v == "0" || v == "1" => return None,
88 Ok(v) => match v.parse::<usize>() {
89 Ok(n) => n,
90 Err(_) => {
91 warn_bad_once(&format!("MEMRA_PP_STAGES={v} unparseable; door stays OFF"));
92 return None;
93 }
94 },
95 Err(_) => return None,
96 };
97 if n_st < 2 || n_st > n_layers {
98 warn_bad_once(&format!(
99 "MEMRA_PP_STAGES={n_st} outside [2, n_layers={n_layers}]; door stays OFF"
100 ));
101 return None;
102 }
103 let mut fence = Vec::with_capacity(n_st + 1);
104 fence.push(0usize);
105 if let Ok(s) = std::env::var("MEMRA_PP_SPLITS") {
106 let parts: Result<Vec<usize>, _> =
107 s.split(',').map(|p| p.trim().parse::<usize>()).collect();
108 match parts {
109 Ok(cuts) if cuts.len() == n_st - 1 => fence.extend(cuts),
110 _ => {
111 warn_bad_once(&format!(
112 "MEMRA_PP_SPLITS={s} invalid (want {} comma-separated cuts); door stays OFF",
113 n_st - 1
114 ));
115 return None;
116 }
117 }
118 } else if let Ok(v) = std::env::var("MEMRA_PP_SPLIT") {
119 // N=2 back-compat spelling. With N>2 a single split is ambiguous — fail the door
120 // loudly rather than guess (a silent even-split would fake a gate config).
121 if n_st != 2 {
122 warn_bad_once(&format!(
123 "MEMRA_PP_SPLIT={v} set with MEMRA_PP_STAGES={n_st}; use MEMRA_PP_SPLITS \
124 for N>2 — door stays OFF"
125 ));
126 return None;
127 }
128 match v.parse::<usize>() {
129 Ok(c) => fence.push(c),
130 Err(_) => {
131 warn_bad_once(&format!("MEMRA_PP_SPLIT={v} unparseable; door stays OFF"));
132 return None;
133 }
134 }
135 } else {
136 for s in 1..n_st {
137 fence.push(s * n_layers / n_st);
138 }
139 }
140 fence.push(n_layers);
141 for w in fence.windows(2) {
142 if w[0] >= w[1] {
143 warn_bad_once(&format!(
144 "pp stage fence {fence:?} not strictly increasing over [0, {n_layers}]; \
145 door stays OFF"
146 ));
147 return None;
148 }
149 }
150 Some(fence)
151}
152
153/// N=2 back-compat view of the door (the gemma4 arm and `pp2-gate` are 2-stage): `Some(cut)`
154/// iff the door is open with EXACTLY two stages.
155pub fn pp2_split(n_layers: usize) -> Option<usize> {
156 pp_cuts(n_layers).filter(|f| f.len() == 3).map(|f| f[1])
157}
158
159/// The stage that owns layer `il` under `fence` (see `pp_cuts`).
160pub fn stage_of(fence: &[usize], il: usize) -> usize {
161 debug_assert!(fence.len() >= 2);
162 match fence[1..fence.len() - 1].binary_search(&il) {
163 // fence[1..][k] == il means il is the FIRST layer of stage k+1
164 Ok(k) => k + 1,
165 Err(k) => k,
166 }
167}
168
169/// MEMRA_PP_STREAMS=0: rollback to the increment-1 same-stream seam (boundary = two plain
170/// dtod copies on the ambient compute stream, no per-stage streams/events/devices).
171pub fn pp2_streams_off() -> bool {
172 matches!(std::env::var("MEMRA_PP_STREAMS").as_deref(), Ok("0"))
173}
174
175/// True iff the ppN door would put TWO OR MORE stage streams on ONE device (devices
176/// unset = all stages on the primary; or an explicit placement with a repeated device).
177/// The deferred-readback (pipelined) arm is REFUSED in this regime: the 2026-08-02 x20
178/// soak record — singledev pipelined 13/20 PASS default, 7 failures each diverging at a
179/// different step (timing-race signature); MEMRA_PDL=0 went 20/20 on one soak but a
180/// second same-config soak on the auto-gated build failed 2/20 (n2) and battery-4 failed
181/// n4 — so PDL narrows the window without closing it, and the true root cause (same
182/// Engine kernels concurrent on two streams of one device) is NOT fixed by any flag yet.
183/// Cross-device pipelined (one stage stream per device) is 23/23 clean post-fix. Refuse
184/// loudly rather than return silently-wrong logits. Env-only read (callable pre-runtime).
185pub fn pp_multi_stream_same_device() -> bool {
186 let stages_open = std::env::var("MEMRA_PP_STAGES")
187 .map(|v| v.parse::<usize>().map(|n| n >= 2).unwrap_or(false))
188 .unwrap_or(false);
189 let devices = std::env::var("MEMRA_PP_DEVICES").ok().filter(|v| !v.is_empty());
190 if (!stages_open && devices.is_none()) || pp2_streams_off() {
191 return false;
192 }
193 match devices {
194 None => true, // door open, no placement: every stage stream lands on the primary
195 Some(s) => {
196 let mut v: Vec<&str> = s.split(',').map(|p| p.trim()).collect();
197 let n = v.len();
198 v.sort_unstable();
199 v.dedup();
200 v.len() < n // repeated device = shared-device streams
201 }
202 }
203}
204
205/// True iff the ppN door is open AND the placement spans 2+ DISTINCT devices AND the
206/// per-stage sharded loader is on — i.e. some layers' weights live on a device other than
207/// the primary. Any path that walks the WHOLE trunk on one stream in this regime reads
208/// those weights over PCIe every step. Env-only read (callable pre-runtime).
209///
210/// Measured cost of doing that (pp2-hardening 2026-08-06, 2x RTX PRO 6000, PCIe Gen5 x16
211/// P2P, decode-batch-bench q9, N=5 interleaved, `research/pp2-hardening-20260806`):
212/// **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)**.
213/// The same sweep with `MEMRA_PP_SHARD=0` (weights all home) returns 178.5/491.1/656.6 —
214/// identical to the single-device door-open arm — so the entire cliff is the peer read,
215/// not the door and not the placement plumbing. Exactness is NOT the issue: peer reads
216/// return identical bytes and every `decode-batch-gate` gate PASSED on this config, which
217/// is precisely why it needs a refusal rather than a gate.
218pub fn pp_sharded_cross_device() -> bool {
219 let stages_open = std::env::var("MEMRA_PP_STAGES")
220 .map(|v| v.parse::<usize>().map(|n| n >= 2).unwrap_or(false))
221 .unwrap_or(false);
222 // MEMRA_PP_STREAMS=0 (2026-08-06, pp2-batch): the same-stream rollback seam ALSO turns
223 // the sharded loader off — `layer_engine` returns the primary engine whenever
224 // `pp2_streams_off()`, and `new_cache` skips `Cache::new_ppn` on the same condition. So
225 // in that regime every weight and every cache is home on the primary and an unsplit walk
226 // peer-reads NOTHING. Without this term the guard refused that config too: a spurious
227 // refusal of a placement that is sound and full-speed. Found wiring the batched pp arm.
228 if !stages_open || pp_shard_off() || pp2_streams_off() {
229 return false;
230 }
231 match pp2_devices_env() {
232 None => false, // no placement: every stage is the primary device, nothing remote
233 Some(s) => {
234 let mut v: Vec<&str> = s.split(',').map(|p| p.trim()).collect();
235 v.sort_unstable();
236 v.dedup();
237 v.len() >= 2
238 }
239 }
240}
241
242/// The shared fail-closed guard for EVERY decode path that has no pp stage split.
243/// Returns `Err` iff `pp_sharded_cross_device()` — i.e. the caller would walk the whole
244/// trunk on one stream while some layers' weights live on another device, peer-reading
245/// them every step. `path` names the refusing function so the operator knows which loop
246/// they hit; `alt` names the working alternative for that loop.
247///
248/// One helper rather than four copies because the audit found FOUR paths with the same
249/// hole (`decode_step_batch`, `decode_step_dc`, the graph capture that wraps dc, and
250/// `decode_step_t*` verify), and a per-path copy is how one gets missed on the next
251/// addition. Override: `MEMRA_PP_ALLOW_UNSPLIT_BATCH=1` (one door for all of them —
252/// they are the same measurement question).
253pub fn refuse_unsplit_if_remote(path: &str, alt: &str) -> Result<(), Box<dyn std::error::Error>> {
254 if pp_host_bounce_active() {
255 return Err(format!(
256 "{path}: refused with MEMRA_PP_HOST_BOUNCE=1 on sharded cross-device PP — \
257 this unsplit path peer-reads remote weights, while host bounce covers only \
258 explicit stage-boundary transfers. Use {alt}; the \
259 MEMRA_PP_ALLOW_UNSPLIT_BATCH override is unavailable on a broken-peer host."
260 )
261 .into());
262 }
263 if pp_sharded_cross_device()
264 && std::env::var("MEMRA_PP_ALLOW_UNSPLIT_BATCH").as_deref() != Ok("1")
265 {
266 return Err(format!(
267 "{path}: refused with the ppN door open across 2+ devices — this path has no pp \
268 stage split, so it would walk ALL layers on one stream and peer-read every \
269 remote stage's weights each step (measured 28x slower at B=1, 13.9x at B=8 on \
270 a PRO 6000 pair over PCIe Gen5 x16 P2P; research/pp2-hardening-20260806). \
271 Exactness is unaffected — peer reads return identical bytes and the exactness \
272 gates PASS on this config — which is exactly why it must refuse instead of \
273 being caught by a gate. Fixes, in order: {alt}; or MEMRA_PP_SHARD=0 (all \
274 weights home on the primary — full speed, forfeits the capacity PP-2 exists \
275 for); or close the pp door. MEMRA_PP_ALLOW_UNSPLIT_BATCH=1 overrides for \
276 measurement."
277 )
278 .into());
279 }
280 Ok(())
281}
282
283/// MEMRA_BATCH_PP=0: rollback/A-B seam for the BATCHED stage split (pp2-batch 2026-08-06).
284/// Default ON — with the ppN door open the batched decode step takes its own stage split
285/// (`decode_step_batch_ppn`) exactly as the eager step does. Setting 0 sends the batched
286/// path back through the unsplit body, which under a sharded cross-device placement is
287/// then caught by `refuse_unsplit_if_remote` (the 28x peer-read regime) rather than run
288/// silently. Exists so the bit-identity gate can A/B split vs unsplit IN ONE PROCESS
289/// against the same loaded weights — read per step, never memoized, for that reason.
290pub fn batch_pp_on() -> bool {
291 std::env::var("MEMRA_BATCH_PP").as_deref() != Ok("0")
292}
293
294/// MEMRA_PRIME_PP=0: rollback/A-B seam for the PRIME (chunked prefill) stage split
295/// (lane/pp-leverb 2026-08-08). Default ON — with the ppN door open the chunked prime takes
296/// its own per-stage range walk exactly as the eager/batched/verify steps do. Setting 0 sends
297/// prime back through the unsplit whole-trunk walk. NOTE: unlike batch/dc/graph/spec, prime
298/// keeps NO `refuse_unsplit_if_remote` — its unsplit walk over a sharded placement is the
299/// measured 22% amortized peer-read tax (research/pp-prefill-20260807 anatomy: m=4096
300/// amortizes the weight reads), not the decode 28x cliff, and the unsplit walk IS the
301/// split-vs-unsplit gate's reference arm (`prime-split-gate`), so it must stay callable.
302/// Read per call, never memoized (the gate A/Bs both arms in one process).
303pub fn prime_pp_on() -> bool {
304 std::env::var("MEMRA_PRIME_PP").as_deref() != Ok("0")
305}
306
307/// MEMRA_PRIME_PIPE=0: rollback/A-B seam for the PP-2 PRIME CHUNK PIPELINE
308/// (lane/cx-pipeline-prime 2026-08-08). Default ON when the prime stage split is live;
309/// setting 0 keeps the serial per-chunk stage walk. Read per prime call so the exactness
310/// gate can replay both schedules against one loaded model.
311pub fn prime_pipe_on() -> bool {
312 std::env::var("MEMRA_PRIME_PIPE").as_deref() != Ok("0")
313}
314
315/// SPLIT-LIVENESS COUNTER for the prime stage split: bumped ONCE per prime chunk that
316/// actually executed the per-stage walk. The `prime-split-gate` requires this to ADVANCE
317/// during its split arm — bit-identity of two identical UNSPLIT walks is vacuous, so a gate
318/// that only compared bits would go green while the walker doesn't exist. With the counter,
319/// the gate is RED until the walker lands (the tickinv35 pattern: the gate exists and fails
320/// before the mechanism does). Relaxed ordering: single-threaded host issue, count-only.
321pub static PRIME_SPLIT_CHUNKS: AtomicUsize = AtomicUsize::new(0);
322
323/// Read the split-liveness counter (gate-side).
324pub fn prime_split_chunks() -> usize {
325 PRIME_SPLIT_CHUNKS.load(Ordering::Relaxed)
326}
327
328/// PIPELINE-LIVENESS COUNTER: bumped only when a second PP-2 prime stage enters its layer
329/// walker while the other stage's walker is still active. Step's per-layer router readback
330/// synchronizes the host, so enqueue order alone is not liveness: a single host thread can
331/// call stage 0(N+1) before the stage-1 epilogue and still serialize all trunk computation.
332pub static PRIME_PIPE_OVERLAPS: AtomicUsize = AtomicUsize::new(0);
333
334/// Read the prime-pipeline overlap counter (gate-side).
335pub fn prime_pipe_overlaps() -> usize {
336 PRIME_PIPE_OVERLAPS.load(Ordering::Relaxed)
337}
338
339static PRIME_PIPE_ACTIVE_STAGES: AtomicUsize = AtomicUsize::new(0);
340
341pub(crate) struct PrimePipeStageGuard;
342
343/// Mark one host-driven stage walker active. With PP-2, a transition 1 -> 2 proves the
344/// two device walkers overlap in wall time; exactly one transition is counted per pair.
345pub(crate) fn enter_prime_pipe_stage() -> PrimePipeStageGuard {
346 let active = PRIME_PIPE_ACTIVE_STAGES.fetch_add(1, Ordering::AcqRel);
347 if active > 0 {
348 PRIME_PIPE_OVERLAPS.fetch_add(1, Ordering::Relaxed);
349 }
350 PrimePipeStageGuard
351}
352
353impl Drop for PrimePipeStageGuard {
354 fn drop(&mut self) {
355 let active = PRIME_PIPE_ACTIVE_STAGES.fetch_sub(1, Ordering::AcqRel);
356 debug_assert!(active > 0, "prime pipeline active-stage counter underflow");
357 }
358}
359
360/// Step35 cross-request prime liveness counters (lane/cx-prime-batch, 2026-08-08).
361/// The exactness gate requires BOTH to advance: a successful step35 batch alone is not
362/// sufficient under PP-N if it walked the whole sharded trunk on one stream.
363pub static STEP35_PRIME_BATCHES: AtomicUsize = AtomicUsize::new(0);
364pub static STEP35_PRIME_BATCH_SPLITS: AtomicUsize = AtomicUsize::new(0);
365
366pub fn step35_prime_batches() -> usize {
367 STEP35_PRIME_BATCHES.load(Ordering::Relaxed)
368}
369
370pub fn step35_prime_batch_splits() -> usize {
371 STEP35_PRIME_BATCH_SPLITS.load(Ordering::Relaxed)
372}
373
374/// MEMRA_SPEC_PP=0: rollback/A-B seam for the SPEC VERIFY stage split (pp2-spec 2026-08-06).
375/// Default ON — with the ppN door open the verify forward (`decode_step_t_core_ppn`) takes its
376/// own stage split exactly as the eager and batched steps do. Setting 0 sends verify back through
377/// the unsplit trunk walk, which under a sharded cross-device placement is then caught by
378/// `refuse_unsplit_if_remote` (the 28x peer-read regime) rather than running silently. Exists so
379/// the bit-identity gate can A/B split vs unsplit IN ONE PROCESS against the same loaded weights
380/// — read per verify call, never memoized, for that reason.
381pub fn spec_pp_on() -> bool {
382 std::env::var("MEMRA_SPEC_PP").as_deref() != Ok("0")
383}
384
385/// MEMRA_PP_OVERLAP=1: alternate the double-buffered boundary slots per step (the
386/// pipelining seed). Default OFF — scheduling structure only, never math. Read per step
387/// so gates can A/B in-process.
388pub fn pp2_overlap() -> bool {
389 matches!(std::env::var("MEMRA_PP_OVERLAP").as_deref(), Ok("1"))
390}
391
392/// Broken-peer escape hatch: stage-boundary activations travel through page-locked host
393/// memory instead of `cudaMemcpyPeerAsync`. Default OFF; captured when `PpNRt` is built.
394pub fn pp_host_bounce_on() -> bool {
395 matches!(std::env::var("MEMRA_PP_HOST_BOUNCE").as_deref(), Ok("1"))
396}
397
398/// True when host bounce is the live transport for a sharded cross-device placement.
399/// Callers use this to close paths that still peer-read non-boundary state.
400pub fn pp_host_bounce_active() -> bool {
401 pp_host_bounce_on() && pp_sharded_cross_device()
402}
403
404/// M2 increment 2 rollback seam: MEMRA_PP_SHARD=0 = the M1 bring-up placement (all
405/// weights upload through the primary engine; remote stages peer-read). Default ON —
406/// under MEMRA_PP_DEVICES each stage's layer range uploads through its own engine.
407pub fn pp_shard_off() -> bool {
408 matches!(std::env::var("MEMRA_PP_SHARD").as_deref(), Ok("0"))
409}
410
411/// Raw `MEMRA_PP_DEVICES` (parsed/validated at PpNRt build — a bad string must fail the
412/// decode step loudly, never silently fall back to same-device and fake a gate PASS).
413fn pp2_devices_env() -> Option<String> {
414 std::env::var("MEMRA_PP_DEVICES").ok().filter(|v| !v.is_empty())
415}
416
417static WARNED_BAD: AtomicBool = AtomicBool::new(false);
418fn warn_bad_once(msg: &str) {
419 if !WARNED_BAD.swap(true, Ordering::Relaxed) {
420 eprintln!("[pp] {msg}");
421 }
422}
423
424static WARNED_UNWIRED: AtomicBool = AtomicBool::new(false);
425/// One-time notice when the door is set but the executing path has no pp arm
426/// (M2 wires the generic eager decode at any N and the gemma4 eager arm at N=2).
427pub fn warn_unwired_once(path: &str) {
428 let open = std::env::var("MEMRA_PP_STAGES")
429 .map(|v| !v.is_empty() && v != "0" && v != "1")
430 .unwrap_or(false);
431 if open && !WARNED_UNWIRED.swap(true, Ordering::Relaxed) {
432 eprintln!(
433 "[pp] MEMRA_PP_STAGES set but `{path}` has no pp arm at this N; running unsplit"
434 );
435 }
436}
437
438// ======================================================================================
439// PpNRt: the M2 transport runtime (per-stage streams, per-boundary events + slots)
440// ======================================================================================
441
442/// One pipeline stage's execution home: device, context, launch stream, and (for a stage
443/// remote to the primary engine's device) a dedicated Engine in that device's primary
444/// context (CUmodules are per-context).
445pub struct StageRt {
446 pub dev: usize,
447 pub ctx: Arc<CudaContext>,
448 pub stream: Arc<CudaStream>,
449 /// `Some` only when `dev` differs from the primary engine's device.
450 engine: Option<Engine>,
451}
452
453/// One boundary slot: a persistent RX-side buffer + its TX/RX completion events.
454/// PERSISTENT because the buffer is written by the TX stage's stream and read by the RX
455/// stage's: a per-step alloc/free would enqueue the free on ONE stream while the other
456/// might still be reading (the cross-stream free hazard) — a never-freed slot cannot race.
457struct BoundarySlot {
458 buf: Mutex<Option<CudaSlice<f32>>>,
459 /// Recorded on the TX stage's stream after the TX copy; RX waits on it. Created in
460 /// the TX stage's context (cuEventRecord requires event ctx == stream ctx).
461 ev_tx: CudaEvent,
462 /// Recorded on the RX stage's stream after the RX copy; the NEXT TX into this slot
463 /// waits on it (write-after-read guard). Created in the RX stage's context. Waiting
464 /// on a never-recorded event is a defined no-op, so step 0 needs no special case.
465 ev_rx: CudaEvent,
466}
467
468/// Boundary b sits between stage b (TX) and stage b+1 (RX). Two slots, alternating per
469/// step under MEMRA_PP_OVERLAP=1 (each boundary counts its own steps — a decode step
470/// crosses every boundary exactly once, so the counters stay in lockstep).
471struct BoundaryRt {
472 slots: [BoundarySlot; 2],
473 step: AtomicUsize,
474 /// true iff stage b and stage b+1 live on different devices (peer transport).
475 cross: bool,
476}
477
478#[derive(Clone, Copy, Debug, PartialEq, Eq)]
479enum BoundaryTransport {
480 Local,
481 Peer,
482 HostBounce,
483}
484
485fn boundary_transport(cross: bool, host_bounce: bool) -> BoundaryTransport {
486 match (cross, host_bounce) {
487 (false, _) => BoundaryTransport::Local,
488 (true, false) => BoundaryTransport::Peer,
489 (true, true) => BoundaryTransport::HostBounce,
490 }
491}
492
493fn host_bounce_capacity(n_embd: usize) -> Result<(usize, usize), String> {
494 if n_embd == 0 {
495 return Err("MEMRA_PP_HOST_BOUNCE needs non-zero model n_embd".into());
496 }
497 let elems = n_embd
498 .checked_mul(crate::cache::PRIME_CHUNK_MAX_TOKENS)
499 .ok_or_else(|| format!("host-bounce element count overflows for n_embd={n_embd}"))?;
500 let bytes = elems
501 .checked_mul(std::mem::size_of::<f32>())
502 .ok_or_else(|| format!("host-bounce byte count overflows for n_embd={n_embd}"))?;
503 Ok((elems, bytes))
504}
505
506/// One bidirectional-DMA staging allocation. `CU_MEMHOSTALLOC_PORTABLE` matters here: the
507/// D2H producer and H2D consumer are in distinct CUDA primary contexts. Cacheable memory is
508/// intentional (rather than cudarc's write-combined pinned slice) because this allocation is
509/// the destination of D2H as well as the source of H2D.
510struct PinnedHostBounce {
511 ptr: *mut f32,
512 len: usize,
513}
514
515unsafe impl Send for PinnedHostBounce {}
516unsafe impl Sync for PinnedHostBounce {}
517
518impl PinnedHostBounce {
519 fn new(len: usize) -> Result<Self, Box<dyn std::error::Error>> {
520 let bytes = len
521 .checked_mul(std::mem::size_of::<f32>())
522 .ok_or("host-bounce pinned allocation size overflow")?;
523 let ptr = unsafe {
524 cudarc::driver::result::malloc_host(
525 bytes,
526 cudarc::driver::sys::CU_MEMHOSTALLOC_PORTABLE,
527 )?
528 } as *mut f32;
529 if ptr.is_null() {
530 return Err("cuMemHostAlloc returned a null host-bounce pointer".into());
531 }
532 Ok(Self { ptr, len })
533 }
534
535 fn prefix(&self, n: usize) -> &[f32] {
536 assert!(n <= self.len, "host-bounce source {n} > capacity {}", self.len);
537 unsafe { std::slice::from_raw_parts(self.ptr, n) }
538 }
539
540 fn prefix_mut(&mut self, n: usize) -> &mut [f32] {
541 assert!(n <= self.len, "host-bounce destination {n} > capacity {}", self.len);
542 unsafe { std::slice::from_raw_parts_mut(self.ptr, n) }
543 }
544}
545
546impl Drop for PinnedHostBounce {
547 fn drop(&mut self) {
548 let _ = unsafe { cudarc::driver::result::free_host(self.ptr.cast()) };
549 }
550}
551
552struct HostBounceRt {
553 n_embd: usize,
554 capacity: usize,
555 slots: Vec<Option<[Mutex<PinnedHostBounce>; 2]>>,
556}
557
558impl HostBounceRt {
559 fn new(n_embd: usize, boundaries: &[BoundaryRt]) -> Result<Self, Box<dyn std::error::Error>> {
560 let (capacity, _) = host_bounce_capacity(n_embd)?;
561 let mut slots = Vec::with_capacity(boundaries.len());
562 for boundary in boundaries {
563 slots.push(if boundary.cross {
564 Some([
565 Mutex::new(PinnedHostBounce::new(capacity)?),
566 Mutex::new(PinnedHostBounce::new(capacity)?),
567 ])
568 } else {
569 None
570 });
571 }
572 Ok(Self { n_embd, capacity, slots })
573 }
574
575 fn slot(
576 &self,
577 boundary: usize,
578 slot: usize,
579 ) -> Result<&Mutex<PinnedHostBounce>, Box<dyn std::error::Error>> {
580 self.slots
581 .get(boundary)
582 .and_then(Option::as_ref)
583 .and_then(|slots| slots.get(slot))
584 .ok_or_else(|| format!("host-bounce slot {boundary}:{slot} is not initialized").into())
585 }
586}
587
588pub struct PpNRt {
589 stages: Vec<StageRt>,
590 boundaries: Vec<BoundaryRt>,
591 /// true iff ANY boundary crosses devices.
592 cross_any: bool,
593 /// Captured once at runtime construction; default false preserves the peer transport.
594 host_bounce: bool,
595 /// Lazily allocated after the authoritative model width is known at cache creation.
596 bounce: OnceLock<Result<HostBounceRt, String>>,
597 /// Dedicated readback stream in the LAST stage's context (deferred logits D2H —
598 /// waiting there instead of on the compute stream keeps later tokens enqueuable).
599 readback: Arc<CudaStream>,
600}
601
602/// M1 name kept alive for external callers (`pp-transport-smoke`, receipts, docs).
603pub type Pp2Rt = PpNRt;
604
605static RTN: OnceLock<Result<PpNRt, String>> = OnceLock::new();
606
607impl PpNRt {
608 /// The process-wide transport runtime, built on first use against the primary engine.
609 /// The stage count + device map freeze at first build (one config per process — gates
610 /// run one placement per invocation). Build errors are sticky and loud.
611 pub fn get(e: &Engine) -> Result<&'static PpNRt, Box<dyn std::error::Error>> {
612 RTN.get_or_init(|| Self::build(e).map_err(|err| err.to_string()))
613 .as_ref()
614 .map_err(|s| -> Box<dyn std::error::Error> { s.clone().into() })
615 }
616
617 fn build(e: &Engine) -> Result<PpNRt, Box<dyn std::error::Error>> {
618 let primary_dev = e.ctx().ordinal();
619 // Stage count: MEMRA_PP_DEVICES length wins when set (it IS the placement);
620 // else MEMRA_PP_STAGES; else 2 (the M1 default — pp-transport-smoke runs doorless).
621 let devices: Vec<usize> = match pp2_devices_env() {
622 Some(s) => {
623 let parts: Result<Vec<usize>, _> =
624 s.split(',').map(|p| p.trim().parse::<usize>()).collect();
625 match parts {
626 Ok(v) if v.len() >= 2 => v,
627 _ => {
628 return Err(format!(
629 "MEMRA_PP_DEVICES={s} unparseable (want <d0>,..,<dN-1> e.g. 0,1,2,3)"
630 )
631 .into())
632 }
633 }
634 }
635 None => {
636 let n_st = std::env::var("MEMRA_PP_STAGES")
637 .ok()
638 .and_then(|v| v.parse::<usize>().ok())
639 .filter(|&n| n >= 2)
640 .unwrap_or(2);
641 vec![primary_dev; n_st]
642 }
643 };
644 if let Ok(v) = std::env::var("MEMRA_PP_STAGES") {
645 if let Ok(n) = v.parse::<usize>() {
646 if n >= 2 && n != devices.len() {
647 return Err(format!(
648 "MEMRA_PP_DEVICES lists {} devices but MEMRA_PP_STAGES={n} — \
649 refusing an ambiguous placement",
650 devices.len()
651 )
652 .into());
653 }
654 }
655 }
656 let n_st = devices.len();
657 let cross_any = devices.iter().any(|&d| d != devices[0]);
658 let host_bounce = pp_host_bounce_on();
659 if host_bounce && cross_any {
660 if pp_shard_off() {
661 return Err(
662 "MEMRA_PP_HOST_BOUNCE=1 refuses MEMRA_PP_SHARD=0: the boundary can bounce, \
663 but remote stages would still peer-read primary-device weights"
664 .into(),
665 );
666 }
667 if devices.last().copied() != Some(primary_dev) {
668 return Err(format!(
669 "MEMRA_PP_HOST_BOUNCE=1 requires the primary engine on the last/head stage \
670 (primary dev{primary_dev}, placement {devices:?}); otherwise returned \
671 logits/hidden state remain peer reads"
672 )
673 .into());
674 }
675 }
676
677 // Validate every placement ordinal in both transports. The peer transport additionally
678 // requires peer access both ways; host bounce deliberately skips that capability gate.
679 let mut used: Vec<usize> = devices.clone();
680 used.push(primary_dev);
681 used.sort_unstable();
682 used.dedup();
683 if used.len() > 1 {
684 let n = cudarc::driver::result::device::get_count()? as usize;
685 for &d in &used {
686 if d >= n {
687 return Err(format!(
688 "MEMRA_PP_DEVICES={devices:?} but only {n} CUDA device(s) present"
689 )
690 .into());
691 }
692 }
693 if !host_bounce {
694 for &a in &used {
695 for &b in &used {
696 if a == b {
697 continue;
698 }
699 let da = cudarc::driver::result::device::get(a as i32)?;
700 let db = cudarc::driver::result::device::get(b as i32)?;
701 let mut can: i32 = 0;
702 unsafe {
703 cudarc::driver::sys::cuDeviceCanAccessPeer(&mut can, da, db).result()?;
704 }
705 if can == 0 {
706 return Err(format!(
707 "device {a} cannot peer-access device {b} \
708 (cuDeviceCanAccessPeer=0); ppN cross-device needs P2P — \
709 refusing a silently-staged path"
710 )
711 .into());
712 }
713 }
714 }
715 }
716 }
717
718 // PER-STAGE ENGINE ISOLATION (2026-08-02 singledev pipelined find): Engine owns
719 // lazily-grown SHARED scratch pools (fa_part_pool, fa_vf16_scratch, argmax
720 // partials, ...) that are stable-pointer by design — safe on one stream, a data
721 // race the moment two stage streams run concurrently through the SAME Engine
722 // (deferred readback, >=2 tokens in flight: token t+1's stage-0 fa memsets the
723 // partials while token t's stage-s fa still reads them — the nondeterministic
724 // all-logits divergence; cross-device arms were immune because remote stages
725 // already got their own Engine). Every stage s>0 gets its OWN Engine even on the
726 // primary device: same CUcontext (primary retain), so the per-context CUmodule
727 // cache makes it cheap; scratch pools are per-Engine, so stages never share.
728 // Stage 0 keeps the primary engine (single-threaded host issue: the only
729 // concurrent user of `e` during a pp walk is stage 0 itself).
730 let mk_stage = |dev: usize, s: usize| -> Result<StageRt, Box<dyn std::error::Error>> {
731 if dev == primary_dev && s == 0 {
732 let ctx = e.ctx().clone();
733 let stream = ctx.new_stream()?;
734 Ok(StageRt { dev, ctx, stream, engine: None })
735 } else {
736 let eng = Engine::new(dev)?;
737 let ctx = eng.ctx().clone();
738 let stream = ctx.new_stream()?;
739 Ok(StageRt { dev, ctx, stream, engine: Some(eng) })
740 }
741 };
742 let mut stages = Vec::with_capacity(n_st);
743 for (s, &d) in devices.iter().enumerate() {
744 stages.push(mk_stage(d, s)?);
745 }
746
747 if used.len() > 1 {
748 if !host_bounce {
749 // A context per distinct device (first stage that lives there; the primary's
750 // context for the primary device).
751 let ctx_of = |d: usize| -> &Arc<CudaContext> {
752 if d == primary_dev {
753 e.ctx()
754 } else {
755 &stages.iter().find(|s| s.dev == d).unwrap().ctx
756 }
757 };
758 // Enable peer access BOTH ways for every distinct pair (idempotent;
759 // ALREADY_ENABLED is success).
760 for &a in &used {
761 for &b in &used {
762 if a == b {
763 continue;
764 }
765 ctx_of(a).bind_to_thread()?;
766 let rc = unsafe {
767 cudarc::driver::sys::cuCtxEnablePeerAccess(ctx_of(b).cu_ctx(), 0)
768 };
769 use cudarc::driver::sys::cudaError_enum as E;
770 if rc != E::CUDA_SUCCESS && rc != E::CUDA_ERROR_PEER_ACCESS_ALREADY_ENABLED {
771 return Err(format!(
772 "cuCtxEnablePeerAccess(dev{a} -> dev{b}) failed: {rc:?}"
773 )
774 .into());
775 }
776 }
777 }
778 // MEM-POOL access grant (8x box 2026-08-02, M1 cross-device fix #2):
779 // cuCtxEnablePeerAccess does NOT map STREAM-ORDERED POOL allocations, and every
780 // engine buffer/weight goes through the device default pool (cuMemAllocAsync via
781 // cudarc; memra-runtime configures that pool). A stage kernel dereferencing
782 // another device's weights — or a boundary peer TX writing the RX slot — needs
783 // cuMemPoolSetAccess on the OWNING device's default pool for the ACCESSING
784 // device; without it the first remote dereference is CUDA_ERROR_ILLEGAL_ADDRESS
785 // (reported at the next API call in the poisoned context). Grant all pairs.
786 for &owner in &used {
787 for &accessor in &used {
788 if owner == accessor {
789 continue;
790 }
791 let dev = cudarc::driver::result::device::get(owner as i32)?;
792 let mut pool: cudarc::driver::sys::CUmemoryPool = std::ptr::null_mut();
793 unsafe {
794 cudarc::driver::sys::cuDeviceGetDefaultMemPool(&mut pool, dev).result()?;
795 }
796 let desc = cudarc::driver::sys::CUmemAccessDesc {
797 location: cudarc::driver::sys::CUmemLocation {
798 type_: cudarc::driver::sys::CUmemLocationType::CU_MEM_LOCATION_TYPE_DEVICE,
799 id: accessor as i32,
800 },
801 flags: cudarc::driver::sys::CUmemAccess_flags::CU_MEM_ACCESS_FLAGS_PROT_READWRITE,
802 };
803 let rc = unsafe { cudarc::driver::sys::cuMemPoolSetAccess(pool, &desc, 1) };
804 if rc != cudarc::driver::sys::cudaError_enum::CUDA_SUCCESS {
805 return Err(format!(
806 "cuMemPoolSetAccess(dev{owner} pool -> dev{accessor}) failed: {rc:?}"
807 )
808 .into());
809 }
810 }
811 }
812 // MEM-POOL access grant (8x box 2026-08-02, cross-device fix #2):
813 // cuCtxEnablePeerAccess does NOT map STREAM-ORDERED POOL allocations, and every
814 // engine buffer/weight goes through the device default pool (cuMemAllocAsync via
815 // cudarc; memra-runtime configures that pool). A stage-1 kernel dereferencing
816 // dev0 weights — or the stage-0 peer TX writing dev1's RX slot — needs
817 // cuMemPoolSetAccess on the OWNING device's default pool for the ACCESSING
818 // device; without it the first remote dereference is CUDA_ERROR_ILLEGAL_ADDRESS
819 // (reported at the next API call in the poisoned context). Grant both ways.
820 for (owner, accessor) in [(stages[0].dev, stages[1].dev), (stages[1].dev, stages[0].dev)] {
821 let dev = cudarc::driver::result::device::get(owner as i32)?;
822 let mut pool: cudarc::driver::sys::CUmemoryPool = std::ptr::null_mut();
823 unsafe {
824 cudarc::driver::sys::cuDeviceGetDefaultMemPool(&mut pool, dev).result()?;
825 }
826 let desc = cudarc::driver::sys::CUmemAccessDesc {
827 location: cudarc::driver::sys::CUmemLocation {
828 type_: cudarc::driver::sys::CUmemLocationType::CU_MEM_LOCATION_TYPE_DEVICE,
829 id: accessor as i32,
830 },
831 flags: cudarc::driver::sys::CUmemAccess_flags::CU_MEM_ACCESS_FLAGS_PROT_READWRITE,
832 };
833 let rc = unsafe { cudarc::driver::sys::cuMemPoolSetAccess(pool, &desc, 1) };
834 if rc != cudarc::driver::sys::cudaError_enum::CUDA_SUCCESS {
835 return Err(format!(
836 "cuMemPoolSetAccess(dev{owner} pool -> dev{accessor}) failed: {rc:?}"
837 )
838 .into());
839 }
840 }
841 // restore the primary context for the caller's subsequent work
842 e.ctx().bind_to_thread()?;
843 eprintln!(
844 "[pp] cross-device transport: {} (cudaMemcpyPeerAsync per cross boundary; \
845 peer + default-pool access granted all pairs over {used:?}; weight home: {})",
846 devices
847 .iter()
848 .enumerate()
849 .map(|(s, d)| format!("stage{s}=dev{d}"))
850 .collect::<Vec<_>>()
851 .join(" "),
852 if pp_shard_off() {
853 format!("dev{primary_dev} (MEMRA_PP_SHARD=0 bring-up placement)")
854 } else {
855 "per-stage (sharded loader)".to_string()
856 }
857 );
858 } else {
859 e.ctx().bind_to_thread()?;
860 eprintln!(
861 "[pp] cross-device transport: {} (HOST-STAGED pinned D2H -> H2D per cross \
862 boundary; MEMRA_PP_HOST_BOUNCE=1; peer access and peer-pool grants \
863 bypassed; weight home: per-stage (sharded loader))",
864 devices
865 .iter()
866 .enumerate()
867 .map(|(s, d)| format!("stage{s}=dev{d}"))
868 .collect::<Vec<_>>()
869 .join(" "),
870 );
871 }
872 }
873
874 let mk_slot = |tx: &StageRt, rx: &StageRt| -> Result<BoundarySlot, Box<dyn std::error::Error>> {
875 Ok(BoundarySlot {
876 buf: Mutex::new(None),
877 ev_tx: tx.ctx.new_event(None)?,
878 ev_rx: rx.ctx.new_event(None)?,
879 })
880 };
881 let mut boundaries = Vec::with_capacity(n_st - 1);
882 for b in 0..n_st - 1 {
883 let (tx, rx) = (&stages[b], &stages[b + 1]);
884 boundaries.push(BoundaryRt {
885 slots: [mk_slot(tx, rx)?, mk_slot(tx, rx)?],
886 step: AtomicUsize::new(0),
887 cross: tx.dev != rx.dev,
888 });
889 }
890 let readback = stages[n_st - 1].ctx.new_stream()?;
891 Ok(PpNRt {
892 stages,
893 boundaries,
894 cross_any,
895 host_bounce,
896 bounce: OnceLock::new(),
897 readback,
898 })
899 }
900
901 pub fn n_stages(&self) -> usize {
902 self.stages.len()
903 }
904
905 /// True iff any boundary crosses devices.
906 pub fn cross_device(&self) -> bool {
907 self.cross_any
908 }
909
910 /// Allocate both pinned slots for every cross-device boundary exactly once. `new_cache`
911 /// calls this with the GGUF model geometry before the first forward; the fixed 4096-token
912 /// prime cap makes the largest payload `4096 * n_embd * sizeof(f32)` without hard-coding a
913 /// model width here.
914 pub fn init_host_bounce(
915 &self,
916 e: &Engine,
917 n_embd: usize,
918 ) -> Result<(), Box<dyn std::error::Error>> {
919 if !self.host_bounce || !self.cross_any {
920 return Ok(());
921 }
922 e.ctx().bind_to_thread()?;
923 let result = self.bounce.get_or_init(|| {
924 HostBounceRt::new(n_embd, &self.boundaries)
925 .map(|rt| {
926 let bytes = rt.capacity * std::mem::size_of::<f32>();
927 eprintln!(
928 "[pp] host-bounce staging ready: n_embd={n_embd} max_tokens={} \
929 slot_bytes={bytes} slots_per_cross_boundary=2",
930 crate::cache::PRIME_CHUNK_MAX_TOKENS,
931 );
932 rt
933 })
934 .map_err(|err| err.to_string())
935 });
936 let bounce = result
937 .as_ref()
938 .map_err(|err| -> Box<dyn std::error::Error> { err.clone().into() })?;
939 if bounce.n_embd != n_embd {
940 return Err(format!(
941 "host-bounce runtime initialized for n_embd={} but model requests n_embd={n_embd}; \
942 one PP runtime supports one model geometry per process",
943 bounce.n_embd,
944 )
945 .into());
946 }
947 Ok(())
948 }
949
950 fn bounce_rt(&self) -> Result<&HostBounceRt, Box<dyn std::error::Error>> {
951 self.bounce
952 .get()
953 .ok_or_else(|| -> Box<dyn std::error::Error> {
954 "MEMRA_PP_HOST_BOUNCE=1 staging was not initialized from model geometry".into()
955 })?
956 .as_ref()
957 .map_err(|err| -> Box<dyn std::error::Error> { err.clone().into() })
958 }
959
960 /// The engine a stage's subgraph must run through: the primary engine when the stage
961 /// lives on the primary device, else the stage's own (remote-context) engine.
962 pub fn engine<'a>(&'a self, s: usize, primary: &'a Engine) -> &'a Engine {
963 self.stages[s].engine.as_ref().unwrap_or(primary)
964 }
965
966 /// Bind this OS thread to stage `s`'s CUDA context before issuing work there.
967 pub fn bind_stage(&self, s: usize) -> Result<(), Box<dyn std::error::Error>> {
968 self.stages[s].ctx.bind_to_thread()?;
969 Ok(())
970 }
971
972 /// Enter stage `s`: until the guard drops, every engine op on this thread launches on
973 /// the stage's stream (memra_runtime ambient-stream override).
974 pub fn enter(&self, s: usize) -> memra_runtime::StreamOverride {
975 memra_runtime::push_stream_override(self.stages[s].stream.clone())
976 }
977
978 /// Allocate/grow BOTH slots for a boundary before pipelined issue starts. `tx()` can
979 /// grow a slot lazily, but first-use ordering requires synchronizing the RX stream
980 /// after that allocation. If slot 1 first grows after stage 1 of chunk N has already
981 /// been queued, that sync drains chunk N and erases the only overlap in a two-chunk
982 /// prime. Prewarming both slots pays the same one-time sync before either stage starts.
983 pub fn prepare_overlap_slots(&self, b: usize, n: usize)
984 -> Result<(), Box<dyn std::error::Error>> {
985 let bd = &self.boundaries[b];
986 let s_rx = &self.stages[b + 1].stream;
987 let mut grew = false;
988 for sl in &bd.slots {
989 let mut guard = sl.buf.lock().unwrap();
990 if guard.as_ref().map(|bf| bf.len() < n).unwrap_or(true) {
991 *guard = Some(s_rx.alloc_zeros::<f32>(n)?);
992 grew = true;
993 }
994 }
995 if grew {
996 s_rx.synchronize()?;
997 }
998 Ok(())
999 }
1000
1001 /// Boundary TX at boundary `b` (call within the stage-`b` scope; `x` = the
1002 /// materialized [n] residual): wait for the slot's previous RX (write-after-read
1003 /// guard), copy `x` into the slot's persistent buffer via the boundary's transport on
1004 /// stage-b's stream (the owning-stream/publication law), record ev_tx. Returns the
1005 /// slot index for the paired rx().
1006 ///
1007 /// `n` is the PAYLOAD ELEMENT COUNT, not a fixed model constant: the eager arm passes
1008 /// `n_embd` (one row), the batched arm passes `b_n * n_embd` (B stacked rows, the
1009 /// [B, n_embd] boundary). The slot buffer is GROW-ONLY and the transport moves exactly
1010 /// the first `n` elements — batched serving changes B every tick (chunk fill), and a
1011 /// realloc-on-every-size-change would host-sync the RX stream per width change (see the
1012 /// SLOT FIRST-USE ORDERING note below for why each allocation needs that sync). Growing
1013 /// to the high-water mark makes the syncs O(distinct widths) instead of O(width changes).
1014 pub fn tx(&self, b: usize, x: &CudaSlice<f32>, n: usize)
1015 -> Result<usize, Box<dyn std::error::Error>> {
1016 assert_eq!(x.len(), n, "pp tx: residual length mismatch");
1017 let bd = &self.boundaries[b];
1018 let slot_idx = if pp2_overlap() {
1019 bd.step.fetch_add(1, Ordering::Relaxed) % 2
1020 } else {
1021 0
1022 };
1023 self.tx_slot(b, x, n, slot_idx)
1024 }
1025
1026 /// Pipelined boundary TX: always alternate the shared double-buffer slots, independent
1027 /// of the decode-side `MEMRA_PP_OVERLAP` experiment flag. The boundary-local atomic
1028 /// keeps concurrent callers on one slot sequence rather than each restarting at A.
1029 pub fn tx_pipelined(&self, b: usize, x: &CudaSlice<f32>, n: usize)
1030 -> Result<usize, Box<dyn std::error::Error>> {
1031 assert_eq!(x.len(), n, "pp tx: residual length mismatch");
1032 let slot_idx = self.boundaries[b].step.fetch_add(1, Ordering::Relaxed) % 2;
1033 self.tx_slot(b, x, n, slot_idx)
1034 }
1035
1036 fn tx_slot(&self, b: usize, x: &CudaSlice<f32>, n: usize, slot_idx: usize)
1037 -> Result<usize, Box<dyn std::error::Error>> {
1038 debug_assert!(slot_idx < 2);
1039 let bd = &self.boundaries[b];
1040 let sl = &bd.slots[slot_idx];
1041 let s_tx = &self.stages[b].stream;
1042 s_tx.wait(&sl.ev_rx)?;
1043 let mut guard = sl.buf.lock().unwrap();
1044 if guard.as_ref().map(|bf| bf.len() < n).unwrap_or(true) {
1045 // allocated on the RX stage's stream: the buffer lives on the RX device.
1046 let s_rx = &self.stages[b + 1].stream;
1047 *guard = Some(s_rx.alloc_zeros::<f32>(n)?);
1048 // SLOT FIRST-USE ORDERING (2026-08-02 pipelined-gate find): the lazy alloc's
1049 // pool-alloc + memset enqueue on the RX stream; the TX copy below issues on
1050 // the TX stream, and on a slot's FIRST use ev_rx has never been recorded —
1051 // nothing orders them. With >=2 tokens in flight the RX stream is still busy
1052 // with the previous token, the memset lands AFTER the TX copy, and the
1053 // boundary residual is zeroed (window=1 passed, window>=2 failed at the
1054 // slot-1 first-use step; -overlap arms passed because the synchronous serial
1055 // arm pre-warmed both slots). Host-sync the RX stream once per slot
1056 // allocation — at most 2*(N-1) one-time syncs per process, all during prime.
1057 s_rx.synchronize()?;
1058 }
1059 let buf = guard.as_mut().unwrap();
1060 match boundary_transport(bd.cross, self.host_bounce) {
1061 BoundaryTransport::Local => s_tx.memcpy_dtod(x, buf)?,
1062 BoundaryTransport::HostBounce => {
1063 let bounce = self.bounce_rt()?;
1064 if n > bounce.capacity {
1065 return Err(format!(
1066 "pp host-bounce payload {n} exceeds geometry-sized capacity {} \
1067 (n_embd={}, max prime tokens={})",
1068 bounce.capacity,
1069 bounce.n_embd,
1070 crate::cache::PRIME_CHUNK_MAX_TOKENS,
1071 )
1072 .into());
1073 }
1074 let mut host = bounce.slot(b, slot_idx)?.lock().unwrap();
1075 // D2H is issued on the producing stage's stream. ev_tx below publishes the
1076 // completed host bytes to the receiving stream; the exact prefix avoids moving
1077 // a full 64 MiB slot for a one-row decode, and no peer pointer is formed here.
1078 s_tx.memcpy_dtoh(x, host.prefix_mut(n))?;
1079 }
1080 BoundaryTransport::Peer => {
1081 // cudaMemcpyPeerAsync (M0: 2.8x NCCL at PP activation sizes), issued on the
1082 // publishing TX stream with explicit src/dst contexts.
1083 use cudarc::driver::{DevicePtr, DevicePtrMut};
1084 let (sp, _g0) = x.device_ptr(s_tx);
1085 let (dp, _g1) = buf.device_ptr_mut(s_tx);
1086 self.stages[b].ctx.bind_to_thread()?;
1087 unsafe {
1088 cudarc::driver::result::memcpy_peer_async(
1089 self.stages[b + 1].ctx.cu_ctx(),
1090 dp,
1091 self.stages[b].ctx.cu_ctx(),
1092 sp,
1093 n * std::mem::size_of::<f32>(),
1094 s_tx.cu_stream(),
1095 )?;
1096 }
1097 }
1098 }
1099 sl.ev_tx.record(s_tx)?;
1100 Ok(slot_idx)
1101 }
1102
1103 /// Boundary RX at boundary `b` (call within the stage-`b+1` scope): wait on the slot's
1104 /// ev_tx, copy the boundary buffer into a fresh working buffer (dtod on the RX stream —
1105 /// local on the RX device in both transports), record ev_rx. The returned buffer is
1106 /// RX-stage-owned: allocated, consumed, and eventually freed on that stage's stream.
1107 pub fn rx(&self, b: usize, slot_idx: usize, n: usize)
1108 -> Result<CudaSlice<f32>, Box<dyn std::error::Error>> {
1109 let sl = &self.boundaries[b].slots[slot_idx];
1110 let s_rx = &self.stages[b + 1].stream;
1111 s_rx.wait(&sl.ev_tx)?;
1112 let mut guard = sl.buf.lock().unwrap();
1113 let buf = guard.as_mut().expect("pp rx before tx");
1114 assert!(buf.len() >= n, "pp rx: slot holds {} < requested {n}", buf.len());
1115 if boundary_transport(self.boundaries[b].cross, self.host_bounce)
1116 == BoundaryTransport::HostBounce
1117 {
1118 let bounce = self.bounce_rt()?;
1119 let host = bounce.slot(b, slot_idx)?.lock().unwrap();
1120 let mut dst = buf.slice_mut(0..n);
1121 // The destination stream already waits ev_tx, so this H2D cannot observe the
1122 // staging slot before the source stream's D2H completes.
1123 s_rx.memcpy_htod(host.prefix(n), &mut dst)?;
1124 }
1125 // uninit working buffer (fully overwritten by the copy), allocated explicitly on
1126 // the stage stream so rx() is correct even outside an enter() scope.
1127 let mut work = unsafe { s_rx.alloc::<f32>(n)? };
1128 // Slice the slot to the payload: the buffer is grow-only (see tx), so at a narrower
1129 // width it is LONGER than `work` and cudarc's memcpy_dtod (dst.len() >= src.len())
1130 // would assert. The paired tx wrote exactly these first n elements.
1131 s_rx.memcpy_dtod(&buf.slice(0..n), &mut work)?;
1132 sl.ev_rx.record(s_rx)?;
1133 Ok(work)
1134 }
1135
1136 /// PUBLISH a DEVICE-RESIDENT result off the last stage to the caller's stream
1137 /// (lane/pp2-spec 2026-08-06).
1138 ///
1139 /// Every ppN body before this one returned HOST values — `decode_step_h_ppn` and
1140 /// `decode_step_batch_ppn` both `dtoh` inside the last-stage scope, and a dtoh on the
1141 /// producing stream is self-ordering. The verify trunk is the FIRST ppN body whose
1142 /// contract is device-resident output (`decode_step_t_h_emb_dev` exists precisely so the
1143 /// accept walk argmaxes on-device instead of moving T x n_vocab f32 per round), and
1144 /// device slices carry no stream affinity: the caller resumes on the PRIMARY stream and
1145 /// dereferences buffers whose producing kernels are still queued on the last stage's
1146 /// stream. Nothing orders them.
1147 ///
1148 /// Why this only ever failed on ONE device: with stages on separate devices the caller's
1149 /// first touch is a cross-device copy that the driver orders against the source context,
1150 /// and the readback path syncs. Two streams on the SAME device genuinely overlap, so the
1151 /// primary stream reads a buffer whose matmul has not run — nondeterministic garbage
1152 /// (measured: NaN, 3155.677, and 2.87e-5 where the reference had -2.0048926), and it
1153 /// poisons the NEXT arm in the same process because the corrupted KV persists. This is
1154 /// the same class as the SLOT FIRST-USE ORDERING find above, one level up: there the
1155 /// unordered pair was alloc-memset vs TX copy, here it is stage-N compute vs the
1156 /// caller's consumer.
1157 ///
1158 /// Fix = the boundary law applied to the exit: record an event on the producing stage
1159 /// stream, make the caller's stream wait on it. Event-wait, not a device sync, so the
1160 /// stage streams keep running for the deferred-readback arm. Call INSIDE the last-stage
1161 /// scope, after the last enqueue, with the caller's (pre-`enter`) stream.
1162 pub fn publish_to(&self, s: usize, dst: &Arc<CudaStream>)
1163 -> Result<(), Box<dyn std::error::Error>> {
1164 let st = &self.stages[s];
1165 // Same stream (STREAMS=0 rollback, or a caller already on the stage stream): the
1166 // stream orders itself; recording+waiting would be a no-op with a stray event.
1167 if Arc::ptr_eq(&st.stream, dst) {
1168 return Ok(());
1169 }
1170 let ev = st.ctx.new_event(None)?;
1171 ev.record(&st.stream)?;
1172 dst.wait(&ev)?;
1173 Ok(())
1174 }
1175
1176 /// REVERSE PUBLICATION (#87 root cause, lane/pp2spec-crash 2026-08-07): order every
1177 /// STAGE stream behind the CALLER's stream — the mirror of `publish_to`.
1178 ///
1179 /// `publish_to` orders caller READS behind stage COMPUTE. Nothing ordered the other
1180 /// direction: buffers ALLOCATED on a stage stream (the verify's returned logits/hidden,
1181 /// the VerifyCkpt stashes) are CONSUMED by kernels the caller enqueues on the PRIMARY
1182 /// stream, and when they drop, cudarc enqueues `free_async` on the ALLOCATING (stage)
1183 /// stream. With event tracking elided (the decode-path default) the drop carries no
1184 /// read-guard, so the pool can hand the block to the NEXT stage-stream allocation and
1185 /// its writes overwrite memory the queued primary-stream consumer has not read yet.
1186 /// Measured (research/pp2spec-crash-20260807): the spec round-seed read 13/4096 NaN =
1187 /// the uninitialized-bits signature (P(NaN|random u32) ~ 1/256), clean by host re-read
1188 /// time — a read-before-write race, fatal via the argmax-sentinel -> embed_gather MMU
1189 /// fault, and gated on c>=2 because a backed-up primary stream widens the window.
1190 ///
1191 /// Fix law: before a ppN body enqueues NEW stage-stream work (allocations that may
1192 /// reuse freed blocks), every stage stream waits the caller's stream at its current
1193 /// point. All primary consumers of the previous round's stage-allocated buffers are
1194 /// enqueued by then (single host thread), so reuse-writes land strictly after them.
1195 /// Call at ppN-body ENTRY with the pre-`enter` caller stream. Door-shut configs never
1196 /// build a PpNRt, so single-card behavior is untouched.
1197 pub fn fence_stages_behind(&self, src: &Arc<CudaStream>)
1198 -> Result<(), Box<dyn std::error::Error>> {
1199 let ev = src.context().new_event(None)?;
1200 ev.record(src)?;
1201 for st in &self.stages {
1202 if Arc::ptr_eq(&st.stream, src) {
1203 continue;
1204 }
1205 st.stream.wait(&ev)?;
1206 }
1207 Ok(())
1208 }
1209
1210 /// Deferred readback: record a fresh completion event on the LAST stage's stream
1211 /// (call after the step's logits matmul has been enqueued there).
1212 pub fn record_done(&self) -> Result<CudaEvent, Box<dyn std::error::Error>> {
1213 let last = &self.stages[self.stages.len() - 1];
1214 let ev = last.ctx.new_event(None)?;
1215 ev.record(&last.stream)?;
1216 Ok(ev)
1217 }
1218
1219 /// The dedicated readback stream (last stage's context).
1220 pub fn readback_stream(&self) -> &Arc<CudaStream> {
1221 &self.readback
1222 }
1223}
1224
1225/// M2 increment 3: a step's logits, still device-resident on the LAST stage. `wait()`
1226/// orders the readback stream behind the step's completion event, copies, and syncs —
1227/// tokens enqueued after this step keep running on the stage streams while the caller
1228/// drains token t. Dropping without waiting is safe (buffers free stream-ordered).
1229pub struct PendingLogits {
1230 logits: CudaSlice<f32>,
1231 ev: CudaEvent,
1232 rb: Arc<CudaStream>,
1233}
1234
1235impl PendingLogits {
1236 pub fn new(logits: CudaSlice<f32>, ev: CudaEvent, rb: Arc<CudaStream>) -> Self {
1237 PendingLogits { logits, ev, rb }
1238 }
1239
1240 /// Blocks until this step's logits are computed, returns them host-side. Only this
1241 /// step's work is waited on (event-ordered) — NOT later tokens already enqueued on
1242 /// the stage streams.
1243 pub fn wait(self) -> Result<Vec<f32>, Box<dyn std::error::Error>> {
1244 self.rb.wait(&self.ev)?;
1245 let host = self.rb.clone_dtoh(&self.logits)?;
1246 self.rb.synchronize()?;
1247 // logits drop AFTER the sync: the D2H has fully completed, so the stream-ordered
1248 // free on the compute stream cannot race the copy.
1249 Ok(host)
1250 }
1251}
1252
1253/// Stage-owned cache allocation door: when the ppN door is open AND `MEMRA_PP_DEVICES`
1254/// is set (placement plumbing), each layer's cache is allocated by its OWNING stage's
1255/// engine — on one device this is byte-for-byte today's allocation (gated); cross-device
1256/// it puts each stage's KV on that stage's HBM. Door shut or devices unset: plain
1257/// `Cache::new` (zero behavior change). Trailing MTP/NextN layers (beyond the trunk)
1258/// map to the LAST stage.
1259pub fn new_cache(e: &Engine, cfg: &memra_gguf::config::ModelConfig, max_ctx: usize)
1260 -> Result<crate::cache::Cache, Box<dyn std::error::Error>> {
1261 let n_trunk = (cfg.n_layer - cfg.nextn_predict_layers) as usize;
1262 if let Some(fence) = pp_cuts(n_trunk) {
1263 if pp2_devices_env().is_some() && !pp2_streams_off() {
1264 let rt = PpNRt::get(e)?;
1265 rt.init_host_bounce(e, cfg.n_embd as usize)?;
1266 let n_st = fence.len() - 1;
1267 assert_eq!(
1268 rt.n_stages(), n_st,
1269 "PpNRt stage count {} != fence stages {n_st}", rt.n_stages()
1270 );
1271 // #87 REVERSE PUBLICATION at ADMISSION (lane/pp2spec-crash): this is the one
1272 // stage-stream allocation site OUTSIDE the ppN step bodies — a NEW session's
1273 // KV alloc_zeros enqueue on the STAGE streams, and their pool blocks can be
1274 // reuse of buffers freed from ANOTHER session's in-flight verify whose
1275 // primary-stream reads are still queued (the c=2 residual: exactly one trap
1276 // per admission collision, round 0, after the step-body fences landed).
1277 // Order the stage streams behind the caller before the memsets can clobber.
1278 // Anatomy: `PpNRt::fence_stages_behind`.
1279 rt.fence_stages_behind(&e.stream())?;
1280 let devs: Vec<&dyn memra_kv::KvDev> =
1281 (0..n_st).map(|s| rt.engine(s, e) as &dyn memra_kv::KvDev).collect();
1282 let cache = crate::cache::Cache::new_ppn(&devs, &fence, cfg, max_ctx)?;
1283 sync_stages_after_load(e, n_trunk)?;
1284 return Ok(cache);
1285 }
1286 if !pp2_streams_off() {
1287 // CACHE BIRTH BARRIER (2026-08-02 pipelined-arm residual race): with the door
1288 // open but no device placement, Cache::new's alloc_zeros memsets enqueue on
1289 // the PRIMARY worker stream while the first KV appends / recurrent-state
1290 // reads run on the per-stage streams — no event orders them, and under
1291 // deferred readback the stage streams are hot immediately (a memset tail
1292 // can zero an already-appended KV row; intermittent, ~1-in-3 gate FAIL).
1293 // One context-sync per cache creation kills the class.
1294 let cache = crate::cache::Cache::new(e, cfg, max_ctx)?;
1295 sync_stages_after_load(e, n_trunk)?;
1296 return Ok(cache);
1297 }
1298 }
1299 crate::cache::Cache::new(e, cfg, max_ctx)
1300}
1301
1302/// M2 increment 2 LOAD BARRIER: weight uploads and decode-mirror builds enqueue on the
1303/// loading engines' WORKER streams; the first consumer launches on a DIFFERENT stream
1304/// with no load->decode event — the door-off reference walk on the primary worker
1305/// stream (sharded load: remote builds still in flight), or a fresh per-stage stream.
1306/// The 2026-08-02 gate finds (n2-dev01 step-0 168k-logit graze; split5 ref=0.0 head —
1307/// a half-built rp4 mirror — poisoning step-0 KV and every later step): one
1308/// context-wide synchronize per stage at load end kills the class. No-op when the door
1309/// is shut at load (single-stream load+decode is ordered by the stream itself).
1310pub fn sync_stages_after_load(e: &Engine, n_trunk: usize)
1311 -> Result<(), Box<dyn std::error::Error>> {
1312 if pp2_streams_off() || pp_cuts(n_trunk).is_none() {
1313 return Ok(());
1314 }
1315 let rt = PpNRt::get(e)?;
1316 for s in 0..rt.n_stages() {
1317 rt.stages[s].ctx.bind_to_thread()?;
1318 unsafe {
1319 cudarc::driver::sys::cuCtxSynchronize().result()?;
1320 }
1321 }
1322 e.ctx().bind_to_thread()?;
1323 unsafe {
1324 cudarc::driver::sys::cuCtxSynchronize().result()?;
1325 }
1326 Ok(())
1327}
1328
1329/// M2 increment 2 (weight sharding): the engine that should UPLOAD layer `il`'s weights
1330/// (and build its decode mirrors) — the owning stage's engine when the door is open with
1331/// device placement and sharding not rolled back; else the primary. `il >= n_trunk`
1332/// (MTP/NextN blocks) maps to the last stage. The head (output_norm + lm head) belongs
1333/// to the last trunk layer's stage — call with `il = n_trunk - 1`.
1334pub fn layer_engine<'a>(e: &'a Engine, n_trunk: usize, il: usize)
1335 -> Result<&'a Engine, Box<dyn std::error::Error>> {
1336 if pp_shard_off() || pp2_devices_env().is_none() || pp2_streams_off() {
1337 return Ok(e);
1338 }
1339 let Some(fence) = pp_cuts(n_trunk) else { return Ok(e) };
1340 let rt = PpNRt::get(e)?;
1341 let s = stage_of(&fence, il.min(n_trunk - 1));
1342 Ok(rt.engine(s, e))
1343}
1344
1345/// Restore a cache checkpoint through each layer's owning engine.
1346///
1347/// `source = None` is an in-place rewind: the target already owns the append-only KV bytes and
1348/// only its lengths plus recurrent state move back to the snapshot. `Some(source)` restores into
1349/// a freshly allocated larger cache: checkpoint-valid KV rows are copied from the parked cache,
1350/// while recurrent state always comes from the checkpoint's owned device copies.
1351///
1352/// This cannot use `Cache::rollback(e, ...)` under cross-device PP: a single primary engine is
1353/// not the owner of every stage's cache buffers. The rare rewind/grow boundary synchronizes open
1354/// PP contexts before publishing the restored cache to the next request.
1355pub fn restore_cache_checkpoint(
1356 e: &Engine,
1357 cfg: &memra_gguf::config::ModelConfig,
1358 source: Option<&crate::cache::Cache>,
1359 target: &mut crate::cache::Cache,
1360 snap: &crate::cache::CacheSnapshot,
1361) -> Result<(), Box<dyn std::error::Error>> {
1362 let n = target.kv.len();
1363 if target.recur.len() != n
1364 || snap.kv_len.len() != n
1365 || snap.conv.len() != n
1366 || snap.ssm.len() != n
1367 || source.is_some_and(|s| s.kv.len() != n || s.recur.len() != n)
1368 {
1369 return Err("checkpoint cache layer-count mismatch".into());
1370 }
1371 if snap.pos > target.max_ctx {
1372 return Err(format!(
1373 "checkpoint pos {} exceeds target capacity {}",
1374 snap.pos, target.max_ctx,
1375 )
1376 .into());
1377 }
1378
1379 let n_trunk = (cfg.n_layer - cfg.nextn_predict_layers) as usize;
1380 for il in 0..n {
1381 let owner = layer_engine(e, n_trunk, il)?;
1382 let src_kv = source.map(|s| &s.kv[il]);
1383 match (src_kv, target.kv[il].as_mut(), snap.kv_len[il]) {
1384 (Some(Some(src)), Some(dst), Some(len)) => {
1385 if len > src.len || len > target.max_ctx {
1386 return Err(format!(
1387 "checkpoint layer {il} len {len} exceeds source {} or target {}",
1388 src.len, target.max_ctx,
1389 )
1390 .into());
1391 }
1392 if src.kv_dim_k != dst.kv_dim_k
1393 || src.kv_dim_v != dst.kv_dim_v
1394 || src.k_tok_bytes != dst.k_tok_bytes
1395 || src.v_tok_bytes != dst.v_tok_bytes
1396 {
1397 return Err(format!("checkpoint KV layout mismatch at layer {il}").into());
1398 }
1399 let kb = len * src.k_tok_bytes;
1400 let vb = len * src.v_tok_bytes;
1401 if kb > 0 {
1402 owner.copy_u8_into(&mut dst.k, 0, &src.k, kb)?;
1403 }
1404 if vb > 0 {
1405 owner.copy_u8_into(&mut dst.v, 0, &src.v, vb)?;
1406 }
1407 dst.len = len;
1408 owner.set_i32_one(&mut dst.len_d, len as i32)?;
1409 }
1410 (None, Some(dst), Some(len)) => {
1411 if len > dst.len || len > target.max_ctx {
1412 return Err(format!(
1413 "checkpoint layer {il} len {len} exceeds live {} or target {}",
1414 dst.len, target.max_ctx,
1415 )
1416 .into());
1417 }
1418 dst.len = len;
1419 owner.set_i32_one(&mut dst.len_d, len as i32)?;
1420 }
1421 (Some(None), None, None) | (None, None, None) => {}
1422 _ => return Err(format!("checkpoint KV kind mismatch at layer {il}").into()),
1423 }
1424
1425 match (
1426 target.recur[il].as_mut(),
1427 &snap.conv[il],
1428 &snap.ssm[il],
1429 ) {
1430 (Some(dst), Some(conv), Some(ssm)) => {
1431 if conv.len() != dst.conv_state.len() || ssm.len() != dst.ssm_state.len() {
1432 return Err(
1433 format!("checkpoint recurrent layout mismatch at layer {il}").into(),
1434 );
1435 }
1436 owner.copy_into(&mut dst.conv_state, 0, conv, conv.len())?;
1437 owner.copy_into(&mut dst.ssm_state, 0, ssm, ssm.len())?;
1438 }
1439 (None, None, None) => {}
1440 _ => {
1441 return Err(
1442 format!("checkpoint recurrent kind mismatch at layer {il}").into(),
1443 );
1444 }
1445 }
1446 }
1447 target.pos = snap.pos;
1448
1449 // Open PP uses per-stage streams/contexts; publish every restored plane before the caller
1450 // starts the next prime. Door-shut single-stream restores remain naturally ordered.
1451 sync_stages_after_load(e, n_trunk)?;
1452 if source.is_some() {
1453 // A grown cache replaces and drops the source immediately after this returns. Bound the
1454 // D2D copies first so an async-pool free cannot recycle a source plane prematurely.
1455 e.stream().synchronize()?;
1456 }
1457 Ok(())
1458}
1459
1460#[cfg(test)]
1461mod host_bounce_tests {
1462 use super::{boundary_transport, host_bounce_capacity, BoundaryTransport};
1463
1464 #[test]
1465 fn transport_selection_keeps_peer_default_and_bounces_only_cross_device() {
1466 assert_eq!(boundary_transport(false, false), BoundaryTransport::Local);
1467 assert_eq!(boundary_transport(false, true), BoundaryTransport::Local);
1468 assert_eq!(boundary_transport(true, false), BoundaryTransport::Peer);
1469 assert_eq!(
1470 boundary_transport(true, true),
1471 BoundaryTransport::HostBounce
1472 );
1473 }
1474
1475 #[test]
1476 fn step37_geometry_sizes_each_slot_from_the_prime_cap() {
1477 let (elems, bytes) = host_bounce_capacity(4096).expect("valid Step-3.7 geometry");
1478 assert_eq!(elems, 4096 * crate::cache::PRIME_CHUNK_MAX_TOKENS);
1479 assert_eq!(bytes, 64 * 1024 * 1024);
1480 }
1481
1482 #[test]
1483 fn host_bounce_capacity_rejects_invalid_or_overflowing_geometry() {
1484 assert!(host_bounce_capacity(0).is_err());
1485 assert!(host_bounce_capacity(usize::MAX).is_err());
1486 }
1487}