Skip to main content

forge/
ops.rs

1//! Backend-agnostic tensor operations. Each op validates shapes, dispatches
2//! to the CPU reference or the WGPU kernels, and returns a tensor on the same
3//! device as its inputs.
4
5use crate::backend::{cpu, wgpu as gpu};
6use crate::error::{ForgeError, Result};
7use crate::shape::Shape;
8use crate::tensor::{CpuStorage, Storage, Tensor, WgpuStorage};
9
10fn cpu_f32(t: &Tensor) -> Result<&[f32]> {
11    match t.storage() {
12        Storage::Cpu(CpuStorage::F32(v)) => Ok(v),
13        _ => Err(ForgeError::Device("expected CPU f32 tensor".into())),
14    }
15}
16
17fn cpu_u32(t: &Tensor) -> Result<&[u32]> {
18    match t.storage() {
19        Storage::Cpu(CpuStorage::U32(v)) => Ok(v),
20        _ => Err(ForgeError::Device("expected CPU u32 tensor".into())),
21    }
22}
23
24fn gpu_storage(t: &Tensor) -> Result<&WgpuStorage> {
25    match t.storage() {
26        Storage::Wgpu(s) => Ok(s),
27        _ => Err(ForgeError::Device("expected WGPU tensor".into())),
28    }
29}
30
31fn same_device(tensors: &[&Tensor]) -> Result<()> {
32    let first = tensors[0].device();
33    for t in &tensors[1..] {
34        if !first.same_as(&t.device()) {
35            return Err(ForgeError::Device(format!(
36                "tensors on different devices: {} vs {}",
37                first.describe(),
38                t.device().describe()
39            )));
40        }
41    }
42    Ok(())
43}
44
45fn f32_tensor(storage: Storage, shape: Shape) -> Tensor {
46    Tensor {
47        storage,
48        shape,
49        dtype: crate::dtype::DType::F32,
50    }
51}
52
53/// Elementwise `a + b`; shapes must match exactly.
54pub fn add(a: &Tensor, b: &Tensor) -> Result<Tensor> {
55    same_device(&[a, b])?;
56    if a.shape() != b.shape() {
57        return Err(ForgeError::Shape(format!(
58            "add shape mismatch: {} vs {}",
59            a.shape(),
60            b.shape()
61        )));
62    }
63    let n = a.shape().numel();
64    let storage = match a.storage() {
65        Storage::Cpu(_) => Storage::Cpu(CpuStorage::F32(cpu::add(cpu_f32(a)?, cpu_f32(b)?).into())),
66        Storage::Wgpu(_) => Storage::Wgpu(gpu::ops::add(gpu_storage(a)?, gpu_storage(b)?, n)),
67    };
68    Ok(f32_tensor(storage, a.shape().clone()))
69}
70
71/// GELU (tanh approximation).
72pub fn gelu(x: &Tensor) -> Result<Tensor> {
73    let n = x.shape().numel();
74    let storage = match x.storage() {
75        Storage::Cpu(_) => Storage::Cpu(CpuStorage::F32(cpu::gelu(cpu_f32(x)?).into())),
76        Storage::Wgpu(_) => Storage::Wgpu(gpu::ops::gelu(gpu_storage(x)?, n)),
77    };
78    Ok(f32_tensor(storage, x.shape().clone()))
79}
80
81#[derive(Clone, Copy)]
82pub struct MatmulSpec {
83    /// Interpret A as [k, m] instead of [m, k] (used by backward passes:
84    /// dB = Aᵀ·dY without materializing the transpose).
85    pub trans_a: bool,
86    /// Interpret B as [n, k] instead of [k, n].
87    pub trans_b: bool,
88    /// Scale applied to the product (before bias).
89    pub alpha: f32,
90    /// Logical row count of each batch element of B, when B is a view over
91    /// a larger preallocated buffer (KV cache): only the first `b_rows` rows
92    /// are read, while the batch stride comes from the physical shape.
93    pub b_rows: Option<usize>,
94}
95
96impl Default for MatmulSpec {
97    fn default() -> Self {
98        MatmulSpec {
99            trans_a: false,
100            trans_b: false,
101            alpha: 1.0,
102            b_rows: None,
103        }
104    }
105}
106
107/// Batched matmul with optional A/B-transpose, scaling, and bias.
108///
109/// A: `[m, k]` or `[batch, m, k]` (`[k, m]` with `trans_a`); B: `[k, n]`
110/// (`[n, k]` transposed), optionally batched. A rank-2 operand broadcasts
111/// across the batch. Bias: `[n]`.
112pub fn matmul(a: &Tensor, b: &Tensor, bias: Option<&Tensor>, spec: MatmulSpec) -> Result<Tensor> {
113    same_device(&[a, b])?;
114    if let Some(bias) = bias {
115        same_device(&[a, bias])?;
116    }
117    let (batch_a, ad0, ad1) = rank23_dims(a, "A")?;
118    let (m, k) = if spec.trans_a { (ad1, ad0) } else { (ad0, ad1) };
119    let (batch_b, bd0_phys, bd1) = rank23_dims(b, "B")?;
120    let bd0 = match spec.b_rows {
121        Some(rows) if rows <= bd0_phys => rows,
122        Some(rows) => {
123            return Err(ForgeError::Shape(format!(
124                "matmul b_rows {rows} exceeds physical rows {bd0_phys}"
125            )));
126        }
127        None => bd0_phys,
128    };
129    let (bk, n) = if spec.trans_b { (bd1, bd0) } else { (bd0, bd1) };
130    if bk != k {
131        return Err(ForgeError::Shape(format!(
132            "matmul inner dim mismatch: A {} vs B {}",
133            a.shape(),
134            b.shape()
135        )));
136    }
137    let batch = match (batch_a, batch_b) {
138        (None, None) => 1,
139        (Some(x), None) | (None, Some(x)) => x,
140        (Some(x), Some(y)) if x == y => x,
141        (Some(x), Some(y)) => {
142            return Err(ForgeError::Shape(format!(
143                "matmul batch mismatch: {x} vs {y}"
144            )));
145        }
146    };
147    // Batch strides come from the *physical* shapes.
148    let a_stride = if batch_a.is_some() { ad0 * ad1 } else { 0 };
149    let b_stride = if batch_b.is_some() { bd0_phys * bd1 } else { 0 };
150    if let Some(bias) = bias
151        && bias.shape().numel() != n
152    {
153        return Err(ForgeError::Shape(format!(
154            "bias length {} != n {n}",
155            bias.shape().numel()
156        )));
157    }
158    let out_shape = if batch_a.is_some() || batch_b.is_some() {
159        Shape::new(&[batch, m, n])
160    } else {
161        Shape::new(&[m, n])
162    };
163    let storage = match a.storage() {
164        Storage::Cpu(_) => {
165            let bias = bias.map(cpu_f32).transpose()?;
166            Storage::Cpu(CpuStorage::F32(
167                cpu::matmul(
168                    cpu_f32(a)?,
169                    cpu_f32(b)?,
170                    bias,
171                    m,
172                    k,
173                    n,
174                    batch,
175                    a_stride,
176                    b_stride,
177                    spec.trans_a,
178                    spec.trans_b,
179                    spec.alpha,
180                )
181                .into(),
182            ))
183        }
184        Storage::Wgpu(_) => {
185            let bias = bias.map(gpu_storage).transpose()?;
186            Storage::Wgpu(gpu::ops::matmul(
187                gpu_storage(a)?,
188                gpu_storage(b)?,
189                bias,
190                m,
191                k,
192                n,
193                batch,
194                a_stride,
195                b_stride,
196                spec.trans_a,
197                spec.trans_b,
198                spec.alpha,
199            ))
200        }
201    };
202    Ok(f32_tensor(storage, out_shape))
203}
204
205fn rank23_dims(t: &Tensor, which: &str) -> Result<(Option<usize>, usize, usize)> {
206    match t.shape().dims() {
207        [d0, d1] => Ok((None, *d0, *d1)),
208        [bb, d0, d1] => Ok((Some(*bb), *d0, *d1)),
209        d => Err(ForgeError::Shape(format!(
210            "matmul {which} rank {} unsupported",
211            d.len()
212        ))),
213    }
214}
215
216/// Softmax over the last dim. With `causal`, row r (query position
217/// `r % q_len`, where q_len is the second-to-last dim) sees key j only when
218/// `j <= query + off`; `off` is `key_len - query_len` when using a KV cache.
219pub fn softmax(x: &Tensor, causal: bool, off: usize) -> Result<Tensor> {
220    let dims = x.shape().dims();
221    if dims.is_empty() {
222        return Err(ForgeError::Shape("softmax on scalar".into()));
223    }
224    let cols = dims[dims.len() - 1];
225    let rows = x.shape().numel() / cols;
226    let q_len = if dims.len() >= 2 {
227        dims[dims.len() - 2]
228    } else {
229        1
230    };
231    let storage = match x.storage() {
232        Storage::Cpu(_) => Storage::Cpu(CpuStorage::F32(
233            cpu::softmax(cpu_f32(x)?, rows, cols, q_len, causal, off).into(),
234        )),
235        Storage::Wgpu(_) => Storage::Wgpu(gpu::ops::softmax(
236            gpu_storage(x)?,
237            rows,
238            cols,
239            q_len,
240            causal,
241            off,
242        )),
243    };
244    Ok(f32_tensor(storage, x.shape().clone()))
245}
246
247/// LayerNorm over the last dim.
248pub fn layernorm(x: &Tensor, gamma: &Tensor, beta: &Tensor, eps: f32) -> Result<Tensor> {
249    same_device(&[x, gamma, beta])?;
250    let dims = x.shape().dims();
251    let cols = dims[dims.len() - 1];
252    let rows = x.shape().numel() / cols;
253    if gamma.shape().numel() != cols || beta.shape().numel() != cols {
254        return Err(ForgeError::Shape(
255            "layernorm gamma/beta length mismatch".into(),
256        ));
257    }
258    let storage = match x.storage() {
259        Storage::Cpu(_) => Storage::Cpu(CpuStorage::F32(
260            cpu::layernorm(
261                cpu_f32(x)?,
262                cpu_f32(gamma)?,
263                cpu_f32(beta)?,
264                rows,
265                cols,
266                eps,
267            )
268            .into(),
269        )),
270        Storage::Wgpu(_) => Storage::Wgpu(gpu::ops::layernorm(
271            gpu_storage(x)?,
272            gpu_storage(gamma)?,
273            gpu_storage(beta)?,
274            rows,
275            cols,
276            eps,
277        )),
278    };
279    Ok(f32_tensor(storage, x.shape().clone()))
280}
281
282/// Fused token + positional embedding: `out[t] = wte[ids[t]] + wpe[t + pos]`.
283pub fn embedding(ids: &Tensor, wte: &Tensor, wpe: Option<&Tensor>, pos: usize) -> Result<Tensor> {
284    let chunk_rows = wte
285        .shape()
286        .dims()
287        .first()
288        .copied()
289        .ok_or_else(|| ForgeError::Shape("wte must be rank 2".into()))?;
290    embedding_chunked(ids, std::slice::from_ref(wte), chunk_rows, wpe, pos)
291}
292
293/// Embedding gather from a row-chunked table. `chunks[i]` holds rows
294/// [i * chunk_rows, ...) of the full [vocab, c] table — chunking keeps every
295/// GPU binding under max_storage_buffer_binding_size (GPT-2's wte alone is
296/// ~147 MiB, above the WebGPU default and llvmpipe's hard 128 MiB limit).
297pub fn embedding_chunked(
298    ids: &Tensor,
299    chunks: &[Tensor],
300    chunk_rows: usize,
301    wpe: Option<&Tensor>,
302    pos: usize,
303) -> Result<Tensor> {
304    if chunks.is_empty() {
305        return Err(ForgeError::Shape(
306            "embedding needs at least one chunk".into(),
307        ));
308    }
309    same_device(&[ids, &chunks[0]])?;
310    let t = ids.shape().numel();
311    let c = match chunks[0].shape().dims() {
312        [_, c] => *c,
313        _ => return Err(ForgeError::Shape("wte chunks must be rank 2".into())),
314    };
315    for (i, ch) in chunks.iter().enumerate() {
316        match ch.shape().dims() {
317            [r, cc] if *cc == c && (*r == chunk_rows || i + 1 == chunks.len()) => {}
318            _ => return Err(ForgeError::Shape("inconsistent wte chunk shapes".into())),
319        }
320    }
321    let n_ctx = match wpe {
322        Some(wpe) => match wpe.shape().dims() {
323            [n, wc] if *wc == c => *n,
324            _ => return Err(ForgeError::Shape("wpe must be [n_ctx, c]".into())),
325        },
326        None => 0,
327    };
328    if let Some(wpe) = wpe {
329        same_device(&[&chunks[0], wpe])?;
330        if t + pos > n_ctx {
331            return Err(ForgeError::Shape(format!(
332                "sequence {t}+{pos} exceeds context length {n_ctx}"
333            )));
334        }
335    }
336    let storage = match ids.storage() {
337        Storage::Cpu(_) => {
338            let ids = cpu_u32(ids)?;
339            let wpe = wpe.map(cpu_f32).transpose()?;
340            let mut out = vec![0.0f32; t * c];
341            for (tt, &id) in ids.iter().enumerate() {
342                let (chunk_i, row) = ((id as usize) / chunk_rows, (id as usize) % chunk_rows);
343                let table = cpu_f32(&chunks[chunk_i])?;
344                let dst = &mut out[tt * c..(tt + 1) * c];
345                dst.copy_from_slice(&table[row * c..(row + 1) * c]);
346                if let Some(wpe) = wpe {
347                    for (d, &pv) in dst.iter_mut().zip(&wpe[(tt + pos) * c..(tt + pos + 1) * c]) {
348                        *d += pv;
349                    }
350                }
351            }
352            Storage::Cpu(CpuStorage::F32(out.into()))
353        }
354        Storage::Wgpu(_) => {
355            let wpe = wpe.map(gpu_storage).transpose()?;
356            let gpu_chunks: Vec<(&crate::tensor::WgpuStorage, usize)> = chunks
357                .iter()
358                .map(|ch| Ok((gpu_storage(ch)?, ch.shape().dim(0))))
359                .collect::<Result<_>>()?;
360            Storage::Wgpu(gpu::ops::embedding(
361                gpu_storage(ids)?,
362                &gpu_chunks,
363                chunk_rows,
364                wpe,
365                t,
366                c,
367                n_ctx,
368                pos,
369            ))
370        }
371    };
372    Ok(f32_tensor(storage, Shape::new(&[t, c])))
373}
374
375/// a [m, k] @ concat(chunks)^T -> [m, sum n_i], chunks each [n_i, k].
376/// The column-chunked equivalent of `matmul` with `trans_b` — used for the
377/// weight-tied LM head over a chunked wte.
378pub fn matmul_chunked_transb(a: &Tensor, chunks: &[Tensor], alpha: f32) -> Result<Tensor> {
379    if chunks.is_empty() {
380        return Err(ForgeError::Shape("matmul needs at least one chunk".into()));
381    }
382    same_device(&[a, &chunks[0]])?;
383    let (m, k) = match a.shape().dims() {
384        [m, k] => (*m, *k),
385        _ => return Err(ForgeError::Shape("chunked matmul A must be rank 2".into())),
386    };
387    let mut n_total = 0usize;
388    for ch in chunks {
389        match ch.shape().dims() {
390            [n_i, kk] if *kk == k => n_total += n_i,
391            _ => {
392                return Err(ForgeError::Shape(format!(
393                    "chunk shape {} incompatible with k={k}",
394                    ch.shape()
395                )));
396            }
397        }
398    }
399    let out_shape = Shape::new(&[m, n_total]);
400    match a.storage() {
401        Storage::Cpu(_) => {
402            let a_data = cpu_f32(a)?;
403            let mut out = vec![0.0f32; m * n_total];
404            let mut n_off = 0usize;
405            for ch in chunks {
406                let n_i = ch.shape().dim(0);
407                let part = cpu::matmul(
408                    a_data,
409                    cpu_f32(ch)?,
410                    None,
411                    m,
412                    k,
413                    n_i,
414                    1,
415                    0,
416                    0,
417                    false,
418                    true,
419                    alpha,
420                );
421                for row in 0..m {
422                    out[row * n_total + n_off..row * n_total + n_off + n_i]
423                        .copy_from_slice(&part[row * n_i..(row + 1) * n_i]);
424                }
425                n_off += n_i;
426            }
427            Ok(f32_tensor(
428                Storage::Cpu(CpuStorage::F32(out.into())),
429                out_shape,
430            ))
431        }
432        Storage::Wgpu(s) => {
433            let out = gpu::ops::alloc_storage(&s.ctx, m * n_total);
434            let mut n_off = 0usize;
435            for ch in chunks {
436                let n_i = ch.shape().dim(0);
437                gpu::ops::matmul_into(
438                    &out,
439                    gpu_storage(a)?,
440                    gpu_storage(ch)?,
441                    None,
442                    m,
443                    k,
444                    n_i,
445                    1,
446                    0,
447                    0,
448                    false,
449                    true,
450                    alpha,
451                    n_off,
452                    n_total,
453                );
454                n_off += n_i;
455            }
456            Ok(f32_tensor(Storage::Wgpu(out), out_shape))
457        }
458    }
459}
460
461// ---- backward / training primitives (roadmap v4, Stages 8-9) ----
462
463fn last_dim_rows(x: &Tensor) -> Result<(usize, usize)> {
464    let dims = x.shape().dims();
465    if dims.is_empty() {
466        return Err(ForgeError::Shape("op needs rank >= 1".into()));
467    }
468    let cols = dims[dims.len() - 1];
469    Ok((x.shape().numel() / cols, cols))
470}
471
472/// GELU backward: dx = gelu'(x) * dy.
473pub fn gelu_bwd(x: &Tensor, dy: &Tensor) -> Result<Tensor> {
474    same_device(&[x, dy])?;
475    if x.shape() != dy.shape() {
476        return Err(ForgeError::Shape("gelu_bwd shape mismatch".into()));
477    }
478    let n = x.shape().numel();
479    let storage = match x.storage() {
480        Storage::Cpu(_) => Storage::Cpu(CpuStorage::F32(
481            cpu::gelu_bwd(cpu_f32(x)?, cpu_f32(dy)?).into(),
482        )),
483        Storage::Wgpu(_) => Storage::Wgpu(gpu::ops::gelu_bwd(gpu_storage(x)?, gpu_storage(dy)?, n)),
484    };
485    Ok(f32_tensor(storage, x.shape().clone()))
486}
487
488/// Softmax backward from the forward *output* y: dx = y * (dy - sum(y*dy))
489/// per row. Causal masking needs no parameters here — masked forward outputs
490/// are exact zeros.
491pub fn softmax_bwd(y: &Tensor, dy: &Tensor) -> Result<Tensor> {
492    same_device(&[y, dy])?;
493    if y.shape() != dy.shape() {
494        return Err(ForgeError::Shape("softmax_bwd shape mismatch".into()));
495    }
496    let (rows, cols) = last_dim_rows(y)?;
497    let storage = match y.storage() {
498        Storage::Cpu(_) => Storage::Cpu(CpuStorage::F32(
499            cpu::softmax_bwd(cpu_f32(y)?, cpu_f32(dy)?, rows, cols).into(),
500        )),
501        Storage::Wgpu(_) => Storage::Wgpu(gpu::ops::softmax_bwd(
502            gpu_storage(y)?,
503            gpu_storage(dy)?,
504            rows,
505            cols,
506        )),
507    };
508    Ok(f32_tensor(storage, y.shape().clone()))
509}
510
511/// LayerNorm backward: (dx, dgamma, dbeta).
512pub fn layernorm_bwd(
513    x: &Tensor,
514    gamma: &Tensor,
515    dy: &Tensor,
516    eps: f32,
517) -> Result<(Tensor, Tensor, Tensor)> {
518    same_device(&[x, gamma, dy])?;
519    if x.shape() != dy.shape() {
520        return Err(ForgeError::Shape("layernorm_bwd shape mismatch".into()));
521    }
522    let (rows, cols) = last_dim_rows(x)?;
523    if gamma.shape().numel() != cols {
524        return Err(ForgeError::Shape("layernorm_bwd gamma length".into()));
525    }
526    let pshape = gamma.shape().clone();
527    match x.storage() {
528        Storage::Cpu(_) => {
529            let dx =
530                cpu::layernorm_bwd_dx(cpu_f32(x)?, cpu_f32(gamma)?, cpu_f32(dy)?, rows, cols, eps);
531            let (dg, db) = cpu::layernorm_bwd_dparams(cpu_f32(x)?, cpu_f32(dy)?, rows, cols, eps);
532            Ok((
533                f32_tensor(Storage::Cpu(CpuStorage::F32(dx.into())), x.shape().clone()),
534                f32_tensor(Storage::Cpu(CpuStorage::F32(dg.into())), pshape.clone()),
535                f32_tensor(Storage::Cpu(CpuStorage::F32(db.into())), pshape),
536            ))
537        }
538        Storage::Wgpu(_) => {
539            let (dx, dg, db) = gpu::ops::layernorm_bwd(
540                gpu_storage(x)?,
541                gpu_storage(gamma)?,
542                gpu_storage(dy)?,
543                rows,
544                cols,
545                eps,
546            );
547            Ok((
548                f32_tensor(Storage::Wgpu(dx), x.shape().clone()),
549                f32_tensor(Storage::Wgpu(dg), pshape.clone()),
550                f32_tensor(Storage::Wgpu(db), pshape),
551            ))
552        }
553    }
554}
555
556/// Column sums over all leading dims: `[.., cols]` -> `[cols]`. Bias gradients.
557pub fn sum_rows(x: &Tensor) -> Result<Tensor> {
558    let (rows, cols) = last_dim_rows(x)?;
559    let storage = match x.storage() {
560        Storage::Cpu(_) => Storage::Cpu(CpuStorage::F32(
561            cpu::sum_rows(cpu_f32(x)?, rows, cols).into(),
562        )),
563        Storage::Wgpu(_) => Storage::Wgpu(gpu::ops::sum_rows(gpu_storage(x)?, rows, cols)),
564    };
565    Ok(f32_tensor(storage, Shape::new(&[cols])))
566}
567
568/// `dst[ids[r]] += src[r]`, in place. Embedding-table gradient scatter.
569pub fn scatter_add_rows(dst: &mut Tensor, ids: &Tensor, src: &Tensor) -> Result<()> {
570    same_device(&[dst, ids, src])?;
571    let (vocab, c) = match dst.shape().dims() {
572        [v, c] => (*v, *c),
573        _ => return Err(ForgeError::Shape("scatter_add dst must be rank 2".into())),
574    };
575    let t = ids.shape().numel();
576    if src.shape().dims() != [t, c] {
577        return Err(ForgeError::Shape(format!(
578            "scatter_add src {} != [{t}, {c}]",
579            src.shape()
580        )));
581    }
582    match (&mut dst.storage, src.storage()) {
583        (Storage::Cpu(CpuStorage::F32(d)), Storage::Cpu(CpuStorage::F32(s))) => {
584            let ids = cpu_u32(ids)?;
585            if let Some(&bad) = ids.iter().find(|&&id| id as usize >= vocab) {
586                return Err(ForgeError::Shape(format!(
587                    "scatter_add id {bad} >= {vocab}"
588                )));
589            }
590            let d: &mut Vec<f32> = std::sync::Arc::make_mut(d);
591            cpu::scatter_add_rows(d, ids, s, c);
592            Ok(())
593        }
594        (Storage::Wgpu(d), Storage::Wgpu(s)) => {
595            gpu::ops::scatter_add_rows(d, gpu_storage(ids)?, s, t, c, vocab * c);
596            Ok(())
597        }
598        _ => Err(ForgeError::Device(
599            "scatter_add expects f32 tensors on one device".into(),
600        )),
601    }
602}
603
604/// Per-row NLL: `out[r] = -log(probs[r, ids[r]])`.
605pub fn gather_nll(probs: &Tensor, ids: &Tensor) -> Result<Tensor> {
606    same_device(&[probs, ids])?;
607    let (rows, cols) = last_dim_rows(probs)?;
608    if ids.shape().numel() != rows {
609        return Err(ForgeError::Shape("gather_nll ids length".into()));
610    }
611    let storage = match probs.storage() {
612        Storage::Cpu(_) => Storage::Cpu(CpuStorage::F32(
613            cpu::gather_nll(cpu_f32(probs)?, cpu_u32(ids)?, cols).into(),
614        )),
615        Storage::Wgpu(_) => Storage::Wgpu(gpu::ops::gather_nll(
616            gpu_storage(probs)?,
617            gpu_storage(ids)?,
618            rows,
619            cols,
620        )),
621    };
622    Ok(f32_tensor(storage, Shape::new(&[rows])))
623}
624
625/// Cross-entropy backward: dlogits = (probs - onehot(ids)) * scale.
626pub fn ce_bwd(probs: &Tensor, ids: &Tensor, scale: f32) -> Result<Tensor> {
627    same_device(&[probs, ids])?;
628    let (rows, cols) = last_dim_rows(probs)?;
629    if ids.shape().numel() != rows {
630        return Err(ForgeError::Shape("ce_bwd ids length".into()));
631    }
632    let storage = match probs.storage() {
633        Storage::Cpu(_) => Storage::Cpu(CpuStorage::F32(
634            cpu::ce_bwd(cpu_f32(probs)?, cpu_u32(ids)?, rows, cols, scale).into(),
635        )),
636        Storage::Wgpu(_) => Storage::Wgpu(gpu::ops::ce_bwd(
637            gpu_storage(probs)?,
638            gpu_storage(ids)?,
639            rows,
640            cols,
641            scale,
642        )),
643    };
644    Ok(f32_tensor(storage, probs.shape().clone()))
645}
646
647/// Inverted dropout with a deterministic counter RNG (identical masks on
648/// both backends). Apply the same (p, seed) to dy for the backward pass.
649pub fn dropout(x: &Tensor, p: f32, seed: u32) -> Result<Tensor> {
650    if !(0.0..1.0).contains(&p) {
651        return Err(ForgeError::Shape(format!("dropout p {p} outside [0, 1)")));
652    }
653    if p == 0.0 {
654        return Ok(x.clone());
655    }
656    // Compute the keep-scale once on the CPU and hand the same bits to both
657    // backends: GPU division isn't guaranteed correctly-rounded (unlike
658    // multiplication), so an independent 1.0/(1.0-p) on the GPU can be a
659    // few ULP off from the CPU's, breaking bit-for-bit parity.
660    let scale = 1.0f32 / (1.0 - p);
661    let n = x.shape().numel();
662    let storage = match x.storage() {
663        Storage::Cpu(_) => Storage::Cpu(CpuStorage::F32(
664            cpu::dropout(cpu_f32(x)?, p, scale, seed).into(),
665        )),
666        Storage::Wgpu(_) => Storage::Wgpu(gpu::ops::dropout(gpu_storage(x)?, n, p, scale, seed)),
667    };
668    Ok(f32_tensor(storage, x.shape().clone()))
669}
670
671/// split_heads backward for one of q/k/v (`which` in 0..3):
672/// d [h, t, hd] -> [t, 3c] with the other thirds zero.
673pub fn unsplit_head(d: &Tensor, which: usize) -> Result<Tensor> {
674    let (h, t, hd) = match d.shape().dims() {
675        [h, t, hd] => (*h, *t, *hd),
676        _ => return Err(ForgeError::Shape("unsplit_head needs [h, t, hd]".into())),
677    };
678    let c = h * hd;
679    let shape = Shape::new(&[t, 3 * c]);
680    let storage = match d.storage() {
681        Storage::Cpu(_) => Storage::Cpu(CpuStorage::F32(
682            cpu::unsplit_head(cpu_f32(d)?, t, c, h, which).into(),
683        )),
684        Storage::Wgpu(_) => Storage::Wgpu(gpu::ops::unsplit_head(gpu_storage(d)?, t, c, h, which)),
685    };
686    Ok(f32_tensor(storage, shape))
687}
688
689/// merge_heads backward: dy [t, c] -> [h, t, hd].
690pub fn unmerge_heads(dy: &Tensor, h: usize) -> Result<Tensor> {
691    let (t, c) = match dy.shape().dims() {
692        [t, c] => (*t, *c),
693        _ => return Err(ForgeError::Shape("unmerge_heads needs [t, c]".into())),
694    };
695    if c % h != 0 {
696        return Err(ForgeError::Shape(format!("unmerge_heads c={c} % h={h}")));
697    }
698    let shape = Shape::new(&[h, t, c / h]);
699    let storage = match dy.storage() {
700        Storage::Cpu(_) => Storage::Cpu(CpuStorage::F32(
701            cpu::unmerge_heads(cpu_f32(dy)?, t, c, h).into(),
702        )),
703        Storage::Wgpu(_) => Storage::Wgpu(gpu::ops::unmerge_heads(gpu_storage(dy)?, t, c, h)),
704    };
705    Ok(f32_tensor(storage, shape))
706}
707
708/// Sum of squares of all elements (for the global gradient norm).
709/// Training-path op with a sync readback — WebGPU tensors are native-only
710/// here (browser training is out of scope for 1.0).
711pub fn sumsq(x: &Tensor) -> Result<f32> {
712    match x.storage() {
713        Storage::Cpu(_) => Ok(cpu_f32(x)?.iter().map(|&v| v * v).sum()),
714        #[cfg(not(target_arch = "wasm32"))]
715        Storage::Wgpu(s) => {
716            let (partials, groups) = gpu::ops::sumsq_partials(s, x.shape().numel());
717            let bytes = partials.ctx.readback(&partials.buf, 0, groups * 4)?;
718            let vals: Vec<f32> = bytemuck::pod_collect_to_vec(&bytes);
719            Ok(vals.iter().sum())
720        }
721        #[cfg(target_arch = "wasm32")]
722        Storage::Wgpu(_) => Err(crate::error::ForgeError::Wgpu(
723            "sumsq readback unavailable on wasm32 (training is native-only)".into(),
724        )),
725    }
726}
727
728/// y = x * alpha.
729pub fn scale(x: &Tensor, alpha: f32) -> Result<Tensor> {
730    let n = x.shape().numel();
731    let storage = match x.storage() {
732        Storage::Cpu(_) => Storage::Cpu(CpuStorage::F32(
733            cpu_f32(x)?
734                .iter()
735                .map(|&v| v * alpha)
736                .collect::<Vec<_>>()
737                .into(),
738        )),
739        Storage::Wgpu(_) => Storage::Wgpu(gpu::ops::scale(gpu_storage(x)?, n, alpha)),
740    };
741    Ok(f32_tensor(storage, x.shape().clone()))
742}
743
744/// AdamW step with decoupled weight decay, updating param/m/v in place.
745/// `step` is 1-based.
746#[allow(clippy::too_many_arguments)]
747pub fn adamw(
748    param: &mut Tensor,
749    grad: &Tensor,
750    m: &mut Tensor,
751    v: &mut Tensor,
752    lr: f32,
753    beta1: f32,
754    beta2: f32,
755    eps: f32,
756    weight_decay: f32,
757    step: u32,
758) -> Result<()> {
759    if param.shape() != grad.shape() || param.shape() != m.shape() || param.shape() != v.shape() {
760        return Err(ForgeError::Shape("adamw shape mismatch".into()));
761    }
762    let n = param.shape().numel();
763    match (&mut param.storage, grad.storage()) {
764        (Storage::Cpu(CpuStorage::F32(p)), Storage::Cpu(CpuStorage::F32(g))) => {
765            let (Storage::Cpu(CpuStorage::F32(ms)), Storage::Cpu(CpuStorage::F32(vs))) =
766                (&mut m.storage, &mut v.storage)
767            else {
768                return Err(ForgeError::Device("adamw state device mismatch".into()));
769            };
770            let p: &mut Vec<f32> = std::sync::Arc::make_mut(p);
771            let ms: &mut Vec<f32> = std::sync::Arc::make_mut(ms);
772            let vs: &mut Vec<f32> = std::sync::Arc::make_mut(vs);
773            cpu::adamw(p, g, ms, vs, lr, beta1, beta2, eps, weight_decay, step);
774            Ok(())
775        }
776        (Storage::Wgpu(p), Storage::Wgpu(g)) => {
777            let (Storage::Wgpu(ms), Storage::Wgpu(vs)) = (&m.storage, &v.storage) else {
778                return Err(ForgeError::Device("adamw state device mismatch".into()));
779            };
780            gpu::ops::adamw(p, g, ms, vs, n, lr, beta1, beta2, eps, weight_decay, step);
781            Ok(())
782        }
783        _ => Err(ForgeError::Device(
784            "adamw expects f32 tensors on one device".into(),
785        )),
786    }
787}
788
789/// Append `src` [h, t, hd] into the preallocated cache [h, cap, hd] at row
790/// offset `len` within each head, in place. Used by KV-cache decode.
791pub fn kv_append(cache: &mut Tensor, src: &Tensor, len: usize) -> Result<()> {
792    same_device(&[cache, src])?;
793    let (h, cap, hd) = match cache.shape().dims() {
794        [h, cap, hd] => (*h, *cap, *hd),
795        _ => return Err(ForgeError::Shape("kv_append cache must be rank 3".into())),
796    };
797    let t = match src.shape().dims() {
798        [sh, t, shd] if *sh == h && *shd == hd => *t,
799        _ => {
800            return Err(ForgeError::Shape(format!(
801                "kv_append src {} incompatible with cache {}",
802                src.shape(),
803                cache.shape()
804            )));
805        }
806    };
807    if len + t > cap {
808        return Err(ForgeError::Shape(format!(
809            "kv_append {len}+{t} exceeds cache capacity {cap}"
810        )));
811    }
812    match (&mut cache.storage, src.storage()) {
813        (Storage::Cpu(CpuStorage::F32(dst)), Storage::Cpu(CpuStorage::F32(s))) => {
814            let dst: &mut Vec<f32> = std::sync::Arc::make_mut(dst);
815            cpu::kv_append(dst, s, h, t, hd, cap, len);
816            Ok(())
817        }
818        (Storage::Wgpu(c), Storage::Wgpu(s)) => {
819            gpu::ops::kv_append(c, s, h, t, hd, cap, len);
820            Ok(())
821        }
822        _ => Err(ForgeError::Device(
823            "kv_append expects f32 tensors on one device".into(),
824        )),
825    }
826}
827
828/// qkv [t, 3c] -> (q, k, v) each [h, t, c/h].
829pub fn split_heads(qkv: &Tensor, n_head: usize) -> Result<(Tensor, Tensor, Tensor)> {
830    let (t, c3) = match qkv.shape().dims() {
831        [t, c3] => (*t, *c3),
832        _ => return Err(ForgeError::Shape("split_heads needs [t, 3c]".into())),
833    };
834    let c = c3 / 3;
835    if c3 != 3 * c || c % n_head != 0 {
836        return Err(ForgeError::Shape(format!(
837            "split_heads: dim {c3} not divisible into 3 x {n_head} heads"
838        )));
839    }
840    let shape = Shape::new(&[n_head, t, c / n_head]);
841    match qkv.storage() {
842        Storage::Cpu(_) => {
843            let (q, k, v) = cpu::split_heads(cpu_f32(qkv)?, t, c, n_head);
844            Ok((
845                f32_tensor(Storage::Cpu(CpuStorage::F32(q.into())), shape.clone()),
846                f32_tensor(Storage::Cpu(CpuStorage::F32(k.into())), shape.clone()),
847                f32_tensor(Storage::Cpu(CpuStorage::F32(v.into())), shape),
848            ))
849        }
850        Storage::Wgpu(_) => {
851            let (q, k, v) = gpu::ops::split_heads(gpu_storage(qkv)?, t, c, n_head);
852            Ok((
853                f32_tensor(Storage::Wgpu(q), shape.clone()),
854                f32_tensor(Storage::Wgpu(k), shape.clone()),
855                f32_tensor(Storage::Wgpu(v), shape),
856            ))
857        }
858    }
859}
860
861/// x [h, t, hd] -> [t, h*hd].
862pub fn merge_heads(x: &Tensor) -> Result<Tensor> {
863    let (h, t, hd) = match x.shape().dims() {
864        [h, t, hd] => (*h, *t, *hd),
865        _ => return Err(ForgeError::Shape("merge_heads needs [h, t, hd]".into())),
866    };
867    let c = h * hd;
868    let shape = Shape::new(&[t, c]);
869    let storage = match x.storage() {
870        Storage::Cpu(_) => Storage::Cpu(CpuStorage::F32(
871            cpu::merge_heads(cpu_f32(x)?, t, c, h).into(),
872        )),
873        Storage::Wgpu(_) => Storage::Wgpu(gpu::ops::merge_heads(gpu_storage(x)?, t, c, h)),
874    };
875    Ok(f32_tensor(storage, shape))
876}