Skip to main content

tract_core/ops/
gru_cell.rs

1use crate::internal::*;
2use tract_linalg::element_wise::ElementWise;
3use tract_linalg::routines::Func;
4
5/// Fused GRU cell epilogue.
6///
7/// Given `xh = Xt·Wᵀ + Wb` and `rh = Ht-1·Rᵀ + Rb`, both `[batch, 3*hidden]` in
8/// ONNX gate order z, r, h, and the previous hidden state `h_prev`
9/// `[batch, hidden]`, computes the new hidden state in a single fused pass:
10///
11/// ```text
12/// zt = sigmoid(xh[..h]    + rh[..h])
13/// rt = sigmoid(xh[h..2h]  + rh[h..2h])
14/// ht = tanh   (xh[2h..3h] + rt (.) rh[2h..3h])
15/// Ht = ht + zt (.) (h_prev - ht)
16/// ```
17///
18/// This is the ONNX GRU with `linear_before_reset != 0`, where the reset gate
19/// scales the already-biased recurrent product so the cell needs no second
20/// matmul. With `linear_before_reset == 0` the reset gate applies to `Ht-1`
21/// before that product and no such epilogue exists; the importer keeps the
22/// decomposed form there, as it does for non-standard activations (`f` must be
23/// sigmoid, `g` must be tanh) and for a symbolic hidden size.
24///
25/// Activations use tract's vectorised `sigmoid`/`tanh` linalg kernels over
26/// contiguous gate slices, so they match the decomposed path; the gate sums are
27/// associated differently, so results differ by rounding. Runs in `f32` or
28/// `f16`, matching the dtype the precision transform settled the graph on.
29#[derive(Debug, Clone, Hash, PartialEq, Eq)]
30pub struct GruEpilogue {
31    pub hidden: usize,
32}
33
34impl Op for GruEpilogue {
35    fn name(&self) -> StaticName {
36        "GruEpilogue".into()
37    }
38
39    fn info(&self) -> TractResult<Vec<String>> {
40        Ok(vec![format!("hidden={}", self.hidden)])
41    }
42
43    op_as_typed_op!();
44}
45
46impl EvalOp for GruEpilogue {
47    op_out_of_plan!();
48
49    fn eval(&self, _ctx: &EvalContext, inputs: TVec<TValue>) -> TractResult<TVec<TValue>> {
50        match inputs[0].datum_type().unquantized() {
51            DatumType::F32 => {
52                self.eval_t::<f32>(inputs, Func::Sigmoid.ew_f32()?, Func::Tanh.ew_f32()?)
53            }
54            DatumType::F16 => {
55                self.eval_t::<f16>(inputs, Func::Sigmoid.ew_f16()?, Func::Tanh.ew_f16()?)
56            }
57            dt => bail!("GruEpilogue only supports f32 and f16 preactivations, got {dt:?}"),
58        }
59    }
60}
61
62/// The GRU cell's row loop, over plain slices.
63///
64/// Exposed so a whole-sequence op can run the same arithmetic per timestep without
65/// paying a tensor box per gate: `xh` is consumed in place as the accumulator, and
66/// `h_out` receives the new hidden state. Keeping one implementation is what makes
67/// the fused form bit-identical to the `Scan` it replaces.
68#[allow(clippy::too_many_arguments)]
69pub fn gru_cell_rows<T>(
70    h: usize,
71    rows: usize,
72    xh: &mut [T],
73    rh: &[T],
74    h_prev: &[T],
75    h_out: &mut [T],
76    sigmoid: &dyn ElementWise<T>,
77    tanh: &dyn ElementWise<T>,
78) -> TractResult<()>
79where
80    T: Datum
81        + Copy
82        + std::ops::Mul<Output = T>
83        + std::ops::Add<Output = T>
84        + std::ops::Sub<Output = T>,
85{
86    for row in 0..rows {
87        let gb = row * 3 * h;
88        let hb = row * h;
89        let g = &mut xh[gb..gb + 3 * h];
90        let r = &rh[gb..gb + 3 * h];
91        for j in 0..2 * h {
92            g[j] = g[j] + r[j];
93        }
94        sigmoid.run(&mut g[0..2 * h])?;
95        for j in 0..h {
96            g[2 * h + j] = g[2 * h + j] + g[h + j] * r[2 * h + j];
97        }
98        tanh.run(&mut g[2 * h..3 * h])?;
99        for j in 0..h {
100            let cand = g[2 * h + j];
101            h_out[hb + j] = cand + g[j] * (h_prev[hb + j] - cand);
102        }
103    }
104    Ok(())
105}
106
107impl GruEpilogue {
108    fn eval_t<T>(
109        &self,
110        inputs: TVec<TValue>,
111        sigmoid: Box<dyn ElementWise<T>>,
112        tanh: Box<dyn ElementWise<T>>,
113    ) -> TractResult<TVec<TValue>>
114    where
115        T: Datum
116            + Copy
117            + std::ops::Mul<Output = T>
118            + std::ops::Add<Output = T>
119            + std::ops::Sub<Output = T>,
120    {
121        let h = self.hidden;
122        let h_prev = &inputs[2];
123        let hp = unsafe { h_prev.as_slice_unchecked::<T>() };
124        // Rows come from the state, which also sizes the output: a gate operand
125        // left broadcast on the batch axis would otherwise under-fill it.
126        let rows = hp.len() / h;
127        ensure!(
128            inputs[0].len() == rows * 3 * h && inputs[1].len() == rows * 3 * h,
129            "GruEpilogue expects xh and rh shaped [{rows}, 3*{h}], got {:?} and {:?}",
130            inputs[0].shape(),
131            inputs[1].shape()
132        );
133        let rh = unsafe { inputs[1].as_slice_unchecked::<T>() };
134        let mut acc_t = inputs[0].clone().into_tensor();
135        let acc = unsafe { acc_t.as_slice_mut_unchecked::<T>() };
136        let mut ht = unsafe { Tensor::uninitialized_dt(T::datum_type(), h_prev.shape())? };
137        {
138            let hs = unsafe { ht.as_slice_mut_unchecked::<T>() };
139            gru_cell_rows(h, rows, acc, rh, hp, hs, &*sigmoid, &*tanh)?;
140        }
141        Ok(tvec!(ht.into_tvalue()))
142    }
143}
144
145impl TypedOp for GruEpilogue {
146    as_op!();
147
148    fn output_facts(&self, inputs: &[&TypedFact]) -> TractResult<TVec<TypedFact>> {
149        ensure!(inputs.len() == 3, "GruEpilogue expects [xh, rh, h_prev]");
150        ensure!(
151            inputs[0].datum_type == inputs[2].datum_type,
152            "GruEpilogue gate and state datum types differ: {:?} and {:?}",
153            inputs[0].datum_type,
154            inputs[2].datum_type
155        );
156        let h_prev = inputs[2];
157        Ok(tvec!(h_prev.datum_type.fact(h_prev.shape.clone())))
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    // Fused epilogue must match a scalar reference GRU cell with
166    // linear_before_reset = 1. The reference keeps the ONNX form of the output
167    // update, (1-zt)*ht + zt*Ht-1, so the rearranged form is checked too.
168    // Tolerance covers the rational sigmoid/tanh approximation vs the exact
169    // reference. Multi-row exercises batch.
170    #[test]
171    fn epilogue_matches_scalar_reference() {
172        let h = 4usize;
173        let batch = 3usize;
174        let xh: Vec<f32> =
175            (0..batch * 3 * h).map(|i| ((i * 7 % 29) as f32 - 14.0) * 0.25).collect();
176        let rh: Vec<f32> =
177            (0..batch * 3 * h).map(|i| ((i * 11 % 23) as f32 - 11.0) * 0.3).collect();
178        let hprev: Vec<f32> = (0..batch * h).map(|i| ((i * 5 % 17) as f32 - 8.0) * 0.2).collect();
179        let xh_t = Tensor::from_shape(&[batch, 3 * h], &xh).unwrap();
180        let rh_t = Tensor::from_shape(&[batch, 3 * h], &rh).unwrap();
181        let hprev_t = Tensor::from_shape(&[batch, h], &hprev).unwrap();
182        let op = GruEpilogue { hidden: h };
183        let out = op
184            .eval(
185                &EvalContext::out_of_plan(),
186                tvec!(xh_t.into_tvalue(), rh_t.into_tvalue(), hprev_t.into_tvalue()),
187            )
188            .unwrap();
189        let got = unsafe { out[0].as_slice_unchecked::<f32>() };
190
191        let sig = |x: f32| 1.0 / (1.0 + (-x).exp());
192        for r in 0..batch {
193            for j in 0..h {
194                let p = r * 3 * h; // gate order on the 3*h axis: z, r, h
195                let zt = sig(xh[p + j] + rh[p + j]);
196                let rt = sig(xh[p + h + j] + rh[p + h + j]);
197                let ht = (xh[p + 2 * h + j] + rt * rh[p + 2 * h + j]).tanh();
198                let h_ref = (1.0 - zt) * ht + zt * hprev[r * h + j];
199                assert!((got[r * h + j] - h_ref).abs() < 1e-3, "Ht mismatch at ({r},{j})");
200            }
201        }
202    }
203}