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 (`warn_unwired_once` fires).
63
64use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
65use std::sync::{Arc, Mutex, OnceLock};
66
67use cudarc::driver::{CudaContext, CudaEvent, CudaSlice, CudaStream};
68
69use crate::Engine;
70
71/// Returns the stage fence iff the ppN door is open: `MEMRA_PP_STAGES=N` (N >= 2) with a
72/// valid cut list. The fence has N+1 entries: `[0, c1, .., cN-1, n_layers]`; stage s runs
73/// layers `[fence[s], fence[s+1])`. Reads the environment on every call (gates toggle the
74/// door in-process); the cost is a few getenv per decode step, eager-loop noise.
75pub fn pp_cuts(n_layers: usize) -> Option<Vec<usize>> {
76 let n_st: usize = match std::env::var("MEMRA_PP_STAGES") {
77 Ok(v) if v.is_empty() || v == "0" || v == "1" => return None,
78 Ok(v) => match v.parse::<usize>() {
79 Ok(n) => n,
80 Err(_) => {
81 warn_bad_once(&format!("MEMRA_PP_STAGES={v} unparseable; door stays OFF"));
82 return None;
83 }
84 },
85 Err(_) => return None,
86 };
87 if n_st < 2 || n_st > n_layers {
88 warn_bad_once(&format!(
89 "MEMRA_PP_STAGES={n_st} outside [2, n_layers={n_layers}]; door stays OFF"
90 ));
91 return None;
92 }
93 let mut fence = Vec::with_capacity(n_st + 1);
94 fence.push(0usize);
95 if let Ok(s) = std::env::var("MEMRA_PP_SPLITS") {
96 let parts: Result<Vec<usize>, _> =
97 s.split(',').map(|p| p.trim().parse::<usize>()).collect();
98 match parts {
99 Ok(cuts) if cuts.len() == n_st - 1 => fence.extend(cuts),
100 _ => {
101 warn_bad_once(&format!(
102 "MEMRA_PP_SPLITS={s} invalid (want {} comma-separated cuts); door stays OFF",
103 n_st - 1
104 ));
105 return None;
106 }
107 }
108 } else if let Ok(v) = std::env::var("MEMRA_PP_SPLIT") {
109 // N=2 back-compat spelling. With N>2 a single split is ambiguous — fail the door
110 // loudly rather than guess (a silent even-split would fake a gate config).
111 if n_st != 2 {
112 warn_bad_once(&format!(
113 "MEMRA_PP_SPLIT={v} set with MEMRA_PP_STAGES={n_st}; use MEMRA_PP_SPLITS \
114 for N>2 — door stays OFF"
115 ));
116 return None;
117 }
118 match v.parse::<usize>() {
119 Ok(c) => fence.push(c),
120 Err(_) => {
121 warn_bad_once(&format!("MEMRA_PP_SPLIT={v} unparseable; door stays OFF"));
122 return None;
123 }
124 }
125 } else {
126 for s in 1..n_st {
127 fence.push(s * n_layers / n_st);
128 }
129 }
130 fence.push(n_layers);
131 for w in fence.windows(2) {
132 if w[0] >= w[1] {
133 warn_bad_once(&format!(
134 "pp stage fence {fence:?} not strictly increasing over [0, {n_layers}]; \
135 door stays OFF"
136 ));
137 return None;
138 }
139 }
140 Some(fence)
141}
142
143/// N=2 back-compat view of the door (the gemma4 arm and `pp2-gate` are 2-stage): `Some(cut)`
144/// iff the door is open with EXACTLY two stages.
145pub fn pp2_split(n_layers: usize) -> Option<usize> {
146 pp_cuts(n_layers).filter(|f| f.len() == 3).map(|f| f[1])
147}
148
149/// The stage that owns layer `il` under `fence` (see `pp_cuts`).
150pub fn stage_of(fence: &[usize], il: usize) -> usize {
151 debug_assert!(fence.len() >= 2);
152 match fence[1..fence.len() - 1].binary_search(&il) {
153 // fence[1..][k] == il means il is the FIRST layer of stage k+1
154 Ok(k) => k + 1,
155 Err(k) => k,
156 }
157}
158
159/// MEMRA_PP_STREAMS=0: rollback to the increment-1 same-stream seam (boundary = two plain
160/// dtod copies on the ambient compute stream, no per-stage streams/events/devices).
161pub fn pp2_streams_off() -> bool {
162 matches!(std::env::var("MEMRA_PP_STREAMS").as_deref(), Ok("0"))
163}
164
165/// True iff the ppN door would put TWO OR MORE stage streams on ONE device (devices
166/// unset = all stages on the primary; or an explicit placement with a repeated device).
167/// The deferred-readback (pipelined) arm is REFUSED in this regime: the 2026-08-02 x20
168/// soak record — singledev pipelined 13/20 PASS default, 7 failures each diverging at a
169/// different step (timing-race signature); MEMRA_PDL=0 went 20/20 on one soak but a
170/// second same-config soak on the auto-gated build failed 2/20 (n2) and battery-4 failed
171/// n4 — so PDL narrows the window without closing it, and the true root cause (same
172/// Engine kernels concurrent on two streams of one device) is NOT fixed by any flag yet.
173/// Cross-device pipelined (one stage stream per device) is 23/23 clean post-fix. Refuse
174/// loudly rather than return silently-wrong logits. Env-only read (callable pre-runtime).
175pub fn pp_multi_stream_same_device() -> bool {
176 let stages_open = std::env::var("MEMRA_PP_STAGES")
177 .map(|v| v.parse::<usize>().map(|n| n >= 2).unwrap_or(false))
178 .unwrap_or(false);
179 let devices = std::env::var("MEMRA_PP_DEVICES").ok().filter(|v| !v.is_empty());
180 if (!stages_open && devices.is_none()) || pp2_streams_off() {
181 return false;
182 }
183 match devices {
184 None => true, // door open, no placement: every stage stream lands on the primary
185 Some(s) => {
186 let mut v: Vec<&str> = s.split(',').map(|p| p.trim()).collect();
187 let n = v.len();
188 v.sort_unstable();
189 v.dedup();
190 v.len() < n // repeated device = shared-device streams
191 }
192 }
193}
194
195/// MEMRA_PP_OVERLAP=1: alternate the double-buffered boundary slots per step (the
196/// pipelining seed). Default OFF — scheduling structure only, never math. Read per step
197/// so gates can A/B in-process.
198pub fn pp2_overlap() -> bool {
199 matches!(std::env::var("MEMRA_PP_OVERLAP").as_deref(), Ok("1"))
200}
201
202/// M2 increment 2 rollback seam: MEMRA_PP_SHARD=0 = the M1 bring-up placement (all
203/// weights upload through the primary engine; remote stages peer-read). Default ON —
204/// under MEMRA_PP_DEVICES each stage's layer range uploads through its own engine.
205pub fn pp_shard_off() -> bool {
206 matches!(std::env::var("MEMRA_PP_SHARD").as_deref(), Ok("0"))
207}
208
209/// Raw `MEMRA_PP_DEVICES` (parsed/validated at PpNRt build — a bad string must fail the
210/// decode step loudly, never silently fall back to same-device and fake a gate PASS).
211fn pp2_devices_env() -> Option<String> {
212 std::env::var("MEMRA_PP_DEVICES").ok().filter(|v| !v.is_empty())
213}
214
215static WARNED_BAD: AtomicBool = AtomicBool::new(false);
216fn warn_bad_once(msg: &str) {
217 if !WARNED_BAD.swap(true, Ordering::Relaxed) {
218 eprintln!("[pp] {msg}");
219 }
220}
221
222static WARNED_UNWIRED: AtomicBool = AtomicBool::new(false);
223/// One-time notice when the door is set but the executing path has no pp arm
224/// (M2 wires the generic eager decode at any N and the gemma4 eager arm at N=2).
225pub fn warn_unwired_once(path: &str) {
226 let open = std::env::var("MEMRA_PP_STAGES")
227 .map(|v| !v.is_empty() && v != "0" && v != "1")
228 .unwrap_or(false);
229 if open && !WARNED_UNWIRED.swap(true, Ordering::Relaxed) {
230 eprintln!(
231 "[pp] MEMRA_PP_STAGES set but `{path}` has no pp arm at this N; running unsplit"
232 );
233 }
234}
235
236// ======================================================================================
237// PpNRt: the M2 transport runtime (per-stage streams, per-boundary events + slots)
238// ======================================================================================
239
240/// One pipeline stage's execution home: device, context, launch stream, and (for a stage
241/// remote to the primary engine's device) a dedicated Engine in that device's primary
242/// context (CUmodules are per-context).
243pub struct StageRt {
244 pub dev: usize,
245 pub ctx: Arc<CudaContext>,
246 pub stream: Arc<CudaStream>,
247 /// `Some` only when `dev` differs from the primary engine's device.
248 engine: Option<Engine>,
249}
250
251/// One boundary slot: a persistent RX-side buffer + its TX/RX completion events.
252/// PERSISTENT because the buffer is written by the TX stage's stream and read by the RX
253/// stage's: a per-step alloc/free would enqueue the free on ONE stream while the other
254/// might still be reading (the cross-stream free hazard) — a never-freed slot cannot race.
255struct BoundarySlot {
256 buf: Mutex<Option<CudaSlice<f32>>>,
257 /// Recorded on the TX stage's stream after the TX copy; RX waits on it. Created in
258 /// the TX stage's context (cuEventRecord requires event ctx == stream ctx).
259 ev_tx: CudaEvent,
260 /// Recorded on the RX stage's stream after the RX copy; the NEXT TX into this slot
261 /// waits on it (write-after-read guard). Created in the RX stage's context. Waiting
262 /// on a never-recorded event is a defined no-op, so step 0 needs no special case.
263 ev_rx: CudaEvent,
264}
265
266/// Boundary b sits between stage b (TX) and stage b+1 (RX). Two slots, alternating per
267/// step under MEMRA_PP_OVERLAP=1 (each boundary counts its own steps — a decode step
268/// crosses every boundary exactly once, so the counters stay in lockstep).
269struct BoundaryRt {
270 slots: [BoundarySlot; 2],
271 step: AtomicUsize,
272 /// true iff stage b and stage b+1 live on different devices (peer transport).
273 cross: bool,
274}
275
276pub struct PpNRt {
277 stages: Vec<StageRt>,
278 boundaries: Vec<BoundaryRt>,
279 /// true iff ANY boundary crosses devices.
280 cross_any: bool,
281 /// Dedicated readback stream in the LAST stage's context (deferred logits D2H —
282 /// waiting there instead of on the compute stream keeps later tokens enqueuable).
283 readback: Arc<CudaStream>,
284}
285
286/// M1 name kept alive for external callers (`pp-transport-smoke`, receipts, docs).
287pub type Pp2Rt = PpNRt;
288
289static RTN: OnceLock<Result<PpNRt, String>> = OnceLock::new();
290
291impl PpNRt {
292 /// The process-wide transport runtime, built on first use against the primary engine.
293 /// The stage count + device map freeze at first build (one config per process — gates
294 /// run one placement per invocation). Build errors are sticky and loud.
295 pub fn get(e: &Engine) -> Result<&'static PpNRt, Box<dyn std::error::Error>> {
296 RTN.get_or_init(|| Self::build(e).map_err(|err| err.to_string()))
297 .as_ref()
298 .map_err(|s| -> Box<dyn std::error::Error> { s.clone().into() })
299 }
300
301 fn build(e: &Engine) -> Result<PpNRt, Box<dyn std::error::Error>> {
302 let primary_dev = e.ctx().ordinal();
303 // Stage count: MEMRA_PP_DEVICES length wins when set (it IS the placement);
304 // else MEMRA_PP_STAGES; else 2 (the M1 default — pp-transport-smoke runs doorless).
305 let devices: Vec<usize> = match pp2_devices_env() {
306 Some(s) => {
307 let parts: Result<Vec<usize>, _> =
308 s.split(',').map(|p| p.trim().parse::<usize>()).collect();
309 match parts {
310 Ok(v) if v.len() >= 2 => v,
311 _ => {
312 return Err(format!(
313 "MEMRA_PP_DEVICES={s} unparseable (want <d0>,..,<dN-1> e.g. 0,1,2,3)"
314 )
315 .into())
316 }
317 }
318 }
319 None => {
320 let n_st = std::env::var("MEMRA_PP_STAGES")
321 .ok()
322 .and_then(|v| v.parse::<usize>().ok())
323 .filter(|&n| n >= 2)
324 .unwrap_or(2);
325 vec![primary_dev; n_st]
326 }
327 };
328 if let Ok(v) = std::env::var("MEMRA_PP_STAGES") {
329 if let Ok(n) = v.parse::<usize>() {
330 if n >= 2 && n != devices.len() {
331 return Err(format!(
332 "MEMRA_PP_DEVICES lists {} devices but MEMRA_PP_STAGES={n} — \
333 refusing an ambiguous placement",
334 devices.len()
335 )
336 .into());
337 }
338 }
339 }
340 let n_st = devices.len();
341 let cross_any = devices.iter().any(|&d| d != devices[0]);
342
343 // Every distinct device pair in use must peer-access BOTH ways: boundaries copy
344 // between consecutive stages, stage kernels may dereference primary-device weights
345 // (bring-up placement / MEMRA_PP_SHARD=0) and stage-0's pos_d upload.
346 let mut used: Vec<usize> = devices.clone();
347 used.push(primary_dev);
348 used.sort_unstable();
349 used.dedup();
350 if used.len() > 1 {
351 let n = cudarc::driver::result::device::get_count()? as usize;
352 for &d in &used {
353 if d >= n {
354 return Err(format!(
355 "MEMRA_PP_DEVICES={devices:?} but only {n} CUDA device(s) present"
356 )
357 .into());
358 }
359 }
360 for &a in &used {
361 for &b in &used {
362 if a == b {
363 continue;
364 }
365 let da = cudarc::driver::result::device::get(a as i32)?;
366 let db = cudarc::driver::result::device::get(b as i32)?;
367 let mut can: i32 = 0;
368 unsafe {
369 cudarc::driver::sys::cuDeviceCanAccessPeer(&mut can, da, db).result()?;
370 }
371 if can == 0 {
372 return Err(format!(
373 "device {a} cannot peer-access device {b} (cuDeviceCanAccessPeer=0); \
374 ppN cross-device needs P2P — refusing a silently-staged path"
375 )
376 .into());
377 }
378 }
379 }
380 }
381
382 // PER-STAGE ENGINE ISOLATION (2026-08-02 singledev pipelined find): Engine owns
383 // lazily-grown SHARED scratch pools (fa_part_pool, fa_vf16_scratch, argmax
384 // partials, ...) that are stable-pointer by design — safe on one stream, a data
385 // race the moment two stage streams run concurrently through the SAME Engine
386 // (deferred readback, >=2 tokens in flight: token t+1's stage-0 fa memsets the
387 // partials while token t's stage-s fa still reads them — the nondeterministic
388 // all-logits divergence; cross-device arms were immune because remote stages
389 // already got their own Engine). Every stage s>0 gets its OWN Engine even on the
390 // primary device: same CUcontext (primary retain), so the per-context CUmodule
391 // cache makes it cheap; scratch pools are per-Engine, so stages never share.
392 // Stage 0 keeps the primary engine (single-threaded host issue: the only
393 // concurrent user of `e` during a pp walk is stage 0 itself).
394 let mk_stage = |dev: usize, s: usize| -> Result<StageRt, Box<dyn std::error::Error>> {
395 if dev == primary_dev && s == 0 {
396 let ctx = e.ctx().clone();
397 let stream = ctx.new_stream()?;
398 Ok(StageRt { dev, ctx, stream, engine: None })
399 } else {
400 let eng = Engine::new(dev)?;
401 let ctx = eng.ctx().clone();
402 let stream = ctx.new_stream()?;
403 Ok(StageRt { dev, ctx, stream, engine: Some(eng) })
404 }
405 };
406 let mut stages = Vec::with_capacity(n_st);
407 for (s, &d) in devices.iter().enumerate() {
408 stages.push(mk_stage(d, s)?);
409 }
410
411 if used.len() > 1 {
412 // A context per distinct device (first stage that lives there; the primary's
413 // context for the primary device).
414 let ctx_of = |d: usize| -> &Arc<CudaContext> {
415 if d == primary_dev {
416 e.ctx()
417 } else {
418 &stages.iter().find(|s| s.dev == d).unwrap().ctx
419 }
420 };
421 // Enable peer access BOTH ways for every distinct pair (idempotent;
422 // ALREADY_ENABLED is success).
423 for &a in &used {
424 for &b in &used {
425 if a == b {
426 continue;
427 }
428 ctx_of(a).bind_to_thread()?;
429 let rc = unsafe {
430 cudarc::driver::sys::cuCtxEnablePeerAccess(ctx_of(b).cu_ctx(), 0)
431 };
432 use cudarc::driver::sys::cudaError_enum as E;
433 if rc != E::CUDA_SUCCESS && rc != E::CUDA_ERROR_PEER_ACCESS_ALREADY_ENABLED {
434 return Err(format!(
435 "cuCtxEnablePeerAccess(dev{a} -> dev{b}) failed: {rc:?}"
436 )
437 .into());
438 }
439 }
440 }
441 // MEM-POOL access grant (8x box 2026-08-02, M1 cross-device fix #2):
442 // cuCtxEnablePeerAccess does NOT map STREAM-ORDERED POOL allocations, and every
443 // engine buffer/weight goes through the device default pool (cuMemAllocAsync via
444 // cudarc; memra-runtime configures that pool). A stage kernel dereferencing
445 // another device's weights — or a boundary peer TX writing the RX slot — needs
446 // cuMemPoolSetAccess on the OWNING device's default pool for the ACCESSING
447 // device; without it the first remote dereference is CUDA_ERROR_ILLEGAL_ADDRESS
448 // (reported at the next API call in the poisoned context). Grant all pairs.
449 for &owner in &used {
450 for &accessor in &used {
451 if owner == accessor {
452 continue;
453 }
454 let dev = cudarc::driver::result::device::get(owner as i32)?;
455 let mut pool: cudarc::driver::sys::CUmemoryPool = std::ptr::null_mut();
456 unsafe {
457 cudarc::driver::sys::cuDeviceGetDefaultMemPool(&mut pool, dev).result()?;
458 }
459 let desc = cudarc::driver::sys::CUmemAccessDesc {
460 location: cudarc::driver::sys::CUmemLocation {
461 type_: cudarc::driver::sys::CUmemLocationType::CU_MEM_LOCATION_TYPE_DEVICE,
462 id: accessor as i32,
463 },
464 flags: cudarc::driver::sys::CUmemAccess_flags::CU_MEM_ACCESS_FLAGS_PROT_READWRITE,
465 };
466 let rc = unsafe { cudarc::driver::sys::cuMemPoolSetAccess(pool, &desc, 1) };
467 if rc != cudarc::driver::sys::cudaError_enum::CUDA_SUCCESS {
468 return Err(format!(
469 "cuMemPoolSetAccess(dev{owner} pool -> dev{accessor}) failed: {rc:?}"
470 )
471 .into());
472 }
473 }
474 }
475 // MEM-POOL access grant (8x box 2026-08-02, cross-device fix #2):
476 // cuCtxEnablePeerAccess does NOT map STREAM-ORDERED POOL allocations, and every
477 // engine buffer/weight goes through the device default pool (cuMemAllocAsync via
478 // cudarc; memra-runtime configures that pool). A stage-1 kernel dereferencing
479 // dev0 weights — or the stage-0 peer TX writing dev1's RX slot — needs
480 // cuMemPoolSetAccess on the OWNING device's default pool for the ACCESSING
481 // device; without it the first remote dereference is CUDA_ERROR_ILLEGAL_ADDRESS
482 // (reported at the next API call in the poisoned context). Grant both ways.
483 for (owner, accessor) in [(stages[0].dev, stages[1].dev), (stages[1].dev, stages[0].dev)] {
484 let dev = cudarc::driver::result::device::get(owner as i32)?;
485 let mut pool: cudarc::driver::sys::CUmemoryPool = std::ptr::null_mut();
486 unsafe {
487 cudarc::driver::sys::cuDeviceGetDefaultMemPool(&mut pool, dev).result()?;
488 }
489 let desc = cudarc::driver::sys::CUmemAccessDesc {
490 location: cudarc::driver::sys::CUmemLocation {
491 type_: cudarc::driver::sys::CUmemLocationType::CU_MEM_LOCATION_TYPE_DEVICE,
492 id: accessor as i32,
493 },
494 flags: cudarc::driver::sys::CUmemAccess_flags::CU_MEM_ACCESS_FLAGS_PROT_READWRITE,
495 };
496 let rc = unsafe { cudarc::driver::sys::cuMemPoolSetAccess(pool, &desc, 1) };
497 if rc != cudarc::driver::sys::cudaError_enum::CUDA_SUCCESS {
498 return Err(format!(
499 "cuMemPoolSetAccess(dev{owner} pool -> dev{accessor}) failed: {rc:?}"
500 )
501 .into());
502 }
503 }
504 // restore the primary context for the caller's subsequent work
505 e.ctx().bind_to_thread()?;
506 eprintln!(
507 "[pp] cross-device transport: {} (cudaMemcpyPeerAsync per cross boundary; \
508 peer + default-pool access granted all pairs over {used:?}; weight home: {})",
509 devices
510 .iter()
511 .enumerate()
512 .map(|(s, d)| format!("stage{s}=dev{d}"))
513 .collect::<Vec<_>>()
514 .join(" "),
515 if pp_shard_off() {
516 format!("dev{primary_dev} (MEMRA_PP_SHARD=0 bring-up placement)")
517 } else {
518 "per-stage (sharded loader)".to_string()
519 }
520 );
521 }
522
523 let mk_slot = |tx: &StageRt, rx: &StageRt| -> Result<BoundarySlot, Box<dyn std::error::Error>> {
524 Ok(BoundarySlot {
525 buf: Mutex::new(None),
526 ev_tx: tx.ctx.new_event(None)?,
527 ev_rx: rx.ctx.new_event(None)?,
528 })
529 };
530 let mut boundaries = Vec::with_capacity(n_st - 1);
531 for b in 0..n_st - 1 {
532 let (tx, rx) = (&stages[b], &stages[b + 1]);
533 boundaries.push(BoundaryRt {
534 slots: [mk_slot(tx, rx)?, mk_slot(tx, rx)?],
535 step: AtomicUsize::new(0),
536 cross: tx.dev != rx.dev,
537 });
538 }
539 let readback = stages[n_st - 1].ctx.new_stream()?;
540 Ok(PpNRt { stages, boundaries, cross_any, readback })
541 }
542
543 pub fn n_stages(&self) -> usize {
544 self.stages.len()
545 }
546
547 /// True iff any boundary crosses devices (transport = cudaMemcpyPeerAsync there).
548 pub fn cross_device(&self) -> bool {
549 self.cross_any
550 }
551
552 /// The engine a stage's subgraph must run through: the primary engine when the stage
553 /// lives on the primary device, else the stage's own (remote-context) engine.
554 pub fn engine<'a>(&'a self, s: usize, primary: &'a Engine) -> &'a Engine {
555 self.stages[s].engine.as_ref().unwrap_or(primary)
556 }
557
558 /// Enter stage `s`: until the guard drops, every engine op on this thread launches on
559 /// the stage's stream (memra_runtime ambient-stream override).
560 pub fn enter(&self, s: usize) -> memra_runtime::StreamOverride {
561 memra_runtime::push_stream_override(self.stages[s].stream.clone())
562 }
563
564 /// Boundary TX at boundary `b` (call within the stage-`b` scope; `x` = the
565 /// materialized [n] residual): wait for the slot's previous RX (write-after-read
566 /// guard), copy `x` into the slot's persistent buffer via the boundary's transport on
567 /// stage-b's stream (the owning-stream/publication law), record ev_tx. Returns the
568 /// slot index for the paired rx().
569 pub fn tx(&self, b: usize, x: &CudaSlice<f32>, n: usize)
570 -> Result<usize, Box<dyn std::error::Error>> {
571 assert_eq!(x.len(), n, "pp tx: residual length mismatch");
572 let bd = &self.boundaries[b];
573 let slot_idx = if pp2_overlap() {
574 bd.step.fetch_add(1, Ordering::Relaxed) % 2
575 } else {
576 0
577 };
578 let sl = &bd.slots[slot_idx];
579 let s_tx = &self.stages[b].stream;
580 s_tx.wait(&sl.ev_rx)?;
581 let mut guard = sl.buf.lock().unwrap();
582 if guard.as_ref().map(|bf| bf.len() != n).unwrap_or(true) {
583 // allocated on the RX stage's stream: the buffer lives on the RX device.
584 let s_rx = &self.stages[b + 1].stream;
585 *guard = Some(s_rx.alloc_zeros::<f32>(n)?);
586 // SLOT FIRST-USE ORDERING (2026-08-02 pipelined-gate find): the lazy alloc's
587 // pool-alloc + memset enqueue on the RX stream; the TX copy below issues on
588 // the TX stream, and on a slot's FIRST use ev_rx has never been recorded —
589 // nothing orders them. With >=2 tokens in flight the RX stream is still busy
590 // with the previous token, the memset lands AFTER the TX copy, and the
591 // boundary residual is zeroed (window=1 passed, window>=2 failed at the
592 // slot-1 first-use step; -overlap arms passed because the synchronous serial
593 // arm pre-warmed both slots). Host-sync the RX stream once per slot
594 // allocation — at most 2*(N-1) one-time syncs per process, all during prime.
595 s_rx.synchronize()?;
596 }
597 let buf = guard.as_mut().unwrap();
598 if !bd.cross {
599 s_tx.memcpy_dtod(x, buf)?;
600 } else {
601 // cudaMemcpyPeerAsync (M0: 2.8x NCCL at PP activation sizes), issued on the
602 // publishing TX stream with explicit src/dst contexts.
603 use cudarc::driver::{DevicePtr, DevicePtrMut};
604 let (sp, _g0) = x.device_ptr(s_tx);
605 let (dp, _g1) = buf.device_ptr_mut(s_tx);
606 self.stages[b].ctx.bind_to_thread()?;
607 unsafe {
608 cudarc::driver::result::memcpy_peer_async(
609 self.stages[b + 1].ctx.cu_ctx(),
610 dp,
611 self.stages[b].ctx.cu_ctx(),
612 sp,
613 n * std::mem::size_of::<f32>(),
614 s_tx.cu_stream(),
615 )?;
616 }
617 }
618 sl.ev_tx.record(s_tx)?;
619 Ok(slot_idx)
620 }
621
622 /// Boundary RX at boundary `b` (call within the stage-`b+1` scope): wait on the slot's
623 /// ev_tx, copy the boundary buffer into a fresh working buffer (dtod on the RX stream —
624 /// local on the RX device in both transports), record ev_rx. The returned buffer is
625 /// RX-stage-owned: allocated, consumed, and eventually freed on that stage's stream.
626 pub fn rx(&self, b: usize, slot_idx: usize, n: usize)
627 -> Result<CudaSlice<f32>, Box<dyn std::error::Error>> {
628 let sl = &self.boundaries[b].slots[slot_idx];
629 let s_rx = &self.stages[b + 1].stream;
630 s_rx.wait(&sl.ev_tx)?;
631 let guard = sl.buf.lock().unwrap();
632 let buf = guard.as_ref().expect("pp rx before tx");
633 // uninit working buffer (fully overwritten by the copy), allocated explicitly on
634 // the stage stream so rx() is correct even outside an enter() scope.
635 let mut work = unsafe { s_rx.alloc::<f32>(n)? };
636 s_rx.memcpy_dtod(buf, &mut work)?;
637 sl.ev_rx.record(s_rx)?;
638 Ok(work)
639 }
640
641 /// Deferred readback: record a fresh completion event on the LAST stage's stream
642 /// (call after the step's logits matmul has been enqueued there).
643 pub fn record_done(&self) -> Result<CudaEvent, Box<dyn std::error::Error>> {
644 let last = &self.stages[self.stages.len() - 1];
645 let ev = last.ctx.new_event(None)?;
646 ev.record(&last.stream)?;
647 Ok(ev)
648 }
649
650 /// The dedicated readback stream (last stage's context).
651 pub fn readback_stream(&self) -> &Arc<CudaStream> {
652 &self.readback
653 }
654}
655
656/// M2 increment 3: a step's logits, still device-resident on the LAST stage. `wait()`
657/// orders the readback stream behind the step's completion event, copies, and syncs —
658/// tokens enqueued after this step keep running on the stage streams while the caller
659/// drains token t. Dropping without waiting is safe (buffers free stream-ordered).
660pub struct PendingLogits {
661 logits: CudaSlice<f32>,
662 ev: CudaEvent,
663 rb: Arc<CudaStream>,
664}
665
666impl PendingLogits {
667 pub fn new(logits: CudaSlice<f32>, ev: CudaEvent, rb: Arc<CudaStream>) -> Self {
668 PendingLogits { logits, ev, rb }
669 }
670
671 /// Blocks until this step's logits are computed, returns them host-side. Only this
672 /// step's work is waited on (event-ordered) — NOT later tokens already enqueued on
673 /// the stage streams.
674 pub fn wait(self) -> Result<Vec<f32>, Box<dyn std::error::Error>> {
675 self.rb.wait(&self.ev)?;
676 let host = self.rb.clone_dtoh(&self.logits)?;
677 self.rb.synchronize()?;
678 // logits drop AFTER the sync: the D2H has fully completed, so the stream-ordered
679 // free on the compute stream cannot race the copy.
680 Ok(host)
681 }
682}
683
684/// Stage-owned cache allocation door: when the ppN door is open AND `MEMRA_PP_DEVICES`
685/// is set (placement plumbing), each layer's cache is allocated by its OWNING stage's
686/// engine — on one device this is byte-for-byte today's allocation (gated); cross-device
687/// it puts each stage's KV on that stage's HBM. Door shut or devices unset: plain
688/// `Cache::new` (zero behavior change). Trailing MTP/NextN layers (beyond the trunk)
689/// map to the LAST stage.
690pub fn new_cache(e: &Engine, cfg: &memra_gguf::config::ModelConfig, max_ctx: usize)
691 -> Result<crate::cache::Cache, Box<dyn std::error::Error>> {
692 let n_trunk = (cfg.n_layer - cfg.nextn_predict_layers) as usize;
693 if let Some(fence) = pp_cuts(n_trunk) {
694 if pp2_devices_env().is_some() && !pp2_streams_off() {
695 let rt = PpNRt::get(e)?;
696 let n_st = fence.len() - 1;
697 assert_eq!(
698 rt.n_stages(), n_st,
699 "PpNRt stage count {} != fence stages {n_st}", rt.n_stages()
700 );
701 let devs: Vec<&dyn memra_kv::KvDev> =
702 (0..n_st).map(|s| rt.engine(s, e) as &dyn memra_kv::KvDev).collect();
703 let cache = crate::cache::Cache::new_ppn(&devs, &fence, cfg, max_ctx)?;
704 sync_stages_after_load(e, n_trunk)?;
705 return Ok(cache);
706 }
707 if !pp2_streams_off() {
708 // CACHE BIRTH BARRIER (2026-08-02 pipelined-arm residual race): with the door
709 // open but no device placement, Cache::new's alloc_zeros memsets enqueue on
710 // the PRIMARY worker stream while the first KV appends / recurrent-state
711 // reads run on the per-stage streams — no event orders them, and under
712 // deferred readback the stage streams are hot immediately (a memset tail
713 // can zero an already-appended KV row; intermittent, ~1-in-3 gate FAIL).
714 // One context-sync per cache creation kills the class.
715 let cache = crate::cache::Cache::new(e, cfg, max_ctx)?;
716 sync_stages_after_load(e, n_trunk)?;
717 return Ok(cache);
718 }
719 }
720 crate::cache::Cache::new(e, cfg, max_ctx)
721}
722
723/// M2 increment 2 LOAD BARRIER: weight uploads and decode-mirror builds enqueue on the
724/// loading engines' WORKER streams; the first consumer launches on a DIFFERENT stream
725/// with no load->decode event — the door-off reference walk on the primary worker
726/// stream (sharded load: remote builds still in flight), or a fresh per-stage stream.
727/// The 2026-08-02 gate finds (n2-dev01 step-0 168k-logit graze; split5 ref=0.0 head —
728/// a half-built rp4 mirror — poisoning step-0 KV and every later step): one
729/// context-wide synchronize per stage at load end kills the class. No-op when the door
730/// is shut at load (single-stream load+decode is ordered by the stream itself).
731pub fn sync_stages_after_load(e: &Engine, n_trunk: usize)
732 -> Result<(), Box<dyn std::error::Error>> {
733 if pp2_streams_off() || pp_cuts(n_trunk).is_none() {
734 return Ok(());
735 }
736 let rt = PpNRt::get(e)?;
737 for s in 0..rt.n_stages() {
738 rt.stages[s].ctx.bind_to_thread()?;
739 unsafe {
740 cudarc::driver::sys::cuCtxSynchronize().result()?;
741 }
742 }
743 e.ctx().bind_to_thread()?;
744 unsafe {
745 cudarc::driver::sys::cuCtxSynchronize().result()?;
746 }
747 Ok(())
748}
749
750/// M2 increment 2 (weight sharding): the engine that should UPLOAD layer `il`'s weights
751/// (and build its decode mirrors) — the owning stage's engine when the door is open with
752/// device placement and sharding not rolled back; else the primary. `il >= n_trunk`
753/// (MTP/NextN blocks) maps to the last stage. The head (output_norm + lm head) belongs
754/// to the last trunk layer's stage — call with `il = n_trunk - 1`.
755pub fn layer_engine<'a>(e: &'a Engine, n_trunk: usize, il: usize)
756 -> Result<&'a Engine, Box<dyn std::error::Error>> {
757 if pp_shard_off() || pp2_devices_env().is_none() || pp2_streams_off() {
758 return Ok(e);
759 }
760 let Some(fence) = pp_cuts(n_trunk) else { return Ok(e) };
761 let rt = PpNRt::get(e)?;
762 let s = stage_of(&fence, il.min(n_trunk - 1));
763 Ok(rt.engine(s, e))
764}