Skip to main content

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}