Skip to main content

tract_core/ops/
lstm_cell.rs

1use crate::internal::*;
2use tract_linalg::element_wise::ElementWise;
3use tract_linalg::routines::Func;
4
5/// Fused LSTM cell epilogue.
6///
7/// Given the combined gate pre-activations
8/// `preact = Xt·Wᵀ + Ht-1·Rᵀ + bias` of shape `[batch, 4*hidden]` (ONNX gate
9/// order i, o, f, c) and the previous cell state `c_prev` `[batch, hidden]`,
10/// computes the new hidden `Ht` and cell `Ct` in a SINGLE fused pass.
11///
12/// This collapses the per-gate `Sigmoid`/`Tanh` + elementwise `Mul`/`Add`
13/// chain (≈ 15 separately-dispatched ops, each materialising an intermediate
14/// tensor) into one op — the dominant non-matmul cost for streaming LSTM
15/// inference. Standard activations only (`f = sigmoid`, `g = h = tanh`) and no
16/// peepholes; the importer falls back to the decomposed form otherwise.
17///
18/// Activations use tract's vectorised `sigmoid`/`tanh` linalg kernels
19/// (NEON on aarch64) applied to contiguous gate slices, so the output is
20/// numerically identical to the decomposed Sigmoid/Tanh path while collapsing
21/// the per-gate dispatch into one op. Runs in either `f32` or `f16`, matching
22/// the dtype the precision transform settled the surrounding graph on.
23#[derive(Debug, Clone, Hash, PartialEq, Eq)]
24pub struct LstmEpilogue {
25    pub hidden: usize,
26}
27
28impl Op for LstmEpilogue {
29    fn name(&self) -> StaticName {
30        "LstmEpilogue".into()
31    }
32
33    fn info(&self) -> TractResult<Vec<String>> {
34        Ok(vec![format!("hidden={}", self.hidden)])
35    }
36
37    op_as_typed_op!();
38}
39
40impl EvalOp for LstmEpilogue {
41    op_out_of_plan!();
42
43    fn eval(&self, _ctx: &EvalContext, inputs: TVec<TValue>) -> TractResult<TVec<TValue>> {
44        // Dispatch on the dtype the precision transform left the graph in. The
45        // ONNX LSTM is f32-native, but `FloatPrecisionTranslator` rewrites the
46        // whole float graph (including this op's inputs) to f16, so we must run
47        // in whichever float type actually arrives — reading an f16 buffer as
48        // f32 would walk off the end of the allocation.
49        match inputs[0].datum_type().unquantized() {
50            DatumType::F32 => {
51                self.eval_t::<f32>(inputs, Func::Sigmoid.ew_f32()?, Func::Tanh.ew_f32()?)
52            }
53            DatumType::F16 => {
54                self.eval_t::<f16>(inputs, Func::Sigmoid.ew_f16()?, Func::Tanh.ew_f16()?)
55            }
56            dt => bail!("LstmEpilogue only supports f32 and f16 preactivations, got {dt:?}"),
57        }
58    }
59}
60
61impl LstmEpilogue {
62    fn eval_t<T>(
63        &self,
64        inputs: TVec<TValue>,
65        sigmoid: Box<dyn ElementWise<T>>,
66        tanh: Box<dyn ElementWise<T>>,
67    ) -> TractResult<TVec<TValue>>
68    where
69        T: Datum + Copy + std::ops::Mul<Output = T> + std::ops::Add<Output = T>,
70    {
71        let h = self.hidden;
72        let c_prev = &inputs[1]; // [.., h]
73        let cp = unsafe { c_prev.as_slice_unchecked::<T>() };
74        // Rows come from the state, which also sizes the outputs: a preact left
75        // broadcast on the batch axis would otherwise under-fill them.
76        let rows = cp.len() / h;
77        ensure!(
78            inputs[0].len() == rows * 4 * h,
79            "LstmEpilogue expects preact shaped [{rows}, 4*{h}], got {:?}",
80            inputs[0].shape()
81        );
82        // Mutable copy of preact so the activation kernels run in place.
83        let mut pre_t = inputs[0].clone().into_tensor();
84        let pre = unsafe { pre_t.as_slice_mut_unchecked::<T>() };
85        let mut ht = unsafe { Tensor::uninitialized_dt(T::datum_type(), c_prev.shape())? };
86        let mut ct = unsafe { Tensor::uninitialized_dt(T::datum_type(), c_prev.shape())? };
87        {
88            let hs = unsafe { ht.as_slice_mut_unchecked::<T>() };
89            let cs = unsafe { ct.as_slice_mut_unchecked::<T>() };
90            for r in 0..rows {
91                let pb = r * 4 * h;
92                let cb = r * h;
93                let row = &mut pre[pb..pb + 4 * h];
94                // gate order i,o,f,c: sigmoid the i,o,f block, tanh the c block
95                sigmoid.run(&mut row[0..3 * h])?;
96                tanh.run(&mut row[3 * h..4 * h])?;
97                // Ct = ft*c_prev + it*cc  (it=row[j], ot=row[h+j], ft=row[2h+j], cc=row[3h+j])
98                for j in 0..h {
99                    cs[cb + j] = row[2 * h + j] * cp[cb + j] + row[j] * row[3 * h + j];
100                }
101                // Ht = ot * tanh(Ct): stage tanh(Ct) in hs, then scale by ot
102                hs[cb..cb + h].copy_from_slice(&cs[cb..cb + h]);
103                tanh.run(&mut hs[cb..cb + h])?;
104                for j in 0..h {
105                    hs[cb + j] = hs[cb + j] * row[h + j];
106                }
107            }
108        }
109        Ok(tvec!(ht.into_tvalue(), ct.into_tvalue()))
110    }
111}
112
113impl TypedOp for LstmEpilogue {
114    as_op!();
115
116    fn output_facts(&self, inputs: &[&TypedFact]) -> TractResult<TVec<TypedFact>> {
117        ensure!(inputs.len() == 2, "LstmEpilogue expects [preact, c_prev]");
118        // Ht and Ct share c_prev's shape and dtype ([.., hidden]).
119        let c_prev = inputs[1];
120        let fact = c_prev.datum_type.fact(c_prev.shape.clone());
121        Ok(tvec!(fact.clone(), fact))
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    // Fused epilogue must match a scalar reference LSTM cell (catches gate-order
130    // and cell/hidden-formula bugs). Tolerance covers the rational sigmoid/tanh
131    // approximation (~1e-7) vs the exact reference. Multi-row exercises batch.
132    #[test]
133    fn epilogue_matches_scalar_reference() {
134        let h = 6usize;
135        let batch = 3usize;
136        let preact: Vec<f32> =
137            (0..batch * 4 * h).map(|i| ((i * 7 % 29) as f32 - 14.0) * 0.25).collect();
138        let cprev: Vec<f32> = (0..batch * h).map(|i| ((i * 5 % 17) as f32 - 8.0) * 0.2).collect();
139        let pre_t = Tensor::from_shape(&[batch, 4 * h], &preact).unwrap();
140        let cprev_t = Tensor::from_shape(&[batch, h], &cprev).unwrap();
141        let op = LstmEpilogue { hidden: h };
142        let out = op
143            .eval(&EvalContext::out_of_plan(), tvec!(pre_t.into_tvalue(), cprev_t.into_tvalue()))
144            .unwrap();
145        let ht = unsafe { out[0].as_slice_unchecked::<f32>() };
146        let ct = unsafe { out[1].as_slice_unchecked::<f32>() };
147
148        let sig = |x: f32| 1.0 / (1.0 + (-x).exp());
149        for r in 0..batch {
150            for j in 0..h {
151                let p = r * 4 * h; // gate order on the 4*h axis: i, o, f, c
152                let it = sig(preact[p + j]);
153                let ot = sig(preact[p + h + j]);
154                let ft = sig(preact[p + 2 * h + j]);
155                let cc = preact[p + 3 * h + j].tanh();
156                let c_ref = ft * cprev[r * h + j] + it * cc;
157                let h_ref = ot * c_ref.tanh();
158                assert!((ct[r * h + j] - c_ref).abs() < 1e-3, "Ct mismatch at ({r},{j})");
159                assert!((ht[r * h + j] - h_ref).abs() < 1e-3, "Ht mismatch at ({r},{j})");
160            }
161        }
162    }
163}