1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
//! The batched routed half of a prefill micro-batch layer
//! (`docs/BATCHED_PREFILL.md` steps 2 and 3): ONE expert-cache plan and
//! `pread` round per union-bounded sub-batch, the route-list kernels from
//! `gpu::moe_prefill_batch`, and the per-token sandwich tail unchanged.
//!
//! Byte-identity with the per-token path is structural, not hoped for:
//! the batched kernels are bit-exact against M sequential decode-pair
//! calls (`crates/gpu/tests/moe_prefill_batch_parity.rs`), and the tail
//! kernels are the same dispatches at row offsets. A sub-batch keeps
//! every expert of its UNION resident at once, which is what caps it
//! near M=8 at 32 slots -- the constraint the design doc measured, and
//! the reason the greedy shrink below exists (`union(M) <= slot_count`,
//! or `ExpertCache::plan` aborts rather than degrades).
use std::collections::HashSet;
use std::time::Instant;
use crate::moe_prefill_pipeline::{encode_routes, next_routed_sub_batch};
use crate::real_forward::RealForwardRunner;
use crate::real_forward_dispatch::router_topk_gemma4;
use crate::real_forward_layout::RoutedBlobLayout;
use crate::real_forward_types::RealForwardError;
use crate::real_forward_utils::{f16_slice_to_le_bytes, layer_tensor, norm_view};
use crate::resid_capture::encode_resid_capture;
use crate::steering::encode_steering;
use super::moe;
const RMS_EPS: f32 = 1e-6;
impl RealForwardRunner {
/// One layer's routed half for all `m` tokens of the micro-batch:
/// shared-expert branches first (each its own committed command
/// buffer, overlapping the plan and `pread` below), then one routed
/// command buffer carrying every sub-batch's route-list dispatches
/// and every token's sandwich tail. Returns the committed buffer for
/// the driver to retire after the next layer's router wait, exactly
/// as the per-token path does.
#[allow(clippy::too_many_arguments)]
pub(crate) fn encode_gemma4_layer_routed_moe_batched(
&mut self,
layer: usize,
hidden: usize,
inter: usize,
moe_inter: u32,
num_experts: usize,
top_k: usize,
use_silu: bool,
m: usize,
) -> Result<gpu::CommittedPass, RealForwardError> {
let gpu_err = RealForwardError::Gpu;
let layout = self.routed_layouts[layer];
if layout.phase1 != RoutedBlobLayout::Affine || layout.phase2 != RoutedBlobLayout::Affine {
// Refused rather than looped: a caller that asked for the
// batched half and quietly got the per-token kernels would
// measure the unbatched engine and report it as batched (the
// `encode_gemm_any` doctrine). The GGUF pairs are step 5.
return Err(RealForwardError::Unsupported(format!(
"batched routed prefill is wired for INT4-affine blobs only; layer {layer} phase1 {:?} phase2 {:?}",
layout.phase1, layout.phase2
)));
}
// MAPPED RESIDENCY AND THE BATCHED ROUTED PAIR CANNOT BOTH RUN, and
// the reason is structural rather than a missing branch. This driver
// binds `slot_buffers[layer]` ONCE per layer -- an array indexed by
// CACHE SLOT, which `MoePrefillRoute::slot` indexes into -- because
// sub-batches share the layer's slot cache. Mapped residency has no
// slot cache at all: it has one buffer per layer plus a per-expert
// offset, and the experts to bind are the SUB-BATCH's union, which
// changes inside the loop below. Serving both means re-binding per
// sub-batch instead of per layer, which is a change to this driver
// rather than a branch in it.
//
// Refused here rather than at `open` because `set_routed_batch_prefill`
// can flip this seam AFTER open, so an open-time env read would miss
// the setter -- and this is the site that genuinely cannot serve the
// request, which is where the driver's other refusals already live.
//
// Deleting this arm was MEASURED rather than reasoned about: the run
// trips `assert!(!blobs.is_empty() ...)` in `gpu::moe_prefill_batch`'s
// argument encoder, because `slot_buffers[layer]` is empty under this
// mode. That is a plain `assert!` and aborts in RELEASE, so the
// failure is loud rather than silent -- but it names neither seam and
// comes from a crate the caller never asked for.
if self.mapped.buffers.get(layer).is_some_and(Option::is_some) {
return Err(RealForwardError::Unsupported(format!(
"batched routed prefill (TURBOSPARK_ROUTED_BATCH) and mapped expert \
residency (TURBOSPARK_EXPERT_RESIDENCY=mapped) cannot be combined: this \
pair binds one buffer per CACHE SLOT and mapped residency has no slot \
cache (layer {layer}). Pick one"
)));
}
if self.slot_buffers[layer].len() > gpu::MAX_PREFILL_EXPERT_BINDINGS {
return Err(RealForwardError::Unsupported(format!(
"batched routed prefill needs slot indices below {}; this install has {} slots",
gpu::MAX_PREFILL_EXPERT_BINDINGS,
self.slot_buffers[layer].len()
)));
}
if self
.router_hist
.as_ref()
.is_some_and(crate::router_hist::RouterHistogram::pilot_enabled)
{
return Err(RealForwardError::Unsupported(format!(
"batched routed prefill (TURBOSPARK_ROUTED_BATCH) and the router pilot probe \
(TURBOSPARK_PILOT_PROBE) cannot be combined (layer {layer})"
)));
}
let per_expert_scale = self
.real
.as_ref()
.expect("real state present")
.per_expert_scale[layer]
.clone();
let batch_h1 = self
.real
.as_ref()
.expect("real state present")
.batched()
.batch_h1
.clone();
// Shared-expert branches for every token up front, so the GPU
// drains them while the host plans and preads the routed union.
// (`TURBOSPARK_SHARED_CB=0` reverts the DECODE path to inline
// encoding; the batched path always overlaps, because the union
// `pread` below is one large host stall to hide.)
//
// Under `TURBOSPARK_BATCHED_GEMV` all M tokens share ONE command
// buffer and three M-row GEMMs; otherwise each rides its own
// committed buffer with three GEMVs, which is what shipped.
// Keep the draining handles alive across every fallible routed-I/O
// step below without serializing the shared passes at submission.
let mut _shared_passes = Vec::with_capacity(if self.batched_gemv_prefill { 1 } else { m });
if self.batched_gemv_prefill {
_shared_passes
.push(self.encode_shared_expert_branch_batched(layer, hidden, inter, use_silu, m)?);
} else {
for t in 0..m {
let slot = moe::RoutedSlot {
token: t,
bank: 0,
protect: HashSet::new(),
};
_shared_passes.push(self.encode_shared_expert_branch(
layer,
hidden,
inter,
use_silu,
&slot,
(&batch_h1, (t * hidden * 2) as u64),
)?);
}
}
// Router readback for the WHOLE micro-batch at once, then host
// top-k per token -- the readback is the per-layer blocking wait
// the chunk driver exists to amortize, so pay it once.
let t_router = Instant::now();
let router_logits = {
let real = self.real.as_ref().expect("real state present");
gpu::read_f32_buffer_at(&real.router_logits_f32, 0, m * num_experts)
};
let mut selected_all = Vec::with_capacity(m);
let mut weights_all = Vec::with_capacity(m);
for t in 0..m {
let (selected, route_weights) = router_topk_gemma4(
&router_logits[t * num_experts..(t + 1) * num_experts],
top_k,
&per_expert_scale,
);
if let Some(hist) = self.router_hist.as_mut() {
hist.record(layer, &selected);
}
selected_all.push(selected);
weights_all.push(route_weights);
}
self.phases.router_nanos += t_router.elapsed().as_nanos() as u64;
// The wide argument buffer is bound ONCE per layer with every
// cache slot: sub-batches share the layer's slot cache, the bind
// is a host write that must precede the commit of any dispatch
// reading it, and the only bind that could race an in-flight
// reader is the NEXT layer's -- which happens after this layer's
// last sub-batch is retired.
let blob_refs: Vec<(gpu::MetalBuffer, u64)> = self.slot_buffers[layer]
.iter()
.map(|b| (b.clone(), 0u64))
.collect();
let wide = self
.real
.as_ref()
.expect("real state present")
.batched()
.wide_blobs
.clone();
let t_bind = Instant::now();
let bind_refs: Vec<(&gpu::MetalBuffer, u64)> =
blob_refs.iter().map(|(b, o)| (b, *o)).collect();
wide.bind(&mut self.context, use_silu, &bind_refs)
.map_err(gpu_err)?;
self.phases.bind_nanos += t_bind.elapsed().as_nanos() as u64;
// Sub-batches: greedily as many tokens as the union of their
// routes fits the slot cache. At 32 slots that lands near M=8 on
// the measured union table, at 16 near M=2; a layer whose routing
// is unusually scattered shrinks further, which is why the bound
// is computed per layer rather than frozen.
//
// EACH SUB-BATCH COMMITS ITS OWN COMMAND BUFFER, and the next
// sub-batch's plan WAITS for it first: a later sub-batch's
// `pread` evicts experts from slots an earlier sub-batch's
// dispatches still name, and those `pread`s are host-side work
// the queue knows nothing about -- committing alone does not
// order them. (One command buffer per layer was tried and the
// 8-slot runtime test caught exactly this: fluent wrong logits,
// because union-of-micro-batch > slots is what forces a second
// sub-batch.) The LAST sub-batch stays in flight for the driver
// to retire after the next layer's router wait, exactly as the
// per-token path pipelines its final token.
let cap = self
.expert_cache_slots
.min(gpu::MAX_PREFILL_EXPERT_BINDINGS);
let moe_inter_us = moe_inter as usize;
let mut committed: Option<gpu::CommittedPass> = None;
let mut sub_start = 0usize;
while sub_start < m {
// Nothing this sub-batch's `pread` evicts may still be named
// by an in-flight dispatch: wait out the previous sub-batch's
// command buffer before planning this one.
self.retire_routed(&mut committed);
let sub = next_routed_sub_batch(&selected_all, sub_start, cap, layer, top_k)?;
let slot_of = self.plan_routed_union(layer, "real Gemma 4", &sub.union)?;
let (routes, routing16) =
encode_routes(&selected_all, &weights_all, &sub, &slot_of, top_k);
let sub_len = sub.sub_len;
let real = self.real.as_ref().expect("real state present");
let x_off = (sub_start * hidden * 2) as u64;
let acts_off = (sub_start * top_k * moe_inter_us * 2) as u64;
let rw_off = (sub_start * top_k * 2) as u64;
let routes_off = (sub_start * top_k * gpu::MoePrefillRoute::STRIDE_BYTES) as u64;
gpu::write_buffer_bytes(
&real.batched().batch_routing_w,
rw_off as usize,
&f16_slice_to_le_bytes(&routing16),
);
gpu::write_buffer_bytes(
&real.batched().batch_routes,
routes_off as usize,
&gpu::MoePrefillRoute::bytes(&routes),
);
let offsets = &self.moe_offsets[layer];
let pass = self.context.begin_pass_labeled("routed cb (batched)");
for (blob, _) in &blob_refs {
pass.use_read_buffer(blob);
}
gpu::encode_moe_prefill_phase1(
&mut self.context,
&pass,
&wide,
offsets,
(&real.routed_x, x_off),
(&real.batched().batch_acts, acts_off),
(&real.batched().batch_routes, routes_off),
hidden as u32,
moe_inter,
top_k as u32,
(sub_len * top_k) as u32,
use_silu,
)
.map_err(gpu_err)?;
gpu::encode_moe_prefill_phase2_fused(
&mut self.context,
&pass,
&wide,
offsets,
(&real.batched().batch_acts, acts_off),
(&real.batched().batch_routing_w, rw_off),
(&real.batched().batch_routes, routes_off),
// Seed from ZEROS: this family adds its shared expert in
// the sandwich tail below, after a norm, so the routed sum
// starts from nothing -- which is what the decode path's
// `zero_hidden` argument says too. `0.0f + p` is the
// operation the kernel's old hardcoded seed performed, so
// this row's bytes do not move.
(&real.batched().batch_zero, x_off),
(&real.batched().batch_y, x_off),
hidden as u32,
moe_inter,
top_k as u32,
sub_len as u32,
use_silu,
)
.map_err(gpu_err)?;
// The sandwich tail, per token, on the same command buffer
// after the fused phase 2: the same kernels the per-token
// path dispatches, at this sub-batch's row offsets.
let layer_scalar = real.layer_scalar[layer];
let post_ffn2 = norm_view(
&self.weights,
&self.index,
&layer_tensor(layer, "post_feedforward_layernorm_2.weight"),
hidden,
)?;
let post_ffn = norm_view(
&self.weights,
&self.index,
&layer_tensor(layer, "post_feedforward_layernorm.weight"),
hidden,
)?;
for i in 0..sub_len {
let row_off = x_off + (i * hidden * 2) as u64;
let x_row = ((sub_start + i) * hidden * 2) as u64;
gpu::encode_rms_norm_bf16w(
&mut self.context,
&pass,
(&real.batched().batch_y, row_off),
post_ffn2,
(&real.batched().batch_y, row_off),
hidden as u32,
RMS_EPS,
)
.map_err(gpu_err)?;
gpu::encode_residual_add(
&mut self.context,
&pass,
(&batch_h1, row_off),
(&real.batched().batch_y, row_off),
hidden as u32,
)
.map_err(gpu_err)?;
gpu::encode_rms_norm_bf16w(
&mut self.context,
&pass,
(&batch_h1, row_off),
post_ffn,
(&self.scratch.ffn_normed, 0),
hidden as u32,
RMS_EPS,
)
.map_err(gpu_err)?;
gpu::encode_residual_add(
&mut self.context,
&pass,
(&self.scratch.x, x_row),
(&self.scratch.ffn_normed, 0),
hidden as u32,
)
.map_err(gpu_err)?;
gpu::encode_scalar_mul(
&mut self.context,
&pass,
(&self.scratch.x, x_row),
layer_scalar,
hidden as u32,
)
.map_err(gpu_err)?;
// This row's own offset, matching the per-token path's
// reasoning in `prefill.rs`: `scratch.x` holds every token
// of the whole micro-batch (not just this sub-batch), each
// at its own slot, so steering and capture must operate on
// `x_row` rather than row 0. Dispatched for every row so
// every prompt token is steered; capture's destination is
// one fixed region per layer, and sub-batches commit in
// increasing `sub_start` order, so the globally LAST row
// processed (`m - 1`) is what `record_pass` reads back.
encode_steering(
&mut self.context,
&pass,
&self.scratch,
self.steering.as_ref(),
layer,
hidden,
1,
x_row,
)?;
encode_resid_capture(
&mut self.context,
&pass,
&self.scratch,
self.resid_capture.as_ref(),
layer,
hidden,
x_row,
)?;
}
sub_start += sub_len;
committed = Some(pass.commit().waiting_on_drop());
}
Ok(committed.expect("m >= 1 guarantees at least one sub-batch"))
}
}