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, compiled as MLX compiles its: scalar constants are literals in the source, and
14//! Metal's default (fast) math functions apply, which only `power` uses (the other
15//! functions are the precise ones, as in MLX). Fused results equal the uncompiled
16//! function's bit for bit, except where `power` is fused: there they can differ in the low
17//! bits, as `mx.compile`'s do. Later calls replay the simplified graph on the new inputs
18//! without running the function again.
19//!
20//! ```
21//! use nuvec_compile::compile;
22//! use nuvec_core::{Array, Result, array};
23//!
24//! # fn main() -> Result<()> {
25//! // x * x + 1, as one kernel on the GPU
26//! let f = compile(|x: &Array| x.multiply(x).add(1.0));
27//! assert_eq!(f(&array![1.0, 2.0])?.to_vec::<f32>()?, [2.0, 5.0]);
28//! assert_eq!(f(&array![3.0, 4.0])?.to_vec::<f32>()?, [10.0, 17.0]); // replayed
29//! # Ok(())
30//! # }
31//! ```
32//!
33//! With [`Options::shapeless`] a function is traced once per input structure, dtypes and
34//! ranks, and replayed for any shapes (MLX's `shapeless=True`).
35//!
36//! [`compile_mut`] compiles a stateful step (MLX's `mx.compile(step, inputs=state,
37//! outputs=state)`): a training step over `(model, optimizer)` whose leaves are inputs and
38//! outputs of the tape.
39//!
40//! **Stability:** no promise when this crate is depended on directly; depend on `nuvec`,
41//! whose re-exports are semver-tracked.
42//!
43//! **Status:** pre-alpha.
44
45#![forbid(unsafe_code)]
46
47use std::cell::Cell;
48use std::collections::{BinaryHeap, HashMap, HashSet};
49use std::sync::{Arc, Mutex, PoisonError};
50
51use nuvec_core::graph::{self, Fused, FusedRef, Primitive};
52use nuvec_core::tree::{Tree, TreeDef};
53use nuvec_core::{
54    Array, Complex64, DType, DeviceKind, Error, IntoResult, Result, Stream, bf16, default_stream,
55    f16, random,
56};
57
58/// Dimension sizes as `usize`.
59fn dims(a: &Array) -> Vec<usize> {
60    a.shape().iter().map(|&d| d as usize).collect()
61}
62
63/// The version of this crate.
64pub const VERSION: &str = env!("CARGO_PKG_VERSION");
65
66/// The most operations fused into one kernel.
67const MAX_FUSED_STEPS: usize = 32;
68/// The most inputs and outputs of one fused kernel (Metal binds at most 31 buffers per
69/// kernel; a fused kernel uses five more), as MLX's `max_compile_arrays`.
70const MAX_FUSED_ARRAYS: usize = 24;
71
72thread_local! {
73    /// The traces made on this thread ([`__trace_count`]).
74    static TRACES: Cell<usize> = const { Cell::new(0) };
75}
76
77/// The number of traces compiled functions have made on this thread. A test diagnostic,
78/// not a stable API.
79#[doc(hidden)]
80pub fn __trace_count() -> usize {
81    TRACES.with(Cell::get)
82}
83
84/// Options of [`compile`], [`compile_mut`] and [`compile_flat_with`]:
85/// `Options::default().cache_capacity(8)`.
86#[derive(Clone, Debug)]
87#[non_exhaustive]
88pub struct Options {
89    /// The most traced signatures a compiled function keeps (least recently used first
90    /// out); 0 never caches. Default 64.
91    pub cache_capacity: usize,
92    /// Trace once per input structure, dtypes and ranks, and replay for any shapes
93    /// ([`Options::shapeless`]). Default off.
94    pub shapeless: bool,
95}
96
97impl Default for Options {
98    fn default() -> Self {
99        Self {
100            cache_capacity: 64,
101            shapeless: false,
102        }
103    }
104}
105
106impl Options {
107    /// Sets the cache capacity.
108    pub fn cache_capacity(mut self, n: usize) -> Self {
109        self.cache_capacity = n;
110        self
111    }
112
113    /// Shapeless compilation (MLX's `shapeless=True`): the function is traced once per
114    /// input structure, dtypes and ranks, and replayed for any shapes of those ranks. Each
115    /// replay infers the shapes again, and element-wise operations broadcast their operands
116    /// as the uncompiled ones do, so the results are the shaped compile's bit for bit.
117    ///
118    /// ```
119    /// use nuvec_compile::Options;
120    /// use nuvec_core::{Array, DType, ops};
121    ///
122    /// # fn main() -> nuvec_core::Result<()> {
123    /// let swiglu = Options::default()
124    ///     .shapeless(true)
125    ///     .compile(|(g, u): &(Array, Array)| g.multiply(g.sigmoid()).multiply(u));
126    /// for rows in [7, 512, 2048] {
127    ///     let g = ops::ones([rows, 64], DType::F32);
128    ///     swiglu(&(g.clone(), g))?.eval()?; // traced once, for every row count
129    /// }
130    /// # Ok(())
131    /// # }
132    /// ```
133    ///
134    /// - Operations whose parameters hold traced sizes fail the trace with an error naming
135    ///   them: reshapes, slices and slice updates, broadcasts other than an element-wise
136    ///   operation's own, random draws, custom Metal kernels and attention (whose kernel
137    ///   choice depends on the lengths). Compile those without the option.
138    /// - As in MLX, Rust code that reads a shape while tracing (`x.shape()[0]`, a branch on
139    ///   a size) bakes the first call's value in, and constants built from a traced shape
140    ///   keep it: a replay on another shape then fails in the operation that uses them.
141    /// - [`compile_mut`] keys its state exactly and its inputs shapelessly.
142    pub fn shapeless(mut self, on: bool) -> Self {
143        self.shapeless = on;
144        self
145    }
146
147    /// [`compile`] with these options.
148    pub fn compile<'f, I, O, F, R>(self, f: F) -> impl Fn(&I) -> Result<O> + 'f
149    where
150        I: Tree,
151        O: Tree,
152        F: Fn(&I) -> R + 'f,
153        R: IntoResult<Ok = O>,
154    {
155        let cache: Mutex<Cache<(Tape, O)>> = Mutex::new(Cache::new(self.cache_capacity));
156        move |inputs: &I| {
157            let leaves = inputs.leaves();
158            if leaves.iter().any(|a| graph::is_tracer(a) || a.is_failed()) {
159                // Inside another transform, or on failed inputs (whose failure the direct
160                // call carries to the outputs): no trace.
161                return f(inputs).into_result();
162            }
163            let key = Key::new(vec![inputs.def()], &leaves, self.shapeless.then_some(0));
164            let cached = lock(&cache).get(&key);
165            let entry = match cached {
166                Some(entry) => entry,
167                None => {
168                    let mut out = None;
169                    let mut body = |xs: &[Array]| -> Result<Vec<Array>> {
170                        let o = f(&inputs.unflatten(xs.iter().cloned())?).into_result()?;
171                        let leaves = o.leaves();
172                        out = Some(o.map(|_, a| light(a)));
173                        Ok(leaves)
174                    };
175                    let tape = Tape::trace(&mut body, &leaves, key.stream, self.shapeless)?;
176                    let out =
177                        out.ok_or_else(|| Error::InvalidArgument("compile: no output".into()))?;
178                    let entry = Arc::new((tape, out));
179                    if !entry.0.captures_tracer() {
180                        lock(&cache).insert(key, entry.clone());
181                    }
182                    entry
183                }
184            };
185            let (tape, template) = &*entry;
186            template.unflatten(tape.replay(&leaves)?)
187        }
188    }
189
190    /// [`compile_mut`] with these options.
191    pub fn compile_mut<'f, S, I, O, F, R>(self, f: F) -> impl Fn(&mut S, &I) -> Result<O> + 'f
192    where
193        S: Tree,
194        I: Tree,
195        O: Tree,
196        F: Fn(&mut S, &I) -> R + 'f,
197        R: IntoResult<Ok = O>,
198    {
199        let cache: Mutex<Cache<MutTape<S, O>>> = Mutex::new(Cache::new(self.cache_capacity));
200        let changed_last = Cell::new(false);
201        move |state: &mut S, inputs: &I| {
202            let state_leaves = state.leaves();
203            let input_leaves = inputs.leaves();
204            let direct = state_leaves
205                .iter()
206                .chain(&input_leaves)
207                .any(|a| graph::is_tracer(a) || a.is_failed());
208            if direct {
209                // Inside another transform (or on failed arrays): run `f` itself.
210                return f(state, inputs).into_result();
211            }
212            let key0 = random::sequence_key();
213            let pre_def = state.def();
214            let mut all = Vec::with_capacity(1 + state_leaves.len() + input_leaves.len());
215            all.push(key0.clone());
216            all.extend(state_leaves.iter().cloned());
217            all.extend(input_leaves.iter().cloned());
218            let shapeless = self.shapeless.then_some(1 + state_leaves.len());
219            let key = Key::new(vec![pre_def.clone(), inputs.def()], &all, shapeless);
220            let cached = lock(&cache).get(&key);
221            let entry = match cached {
222                Some(entry) => entry,
223                None => {
224                    let entry = Arc::new(MutTape::trace(
225                        &f,
226                        state,
227                        inputs,
228                        &all,
229                        key.stream,
230                        self.shapeless,
231                    )?);
232                    if !entry.tape.captures_tracer() {
233                        lock(&cache).insert(key, entry.clone());
234                    }
235                    entry
236                }
237            };
238            let changed = entry.post_def != pre_def;
239            if changed && changed_last.get() {
240                return Err(Error::InvalidArgument(
241                    "compile_mut: state structure keeps changing; call opt.init(&model) before \
242                     the loop, or make changing host fields arrays (or `static_unchecked`)"
243                        .into(),
244                ));
245            }
246            let mut outputs = entry.tape.replay(&all)?;
247            let next_key = outputs.pop().expect("the key is the last output");
248            let state_out = outputs.split_off(entry.outputs);
249            let out = entry.out.unflatten(outputs)?;
250            let next = entry.post.unflatten(state_out)?;
251            changed_last.set(changed);
252            *state = next;
253            random::set_sequence_key(next_key);
254            Ok(out)
255        }
256    }
257}
258
259/// Returns `f` compiled: a function with the same results that traces `f` once per input
260/// structure (the [`TreeDef`]: paths, leaf kinds, trainable bits, enum variants, mode flags
261/// and static fields), leaf dtypes and shapes, and default stream, and replays the
262/// simplified, fused graph on later calls.
263///
264/// `f` takes a tree (an array, a tuple, a struct) and returns one, directly or as a
265/// `Result`; it may borrow locals (the cache stores tapes, not `f`). Write the closure's
266/// parameter type (`|x: &Array|`).
267///
268/// As in MLX, `f` must be pure:
269///
270/// - arrays it captures are constants, frozen at the first trace (so is random state
271///   drawn inside it; [`compile_mut`] threads the random key instead);
272/// - arrays cannot be evaluated inside it.
273///
274/// Called on arrays being traced by a transform (`grad`, `vmap`, another `compile`), or on
275/// failed arrays, it runs `f` directly, so transforms see the original operations and
276/// failures stay sticky.
277///
278/// Each compiled function keeps the tapes of its 64 most recently used signatures
279/// ([`Options::cache_capacity`]). A trace whose constants include an array being traced by
280/// a running transform (a tracer `f` captures) is used once and not kept.
281pub fn compile<'f, I, O, F, R>(f: F) -> impl Fn(&I) -> Result<O> + 'f
282where
283    I: Tree,
284    O: Tree,
285    F: Fn(&I) -> R + 'f,
286    R: IntoResult<Ok = O>,
287{
288    Options::default().compile(f)
289}
290
291/// Compiles a stateful step, as MLX's `mx.compile(step, inputs=state, outputs=state)`:
292/// `f(&mut state, &inputs)` where `state` is a tree (typically `(model, optimizer)`).
293///
294/// ```
295/// use nuvec_compile::compile_mut;
296/// use nuvec_core::{Array, Result, array, value_and_grad};
297///
298/// # fn main() -> Result<()> {
299/// // Gradient descent on w for loss(w) = sum((w x - y)^2), compiled.
300/// let step = compile_mut(|w: &mut Array, (x, y): &(Array, Array)| {
301///     let loss = |w: &Array| {
302///         let r = w.multiply(x).subtract(y);
303///         r.multiply(&r).sum(..)
304///     };
305///     let (l, g) = value_and_grad(loss)(w)?;
306///     *w = w.subtract(g.multiply(0.1));
307///     Ok(l)
308/// });
309/// let mut w = array![0.0];
310/// for _ in 0..50 {
311///     step(&mut w, &(array![1.0], array![2.0]))?;
312/// }
313/// assert!((w.item::<f32>()? - 2.0).abs() < 1e-3);
314/// # Ok(())
315/// # }
316/// ```
317///
318/// - The state's leaves (and the thread's random key) are traced inputs; after the call the
319///   state is rebuilt from the tape's outputs. So the step traces once, an optimizer's
320///   learning-rate array set between calls is just a new input, and random draws inside
321///   (dropout) advance the thread's key sequence exactly as an uncompiled call would.
322/// - The tape is keyed by the state's and the inputs' structure: switching train/eval,
323///   freezing, changing a hashed hyperparameter retraces once.
324/// - A step that changes the state's structure (an optimizer creating its slots at the
325///   first update) retraces once; a structure that changes on two consecutive calls is an
326///   error (call `opt.init(&model)` first). Host fields mutated in `f` are hashed statics,
327///   so mutating one counts as a structure change; `#[tree(skip, static_unchecked)]` fields
328///   are baked into the trace.
329/// - On error the state is unchanged. Called inside another transform, it runs `f`
330///   directly.
331pub fn compile_mut<'f, S, I, O, F, R>(f: F) -> impl Fn(&mut S, &I) -> Result<O> + 'f
332where
333    S: Tree,
334    I: Tree,
335    O: Tree,
336    F: Fn(&mut S, &I) -> R + 'f,
337    R: IntoResult<Ok = O>,
338{
339    Options::default().compile_mut(f)
340}
341
342/// [`compile`] over positional arrays: traces once per input signature (dtypes and
343/// shapes) and default stream.
344///
345/// ```
346/// use nuvec_compile::compile_flat;
347/// use nuvec_core::{Array, Result, array};
348///
349/// # fn main() -> Result<()> {
350/// let f = compile_flat(|xs: &[Array]| Ok(vec![xs[0].multiply(&xs[0]).add(1.0)]));
351/// assert_eq!(f(&[array![1.0, 2.0]])?[0].to_vec::<f32>()?, [2.0, 5.0]);
352/// # Ok(())
353/// # }
354/// ```
355pub fn compile_flat<F>(f: F) -> impl Fn(&[Array]) -> Result<Vec<Array>>
356where
357    F: Fn(&[Array]) -> Result<Vec<Array>>,
358{
359    compile_flat_with(Options::default(), f)
360}
361
362/// [`compile_flat`] with options ([`Options::shapeless`], the cache capacity).
363pub fn compile_flat_with<F>(options: Options, f: F) -> impl Fn(&[Array]) -> Result<Vec<Array>>
364where
365    F: Fn(&[Array]) -> Result<Vec<Array>>,
366{
367    let cache: Mutex<Cache<Tape>> = Mutex::new(Cache::new(options.cache_capacity));
368    move |inputs| {
369        if inputs.iter().any(|a| graph::is_tracer(a) || a.is_failed()) {
370            return f(inputs);
371        }
372        let key = Key::new(Vec::new(), inputs, options.shapeless.then_some(0));
373        let cached = lock(&cache).get(&key);
374        let tape = match cached {
375            Some(tape) => tape,
376            None => {
377                let tape = Arc::new(Tape::trace(
378                    &mut |xs: &[Array]| f(xs),
379                    inputs,
380                    key.stream,
381                    options.shapeless,
382                )?);
383                if !tape.captures_tracer() {
384                    lock(&cache).insert(key, tape.clone());
385                }
386                tape
387            }
388        };
389        tape.replay(inputs)
390    }
391}
392
393fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
394    m.lock().unwrap_or_else(PoisonError::into_inner)
395}
396
397/// A placeholder like `a` (a template leaf: only its dtype and shape matter).
398fn light(a: &Array) -> Array {
399    graph::placeholder(a.dtype(), a.shape()).unwrap_or_else(|_| a.clone())
400}
401
402/// A [`compile_mut`] trace: the tape (outputs: `f`'s, then the post-call state's, then the
403/// advanced random key), the output and post-state templates, and the post-call structure.
404struct MutTape<S, O> {
405    tape: Tape,
406    outputs: usize,
407    out: O,
408    post: S,
409    post_def: TreeDef,
410}
411
412impl<S: Tree, O: Tree> MutTape<S, O> {
413    /// Traces `f` with the thread's random key (`all[0]`), the state's leaves and the
414    /// inputs' as placeholders; `state` itself is not changed.
415    fn trace<I: Tree, R: IntoResult<Ok = O>>(
416        f: &dyn Fn(&mut S, &I) -> R,
417        state: &S,
418        inputs: &I,
419        all: &[Array],
420        stream: Stream,
421        shapeless: bool,
422    ) -> Result<Self> {
423        let ns = state.len();
424        let key0 = all[0].clone();
425        let mut traced: Option<(O, S)> = None;
426        let mut body = |xs: &[Array]| -> Result<Vec<Array>> {
427            random::set_sequence_key(xs[0].clone());
428            let mut s = state.unflatten(xs[1..1 + ns].iter().cloned())?;
429            let i = inputs.unflatten(xs[1 + ns..].iter().cloned())?;
430            let out = f(&mut s, &i).into_result();
431            let key = random::sequence_key();
432            random::set_sequence_key(key0.clone());
433            let out = out?;
434            let mut leaves = out.leaves();
435            leaves.extend(s.leaves());
436            leaves.push(key);
437            traced = Some((out, s));
438            Ok(leaves)
439        };
440        let result = Tape::trace(&mut body, all, stream, shapeless);
441        random::set_sequence_key(key0.clone());
442        let tape = result?;
443        let (out, post) =
444            traced.ok_or_else(|| Error::InvalidArgument("compile_mut: no output".into()))?;
445        Ok(Self {
446            tape,
447            outputs: out.len(),
448            out: out.map(|_, a| light(a)),
449            post_def: post.def(),
450            post: post.map(|_, a| light(a)),
451        })
452    }
453}
454
455/// A compiled function's tapes by key, least recently used evicted first.
456struct Cache<T> {
457    tapes: HashMap<Key, (Arc<T>, u64)>,
458    /// Incremented on every use; a tape's stamp is its last use.
459    clock: u64,
460    capacity: usize,
461}
462
463impl<T> Cache<T> {
464    fn new(capacity: usize) -> Self {
465        Self {
466            tapes: HashMap::new(),
467            clock: 0,
468            capacity,
469        }
470    }
471
472    fn get(&mut self, key: &Key) -> Option<Arc<T>> {
473        self.clock += 1;
474        let (tape, used) = self.tapes.get_mut(key)?;
475        *used = self.clock;
476        Some(tape.clone())
477    }
478
479    fn insert(&mut self, key: Key, tape: Arc<T>) {
480        if self.capacity == 0 {
481            return;
482        }
483        self.clock += 1;
484        if self.tapes.len() >= self.capacity
485            && !self.tapes.contains_key(&key)
486            && let Some(oldest) = self
487                .tapes
488                .iter()
489                .min_by_key(|(_, (_, used))| *used)
490                .map(|(k, _)| k.clone())
491        {
492            self.tapes.remove(&oldest);
493        }
494        self.tapes.insert(key, (tape, self.clock));
495    }
496}
497
498/// What a traced graph depends on: the inputs' structures, dtypes and shapes and the
499/// stream.
500#[derive(Debug, Clone, PartialEq, Eq, Hash)]
501struct Key {
502    stream: Stream,
503    defs: Vec<TreeDef>,
504    /// Dtype and shape per input (`Ok`), or dtype and rank for a shapeless input (`Err`).
505    inputs: Vec<(DType, std::result::Result<Vec<usize>, usize>)>,
506}
507
508impl Key {
509    /// The key of `inputs`; those from position `shapeless` on are keyed by dtype and rank.
510    fn new(defs: Vec<TreeDef>, inputs: &[Array], shapeless: Option<usize>) -> Self {
511        let from = shapeless.unwrap_or(inputs.len());
512        Self {
513            stream: default_stream(),
514            defs,
515            inputs: inputs
516                .iter()
517                .enumerate()
518                .map(|(i, a)| {
519                    (
520                        a.dtype(),
521                        if i < from { Ok(dims(a)) } else { Err(a.ndim()) },
522                    )
523                })
524                .collect(),
525        }
526    }
527}
528
529/// A value of the traced graph.
530#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
531enum Ref {
532    /// The input at this position.
533    Input(usize),
534    /// A constant (captured or independent of the inputs).
535    Const(usize),
536    /// Output `.1` of step `.0`.
537    Step(usize, usize),
538}
539
540#[derive(Debug, Clone)]
541struct Step {
542    primitive: Primitive,
543    inputs: Vec<Ref>,
544    /// Dtype and shape of each output.
545    outputs: Vec<(DType, Vec<usize>)>,
546}
547
548/// A simplified graph, replayed on each call.
549#[derive(Debug)]
550struct Tape {
551    consts: Vec<Array>,
552    steps: Vec<Step>,
553    outputs: Vec<Ref>,
554    /// Replayed on any shapes: element-wise steps broadcast their operands.
555    shapeless: bool,
556    /// Shapeless replays' broadcast views of constants, by constant and shape: the same on
557    /// every call of a shape, so made once (at most [`MAX_VIEWS`]).
558    views: Mutex<HashMap<(usize, Vec<usize>), Array>>,
559}
560
561/// The most broadcast constants a shapeless tape keeps (it starts over when full).
562const MAX_VIEWS: usize = 64;
563
564impl Tape {
565    /// Traces `f` on placeholders like `inputs`, simplifies the graph and, on the GPU,
566    /// fuses element-wise chains.
567    fn trace(
568        f: &mut dyn FnMut(&[Array]) -> Result<Vec<Array>>,
569        inputs: &[Array],
570        stream: Stream,
571        shapeless: bool,
572    ) -> Result<Self> {
573        TRACES.with(|n| n.set(n.get() + 1));
574        let placeholders = inputs
575            .iter()
576            .map(|a| graph::placeholder(a.dtype(), a.shape()))
577            .collect::<Result<Vec<_>>>()?;
578        let outputs = f(&placeholders)?;
579        let mut tape = Builder::new(&placeholders).build(&outputs);
580        if shapeless {
581            tape.make_shapeless()?;
582        }
583        if stream.device.kind == DeviceKind::Gpu {
584            tape.fuse(inputs);
585        }
586        Ok(tape)
587    }
588
589    /// Whether a constant is an array being traced by a running transform (a tracer `f`
590    /// captured): such a tape is used once and not cached.
591    fn captures_tracer(&self) -> bool {
592        self.consts.iter().any(graph::is_tracer)
593    }
594
595    fn replay(&self, inputs: &[Array]) -> Result<Vec<Array>> {
596        let mut values: Vec<Vec<Array>> = Vec::with_capacity(self.steps.len());
597        let get = |r: &Ref, values: &[Vec<Array>]| match *r {
598            Ref::Input(i) => inputs[i].clone(),
599            Ref::Const(c) => self.consts[c].clone(),
600            Ref::Step(s, o) => values[s][o].clone(),
601        };
602        for step in &self.steps {
603            let mut args: Vec<Array> = step.inputs.iter().map(|r| get(r, &values)).collect();
604            if self.shapeless && is_element_wise(&step.primitive) {
605                self.broadcast_operands(step, &mut args)?;
606            }
607            values.push(graph::from_primitive(step.primitive.clone(), args)?);
608        }
609        Ok(self.outputs.iter().map(|r| get(r, &values)).collect())
610    }
611
612    /// Broadcasts an element-wise step's `args` to their common shape, as the binary
613    /// operations do; a constant's view of a shape is made once.
614    fn broadcast_operands(&self, step: &Step, args: &mut [Array]) -> Result<()> {
615        let shapes: Vec<Vec<usize>> = args.iter().map(dims).collect();
616        if shapes.windows(2).all(|w| w[0] == w[1]) {
617            return Ok(());
618        }
619        let mut shape = Vec::new();
620        for s in &shapes {
621            shape = graph::broadcast_shapes(step.primitive.name(), &shape, s)?;
622        }
623        let expand = |a: &Array| {
624            graph::from_primitive(Primitive::Broadcast(shape.clone()), vec![a.clone()])
625                .map(|mut v| v.remove(0))
626        };
627        for ((a, s), r) in args.iter_mut().zip(&shapes).zip(&step.inputs) {
628            if *s == shape {
629                continue;
630            }
631            *a = match *r {
632                Ref::Const(c) => {
633                    let mut views = lock(&self.views);
634                    if views.len() >= MAX_VIEWS {
635                        views.clear();
636                    }
637                    match views.entry((c, shape.clone())) {
638                        std::collections::hash_map::Entry::Occupied(v) => v.get().clone(),
639                        std::collections::hash_map::Entry::Vacant(v) => {
640                            v.insert(expand(a)?).clone()
641                        }
642                    }
643                }
644                _ => expand(a)?,
645            };
646        }
647        Ok(())
648    }
649
650    /// Makes a traced tape replayable on any shapes of the traced ranks. The operations
651    /// broadcast an element-wise operation's operands to the result's shape; a shapeless
652    /// replay re-derives that broadcast from the operands (as MLX's `Broadcast` does from
653    /// its partner inputs). So an element-wise step's broadcast operands are replaced by
654    /// their sources, and so are the broadcast views among the constants (scalar operands,
655    /// captured weights). Steps left without a use are dropped; a step whose parameters
656    /// still fix a shape fails the trace.
657    fn make_shapeless(&mut self) -> Result<()> {
658        self.shapeless = true;
659        // The constant each broadcast-view constant was expanded from.
660        let mut sources: HashMap<usize, usize> = HashMap::new();
661        for c in 0..self.consts.len() {
662            let a = &self.consts[c];
663            if graph::is_tracer(a) {
664                continue;
665            }
666            if let (Some(Primitive::Broadcast(_)), [x]) =
667                (graph::primitive(a), graph::inputs(a).as_slice())
668            {
669                let x = x.clone();
670                self.consts.push(x);
671                sources.insert(c, self.consts.len() - 1);
672            }
673        }
674        for k in 0..self.steps.len() {
675            if !is_element_wise(&self.steps[k].primitive) {
676                continue;
677            }
678            for i in 0..self.steps[k].inputs.len() {
679                let mut r = self.steps[k].inputs[i];
680                loop {
681                    r = match r {
682                        Ref::Step(j, 0)
683                            if matches!(self.steps[j].primitive, Primitive::Broadcast(_)) =>
684                        {
685                            self.steps[j].inputs[0]
686                        }
687                        Ref::Const(c) if sources.contains_key(&c) => Ref::Const(sources[&c]),
688                        _ => break,
689                    };
690                }
691                self.steps[k].inputs[i] = r;
692            }
693        }
694        self.drop_unused();
695        match self.steps.iter().find(|s| s.primitive.fixes_shape()) {
696            Some(s) => Err(Error::InvalidArgument(format!(
697                "compile (shapeless): `{}` fixes a traced shape; compile this function \
698                 without shapeless",
699                s.primitive.name()
700            ))),
701            None => Ok(()),
702        }
703    }
704
705    /// Removes the steps no output depends on.
706    fn drop_unused(&mut self) {
707        let n = self.steps.len();
708        let step_of = |r: &Ref| match *r {
709            Ref::Step(j, _) => Some(j),
710            _ => None,
711        };
712        let mut live = vec![false; n];
713        let mut stack: Vec<usize> = self.outputs.iter().filter_map(step_of).collect();
714        while let Some(j) = stack.pop() {
715            if !std::mem::replace(&mut live[j], true) {
716                stack.extend(self.steps[j].inputs.iter().filter_map(step_of));
717            }
718        }
719        let mut index = vec![0; n];
720        let remap = |r: Ref, index: &[usize]| match r {
721            Ref::Step(j, o) => Ref::Step(index[j], o),
722            r => r,
723        };
724        let mut steps = Vec::with_capacity(n);
725        for (k, mut step) in std::mem::take(&mut self.steps).into_iter().enumerate() {
726            if live[k] {
727                step.inputs = step.inputs.iter().map(|&r| remap(r, &index)).collect();
728                index[k] = steps.len();
729                steps.push(step);
730            }
731        }
732        self.outputs = self.outputs.iter().map(|&r| remap(r, &index)).collect();
733        self.steps = steps;
734    }
735
736    /// A constant a fused kernel writes as a literal: a float scalar (or a broadcast one, as
737    /// the operations make for scalar operands) with a known value, not a tracer.
738    fn literal(&self, r: Ref) -> Option<(DType, u64)> {
739        let Ref::Const(c) = r else { return None };
740        let a = &self.consts[c];
741        if graph::is_tracer(a) || !matches!(a.dtype(), DType::F32 | DType::F16 | DType::BF16) {
742            return None;
743        }
744        let value = scalar_bits(a).or_else(|| {
745            match (graph::primitive(a), graph::inputs(a).as_slice()) {
746                (Some(Primitive::Broadcast(_)), [x]) if x.dtype() == a.dtype() => scalar_bits(x),
747                _ => None,
748            }
749        })?;
750        Some((a.dtype(), value))
751    }
752
753    fn dtype(&self, r: Ref, inputs: &[Array]) -> DType {
754        match r {
755            Ref::Input(i) => inputs[i].dtype(),
756            Ref::Const(c) => self.consts[c].dtype(),
757            Ref::Step(s, o) => self.steps[s].outputs[o].0,
758        }
759    }
760
761    /// Replaces chains of element-wise steps by fused steps (MLX's `compile_fuse`). Walking
762    /// back from a group's last step, an element-wise step joins the group when every step
763    /// that consumes it is in the group and the kernel limits hold. A joining step that is
764    /// also an output of the function becomes another output of the fused step. So nothing
765    /// outside a group consumes its inner values, and the graph stays acyclic.
766    fn fuse(&mut self, inputs: &[Array]) {
767        let n = self.steps.len();
768        let fusable: Vec<bool> = self
769            .steps
770            .iter()
771            .map(|s| {
772                let dtypes: Vec<DType> = s.inputs.iter().map(|&r| self.dtype(r, inputs)).collect();
773                s.outputs.len() == 1 && Fused::supports(&s.primitive, &dtypes)
774            })
775            .collect();
776        let mut consumers: Vec<Vec<usize>> = vec![Vec::new(); n];
777        for (k, s) in self.steps.iter().enumerate() {
778            for r in &s.inputs {
779                if let Ref::Step(j, _) = *r {
780                    consumers[j].push(k);
781                }
782            }
783        }
784        let mut is_output = vec![false; n];
785        for r in &self.outputs {
786            if let Ref::Step(j, _) = *r {
787                is_output[j] = true;
788            }
789        }
790        let step_inputs = |k: usize| {
791            self.steps[k].inputs.iter().filter_map(|r| match *r {
792                Ref::Step(j, _) => Some(j),
793                _ => None,
794            })
795        };
796        // Per step: its group's root (last step), if fused.
797        let mut group: Vec<Option<usize>> = vec![None; n];
798        // Per root: the members (ascending) and the external inputs.
799        let mut groups: HashMap<usize, (Vec<usize>, Vec<Ref>)> = HashMap::new();
800        for root in (0..n).rev() {
801            if group[root].is_some() || !fusable[root] {
802                continue;
803            }
804            let mut members = HashSet::from([root]);
805            let mut external: Vec<Ref> = Vec::new();
806            let mut outputs = 1;
807            let add_inputs = |k: usize, external: &mut Vec<Ref>| {
808                for &r in &self.steps[k].inputs {
809                    if !external.contains(&r) {
810                        external.push(r);
811                    }
812                }
813            };
814            add_inputs(root, &mut external);
815            // Highest first: a step is decided after every step that could consume it.
816            let mut candidates: BinaryHeap<usize> = step_inputs(root).collect();
817            let mut seen = HashSet::new();
818            while let Some(j) = candidates.pop() {
819                // Shapeless: a fused kernel computes every member at the group's shape, and
820                // a member's own shape can be smaller, so the function's outputs stay out.
821                if !seen.insert(j)
822                    || group[j].is_some()
823                    || !fusable[j]
824                    || (self.shapeless && is_output[j])
825                    || members.len() >= MAX_FUSED_STEPS
826                    || !consumers[j].iter().all(|c| members.contains(c))
827                {
828                    continue;
829                }
830                let new = self.steps[j]
831                    .inputs
832                    .iter()
833                    .filter(|r| !external.contains(r))
834                    .collect::<HashSet<_>>()
835                    .len();
836                let arrays = external.len() - 1 + new + outputs + usize::from(is_output[j]);
837                if arrays > MAX_FUSED_ARRAYS {
838                    continue;
839                }
840                members.insert(j);
841                outputs += usize::from(is_output[j]);
842                external.retain(|&r| r != Ref::Step(j, 0));
843                add_inputs(j, &mut external);
844                candidates.extend(step_inputs(j));
845            }
846            if members.len() < 2 || external.len() + outputs > MAX_FUSED_ARRAYS {
847                continue;
848            }
849            let mut members: Vec<usize> = members.into_iter().collect();
850            members.sort_unstable();
851            for &m in &members {
852                group[m] = Some(root);
853            }
854            groups.insert(root, (members, external));
855        }
856        if groups.is_empty() {
857            return;
858        }
859        // Rebuild: each group becomes one step at its root's position. `index` maps a
860        // kept or fused step's outputs to the new step (and the output offset in it).
861        let mut index: Vec<Option<(usize, usize)>> = vec![None; n];
862        let remap = |r: Ref, index: &[Option<(usize, usize)>]| match r {
863            Ref::Step(j, o) => {
864                let (k, offset) = index[j].expect("defined before use");
865                Ref::Step(k, offset + o)
866            }
867            r => r,
868        };
869        let mut steps = Vec::with_capacity(n);
870        for k in 0..n {
871            let step = match (group[k], groups.get(&k)) {
872                (Some(_), None) => continue, // inside a group: defined with its root
873                (Some(_), Some((members, external))) => {
874                    let position: HashMap<usize, usize> =
875                        members.iter().enumerate().map(|(p, &m)| (m, p)).collect();
876                    // Scalar float constants go into the kernel as literals (MLX's compiled
877                    // kernels write theirs into the source); the rest are its inputs.
878                    let mut constants: Vec<(DType, u64)> = Vec::new();
879                    let mut literal: HashMap<Ref, usize> = HashMap::new();
880                    for &r in external {
881                        if let Some(value) = self.literal(r) {
882                            literal.insert(r, constants.len());
883                            constants.push(value);
884                        }
885                    }
886                    let external: Vec<Ref> = external
887                        .iter()
888                        .copied()
889                        .filter(|r| !literal.contains_key(r))
890                        .collect();
891                    let external = &external;
892                    let graph = members
893                        .iter()
894                        .map(|&m| {
895                            let args = self.steps[m]
896                                .inputs
897                                .iter()
898                                .map(|r| match *r {
899                                    Ref::Step(j, _) if position.contains_key(&j) => {
900                                        FusedRef::Step(position[&j])
901                                    }
902                                    r if literal.contains_key(&r) => {
903                                        FusedRef::Constant(literal[&r])
904                                    }
905                                    r => FusedRef::Input(
906                                        external.iter().position(|&e| e == r).expect("external"),
907                                    ),
908                                })
909                                .collect();
910                            (self.steps[m].primitive.clone(), args)
911                        })
912                        .collect();
913                    // Outputs: the members the function returns, then the root.
914                    let outs: Vec<usize> = members
915                        .iter()
916                        .copied()
917                        .filter(|&m| m != k && is_output[m])
918                        .chain([k])
919                        .collect();
920                    for (o, &m) in outs.iter().enumerate() {
921                        index[m] = Some((steps.len(), o));
922                    }
923                    let dtypes: Vec<DType> =
924                        external.iter().map(|&r| self.dtype(r, inputs)).collect();
925                    let positions: Vec<usize> = outs.iter().map(|m| position[m]).collect();
926                    let Ok(f) = Fused::with_constants(&dtypes, &constants, graph, &positions)
927                    else {
928                        unreachable!("every member is fusable")
929                    };
930                    steps.push(Step {
931                        primitive: Primitive::Compiled(f),
932                        inputs: external.iter().map(|&r| remap(r, &index)).collect(),
933                        outputs: outs
934                            .iter()
935                            .map(|&m| self.steps[m].outputs[0].clone())
936                            .collect(),
937                    });
938                    continue;
939                }
940                (None, _) => {
941                    let s = &self.steps[k];
942                    Step {
943                        primitive: s.primitive.clone(),
944                        inputs: s.inputs.iter().map(|&r| remap(r, &index)).collect(),
945                        outputs: s.outputs.clone(),
946                    }
947                }
948            };
949            index[k] = Some((steps.len(), 0));
950            steps.push(step);
951        }
952        self.outputs = self.outputs.iter().map(|&r| remap(r, &index)).collect();
953        self.steps = steps;
954    }
955}
956
957/// Builds a tape from a traced graph, simplifying as it goes.
958struct Builder {
959    /// Resolved arrays, by operation id and output index.
960    refs: HashMap<(usize, usize), Ref>,
961    consts: Vec<Array>,
962    /// Constant scalars by value, for merging.
963    scalars: HashMap<(DType, Vec<usize>, u64), usize>,
964    steps: Vec<Step>,
965    /// Steps by primitive and inputs, for common-subexpression elimination.
966    seen: HashMap<(Primitive, Vec<Ref>), usize>,
967}
968
969impl Builder {
970    fn new(placeholders: &[Array]) -> Self {
971        let refs = placeholders
972            .iter()
973            .enumerate()
974            .map(|(i, p)| ((graph::id(p), graph::output_index(p)), Ref::Input(i)))
975            .collect();
976        Self {
977            refs,
978            consts: Vec::new(),
979            scalars: HashMap::new(),
980            steps: Vec::new(),
981            seen: HashMap::new(),
982        }
983    }
984
985    fn key(a: &Array) -> (usize, usize) {
986        (graph::id(a), graph::output_index(a))
987    }
988
989    /// Visits the graph of `outputs` inputs first (iteratively, so depth is limited only
990    /// by memory) and returns the tape.
991    fn build(mut self, outputs: &[Array]) -> Tape {
992        let mut stack: Vec<(Array, bool)> = outputs.iter().map(|a| (a.clone(), false)).collect();
993        while let Some((a, expanded)) = stack.pop() {
994            if self.refs.contains_key(&Self::key(&a)) {
995                continue;
996            }
997            let inputs = graph::inputs(&a);
998            if expanded || inputs.is_empty() {
999                self.resolve(&a, &inputs);
1000                continue;
1001            }
1002            stack.push((a, true));
1003            stack.extend(
1004                inputs
1005                    .into_iter()
1006                    .filter(|i| !self.refs.contains_key(&Self::key(i)))
1007                    .map(|i| (i, false)),
1008            );
1009        }
1010        let outputs = outputs.iter().map(|a| self.refs[&Self::key(a)]).collect();
1011        Tape {
1012            consts: self.consts,
1013            steps: self.steps,
1014            outputs,
1015            shapeless: false,
1016            views: Mutex::default(),
1017        }
1018    }
1019
1020    /// Records the operation producing `a` (whose inputs are resolved): a constant if it
1021    /// does not depend on an input, an existing value if it is an identity or computed
1022    /// already, else a new step.
1023    fn resolve(&mut self, a: &Array, inputs: &[Array]) {
1024        let siblings = graph::siblings(a);
1025        let args: Vec<Ref> = inputs.iter().map(|i| self.refs[&Self::key(i)]).collect();
1026        let primitive = graph::primitive(a);
1027        // A collective communicates on every call: never a constant.
1028        let effect = |p: &Primitive| matches!(p, Primitive::Collective { .. });
1029        let Some(primitive) =
1030            primitive.filter(|p| effect(p) || !args.iter().all(|r| matches!(r, Ref::Const(_))))
1031        else {
1032            for s in &siblings {
1033                let c = self.constant(s);
1034                self.refs.insert(Self::key(s), Ref::Const(c));
1035            }
1036            return;
1037        };
1038        if siblings.len() == 1 && is_identity(&primitive, &inputs[0], a) {
1039            self.refs.insert(Self::key(a), args[0]);
1040            return;
1041        }
1042        // A collective is never merged with an identical one: each call communicates (two
1043        // equal `recv`s receive two messages).
1044        let merged = match effect(&primitive) {
1045            true => None,
1046            false => self.seen.get(&(primitive.clone(), args.clone())).copied(),
1047        };
1048        let k = match merged {
1049            Some(k) => k,
1050            None => {
1051                self.steps.push(Step {
1052                    primitive: primitive.clone(),
1053                    inputs: args.clone(),
1054                    outputs: siblings.iter().map(|s| (s.dtype(), dims(s))).collect(),
1055                });
1056                let k = self.steps.len() - 1;
1057                if !effect(&primitive) {
1058                    self.seen.insert((primitive, args), k);
1059                }
1060                k
1061            }
1062        };
1063        for (o, s) in siblings.iter().enumerate() {
1064            self.refs.insert(Self::key(s), Ref::Step(k, o));
1065        }
1066    }
1067
1068    /// The constant index of `a`, shared with an equal scalar constant if there is one. A
1069    /// tracer (an array an outer transform differentiates, captured by `f`) is never merged
1070    /// with an equal value: the outer transform must see it.
1071    fn constant(&mut self, a: &Array) -> usize {
1072        if graph::is_tracer(a) {
1073            self.consts.push(a.clone());
1074            return self.consts.len() - 1;
1075        }
1076        let value = scalar_bits(a).or_else(|| {
1077            // A broadcast scalar, as the operations make for scalar operands.
1078            match (graph::primitive(a), graph::inputs(a).as_slice()) {
1079                (Some(Primitive::Broadcast(_)), [x]) => scalar_bits(x),
1080                _ => None,
1081            }
1082        });
1083        let key = value.map(|v| (a.dtype(), dims(a), v));
1084        if let Some(&c) = key.as_ref().and_then(|k| self.scalars.get(k)) {
1085            return c;
1086        }
1087        self.consts.push(a.clone());
1088        let c = self.consts.len() - 1;
1089        if let Some(key) = key {
1090            self.scalars.insert(key, c);
1091        }
1092        c
1093    }
1094}
1095
1096/// Whether `primitive` is element-wise over operands of one shape, which the operations
1097/// broadcast to the result's shape.
1098fn is_element_wise(primitive: &Primitive) -> bool {
1099    matches!(
1100        primitive,
1101        Primitive::Binary(_) | Primitive::Select | Primitive::Compiled(_)
1102    )
1103}
1104
1105/// Whether `primitive` maps `input` to `output` unchanged.
1106fn is_identity(primitive: &Primitive, input: &Array, output: &Array) -> bool {
1107    let same = input.shape() == output.shape() && graph::strides(input) == graph::strides(output);
1108    match primitive {
1109        // Not `StopGradient`: a replay under an outer transform must still stop gradients.
1110        Primitive::Broadcast(_) | Primitive::Reshape(_) => same,
1111        Primitive::Transpose(axes) => axes.iter().enumerate().all(|(i, &a)| i == a),
1112        Primitive::AsType(d) => *d == input.dtype(),
1113        _ => false,
1114    }
1115}
1116
1117/// The bits of an evaluated one-element array's value.
1118fn scalar_bits(a: &Array) -> Option<u64> {
1119    if a.size() != 1 || !a.is_evaluated() {
1120        return None;
1121    }
1122    Some(match a.dtype() {
1123        DType::Bool => u64::from(a.item::<bool>().ok()?),
1124        DType::U8 => u64::from(a.item::<u8>().ok()?),
1125        DType::U16 => u64::from(a.item::<u16>().ok()?),
1126        DType::U32 => u64::from(a.item::<u32>().ok()?),
1127        DType::U64 => a.item::<u64>().ok()?,
1128        DType::I8 => a.item::<i8>().ok()? as u64,
1129        DType::I16 => a.item::<i16>().ok()? as u64,
1130        DType::I32 => a.item::<i32>().ok()? as u64,
1131        DType::I64 => a.item::<i64>().ok()? as u64,
1132        DType::F16 => u64::from(a.item::<f16>().ok()?.to_bits()),
1133        DType::BF16 => u64::from(a.item::<bf16>().ok()?.to_bits()),
1134        DType::F32 => u64::from(a.item::<f32>().ok()?.to_bits()),
1135        DType::F64 => a.item::<f64>().ok()?.to_bits(),
1136        DType::Complex64 => {
1137            let z = a.item::<Complex64>().ok()?;
1138            u64::from(z.re.to_bits()) << 32 | u64::from(z.im.to_bits())
1139        }
1140        _ => return None,
1141    })
1142}
1143
1144#[cfg(test)]
1145mod tests {
1146    use nuvec_core::{Device, array, set_default_stream};
1147
1148    use super::*;
1149
1150    fn trace(f: &dyn Fn(&[Array]) -> Result<Vec<Array>>, inputs: &[Array], device: Device) -> Tape {
1151        Tape::trace(
1152            &mut |xs: &[Array]| f(xs),
1153            inputs,
1154            Stream::default_for(device),
1155            false,
1156        )
1157        .unwrap()
1158    }
1159
1160    fn names(tape: &Tape) -> Vec<&'static str> {
1161        tape.steps.iter().map(|s| s.primitive.name()).collect()
1162    }
1163
1164    #[test]
1165    fn version_is_set() {
1166        assert!(!VERSION.is_empty());
1167    }
1168
1169    #[test]
1170    fn simplifies_identities_constants_and_common_subexpressions() {
1171        set_default_stream(Stream::default_for(Device::cpu()));
1172        let x = array![1.0, 2.0];
1173        let f = |xs: &[Array]| {
1174            let x = xs[0].astype(DType::F32).reshape([2]);
1175            let a = x.multiply(2.0); // a scalar constant ...
1176            let b = x.multiply(2.0); // ... merged with this one, so `b` is `a`
1177            Ok(vec![a.add(&b), x.exp(), x.exp()])
1178        };
1179        let tape = trace(&f, std::slice::from_ref(&x), Device::cpu());
1180        let mut ops = names(&tape);
1181        ops.sort_unstable();
1182        assert_eq!(ops, ["add", "exp", "multiply"]);
1183        assert_eq!(tape.outputs[1], tape.outputs[2]);
1184        let outs = tape.replay(&[x]).unwrap();
1185        assert_eq!(outs[0].to_vec::<f32>().unwrap(), [4.0, 8.0]);
1186    }
1187
1188    #[test]
1189    fn fuses_element_wise_chains_on_the_gpu_only() {
1190        set_default_stream(Stream::default_for(Device::cpu()));
1191        let x = array![1.0, 2.0];
1192        let w = array![[1.0, 0.0], [0.0, 1.0]];
1193        // matmul, then a chain whose middle value is also an output of the function: one
1194        // kernel with two outputs.
1195        let f = |xs: &[Array]| {
1196            let h = xs[0].reshape([1, 2]).matmul(&w);
1197            let a = h.multiply(2.0).exp();
1198            let b = a.multiply(&a).add(1.0);
1199            Ok(vec![a, b.sigmoid()])
1200        };
1201        assert_eq!(
1202            names(&trace(&f, std::slice::from_ref(&x), Device::cpu())),
1203            [
1204                "reshape", "matmul", "multiply", "exp", "multiply", "add", "sigmoid"
1205            ]
1206        );
1207        let tape = trace(&f, &[x], Device::gpu(0));
1208        assert_eq!(names(&tape), ["reshape", "matmul", "compiled"]);
1209        let Primitive::Compiled(fused) = &tape.steps[2].primitive else {
1210            unreachable!()
1211        };
1212        assert_eq!((fused.len(), fused.dtypes().len()), (5, 2));
1213        assert_eq!(tape.outputs, [Ref::Step(2, 0), Ref::Step(2, 1)]);
1214    }
1215
1216    #[test]
1217    fn copies_and_dependencies_are_kept() {
1218        set_default_stream(Stream::default_for(Device::cpu()));
1219        let x = array![1.0, 2.0];
1220        let y = array![3.0, 4.0];
1221        let f = |xs: &[Array]| {
1222            let copy = graph::from_primitive(Primitive::Copy, vec![xs[0].clone()])?.remove(0);
1223            let ordered = graph::from_primitive(
1224                Primitive::Depends { n_deps: 1 },
1225                vec![xs[0].clone(), xs[1].clone()],
1226            )?
1227            .remove(0);
1228            // A same-dtype conversion is still an identity.
1229            Ok(vec![copy.add(ordered.astype(DType::F32))])
1230        };
1231        let tape = trace(&f, &[x.clone(), y.clone()], Device::cpu());
1232        let mut ops = names(&tape);
1233        ops.sort_unstable();
1234        assert_eq!(ops, ["add", "copy", "depends"]);
1235        let out = tape.replay(&[x, y]).unwrap().remove(0);
1236        assert_eq!(out.to_vec::<f32>().unwrap(), [2.0, 4.0]);
1237    }
1238
1239    #[test]
1240    fn a_shared_value_used_outside_a_group_stays_outside() {
1241        let x = array![1.0, 2.0];
1242        // `t` feeds the chain and a reduction: it cannot be fused into the chain. The
1243        // broadcast sum is a view the fused kernel reads.
1244        let f = |xs: &[Array]| {
1245            let t = xs[0].exp();
1246            let s = t.sum_keepdims(..);
1247            Ok(vec![t.multiply(3.0).add(&s)])
1248        };
1249        let tape = trace(&f, &[x], Device::gpu(0));
1250        assert_eq!(names(&tape), ["exp", "sum", "broadcast_to", "compiled"]);
1251    }
1252}