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
8type PackedR = (Box<dyn MatMatMul>, usize, Box<dyn MMMInputValue>);
11
12const MAX_UNPROJECTED_HIDDEN: usize = 64;
15
16#[derive(Debug, Clone, Hash, PartialEq, Eq)]
39pub struct GruSeq {
40 pub hidden: usize,
41 pub has_bias: bool,
42 pub chunk: isize,
44 pub emit_y: bool,
47 pub reset_every_turn: bool,
50 pub projected: bool,
53 pub packed_r: Option<PackedR>,
55}
56
57#[derive(Default)]
58struct GruSeqState {
59 h: Option<Tensor>,
60 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 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 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 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 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 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 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 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 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 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 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 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 {
209 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)?; Ok(tvec!(y.into_tensor().into(), h_out.into()))
262 }
263}
264
265fn 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
282fn 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 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 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 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 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 #[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 #[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 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 #[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 #[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 #[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 #[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}