Skip to main content

forge/models/gpt2/
mod.rs

1//! GPT-2 model assembly and generation.
2
3use std::ops::ControlFlow;
4use std::path::Path;
5
6use rand::rngs::StdRng;
7use rand::{Rng, SeedableRng};
8use serde::Deserialize;
9
10#[cfg(feature = "train")]
11use crate::autograd::{TVar, Tape};
12use crate::device::Device;
13use crate::error::{ForgeError, Result};
14use crate::nn::{Embedding, LayerNorm, Linear};
15use crate::ops::{self, MatmulSpec};
16use crate::tensor::Tensor;
17use crate::tokenizer::Tokenizer;
18
19fn default_eps() -> f32 {
20    1e-5
21}
22
23#[derive(Debug, Clone)]
24pub struct Gpt2Config {
25    pub n_layer: usize,
26    pub n_head: usize,
27    pub n_embd: usize,
28    pub n_ctx: usize,
29    pub vocab_size: usize,
30    pub layer_norm_epsilon: f32,
31    pub eos_token_id: Option<u32>,
32}
33
34/// HF config.json may carry `n_ctx`, `n_positions`, or both.
35#[derive(Deserialize)]
36struct RawConfig {
37    n_layer: usize,
38    n_head: usize,
39    n_embd: usize,
40    #[serde(default)]
41    n_ctx: Option<usize>,
42    #[serde(default)]
43    n_positions: Option<usize>,
44    vocab_size: usize,
45    #[serde(default = "default_eps")]
46    layer_norm_epsilon: f32,
47    #[serde(default)]
48    eos_token_id: Option<u32>,
49}
50
51impl Gpt2Config {
52    /// GPT-2 small (124M).
53    pub fn gpt2() -> Self {
54        Gpt2Config {
55            n_layer: 12,
56            n_head: 12,
57            n_embd: 768,
58            n_ctx: 1024,
59            vocab_size: 50257,
60            layer_norm_epsilon: 1e-5,
61            eos_token_id: Some(50256),
62        }
63    }
64
65    pub fn from_json(path: impl AsRef<Path>) -> Result<Self> {
66        let text = std::fs::read_to_string(path)?;
67        Self::from_json_str(&text)
68    }
69
70    /// Parse an HF `config.json` string — the primary form; on wasm the
71    /// string arrives from an HTTP fetch.
72    pub fn from_json_str(text: &str) -> Result<Self> {
73        let raw: RawConfig = serde_json::from_str(text)?;
74        let n_ctx = raw.n_ctx.or(raw.n_positions).ok_or_else(|| {
75            ForgeError::Json(serde::de::Error::custom("missing n_ctx/n_positions"))
76        })?;
77        Ok(Gpt2Config {
78            n_layer: raw.n_layer,
79            n_head: raw.n_head,
80            n_embd: raw.n_embd,
81            n_ctx,
82            vocab_size: raw.vocab_size,
83            layer_norm_epsilon: raw.layer_norm_epsilon,
84            eos_token_id: raw.eos_token_id,
85        })
86    }
87}
88
89struct Block {
90    ln_1: LayerNorm,
91    attn_qkv: Linear,
92    attn_proj: Linear,
93    ln_2: LayerNorm,
94    mlp_fc: Linear,
95    mlp_proj: Linear,
96}
97
98pub struct Gpt2 {
99    emb: Embedding,
100    blocks: Vec<Block>,
101    ln_f: LayerNorm,
102    pub config: Gpt2Config,
103}
104
105#[derive(Debug, Clone, Copy)]
106pub enum Sampling {
107    Greedy,
108    TopK {
109        k: usize,
110        temperature: f32,
111        seed: u64,
112    },
113}
114
115/// One block's attention probabilities for one decode step, read back from
116/// the device.
117///
118/// `probs` is the row-major `[n_head, q_len, kv_len]` tensor the model
119/// actually attended with — captured after the softmax, not recomputed — so a
120/// visualization built from it shows the same arithmetic that produced the
121/// text. `ops::softmax` is out-of-place, so capturing it perturbs nothing.
122#[derive(Debug, Clone)]
123pub struct AttnStep {
124    pub layer: usize,
125    pub n_head: usize,
126    /// Query positions in this step: the whole prompt on prefill, 1 per decode.
127    pub q_len: usize,
128    /// Past positions attended to, including the queries themselves.
129    pub kv_len: usize,
130    pub probs: Vec<f32>,
131}
132
133/// One block's Q/K/V, MLP and output tensors for one step, read back from the
134/// device. Captured only for the first `detail_layers` blocks — the expensive
135/// kinds — while [`AttnStep`] is captured for every block.
136///
137/// `q`/`k`/`v` are the tensors for the *new* positions only (`[n_head, q_len,
138/// head_dim]`), which is what the step computed: the older keys and values
139/// live in the cache and were never recomputed.
140#[derive(Debug, Clone)]
141pub struct LayerDetail {
142    pub layer: usize,
143    /// `[q_len, n_embd]`, the block's first LayerNorm.
144    pub ln1_out: Vec<f32>,
145    /// `[n_head, q_len, head_dim]`
146    pub q: Vec<f32>,
147    pub k: Vec<f32>,
148    pub v: Vec<f32>,
149    /// `[n_head, q_len, kv_len]` — `q · kᵢ / √head_dim` *before* the causal
150    /// mask and the softmax. The masked triangle only means something next to
151    /// the full rectangle it was cut from.
152    pub scores: Vec<f32>,
153    /// `[q_len, n_embd]` — the heads concatenated, before the output
154    /// projection. Kept separate from `attn_proj_out` because this is the one
155    /// place `n_head × head_dim` becomes `n_embd`.
156    pub attn_head_out: Vec<f32>,
157    /// `[q_len, n_embd]`, after the attention output projection.
158    pub attn_proj_out: Vec<f32>,
159    /// `[q_len, n_embd]`, after the first residual add.
160    pub resid_attn: Vec<f32>,
161    /// `[q_len, n_embd]`, the block's second LayerNorm.
162    pub ln2_out: Vec<f32>,
163    /// `[q_len, 4 * n_embd]`, post-GELU.
164    pub mlp_hidden: Vec<f32>,
165    /// `[q_len, n_embd]`, after the second residual add.
166    pub block_out: Vec<f32>,
167}
168
169/// Everything one decode step computed, read back in a single batched round
170/// trip. A strict superset of [`AttnStep`]: `attn` is exactly what
171/// [`Gpt2::logits_step_attn_async`] reports for the same step.
172#[derive(Debug, Clone)]
173pub struct StepTrace {
174    /// Query positions in this step: the whole prompt on prefill, 1 per decode.
175    pub q_len: usize,
176    /// Past positions attended to, including the queries themselves.
177    pub kv_len: usize,
178    pub n_head: usize,
179    pub n_embd: usize,
180    /// `[q_len, n_embd]` — token + position embedding, before block 0.
181    /// Empty when `detail_layers` is 0.
182    pub embedding: Vec<f32>,
183    /// Every block, in layer order.
184    pub attn: Vec<AttnStep>,
185    /// The first `detail_layers` blocks, in layer order.
186    pub detail: Vec<LayerDetail>,
187    /// `[q_len, n_embd]` — after the final LayerNorm, i.e. what the LM head
188    /// multiplies. Empty when `detail_layers` is 0.
189    pub ln_f_out: Vec<f32>,
190    /// Top-n `(token id, probability)`, most likely first. Empty when `top_n`
191    /// is 0.
192    pub top: Vec<(u32, f32)>,
193}
194
195/// What a captured tensor is, so the batched readback can be interpreted
196/// without guessing from rank.
197#[derive(Debug, Clone, Copy)]
198enum ProbeKind {
199    Embedding,
200    Ln1Out { layer: usize },
201    Query { layer: usize },
202    Key { layer: usize },
203    Value { layer: usize },
204    Scores { layer: usize },
205    Attention { layer: usize },
206    AttnHeadOut { layer: usize },
207    AttnProjOut { layer: usize },
208    ResidAttn { layer: usize },
209    Ln2Out { layer: usize },
210    MlpHidden { layer: usize },
211    BlockOut { layer: usize },
212    LnFOut,
213}
214
215/// Capture request + accumulator, threaded through the forward pass.
216///
217/// Every `push` is an `Arc` clone of a tensor the step computes anyway, so the
218/// probing and non-probing paths run identical arithmetic. `detail_layers`
219/// bounds the expensive kinds; `Attention` is captured for every layer
220/// regardless, because the folded block stack still needs it.
221struct Probe {
222    detail_layers: usize,
223    kinds: Vec<ProbeKind>,
224    tensors: Vec<Tensor>,
225}
226
227impl Probe {
228    fn new(detail_layers: usize) -> Self {
229        Probe {
230            detail_layers,
231            kinds: Vec::new(),
232            tensors: Vec::new(),
233        }
234    }
235
236    fn detail(&self, layer: usize) -> bool {
237        layer < self.detail_layers
238    }
239
240    fn push(&mut self, kind: ProbeKind, t: &Tensor) {
241        self.kinds.push(kind);
242        self.tensors.push(t.clone());
243    }
244}
245
246/// Preallocated per-layer K/V tensors (`[n_head, n_ctx, head_dim]`) for
247/// incremental decode. `len` positions are filled; new tokens append.
248pub struct KvCache {
249    k: Vec<Tensor>,
250    v: Vec<Tensor>,
251    len: usize,
252}
253
254impl KvCache {
255    pub fn len(&self) -> usize {
256        self.len
257    }
258
259    pub fn is_empty(&self) -> bool {
260        self.len == 0
261    }
262}
263
264/// Per-position surprisal from [`Gpt2::surprisal_async`]. All three vectors are
265/// the length of the input, and index 0 is a placeholder in each — nothing
266/// precedes the first token.
267#[derive(Clone, Debug)]
268pub struct Surprisal {
269    /// `-log2 p(ids[i] | ids[..i])`, in bits.
270    pub bits: Vec<f32>,
271    /// The token the model would have chosen at this position.
272    pub top: Vec<u32>,
273    /// How probable the model thought its own choice was, in `0..=1`.
274    pub top_p: Vec<f32>,
275}
276
277impl Gpt2 {
278    /// Load HF GPT-2 weights. `path` is the .safetensors file. Weight names
279    /// may or may not carry the "transformer." prefix; both are accepted.
280    /// The LM head is tied to `wte` (the checkpoint has no separate tensor).
281    pub fn from_safetensors(
282        path: impl AsRef<Path>,
283        config: Gpt2Config,
284        device: &Device,
285    ) -> Result<Self> {
286        let bytes = std::fs::read(path.as_ref())?;
287        Self::from_safetensors_bytes(&bytes, config, device)
288    }
289
290    /// Load HF GPT-2 weights from in-memory .safetensors bytes — the primary
291    /// form; on wasm the bytes arrive from an HTTP fetch. `wte` never touches
292    /// the device un-chunked: it is uploaded row-chunked straight from the
293    /// file buffer (roadmap v4, pitfall 2).
294    pub fn from_safetensors_bytes(
295        bytes: &[u8],
296        config: Gpt2Config,
297        device: &Device,
298    ) -> Result<Self> {
299        let st = safetensors::SafeTensors::deserialize(bytes)
300            .map_err(|e| ForgeError::SafeTensors(format!("deserialize: {e}")))?;
301        let view = |name: &str| -> Result<safetensors::tensor::TensorView<'_>> {
302            st.tensor(name)
303                .or_else(|_| st.tensor(&format!("transformer.{name}")))
304                .map_err(|_| ForgeError::SafeTensors(format!("missing tensor {name}")))
305        };
306        let host = |name: &str| -> Result<(Vec<usize>, Vec<f32>)> {
307            let v = view(name)?;
308            if v.dtype() != safetensors::tensor::Dtype::F32 {
309                return Err(ForgeError::SafeTensors(format!(
310                    "{name}: expected f32, got {:?}",
311                    v.dtype()
312                )));
313            }
314            Ok((v.shape().to_vec(), bytemuck::pod_collect_to_vec(v.data())))
315        };
316        let take = |name: &str| -> Result<Tensor> {
317            let (shape, data) = host(name)?;
318            Tensor::from_f32(&data, shape, device)
319        };
320        let eps = config.layer_norm_epsilon;
321        // wte (~147 MiB for GPT-2) can exceed a single GPU binding, so it
322        // goes host buffer -> row-chunked device tensors directly.
323        let (_, wte_host) = host("wte.weight")?;
324        let emb = Embedding::from_host_wte(
325            &wte_host,
326            config.vocab_size,
327            config.n_embd,
328            take("wpe.weight")?,
329            device,
330        )?;
331        drop(wte_host);
332        let mut blocks = Vec::with_capacity(config.n_layer);
333        for i in 0..config.n_layer {
334            blocks.push(Block {
335                ln_1: LayerNorm {
336                    gamma: take(&format!("h.{i}.ln_1.weight"))?,
337                    beta: take(&format!("h.{i}.ln_1.bias"))?,
338                    eps,
339                },
340                attn_qkv: Linear {
341                    w: take(&format!("h.{i}.attn.c_attn.weight"))?,
342                    b: Some(take(&format!("h.{i}.attn.c_attn.bias"))?),
343                },
344                attn_proj: Linear {
345                    w: take(&format!("h.{i}.attn.c_proj.weight"))?,
346                    b: Some(take(&format!("h.{i}.attn.c_proj.bias"))?),
347                },
348                ln_2: LayerNorm {
349                    gamma: take(&format!("h.{i}.ln_2.weight"))?,
350                    beta: take(&format!("h.{i}.ln_2.bias"))?,
351                    eps,
352                },
353                mlp_fc: Linear {
354                    w: take(&format!("h.{i}.mlp.c_fc.weight"))?,
355                    b: Some(take(&format!("h.{i}.mlp.c_fc.bias"))?),
356                },
357                mlp_proj: Linear {
358                    w: take(&format!("h.{i}.mlp.c_proj.weight"))?,
359                    b: Some(take(&format!("h.{i}.mlp.c_proj.bias"))?),
360                },
361            });
362        }
363        let ln_f = LayerNorm {
364            gamma: take("ln_f.weight")?,
365            beta: take("ln_f.bias")?,
366            eps,
367        };
368        Ok(Gpt2 {
369            emb,
370            blocks,
371            ln_f,
372            config,
373        })
374    }
375
376    fn attention(&self, block: &Block, h: &Tensor) -> Result<Tensor> {
377        let n_head = self.config.n_head;
378        let hd = self.config.n_embd / n_head;
379        let qkv = block.attn_qkv.forward(h)?; // [t, 3c]
380        let (q, k, v) = ops::split_heads(&qkv, n_head)?; // [h, t, hd] each
381        let att = ops::matmul(
382            &q,
383            &k,
384            None,
385            MatmulSpec {
386                trans_b: true,
387                alpha: 1.0 / (hd as f32).sqrt(),
388                ..Default::default()
389            },
390        )?; // [h, t, t]
391        let att = ops::softmax(&att, true, 0)?;
392        let y = ops::matmul(&att, &v, None, MatmulSpec::default())?; // [h, t, hd]
393        let y = ops::merge_heads(&y)?; // [t, c]
394        block.attn_proj.forward(&y)
395    }
396
397    /// Attention over new tokens only, appending their K/V to the cache and
398    /// attending to all `len + t` cached positions.
399    ///
400    /// `probe`, when present, collects the post-softmax probabilities — and,
401    /// for a detail layer, Q/K/V as well. Every capture is an `Arc` handle to
402    /// a tensor that is computed regardless, so the probing and non-probing
403    /// paths run identical arithmetic.
404    #[allow(clippy::too_many_arguments)] // the cache slots and the probe are
405    // all per-call state; bundling them would only move the argument list.
406    fn attention_cached(
407        &self,
408        layer: usize,
409        block: &Block,
410        h: &Tensor,
411        k_cache: &mut Tensor,
412        v_cache: &mut Tensor,
413        len: usize,
414        mut probe: Option<&mut Probe>,
415    ) -> Result<Tensor> {
416        let n_head = self.config.n_head;
417        let hd = self.config.n_embd / n_head;
418        let qkv = block.attn_qkv.forward(h)?; // [t, 3c]
419        let (q, k, v) = ops::split_heads(&qkv, n_head)?; // [h, t, hd]
420        if let Some(p) = probe.as_deref_mut().filter(|p| p.detail(layer)) {
421            p.push(ProbeKind::Query { layer }, &q);
422            p.push(ProbeKind::Key { layer }, &k);
423            p.push(ProbeKind::Value { layer }, &v);
424        }
425        let t = q.shape().dim(1);
426        ops::kv_append(k_cache, &k, len)?;
427        ops::kv_append(v_cache, &v, len)?;
428        let kv_len = len + t;
429        let scores = ops::matmul(
430            &q,
431            k_cache,
432            None,
433            MatmulSpec {
434                trans_b: true,
435                alpha: 1.0 / (hd as f32).sqrt(),
436                b_rows: Some(kv_len),
437                ..Default::default()
438            },
439        )?; // [h, t, kv_len]
440        // The raw dot products, before the mask and the softmax: the causal
441        // triangle the next capture holds is only legible beside the full
442        // rectangle it was cut from.
443        if let Some(p) = probe.as_deref_mut().filter(|p| p.detail(layer)) {
444            p.push(ProbeKind::Scores { layer }, &scores);
445        }
446        let att = ops::softmax(&scores, true, len)?; // off = kv_len - t
447        if let Some(p) = probe.as_deref_mut() {
448            p.push(ProbeKind::Attention { layer }, &att);
449        }
450        let y = ops::matmul(
451            &att,
452            v_cache,
453            None,
454            MatmulSpec {
455                b_rows: Some(kv_len),
456                ..Default::default()
457            },
458        )?; // [h, t, hd]
459        let y = ops::merge_heads(&y)?;
460        // Concatenation and projection are two stages of the picture, and
461        // returning only the projected tensor hides the one place
462        // `n_head × head_dim` becomes `n_embd`.
463        if let Some(p) = probe.as_deref_mut().filter(|p| p.detail(layer)) {
464            p.push(ProbeKind::AttnHeadOut { layer }, &y);
465        }
466        let proj = block.attn_proj.forward(&y)?;
467        if let Some(p) = probe.filter(|p| p.detail(layer)) {
468            p.push(ProbeKind::AttnProjOut { layer }, &proj);
469        }
470        Ok(proj)
471    }
472
473    /// Hidden states after the final LayerNorm: [t, n_embd].
474    fn hidden(&self, ids: &[u32]) -> Result<Tensor> {
475        if ids.is_empty() {
476            return Err(ForgeError::Shape("empty token sequence".into()));
477        }
478        if ids.len() > self.config.n_ctx {
479            return Err(ForgeError::Shape(format!(
480                "sequence length {} exceeds n_ctx {}",
481                ids.len(),
482                self.config.n_ctx
483            )));
484        }
485        let device = self.emb.wpe.device();
486        // One command buffer for the whole pass instead of one per kernel.
487        let _scope = device.dispatch_scope();
488        let ids_t = Tensor::from_u32(ids, [ids.len()], &device)?;
489        let mut x = self.emb.forward(&ids_t, 0)?;
490        for block in &self.blocks {
491            let attn_out = self.attention(block, &block.ln_1.forward(&x)?)?;
492            x = ops::add(&x, &attn_out)?;
493            let mlp_in = block.ln_2.forward(&x)?;
494            let mlp_out = block
495                .mlp_proj
496                .forward(&ops::gelu(&block.mlp_fc.forward(&mlp_in)?)?)?;
497            x = ops::add(&x, &mlp_out)?;
498        }
499        self.ln_f.forward(&x)
500    }
501
502    /// Hidden states for `ids` (new tokens) continuing from `cache`;
503    /// appends their K/V and advances `cache.len`. `probe` is threaded through
504    /// to [`Gpt2::attention_cached`] and collects the embedding, every block's
505    /// attention, and the detail layers' Q/K/V, MLP and block outputs.
506    fn hidden_cached(
507        &self,
508        ids: &[u32],
509        cache: &mut KvCache,
510        mut probe: Option<&mut Probe>,
511    ) -> Result<Tensor> {
512        if ids.is_empty() {
513            return Err(ForgeError::Shape("empty token sequence".into()));
514        }
515        let pos = cache.len;
516        if pos + ids.len() > self.config.n_ctx {
517            return Err(ForgeError::Shape(format!(
518                "sequence length {}+{} exceeds n_ctx {}",
519                pos,
520                ids.len(),
521                self.config.n_ctx
522            )));
523        }
524        let device = self.emb.wpe.device();
525        // As in `hidden`: one submit for the step, not one per kernel. The
526        // probe paths read tensors back mid-pass, which flushes and reopens —
527        // correct, and the reason drawing the attention costs what it does.
528        let _scope = device.dispatch_scope();
529        let ids_t = Tensor::from_u32(ids, [ids.len()], &device)?;
530        let mut x = self.emb.forward(&ids_t, pos)?;
531        // The embedding is only worth a round trip when the detail stages are
532        // being drawn; `detail_layers == 0` is the cheap attention-only probe.
533        if let Some(p) = probe.as_deref_mut().filter(|p| p.detail_layers > 0) {
534            p.push(ProbeKind::Embedding, &x);
535        }
536        for (layer, (block, (kc, vc))) in self
537            .blocks
538            .iter()
539            .zip(cache.k.iter_mut().zip(cache.v.iter_mut()))
540            .enumerate()
541        {
542            // Bound rather than passed inline so it can be captured: the
543            // walkthrough draws a LayerNorm as its own stage.
544            let ln1 = block.ln_1.forward(&x)?;
545            if let Some(p) = probe.as_deref_mut().filter(|p| p.detail(layer)) {
546                p.push(ProbeKind::Ln1Out { layer }, &ln1);
547            }
548            let attn_out =
549                self.attention_cached(layer, block, &ln1, kc, vc, pos, probe.as_deref_mut())?;
550            x = ops::add(&x, &attn_out)?;
551            if let Some(p) = probe.as_deref_mut().filter(|p| p.detail(layer)) {
552                p.push(ProbeKind::ResidAttn { layer }, &x);
553            }
554            let mlp_in = block.ln_2.forward(&x)?;
555            if let Some(p) = probe.as_deref_mut().filter(|p| p.detail(layer)) {
556                p.push(ProbeKind::Ln2Out { layer }, &mlp_in);
557            }
558            let mlp_hidden = ops::gelu(&block.mlp_fc.forward(&mlp_in)?)?;
559            if let Some(p) = probe.as_deref_mut().filter(|p| p.detail(layer)) {
560                p.push(ProbeKind::MlpHidden { layer }, &mlp_hidden);
561            }
562            let mlp_out = block.mlp_proj.forward(&mlp_hidden)?;
563            x = ops::add(&x, &mlp_out)?;
564            if let Some(p) = probe.as_deref_mut().filter(|p| p.detail(layer)) {
565                p.push(ProbeKind::BlockOut { layer }, &x);
566            }
567        }
568        cache.len += ids.len();
569        let out = self.ln_f.forward(&x)?;
570        if let Some(p) = probe.filter(|p| p.detail_layers > 0) {
571            p.push(ProbeKind::LnFOut, &out);
572        }
573        Ok(out)
574    }
575
576    /// Empty KV cache sized for this model's full context.
577    pub fn new_cache(&self) -> Result<KvCache> {
578        let device = self.emb.wpe.device();
579        let hd = self.config.n_embd / self.config.n_head;
580        let shape = [self.config.n_head, self.config.n_ctx, hd];
581        let mut k = Vec::with_capacity(self.config.n_layer);
582        let mut v = Vec::with_capacity(self.config.n_layer);
583        for _ in 0..self.config.n_layer {
584            k.push(Tensor::zeros(shape, &device)?);
585            v.push(Tensor::zeros(shape, &device)?);
586        }
587        Ok(KvCache { k, v, len: 0 })
588    }
589
590    /// Full logits [t, vocab] — used by verification tests.
591    /// The LM head is wte-tied: logits = h @ wte^T.
592    pub fn forward(&self, ids: &[u32]) -> Result<Tensor> {
593        // Nests inside `hidden_cached`'s scope so the wte-tied head joins the
594        // same command buffer: one submit for the whole step.
595        let _scope = self.emb.wpe.device().dispatch_scope();
596        let h = self.hidden(ids)?;
597        ops::matmul_chunked_transb(&h, &self.emb.wte_chunks, 1.0)
598    }
599
600    /// Logits for the last position only, recomputing the full context
601    /// (no cache) — the reference decode path used by verification tests.
602    pub fn logits_last(&self, ids: &[u32]) -> Result<Vec<f32>> {
603        // Nests inside `hidden_cached`'s scope so the wte-tied head joins the
604        // same command buffer: one submit for the whole step.
605        let _scope = self.emb.wpe.device().dispatch_scope();
606        let h = self.hidden(ids)?;
607        let last = h.narrow_rows(ids.len() - 1, 1)?;
608        ops::matmul_chunked_transb(&last, &self.emb.wte_chunks, 1.0)?.to_vec_f32()
609    }
610
611    /// Last-position logits for `ids` continuing from `cache` (incremental
612    /// decode: pass the full prompt once, then one token at a time).
613    pub fn logits_step(&self, ids: &[u32], cache: &mut KvCache) -> Result<Vec<f32>> {
614        // Nests inside `hidden_cached`'s scope so the wte-tied head joins the
615        // same command buffer: one submit for the whole step.
616        let _scope = self.emb.wpe.device().dispatch_scope();
617        let h = self.hidden_cached(ids, cache, None)?;
618        let last = h.narrow_rows(ids.len() - 1, 1)?;
619        ops::matmul_chunked_transb(&last, &self.emb.wte_chunks, 1.0)?.to_vec_f32()
620    }
621
622    /// Async form of [`Gpt2::logits_step`] — identical math; the readback is
623    /// awaited so it works on wasm32 (roadmap v4, pitfall 14).
624    pub async fn logits_step_async(&self, ids: &[u32], cache: &mut KvCache) -> Result<Vec<f32>> {
625        // Nests inside `hidden_cached`'s scope so the wte-tied head joins the
626        // same command buffer: one submit for the whole step.
627        let _scope = self.emb.wpe.device().dispatch_scope();
628        let h = self.hidden_cached(ids, cache, None)?;
629        let last = h.narrow_rows(ids.len() - 1, 1)?;
630        ops::matmul_chunked_transb(&last, &self.emb.wte_chunks, 1.0)?
631            .to_vec_f32_async()
632            .await
633    }
634
635    /// The last position's hidden state **after the final LayerNorm** —
636    /// `[n_embd]`, exactly the vector [`Gpt2::logits_step`] hands to the head.
637    ///
638    /// This is the only stage at which two models' activations can be merged
639    /// and still decode: it is what `wte^T` multiplies, so models that share a
640    /// (frozen) `wte` share a basis here and nowhere else. Anything deeper —
641    /// per-head Q/K/V, the post-GELU MLP hidden — is private to one model.
642    pub fn hidden_step(&self, ids: &[u32], cache: &mut KvCache) -> Result<Vec<f32>> {
643        let h = self.hidden_cached(ids, cache, None)?;
644        h.narrow_rows(ids.len() - 1, 1)?.to_vec_f32()
645    }
646
647    /// Async form of [`Gpt2::hidden_step`] — identical math, awaited readback
648    /// so it works on wasm32.
649    pub async fn hidden_step_async(&self, ids: &[u32], cache: &mut KvCache) -> Result<Vec<f32>> {
650        let h = self.hidden_cached(ids, cache, None)?;
651        h.narrow_rows(ids.len() - 1, 1)?.to_vec_f32_async().await
652    }
653
654    /// Logits for a hidden state supplied from outside: `h @ wte^T`, the same
655    /// wte-tied head every other decode path applies. Lets a vector this model
656    /// did not produce — a merge of several models' [`Gpt2::hidden_step`]
657    /// outputs, say — be decoded to a distribution over the vocabulary.
658    pub fn logits_from_hidden(&self, h: &[f32]) -> Result<Vec<f32>> {
659        ops::matmul_chunked_transb(&self.hidden_tensor(h)?, &self.emb.wte_chunks, 1.0)?.to_vec_f32()
660    }
661
662    /// Async form of [`Gpt2::logits_from_hidden`].
663    pub async fn logits_from_hidden_async(&self, h: &[f32]) -> Result<Vec<f32>> {
664        ops::matmul_chunked_transb(&self.hidden_tensor(h)?, &self.emb.wte_chunks, 1.0)?
665            .to_vec_f32_async()
666            .await
667    }
668
669    /// The token embedding table as host floats, chunks reassembled. Exists so
670    /// a council can verify its experts really do share one `wte` — the
671    /// invariant that makes merging their hidden states mean anything.
672    pub fn wte_host(&self) -> Result<Vec<f32>> {
673        let mut out = Vec::with_capacity(self.config.vocab_size * self.config.n_embd);
674        for chunk in &self.emb.wte_chunks {
675            out.extend(chunk.to_vec_f32()?);
676        }
677        Ok(out)
678    }
679
680    fn hidden_tensor(&self, h: &[f32]) -> Result<Tensor> {
681        if h.len() != self.config.n_embd {
682            return Err(ForgeError::Shape(format!(
683                "hidden state has {} elements, expected n_embd {}",
684                h.len(),
685                self.config.n_embd
686            )));
687        }
688        Tensor::from_f32(h, [1, self.config.n_embd], &self.emb.wpe.device())
689    }
690
691    /// [`Gpt2::logits_step_async`] plus every block's attention probabilities
692    /// for this step, in layer order.
693    ///
694    /// The cheap probe: exactly [`Gpt2::logits_step_trace_async`] with no
695    /// detail layers and no top-n, so the two can never disagree about what
696    /// the model attended with.
697    pub async fn logits_step_attn_async(
698        &self,
699        ids: &[u32],
700        cache: &mut KvCache,
701    ) -> Result<(Vec<f32>, Vec<AttnStep>)> {
702        let (logits, trace) = self.logits_step_trace_async(ids, cache, 0, 0).await?;
703        Ok((logits, trace.attn))
704    }
705
706    /// [`Gpt2::logits_step_async`] plus a full calculation trace for this step:
707    /// the embedding, every block's attention, the whole forward pass through
708    /// the first `detail_layers` blocks (both LayerNorms, Q/K/V, pre-softmax
709    /// scores, the concatenated heads and their projection, both residual
710    /// adds, post-GELU MLP), the final LayerNorm, and the `top_n` most likely
711    /// next tokens with their probabilities.
712    ///
713    /// The logits are identical to `logits_step_async` — the probe only reads
714    /// tensors the step computes anyway. The whole step is enqueued first, and
715    /// every captured tensor comes back in a *single* batched readback: one at
716    /// a time they cost ~2.7x the decode itself, since the price is the round
717    /// trip rather than the bytes.
718    ///
719    /// `detail_layers` is the cost knob. At 0 this is the attention-only probe
720    /// and nothing but the post-softmax tensors is read. Each detail layer adds
721    /// `q_len * (13 * n_embd + n_head * kv_len)` floats, so a long prefill is
722    /// the only expensive call — a decode step is `q_len == 1`.
723    pub async fn logits_step_trace_async(
724        &self,
725        ids: &[u32],
726        cache: &mut KvCache,
727        detail_layers: usize,
728        top_n: usize,
729    ) -> Result<(Vec<f32>, StepTrace)> {
730        let q_len = ids.len();
731        let detail_layers = detail_layers.min(self.config.n_layer);
732        let mut probe = Probe::new(detail_layers);
733        let h = self.hidden_cached(ids, cache, Some(&mut probe))?;
734        let last = h.narrow_rows(q_len - 1, 1)?;
735        let Probe {
736            kinds,
737            mut tensors,
738            detail_layers,
739        } = probe;
740        // Logits last, so the kinds stay aligned with the tensors that
741        // produced them and the shapes below line up.
742        let shapes: Vec<Vec<usize>> = tensors.iter().map(|t| t.shape().dims().to_vec()).collect();
743        tensors.push(ops::matmul_chunked_transb(
744            &last,
745            &self.emb.wte_chunks,
746            1.0,
747        )?);
748
749        let mut read = Tensor::to_vec_f32_batch(&tensors).await?;
750        let logits = read.pop().expect("logits were pushed last");
751
752        let mut trace = StepTrace {
753            q_len,
754            kv_len: cache.len,
755            n_head: self.config.n_head,
756            n_embd: self.config.n_embd,
757            embedding: Vec::new(),
758            attn: Vec::with_capacity(self.config.n_layer),
759            detail: (0..detail_layers)
760                .map(|layer| LayerDetail {
761                    layer,
762                    ln1_out: Vec::new(),
763                    q: Vec::new(),
764                    k: Vec::new(),
765                    v: Vec::new(),
766                    scores: Vec::new(),
767                    attn_head_out: Vec::new(),
768                    attn_proj_out: Vec::new(),
769                    resid_attn: Vec::new(),
770                    ln2_out: Vec::new(),
771                    mlp_hidden: Vec::new(),
772                    block_out: Vec::new(),
773                })
774                .collect(),
775            ln_f_out: Vec::new(),
776            top: top_probs(&logits, top_n),
777        };
778        for ((kind, dims), data) in kinds.iter().zip(&shapes).zip(read) {
779            match *kind {
780                ProbeKind::Embedding => trace.embedding = data,
781                ProbeKind::Ln1Out { layer } => trace.detail[layer].ln1_out = data,
782                ProbeKind::Query { layer } => trace.detail[layer].q = data,
783                ProbeKind::Key { layer } => trace.detail[layer].k = data,
784                ProbeKind::Value { layer } => trace.detail[layer].v = data,
785                ProbeKind::Scores { layer } => trace.detail[layer].scores = data,
786                ProbeKind::AttnHeadOut { layer } => trace.detail[layer].attn_head_out = data,
787                ProbeKind::AttnProjOut { layer } => trace.detail[layer].attn_proj_out = data,
788                ProbeKind::ResidAttn { layer } => trace.detail[layer].resid_attn = data,
789                ProbeKind::Ln2Out { layer } => trace.detail[layer].ln2_out = data,
790                ProbeKind::MlpHidden { layer } => trace.detail[layer].mlp_hidden = data,
791                ProbeKind::BlockOut { layer } => trace.detail[layer].block_out = data,
792                ProbeKind::LnFOut => trace.ln_f_out = data,
793                ProbeKind::Attention { layer } => {
794                    let [n_head, q_len, kv_len] = dims[..] else {
795                        return Err(ForgeError::Shape(format!(
796                            "attention probe expected rank 3, got {dims:?}"
797                        )));
798                    };
799                    trace.attn.push(AttnStep {
800                        layer,
801                        n_head,
802                        q_len,
803                        kv_len,
804                        probs: data,
805                    });
806                }
807            }
808        }
809        Ok((logits, trace))
810    }
811
812    /// How surprised the model was by text that is already there.
813    ///
814    /// This is reading, not writing: for each position the model is asked what
815    /// it expected *before* seeing the character that actually followed, and
816    /// the answer is scored against what did. It is a teacher-forced scoring
817    /// pass, so the whole sequence costs **one forward pass** rather than `t`
818    /// decode steps — the model reads a paragraph in the time it would take to
819    /// generate one character.
820    ///
821    /// `bits[i]` is `-log2 p(ids[i] | ids[..i])`: 0 means "entirely expected",
822    /// and `log2(vocab_size)` is what a uniform guess would score.
823    /// `bits[0]` is 0 — nothing precedes the first token, so nothing about it
824    /// can be a surprise.
825    ///
826    /// `top[i]` and `top_p[i]` are the token the model would have picked at
827    /// that position and how sure it was, which is what makes a surprise
828    /// legible: "it expected `e` here" says more than a number.
829    ///
830    /// The softmax and the gather are done on the host deliberately. The GPU
831    /// ops that would do them (`ops::softmax` over `[t, vocab]`, and
832    /// `ops::gather_nll`) sit behind the `train` feature, and for a
833    /// character-level vocabulary the host loop is free. Note the cost model
834    /// for a BPE model, though: the readback is `t × vocab × 4` bytes, which
835    /// for GPT-2's 50257-token vocabulary is ~196 KB per position.
836    pub async fn surprisal_async(&self, ids: &[u32]) -> Result<Surprisal> {
837        let t = ids.len();
838        if t == 0 {
839            return Err(ForgeError::Shape(
840                "surprisal needs a non-empty sequence".into(),
841            ));
842        }
843        let vocab = self.config.vocab_size;
844        let logits = self.forward(ids)?.to_vec_f32_async().await?;
845
846        let mut out = Surprisal {
847            bits: vec![0.0; t],
848            top: vec![ids[0]; t],
849            top_p: vec![0.0; t],
850        };
851        // Position i is predicted by row i-1: the model saw ids[..i] and the
852        // logits it produced there are its guess about ids[i].
853        for i in 1..t {
854            let row = &logits[(i - 1) * vocab..i * vocab];
855            // Log-sum-exp in the stable form; a char model's logits are small,
856            // but a shifted exp costs nothing and cannot overflow.
857            let max = row.iter().copied().fold(f32::NEG_INFINITY, f32::max);
858            let sum: f32 = row.iter().map(|l| (l - max).exp()).sum();
859            let log_z = max + sum.ln();
860
861            let target = ids[i] as usize;
862            if target >= vocab {
863                return Err(ForgeError::Shape(format!(
864                    "token id {target} >= vocab_size {vocab}"
865                )));
866            }
867            // nats -> bits: a bit is the unit a reader can reason about.
868            out.bits[i] = -(row[target] - log_z) / std::f32::consts::LN_2;
869
870            let (best, best_logit) = row
871                .iter()
872                .enumerate()
873                .max_by(|a, b| a.1.total_cmp(b.1))
874                .map(|(i, l)| (i as u32, *l))
875                .unwrap_or((0, 0.0));
876            out.top[i] = best;
877            out.top_p[i] = (best_logit - log_z).exp();
878        }
879        Ok(out)
880    }
881
882    // ---- construction, parameters, serialization ----
883    //
884    // Motivated by training but not gated behind it: `init_random` builds a
885    // model, `params`/`param_specs` name its tensors and `save_safetensors`
886    // writes them. None records a tape, and `tests/streaming.rs` and
887    // `tests/council.rs` — both pure inference — call `init_random`. Only
888    // `loss` and `loss_grads` below are `#[cfg(feature = "train")]`.
889
890    /// Random initialization for training from scratch: N(0, 0.02) weights,
891    /// residual projections scaled by 1/sqrt(2*n_layer), zero biases,
892    /// identity LayerNorms.
893    pub fn init_random(config: Gpt2Config, device: &Device, seed: u64) -> Result<Gpt2> {
894        let mut rng = StdRng::seed_from_u64(seed);
895        let c = config.n_embd;
896        let std = 0.02f32;
897        let resid_std = std / (2.0 * config.n_layer as f32).sqrt();
898        let mut normal = |n: usize, std: f32| -> Vec<f32> {
899            (0..n)
900                .map(|_| {
901                    let u1: f32 = rng.random::<f32>().max(1e-7);
902                    let u2: f32 = rng.random::<f32>();
903                    (-2.0 * u1.ln()).sqrt() * (2.0 * std::f32::consts::PI * u2).cos() * std
904                })
905                .collect()
906        };
907        let wte_host = normal(config.vocab_size * c, std);
908        let wpe = Tensor::from_f32(&normal(config.n_ctx * c, std), [config.n_ctx, c], device)?;
909        let emb = Embedding::from_host_wte(&wte_host, config.vocab_size, c, wpe, device)?;
910        drop(wte_host);
911        let eps = config.layer_norm_epsilon;
912        let ones = |dev: &Device| Tensor::from_f32(&vec![1.0f32; c], [c], dev);
913        let zeros1 = |dev: &Device, n: usize| Tensor::zeros([n], dev);
914        let mut blocks = Vec::with_capacity(config.n_layer);
915        for _ in 0..config.n_layer {
916            blocks.push(Block {
917                ln_1: LayerNorm {
918                    gamma: ones(device)?,
919                    beta: zeros1(device, c)?,
920                    eps,
921                },
922                attn_qkv: Linear {
923                    w: Tensor::from_f32(&normal(c * 3 * c, std), [c, 3 * c], device)?,
924                    b: Some(zeros1(device, 3 * c)?),
925                },
926                attn_proj: Linear {
927                    w: Tensor::from_f32(&normal(c * c, resid_std), [c, c], device)?,
928                    b: Some(zeros1(device, c)?),
929                },
930                ln_2: LayerNorm {
931                    gamma: ones(device)?,
932                    beta: zeros1(device, c)?,
933                    eps,
934                },
935                mlp_fc: Linear {
936                    w: Tensor::from_f32(&normal(c * 4 * c, std), [c, 4 * c], device)?,
937                    b: Some(zeros1(device, 4 * c)?),
938                },
939                mlp_proj: Linear {
940                    w: Tensor::from_f32(&normal(4 * c * c, resid_std), [4 * c, c], device)?,
941                    b: Some(zeros1(device, c)?),
942                },
943            });
944        }
945        let ln_f = LayerNorm {
946            gamma: ones(device)?,
947            beta: zeros1(device, c)?,
948            eps,
949        };
950        Ok(Gpt2 {
951            emb,
952            blocks,
953            ln_f,
954            config,
955        })
956    }
957
958    /// Parameter names (safetensors keys) and weight-decay flags, in the
959    /// canonical order used by `params`, `params_mut`, and `loss_grads`.
960    pub fn param_specs(&self) -> Vec<(String, bool)> {
961        let mut out = vec![
962            ("wte.weight".to_string(), true),
963            ("wpe.weight".to_string(), true),
964        ];
965        for i in 0..self.config.n_layer {
966            out.push((format!("h.{i}.ln_1.weight"), false));
967            out.push((format!("h.{i}.ln_1.bias"), false));
968            out.push((format!("h.{i}.attn.c_attn.weight"), true));
969            out.push((format!("h.{i}.attn.c_attn.bias"), false));
970            out.push((format!("h.{i}.attn.c_proj.weight"), true));
971            out.push((format!("h.{i}.attn.c_proj.bias"), false));
972            out.push((format!("h.{i}.ln_2.weight"), false));
973            out.push((format!("h.{i}.ln_2.bias"), false));
974            out.push((format!("h.{i}.mlp.c_fc.weight"), true));
975            out.push((format!("h.{i}.mlp.c_fc.bias"), false));
976            out.push((format!("h.{i}.mlp.c_proj.weight"), true));
977            out.push((format!("h.{i}.mlp.c_proj.bias"), false));
978        }
979        out.push(("ln_f.weight".to_string(), false));
980        out.push(("ln_f.bias".to_string(), false));
981        out
982    }
983
984    /// The single-chunk token embedding table, required for training.
985    fn wte_single(&self) -> Result<&Tensor> {
986        if self.emb.wte_chunks.len() != 1 {
987            return Err(ForgeError::Shape(format!(
988                "training requires a single-chunk wte; got {} chunks (device \
989                 binding limit too small for vocab x n_embd)",
990                self.emb.wte_chunks.len()
991            )));
992        }
993        Ok(&self.emb.wte_chunks[0])
994    }
995
996    /// Parameters in `param_specs` order.
997    pub fn params(&self) -> Result<Vec<&Tensor>> {
998        let mut out = vec![self.wte_single()?, &self.emb.wpe];
999        for b in &self.blocks {
1000            out.extend([&b.ln_1.gamma, &b.ln_1.beta, &b.attn_qkv.w]);
1001            out.push(bias(&b.attn_qkv)?);
1002            out.push(&b.attn_proj.w);
1003            out.push(bias(&b.attn_proj)?);
1004            out.extend([&b.ln_2.gamma, &b.ln_2.beta, &b.mlp_fc.w]);
1005            out.push(bias(&b.mlp_fc)?);
1006            out.push(&b.mlp_proj.w);
1007            out.push(bias(&b.mlp_proj)?);
1008        }
1009        out.extend([&self.ln_f.gamma, &self.ln_f.beta]);
1010        Ok(out)
1011    }
1012
1013    /// Mutable parameters in `param_specs` order (for the optimizer).
1014    pub fn params_mut(&mut self) -> Result<Vec<&mut Tensor>> {
1015        if self.emb.wte_chunks.len() != 1 {
1016            return Err(ForgeError::Shape(
1017                "training requires a single-chunk wte".into(),
1018            ));
1019        }
1020        let mut out: Vec<&mut Tensor> = Vec::new();
1021        out.push(&mut self.emb.wte_chunks[0]);
1022        out.push(&mut self.emb.wpe);
1023        for b in &mut self.blocks {
1024            out.push(&mut b.ln_1.gamma);
1025            out.push(&mut b.ln_1.beta);
1026            out.push(&mut b.attn_qkv.w);
1027            out.push(
1028                b.attn_qkv
1029                    .b
1030                    .as_mut()
1031                    .ok_or_else(|| ForgeError::Shape("training requires biases".into()))?,
1032            );
1033            out.push(&mut b.attn_proj.w);
1034            out.push(
1035                b.attn_proj
1036                    .b
1037                    .as_mut()
1038                    .ok_or_else(|| ForgeError::Shape("training requires biases".into()))?,
1039            );
1040            out.push(&mut b.ln_2.gamma);
1041            out.push(&mut b.ln_2.beta);
1042            out.push(&mut b.mlp_fc.w);
1043            out.push(
1044                b.mlp_fc
1045                    .b
1046                    .as_mut()
1047                    .ok_or_else(|| ForgeError::Shape("training requires biases".into()))?,
1048            );
1049            out.push(&mut b.mlp_proj.w);
1050            out.push(
1051                b.mlp_proj
1052                    .b
1053                    .as_mut()
1054                    .ok_or_else(|| ForgeError::Shape("training requires biases".into()))?,
1055            );
1056        }
1057        out.push(&mut self.ln_f.gamma);
1058        out.push(&mut self.ln_f.beta);
1059        Ok(out)
1060    }
1061
1062    /// Save a checkpoint loadable by `from_safetensors`. A chunked wte is
1063    /// reassembled host-side first.
1064    pub fn save_safetensors(&self, path: impl AsRef<Path>) -> Result<()> {
1065        let c = self.config.n_embd;
1066        let mut wte_host = Vec::with_capacity(self.config.vocab_size * c);
1067        for ch in &self.emb.wte_chunks {
1068            wte_host.extend(ch.to_vec_f32()?);
1069        }
1070        let mut entries = vec![(
1071            "wte.weight".to_string(),
1072            vec![self.config.vocab_size, c],
1073            wte_host,
1074        )];
1075        let specs = self.param_specs();
1076        let params = self.params_for_save()?;
1077        for ((name, _), t) in specs.iter().zip(params).skip(1) {
1078            entries.push((name.clone(), t.shape().dims().to_vec(), t.to_vec_f32()?));
1079        }
1080        crate::serialization::save_safetensors(path, &entries)
1081    }
1082
1083    /// Like `params` but tolerates a chunked wte (slot 0 is unused by the
1084    /// caller, which reassembles wte itself).
1085    fn params_for_save(&self) -> Result<Vec<&Tensor>> {
1086        let mut out = vec![&self.emb.wte_chunks[0], &self.emb.wpe];
1087        for b in &self.blocks {
1088            out.extend([&b.ln_1.gamma, &b.ln_1.beta, &b.attn_qkv.w]);
1089            out.push(bias(&b.attn_qkv)?);
1090            out.push(&b.attn_proj.w);
1091            out.push(bias(&b.attn_proj)?);
1092            out.extend([&b.ln_2.gamma, &b.ln_2.beta, &b.mlp_fc.w]);
1093            out.push(bias(&b.mlp_fc)?);
1094            out.push(&b.mlp_proj.w);
1095            out.push(bias(&b.mlp_proj)?);
1096        }
1097        out.extend([&self.ln_f.gamma, &self.ln_f.beta]);
1098        Ok(out)
1099    }
1100
1101    #[cfg(feature = "train")]
1102    #[cfg_attr(docsrs, doc(cfg(feature = "train")))]
1103    /// Mean cross-entropy of `targets` given `input`, forward only — no tape,
1104    /// no gradients, no dropout. This is the evaluation path: a validation
1105    /// split scored with [`Gpt2::loss_grads`] would build and discard a full
1106    /// backward graph per window.
1107    pub fn loss(&self, input: &[u32], targets: &[u32]) -> Result<f32> {
1108        if input.is_empty() || input.len() != targets.len() {
1109            return Err(ForgeError::Shape(
1110                "loss needs equal, non-empty input/target lengths".into(),
1111            ));
1112        }
1113        let t = input.len();
1114        let device = self.emb.wpe.device();
1115        let tgt_t = Tensor::from_u32(targets, [t], &device)?;
1116        let logits = self.forward(input)?;
1117        let probs = ops::softmax(&logits, false, 0)?;
1118        let nll = ops::gather_nll(&probs, &tgt_t)?.to_vec_f32()?;
1119        Ok(nll.iter().sum::<f32>() / t as f32)
1120    }
1121
1122    #[cfg(feature = "train")]
1123    #[cfg_attr(docsrs, doc(cfg(feature = "train")))]
1124    /// One training forward + backward on a single sequence: mean
1125    /// cross-entropy of `targets` given `input`, and gradients for every
1126    /// parameter in `param_specs` order. Batching is achieved by
1127    /// accumulating grads over calls (roadmap v4 batching policy).
1128    pub fn loss_grads(
1129        &self,
1130        input: &[u32],
1131        targets: &[u32],
1132        dropout_p: f32,
1133        seed: u32,
1134    ) -> Result<(f32, Vec<Tensor>)> {
1135        if input.is_empty() || input.len() != targets.len() {
1136            return Err(ForgeError::Shape(
1137                "loss_grads needs equal, non-empty input/target lengths".into(),
1138            ));
1139        }
1140        if input.len() > self.config.n_ctx {
1141            return Err(ForgeError::Shape(format!(
1142                "sequence length {} exceeds n_ctx {}",
1143                input.len(),
1144                self.config.n_ctx
1145            )));
1146        }
1147        let device = self.emb.wpe.device();
1148        let t = input.len();
1149        let ids_t = Tensor::from_u32(input, [t], &device)?;
1150        let tgt_t = Tensor::from_u32(targets, [t], &device)?;
1151        let eps = self.config.layer_norm_epsilon;
1152        let n_head = self.config.n_head;
1153        let hd = self.config.n_embd / n_head;
1154
1155        let mut tape = Tape::new();
1156        // Leaves in param_specs order (ids 0..n_params).
1157        let pvars: Vec<TVar> = self
1158            .params()?
1159            .into_iter()
1160            .map(|p| tape.leaf(p.clone()))
1161            .collect();
1162        let n_params = pvars.len();
1163
1164        let mut site = 0u32;
1165        let mut dseed = move || {
1166            site = site.wrapping_add(1);
1167            seed.wrapping_mul(0x9E37_79B1)
1168                .wrapping_add(site.wrapping_mul(0x85EB_CA77))
1169        };
1170
1171        let mut x = tape.embedding(&ids_t, &pvars[0], &pvars[1], 0)?;
1172        x = tape.dropout(&x, dropout_p, dseed())?;
1173        for i in 0..self.config.n_layer {
1174            let base = 2 + i * 12;
1175            let [g1, b1, wqkv, bqkv, wproj, bproj, g2, b2, wfc, bfc, wmp, bmp] =
1176                std::array::from_fn(|j| &pvars[base + j]);
1177            let a = tape.layernorm(&x, g1, b1, eps)?;
1178            let qkv = tape.matmul(&a, wqkv, Some(bqkv), MatmulSpec::default())?;
1179            let (q, k, v) = tape.split_heads(&qkv, n_head)?;
1180            let att = tape.matmul(
1181                &q,
1182                &k,
1183                None,
1184                MatmulSpec {
1185                    trans_b: true,
1186                    alpha: 1.0 / (hd as f32).sqrt(),
1187                    ..Default::default()
1188                },
1189            )?;
1190            let probs = tape.softmax(&att, true, 0)?;
1191            let probs = tape.dropout(&probs, dropout_p, dseed())?;
1192            let y = tape.matmul(&probs, &v, None, MatmulSpec::default())?;
1193            let y = tape.merge_heads(&y)?;
1194            let y = tape.matmul(&y, wproj, Some(bproj), MatmulSpec::default())?;
1195            let y = tape.dropout(&y, dropout_p, dseed())?;
1196            x = tape.add(&x, &y)?;
1197            let a2 = tape.layernorm(&x, g2, b2, eps)?;
1198            let f = tape.matmul(&a2, wfc, Some(bfc), MatmulSpec::default())?;
1199            let f = tape.gelu(&f)?;
1200            let f = tape.matmul(&f, wmp, Some(bmp), MatmulSpec::default())?;
1201            let f = tape.dropout(&f, dropout_p, dseed())?;
1202            x = tape.add(&x, &f)?;
1203        }
1204        let xf = tape.layernorm(&x, &pvars[n_params - 2], &pvars[n_params - 1], eps)?;
1205        // Weight-tied LM head: wte gets gradient from here *and* from the
1206        // embedding scatter (roadmap v4, pitfall 6).
1207        let logits = tape.matmul(
1208            &xf,
1209            &pvars[0],
1210            None,
1211            MatmulSpec {
1212                trans_b: true,
1213                ..Default::default()
1214            },
1215        )?;
1216
1217        let probs = ops::softmax(&logits.t, false, 0)?;
1218        let nll = ops::gather_nll(&probs, &tgt_t)?.to_vec_f32()?;
1219        let loss = nll.iter().sum::<f32>() / t as f32;
1220        let dlogits = ops::ce_bwd(&probs, &tgt_t, 1.0 / t as f32)?;
1221        let all = tape.backward(&logits, dlogits)?;
1222        let mut grads = Vec::with_capacity(n_params);
1223        for (i, g) in all.into_iter().take(n_params).enumerate() {
1224            grads.push(
1225                g.ok_or_else(|| ForgeError::Shape(format!("parameter {i} received no gradient")))?,
1226            );
1227        }
1228        Ok((loss, grads))
1229    }
1230
1231    /// Autoregressive generation with KV-cache decode.
1232    /// Returns the full text (prompt + completion).
1233    pub fn generate(
1234        &self,
1235        tokenizer: &impl Tokenizer,
1236        prompt: &str,
1237        max_new_tokens: usize,
1238        sampling: Sampling,
1239    ) -> Result<String> {
1240        self.generate_streaming(tokenizer, prompt, max_new_tokens, sampling, |_, _| {})
1241    }
1242
1243    /// Sync streaming generation with KV-cache decode. `on_token` fires
1244    /// exactly once per *generated* token (never for the prompt) with the
1245    /// token id and the newly decodable text delta — which may be empty when
1246    /// a multi-byte character spans several BPE tokens, since a partial UTF-8
1247    /// sequence is held back until its trailing bytes arrive.
1248    ///
1249    /// This is the accurate throughput hook: callback invocations map 1:1 to
1250    /// tokens, unlike [`Gpt2::generate_async`]'s text-delta callback.
1251    pub fn generate_streaming(
1252        &self,
1253        tokenizer: &impl Tokenizer,
1254        prompt: &str,
1255        max_new_tokens: usize,
1256        sampling: Sampling,
1257        mut on_token: impl FnMut(u32, &str),
1258    ) -> Result<String> {
1259        self.generate_streaming_ctl(tokenizer, prompt, max_new_tokens, sampling, |id, text| {
1260            on_token(id, text);
1261            ControlFlow::Continue(())
1262        })
1263    }
1264
1265    /// [`Gpt2::generate_streaming`] with an interruptible callback: returning
1266    /// [`ControlFlow::Break`] stops after the current token and returns the
1267    /// text generated so far. Long interactive runs need this — a callback
1268    /// that cannot abort can only be "cancelled" once the whole run finishes.
1269    pub fn generate_streaming_ctl(
1270        &self,
1271        tokenizer: &impl Tokenizer,
1272        prompt: &str,
1273        max_new_tokens: usize,
1274        sampling: Sampling,
1275        mut on_token: impl FnMut(u32, &str) -> ControlFlow<()>,
1276    ) -> Result<String> {
1277        let mut ids = tokenizer.encode(prompt)?;
1278        if ids.is_empty() {
1279            return Err(ForgeError::Tokenizer("prompt produced no tokens".into()));
1280        }
1281        let mut rng = match sampling {
1282            Sampling::TopK { seed, .. } => Some(StdRng::seed_from_u64(seed)),
1283            Sampling::Greedy => None,
1284        };
1285        let mut cache = self.new_cache()?;
1286        let mut logits = self.logits_step(&ids, &mut cache)?; // prompt prefill
1287        // Same append-only byte stream as `generate_async`, but the delta is
1288        // captured per token instead of being forwarded as it is produced.
1289        let mut bytes = tokenizer.decode_bytes(&ids);
1290        let mut sent = emit_valid_prefix(&bytes, 0, &mut |_: &str| {});
1291        for _ in 0..max_new_tokens {
1292            let next = sample(&logits, sampling, rng.as_mut());
1293            if Some(next) == self.config.eos_token_id {
1294                break;
1295            }
1296            ids.push(next);
1297            bytes.extend(tokenizer.decode_bytes(&[next]));
1298            let mut delta = String::new();
1299            sent = emit_valid_prefix(&bytes, sent, &mut |s: &str| delta.push_str(s));
1300            if on_token(next, &delta).is_break() {
1301                break;
1302            }
1303            if ids.len() >= self.config.n_ctx {
1304                break;
1305            }
1306            logits = self.logits_step(&[next], &mut cache)?; // single-token decode
1307        }
1308        Ok(tokenizer.decode(&ids))
1309    }
1310
1311    /// Async autoregressive generation with KV-cache decode — the browser
1312    /// form of [`Gpt2::generate`]. `on_text` receives each newly decoded text
1313    /// fragment (the delta of the full decode, so multi-byte characters that
1314    /// span BPE tokens are never split) so a page can stream output.
1315    pub async fn generate_async(
1316        &self,
1317        tokenizer: &impl Tokenizer,
1318        prompt: &str,
1319        max_new_tokens: usize,
1320        sampling: Sampling,
1321        mut on_text: impl FnMut(&str),
1322    ) -> Result<String> {
1323        self.generate_async_ctl(tokenizer, prompt, max_new_tokens, sampling, |s| {
1324            on_text(s);
1325            ControlFlow::Continue(())
1326        })
1327        .await
1328    }
1329
1330    /// [`Gpt2::generate_async`] with an interruptible callback — the async
1331    /// counterpart of [`Gpt2::generate_streaming_ctl`]. Returning
1332    /// [`ControlFlow::Break`] stops after the current token, which is how a
1333    /// page offers a working "stop" button.
1334    pub async fn generate_async_ctl(
1335        &self,
1336        tokenizer: &impl Tokenizer,
1337        prompt: &str,
1338        max_new_tokens: usize,
1339        sampling: Sampling,
1340        on_text: impl FnMut(&str) -> ControlFlow<()>,
1341    ) -> Result<String> {
1342        // `None` turns off the probe entirely: no capture, no readback, and
1343        // the same `logits_step_async` this method has always called.
1344        self.generate_async_probe(
1345            tokenizer,
1346            prompt,
1347            max_new_tokens,
1348            sampling,
1349            on_text,
1350            None::<fn(&[AttnStep])>,
1351        )
1352        .await
1353    }
1354
1355    /// [`Gpt2::generate_async_ctl`] with an optional attention probe:
1356    /// `on_attn`, when present, fires once per decode step with every block's
1357    /// attention probabilities — including the prompt prefill, whose `q_len`
1358    /// is the prompt length rather than 1.
1359    ///
1360    /// Opt-in because it costs one readback per block per token. Passing
1361    /// `None` is exactly the non-probing path; the generated text is identical
1362    /// either way, since the probe reads tensors the step already computed.
1363    pub async fn generate_async_probe(
1364        &self,
1365        tokenizer: &impl Tokenizer,
1366        prompt: &str,
1367        max_new_tokens: usize,
1368        sampling: Sampling,
1369        on_text: impl FnMut(&str) -> ControlFlow<()>,
1370        on_attn: Option<impl FnMut(&[AttnStep])>,
1371    ) -> Result<String> {
1372        // No detail layers and no top-n: the attention-only probe, which is
1373        // what this signature has always promised.
1374        self.generate_async_trace(
1375            tokenizer,
1376            prompt,
1377            max_new_tokens,
1378            sampling,
1379            on_text,
1380            on_attn.map(|mut f| move |t: &StepTrace| f(&t.attn)),
1381            0,
1382            0,
1383        )
1384        .await
1385    }
1386
1387    /// [`Gpt2::generate_async_probe`] with the full calculation trace:
1388    /// `on_trace`, when present, fires once per step — including the prompt
1389    /// prefill, whose `q_len` is the prompt length rather than 1 — with
1390    /// everything [`Gpt2::logits_step_trace_async`] captured.
1391    ///
1392    /// `None` is byte-for-byte the non-probing path. With `Some`, the
1393    /// generated text is still identical: the probe reads tensors the step
1394    /// already computed and changes no arithmetic.
1395    #[allow(clippy::too_many_arguments)] // sampling, two sinks and the two
1396    // cost knobs; a builder here would be ceremony over a call made twice.
1397    pub async fn generate_async_trace(
1398        &self,
1399        tokenizer: &impl Tokenizer,
1400        prompt: &str,
1401        max_new_tokens: usize,
1402        sampling: Sampling,
1403        mut on_text: impl FnMut(&str) -> ControlFlow<()>,
1404        mut on_trace: Option<impl FnMut(&StepTrace)>,
1405        detail_layers: usize,
1406        top_n: usize,
1407    ) -> Result<String> {
1408        // The streaming helper takes a plain sink, so the break request is
1409        // captured here and checked once the delta has been forwarded. A Cell
1410        // keeps the flag readable while the closure holds it.
1411        let stop = std::cell::Cell::new(false);
1412        let mut on_text = |s: &str| {
1413            if on_text(s).is_break() {
1414                stop.set(true);
1415            }
1416        };
1417        let mut ids = tokenizer.encode(prompt)?;
1418        if ids.is_empty() {
1419            return Err(ForgeError::Tokenizer("prompt produced no tokens".into()));
1420        }
1421        let mut rng = match sampling {
1422            Sampling::TopK { seed, .. } => Some(StdRng::seed_from_u64(seed)),
1423            Sampling::Greedy => None,
1424        };
1425        let mut cache = self.new_cache()?;
1426        let mut logits = self
1427            .step_async(&ids, &mut cache, &mut on_trace, detail_layers, top_n)
1428            .await?; // prompt prefill
1429        // Stream over the raw byte-level decode (append-only per token),
1430        // emitting only its valid-UTF-8 prefix: a multi-byte character split
1431        // across BPE tokens is held back until its trailing bytes arrive.
1432        let mut bytes = tokenizer.decode_bytes(&ids);
1433        let mut sent = emit_valid_prefix(&bytes, 0, &mut on_text);
1434        for _ in 0..max_new_tokens {
1435            let next = sample(&logits, sampling, rng.as_mut());
1436            if Some(next) == self.config.eos_token_id {
1437                break;
1438            }
1439            ids.push(next);
1440            bytes.extend(tokenizer.decode_bytes(&[next]));
1441            sent = emit_valid_prefix(&bytes, sent, &mut on_text);
1442            if stop.get() {
1443                break;
1444            }
1445            if ids.len() >= self.config.n_ctx {
1446                break;
1447            }
1448            logits = self
1449                .step_async(&[next], &mut cache, &mut on_trace, detail_layers, top_n)
1450                .await?; // single-token decode
1451        }
1452        Ok(tokenizer.decode(&ids))
1453    }
1454
1455    /// One decode step, forwarding the trace to `on_trace` when probing.
1456    async fn step_async(
1457        &self,
1458        ids: &[u32],
1459        cache: &mut KvCache,
1460        on_trace: &mut Option<impl FnMut(&StepTrace)>,
1461        detail_layers: usize,
1462        top_n: usize,
1463    ) -> Result<Vec<f32>> {
1464        match on_trace {
1465            Some(f) => {
1466                let (logits, trace) = self
1467                    .logits_step_trace_async(ids, cache, detail_layers, top_n)
1468                    .await?;
1469                f(&trace);
1470                Ok(logits)
1471            }
1472            None => self.logits_step_async(ids, cache).await,
1473        }
1474    }
1475}
1476
1477/// Send `bytes[sent..]` to `on_text` up to the longest decodable prefix;
1478/// returns the new high-water mark. Invalid sequences become U+FFFD (matching
1479/// `from_utf8_lossy`); an *incomplete* trailing character is held back until
1480/// its remaining bytes arrive.
1481fn emit_valid_prefix(bytes: &[u8], mut sent: usize, on_text: &mut impl FnMut(&str)) -> usize {
1482    loop {
1483        match std::str::from_utf8(&bytes[sent..]) {
1484            Ok(s) => {
1485                if !s.is_empty() {
1486                    on_text(s);
1487                }
1488                return bytes.len();
1489            }
1490            Err(e) => {
1491                let valid = e.valid_up_to();
1492                if valid > 0 {
1493                    on_text(std::str::from_utf8(&bytes[sent..sent + valid]).unwrap());
1494                    sent += valid;
1495                }
1496                match e.error_len() {
1497                    // Invalid sequence: emit a replacement char and skip it.
1498                    Some(bad) => {
1499                        on_text("\u{FFFD}");
1500                        sent += bad;
1501                    }
1502                    // Incomplete tail: wait for the next token's bytes.
1503                    None => return sent,
1504                }
1505            }
1506        }
1507    }
1508}
1509
1510/// The `n` most likely next tokens and their probabilities, most likely first.
1511///
1512/// A full softmax over the row, so a bar labelled 0.21 means 21% of the model's
1513/// probability mass — not 21% of whatever survived a top-k truncation. This is
1514/// the model's own distribution, before any sampling temperature: the sampler
1515/// picks from it, but the picture is of the model.
1516///
1517/// No GPU work: the logits were already read back for sampling.
1518pub fn top_probs(logits: &[f32], n: usize) -> Vec<(u32, f32)> {
1519    let n = n.min(logits.len());
1520    if n == 0 {
1521        return Vec::new();
1522    }
1523    let by_logit = |a: &u32, b: &u32| logits[*b as usize].total_cmp(&logits[*a as usize]);
1524    let mut idx: Vec<u32> = (0..logits.len() as u32).collect();
1525    // Partition first: `n` is ~24 and the vocabulary is up to 50257, so a full
1526    // sort per token would be the most expensive thing in the trace.
1527    idx.select_nth_unstable_by(n - 1, by_logit);
1528    idx.truncate(n);
1529    idx.sort_unstable_by(by_logit);
1530
1531    let max = logits.iter().copied().fold(f32::NEG_INFINITY, f32::max);
1532    let total: f32 = logits.iter().map(|l| (l - max).exp()).sum();
1533    idx.into_iter()
1534        .map(|i| (i, (logits[i as usize] - max).exp() / total))
1535        .collect()
1536}
1537
1538fn bias(l: &Linear) -> Result<&Tensor> {
1539    l.b.as_ref()
1540        .ok_or_else(|| ForgeError::Shape("training requires biases".into()))
1541}
1542
1543pub(crate) fn sample(logits: &[f32], sampling: Sampling, rng: Option<&mut StdRng>) -> u32 {
1544    match sampling {
1545        Sampling::Greedy => argmax(logits),
1546        Sampling::TopK { k, temperature, .. } => {
1547            let mut indexed: Vec<(usize, f32)> = logits.iter().copied().enumerate().collect();
1548            indexed.sort_by(|a, b| b.1.total_cmp(&a.1));
1549            indexed.truncate(k.max(1));
1550            let t = temperature.max(1e-4);
1551            let max = indexed[0].1;
1552            let weights: Vec<f32> = indexed.iter().map(|(_, l)| ((l - max) / t).exp()).collect();
1553            let total: f32 = weights.iter().sum();
1554            let mut r = rng.expect("rng required for TopK").random::<f32>() * total;
1555            for ((idx, _), w) in indexed.iter().zip(&weights) {
1556                if r <= *w {
1557                    return *idx as u32;
1558                }
1559                r -= w;
1560            }
1561            indexed[0].0 as u32
1562        }
1563    }
1564}
1565
1566/// Draws tokens from a row of logits, carrying the sampling stream with it.
1567///
1568/// [`Gpt2::generate`] and friends sample internally; this is the same drawing
1569/// logic for callers that own the loop themselves, and it owns its RNG so no
1570/// caller has to name `rand`'s types.
1571///
1572/// ```no_run
1573/// # use forge::{Sampler, Sampling};
1574/// let mut s = Sampler::new(1337);
1575/// let logits = vec![0.1f32, 2.0, 0.3];
1576/// let id = s.pick(&logits, Sampling::TopK { k: 2, temperature: 0.8, seed: 0 });
1577/// ```
1578///
1579/// `Sampling::TopK`'s own `seed` field is ignored here — the stream is this
1580/// sampler's, seeded once at [`Sampler::new`], so successive picks continue it
1581/// rather than restarting it.
1582pub struct Sampler {
1583    rng: StdRng,
1584}
1585
1586impl Sampler {
1587    pub fn new(seed: u64) -> Self {
1588        Sampler {
1589            rng: StdRng::seed_from_u64(seed),
1590        }
1591    }
1592
1593    /// One token id from `logits`. Deterministic under [`Sampling::Greedy`];
1594    /// otherwise it advances this sampler's stream.
1595    pub fn pick(&mut self, logits: &[f32], sampling: Sampling) -> u32 {
1596        sample(logits, sampling, Some(&mut self.rng))
1597    }
1598
1599    /// Restart the stream. Same contract as constructing a fresh sampler.
1600    pub fn reseed(&mut self, seed: u64) {
1601        self.rng = StdRng::seed_from_u64(seed);
1602    }
1603}
1604
1605fn argmax(v: &[f32]) -> u32 {
1606    let mut best = 0usize;
1607    for (i, &x) in v.iter().enumerate() {
1608        if x > v[best] {
1609            best = i;
1610        }
1611    }
1612    best as u32
1613}