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 signature
4//! traces the function on placeholders and simplifies the graph:
5//!
6//! - identity views and conversions are removed;
7//! - equal scalar constants are merged;
8//! - equal operations on equal inputs are computed once (common-subexpression
9//!   elimination).
10//!
11//! On the GPU, chains of element-wise operations are then fused into single generated
12//! kernels. Later calls replay the simplified graph on the new inputs without running the
13//! function again.
14//!
15//! ```
16//! use nuvec_compile::compile;
17//! use nuvec_core::{Array, Result, add, multiply};
18//!
19//! # fn main() -> Result<()> {
20//! // x * x + 1, as one kernel on the GPU
21//! let f = compile(|xs: &[Array]| Ok(vec![add(&multiply(&xs[0], &xs[0])?, 1.0)?]));
22//! let x = Array::from_slice(&[1.0f32, 2.0], &[2])?;
23//! assert_eq!(f(&[x])?[0].to_vec::<f32>()?, [2.0, 5.0]);
24//! let y = Array::from_slice(&[3.0f32, 4.0], &[2])?;
25//! assert_eq!(f(&[y])?[0].to_vec::<f32>()?, [10.0, 17.0]); // replayed, not traced again
26//! # Ok(())
27//! # }
28//! ```
29//!
30//! **Status:** pre-alpha.
31
32#![forbid(unsafe_code)]
33
34use std::collections::{BinaryHeap, HashMap, HashSet};
35use std::sync::{Arc, Mutex, PoisonError};
36
37use nuvec_core::{
38    Array, Complex64, DType, DeviceKind, Fused, FusedRef, Primitive, Result, Stream, bf16,
39    default_stream, f16,
40};
41
42/// The version of this crate.
43pub const VERSION: &str = env!("CARGO_PKG_VERSION");
44
45/// The most operations fused into one kernel.
46const MAX_FUSED_STEPS: usize = 32;
47/// The most inputs and outputs of one fused kernel (Metal binds at most 31 buffers per
48/// kernel; a fused kernel uses five more), as MLX's `max_compile_arrays`.
49const MAX_FUSED_ARRAYS: usize = 24;
50
51/// Returns `f` compiled: a function with the same results that traces `f` once per input
52/// signature (dtypes and shapes) and default stream, and replays the simplified, fused
53/// graph on later calls.
54///
55/// As in MLX, `f` must be pure:
56///
57/// - arrays it captures are constants, frozen at the first trace (so is random state
58///   drawn inside it);
59/// - arrays cannot be evaluated inside it.
60///
61/// Called on arrays being traced by a transform (`grad`, `vmap`, another `compile`), it
62/// runs `f` directly, so transforms see the original operations.
63pub fn compile<F>(f: F) -> impl Fn(&[Array]) -> Result<Vec<Array>>
64where
65    F: Fn(&[Array]) -> Result<Vec<Array>>,
66{
67    let cache: Mutex<HashMap<Key, Arc<Tape>>> = Mutex::default();
68    move |inputs| {
69        if inputs.iter().any(Array::is_tracer) {
70            return f(inputs);
71        }
72        let key = Key::new(inputs);
73        let cached = cache
74            .lock()
75            .unwrap_or_else(PoisonError::into_inner)
76            .get(&key)
77            .cloned();
78        let tape = match cached {
79            Some(tape) => tape,
80            None => {
81                let tape = Arc::new(Tape::trace(&f, inputs, key.stream)?);
82                cache
83                    .lock()
84                    .unwrap_or_else(PoisonError::into_inner)
85                    .insert(key, tape.clone());
86                tape
87            }
88        };
89        tape.replay(inputs)
90    }
91}
92
93/// What a traced graph depends on: the inputs' dtypes and shapes and the stream.
94#[derive(Debug, Clone, PartialEq, Eq, Hash)]
95struct Key {
96    stream: Stream,
97    inputs: Vec<(DType, Vec<usize>)>,
98}
99
100impl Key {
101    fn new(inputs: &[Array]) -> Self {
102        Self {
103            stream: default_stream(),
104            inputs: inputs
105                .iter()
106                .map(|a| (a.dtype(), a.shape().to_vec()))
107                .collect(),
108        }
109    }
110}
111
112/// A value of the traced graph.
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
114enum Ref {
115    /// The input at this position.
116    Input(usize),
117    /// A constant (captured or independent of the inputs).
118    Const(usize),
119    /// Output `.1` of step `.0`.
120    Step(usize, usize),
121}
122
123#[derive(Debug, Clone)]
124struct Step {
125    primitive: Primitive,
126    inputs: Vec<Ref>,
127    /// Dtype and shape of each output.
128    outputs: Vec<(DType, Vec<usize>)>,
129}
130
131/// A simplified graph, replayed on each call.
132#[derive(Debug)]
133struct Tape {
134    consts: Vec<Array>,
135    steps: Vec<Step>,
136    outputs: Vec<Ref>,
137}
138
139impl Tape {
140    /// Traces `f` on placeholders like `inputs`, simplifies the graph and, on the GPU,
141    /// fuses element-wise chains.
142    fn trace(
143        f: &dyn Fn(&[Array]) -> Result<Vec<Array>>,
144        inputs: &[Array],
145        stream: Stream,
146    ) -> Result<Self> {
147        let placeholders = inputs
148            .iter()
149            .map(|a| Array::placeholder(a.dtype(), a.shape()))
150            .collect::<Result<Vec<_>>>()?;
151        let outputs = f(&placeholders)?;
152        let mut tape = Builder::new(&placeholders).build(&outputs);
153        if stream.device.kind == DeviceKind::Gpu {
154            tape.fuse(inputs);
155        }
156        Ok(tape)
157    }
158
159    fn replay(&self, inputs: &[Array]) -> Result<Vec<Array>> {
160        let mut values: Vec<Vec<Array>> = Vec::with_capacity(self.steps.len());
161        let get = |r: &Ref, values: &[Vec<Array>]| match *r {
162            Ref::Input(i) => inputs[i].clone(),
163            Ref::Const(c) => self.consts[c].clone(),
164            Ref::Step(s, o) => values[s][o].clone(),
165        };
166        for step in &self.steps {
167            let args = step.inputs.iter().map(|r| get(r, &values)).collect();
168            values.push(Array::from_primitive(step.primitive.clone(), args)?);
169        }
170        Ok(self.outputs.iter().map(|r| get(r, &values)).collect())
171    }
172
173    fn dtype(&self, r: Ref, inputs: &[Array]) -> DType {
174        match r {
175            Ref::Input(i) => inputs[i].dtype(),
176            Ref::Const(c) => self.consts[c].dtype(),
177            Ref::Step(s, o) => self.steps[s].outputs[o].0,
178        }
179    }
180
181    /// Replaces chains of element-wise steps by fused steps (MLX's `compile_fuse`). Walking
182    /// back from a group's last step, an element-wise step joins the group when every step
183    /// that consumes it is in the group and the kernel limits hold. A joining step that is
184    /// also an output of the function becomes another output of the fused step. So nothing
185    /// outside a group consumes its inner values, and the graph stays acyclic.
186    fn fuse(&mut self, inputs: &[Array]) {
187        let n = self.steps.len();
188        let fusable: Vec<bool> = self
189            .steps
190            .iter()
191            .map(|s| {
192                let dtypes: Vec<DType> = s.inputs.iter().map(|&r| self.dtype(r, inputs)).collect();
193                s.outputs.len() == 1 && Fused::supports(&s.primitive, &dtypes)
194            })
195            .collect();
196        let mut consumers: Vec<Vec<usize>> = vec![Vec::new(); n];
197        for (k, s) in self.steps.iter().enumerate() {
198            for r in &s.inputs {
199                if let Ref::Step(j, _) = *r {
200                    consumers[j].push(k);
201                }
202            }
203        }
204        let mut is_output = vec![false; n];
205        for r in &self.outputs {
206            if let Ref::Step(j, _) = *r {
207                is_output[j] = true;
208            }
209        }
210        let step_inputs = |k: usize| {
211            self.steps[k].inputs.iter().filter_map(|r| match *r {
212                Ref::Step(j, _) => Some(j),
213                _ => None,
214            })
215        };
216        // Per step: its group's root (last step), if fused.
217        let mut group: Vec<Option<usize>> = vec![None; n];
218        // Per root: the members (ascending) and the external inputs.
219        let mut groups: HashMap<usize, (Vec<usize>, Vec<Ref>)> = HashMap::new();
220        for root in (0..n).rev() {
221            if group[root].is_some() || !fusable[root] {
222                continue;
223            }
224            let mut members = HashSet::from([root]);
225            let mut external: Vec<Ref> = Vec::new();
226            let mut outputs = 1;
227            let add_inputs = |k: usize, external: &mut Vec<Ref>| {
228                for &r in &self.steps[k].inputs {
229                    if !external.contains(&r) {
230                        external.push(r);
231                    }
232                }
233            };
234            add_inputs(root, &mut external);
235            // Highest first: a step is decided after every step that could consume it.
236            let mut candidates: BinaryHeap<usize> = step_inputs(root).collect();
237            let mut seen = HashSet::new();
238            while let Some(j) = candidates.pop() {
239                if !seen.insert(j)
240                    || group[j].is_some()
241                    || !fusable[j]
242                    || members.len() >= MAX_FUSED_STEPS
243                    || !consumers[j].iter().all(|c| members.contains(c))
244                {
245                    continue;
246                }
247                let new = self.steps[j]
248                    .inputs
249                    .iter()
250                    .filter(|r| !external.contains(r))
251                    .collect::<HashSet<_>>()
252                    .len();
253                let arrays = external.len() - 1 + new + outputs + usize::from(is_output[j]);
254                if arrays > MAX_FUSED_ARRAYS {
255                    continue;
256                }
257                members.insert(j);
258                outputs += usize::from(is_output[j]);
259                external.retain(|&r| r != Ref::Step(j, 0));
260                add_inputs(j, &mut external);
261                candidates.extend(step_inputs(j));
262            }
263            if members.len() < 2 || external.len() + outputs > MAX_FUSED_ARRAYS {
264                continue;
265            }
266            let mut members: Vec<usize> = members.into_iter().collect();
267            members.sort_unstable();
268            for &m in &members {
269                group[m] = Some(root);
270            }
271            groups.insert(root, (members, external));
272        }
273        if groups.is_empty() {
274            return;
275        }
276        // Rebuild: each group becomes one step at its root's position. `index` maps a
277        // kept or fused step's outputs to the new step (and the output offset in it).
278        let mut index: Vec<Option<(usize, usize)>> = vec![None; n];
279        let remap = |r: Ref, index: &[Option<(usize, usize)>]| match r {
280            Ref::Step(j, o) => {
281                let (k, offset) = index[j].expect("defined before use");
282                Ref::Step(k, offset + o)
283            }
284            r => r,
285        };
286        let mut steps = Vec::with_capacity(n);
287        for k in 0..n {
288            let step = match (group[k], groups.get(&k)) {
289                (Some(_), None) => continue, // inside a group: defined with its root
290                (Some(_), Some((members, external))) => {
291                    let position: HashMap<usize, usize> =
292                        members.iter().enumerate().map(|(p, &m)| (m, p)).collect();
293                    let graph = members
294                        .iter()
295                        .map(|&m| {
296                            let args = self.steps[m]
297                                .inputs
298                                .iter()
299                                .map(|r| match *r {
300                                    Ref::Step(j, _) if position.contains_key(&j) => {
301                                        FusedRef::Step(position[&j])
302                                    }
303                                    r => FusedRef::Input(
304                                        external.iter().position(|&e| e == r).expect("external"),
305                                    ),
306                                })
307                                .collect();
308                            (self.steps[m].primitive.clone(), args)
309                        })
310                        .collect();
311                    // Outputs: the members the function returns, then the root.
312                    let outs: Vec<usize> = members
313                        .iter()
314                        .copied()
315                        .filter(|&m| m != k && is_output[m])
316                        .chain([k])
317                        .collect();
318                    for (o, &m) in outs.iter().enumerate() {
319                        index[m] = Some((steps.len(), o));
320                    }
321                    let dtypes: Vec<DType> =
322                        external.iter().map(|&r| self.dtype(r, inputs)).collect();
323                    let positions: Vec<usize> = outs.iter().map(|m| position[m]).collect();
324                    let Ok(f) = Fused::new(&dtypes, graph, &positions) else {
325                        unreachable!("every member is fusable")
326                    };
327                    steps.push(Step {
328                        primitive: Primitive::Compiled(f),
329                        inputs: external.iter().map(|&r| remap(r, &index)).collect(),
330                        outputs: outs
331                            .iter()
332                            .map(|&m| self.steps[m].outputs[0].clone())
333                            .collect(),
334                    });
335                    continue;
336                }
337                (None, _) => {
338                    let s = &self.steps[k];
339                    Step {
340                        primitive: s.primitive.clone(),
341                        inputs: s.inputs.iter().map(|&r| remap(r, &index)).collect(),
342                        outputs: s.outputs.clone(),
343                    }
344                }
345            };
346            index[k] = Some((steps.len(), 0));
347            steps.push(step);
348        }
349        self.outputs = self.outputs.iter().map(|&r| remap(r, &index)).collect();
350        self.steps = steps;
351    }
352}
353
354/// Builds a tape from a traced graph, simplifying as it goes.
355struct Builder {
356    /// Resolved arrays, by operation id and output index.
357    refs: HashMap<(usize, usize), Ref>,
358    consts: Vec<Array>,
359    /// Constant scalars by value, for merging.
360    scalars: HashMap<(DType, Vec<usize>, u64), usize>,
361    steps: Vec<Step>,
362    /// Steps by primitive and inputs, for common-subexpression elimination.
363    seen: HashMap<(Primitive, Vec<Ref>), usize>,
364}
365
366impl Builder {
367    fn new(placeholders: &[Array]) -> Self {
368        let refs = placeholders
369            .iter()
370            .enumerate()
371            .map(|(i, p)| ((p.id(), p.output_index()), Ref::Input(i)))
372            .collect();
373        Self {
374            refs,
375            consts: Vec::new(),
376            scalars: HashMap::new(),
377            steps: Vec::new(),
378            seen: HashMap::new(),
379        }
380    }
381
382    fn key(a: &Array) -> (usize, usize) {
383        (a.id(), a.output_index())
384    }
385
386    /// Visits the graph of `outputs` inputs first (iteratively, so depth is limited only
387    /// by memory) and returns the tape.
388    fn build(mut self, outputs: &[Array]) -> Tape {
389        let mut stack: Vec<(Array, bool)> = outputs.iter().map(|a| (a.clone(), false)).collect();
390        while let Some((a, expanded)) = stack.pop() {
391            if self.refs.contains_key(&Self::key(&a)) {
392                continue;
393            }
394            let inputs = a.inputs();
395            if expanded || inputs.is_empty() {
396                self.resolve(&a, &inputs);
397                continue;
398            }
399            stack.push((a, true));
400            stack.extend(
401                inputs
402                    .into_iter()
403                    .filter(|i| !self.refs.contains_key(&Self::key(i)))
404                    .map(|i| (i, false)),
405            );
406        }
407        let outputs = outputs.iter().map(|a| self.refs[&Self::key(a)]).collect();
408        Tape {
409            consts: self.consts,
410            steps: self.steps,
411            outputs,
412        }
413    }
414
415    /// Records the operation producing `a` (whose inputs are resolved): a constant if it
416    /// does not depend on an input, an existing value if it is an identity or computed
417    /// already, else a new step.
418    fn resolve(&mut self, a: &Array, inputs: &[Array]) {
419        let siblings = a.siblings();
420        let args: Vec<Ref> = inputs.iter().map(|i| self.refs[&Self::key(i)]).collect();
421        let primitive = a.primitive();
422        let Some(primitive) =
423            primitive.filter(|_| !args.iter().all(|r| matches!(r, Ref::Const(_))))
424        else {
425            for s in &siblings {
426                let c = self.constant(s);
427                self.refs.insert(Self::key(s), Ref::Const(c));
428            }
429            return;
430        };
431        if siblings.len() == 1 && is_identity(&primitive, &inputs[0], a) {
432            self.refs.insert(Self::key(a), args[0]);
433            return;
434        }
435        let k = match self.seen.get(&(primitive.clone(), args.clone())) {
436            Some(&k) => k,
437            None => {
438                self.steps.push(Step {
439                    primitive: primitive.clone(),
440                    inputs: args.clone(),
441                    outputs: siblings
442                        .iter()
443                        .map(|s| (s.dtype(), s.shape().to_vec()))
444                        .collect(),
445                });
446                let k = self.steps.len() - 1;
447                self.seen.insert((primitive, args), k);
448                k
449            }
450        };
451        for (o, s) in siblings.iter().enumerate() {
452            self.refs.insert(Self::key(s), Ref::Step(k, o));
453        }
454    }
455
456    /// The constant index of `a`, shared with an equal scalar constant if there is one.
457    fn constant(&mut self, a: &Array) -> usize {
458        let value = scalar_bits(a).or_else(|| {
459            // A broadcast scalar, as the operations make for scalar operands.
460            match (a.primitive(), a.inputs().as_slice()) {
461                (Some(Primitive::Broadcast(_)), [x]) => scalar_bits(x),
462                _ => None,
463            }
464        });
465        let key = value.map(|v| (a.dtype(), a.shape().to_vec(), v));
466        if let Some(&c) = key.as_ref().and_then(|k| self.scalars.get(k)) {
467            return c;
468        }
469        self.consts.push(a.clone());
470        let c = self.consts.len() - 1;
471        if let Some(key) = key {
472            self.scalars.insert(key, c);
473        }
474        c
475    }
476}
477
478/// Whether `primitive` maps `input` to `output` unchanged.
479fn is_identity(primitive: &Primitive, input: &Array, output: &Array) -> bool {
480    let same = input.shape() == output.shape() && input.strides() == output.strides();
481    match primitive {
482        Primitive::Broadcast(_) | Primitive::Reshape(_) | Primitive::StopGradient => same,
483        Primitive::Transpose(axes) => axes.iter().enumerate().all(|(i, &a)| i == a),
484        Primitive::AsType(d) => *d == input.dtype(),
485        _ => false,
486    }
487}
488
489/// The bits of an evaluated one-element array's value.
490fn scalar_bits(a: &Array) -> Option<u64> {
491    if a.size() != 1 || !a.is_evaluated() {
492        return None;
493    }
494    Some(match a.dtype() {
495        DType::Bool => u64::from(a.item::<bool>().ok()?),
496        DType::U8 => u64::from(a.item::<u8>().ok()?),
497        DType::U16 => u64::from(a.item::<u16>().ok()?),
498        DType::U32 => u64::from(a.item::<u32>().ok()?),
499        DType::U64 => a.item::<u64>().ok()?,
500        DType::I8 => a.item::<i8>().ok()? as u64,
501        DType::I16 => a.item::<i16>().ok()? as u64,
502        DType::I32 => a.item::<i32>().ok()? as u64,
503        DType::I64 => a.item::<i64>().ok()? as u64,
504        DType::F16 => u64::from(a.item::<f16>().ok()?.to_bits()),
505        DType::BF16 => u64::from(a.item::<bf16>().ok()?.to_bits()),
506        DType::F32 => u64::from(a.item::<f32>().ok()?.to_bits()),
507        DType::F64 => a.item::<f64>().ok()?.to_bits(),
508        DType::Complex64 => {
509            let z = a.item::<Complex64>().ok()?;
510            u64::from(z.re.to_bits()) << 32 | u64::from(z.im.to_bits())
511        }
512        _ => return None,
513    })
514}
515
516#[cfg(test)]
517mod tests {
518    use nuvec_core::{Device, add, exp, multiply, set_default_stream};
519
520    use super::*;
521
522    fn trace(f: &dyn Fn(&[Array]) -> Result<Vec<Array>>, inputs: &[Array], device: Device) -> Tape {
523        Tape::trace(f, inputs, Stream::default_for(device)).unwrap()
524    }
525
526    fn names(tape: &Tape) -> Vec<&'static str> {
527        tape.steps.iter().map(|s| s.primitive.name()).collect()
528    }
529
530    #[test]
531    fn version_is_set() {
532        assert!(!VERSION.is_empty());
533    }
534
535    #[test]
536    fn simplifies_identities_constants_and_common_subexpressions() {
537        set_default_stream(Stream::default_for(Device::cpu()));
538        let x = Array::from_slice(&[1.0f32, 2.0], &[2]).unwrap();
539        let f = |xs: &[Array]| {
540            let x = xs[0].astype(DType::F32)?.reshape(&[2])?;
541            let a = multiply(&x, 2.0)?; // a scalar constant ...
542            let b = multiply(&x, 2.0)?; // ... merged with this one, so `b` is `a`
543            Ok(vec![add(&a, &b)?, exp(&x)?, exp(&x)?])
544        };
545        let tape = trace(&f, std::slice::from_ref(&x), Device::cpu());
546        let mut ops = names(&tape);
547        ops.sort_unstable();
548        assert_eq!(ops, ["add", "exp", "multiply"]);
549        assert_eq!(tape.outputs[1], tape.outputs[2]);
550        let outs = tape.replay(&[x]).unwrap();
551        assert_eq!(outs[0].to_vec::<f32>().unwrap(), [4.0, 8.0]);
552    }
553
554    #[test]
555    fn fuses_element_wise_chains_on_the_gpu_only() {
556        set_default_stream(Stream::default_for(Device::cpu()));
557        let x = Array::from_slice(&[1.0f32, 2.0], &[2]).unwrap();
558        let w = Array::from_slice(&[1.0f32, 0.0, 0.0, 1.0], &[2, 2]).unwrap();
559        // matmul, then a chain whose middle value is also an output of the function: one
560        // kernel with two outputs.
561        let f = |xs: &[Array]| {
562            let h = nuvec_core::matmul(&xs[0].reshape(&[1, 2])?, &w)?;
563            let a = exp(&multiply(&h, 2.0)?)?;
564            let b = add(&multiply(&a, &a)?, 1.0)?;
565            Ok(vec![a, nuvec_core::sigmoid(&b)?])
566        };
567        assert_eq!(
568            names(&trace(&f, std::slice::from_ref(&x), Device::cpu())),
569            [
570                "reshape", "matmul", "multiply", "exp", "multiply", "add", "sigmoid"
571            ]
572        );
573        let tape = trace(&f, &[x], Device::gpu(0));
574        assert_eq!(names(&tape), ["reshape", "matmul", "compiled"]);
575        let Primitive::Compiled(fused) = &tape.steps[2].primitive else {
576            unreachable!()
577        };
578        assert_eq!((fused.len(), fused.dtypes().len()), (5, 2));
579        assert_eq!(tape.outputs, [Ref::Step(2, 0), Ref::Step(2, 1)]);
580    }
581
582    #[test]
583    fn a_shared_value_used_outside_a_group_stays_outside() {
584        let x = Array::from_slice(&[1.0f32, 2.0], &[2]).unwrap();
585        // `t` feeds the chain and a reduction: it cannot be fused into the chain. The
586        // broadcast sum is a view the fused kernel reads.
587        let f = |xs: &[Array]| {
588            let t = exp(&xs[0])?;
589            let s = nuvec_core::sum(&t, &[], true)?;
590            Ok(vec![add(&multiply(&t, 3.0)?, &s)?])
591        };
592        let tape = trace(&f, &[x], Device::gpu(0));
593        assert_eq!(names(&tape), ["exp", "sum", "broadcast_to", "compiled"]);
594    }
595}