Skip to main content

tract_core/
batchify.rs

1//! Give a model's dataflow a batch axis, on axis 0 of every input and output, so
2//! that a turn can seat several callers in one run.
3//!
4//! Every op the axis reaches is asked to host it, through the same
5//! `TypedOp::change_axes` protocol declutter propagates axis changes with, and
6//! the answer is accepted only if the op **carries the axis through** to all of
7//! its outputs. That is a stricter contract than the protocol's own, which only
8//! owes semantics at extent 1: an `EinSum` offered an extra leading axis on an
9//! input takes it as a fresh contraction label, summing an axis of extent one
10//! away, which is exact there and sums the seats together once the extent is the
11//! batch. So the batched `EinSum` is built here instead, with the label on the
12//! output as well, and an op that answers with anything but the axis carried
13//! through is refused by name.
14//!
15//! An input the caller lists as **shared** is one every seat reads whole -- a
16//! table, a language id. It keeps its own shape, and the ops that need their
17//! operands to agree on rank read it through an `AxisOp::Add(0)`, so it
18//! broadcasts across the turn.
19//!
20//! An input already sized by the batch symbol has that axis **moved** to the
21//! front, as an interface change plus the inverse `AxisOp::Move` on the wire
22//! behind it: an export putting the batch inside (`[T, B, H]` being the common
23//! shape) becomes a graph edit declutter can absorb rather than a transpose the
24//! runtime would pay per turn. Only axis 0 makes a seat's values a contiguous
25//! run, which is what the laned runtime addresses and what stacking a turn's
26//! answer back relies on.
27//!
28//! What this does not touch is state: a seat's own history is the laned
29//! runtime's lane addressing, and a batch axis on the dataflow does not imply
30//! one. Batchify gives you seats, lane addressing gives each seat its own past.
31
32use std::collections::HashSet;
33
34use crate::internal::*;
35use crate::ops::array::{MultiBroadcastTo, TypedConcat};
36use crate::ops::change_axes::AxisOp;
37use crate::ops::einsum::EinSum;
38use crate::ops::source::TypedSource;
39use crate::transform::ModelTransform;
40
41#[derive(Debug, Default, serde::Deserialize)]
42pub struct BatchifyConfig {
43    /// Symbol sizing the batch axis. Defaults to "B".
44    pub symbol: Option<String>,
45    /// Inputs every seat shares, by node name.
46    #[serde(default)]
47    pub shared: Option<Vec<String>>,
48}
49
50#[derive(Debug)]
51pub struct Batchify(pub BatchifyConfig);
52
53impl ModelTransform for Batchify {
54    fn name(&self) -> StaticName {
55        "batchify".into()
56    }
57
58    fn transform(&self, model: &mut TypedModel) -> TractResult<()> {
59        let name = self.0.symbol.as_deref().unwrap_or("B");
60        let symbol = model.symbols.sym(name);
61        let shared = self.0.shared.as_deref().unwrap_or(&[]);
62        *model = batchify(model, &symbol, shared)?;
63        Ok(())
64    }
65}
66
67/// What an op does with the batch axis arriving on some of its inputs: the op to
68/// wire in its place, and the input slots that must read through an
69/// `AxisOp::Add(0)` to agree on rank with the batched ones.
70struct Hosted {
71    op: Option<Box<dyn TypedOp>>,
72    pad: TVec<usize>,
73    /// Whether a padded slot must also be broadcast to the batch: an op whose
74    /// operands have to agree axis by axis, rather than broadcast against each
75    /// other, reads an axis of extent one as an axis of extent one.
76    broadcast: bool,
77}
78
79/// Batch axis on axis 0 of every input but the `shared` ones, sized by `batch`.
80pub fn batchify(model: &TypedModel, batch: &Symbol, shared: &[String]) -> TractResult<TypedModel> {
81    for name in shared {
82        ensure!(
83            model.input_outlets()?.iter().any(|o| &model.node(o.node).name == name),
84            "{name} is listed as a shared input but is not a model input"
85        );
86    }
87    // An export which kept the batch on one input and collapsed it to a leading
88    // one on another -- an RNNT joint, whose decoder side carries the beam it is
89    // batched over -- has the axis already, and the collapsed inputs join it
90    // rather than introduce a second batch of their own.
91    let mut join = false;
92    for outlet in model.input_outlets()? {
93        let name = &model.node(outlet.node).name;
94        join |= !shared.contains(name) && carrier(model.outlet_fact(*outlet)?, batch).is_some();
95    }
96    let mut carried: HashSet<OutletId> = Default::default();
97    let mut hosts: HashMap<usize, Hosted> = Default::default();
98    for id in model.eval_order()? {
99        let node = model.node(id);
100        if let Some(source) = node.op_as::<TypedSource>() {
101            if !shared.contains(&node.name)
102                && carrier(&source.fact, batch).is_none()
103                && !collapsed(&source.fact, batch, join)
104            {
105                carried.insert(id.into());
106            }
107            continue;
108        }
109        let batched: TVec<usize> = node
110            .inputs
111            .iter()
112            .enumerate()
113            .filter(|(_, input)| carried.contains(input))
114            .map(|(slot, _)| slot)
115            .collect();
116        if batched.is_empty() {
117            continue;
118        }
119        let mut hosted = host(model, node, &batched, &carried)
120            .with_context(|| format!("Giving {node} a batch axis"))?;
121        hosted.broadcast = node.op_is::<TypedConcat>();
122        hosts.insert(id, hosted);
123        carried.extend((0..node.outputs.len()).map(|slot| OutletId::new(id, slot)));
124    }
125    let wired = wire(model, batch, shared, join, &hosts)?;
126    check(wired, batch)
127}
128
129/// The batch axis an input carries, if any: the first axis sized by an
130/// expression the batch symbol is in.
131fn carrier(fact: &TypedFact, batch: &Symbol) -> Option<usize> {
132    fact.shape.iter().position(|dim| dim.symbols().contains(batch))
133}
134
135/// Whether an input's batch is the leading one an export collapsed it to: the
136/// model carries the axis elsewhere, this input has no dim sized by it, and its
137/// first axis is one. Such an input is resized rather than reshaped, so the
138/// graph behind it hosts nothing.
139fn collapsed(fact: &TypedFact, batch: &Symbol, join: bool) -> bool {
140    join && carrier(fact, batch).is_none() && fact.shape.first() == Some(&1.to_dim())
141}
142
143/// Asks an op to host the batch axis on the inputs that carry it, and refuses an
144/// answer that does not carry it through to every output.
145fn host(
146    model: &TypedModel,
147    node: &TypedNode,
148    batched: &[usize],
149    carried: &HashSet<OutletId>,
150) -> TractResult<Hosted> {
151    if let Some(einsum) = node.op_as::<EinSum>() {
152        return batched_einsum(einsum, batched);
153    }
154    if let Some(axis_op) = node.op_as::<AxisOp>() {
155        return Ok(Hosted {
156            op: Some(Box::new(shifted_axis_op(axis_op))),
157            pad: tvec!(),
158            broadcast: false,
159        });
160    }
161    let change = AxisOp::Add(0);
162    let consequence = node
163        .op
164        .change_axes(model, node, InOut::In(batched[0]), &change)?
165        .context("the op takes no extra leading axis at all")?;
166    for slot in 0..node.outputs.len() {
167        ensure!(
168            consequence.wire_changes.contains(&(InOut::Out(slot), change.clone())),
169            "the op takes an extra leading axis on an input but does not carry it to output {slot}, \
170             so the seats would be folded into one another"
171        );
172    }
173    let pad = consequence
174        .wire_changes
175        .iter()
176        .filter_map(|(io, _)| match io {
177            InOut::In(slot) if !carried.contains(&node.inputs[*slot]) => Some(*slot),
178            _ => None,
179        })
180        .collect();
181    Ok(Hosted { op: consequence.substitute_op, pad, broadcast: false })
182}
183
184/// An `EinSum` batched by one label of its own, on axis 0 of the batched inputs
185/// and of every output. Its `change_axes` would rather contract the axis away,
186/// which is the same thing only while the extent is one.
187fn batched_einsum(einsum: &EinSum, batched: &[usize]) -> TractResult<Hosted> {
188    let label = einsum.axes.available_label();
189    let mut axes = einsum.axes.clone().with_extra_axis(label, InOut::In(batched[0]), 0)?;
190    for slot in &batched[1..] {
191        axes = axes.with_extra_axis_occurency(label, InOut::In(*slot), 0)?;
192    }
193    for slot in 0..axes.output_count() {
194        axes = axes.with_extra_axis_occurency(label, InOut::Out(slot), 0)?;
195    }
196    let op = EinSum { axes, ..einsum.clone() };
197    Ok(Hosted { op: Some(Box::new(op)), pad: tvec!(), broadcast: false })
198}
199
200/// An `AxisOp` one axis further up, which is what hosting the batch on axis 0
201/// asks of it: an export collapsed to one stream reaches the graph through an
202/// `Add(0)` putting the batch back, and the ops the change_axes protocol
203/// answers for would carry the seats to axis 1 instead, folding them into the
204/// axis the collapse left behind.
205fn shifted_axis_op(op: &AxisOp) -> AxisOp {
206    match op {
207        AxisOp::Add(a) => AxisOp::Add(a + 1),
208        AxisOp::Rm(a) => AxisOp::Rm(a + 1),
209        AxisOp::Move(from, to) => AxisOp::Move(from + 1, to + 1),
210        AxisOp::Reshape(at, from, to) => AxisOp::Reshape(at + 1, from.clone(), to.clone()),
211    }
212}
213
214/// The batchified model, rebuilt through its ops so that every fact comes from
215/// Moves a `pulse.*_axes` property right by one wherever the interface it
216/// indexes gained the batch axis. A negative entry marks an interface with no
217/// streaming axis and stays as it is.
218fn shift_pulse_axes(model: &mut TypedModel, key: &str, widened: &[bool]) -> TractResult<()> {
219    let Some(axes) = model.properties.get(key) else { return Ok(()) };
220    let mut axes: Vec<i64> = axes.cast_to::<i64>()?.try_as_plain_ram()?.as_slice::<i64>()?.to_vec();
221    ensure!(
222        axes.len() == widened.len(),
223        "{key} holds {} axes for {} interfaces",
224        axes.len(),
225        widened.len()
226    );
227    for (axis, widened) in axes.iter_mut().zip(widened) {
228        if *widened && *axis >= 0 {
229            *axis += 1;
230        }
231    }
232    model.properties.insert(key.to_string(), tensor1(&axes).into_arc_tensor());
233    Ok(())
234}
235
236/// `output_facts` with the batch extent in place rather than from the facts the
237/// unbatched model stored.
238fn wire(
239    model: &TypedModel,
240    batch: &Symbol,
241    shared: &[String],
242    join: bool,
243    hosts: &HashMap<usize, Hosted>,
244) -> TractResult<TypedModel> {
245    let mut target = TypedModel { symbols: model.symbols.clone(), ..TypedModel::default() };
246    let mut mapping: HashMap<OutletId, OutletId> = Default::default();
247    let mut interface: HashMap<OutletId, OutletId> = Default::default();
248    for id in model.eval_order()? {
249        let node = model.node(id);
250        let wires = if let Some(source) = node.op_as::<TypedSource>() {
251            let (source, interior) = batched_source(
252                &mut target,
253                node,
254                source,
255                batch,
256                !shared.contains(&node.name),
257                join,
258            )?;
259            interface.insert(id.into(), source);
260            tvec!(interior)
261        } else {
262            let hosted = hosts.get(&id);
263            let mut inputs: TVec<OutletId> =
264                node.inputs.iter().map(|input| mapping[input]).collect();
265            for slot in hosted.map(|h| &*h.pad).unwrap_or(&[]) {
266                let mut wire = target.wire_node(
267                    format!("{}.batchify.rank.{slot}", node.name),
268                    AxisOp::Add(0),
269                    &[inputs[*slot]],
270                )?[0];
271                if hosted.is_some_and(|h| h.broadcast) {
272                    let mut shape: TVec<TDim> = target.outlet_fact(wire)?.shape.to_tvec();
273                    shape[0] = batch.to_dim();
274                    wire = target.wire_node(
275                        format!("{}.batchify.broadcast.{slot}", node.name),
276                        MultiBroadcastTo { shape: shape.into() },
277                        &[wire],
278                    )?[0];
279                }
280                inputs[*slot] = wire;
281            }
282            let op = hosted.and_then(|h| h.op.clone()).unwrap_or_else(|| node.op.clone());
283            target
284                .wire_node(&node.name, op, &inputs)
285                .with_context(|| format!("Wiring {node} with a batch axis"))?
286        };
287        for (slot, wire) in wires.into_iter().enumerate() {
288            let outlet = OutletId::new(id, slot);
289            if let Some(label) = model.outlet_label(outlet) {
290                target.set_outlet_label(wire, label.to_string())?;
291            }
292            mapping.insert(outlet, wire);
293        }
294    }
295    let inputs: Vec<OutletId> = model.input_outlets()?.iter().map(|i| interface[i]).collect();
296    let mut outputs: Vec<OutletId> = model.output_outlets()?.iter().map(|o| mapping[o]).collect();
297    for (ix, output) in outputs.iter_mut().enumerate() {
298        let fact = target.outlet_fact(*output)?.clone();
299        if let Some(axis) = carrier(&fact, batch).filter(|axis| *axis > 0) {
300            let label = target.outlet_label(*output).map(|l| l.to_string());
301            *output = target.wire_node(
302                format!("batchify.move.output.{ix}"),
303                AxisOp::Move(axis, 0),
304                &[*output],
305            )?[0];
306            if let Some(label) = label {
307                target.set_outlet_label(*output, label)?;
308            }
309        }
310    }
311    target.set_input_outlets(&inputs)?;
312    target.select_output_outlets(&outputs)?;
313    // A batched model is the same model, so it keeps what the graph says about
314    // itself -- its name, and the pulse properties a streaming caller reads to
315    // find the delay and the streaming axes. The axes among them are indices
316    // into shapes this transform has just widened.
317    target.properties.clone_from(&model.properties);
318    if !join {
319        let batched_inputs: Vec<bool> = model
320            .input_outlets()?
321            .iter()
322            .map(|o| !shared.contains(&model.node(o.node).name))
323            .collect();
324        shift_pulse_axes(&mut target, "pulse.input_axes", &batched_inputs)?;
325        let batched_outputs = vec![true; model.output_outlets()?.len()];
326        shift_pulse_axes(&mut target, "pulse.output_axes", &batched_outputs)?;
327    }
328    Ok(target)
329}
330
331/// A source of the batchified model: a batched one carries the batch on axis 0,
332/// by gaining the axis or by having the one it already carries moved there, and
333/// the wire behind it moves it back so that the graph sees the layout it was
334/// built for.
335fn batched_source(
336    target: &mut TypedModel,
337    node: &TypedNode,
338    source: &TypedSource,
339    batch: &Symbol,
340    batched: bool,
341    join: bool,
342) -> TractResult<(OutletId, OutletId)> {
343    let carrier = carrier(&source.fact, batch);
344    if !batched || carrier == Some(0) {
345        let wire = target.wire_node(&node.name, source.clone(), &[])?[0];
346        return Ok((wire, wire));
347    }
348    if collapsed(&source.fact, batch, join) {
349        // The seats go where the collapse left its one, so the interface and the
350        // interior are the same wire, one axis wider.
351        let mut fact = source.fact.clone();
352        let mut shape = fact.shape.to_tvec();
353        shape[0] = batch.to_dim();
354        fact.shape = shape.into();
355        let wire = target.wire_node(&node.name, TypedSource::new(fact), &[])?[0];
356        return Ok((wire, wire));
357    }
358    let mut shape = source.fact.shape.to_tvec();
359    let mut restore = None;
360    match carrier {
361        Some(axis) => {
362            let dim = shape.remove(axis);
363            shape.insert(0, dim);
364            restore = Some(AxisOp::Move(0, axis));
365        }
366        None => shape.insert(0, batch.to_dim()),
367    }
368    let mut fact = source.fact.clone();
369    fact.shape = shape.into();
370    let wire = target.wire_node(&node.name, TypedSource::new(fact), &[])?[0];
371    match restore {
372        Some(restore) => {
373            let interior =
374                target.wire_node(format!("{}.batchify.move", node.name), restore, &[wire])?[0];
375            Ok((wire, interior))
376        }
377        None => Ok((wire, wire)),
378    }
379}
380
381/// Fails on a model the laned runtime would refuse anyway, naming what it found:
382/// a model answering nothing per seat has no turn to run.
383fn check(model: TypedModel, batch: &Symbol) -> TractResult<TypedModel> {
384    let batch = batch.to_dim();
385    let mut shared = vec![];
386    for (ix, outlet) in model.output_outlets()?.iter().enumerate() {
387        let fact = model.outlet_fact(*outlet)?;
388        if fact.shape.first() == Some(&batch) {
389            return Ok(model);
390        }
391        shared.push(format!("output {ix} is {:?}", fact.shape));
392    }
393    bail!("No output carries the batch axis on axis 0: {}", shared.join(", "))
394}
395
396register_model_transform!("batchify", BatchifyConfig, |config| Ok(Box::new(Batchify(config))));