Skip to main content

tract_core/ops/
gru_seq.rs

1use crate::internal::*;
2use tract_linalg::MmmDispatch;
3use tract_linalg::mmm::{AsInputValue, FusedSpec, MMMInputValue, MatMatMul, Query};
4use tract_linalg::pack::PackedFormat;
5use tract_linalg::routines::Func;
6use tract_ndarray::prelude::*;
7
8/// The recurrent weight `R`, packed once and reused for every timestep of every
9/// later call — see [`GruSeqState::packed_r`]. The `usize` is which of the
10/// kernel's packings `R` was prepared in.
11type PackedR = (Box<dyn MatMatMul>, usize, Box<dyn MMMInputValue>);
12
13/// Whole-sequence GRU: the ONNX GRU with `linear_before_reset != 0`, run as one op
14/// instead of a `Scan` that dispatches its body once per timestep.
15///
16/// Valid exactly where [`crate::ops::gru_cell::GruEpilogue`] is: sigmoid `f`, tanh
17/// `g`, no peepholes, no extra cell state, no `sequence_lens`. `R` must be a
18/// constant -- it is packed once and the packing is cached for the op's life.
19///
20/// The input-side product `X.Wt` does not depend on the recurrent state, so it is
21/// taken once over the whole sequence as a single GEMM rather than once per step.
22///
23/// State: the op works both ways, as the `Scan` it replaces does. By default the
24/// hidden state lives in the session and persists across calls -- `initial_h`
25/// seeds the first call only -- so models keep their current behaviour bit for
26/// bit. With `reset_every_turn` the state is re-seeded from `initial_h` on every
27/// call instead, which is the ONNX contract and what a caller managing its own
28/// state wants. The flag is named after and means the same as `Scan`'s.
29#[derive(Debug, Clone, Hash, PartialEq, Eq)]
30pub struct GruSeq {
31    pub hidden: usize,
32    pub has_bias: bool,
33    /// -1 runs the sequence backwards (the `.back` side of a bidirectional GRU).
34    pub chunk: isize,
35    /// Fill the `Y` output with the whole sequence. False leaves it empty, for a
36    /// caller that only reads `Y_h`.
37    pub emit_y: bool,
38    /// Re-seed the hidden state from `initial_h` on every call instead of
39    /// carrying it in the session. Same meaning as `Scan::reset_every_turn`.
40    pub reset_every_turn: bool,
41}
42
43#[derive(Default)]
44struct GruSeqState {
45    h: Option<Tensor>,
46    /// R packed for the recurrent GEMM, built on the first call and reused for
47    /// every timestep of every later call. Sound only because the wiring requires
48    /// `R` to be a constant.
49    packed_r: Option<PackedR>,
50}
51
52impl std::fmt::Debug for GruSeqState {
53    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
54        f.debug_struct("GruSeqState").field("h", &self.h).finish()
55    }
56}
57
58impl Clone for GruSeqState {
59    fn clone(&self) -> Self {
60        // The packed weight is a cache; a clone rebuilds it on first use.
61        GruSeqState { h: self.h.clone(), packed_r: None }
62    }
63}
64
65impl Op for GruSeq {
66    fn name(&self) -> StaticName {
67        "GruSeq".into()
68    }
69    fn info(&self) -> TractResult<Vec<String>> {
70        Ok(vec![format!(
71            "hidden={} bias={} chunk={} reset_every_turn={} emit_y={}",
72            self.hidden, self.has_bias, self.chunk, self.reset_every_turn, self.emit_y
73        )])
74    }
75    op_as_typed_op!();
76}
77
78impl EvalOp for GruSeq {
79    not_out_of_plan!();
80
81    /// Stateful even with `reset_every_turn`: the state also holds the packed `R`,
82    /// which is a per-run cache rather than part of the recurrence.
83    fn state(&self, _ctx: &EvalContext) -> TractResult<Option<Box<dyn OpState>>> {
84        Ok(Some(Box::<GruSeqState>::default()))
85    }
86}
87
88impl OpState for GruSeqState {
89    fn eval(
90        &mut self,
91        _ctx: &EvalContext,
92        op: &dyn Op,
93        inputs: TVec<TValue>,
94    ) -> TractResult<TVec<TValue>> {
95        let op = op.downcast_ref::<GruSeq>().context("wrong op")?;
96        op.eval_with(&mut self.h, &mut self.packed_r, inputs)
97    }
98
99    /// GruSeq carries a single hidden state, not one per lane, so it cannot
100    /// be split across lanes -- same stance as the `Scan` it replaces.
101    fn reset_lanes(&mut self, _lanes: &[LaneId]) -> TractResult<()> {
102        bail!("GruSeq is not lane-aware: it carries a single hidden state")
103    }
104}
105
106impl GruSeq {
107    fn eval_with(
108        &self,
109        carry: &mut Option<Tensor>,
110        packed_r: &mut Option<PackedR>,
111        inputs: TVec<TValue>,
112    ) -> TractResult<TVec<TValue>> {
113        let (x, w, r) = (&inputs[0], &inputs[1], &inputs[2]);
114        let b = if self.has_bias { Some(&inputs[3]) } else { None };
115        let h0 = &inputs[inputs.len() - 1];
116        let h = self.hidden;
117
118        let x = x.to_plain_array_view::<f32>()?.into_dimensionality::<Ix3>()?; // [batch, T, in]
119        let w = w.to_plain_array_view::<f32>()?.into_dimensionality::<Ix2>()?; // [3h, in]
120        let (batch, t_len, in_size) = x.dim();
121
122        let (wb, rb) = match b {
123            Some(b) => {
124                // B keeps its direction axis: [1, 6*hidden], unlike W and R.
125                let v = b.to_plain_array_view::<f32>()?;
126                let b = match v.ndim() {
127                    2 => v.into_dimensionality::<Ix2>()?.index_axis_move(Axis(0), 0).to_owned(),
128                    _ => v.into_dimensionality::<Ix1>()?.to_owned(),
129                };
130                (Some(b.slice(s![0..3 * h]).to_owned()), Some(b.slice(s![3 * h..6 * h]).to_owned()))
131            }
132            None => (None, None),
133        };
134
135        // One GEMM for the whole sequence, and the W-side bias folded in once here
136        // rather than once per timestep. The step loop reads one timestep's rows at
137        // a time, so the rows are ordered by timestep, not by batch element.
138        let x_permuted = x.permuted_axes([1, 0, 2]);
139        let x_by_step = x_permuted.as_standard_layout();
140        let mut xw = x_by_step.to_shape((t_len * batch, in_size))?.dot(&w.t());
141        if let Some(wb) = &wb {
142            xw += &wb.view().insert_axis(Axis(0));
143        }
144
145        // Pack R once for the whole model's life, then run tract's own MMM per step
146        // -- the same kernel the Scan body dispatches, so the arithmetic matches, but
147        // without re-packing or re-dispatching a graph node each timestep.
148        if packed_r.is_none() {
149            // Computed transposed: R[3h, h] . h_prev[h, batch] -> [3h, batch].
150            // With batch == 1 that is n == 1, which is how tract selects its
151            // matrix-vector kernel -- the side that gets packed is R, once, and the
152            // per-step vector is never packed. No extractor: R is packed here once,
153            // so an extractor would re-run on every panel of every step.
154            let query = Query {
155                allow_extractor: false,
156                ..Query::plain(f32::datum_type(), Some(3 * h), Some(h), Some(batch))
157            };
158            let (mmm, packing, _) = MmmDispatch::native()
159                .pick(&query)
160                .context("no matmul kernel for the recurrent product")?;
161            let (pack_a, _) = &mmm.packings()[packing];
162            let r_t = r.clone().into_tensor();
163            let pa = pack_a.prepare_one(&r_t, 1, 0)?;
164            *packed_r = Some((mmm, packing, pa));
165        }
166        let (mmm, packing, pa) = packed_r.as_ref().unwrap();
167        let (_, pack_b) = &mmm.packings()[*packing];
168
169        // With reset_every_turn the initializer wins every call; otherwise the
170        // session's carry seeds every call but the first.
171        let mut ht: Tensor = match carry.as_ref().filter(|_| !self.reset_every_turn) {
172            Some(c) => squeeze_state(c, batch, h)?,
173            None => squeeze_state(h0, batch, h)?,
174        };
175
176        let sigmoid = Func::Sigmoid.ew_f32()?;
177        let tanh = Func::Tanh.ew_f32()?;
178
179        // Everything the loop needs, allocated once -- including the packed form of
180        // the per-step state. `prepare_one` would allocate a fresh panel buffer on
181        // every timestep; the buffer's size depends only on `h` and `batch`, so it
182        // is built here and refilled in place instead.
183        let pf = pack_b
184            .downcast_ref::<PackedFormat>()
185            .context("recurrent product expects a plainly packed B side")?;
186        let mut packed_ht = pf.new_packed_buffer(h, batch)?;
187        // The recurrent product is stored straight into its [batch, 3*h] layout by
188        // stride, so the step needs no transposing copy out of a [3*h, batch] temp.
189        let mut rh = Tensor::zero::<f32>(&[batch, 3 * h])?;
190        let mut h_next = Tensor::zero::<f32>(&[batch, h])?;
191        let mut y = Array3::<f32>::zeros((batch, if self.emit_y { t_len } else { 0 }, h));
192
193        for step in 0..t_len {
194            let t = if self.chunk < 0 { t_len - 1 - step } else { step };
195
196            // rh = h_prev . R^T (+ R-side bias), written into the same buffer each step.
197            {
198                // ht is [batch, h]; the product wants [h, batch], which the packer
199                // reaches by stride, so the state never needs transposing into a
200                // temporary.
201                pf.repack_tensor_view(&mut packed_ht, &ht.view(), 1, 0)?;
202                let pb = &packed_ht;
203                unsafe {
204                    let c = mmm.c_view(Some(1), Some(0)).wrap(&rh.view_mut());
205                    mmm.run(
206                        3 * h,
207                        batch,
208                        &[
209                            FusedSpec::AddMatMul {
210                                a: AsInputValue::Borrowed(&**pa),
211                                b: AsInputValue::Borrowed(pb),
212                                packing: 0,
213                            },
214                            FusedSpec::Store(c),
215                        ],
216                    )?;
217                }
218                if let Some(rb) = &rb {
219                    let rb = rb.as_slice().context("R-side bias not contiguous")?;
220                    for row in rh.try_as_plain_ram_mut()?.as_slice_mut::<f32>()?.chunks_mut(3 * h) {
221                        for (o, b) in row.iter_mut().zip(rb) {
222                            *o += b;
223                        }
224                    }
225                }
226            }
227
228            let xh_row = &mut xw.as_slice_mut().context("xw not contiguous")?
229                [t * batch * 3 * h..(t + 1) * batch * 3 * h];
230            crate::ops::gru_cell::gru_cell_rows(
231                h,
232                batch,
233                xh_row,
234                rh.try_as_plain_ram()?.as_slice::<f32>()?,
235                ht.try_as_plain_ram()?.as_slice::<f32>()?,
236                h_next.try_as_plain_ram_mut()?.as_slice_mut::<f32>()?,
237                &*sigmoid,
238                &*tanh,
239            )?;
240            std::mem::swap(&mut ht, &mut h_next);
241            if self.emit_y {
242                y.slice_mut(s![.., t, ..])
243                    .assign(&ht.to_plain_array_view::<f32>()?.into_dimensionality::<Ix2>()?);
244            }
245        }
246
247        *carry = if self.reset_every_turn { None } else { Some(ht.clone()) };
248        let mut h_out = ht;
249        h_out.insert_axis(1)?; // back to [batch, 1, hidden]
250        Ok(tvec!(y.into_tensor().into(), h_out.into()))
251    }
252}
253
254/// initial_h and the state slot are chunk-shaped [batch, 1, hidden].
255fn squeeze_state(t: &Tensor, batch: usize, h: usize) -> TractResult<Tensor> {
256    let mut t = t.clone().into_tensor();
257    ensure!(
258        t.len() == batch * h,
259        "GruSeq state holds {} elements, expected batch {batch} x hidden {h}",
260        t.len()
261    );
262    t.set_shape(&[batch, h])?;
263    Ok(t)
264}
265
266impl TypedOp for GruSeq {
267    fn output_facts(&self, inputs: &[&TypedFact]) -> TractResult<TVec<TypedFact>> {
268        let x = inputs[0];
269        let batch = x.shape[0].clone();
270        let t = x.shape[1].clone();
271        let y_len = if self.emit_y { t } else { 0.to_dim() };
272        Ok(tvec!(
273            f32::fact([batch.clone(), y_len, self.hidden.to_dim()]),
274            f32::fact([batch, 1.to_dim(), self.hidden.to_dim()])
275        ))
276    }
277    as_op!();
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283    use crate::ops::gru_cell::GruEpilogue;
284
285    /// The fused op must reproduce the per-timestep recurrence exactly: same gate
286    /// maths, same order, so the only difference from a `Scan` is how often tract
287    /// dispatches. Checked against a plain reference loop here; checked against the
288    /// real `Scan` lowering end to end on GTCRN.
289    fn reference(
290        x: &Array3<f32>,
291        w: &Array2<f32>,
292        r: &Array2<f32>,
293        b: Option<&Array1<f32>>,
294        h0: &Array2<f32>,
295        hidden: usize,
296        backward: bool,
297    ) -> (Array3<f32>, Array2<f32>) {
298        let (batch, t_len, _) = x.dim();
299        let mut ht = h0.clone();
300        let mut y = Array3::<f32>::zeros((batch, t_len, hidden));
301        for step in 0..t_len {
302            let t = if backward { t_len - 1 - step } else { step };
303            let mut xh = x.slice(s![.., t, ..]).to_owned().dot(&w.t());
304            let mut rh = ht.dot(&r.t());
305            if let Some(b) = b {
306                xh += &b.slice(s![0..3 * hidden]).insert_axis(Axis(0));
307                rh += &b.slice(s![3 * hidden..6 * hidden]).insert_axis(Axis(0));
308            }
309            let out = GruEpilogue { hidden }
310                .eval(
311                    &EvalContext::out_of_plan(),
312                    tvec!(
313                        xh.into_tensor().into(),
314                        rh.into_tensor().into(),
315                        ht.clone().into_tensor().into()
316                    ),
317                )
318                .unwrap();
319            ht = out[0]
320                .to_plain_array_view::<f32>()
321                .unwrap()
322                .into_dimensionality::<Ix2>()
323                .unwrap()
324                .to_owned();
325            y.slice_mut(s![.., t, ..]).assign(&ht);
326        }
327        (y, ht)
328    }
329
330    fn run_case(batch: usize, t_len: usize, backward: bool, bias: bool) {
331        let (input, hidden) = (12usize, 16usize);
332        let f = |n: usize, k: f32| Array1::from_iter((0..n).map(|i| ((i as f32) * k).sin() * 0.3));
333        let x = f(batch * t_len * input, 0.7).into_shape_with_order((batch, t_len, input)).unwrap();
334        let w = f(3 * hidden * input, 0.31).into_shape_with_order((3 * hidden, input)).unwrap();
335        let r = f(3 * hidden * hidden, 0.17).into_shape_with_order((3 * hidden, hidden)).unwrap();
336        let b = bias.then(|| f(6 * hidden, 0.11));
337        let h0 = Array2::<f32>::zeros((batch, hidden));
338
339        let (want_y, want_h) = reference(&x, &w, &r, b.as_ref(), &h0, hidden, backward);
340
341        let op = GruSeq {
342            hidden,
343            has_bias: bias,
344            chunk: if backward { -1 } else { 1 },
345            reset_every_turn: false,
346            emit_y: true,
347        };
348        let mut inputs: TVec<TValue> = tvec!(
349            x.clone().into_tensor().into(),
350            w.clone().into_tensor().into(),
351            r.clone().into_tensor().into()
352        );
353        if let Some(b) = &b {
354            inputs.push(b.clone().into_tensor().into());
355        }
356        inputs.push(h0.clone().into_tensor().into());
357        let mut carry = None;
358        let mut packed = None;
359        let got = op.eval_with(&mut carry, &mut packed, inputs).unwrap();
360
361        let got_y = got[0].clone().into_tensor();
362        let got_h = got[1]
363            .to_plain_array_view::<f32>()
364            .unwrap()
365            .into_dimensionality::<Ix3>()
366            .unwrap()
367            .index_axis_move(Axis(1), 0)
368            .to_owned();
369
370        // Tolerance, not equality: the reference GEMMs through `matrixmultiply` and
371        // the op through tract's MMM. Their last bits differ on targets with no SIMD
372        // mmm kernel, and the gap compounds over a 33-step recurrence. Bit-exactness
373        // holds against the `Scan` this replaces, which shares the MMM path, and is
374        // checked e2e.
375        got_y.close_enough(&want_y.into_tensor(), Approximation::Approximate).unwrap_or_else(|e| {
376            panic!("Y mismatch b={batch} t={t_len} backward={backward} bias={bias}: {e}")
377        });
378        got_h
379            .into_tensor()
380            .close_enough(&want_h.into_tensor(), Approximation::Approximate)
381            .unwrap_or_else(|e| {
382                panic!("Y_h mismatch b={batch} t={t_len} backward={backward} bias={bias}: {e}")
383            });
384    }
385
386    #[test]
387    fn matches_the_step_by_step_recurrence() {
388        for &batch in &[1usize, 2, 3] {
389            for &t in &[1usize, 2, 5, 33] {
390                for &backward in &[false, true] {
391                    for &bias in &[false, true] {
392                        run_case(batch, t, backward, bias);
393                    }
394                }
395            }
396        }
397    }
398
399    /// The hidden state persists across calls, as the `Scan` it replaces does.
400    #[test]
401    fn carries_state_across_calls() {
402        let op =
403            GruSeq { hidden: 4, has_bias: false, chunk: 1, reset_every_turn: false, emit_y: true };
404        let x = Array3::<f32>::from_elem((1, 3, 2), 0.5);
405        let w = Array2::<f32>::from_elem((12, 2), 0.1);
406        let r = Array2::<f32>::from_elem((12, 4), 0.1);
407        let h0 = Array2::<f32>::zeros((1, 4));
408        let mk = || -> TVec<TValue> {
409            tvec!(
410                x.clone().into_tensor().into(),
411                w.clone().into_tensor().into(),
412                r.clone().into_tensor().into(),
413                h0.clone().into_tensor().into()
414            )
415        };
416        let mut carry = None;
417        let mut packed = None;
418        let first = op.eval_with(&mut carry, &mut packed, mk()).unwrap();
419        assert!(carry.is_some(), "state must be retained");
420        let second = op.eval_with(&mut carry, &mut packed, mk()).unwrap();
421        assert_ne!(
422            first[1].to_plain_array_view::<f32>().unwrap(),
423            second[1].to_plain_array_view::<f32>().unwrap(),
424            "second call must continue from the carried state, not restart from initial_h"
425        );
426    }
427
428    /// With reset_every_turn the initializer wins every call, so identical inputs
429    /// give identical outputs -- the ONNX contract, and what a caller managing its
430    /// own state across calls needs.
431    #[test]
432    fn reset_every_turn_restarts_from_initial_h() {
433        let op =
434            GruSeq { hidden: 4, has_bias: false, chunk: 1, reset_every_turn: true, emit_y: true };
435        let x = Array3::<f32>::from_elem((1, 3, 2), 0.5);
436        let w = Array2::<f32>::from_elem((12, 2), 0.1);
437        let r = Array2::<f32>::from_elem((12, 4), 0.1);
438        let h0 = Array2::<f32>::zeros((1, 4));
439        let mk = || -> TVec<TValue> {
440            tvec!(
441                x.clone().into_tensor().into(),
442                w.clone().into_tensor().into(),
443                r.clone().into_tensor().into(),
444                h0.clone().into_tensor().into()
445            )
446        };
447        let mut carry = None;
448        let mut packed = None;
449        let first = op.eval_with(&mut carry, &mut packed, mk()).unwrap();
450        assert!(carry.is_none(), "state must not be retained");
451        let second = op.eval_with(&mut carry, &mut packed, mk()).unwrap();
452        for slot in 0..2 {
453            assert_eq!(
454                first[slot].to_plain_array_view::<f32>().unwrap(),
455                second[slot].to_plain_array_view::<f32>().unwrap(),
456                "output {slot} must not drift between identical calls"
457            );
458        }
459    }
460}