Skip to main content

nuvec_compile/
lib.rs

1//! Function compilation: tracing, graph simplification and kernel fusion.
2//!
3//! [`compile`] works as MLX's `mx.compile`. The first call with a given input structure and
4//! signature traces the function on placeholders and simplifies the graph:
5//!
6//! - identity views and same-type conversions are removed (explicit copies and
7//!   dependencies, `Primitive::Copy` and `Primitive::Depends`, are kept);
8//! - equal scalar constants are merged;
9//! - equal operations on equal inputs are computed once (common-subexpression
10//!   elimination).
11//!
12//! On the GPU, chains of element-wise operations are then fused into single generated
13//! kernels. Later calls replay the simplified graph on the new inputs without running the
14//! function again.
15//!
16//! ```
17//! use nuvec_compile::compile;
18//! use nuvec_core::{Array, Result, array};
19//!
20//! # fn main() -> Result<()> {
21//! // x * x + 1, as one kernel on the GPU
22//! let f = compile(|x: &Array| x.multiply(x).add(1.0));
23//! assert_eq!(f(&array![1.0, 2.0])?.to_vec::<f32>()?, [2.0, 5.0]);
24//! assert_eq!(f(&array![3.0, 4.0])?.to_vec::<f32>()?, [10.0, 17.0]); // replayed
25//! # Ok(())
26//! # }
27//! ```
28//!
29//! [`compile_mut`] compiles a stateful step (MLX's `mx.compile(step, inputs=state,
30//! outputs=state)`): a training step over `(model, optimizer)` whose leaves are inputs and
31//! outputs of the tape.
32//!
33//! **Stability:** no promise when this crate is depended on directly; depend on `nuvec`,
34//! whose re-exports are semver-tracked.
35//!
36//! **Status:** pre-alpha.
37
38#![forbid(unsafe_code)]
39
40use std::cell::Cell;
41use std::collections::{BinaryHeap, HashMap, HashSet};
42use std::sync::{Arc, Mutex, PoisonError};
43
44use nuvec_core::graph::{self, Fused, FusedRef, Primitive};
45use nuvec_core::tree::{Tree, TreeDef};
46use nuvec_core::{
47    Array, Complex64, DType, DeviceKind, Error, IntoResult, Result, Stream, bf16, default_stream,
48    f16, random,
49};
50
51/// Dimension sizes as `usize`.
52fn dims(a: &Array) -> Vec<usize> {
53    a.shape().iter().map(|&d| d as usize).collect()
54}
55
56/// The version of this crate.
57pub const VERSION: &str = env!("CARGO_PKG_VERSION");
58
59/// The most operations fused into one kernel.
60const MAX_FUSED_STEPS: usize = 32;
61/// The most inputs and outputs of one fused kernel (Metal binds at most 31 buffers per
62/// kernel; a fused kernel uses five more), as MLX's `max_compile_arrays`.
63const MAX_FUSED_ARRAYS: usize = 24;
64
65/// Options of [`compile`] and [`compile_mut`]: `Options::default().cache_capacity(8)`.
66#[derive(Clone, Debug)]
67#[non_exhaustive]
68pub struct Options {
69    /// The most traced signatures a compiled function keeps (least recently used first
70    /// out); 0 never caches. Default 64.
71    pub cache_capacity: usize,
72}
73
74impl Default for Options {
75    fn default() -> Self {
76        Self { cache_capacity: 64 }
77    }
78}
79
80impl Options {
81    /// Sets the cache capacity.
82    pub fn cache_capacity(mut self, n: usize) -> Self {
83        self.cache_capacity = n;
84        self
85    }
86
87    /// [`compile`] with these options.
88    pub fn compile<'f, I, O, F, R>(self, f: F) -> impl Fn(&I) -> Result<O> + 'f
89    where
90        I: Tree,
91        O: Tree,
92        F: Fn(&I) -> R + 'f,
93        R: IntoResult<Ok = O>,
94    {
95        let cache: Mutex<Cache<(Tape, O)>> = Mutex::new(Cache::new(self.cache_capacity));
96        move |inputs: &I| {
97            let leaves = inputs.leaves();
98            if leaves.iter().any(|a| graph::is_tracer(a) || a.is_failed()) {
99                // Inside another transform, or on failed inputs (whose failure the direct
100                // call carries to the outputs): no trace.
101                return f(inputs).into_result();
102            }
103            let key = Key::new(vec![inputs.def()], &leaves);
104            let cached = lock(&cache).get(&key);
105            let entry = match cached {
106                Some(entry) => entry,
107                None => {
108                    let mut out = None;
109                    let mut body = |xs: &[Array]| -> Result<Vec<Array>> {
110                        let o = f(&inputs.unflatten(xs.iter().cloned())?).into_result()?;
111                        let leaves = o.leaves();
112                        out = Some(o.map(|_, a| light(a)));
113                        Ok(leaves)
114                    };
115                    let tape = Tape::trace(&mut body, &leaves, key.stream)?;
116                    let out =
117                        out.ok_or_else(|| Error::InvalidArgument("compile: no output".into()))?;
118                    let entry = Arc::new((tape, out));
119                    if !entry.0.captures_tracer() {
120                        lock(&cache).insert(key, entry.clone());
121                    }
122                    entry
123                }
124            };
125            let (tape, template) = &*entry;
126            template.unflatten(tape.replay(&leaves)?)
127        }
128    }
129
130    /// [`compile_mut`] with these options.
131    pub fn compile_mut<'f, S, I, O, F, R>(self, f: F) -> impl Fn(&mut S, &I) -> Result<O> + 'f
132    where
133        S: Tree,
134        I: Tree,
135        O: Tree,
136        F: Fn(&mut S, &I) -> R + 'f,
137        R: IntoResult<Ok = O>,
138    {
139        let cache: Mutex<Cache<MutTape<S, O>>> = Mutex::new(Cache::new(self.cache_capacity));
140        let changed_last = Cell::new(false);
141        move |state: &mut S, inputs: &I| {
142            let state_leaves = state.leaves();
143            let input_leaves = inputs.leaves();
144            let direct = state_leaves
145                .iter()
146                .chain(&input_leaves)
147                .any(|a| graph::is_tracer(a) || a.is_failed());
148            if direct {
149                // Inside another transform (or on failed arrays): run `f` itself.
150                return f(state, inputs).into_result();
151            }
152            let key0 = random::sequence_key();
153            let pre_def = state.def();
154            let mut all = Vec::with_capacity(1 + state_leaves.len() + input_leaves.len());
155            all.push(key0.clone());
156            all.extend(state_leaves.iter().cloned());
157            all.extend(input_leaves.iter().cloned());
158            let key = Key::new(vec![pre_def.clone(), inputs.def()], &all);
159            let cached = lock(&cache).get(&key);
160            let entry = match cached {
161                Some(entry) => entry,
162                None => {
163                    let entry = Arc::new(MutTape::trace(&f, state, inputs, &all, key.stream)?);
164                    if !entry.tape.captures_tracer() {
165                        lock(&cache).insert(key, entry.clone());
166                    }
167                    entry
168                }
169            };
170            let changed = entry.post_def != pre_def;
171            if changed && changed_last.get() {
172                return Err(Error::InvalidArgument(
173                    "compile_mut: state structure keeps changing; call opt.init(&model) before \
174                     the loop, or make changing host fields arrays (or `static_unchecked`)"
175                        .into(),
176                ));
177            }
178            let mut outputs = entry.tape.replay(&all)?;
179            let next_key = outputs.pop().expect("the key is the last output");
180            let state_out = outputs.split_off(entry.outputs);
181            let out = entry.out.unflatten(outputs)?;
182            let next = entry.post.unflatten(state_out)?;
183            changed_last.set(changed);
184            *state = next;
185            random::set_sequence_key(next_key);
186            Ok(out)
187        }
188    }
189}
190
191/// Returns `f` compiled: a function with the same results that traces `f` once per input
192/// structure (the [`TreeDef`]: paths, leaf kinds, trainable bits, enum variants, mode flags
193/// and static fields), leaf dtypes and shapes, and default stream, and replays the
194/// simplified, fused graph on later calls.
195///
196/// `f` takes a tree (an array, a tuple, a struct) and returns one, directly or as a
197/// `Result`; it may borrow locals (the cache stores tapes, not `f`). Write the closure's
198/// parameter type (`|x: &Array|`).
199///
200/// As in MLX, `f` must be pure:
201///
202/// - arrays it captures are constants, frozen at the first trace (so is random state
203///   drawn inside it; [`compile_mut`] threads the random key instead);
204/// - arrays cannot be evaluated inside it.
205///
206/// Called on arrays being traced by a transform (`grad`, `vmap`, another `compile`), or on
207/// failed arrays, it runs `f` directly, so transforms see the original operations and
208/// failures stay sticky.
209///
210/// Each compiled function keeps the tapes of its 64 most recently used signatures
211/// ([`Options::cache_capacity`]). A trace whose constants include an array being traced by
212/// a running transform (a tracer `f` captures) is used once and not kept.
213pub fn compile<'f, I, O, F, R>(f: F) -> impl Fn(&I) -> Result<O> + 'f
214where
215    I: Tree,
216    O: Tree,
217    F: Fn(&I) -> R + 'f,
218    R: IntoResult<Ok = O>,
219{
220    Options::default().compile(f)
221}
222
223/// Compiles a stateful step, as MLX's `mx.compile(step, inputs=state, outputs=state)`:
224/// `f(&mut state, &inputs)` where `state` is a tree (typically `(model, optimizer)`).
225///
226/// ```
227/// use nuvec_compile::compile_mut;
228/// use nuvec_core::{Array, Result, array, value_and_grad};
229///
230/// # fn main() -> Result<()> {
231/// // Gradient descent on w for loss(w) = sum((w x - y)^2), compiled.
232/// let step = compile_mut(|w: &mut Array, (x, y): &(Array, Array)| {
233///     let loss = |w: &Array| {
234///         let r = w.multiply(x).subtract(y);
235///         r.multiply(&r).sum(..)
236///     };
237///     let (l, g) = value_and_grad(loss)(w)?;
238///     *w = w.subtract(g.multiply(0.1));
239///     Ok(l)
240/// });
241/// let mut w = array![0.0];
242/// for _ in 0..50 {
243///     step(&mut w, &(array![1.0], array![2.0]))?;
244/// }
245/// assert!((w.item::<f32>()? - 2.0).abs() < 1e-3);
246/// # Ok(())
247/// # }
248/// ```
249///
250/// - The state's leaves (and the thread's random key) are traced inputs; after the call the
251///   state is rebuilt from the tape's outputs. So the step traces once, an optimizer's
252///   learning-rate array set between calls is just a new input, and random draws inside
253///   (dropout) advance the thread's key sequence exactly as an uncompiled call would.
254/// - The tape is keyed by the state's and the inputs' structure: switching train/eval,
255///   freezing, changing a hashed hyperparameter retraces once.
256/// - A step that changes the state's structure (an optimizer creating its slots at the
257///   first update) retraces once; a structure that changes on two consecutive calls is an
258///   error (call `opt.init(&model)` first). Host fields mutated in `f` are hashed statics,
259///   so mutating one counts as a structure change; `#[tree(skip, static_unchecked)]` fields
260///   are baked into the trace.
261/// - On error the state is unchanged. Called inside another transform, it runs `f`
262///   directly.
263pub fn compile_mut<'f, S, I, O, F, R>(f: F) -> impl Fn(&mut S, &I) -> Result<O> + 'f
264where
265    S: Tree,
266    I: Tree,
267    O: Tree,
268    F: Fn(&mut S, &I) -> R + 'f,
269    R: IntoResult<Ok = O>,
270{
271    Options::default().compile_mut(f)
272}
273
274/// [`compile`] over positional arrays: traces once per input signature (dtypes and
275/// shapes) and default stream.
276///
277/// ```
278/// use nuvec_compile::compile_flat;
279/// use nuvec_core::{Array, Result, array};
280///
281/// # fn main() -> Result<()> {
282/// let f = compile_flat(|xs: &[Array]| Ok(vec![xs[0].multiply(&xs[0]).add(1.0)]));
283/// assert_eq!(f(&[array![1.0, 2.0]])?[0].to_vec::<f32>()?, [2.0, 5.0]);
284/// # Ok(())
285/// # }
286/// ```
287pub fn compile_flat<F>(f: F) -> impl Fn(&[Array]) -> Result<Vec<Array>>
288where
289    F: Fn(&[Array]) -> Result<Vec<Array>>,
290{
291    let cache: Mutex<Cache<Tape>> = Mutex::new(Cache::new(Options::default().cache_capacity));
292    move |inputs| {
293        if inputs.iter().any(|a| graph::is_tracer(a) || a.is_failed()) {
294            return f(inputs);
295        }
296        let key = Key::new(Vec::new(), inputs);
297        let cached = lock(&cache).get(&key);
298        let tape = match cached {
299            Some(tape) => tape,
300            None => {
301                let tape = Arc::new(Tape::trace(&mut |xs: &[Array]| f(xs), inputs, key.stream)?);
302                if !tape.captures_tracer() {
303                    lock(&cache).insert(key, tape.clone());
304                }
305                tape
306            }
307        };
308        tape.replay(inputs)
309    }
310}
311
312fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
313    m.lock().unwrap_or_else(PoisonError::into_inner)
314}
315
316/// A placeholder like `a` (a template leaf: only its dtype and shape matter).
317fn light(a: &Array) -> Array {
318    graph::placeholder(a.dtype(), a.shape()).unwrap_or_else(|_| a.clone())
319}
320
321/// A [`compile_mut`] trace: the tape (outputs: `f`'s, then the post-call state's, then the
322/// advanced random key), the output and post-state templates, and the post-call structure.
323struct MutTape<S, O> {
324    tape: Tape,
325    outputs: usize,
326    out: O,
327    post: S,
328    post_def: TreeDef,
329}
330
331impl<S: Tree, O: Tree> MutTape<S, O> {
332    /// Traces `f` with the thread's random key (`all[0]`), the state's leaves and the
333    /// inputs' as placeholders; `state` itself is not changed.
334    fn trace<I: Tree, R: IntoResult<Ok = O>>(
335        f: &dyn Fn(&mut S, &I) -> R,
336        state: &S,
337        inputs: &I,
338        all: &[Array],
339        stream: Stream,
340    ) -> Result<Self> {
341        let ns = state.len();
342        let key0 = all[0].clone();
343        let mut traced: Option<(O, S)> = None;
344        let mut body = |xs: &[Array]| -> Result<Vec<Array>> {
345            random::set_sequence_key(xs[0].clone());
346            let mut s = state.unflatten(xs[1..1 + ns].iter().cloned())?;
347            let i = inputs.unflatten(xs[1 + ns..].iter().cloned())?;
348            let out = f(&mut s, &i).into_result();
349            let key = random::sequence_key();
350            random::set_sequence_key(key0.clone());
351            let out = out?;
352            let mut leaves = out.leaves();
353            leaves.extend(s.leaves());
354            leaves.push(key);
355            traced = Some((out, s));
356            Ok(leaves)
357        };
358        let result = Tape::trace(&mut body, all, stream);
359        random::set_sequence_key(key0.clone());
360        let tape = result?;
361        let (out, post) =
362            traced.ok_or_else(|| Error::InvalidArgument("compile_mut: no output".into()))?;
363        Ok(Self {
364            tape,
365            outputs: out.len(),
366            out: out.map(|_, a| light(a)),
367            post_def: post.def(),
368            post: post.map(|_, a| light(a)),
369        })
370    }
371}
372
373/// A compiled function's tapes by key, least recently used evicted first.
374struct Cache<T> {
375    tapes: HashMap<Key, (Arc<T>, u64)>,
376    /// Incremented on every use; a tape's stamp is its last use.
377    clock: u64,
378    capacity: usize,
379}
380
381impl<T> Cache<T> {
382    fn new(capacity: usize) -> Self {
383        Self {
384            tapes: HashMap::new(),
385            clock: 0,
386            capacity,
387        }
388    }
389
390    fn get(&mut self, key: &Key) -> Option<Arc<T>> {
391        self.clock += 1;
392        let (tape, used) = self.tapes.get_mut(key)?;
393        *used = self.clock;
394        Some(tape.clone())
395    }
396
397    fn insert(&mut self, key: Key, tape: Arc<T>) {
398        if self.capacity == 0 {
399            return;
400        }
401        self.clock += 1;
402        if self.tapes.len() >= self.capacity
403            && !self.tapes.contains_key(&key)
404            && let Some(oldest) = self
405                .tapes
406                .iter()
407                .min_by_key(|(_, (_, used))| *used)
408                .map(|(k, _)| k.clone())
409        {
410            self.tapes.remove(&oldest);
411        }
412        self.tapes.insert(key, (tape, self.clock));
413    }
414}
415
416/// What a traced graph depends on: the inputs' structures, dtypes and shapes and the
417/// stream.
418#[derive(Debug, Clone, PartialEq, Eq, Hash)]
419struct Key {
420    stream: Stream,
421    defs: Vec<TreeDef>,
422    inputs: Vec<(DType, Vec<usize>)>,
423}
424
425impl Key {
426    fn new(defs: Vec<TreeDef>, inputs: &[Array]) -> Self {
427        Self {
428            stream: default_stream(),
429            defs,
430            inputs: inputs.iter().map(|a| (a.dtype(), dims(a))).collect(),
431        }
432    }
433}
434
435/// A value of the traced graph.
436#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
437enum Ref {
438    /// The input at this position.
439    Input(usize),
440    /// A constant (captured or independent of the inputs).
441    Const(usize),
442    /// Output `.1` of step `.0`.
443    Step(usize, usize),
444}
445
446#[derive(Debug, Clone)]
447struct Step {
448    primitive: Primitive,
449    inputs: Vec<Ref>,
450    /// Dtype and shape of each output.
451    outputs: Vec<(DType, Vec<usize>)>,
452}
453
454/// A simplified graph, replayed on each call.
455#[derive(Debug)]
456struct Tape {
457    consts: Vec<Array>,
458    steps: Vec<Step>,
459    outputs: Vec<Ref>,
460}
461
462impl Tape {
463    /// Traces `f` on placeholders like `inputs`, simplifies the graph and, on the GPU,
464    /// fuses element-wise chains.
465    fn trace(
466        f: &mut dyn FnMut(&[Array]) -> Result<Vec<Array>>,
467        inputs: &[Array],
468        stream: Stream,
469    ) -> Result<Self> {
470        let placeholders = inputs
471            .iter()
472            .map(|a| graph::placeholder(a.dtype(), a.shape()))
473            .collect::<Result<Vec<_>>>()?;
474        let outputs = f(&placeholders)?;
475        let mut tape = Builder::new(&placeholders).build(&outputs);
476        if stream.device.kind == DeviceKind::Gpu {
477            tape.fuse(inputs);
478        }
479        Ok(tape)
480    }
481
482    /// Whether a constant is an array being traced by a running transform (a tracer `f`
483    /// captured): such a tape is used once and not cached.
484    fn captures_tracer(&self) -> bool {
485        self.consts.iter().any(graph::is_tracer)
486    }
487
488    fn replay(&self, inputs: &[Array]) -> Result<Vec<Array>> {
489        let mut values: Vec<Vec<Array>> = Vec::with_capacity(self.steps.len());
490        let get = |r: &Ref, values: &[Vec<Array>]| match *r {
491            Ref::Input(i) => inputs[i].clone(),
492            Ref::Const(c) => self.consts[c].clone(),
493            Ref::Step(s, o) => values[s][o].clone(),
494        };
495        for step in &self.steps {
496            let args = step.inputs.iter().map(|r| get(r, &values)).collect();
497            values.push(graph::from_primitive(step.primitive.clone(), args)?);
498        }
499        Ok(self.outputs.iter().map(|r| get(r, &values)).collect())
500    }
501
502    fn dtype(&self, r: Ref, inputs: &[Array]) -> DType {
503        match r {
504            Ref::Input(i) => inputs[i].dtype(),
505            Ref::Const(c) => self.consts[c].dtype(),
506            Ref::Step(s, o) => self.steps[s].outputs[o].0,
507        }
508    }
509
510    /// Replaces chains of element-wise steps by fused steps (MLX's `compile_fuse`). Walking
511    /// back from a group's last step, an element-wise step joins the group when every step
512    /// that consumes it is in the group and the kernel limits hold. A joining step that is
513    /// also an output of the function becomes another output of the fused step. So nothing
514    /// outside a group consumes its inner values, and the graph stays acyclic.
515    fn fuse(&mut self, inputs: &[Array]) {
516        let n = self.steps.len();
517        let fusable: Vec<bool> = self
518            .steps
519            .iter()
520            .map(|s| {
521                let dtypes: Vec<DType> = s.inputs.iter().map(|&r| self.dtype(r, inputs)).collect();
522                s.outputs.len() == 1 && Fused::supports(&s.primitive, &dtypes)
523            })
524            .collect();
525        let mut consumers: Vec<Vec<usize>> = vec![Vec::new(); n];
526        for (k, s) in self.steps.iter().enumerate() {
527            for r in &s.inputs {
528                if let Ref::Step(j, _) = *r {
529                    consumers[j].push(k);
530                }
531            }
532        }
533        let mut is_output = vec![false; n];
534        for r in &self.outputs {
535            if let Ref::Step(j, _) = *r {
536                is_output[j] = true;
537            }
538        }
539        let step_inputs = |k: usize| {
540            self.steps[k].inputs.iter().filter_map(|r| match *r {
541                Ref::Step(j, _) => Some(j),
542                _ => None,
543            })
544        };
545        // Per step: its group's root (last step), if fused.
546        let mut group: Vec<Option<usize>> = vec![None; n];
547        // Per root: the members (ascending) and the external inputs.
548        let mut groups: HashMap<usize, (Vec<usize>, Vec<Ref>)> = HashMap::new();
549        for root in (0..n).rev() {
550            if group[root].is_some() || !fusable[root] {
551                continue;
552            }
553            let mut members = HashSet::from([root]);
554            let mut external: Vec<Ref> = Vec::new();
555            let mut outputs = 1;
556            let add_inputs = |k: usize, external: &mut Vec<Ref>| {
557                for &r in &self.steps[k].inputs {
558                    if !external.contains(&r) {
559                        external.push(r);
560                    }
561                }
562            };
563            add_inputs(root, &mut external);
564            // Highest first: a step is decided after every step that could consume it.
565            let mut candidates: BinaryHeap<usize> = step_inputs(root).collect();
566            let mut seen = HashSet::new();
567            while let Some(j) = candidates.pop() {
568                if !seen.insert(j)
569                    || group[j].is_some()
570                    || !fusable[j]
571                    || members.len() >= MAX_FUSED_STEPS
572                    || !consumers[j].iter().all(|c| members.contains(c))
573                {
574                    continue;
575                }
576                let new = self.steps[j]
577                    .inputs
578                    .iter()
579                    .filter(|r| !external.contains(r))
580                    .collect::<HashSet<_>>()
581                    .len();
582                let arrays = external.len() - 1 + new + outputs + usize::from(is_output[j]);
583                if arrays > MAX_FUSED_ARRAYS {
584                    continue;
585                }
586                members.insert(j);
587                outputs += usize::from(is_output[j]);
588                external.retain(|&r| r != Ref::Step(j, 0));
589                add_inputs(j, &mut external);
590                candidates.extend(step_inputs(j));
591            }
592            if members.len() < 2 || external.len() + outputs > MAX_FUSED_ARRAYS {
593                continue;
594            }
595            let mut members: Vec<usize> = members.into_iter().collect();
596            members.sort_unstable();
597            for &m in &members {
598                group[m] = Some(root);
599            }
600            groups.insert(root, (members, external));
601        }
602        if groups.is_empty() {
603            return;
604        }
605        // Rebuild: each group becomes one step at its root's position. `index` maps a
606        // kept or fused step's outputs to the new step (and the output offset in it).
607        let mut index: Vec<Option<(usize, usize)>> = vec![None; n];
608        let remap = |r: Ref, index: &[Option<(usize, usize)>]| match r {
609            Ref::Step(j, o) => {
610                let (k, offset) = index[j].expect("defined before use");
611                Ref::Step(k, offset + o)
612            }
613            r => r,
614        };
615        let mut steps = Vec::with_capacity(n);
616        for k in 0..n {
617            let step = match (group[k], groups.get(&k)) {
618                (Some(_), None) => continue, // inside a group: defined with its root
619                (Some(_), Some((members, external))) => {
620                    let position: HashMap<usize, usize> =
621                        members.iter().enumerate().map(|(p, &m)| (m, p)).collect();
622                    let graph = members
623                        .iter()
624                        .map(|&m| {
625                            let args = self.steps[m]
626                                .inputs
627                                .iter()
628                                .map(|r| match *r {
629                                    Ref::Step(j, _) if position.contains_key(&j) => {
630                                        FusedRef::Step(position[&j])
631                                    }
632                                    r => FusedRef::Input(
633                                        external.iter().position(|&e| e == r).expect("external"),
634                                    ),
635                                })
636                                .collect();
637                            (self.steps[m].primitive.clone(), args)
638                        })
639                        .collect();
640                    // Outputs: the members the function returns, then the root.
641                    let outs: Vec<usize> = members
642                        .iter()
643                        .copied()
644                        .filter(|&m| m != k && is_output[m])
645                        .chain([k])
646                        .collect();
647                    for (o, &m) in outs.iter().enumerate() {
648                        index[m] = Some((steps.len(), o));
649                    }
650                    let dtypes: Vec<DType> =
651                        external.iter().map(|&r| self.dtype(r, inputs)).collect();
652                    let positions: Vec<usize> = outs.iter().map(|m| position[m]).collect();
653                    let Ok(f) = Fused::new(&dtypes, graph, &positions) else {
654                        unreachable!("every member is fusable")
655                    };
656                    steps.push(Step {
657                        primitive: Primitive::Compiled(f),
658                        inputs: external.iter().map(|&r| remap(r, &index)).collect(),
659                        outputs: outs
660                            .iter()
661                            .map(|&m| self.steps[m].outputs[0].clone())
662                            .collect(),
663                    });
664                    continue;
665                }
666                (None, _) => {
667                    let s = &self.steps[k];
668                    Step {
669                        primitive: s.primitive.clone(),
670                        inputs: s.inputs.iter().map(|&r| remap(r, &index)).collect(),
671                        outputs: s.outputs.clone(),
672                    }
673                }
674            };
675            index[k] = Some((steps.len(), 0));
676            steps.push(step);
677        }
678        self.outputs = self.outputs.iter().map(|&r| remap(r, &index)).collect();
679        self.steps = steps;
680    }
681}
682
683/// Builds a tape from a traced graph, simplifying as it goes.
684struct Builder {
685    /// Resolved arrays, by operation id and output index.
686    refs: HashMap<(usize, usize), Ref>,
687    consts: Vec<Array>,
688    /// Constant scalars by value, for merging.
689    scalars: HashMap<(DType, Vec<usize>, u64), usize>,
690    steps: Vec<Step>,
691    /// Steps by primitive and inputs, for common-subexpression elimination.
692    seen: HashMap<(Primitive, Vec<Ref>), usize>,
693}
694
695impl Builder {
696    fn new(placeholders: &[Array]) -> Self {
697        let refs = placeholders
698            .iter()
699            .enumerate()
700            .map(|(i, p)| ((graph::id(p), graph::output_index(p)), Ref::Input(i)))
701            .collect();
702        Self {
703            refs,
704            consts: Vec::new(),
705            scalars: HashMap::new(),
706            steps: Vec::new(),
707            seen: HashMap::new(),
708        }
709    }
710
711    fn key(a: &Array) -> (usize, usize) {
712        (graph::id(a), graph::output_index(a))
713    }
714
715    /// Visits the graph of `outputs` inputs first (iteratively, so depth is limited only
716    /// by memory) and returns the tape.
717    fn build(mut self, outputs: &[Array]) -> Tape {
718        let mut stack: Vec<(Array, bool)> = outputs.iter().map(|a| (a.clone(), false)).collect();
719        while let Some((a, expanded)) = stack.pop() {
720            if self.refs.contains_key(&Self::key(&a)) {
721                continue;
722            }
723            let inputs = graph::inputs(&a);
724            if expanded || inputs.is_empty() {
725                self.resolve(&a, &inputs);
726                continue;
727            }
728            stack.push((a, true));
729            stack.extend(
730                inputs
731                    .into_iter()
732                    .filter(|i| !self.refs.contains_key(&Self::key(i)))
733                    .map(|i| (i, false)),
734            );
735        }
736        let outputs = outputs.iter().map(|a| self.refs[&Self::key(a)]).collect();
737        Tape {
738            consts: self.consts,
739            steps: self.steps,
740            outputs,
741        }
742    }
743
744    /// Records the operation producing `a` (whose inputs are resolved): a constant if it
745    /// does not depend on an input, an existing value if it is an identity or computed
746    /// already, else a new step.
747    fn resolve(&mut self, a: &Array, inputs: &[Array]) {
748        let siblings = graph::siblings(a);
749        let args: Vec<Ref> = inputs.iter().map(|i| self.refs[&Self::key(i)]).collect();
750        let primitive = graph::primitive(a);
751        let Some(primitive) =
752            primitive.filter(|_| !args.iter().all(|r| matches!(r, Ref::Const(_))))
753        else {
754            for s in &siblings {
755                let c = self.constant(s);
756                self.refs.insert(Self::key(s), Ref::Const(c));
757            }
758            return;
759        };
760        if siblings.len() == 1 && is_identity(&primitive, &inputs[0], a) {
761            self.refs.insert(Self::key(a), args[0]);
762            return;
763        }
764        let k = match self.seen.get(&(primitive.clone(), args.clone())) {
765            Some(&k) => k,
766            None => {
767                self.steps.push(Step {
768                    primitive: primitive.clone(),
769                    inputs: args.clone(),
770                    outputs: siblings.iter().map(|s| (s.dtype(), dims(s))).collect(),
771                });
772                let k = self.steps.len() - 1;
773                self.seen.insert((primitive, args), k);
774                k
775            }
776        };
777        for (o, s) in siblings.iter().enumerate() {
778            self.refs.insert(Self::key(s), Ref::Step(k, o));
779        }
780    }
781
782    /// The constant index of `a`, shared with an equal scalar constant if there is one. A
783    /// tracer (an array an outer transform differentiates, captured by `f`) is never merged
784    /// with an equal value: the outer transform must see it.
785    fn constant(&mut self, a: &Array) -> usize {
786        if graph::is_tracer(a) {
787            self.consts.push(a.clone());
788            return self.consts.len() - 1;
789        }
790        let value = scalar_bits(a).or_else(|| {
791            // A broadcast scalar, as the operations make for scalar operands.
792            match (graph::primitive(a), graph::inputs(a).as_slice()) {
793                (Some(Primitive::Broadcast(_)), [x]) => scalar_bits(x),
794                _ => None,
795            }
796        });
797        let key = value.map(|v| (a.dtype(), dims(a), v));
798        if let Some(&c) = key.as_ref().and_then(|k| self.scalars.get(k)) {
799            return c;
800        }
801        self.consts.push(a.clone());
802        let c = self.consts.len() - 1;
803        if let Some(key) = key {
804            self.scalars.insert(key, c);
805        }
806        c
807    }
808}
809
810/// Whether `primitive` maps `input` to `output` unchanged.
811fn is_identity(primitive: &Primitive, input: &Array, output: &Array) -> bool {
812    let same = input.shape() == output.shape() && graph::strides(input) == graph::strides(output);
813    match primitive {
814        Primitive::Broadcast(_) | Primitive::Reshape(_) | Primitive::StopGradient => same,
815        Primitive::Transpose(axes) => axes.iter().enumerate().all(|(i, &a)| i == a),
816        Primitive::AsType(d) => *d == input.dtype(),
817        _ => false,
818    }
819}
820
821/// The bits of an evaluated one-element array's value.
822fn scalar_bits(a: &Array) -> Option<u64> {
823    if a.size() != 1 || !a.is_evaluated() {
824        return None;
825    }
826    Some(match a.dtype() {
827        DType::Bool => u64::from(a.item::<bool>().ok()?),
828        DType::U8 => u64::from(a.item::<u8>().ok()?),
829        DType::U16 => u64::from(a.item::<u16>().ok()?),
830        DType::U32 => u64::from(a.item::<u32>().ok()?),
831        DType::U64 => a.item::<u64>().ok()?,
832        DType::I8 => a.item::<i8>().ok()? as u64,
833        DType::I16 => a.item::<i16>().ok()? as u64,
834        DType::I32 => a.item::<i32>().ok()? as u64,
835        DType::I64 => a.item::<i64>().ok()? as u64,
836        DType::F16 => u64::from(a.item::<f16>().ok()?.to_bits()),
837        DType::BF16 => u64::from(a.item::<bf16>().ok()?.to_bits()),
838        DType::F32 => u64::from(a.item::<f32>().ok()?.to_bits()),
839        DType::F64 => a.item::<f64>().ok()?.to_bits(),
840        DType::Complex64 => {
841            let z = a.item::<Complex64>().ok()?;
842            u64::from(z.re.to_bits()) << 32 | u64::from(z.im.to_bits())
843        }
844        _ => return None,
845    })
846}
847
848#[cfg(test)]
849mod tests {
850    use nuvec_core::{Device, array, set_default_stream};
851
852    use super::*;
853
854    fn trace(f: &dyn Fn(&[Array]) -> Result<Vec<Array>>, inputs: &[Array], device: Device) -> Tape {
855        Tape::trace(
856            &mut |xs: &[Array]| f(xs),
857            inputs,
858            Stream::default_for(device),
859        )
860        .unwrap()
861    }
862
863    fn names(tape: &Tape) -> Vec<&'static str> {
864        tape.steps.iter().map(|s| s.primitive.name()).collect()
865    }
866
867    #[test]
868    fn version_is_set() {
869        assert!(!VERSION.is_empty());
870    }
871
872    #[test]
873    fn simplifies_identities_constants_and_common_subexpressions() {
874        set_default_stream(Stream::default_for(Device::cpu()));
875        let x = array![1.0, 2.0];
876        let f = |xs: &[Array]| {
877            let x = xs[0].astype(DType::F32).reshape([2]);
878            let a = x.multiply(2.0); // a scalar constant ...
879            let b = x.multiply(2.0); // ... merged with this one, so `b` is `a`
880            Ok(vec![a.add(&b), x.exp(), x.exp()])
881        };
882        let tape = trace(&f, std::slice::from_ref(&x), Device::cpu());
883        let mut ops = names(&tape);
884        ops.sort_unstable();
885        assert_eq!(ops, ["add", "exp", "multiply"]);
886        assert_eq!(tape.outputs[1], tape.outputs[2]);
887        let outs = tape.replay(&[x]).unwrap();
888        assert_eq!(outs[0].to_vec::<f32>().unwrap(), [4.0, 8.0]);
889    }
890
891    #[test]
892    fn fuses_element_wise_chains_on_the_gpu_only() {
893        set_default_stream(Stream::default_for(Device::cpu()));
894        let x = array![1.0, 2.0];
895        let w = array![[1.0, 0.0], [0.0, 1.0]];
896        // matmul, then a chain whose middle value is also an output of the function: one
897        // kernel with two outputs.
898        let f = |xs: &[Array]| {
899            let h = xs[0].reshape([1, 2]).matmul(&w);
900            let a = h.multiply(2.0).exp();
901            let b = a.multiply(&a).add(1.0);
902            Ok(vec![a, b.sigmoid()])
903        };
904        assert_eq!(
905            names(&trace(&f, std::slice::from_ref(&x), Device::cpu())),
906            [
907                "reshape", "matmul", "multiply", "exp", "multiply", "add", "sigmoid"
908            ]
909        );
910        let tape = trace(&f, &[x], Device::gpu(0));
911        assert_eq!(names(&tape), ["reshape", "matmul", "compiled"]);
912        let Primitive::Compiled(fused) = &tape.steps[2].primitive else {
913            unreachable!()
914        };
915        assert_eq!((fused.len(), fused.dtypes().len()), (5, 2));
916        assert_eq!(tape.outputs, [Ref::Step(2, 0), Ref::Step(2, 1)]);
917    }
918
919    #[test]
920    fn copies_and_dependencies_are_kept() {
921        set_default_stream(Stream::default_for(Device::cpu()));
922        let x = array![1.0, 2.0];
923        let y = array![3.0, 4.0];
924        let f = |xs: &[Array]| {
925            let copy = graph::from_primitive(Primitive::Copy, vec![xs[0].clone()])?.remove(0);
926            let ordered = graph::from_primitive(
927                Primitive::Depends { n_deps: 1 },
928                vec![xs[0].clone(), xs[1].clone()],
929            )?
930            .remove(0);
931            // A same-dtype conversion is still an identity.
932            Ok(vec![copy.add(ordered.astype(DType::F32))])
933        };
934        let tape = trace(&f, &[x.clone(), y.clone()], Device::cpu());
935        let mut ops = names(&tape);
936        ops.sort_unstable();
937        assert_eq!(ops, ["add", "copy", "depends"]);
938        let out = tape.replay(&[x, y]).unwrap().remove(0);
939        assert_eq!(out.to_vec::<f32>().unwrap(), [2.0, 4.0]);
940    }
941
942    #[test]
943    fn a_shared_value_used_outside_a_group_stays_outside() {
944        let x = array![1.0, 2.0];
945        // `t` feeds the chain and a reduction: it cannot be fused into the chain. The
946        // broadcast sum is a view the fused kernel reads.
947        let f = |xs: &[Array]| {
948            let t = xs[0].exp();
949            let s = t.sum_keepdims(..);
950            Ok(vec![t.multiply(3.0).add(&s)])
951        };
952        let tape = trace(&f, &[x], Device::gpu(0));
953        assert_eq!(names(&tape), ["exp", "sum", "broadcast_to", "compiled"]);
954    }
955}