Skip to main content

tract_core/ops/array/
broadcast.rs

1use tract_data::itertools::izip;
2
3use crate::broadcast::multi_broadcast;
4use crate::internal::*;
5use crate::ops::binary::TypedBinOp;
6
7#[derive(Debug, Clone, new, Hash, PartialEq, Eq)]
8pub struct MultiBroadcastTo {
9    pub shape: ShapeFact,
10}
11
12impl Op for MultiBroadcastTo {
13    fn name(&self) -> StaticName {
14        "MultiBroadcastTo".into()
15    }
16
17    op_as_typed_op!();
18}
19
20impl EvalOp for MultiBroadcastTo {
21    op_out_of_plan!();
22
23    fn eval(&self, ctx: &EvalContext, inputs: TVec<TValue>) -> TractResult<TVec<TValue>> {
24        let shape = self.shape.eval_to_usize(ctx.symbols)?;
25        Ok(tvec!(inputs[0].broadcast_to_shape(&shape)?.into_tvalue()))
26    }
27}
28
29impl TypedOp for MultiBroadcastTo {
30    fn axes_mapping(
31        &self,
32        inputs: &[&TypedFact],
33        outputs: &[&TypedFact],
34    ) -> TractResult<AxesMapping> {
35        // ONNX-style broadcasting right-aligns input over output, so when
36        // output_rank > input_rank the leading output axes are pure
37        // broadcast axes with no input correspondence. natural_for_rank's
38        // square shape would skip them and trip the optimizer's axes-mapping
39        // check (caught under paranoid_assertions).
40        let in_rank = inputs[0].rank();
41        let out_rank = outputs[0].rank();
42        let leading = out_rank.saturating_sub(in_rank);
43        let mut axes = tvec!();
44        let mut alphabet = 'a'..;
45        for o in 0..leading {
46            axes.push(
47                Axis::new(alphabet.next().unwrap(), inputs.len(), outputs.len()).output(0, o),
48            );
49        }
50        for i in 0..in_rank.min(out_rank) {
51            axes.push(
52                Axis::new(alphabet.next().unwrap(), inputs.len(), outputs.len())
53                    .input(0, i)
54                    .output(0, leading + i),
55            );
56        }
57        AxesMapping::new(inputs.len(), outputs.len(), axes)
58    }
59
60    fn change_axes(
61        &self,
62        model: &TypedModel,
63        node: &TypedNode,
64        _io: InOut,
65        change: &AxisOp,
66    ) -> TractResult<Option<AxisChangeConsequence>> {
67        // Only propagate axis changes that touch passthrough axes — those
68        // where the input and output shapes agree. Touching a broadcast
69        // axis (input=1, output=N) would make the input and output rank
70        // diverge through the change and break the broadcast relationship,
71        // and propagating Rm of a non-trivial axis into a Source produces
72        // the "Removing non-trivial axis" hard error from change_shape.
73        let input_shape = &model.outlet_fact(node.inputs[0])?.shape;
74        let canonical = change.canonical();
75        let touched: TVec<usize> = match canonical.as_ref() {
76            AxisOp::Add(ix) | AxisOp::Rm(ix) => tvec![*ix],
77            AxisOp::Move(from, to) => {
78                rule_if!(input_shape.rank() == self.shape.rank());
79                tvec![*from, *to]
80            }
81            _ => return Ok(None),
82        };
83        for &ix in &touched {
84            if ix < self.shape.rank()
85                && ix < input_shape.rank()
86                && input_shape[ix] != self.shape[ix]
87            {
88                return Ok(None);
89            }
90        }
91
92        let mut shape = self.shape.clone();
93        if change.change_shape(&mut shape, false).is_ok() {
94            return Ok(Some(AxisChangeConsequence::new(
95                model,
96                node,
97                Some(Box::new(MultiBroadcastTo { shape })),
98                change,
99            )));
100        }
101        Ok(None)
102    }
103
104    fn output_facts(&self, inputs: &[&TypedFact]) -> TractResult<TVec<TypedFact>> {
105        ensure!(inputs.len() == 1);
106        let mut fact = inputs[0].datum_type.fact(self.shape.clone());
107        fact.uniform.clone_from(&inputs[0].uniform);
108        fact.uniform_tdim = inputs[0].uniform_tdim.clone();
109        Ok(tvec!(fact))
110    }
111
112    fn input_roi(
113        &self,
114        model: &TypedModel,
115        node: &TypedNode,
116    ) -> TractResult<Option<TVec<Option<TDim>>>> {
117        crate::optim::propagate_roi::bubble_roi(model, node)
118    }
119
120    fn set_symbols(
121        &self,
122        _source: &TypedModel,
123        node: &TypedNode,
124        target: &mut TypedModel,
125        mapping: &HashMap<OutletId, OutletId>,
126        subs: &HashMap<Symbol, TDim>,
127    ) -> TractResult<TVec<OutletId>> {
128        let input = mapping[&node.inputs[0]];
129        let shape: TVec<_> =
130            self.shape.iter().map(|d| d.substitute_all(subs)).collect::<TractResult<_>>()?;
131        let op = Self { shape: shape.into() };
132        target.wire_node(&node.name, op, &[input])
133    }
134
135    fn declutter(
136        &self,
137        model: &TypedModel,
138        node: &TypedNode,
139    ) -> TractResult<Option<TypedModelPatch>> {
140        let input_fact = model.outlet_fact(node.inputs[0])?;
141        if input_fact.shape == self.shape {
142            return TypedModelPatch::shunt_one_op(model, node);
143        }
144        // Swap with an AxisOp successor: `Broadcast(x, S) → AxisOp` becomes
145        // `AxisOp(x) → Broadcast(σ(S))` whenever the AxisOp transforms every
146        // axis the broadcast actually expanded.  Fires per-successor, so this
147        // works under fan-out (the original broadcast stays in place for
148        // siblings; only the matched AxisOp branch is rerouted).
149        for succ in &*node.outputs[0].successors {
150            let succ = model.node(succ.node);
151            let Some(op) = succ.op_as::<AxisOp>() else { continue };
152            // The AxisOp's indices refer to the broadcast output; they are only
153            // meaningful on the input if the broadcast did not add leading axes.
154            if input_fact.rank() != self.shape.rank() {
155                continue;
156            }
157            let mut shape = self.shape.clone();
158            if izip!(0.., &*input_fact.shape, &*self.shape)
159                .filter(|(_, l, r)| l != r)
160                .all(|(axis, _, _)| op.transform_axis(axis).is_some())
161                && op.change_shape(&mut shape, false).is_ok()
162            {
163                let mut patch = TypedModelPatch::default();
164                let mut wire = patch.tap_model(model, node.inputs[0])?;
165                wire = patch.wire_node(&succ.name, op.clone(), &[wire])?[0];
166                wire = patch.wire_node(&node.name, MultiBroadcastTo { shape }, &[wire])?[0];
167                patch.shunt_outside(model, succ.id.into(), wire)?;
168                return Ok(Some(patch));
169            }
170        }
171        if let [succ] = &*node.outputs[0].successors {
172            let succ = model.node(succ.node);
173            if succ.op_is::<TypedBinOp>() {
174                let our_slot = node.outputs[0].successors[0].slot;
175                let other_slot = 1 - our_slot;
176                let other_operand = succ.inputs[other_slot];
177                let other_fact = model.outlet_fact(other_operand)?;
178                let output_fact = model.outlet_fact(succ.id.into())?;
179                if input_fact.rank() == other_fact.rank()
180                    && multi_broadcast(&[&input_fact.shape, &other_fact.shape])
181                        .is_ok_and(|s| *s == *output_fact.shape)
182                {
183                    let mut operands = tvec!(node.inputs[0], other_operand);
184                    if our_slot == 1 {
185                        operands.swap(0, 1);
186                    }
187                    return TypedModelPatch::rewire(
188                        model,
189                        &operands,
190                        &[succ.id.into()],
191                        &|p, inputs| p.wire_node(&succ.name, succ.op.clone(), inputs),
192                    )
193                    .map(Some);
194                }
195            }
196        }
197        Ok(None)
198    }
199
200    as_op!();
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206    use crate::ops::change_axes::AxisOp;
207    use crate::ops::logic::And;
208
209    /// `Broadcast → Move` with the broadcast feeding a SINGLE successor.
210    /// Pre-existing path: the swap rewrite kicks in.
211    #[test]
212    fn broadcast_move_single_successor_swaps() -> TractResult<()> {
213        let mut model = TypedModel::default();
214        let t = model.symbols.sym("T");
215        let pad = model.add_source("pad", bool::fact(&[t.to_dim()]))?;
216        let unsq = model.wire_node("unsq", AxisOp::Add(0), &[pad])?[0];
217        let bcast = model.wire_node(
218            "bcast",
219            MultiBroadcastTo { shape: ShapeFact::from_dims([t.to_dim(), t.to_dim()]) },
220            &[unsq],
221        )?[0];
222        let mv = model.wire_node("move", AxisOp::Move(0, 1), &[bcast])?[0];
223        model.select_output_outlets(&[mv])?;
224
225        let model = model.into_decluttered()?;
226
227        let move_count = model
228            .nodes()
229            .iter()
230            .filter(|n| matches!(n.op_as::<AxisOp>(), Some(AxisOp::Move(0, 1))))
231            .count();
232        assert_eq!(move_count, 0, "Move should have been pushed through Broadcast and absorbed");
233        Ok(())
234    }
235
236    /// `Broadcast → {Move, And-direct}` — the encoder-style pad-mask outer-AND
237    /// pattern.  Pre-fix: declutter bailed because broadcast had > 1 successor;
238    /// the Move stayed.  Post-fix: the Move-branch gets its own swapped
239    /// chain, the direct-AND branch still consumes the original broadcast.
240    #[test]
241    fn broadcast_move_fanout_pushes_through_one_branch() -> TractResult<()> {
242        let mut model = TypedModel::default();
243        let t = model.symbols.sym("T");
244        let pad = model.add_source("pad", bool::fact(&[t.to_dim()]))?;
245        let unsq = model.wire_node("unsq", AxisOp::Add(0), &[pad])?[0];
246        let bcast = model.wire_node(
247            "bcast",
248            MultiBroadcastTo { shape: ShapeFact::from_dims([t.to_dim(), t.to_dim()]) },
249            &[unsq],
250        )?[0];
251        let mv = model.wire_node("move", AxisOp::Move(0, 1), &[bcast])?[0];
252        let and = model.wire_node("and", TypedBinOp(Box::new(And), None), &[bcast, mv])?[0];
253        model.select_output_outlets(&[and])?;
254
255        let model = model.into_decluttered()?;
256
257        // Expected: fan-out swap-through fires on the Move branch, then the
258        // existing Broadcast→TypedBinOp rule fires on each (now single-
259        // successor) broadcast, eliminating both — the AND ends up
260        // broadcasting [1, T] and [T, 1] implicitly.
261        let bcast_count = model.nodes().iter().filter(|n| n.op_is::<MultiBroadcastTo>()).count();
262        assert_eq!(
263            bcast_count, 0,
264            "Both broadcasts should be subsumed into AND's implicit broadcasting"
265        );
266
267        let and_node =
268            model.nodes().iter().find(|n| n.op_is::<TypedBinOp>()).expect("AND should survive");
269        assert_eq!(and_node.inputs.len(), 2);
270        let and_input_shapes: Vec<_> = and_node
271            .inputs
272            .iter()
273            .map(|i| model.outlet_fact(*i).unwrap().shape.to_tvec())
274            .collect();
275        let expected_a = tvec![1.to_dim(), t.to_dim()];
276        let expected_b = tvec![t.to_dim(), 1.to_dim()];
277        let (a, b) = (&and_input_shapes[0], &and_input_shapes[1]);
278        assert!(
279            (a == &expected_a && b == &expected_b) || (a == &expected_b && b == &expected_a),
280            "AND should receive [1, T] and [T, 1]; got {a:?} and {b:?}"
281        );
282        Ok(())
283    }
284
285    /// `Broadcast → AxisOp` where the broadcast adds a leading axis (input
286    /// rank < output rank).  The AxisOp's indices refer to the output shape
287    /// and are meaningless on the input; the swap must not fire.  Pre-fix,
288    /// the guard izip truncated to the shorter rank and wiring the AxisOp
289    /// onto the input panicked in AxisOp::change_shape.
290    #[test]
291    fn broadcast_adding_leading_axis_does_not_swap_with_axis_op() -> TractResult<()> {
292        let mut model = TypedModel::default();
293        let src = model.add_source("src", f32::fact([512, 1]))?;
294        let bcast = model.wire_node(
295            "bcast",
296            MultiBroadcastTo {
297                shape: ShapeFact::from_dims([1.to_dim(), 512.to_dim(), 16.to_dim()]),
298            },
299            &[src],
300        )?[0];
301        let unsq = model.wire_node("unsq", AxisOp::Add(3), &[bcast])?[0];
302        model.select_output_outlets(&[unsq])?;
303
304        let model = model.into_decluttered()?;
305        assert_eq!(
306            model.output_fact(0)?.shape.to_tvec(),
307            tvec![1.to_dim(), 512.to_dim(), 16.to_dim(), 1.to_dim()]
308        );
309        Ok(())
310    }
311}