ferrox_models/decoder/entry.rs
1//! The public forward entry points of [`Decoder`], and the one place
2//! each of them enters the CPU worker pool.
3//!
4//! # Why these ten functions live in their own file
5//!
6//! Every one of them is a wrapper of the same shape:
7//! [`ferrox_core::par::on_workers`] around a body that lives in
8//! `decoder.rs`. That shape is the fix for the cost measured in #27 and
9//! #128, and it is worth exactly one file so that the rule is visible
10//! rather than repeated across a six-thousand-line module: **a forward
11//! pass enters the pool once, at its outermost boundary.**
12//!
13//! # What the wrapper buys
14//!
15//! `rayon::join` and the `par_iter` bridges cost very different things
16//! depending on who calls them. From a rayon worker the caller runs one
17//! half itself and waits on a spin latch; from any other thread the job
18//! is injected and the caller blocks on a pthread condvar, contributing
19//! no arithmetic while it sleeps. A decode step opens roughly five
20//! parallel regions per layer, so a 30-layer model was paying ~150 of
21//! the second kind per token.
22//!
23//! Sampled with `sample` on an M2 Pro over SmolLM2-135M Q8_0 `tg128`,
24//! the driving thread held **74% of the token** inside `__psynch_cvwait`
25//! under rayon's `LockLatch`, while the matvec kernel accounted for
26//! about a tenth of the same window across every thread in the process.
27//! Wrapping the step turns those ~150 cold entries into one, and
28//! [`ferrox_core::par::cold_regions`] is how a test says so without a
29//! stopwatch.
30//!
31//! # The invariant this file exists to hold
32//!
33//! `decoder.rs` declares no `pub fn forward_*` of its own; the bodies
34//! there are private and named `*_on_worker`, or are the `*_inner`
35//! helpers those call. `a_public_forward_may_not_be_declared_outside_
36//! this_file` is the test that keeps it that way, because an entry point
37//! added next door would silently reintroduce the per-region cost while
38//! every other entry point still read as fixed.
39
40use ferrox_core::cache::{KvCache, PagedKvCache, PagedStoreExhausted, SharedPagedKv};
41use ferrox_core::par;
42
43use super::{Decoder, MultiSeqKv};
44
45impl Decoder {
46 /// Runs one decode step for `token_id` at position `pos`, updating
47 /// `kv_caches` (one per layer) in place, and returns the logits over
48 /// the (test-scale) vocabulary.
49 ///
50 /// Enters the CPU worker pool once for the whole call; see the
51 /// module docs for what that is worth.
52 pub fn forward_token(
53 &self,
54 token_id: usize,
55 pos: usize,
56 kv_caches: &mut [KvCache],
57 ) -> Vec<f32> {
58 par::on_workers(move || self.forward_token_on_worker(token_id, pos, kv_caches))
59 }
60
61 /// Same computation as `forward_token`, but each layer's K/V cache
62 /// is a `PagedKvCache` (block-table-indexed into a per-layer
63 /// `PagedKvStore`) instead of a `KvCache`'s contiguous buffer --
64 /// exercises the paged attention kernel in a real decode loop
65 /// instead of only in isolation. `kv_caches`/`stores` are parallel
66 /// per-layer arrays, mirroring `forward_token`'s `kv_caches: &mut
67 /// [KvCache]`. Must produce bit-identical output to `forward_token`
68 /// given stores sized so no layer ever exhausts its blocks --
69 /// pinned by
70 /// `forward_token_paged_matches_forward_token_bit_identical` and,
71 /// per attention arm, by
72 /// `every_paged_attention_arm_is_bit_identical_to_its_contiguous_twin`.
73 ///
74 /// This used to refuse gpt-oss outright, because the paged kernel
75 /// had no attention-sink term and no sliding-window arm and would
76 /// have answered differently from the contiguous path without
77 /// saying so. It now mirrors all three arms of that dispatch, so
78 /// the refusal is gone rather than merely relaxed.
79 ///
80 /// Enters the CPU worker pool once for the whole call; see the
81 /// module docs for what that is worth.
82 pub fn forward_token_paged(
83 &self,
84 token_id: usize,
85 pos: usize,
86 kv_caches: &mut [PagedKvCache],
87 stores: &SharedPagedKv,
88 ) -> Result<Vec<f32>, PagedStoreExhausted> {
89 par::on_workers(move || {
90 self.forward_token_paged_on_worker(token_id, pos, kv_caches, stores)
91 })
92 }
93
94 /// Processes multiple new positions in one call instead of calling
95 /// `forward_token` once per position. `tokens[i]` is the token at
96 /// absolute position `start_pos + i`; all positions attend
97 /// causally (position `i` sees positions `0..=i` of this batch
98 /// plus everything already in `kv_caches`, nothing later).
99 ///
100 /// The attention block's Q/K/V/O projections and the MoE router
101 /// are computed as batched matmuls (`WeightMatrix::apply_batch`),
102 /// which for quantized weights means each weight row is read from
103 /// memory once and dotted against every position in the batch,
104 /// not once per position -- see `apply_batch`'s doc comment for
105 /// why that's a real memory-bandwidth saving, not just fewer
106 /// function calls. The expert FFN stage is *not* batched: which
107 /// expert(s) a position routes to is data-dependent per position,
108 /// so positions routed to different experts can't share a single
109 /// matmul the way the shared Q/K/V/router projections can. RoPE
110 /// and attention itself (causal masking, softmax) are also
111 /// per-position, since they're cheap relative to the matmuls and
112 /// batching them would add complexity for little benefit.
113 ///
114 /// This is what makes prompt-lookup speculative decoding
115 /// (`speculative` module) actually save work rather than just
116 /// reshuffle it: verifying `k` draft tokens costs one batched call
117 /// here, not `k` calls to `forward_token`.
118 ///
119 /// Thin wrapper over [`Self::forward_hidden_batch`] + `output_head`.
120 ///
121 /// Enters the CPU worker pool once for the whole call; see the
122 /// module docs for what that is worth.
123 pub fn forward_batch(
124 &self,
125 tokens: &[usize],
126 start_pos: usize,
127 kv_caches: &mut [KvCache],
128 ) -> Vec<Vec<f32>> {
129 par::on_workers(move || {
130 let hiddens = self.forward_hidden_batch(tokens, start_pos, kv_caches);
131 if hiddens.is_empty() {
132 return Vec::new();
133 }
134 let batch_size = hiddens.len();
135 let flat: Vec<f32> = hiddens.into_iter().flatten().collect();
136 self.logits_from_flat_hidden(flat, batch_size)
137 })
138 }
139
140 /// [`Self::forward_batch`] that also hands back the final-layer
141 /// hidden state for every position instead of dropping it.
142 ///
143 /// `forward_batch` computes these and throws them away; a
144 /// hidden-state-conditioned drafter (EAGLE, MTP, dFlash) needs
145 /// exactly the vector for the last *verified* position, so
146 /// recomputing it would mean running the target model twice for
147 /// something the first pass already had in hand. The extra cost
148 /// here is one copy of `[batch x hidden]`, which is why
149 /// `forward_batch` keeps its move-only path for the prefill case
150 /// that does not want them.
151 ///
152 /// Returns `(logits_per_position, hidden_per_position)`, both
153 /// indexed by position in `tokens`.
154 ///
155 /// Enters the CPU worker pool once for the whole call; see the
156 /// module docs for what that is worth.
157 pub fn forward_batch_with_hidden(
158 &self,
159 tokens: &[usize],
160 start_pos: usize,
161 kv_caches: &mut [KvCache],
162 ) -> (Vec<Vec<f32>>, Vec<Vec<f32>>) {
163 par::on_workers(move || {
164 let hiddens = self.forward_hidden_batch(tokens, start_pos, kv_caches);
165 if hiddens.is_empty() {
166 return (Vec::new(), Vec::new());
167 }
168 let batch_size = hiddens.len();
169 let flat: Vec<f32> = hiddens.iter().flatten().copied().collect();
170 (self.logits_from_flat_hidden(flat, batch_size), hiddens)
171 })
172 }
173
174 /// [`Self::forward_batch`] for the common case where only the final
175 /// position's logits are wanted: prefill a prompt, then sample the
176 /// next token. Runs `output_head` on **one** row instead of all
177 /// `batch_size` of them.
178 ///
179 /// The KV cache and every hidden state are identical either way —
180 /// only the vocabulary projection is skipped, and only for rows
181 /// whose logits the caller was going to drop. That projection is not
182 /// a rounding error: it is `[batch x hidden] x [hidden x vocab]`,
183 /// which for a large-vocabulary model with a small body is a large
184 /// share of prefill. `V*H / (V*H + L*P_layer)` comes to 30% on
185 /// Gemma-3-1B, 21% on Llama-3.2-1B and SmolLM2, 23% on Gemma-2-2B.
186 /// llama.cpp does not do this work at all during `pp512` —
187 /// `llama_batch_get_one` leaves `logits` unset, so `inp_out_ids`
188 /// selects a single row.
189 ///
190 /// [`Self::forward_batch`] stays for the callers that genuinely need
191 /// every row: speculative verification checks each draft position,
192 /// and `/v1/embeddings` pools over all of them.
193 ///
194 /// Enters the CPU worker pool once for the whole call; see the
195 /// module docs for what that is worth.
196 pub fn forward_batch_last(
197 &self,
198 tokens: &[usize],
199 start_pos: usize,
200 kv_caches: &mut [KvCache],
201 ) -> Vec<f32> {
202 par::on_workers(move || self.forward_batch_last_inner(tokens, start_pos, kv_caches, false))
203 }
204
205 /// [`Self::forward_batch_last`] for a caller that will READ the
206 /// caches afterwards rather than only decode from them.
207 ///
208 /// A Metal prefill otherwise leaves K/V on the device and the host
209 /// rows zero-filled, which is invisible to a caller that keeps
210 /// decoding (the device buffers stay authoritative) and fatal to
211 /// one that copies the rows somewhere else. Two callers do copy
212 /// them: `forward_batch_last_paged`, into the page store, and
213 /// `ferrox-server`'s prefix cache, into a snapshot a later request
214 /// restores from. Both used to get zeros, and both answered fluent
215 /// nonsense from a prompt the model never attended over.
216 ///
217 /// Costs one KV download per layer. Use [`Self::forward_batch_last`]
218 /// when nothing will read the caches back.
219 ///
220 /// Enters the CPU worker pool once for the whole call; see the
221 /// module docs for what that is worth.
222 pub fn forward_batch_last_host_kv(
223 &self,
224 tokens: &[usize],
225 start_pos: usize,
226 kv_caches: &mut [KvCache],
227 ) -> Vec<f32> {
228 par::on_workers(move || self.forward_batch_last_inner(tokens, start_pos, kv_caches, true))
229 }
230
231 /// [`Self::forward_batch_last`] over paged KV: the prefill twin of
232 /// [`Self::forward_token_paged`].
233 ///
234 /// # Why this gathers instead of paging the kernel
235 ///
236 /// `forward_hidden_batch`'s fast arm hands `cache.k` / `cache.v` to
237 /// `causal_gqa_attention_prefill_shared_kv_windowed`, which is Rayon
238 /// over `[query-block x head]` against one flat KV buffer. That
239 /// blocking is why CPU prefill is not the per-query path, and a
240 /// block table cannot be handed to it as a slice.
241 ///
242 /// The alternative was a second blocked kernel that reads through
243 /// the table. This file has just finished paying for what a second
244 /// copy of a rule costs: the paged decode path silently lost the
245 /// window arm, the sink term, the attention softcap, the embedding
246 /// scale and the final logit softcap, one at a time, because it was
247 /// a copy. A prefill kernel is a much larger surface to keep in
248 /// step than any of those. So the pages are materialised, the ONE
249 /// prefill implementation every other path uses runs against them,
250 /// and the new rows go back.
251 ///
252 /// Bit-identity is therefore by construction rather than by
253 /// agreement between two kernels: this calls the same function with
254 /// the same values. What the tests pin is that the gather and the
255 /// scatter are faithful, not that two implementations of attention
256 /// happen to match.
257 ///
258 /// The cost is one KV-sized copy per layer per call, against the
259 /// matmuls that dominate prefill. Decode is untouched: it still
260 /// reads through the block table and copies nothing, which is where
261 /// page sharing pays.
262 ///
263 /// # Failure is checked before anything is written
264 ///
265 /// Every layer's blocks are reserved up front, so a store too small
266 /// for the batch refuses with `PagedStoreExhausted` having mutated
267 /// no layer. A partial append would leave some layers longer than
268 /// others, and no caller can recover from that.
269 ///
270 /// Enters the CPU worker pool once for the whole call; see the
271 /// module docs for what that is worth.
272 pub fn forward_batch_last_paged(
273 &self,
274 tokens: &[usize],
275 start_pos: usize,
276 kv_caches: &mut [PagedKvCache],
277 stores: &SharedPagedKv,
278 ) -> Result<Vec<f32>, PagedStoreExhausted> {
279 par::on_workers(move || {
280 self.forward_batch_last_paged_on_worker(tokens, start_pos, kv_caches, stores)
281 })
282 }
283
284 /// Like [`Self::forward_batch`], but returns final RMS-normed hidden
285 /// states (pre-`output_head`) — one `hidden_dim` vector per input
286 /// token. Used by `/v1/embeddings` pooling (mean / last).
287 ///
288 /// Enters the CPU worker pool once for the whole call; see the
289 /// module docs for what that is worth.
290 pub fn forward_hidden_batch(
291 &self,
292 tokens: &[usize],
293 start_pos: usize,
294 kv_caches: &mut [KvCache],
295 ) -> Vec<Vec<f32>> {
296 par::on_workers(move || {
297 self.forward_hidden_batch_inner(tokens, start_pos, kv_caches, false)
298 })
299 }
300
301 /// Continuous-batching primitive: one decode step across N
302 /// independent *sequences*, each contributing exactly one new
303 /// token at its own current position, sharing every layer's
304 /// projection/router matmuls the same way `forward_batch` shares
305 /// them across positions of a single sequence -- but each
306 /// sequence keeps its own `KvCache`, independent `seq_len`, and
307 /// independent position, so sequences admitted/evicted at
308 /// different times can still share one batched matmul per step
309 /// (this is what "continuous" batching means: the batch
310 /// membership can change every step, unlike `forward_batch`'s
311 /// fixed-size prompt-processing batch). `kv_caches[s][l]` is
312 /// sequence `s`'s layer-`l` cache; `tokens[s]`/`positions[s]` is
313 /// that sequence's next token and its position within its own
314 /// history. Returns one logits vector per sequence, same order as
315 /// `tokens`.
316 ///
317 /// Must produce bit-identical output to calling `forward_token`
318 /// once per sequence with that sequence's own cache/position --
319 /// batching independent sequences together is a scheduling detail,
320 /// not a math change (no sequence's attention ever reads another
321 /// sequence's cache).
322 ///
323 /// Enters the CPU worker pool once for the whole call; see the
324 /// module docs for what that is worth.
325 pub fn forward_multi_seq(
326 &self,
327 tokens: &[usize],
328 positions: &[usize],
329 kv_caches: &mut [Vec<KvCache>],
330 ) -> Vec<Vec<f32>> {
331 par::on_workers(move || {
332 self.forward_multi_seq_kv(tokens, positions, &mut MultiSeqKv::Contiguous(kv_caches))
333 })
334 }
335
336 /// [`Self::forward_multi_seq`] over either KV backing.
337 ///
338 /// One body for both: the batched projections are identical, and
339 /// the per-sequence attention step is the only place the backing
340 /// shows through.
341 ///
342 /// Enters the CPU worker pool once for the whole call; see the
343 /// module docs for what that is worth.
344 pub fn forward_multi_seq_kv(
345 &self,
346 tokens: &[usize],
347 positions: &[usize],
348 kv: &mut MultiSeqKv<'_>,
349 ) -> Vec<Vec<f32>> {
350 par::on_workers(move || self.forward_multi_seq_kv_on_worker(tokens, positions, kv))
351 }
352}
353
354#[cfg(test)]
355mod tests {
356 /// Every public forward entry point belongs in this file, because
357 /// this file is where the pool is entered. One declared next door
358 /// would open a parallel region per matvec again, and nothing but
359 /// a throughput measurement on a quiet host would notice.
360 ///
361 /// Sabotage: move any wrapper below back into `decoder.rs` with its
362 /// `pub` intact and this goes red naming it.
363 #[test]
364 fn a_public_forward_may_not_be_declared_outside_this_file() {
365 let body = include_str!("../decoder.rs");
366 let stray: Vec<&str> = body
367 .lines()
368 .map(str::trim)
369 .filter(|l| l.starts_with("pub fn forward"))
370 .collect();
371 assert!(
372 stray.is_empty(),
373 "these forward entry points bypass the pool wrapper in entry.rs: {stray:?}"
374 );
375 }
376}