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