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/// call. The `usize` is which of the kernel's packings `R` was prepared in.
10type PackedR = (Box<dyn MatMatMul>, usize, Box<dyn MMMInputValue>);
11
12/// Up to this hidden size the input-side product stays in the op: the graph
13/// nodes projecting it out cost more to dispatch than the platform kernel saves.
14const MAX_UNPROJECTED_HIDDEN: usize = 64;
15
16/// Whole-sequence GRU: the ONNX GRU with `linear_before_reset != 0`, run as one op
17/// instead of a `Scan` that dispatches its body once per timestep.
18///
19/// Valid exactly where [`crate::ops::gru_cell::GruEpilogue`] is: sigmoid `f`, tanh
20/// `g`, no peepholes, no extra cell state, no `sequence_lens`. `R` must be a
21/// constant. Codegen packs it into the op when the batch size is known, so every
22/// state spawned from the plan shares that packing; otherwise each state packs it
23/// on its first call.
24///
25/// The input-side product `X.Wt` does not depend on the recurrent state, so it is
26/// taken once over the whole sequence rather than once per step. Above a hidden size
27/// of 64, declutter moves it out of the op into the graph -- an `EinSum` plus the
28/// `W`-side bias -- so it gets
29/// the platform's matmul kernel with `W` packed once, and marks the op `projected`:
30/// input 0 is then `X.Wt + Wb`, `[batch, T, 3*hidden]`, and there is no `W` input.
31///
32/// State: the op works both ways, as the `Scan` it replaces does. By default the
33/// hidden state lives in the session and persists across calls -- `initial_h`
34/// seeds the first call only -- so models keep their current behaviour bit for
35/// bit. With `reset_every_turn` the state is re-seeded from `initial_h` on every
36/// call instead, which is the ONNX contract and what a caller managing its own
37/// state wants. The flag is named after and means the same as `Scan`'s.
38#[derive(Debug, Clone, Hash, PartialEq, Eq)]
39pub struct GruSeq {
40    pub hidden: usize,
41    pub has_bias: bool,
42    /// -1 runs the sequence backwards (the `.back` side of a bidirectional GRU).
43    pub chunk: isize,
44    /// Fill the `Y` output with the whole sequence. False leaves it empty, for a
45    /// caller that only reads `Y_h`.
46    pub emit_y: bool,
47    /// Re-seed the hidden state from `initial_h` on every call instead of
48    /// carrying it in the session. Same meaning as `Scan::reset_every_turn`.
49    pub reset_every_turn: bool,
50    /// Input 0 is the input-side product `X.Wt + Wb` rather than `X`, and `W` is not
51    /// an input. Set by declutter; see the type's docs.
52    pub projected: bool,
53    /// `R` packed by codegen. `None` until then, or when the batch size is symbolic.
54    pub packed_r: Option<PackedR>,
55}
56
57#[derive(Default)]
58struct GruSeqState {
59    h: Option<Tensor>,
60    /// R packed on the first call, for an op codegen did not pack it into. Sound
61    /// only because the wiring requires `R` to be a constant.
62    packed_r: Option<PackedR>,
63}
64
65impl std::fmt::Debug for GruSeqState {
66    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
67        f.debug_struct("GruSeqState").field("h", &self.h).finish()
68    }
69}
70
71impl Clone for GruSeqState {
72    fn clone(&self) -> Self {
73        // The packed weight is a cache; a clone rebuilds it on first use.
74        GruSeqState { h: self.h.clone(), packed_r: None }
75    }
76}
77
78impl Op for GruSeq {
79    fn name(&self) -> StaticName {
80        "GruSeq".into()
81    }
82    fn info(&self) -> TractResult<Vec<String>> {
83        Ok(vec![format!(
84            "hidden={} bias={} chunk={} reset_every_turn={} emit_y={} projected={}",
85            self.hidden,
86            self.has_bias,
87            self.chunk,
88            self.reset_every_turn,
89            self.emit_y,
90            self.projected
91        )])
92    }
93    op_as_typed_op!();
94}
95
96impl EvalOp for GruSeq {
97    not_out_of_plan!();
98
99    /// Stateful even with `reset_every_turn`: the state also holds the packed `R`,
100    /// which is a per-run cache rather than part of the recurrence.
101    fn state(&self, _ctx: &EvalContext) -> TractResult<Option<Box<dyn OpState>>> {
102        Ok(Some(Box::<GruSeqState>::default()))
103    }
104}
105
106impl OpState for GruSeqState {
107    fn eval(
108        &mut self,
109        _ctx: &EvalContext,
110        op: &dyn Op,
111        inputs: TVec<TValue>,
112    ) -> TractResult<TVec<TValue>> {
113        let op = op.downcast_ref::<GruSeq>().context("wrong op")?;
114        op.eval_with(&mut self.h, &mut self.packed_r, inputs)
115    }
116
117    /// GruSeq carries a single hidden state, not one per lane, so it cannot
118    /// be split across lanes -- same stance as the `Scan` it replaces.
119    fn reset_lanes(&mut self, _lanes: &[LaneId]) -> TractResult<()> {
120        bail!("GruSeq is not lane-aware: it carries a single hidden state")
121    }
122}
123
124impl GruSeq {
125    fn eval_with(
126        &self,
127        carry: &mut Option<Tensor>,
128        packed_r: &mut Option<PackedR>,
129        inputs: TVec<TValue>,
130    ) -> TractResult<TVec<TValue>> {
131        let r_slot = if self.projected { 1 } else { 2 };
132        let r = &inputs[r_slot];
133        let b = if self.has_bias { Some(&inputs[r_slot + 1]) } else { None };
134        let h0 = &inputs[inputs.len() - 1];
135        let h = self.hidden;
136
137        // [batch, T, in], or [batch, T, 3*hidden] once projected.
138        let x = inputs[0].to_plain_array_view::<f32>()?.into_dimensionality::<Ix3>()?;
139        let (batch, t_len, in_size) = x.dim();
140
141        let (wb, rb) = match b {
142            Some(b) => {
143                // B keeps its direction axis: [1, 6*hidden], unlike W and R.
144                let v = b.to_plain_array_view::<f32>()?;
145                let b = match v.ndim() {
146                    2 => v.into_dimensionality::<Ix2>()?.index_axis_move(Axis(0), 0).to_owned(),
147                    _ => v.into_dimensionality::<Ix1>()?.to_owned(),
148                };
149                (Some(b.slice(s![0..3 * h]).to_owned()), Some(b.slice(s![3 * h..6 * h]).to_owned()))
150            }
151            None => (None, None),
152        };
153
154        // The step loop reads one timestep's rows at a time, so the rows are ordered
155        // by timestep, not by batch element.
156        let x_permuted = x.permuted_axes([1, 0, 2]);
157        let x_by_step = x_permuted.as_standard_layout();
158        let x_by_step = x_by_step.to_shape((t_len * batch, in_size))?;
159        let mut xw = if self.projected {
160            x_by_step.to_owned()
161        } else {
162            // One GEMM for the whole sequence, the W-side bias folded in once here.
163            let w = inputs[1].to_plain_array_view::<f32>()?.into_dimensionality::<Ix2>()?;
164            let mut xw = x_by_step.dot(&w.t());
165            if let Some(wb) = &wb {
166                xw += &wb.view().insert_axis(Axis(0));
167            }
168            xw
169        };
170
171        // Pack R once for the whole model's life, then run tract's own MMM per step
172        // -- the same kernel the Scan body dispatches, so the arithmetic matches, but
173        // without re-packing or re-dispatching a graph node each timestep.
174        if self.packed_r.is_none() && packed_r.is_none() {
175            *packed_r = Some(pack_r(&r.clone().into_tensor(), h, batch)?);
176        }
177        let (mmm, packing, pa) = self.packed_r.as_ref().or(packed_r.as_ref()).unwrap();
178        let (_, pack_b) = &mmm.packings()[*packing];
179
180        // With reset_every_turn the initializer wins every call; otherwise the
181        // session's carry seeds every call but the first.
182        let mut ht: Tensor = match carry.as_ref().filter(|_| !self.reset_every_turn) {
183            Some(c) => squeeze_state(c, batch, h)?,
184            None => squeeze_state(h0, batch, h)?,
185        };
186
187        let sigmoid = Func::Sigmoid.ew_f32()?;
188        let tanh = Func::Tanh.ew_f32()?;
189
190        // Everything the loop needs, allocated once -- including the packed form of
191        // the per-step state. `prepare_one` would allocate a fresh panel buffer on
192        // every timestep; the buffer's size depends only on `h` and `batch`, so it
193        // is built here and refilled in place instead.
194        let pf = pack_b
195            .downcast_ref::<PackedFormat>()
196            .context("recurrent product expects a plainly packed B side")?;
197        let mut packed_ht = pf.new_packed_buffer(h, batch)?;
198        // The recurrent product is stored straight into its [batch, 3*h] layout by
199        // stride, so the step needs no transposing copy out of a [3*h, batch] temp.
200        let mut rh = Tensor::zero::<f32>(&[batch, 3 * h])?;
201        let mut h_next = Tensor::zero::<f32>(&[batch, h])?;
202        let mut y = Array3::<f32>::zeros((batch, if self.emit_y { t_len } else { 0 }, h));
203
204        for step in 0..t_len {
205            let t = if self.chunk < 0 { t_len - 1 - step } else { step };
206
207            // rh = h_prev . R^T (+ R-side bias), written into the same buffer each step.
208            {
209                // ht is [batch, h]; the product wants [h, batch], which the packer
210                // reaches by stride, so the state never needs transposing into a
211                // temporary.
212                pf.repack_tensor_view(&mut packed_ht, &ht.view(), 1, 0)?;
213                let pb = &packed_ht;
214                unsafe {
215                    let c = mmm.c_view(Some(1), Some(0)).wrap(&rh.view_mut());
216                    mmm.run(
217                        3 * h,
218                        batch,
219                        &[
220                            FusedSpec::AddMatMul {
221                                a: AsInputValue::Borrowed(&**pa),
222                                b: AsInputValue::Borrowed(pb),
223                                packing: 0,
224                            },
225                            FusedSpec::Store(c),
226                        ],
227                    )?;
228                }
229                if let Some(rb) = &rb {
230                    let rb = rb.as_slice().context("R-side bias not contiguous")?;
231                    for row in rh.try_as_plain_ram_mut()?.as_slice_mut::<f32>()?.chunks_mut(3 * h) {
232                        for (o, b) in row.iter_mut().zip(rb) {
233                            *o += b;
234                        }
235                    }
236                }
237            }
238
239            let xh_row = &mut xw.as_slice_mut().context("xw not contiguous")?
240                [t * batch * 3 * h..(t + 1) * batch * 3 * h];
241            crate::ops::gru_cell::gru_cell_rows(
242                h,
243                batch,
244                xh_row,
245                rh.try_as_plain_ram()?.as_slice::<f32>()?,
246                ht.try_as_plain_ram()?.as_slice::<f32>()?,
247                h_next.try_as_plain_ram_mut()?.as_slice_mut::<f32>()?,
248                &*sigmoid,
249                &*tanh,
250            )?;
251            std::mem::swap(&mut ht, &mut h_next);
252            if self.emit_y {
253                y.slice_mut(s![.., t, ..])
254                    .assign(&ht.to_plain_array_view::<f32>()?.into_dimensionality::<Ix2>()?);
255            }
256        }
257
258        *carry = if self.reset_every_turn { None } else { Some(ht.clone()) };
259        let mut h_out = ht;
260        h_out.insert_axis(1)?; // back to [batch, 1, hidden]
261        Ok(tvec!(y.into_tensor().into(), h_out.into()))
262    }
263}
264
265/// Packs `R` for the recurrent product, computed transposed: R[3h, h] . h_prev[h, batch]
266/// -> [3h, batch]. With batch == 1 that is n == 1, which is how tract selects its
267/// matrix-vector kernel -- the side that gets packed is R, and the per-step vector is
268/// never packed. No extractor: R is packed once, so an extractor would re-run on every
269/// panel of every step.
270fn pack_r(r: &Tensor, h: usize, batch: usize) -> TractResult<PackedR> {
271    let query = Query {
272        allow_extractor: false,
273        ..Query::plain(f32::datum_type(), Some(3 * h), Some(h), Some(batch))
274    };
275    let (mmm, packing, _) =
276        MmmDispatch::native().pick(&query).context("no matmul kernel for the recurrent product")?;
277    let (pack_a, _) = &mmm.packings()[packing];
278    let pa = pack_a.prepare_one(r, 1, 0)?;
279    Ok((mmm, packing, pa))
280}
281
282/// initial_h and the state slot are chunk-shaped [batch, 1, hidden].
283fn squeeze_state(t: &Tensor, batch: usize, h: usize) -> TractResult<Tensor> {
284    let mut t = t.clone().into_tensor();
285    ensure!(
286        t.len() == batch * h,
287        "GruSeq state holds {} elements, expected batch {batch} x hidden {h}",
288        t.len()
289    );
290    t.set_shape(&[batch, h])?;
291    Ok(t)
292}
293
294impl TypedOp for GruSeq {
295    /// Moves the input-side product into the graph above a hidden size of 64, so it
296    /// runs on the platform's matmul kernel with `W` packed once rather than as a
297    /// generic GEMM per call.
298    fn declutter(
299        &self,
300        model: &TypedModel,
301        node: &TypedNode,
302    ) -> TractResult<Option<TypedModelPatch>> {
303        if self.projected || self.hidden <= MAX_UNPROJECTED_HIDDEN {
304            return Ok(None);
305        }
306        let h = self.hidden;
307        let mut patch = TypedModelPatch::default();
308        let taps = patch.taps(model, &node.inputs)?;
309        let name = &node.name;
310        let mut xw = patch.wire_node(
311            format!("{name}.xw"),
312            crate::ops::einsum::EinSum {
313                axes: "btk,gk->btg".parse()?,
314                operating_dt: f32::datum_type(),
315                q_params: None,
316            },
317            &[taps[0], taps[1]],
318        )?[0];
319        if self.has_bias {
320            let b_rank = patch.outlet_fact(taps[3])?.rank();
321            let mut wb = patch.wire_node(
322                format!("{name}.wb"),
323                crate::ops::array::Slice::new(b_rank - 1, 0, 3 * h),
324                &[taps[3]],
325            )?[0];
326            for axis in 0..(3 - b_rank) {
327                wb =
328                    patch.wire_node(format!("{name}.wb.add_axis{axis}"), AxisOp::Add(0), &[wb])?[0];
329            }
330            xw = patch.wire_node(format!("{name}.xw_b"), crate::ops::math::add(), &[xw, wb])?[0];
331        }
332        let mut inputs = tvec!(xw);
333        inputs.extend(taps[2..].iter().copied());
334        let op = GruSeq { projected: true, ..self.clone() };
335        let outputs = patch.wire_node(name, op, &inputs)?;
336        for (ix, o) in outputs.iter().enumerate() {
337            patch.shunt_outside(model, OutletId::new(node.id, ix), *o)?;
338        }
339        Ok(Some(patch))
340    }
341
342    fn output_facts(&self, inputs: &[&TypedFact]) -> TractResult<TVec<TypedFact>> {
343        let x = inputs[0];
344        let batch = x.shape[0].clone();
345        let t = x.shape[1].clone();
346        let y_len = if self.emit_y { t } else { 0.to_dim() };
347        Ok(tvec!(
348            f32::fact([batch.clone(), y_len, self.hidden.to_dim()]),
349            f32::fact([batch, 1.to_dim(), self.hidden.to_dim()])
350        ))
351    }
352    /// Packs a constant `R` into the op, so states spawned from the plan do not each
353    /// pack it on their first call. Needs a concrete batch size: the kernel is picked
354    /// for it.
355    fn codegen(
356        &self,
357        model: &TypedModel,
358        node: &TypedNode,
359    ) -> TractResult<Option<TypedModelPatch>> {
360        if self.packed_r.is_some() {
361            return Ok(None);
362        }
363        let r_slot = if self.projected { 1 } else { 2 };
364        let Some(r) = model.outlet_fact(node.inputs[r_slot])?.konst.clone() else {
365            return Ok(None);
366        };
367        let Ok(batch) = model.outlet_fact(node.inputs[0])?.shape[0].to_usize() else {
368            return Ok(None);
369        };
370        let op = GruSeq { packed_r: Some(pack_r(&r, self.hidden, batch)?), ..self.clone() };
371        Ok(Some(TypedModelPatch::replace_single_op(model, node, &node.inputs, op)?))
372    }
373
374    as_op!();
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380    use crate::ops::gru_cell::GruEpilogue;
381
382    /// The fused op must reproduce the per-timestep recurrence exactly: same gate
383    /// maths, same order, so the only difference from a `Scan` is how often tract
384    /// dispatches. Checked against a plain reference loop here; checked against the
385    /// real `Scan` lowering end to end on GTCRN.
386    fn reference(
387        x: &Array3<f32>,
388        w: &Array2<f32>,
389        r: &Array2<f32>,
390        b: Option<&Array1<f32>>,
391        h0: &Array2<f32>,
392        hidden: usize,
393        backward: bool,
394    ) -> (Array3<f32>, Array2<f32>) {
395        let (batch, t_len, _) = x.dim();
396        let mut ht = h0.clone();
397        let mut y = Array3::<f32>::zeros((batch, t_len, hidden));
398        for step in 0..t_len {
399            let t = if backward { t_len - 1 - step } else { step };
400            let mut xh = x.slice(s![.., t, ..]).to_owned().dot(&w.t());
401            let mut rh = ht.dot(&r.t());
402            if let Some(b) = b {
403                xh += &b.slice(s![0..3 * hidden]).insert_axis(Axis(0));
404                rh += &b.slice(s![3 * hidden..6 * hidden]).insert_axis(Axis(0));
405            }
406            let out = GruEpilogue { hidden }
407                .eval(
408                    &EvalContext::out_of_plan(),
409                    tvec!(
410                        xh.into_tensor().into(),
411                        rh.into_tensor().into(),
412                        ht.clone().into_tensor().into()
413                    ),
414                )
415                .unwrap();
416            ht = out[0]
417                .to_plain_array_view::<f32>()
418                .unwrap()
419                .into_dimensionality::<Ix2>()
420                .unwrap()
421                .to_owned();
422            y.slice_mut(s![.., t, ..]).assign(&ht);
423        }
424        (y, ht)
425    }
426
427    fn run_case(batch: usize, t_len: usize, backward: bool, bias: bool) {
428        let (input, hidden) = (12usize, 16usize);
429        let f = |n: usize, k: f32| Array1::from_iter((0..n).map(|i| ((i as f32) * k).sin() * 0.3));
430        let x = f(batch * t_len * input, 0.7).into_shape_with_order((batch, t_len, input)).unwrap();
431        let w = f(3 * hidden * input, 0.31).into_shape_with_order((3 * hidden, input)).unwrap();
432        let r = f(3 * hidden * hidden, 0.17).into_shape_with_order((3 * hidden, hidden)).unwrap();
433        let b = bias.then(|| f(6 * hidden, 0.11));
434        let h0 = Array2::<f32>::zeros((batch, hidden));
435
436        let (want_y, want_h) = reference(&x, &w, &r, b.as_ref(), &h0, hidden, backward);
437
438        let op = GruSeq {
439            hidden,
440            has_bias: bias,
441            chunk: if backward { -1 } else { 1 },
442            reset_every_turn: false,
443            emit_y: true,
444            projected: false,
445            packed_r: None,
446        };
447        let mut inputs: TVec<TValue> = tvec!(
448            x.clone().into_tensor().into(),
449            w.clone().into_tensor().into(),
450            r.clone().into_tensor().into()
451        );
452        if let Some(b) = &b {
453            inputs.push(b.clone().into_tensor().into());
454        }
455        inputs.push(h0.clone().into_tensor().into());
456        let mut carry = None;
457        let mut packed = None;
458        let got = op.eval_with(&mut carry, &mut packed, inputs).unwrap();
459
460        let got_y = got[0].clone().into_tensor();
461        let got_h = got[1]
462            .to_plain_array_view::<f32>()
463            .unwrap()
464            .into_dimensionality::<Ix3>()
465            .unwrap()
466            .index_axis_move(Axis(1), 0)
467            .to_owned();
468
469        // Tolerance, not equality: the reference GEMMs through `matrixmultiply` and
470        // the op through tract's MMM. Their last bits differ on targets with no SIMD
471        // mmm kernel, and the gap compounds over a 33-step recurrence. Bit-exactness
472        // holds against the `Scan` this replaces, which shares the MMM path, and is
473        // checked e2e.
474        got_y.close_enough(&want_y.into_tensor(), Approximation::Approximate).unwrap_or_else(|e| {
475            panic!("Y mismatch b={batch} t={t_len} backward={backward} bias={bias}: {e}")
476        });
477        got_h
478            .into_tensor()
479            .close_enough(&want_h.into_tensor(), Approximation::Approximate)
480            .unwrap_or_else(|e| {
481                panic!("Y_h mismatch b={batch} t={t_len} backward={backward} bias={bias}: {e}")
482            });
483    }
484
485    #[test]
486    fn matches_the_step_by_step_recurrence() {
487        for &batch in &[1usize, 2, 3] {
488            for &t in &[1usize, 2, 5, 33] {
489                for &backward in &[false, true] {
490                    for &bias in &[false, true] {
491                        run_case(batch, t, backward, bias);
492                    }
493                }
494            }
495        }
496    }
497
498    /// The hidden state persists across calls, as the `Scan` it replaces does.
499    #[test]
500    fn carries_state_across_calls() {
501        let op = GruSeq {
502            hidden: 4,
503            has_bias: false,
504            chunk: 1,
505            reset_every_turn: false,
506            emit_y: true,
507            projected: false,
508            packed_r: None,
509        };
510        let x = Array3::<f32>::from_elem((1, 3, 2), 0.5);
511        let w = Array2::<f32>::from_elem((12, 2), 0.1);
512        let r = Array2::<f32>::from_elem((12, 4), 0.1);
513        let h0 = Array2::<f32>::zeros((1, 4));
514        let mk = || -> TVec<TValue> {
515            tvec!(
516                x.clone().into_tensor().into(),
517                w.clone().into_tensor().into(),
518                r.clone().into_tensor().into(),
519                h0.clone().into_tensor().into()
520            )
521        };
522        let mut carry = None;
523        let mut packed = None;
524        let first = op.eval_with(&mut carry, &mut packed, mk()).unwrap();
525        assert!(carry.is_some(), "state must be retained");
526        let second = op.eval_with(&mut carry, &mut packed, mk()).unwrap();
527        assert_ne!(
528            first[1].to_plain_array_view::<f32>().unwrap(),
529            second[1].to_plain_array_view::<f32>().unwrap(),
530            "second call must continue from the carried state, not restart from initial_h"
531        );
532    }
533
534    /// With reset_every_turn the initializer wins every call, so identical inputs
535    /// give identical outputs -- the ONNX contract, and what a caller managing its
536    /// own state across calls needs.
537    #[test]
538    fn reset_every_turn_restarts_from_initial_h() {
539        let op = GruSeq {
540            hidden: 4,
541            has_bias: false,
542            chunk: 1,
543            reset_every_turn: true,
544            emit_y: true,
545            projected: false,
546            packed_r: None,
547        };
548        let x = Array3::<f32>::from_elem((1, 3, 2), 0.5);
549        let w = Array2::<f32>::from_elem((12, 2), 0.1);
550        let r = Array2::<f32>::from_elem((12, 4), 0.1);
551        let h0 = Array2::<f32>::zeros((1, 4));
552        let mk = || -> TVec<TValue> {
553            tvec!(
554                x.clone().into_tensor().into(),
555                w.clone().into_tensor().into(),
556                r.clone().into_tensor().into(),
557                h0.clone().into_tensor().into()
558            )
559        };
560        let mut carry = None;
561        let mut packed = None;
562        let first = op.eval_with(&mut carry, &mut packed, mk()).unwrap();
563        assert!(carry.is_none(), "state must not be retained");
564        let second = op.eval_with(&mut carry, &mut packed, mk()).unwrap();
565        for slot in 0..2 {
566            assert_eq!(
567                first[slot].to_plain_array_view::<f32>().unwrap(),
568                second[slot].to_plain_array_view::<f32>().unwrap(),
569                "output {slot} must not drift between identical calls"
570            );
571        }
572    }
573
574    /// One `GruSeq` over a batch of two sequences, or a symbolic one, optimized, with an
575    /// input of two sequences and what the unoptimized op computes for it.
576    fn optimized_gru(symbolic_batch: bool) -> (TypedModel, TValue, TVec<TValue>) {
577        let (input, hidden, t_len) = (12usize, 16usize, 7usize);
578        let f = |n: usize, k: f32| Array1::from_iter((0..n).map(|i| ((i as f32) * k).sin() * 0.3));
579        let x = f(2 * t_len * input, 0.7).into_shape_with_order((2, t_len, input)).unwrap();
580        let w = f(3 * hidden * input, 0.31).into_shape_with_order((3 * hidden, input)).unwrap();
581        let r = f(3 * hidden * hidden, 0.17).into_shape_with_order((3 * hidden, hidden)).unwrap();
582        let h0 = Array3::<f32>::zeros((2, 1, hidden));
583        let op = GruSeq {
584            hidden,
585            has_bias: false,
586            chunk: 1,
587            reset_every_turn: true,
588            emit_y: true,
589            projected: false,
590            packed_r: None,
591        };
592        let x: TValue = x.into_tensor().into();
593        let want = op
594            .eval_with(
595                &mut None,
596                &mut None,
597                tvec!(
598                    x.clone(),
599                    w.clone().into_tensor().into(),
600                    r.clone().into_tensor().into(),
601                    h0.clone().into_tensor().into()
602                ),
603            )
604            .unwrap();
605
606        let mut model = TypedModel::default();
607        let batch = if symbolic_batch { model.symbols.sym("B").to_dim() } else { 2.to_dim() };
608        let source =
609            model.add_source("x", f32::fact(&[batch, t_len.to_dim(), input.to_dim()])).unwrap();
610        let w_c = model.add_const("w", w.into_tensor()).unwrap();
611        let r_c = model.add_const("r", r.into_tensor()).unwrap();
612        let h0_c = model.add_const("h0", h0.into_tensor()).unwrap();
613        let outputs = model.wire_node("gru", op, &[source, w_c, r_c, h0_c]).unwrap();
614        model.select_output_outlets(&outputs).unwrap();
615        (model.into_optimized().unwrap(), x, want)
616    }
617
618    fn packed_in_op(model: &TypedModel) -> bool {
619        model.nodes.iter().find_map(|n| n.op_as::<GruSeq>()).unwrap().packed_r.is_some()
620    }
621
622    /// Codegen packs a constant `R` into the op, and the result matches the op packing
623    /// it on its first call, bit for bit: both pick the same kernel for the same shape.
624    #[test]
625    fn codegen_packs_r_into_the_op() {
626        let (model, x, want) = optimized_gru(false);
627        assert!(packed_in_op(&model), "codegen left R unpacked");
628        let got = model.into_runnable().unwrap().run(tvec!(x)).unwrap();
629        assert_eq!(got, want);
630    }
631
632    /// With a symbolic batch codegen cannot pick the kernel, so it leaves `R` to the
633    /// state, which packs it on its first call and reuses it on the next.
634    #[test]
635    fn symbolic_batch_packs_on_first_call() {
636        let (model, x, want) = optimized_gru(true);
637        assert!(!packed_in_op(&model), "codegen packed R for a symbolic batch");
638        let plan = model.into_runnable().unwrap();
639        let mut state = plan.spawn().unwrap();
640        for _ in 0..2 {
641            assert_eq!(state.run(tvec!(x.clone())).unwrap(), want);
642        }
643    }
644
645    /// States spawned from one plan share the packed `R`; running them on several
646    /// threads at once gives the single-state result every time.
647    #[test]
648    #[cfg(not(target_arch = "wasm32"))]
649    fn packed_r_is_shared_by_concurrent_states() {
650        let (model, x, want) = optimized_gru(false);
651        assert!(packed_in_op(&model));
652        let plan = model.into_runnable().unwrap();
653        std::thread::scope(|scope| {
654            for _ in 0..4 {
655                scope.spawn(|| {
656                    for _ in 0..10 {
657                        assert_eq!(plan.run(tvec!(x.clone())).unwrap(), want);
658                    }
659                });
660            }
661        });
662    }
663
664    /// Above a hidden size of 64 declutter moves the input-side product into the
665    /// graph and codegen packs `R` into the projected op (the state packs it when the
666    /// batch is symbolic); the result still follows the step-by-step recurrence,
667    /// forwards and backwards, with and without bias.
668    #[test]
669    fn large_hidden_is_projected_and_packed() {
670        let (batch, input, hidden, t_len) = (2usize, 12usize, 80usize, 6usize);
671        let f = |n: usize, k: f32| Array1::from_iter((0..n).map(|i| ((i as f32) * k).sin() * 0.3));
672        for case in 0..8 {
673            let (backward, bias, symbolic) = (case & 1 != 0, case & 2 != 0, case & 4 != 0);
674            let x =
675                f(batch * t_len * input, 0.7).into_shape_with_order((batch, t_len, input)).unwrap();
676            let w = f(3 * hidden * input, 0.31).into_shape_with_order((3 * hidden, input)).unwrap();
677            let r =
678                f(3 * hidden * hidden, 0.17).into_shape_with_order((3 * hidden, hidden)).unwrap();
679            let b = bias.then(|| f(6 * hidden, 0.11));
680            let h0 = Array2::<f32>::zeros((batch, hidden));
681            let (want_y, want_h) = reference(&x, &w, &r, b.as_ref(), &h0, hidden, backward);
682
683            let mut model = TypedModel::default();
684            let batch_dim = if symbolic { model.symbols.sym("B").to_dim() } else { batch.to_dim() };
685            let source = model
686                .add_source("x", f32::fact(&[batch_dim, t_len.to_dim(), input.to_dim()]))
687                .unwrap();
688            let mut inputs = tvec!(
689                source,
690                model.add_const("w", w.into_tensor()).unwrap(),
691                model.add_const("r", r.into_tensor()).unwrap()
692            );
693            if let Some(b) = b {
694                inputs.push(model.add_const("b", b.into_tensor()).unwrap());
695            }
696            let h0_3 = h0.clone().insert_axis(Axis(1));
697            inputs.push(model.add_const("h0", h0_3.into_tensor()).unwrap());
698            let op = GruSeq {
699                hidden,
700                has_bias: bias,
701                chunk: if backward { -1 } else { 1 },
702                reset_every_turn: true,
703                emit_y: true,
704                projected: false,
705                packed_r: None,
706            };
707            let outputs = model.wire_node("gru", op, &inputs).unwrap();
708            model.select_output_outlets(&outputs).unwrap();
709            let model = model.into_optimized().unwrap();
710            let fused = model.nodes.iter().find_map(|n| n.op_as::<GruSeq>()).unwrap();
711            assert!(fused.projected, "declutter left the input-side product in the op");
712            assert_eq!(fused.packed_r.is_some(), !symbolic, "R packed by codegen iff batch known");
713
714            let got = model.into_runnable().unwrap().run(tvec!(x.into_tensor().into())).unwrap();
715            got[0]
716                .clone()
717                .into_tensor()
718                .close_enough(&want_y.into_tensor(), Approximation::Approximate)
719                .unwrap();
720            let got_h = got[1]
721                .to_plain_array_view::<f32>()
722                .unwrap()
723                .into_dimensionality::<Ix3>()
724                .unwrap()
725                .index_axis_move(Axis(1), 0)
726                .to_owned();
727            got_h
728                .into_tensor()
729                .close_enough(&want_h.into_tensor(), Approximation::Approximate)
730                .unwrap();
731        }
732    }
733}