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::change_axes::AxisOp;
36use crate::ops::einsum::EinSum;
37use crate::ops::source::TypedSource;
38use crate::transform::ModelTransform;
39
40#[derive(Debug, Default, serde::Deserialize)]
41pub struct BatchifyConfig {
42    /// Symbol sizing the batch axis. Defaults to "B".
43    pub symbol: Option<String>,
44    /// Inputs every seat shares, by node name.
45    #[serde(default)]
46    pub shared: Option<Vec<String>>,
47}
48
49#[derive(Debug)]
50pub struct Batchify(pub BatchifyConfig);
51
52impl ModelTransform for Batchify {
53    fn name(&self) -> StaticName {
54        "batchify".into()
55    }
56
57    fn transform(&self, model: &mut TypedModel) -> TractResult<()> {
58        let name = self.0.symbol.as_deref().unwrap_or("B");
59        let symbol = model.symbols.sym(name);
60        let shared = self.0.shared.as_deref().unwrap_or(&[]);
61        *model = batchify(model, &symbol, shared)?;
62        Ok(())
63    }
64}
65
66/// What an op does with the batch axis arriving on some of its inputs: the op to
67/// wire in its place, and the input slots that must read through an
68/// `AxisOp::Add(0)` to agree on rank with the batched ones.
69struct Hosted {
70    op: Option<Box<dyn TypedOp>>,
71    pad: TVec<usize>,
72}
73
74/// Batch axis on axis 0 of every input but the `shared` ones, sized by `batch`.
75pub fn batchify(model: &TypedModel, batch: &Symbol, shared: &[String]) -> TractResult<TypedModel> {
76    for name in shared {
77        ensure!(
78            model.input_outlets()?.iter().any(|o| &model.node(o.node).name == name),
79            "{name} is listed as a shared input but is not a model input"
80        );
81    }
82    let mut carried: HashSet<OutletId> = Default::default();
83    let mut hosts: HashMap<usize, Hosted> = Default::default();
84    for id in model.eval_order()? {
85        let node = model.node(id);
86        if let Some(source) = node.op_as::<TypedSource>() {
87            if !shared.contains(&node.name) && carrier(&source.fact, batch).is_none() {
88                carried.insert(id.into());
89            }
90            continue;
91        }
92        let batched: TVec<usize> = node
93            .inputs
94            .iter()
95            .enumerate()
96            .filter(|(_, input)| carried.contains(input))
97            .map(|(slot, _)| slot)
98            .collect();
99        if batched.is_empty() {
100            continue;
101        }
102        let hosted = host(model, node, &batched, &carried)
103            .with_context(|| format!("Giving {node} a batch axis"))?;
104        hosts.insert(id, hosted);
105        carried.extend((0..node.outputs.len()).map(|slot| OutletId::new(id, slot)));
106    }
107    let wired = wire(model, batch, shared, &hosts)?;
108    check(wired, batch)
109}
110
111/// The batch axis an input carries, if any: the first axis sized by an
112/// expression the batch symbol is in.
113fn carrier(fact: &TypedFact, batch: &Symbol) -> Option<usize> {
114    fact.shape.iter().position(|dim| dim.symbols().contains(batch))
115}
116
117/// Asks an op to host the batch axis on the inputs that carry it, and refuses an
118/// answer that does not carry it through to every output.
119fn host(
120    model: &TypedModel,
121    node: &TypedNode,
122    batched: &[usize],
123    carried: &HashSet<OutletId>,
124) -> TractResult<Hosted> {
125    if let Some(einsum) = node.op_as::<EinSum>() {
126        return batched_einsum(einsum, batched);
127    }
128    let change = AxisOp::Add(0);
129    let consequence = node
130        .op
131        .change_axes(model, node, InOut::In(batched[0]), &change)?
132        .context("the op takes no extra leading axis at all")?;
133    for slot in 0..node.outputs.len() {
134        ensure!(
135            consequence.wire_changes.contains(&(InOut::Out(slot), change.clone())),
136            "the op takes an extra leading axis on an input but does not carry it to output {slot}, \
137             so the seats would be folded into one another"
138        );
139    }
140    let pad = consequence
141        .wire_changes
142        .iter()
143        .filter_map(|(io, _)| match io {
144            InOut::In(slot) if !carried.contains(&node.inputs[*slot]) => Some(*slot),
145            _ => None,
146        })
147        .collect();
148    Ok(Hosted { op: consequence.substitute_op, pad })
149}
150
151/// An `EinSum` batched by one label of its own, on axis 0 of the batched inputs
152/// and of every output. Its `change_axes` would rather contract the axis away,
153/// which is the same thing only while the extent is one.
154fn batched_einsum(einsum: &EinSum, batched: &[usize]) -> TractResult<Hosted> {
155    let label = einsum.axes.available_label();
156    let mut axes = einsum.axes.clone().with_extra_axis(label, InOut::In(batched[0]), 0)?;
157    for slot in &batched[1..] {
158        axes = axes.with_extra_axis_occurency(label, InOut::In(*slot), 0)?;
159    }
160    for slot in 0..axes.output_count() {
161        axes = axes.with_extra_axis_occurency(label, InOut::Out(slot), 0)?;
162    }
163    let op = EinSum { axes, ..einsum.clone() };
164    Ok(Hosted { op: Some(Box::new(op)), pad: tvec!() })
165}
166
167/// The batchified model, rebuilt through its ops so that every fact comes from
168/// `output_facts` with the batch extent in place rather than from the facts the
169/// unbatched model stored.
170fn wire(
171    model: &TypedModel,
172    batch: &Symbol,
173    shared: &[String],
174    hosts: &HashMap<usize, Hosted>,
175) -> TractResult<TypedModel> {
176    let mut target = TypedModel { symbols: model.symbols.clone(), ..TypedModel::default() };
177    let mut mapping: HashMap<OutletId, OutletId> = Default::default();
178    let mut interface: HashMap<OutletId, OutletId> = Default::default();
179    for id in model.eval_order()? {
180        let node = model.node(id);
181        let wires = if let Some(source) = node.op_as::<TypedSource>() {
182            let (source, interior) =
183                batched_source(&mut target, node, source, batch, !shared.contains(&node.name))?;
184            interface.insert(id.into(), source);
185            tvec!(interior)
186        } else {
187            let hosted = hosts.get(&id);
188            let mut inputs: TVec<OutletId> =
189                node.inputs.iter().map(|input| mapping[input]).collect();
190            for slot in hosted.map(|h| &*h.pad).unwrap_or(&[]) {
191                inputs[*slot] = target.wire_node(
192                    format!("{}.batchify.rank.{slot}", node.name),
193                    AxisOp::Add(0),
194                    &[inputs[*slot]],
195                )?[0];
196            }
197            let op = hosted.and_then(|h| h.op.clone()).unwrap_or_else(|| node.op.clone());
198            target
199                .wire_node(&node.name, op, &inputs)
200                .with_context(|| format!("Wiring {node} with a batch axis"))?
201        };
202        for (slot, wire) in wires.into_iter().enumerate() {
203            let outlet = OutletId::new(id, slot);
204            if let Some(label) = model.outlet_label(outlet) {
205                target.set_outlet_label(wire, label.to_string())?;
206            }
207            mapping.insert(outlet, wire);
208        }
209    }
210    let inputs: Vec<OutletId> = model.input_outlets()?.iter().map(|i| interface[i]).collect();
211    let mut outputs: Vec<OutletId> = model.output_outlets()?.iter().map(|o| mapping[o]).collect();
212    for (ix, output) in outputs.iter_mut().enumerate() {
213        let fact = target.outlet_fact(*output)?.clone();
214        if let Some(axis) = carrier(&fact, batch).filter(|axis| *axis > 0) {
215            let label = target.outlet_label(*output).map(|l| l.to_string());
216            *output = target.wire_node(
217                format!("batchify.move.output.{ix}"),
218                AxisOp::Move(axis, 0),
219                &[*output],
220            )?[0];
221            if let Some(label) = label {
222                target.set_outlet_label(*output, label)?;
223            }
224        }
225    }
226    target.set_input_outlets(&inputs)?;
227    target.select_output_outlets(&outputs)?;
228    Ok(target)
229}
230
231/// A source of the batchified model: a batched one carries the batch on axis 0,
232/// by gaining the axis or by having the one it already carries moved there, and
233/// the wire behind it moves it back so that the graph sees the layout it was
234/// built for.
235fn batched_source(
236    target: &mut TypedModel,
237    node: &TypedNode,
238    source: &TypedSource,
239    batch: &Symbol,
240    batched: bool,
241) -> TractResult<(OutletId, OutletId)> {
242    let carrier = carrier(&source.fact, batch);
243    if !batched || carrier == Some(0) {
244        let wire = target.wire_node(&node.name, source.clone(), &[])?[0];
245        return Ok((wire, wire));
246    }
247    let mut shape = source.fact.shape.to_tvec();
248    let mut restore = None;
249    match carrier {
250        Some(axis) => {
251            let dim = shape.remove(axis);
252            shape.insert(0, dim);
253            restore = Some(AxisOp::Move(0, axis));
254        }
255        None => shape.insert(0, batch.to_dim()),
256    }
257    let mut fact = source.fact.clone();
258    fact.shape = shape.into();
259    let wire = target.wire_node(&node.name, TypedSource::new(fact), &[])?[0];
260    match restore {
261        Some(restore) => {
262            let interior =
263                target.wire_node(format!("{}.batchify.move", node.name), restore, &[wire])?[0];
264            Ok((wire, interior))
265        }
266        None => Ok((wire, wire)),
267    }
268}
269
270/// Fails on a model the laned runtime would refuse anyway, naming what it found:
271/// a model answering nothing per seat has no turn to run.
272fn check(model: TypedModel, batch: &Symbol) -> TractResult<TypedModel> {
273    let batch = batch.to_dim();
274    let mut shared = vec![];
275    for (ix, outlet) in model.output_outlets()?.iter().enumerate() {
276        let fact = model.outlet_fact(*outlet)?;
277        if fact.shape.first() == Some(&batch) {
278            return Ok(model);
279        }
280        shared.push(format!("output {ix} is {:?}", fact.shape));
281    }
282    bail!("No output carries the batch axis on axis 0: {}", shared.join(", "))
283}
284
285register_model_transform!("batchify", BatchifyConfig, |config| Ok(Box::new(Batchify(config))));