Skip to main content

rlx_runtime/
aot_cache.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, version 3.
7
8//! AOT cache — persist optimized LIR modules and reload for backend compile.
9
10use std::fmt;
11use std::fs;
12use std::io;
13use std::path::{Path, PathBuf};
14
15use rlx_ir::DimBinding;
16use rlx_ir::Graph;
17use rlx_ir::LirFingerprint;
18use rlx_ir::LirModule;
19use rlx_ir::hir::HirModule;
20use rlx_opt::CompileResult;
21
22use crate::stages;
23use crate::{CompileOptions, CompiledGraph, Device};
24
25/// Errors from [`AotCache`] disk / compile operations.
26#[derive(Debug)]
27pub enum AotCacheError {
28    Io(io::Error),
29    Serde(String),
30    Lower(rlx_ir::hir::LowerError),
31}
32
33impl fmt::Display for AotCacheError {
34    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35        match self {
36            Self::Io(e) => write!(f, "{e}"),
37            Self::Serde(e) => write!(f, "serde: {e}"),
38            Self::Lower(e) => write!(f, "{e}"),
39        }
40    }
41}
42
43impl std::error::Error for AotCacheError {}
44
45impl From<io::Error> for AotCacheError {
46    fn from(e: io::Error) -> Self {
47        Self::Io(e)
48    }
49}
50
51impl From<rlx_ir::hir::LowerError> for AotCacheError {
52    fn from(e: rlx_ir::hir::LowerError) -> Self {
53        Self::Lower(e)
54    }
55}
56
57/// On-disk AOT cache for optimized LIR modules.
58pub struct AotCache {
59    root: PathBuf,
60}
61
62impl AotCache {
63    pub fn new(root: impl Into<PathBuf>) -> Self {
64        Self { root: root.into() }
65    }
66
67    pub fn root(&self) -> &Path {
68        &self.root
69    }
70
71    fn lir_path(&self, key: &str) -> PathBuf {
72        self.root.join(format!("{key}.lir.bin"))
73    }
74
75    fn meta_path(&self, key: &str) -> PathBuf {
76        self.root.join(format!("{key}.meta.json"))
77    }
78
79    /// Persist an optimized LIR module. Returns its compile fingerprint.
80    pub fn put_lir(&self, key: &str, lir: &LirModule) -> io::Result<LirFingerprint> {
81        fs::create_dir_all(&self.root)?;
82        let fp = LirFingerprint::of(lir);
83        // Compact binary LIR — ~2.5× smaller than JSON and ~10× faster to load
84        // (baked constants are raw f32 bytes, not ASCII); this is the dominant
85        // per-process compile cost for constant-heavy graphs (STFT vocoders).
86        let bytes = rlx_ir::lir_to_bytes(lir)
87            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
88        fs::write(self.lir_path(key), bytes)?;
89        fs::write(
90            self.meta_path(key),
91            format!("{{\"fingerprint\":{}}}\n", fp.0),
92        )?;
93        Ok(fp)
94    }
95
96    /// Load a previously stored LIR module.
97    pub fn get_lir(&self, key: &str) -> io::Result<LirModule> {
98        let bytes = fs::read(self.lir_path(key))?;
99        rlx_ir::lir_from_bytes(&bytes)
100            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))
101    }
102
103    pub fn contains(&self, key: &str) -> bool {
104        self.lir_path(key).is_file()
105    }
106
107    /// MIR graph → fusion pipeline → cached LIR → backend executable.
108    ///
109    /// On a cache hit only the backend compile runs (fusion / vmap /
110    /// autodiff already done). Keys must be unique per graph + options
111    /// fingerprint the caller encodes in `key`.
112    pub fn compile_graph_cached(
113        &self,
114        key: &str,
115        device: Device,
116        graph: Graph,
117        options: &CompileOptions,
118    ) -> Result<CompiledGraph, AotCacheError> {
119        if self.contains(key) {
120            match self.get_lir(key) {
121                Ok(lir) => return Ok(self.compile_lir(device, lir, options)),
122                Err(e) => {
123                    eprintln!("[aot] cache miss (reload failed for {key}): {e}");
124                    let _ = fs::remove_file(self.lir_path(key));
125                    let _ = fs::remove_file(self.meta_path(key));
126                }
127            }
128        }
129        let result = stages::compile_graph_stages(device, graph, options);
130        stages::maybe_log_fusion(&result.fusion);
131        self.put_lir(key, &result.lir)?;
132        Ok(self.compile_lir(device, result.lir, options))
133    }
134
135    /// Compile HIR through the fusion pipeline, cache LIR, return executable.
136    pub fn compile_hir_cached(
137        &self,
138        key: &str,
139        device: Device,
140        hir: HirModule,
141        options: &CompileOptions,
142    ) -> Result<CompiledGraph, AotCacheError> {
143        if self.contains(key) {
144            match self.get_lir(key) {
145                Ok(lir) => return Ok(self.compile_lir(device, lir, options)),
146                Err(e) => {
147                    // Stale / schema-mismatched AOT blob — drop and recompile.
148                    eprintln!("[aot] cache miss (reload failed for {key}): {e}");
149                    let _ = fs::remove_file(self.lir_path(key));
150                    let _ = fs::remove_file(self.meta_path(key));
151                }
152            }
153        }
154        let result = stages::compile_hir_stages(device, hir, options)?;
155        stages::maybe_log_fusion(&result.fusion);
156        self.put_lir(key, &result.lir)?;
157        Ok(self.compile_lir(device, result.lir, options))
158    }
159
160    /// Specialize a cached dynamic LIR template and persist the bound variant.
161    pub fn specialize_cached(
162        &self,
163        base_key: &str,
164        binding: &DimBinding,
165        device: Device,
166        template: &CompileResult,
167        options: &CompileOptions,
168    ) -> Result<CompiledGraph, AotCacheError> {
169        let spec_key = format!("{base_key}__{}", binding_hash(binding));
170        if self.contains(&spec_key) {
171            match self.get_lir(&spec_key) {
172                Ok(lir) => return Ok(self.compile_lir(device, lir, options)),
173                Err(e) => {
174                    eprintln!("[aot] cache miss (reload failed for {spec_key}): {e}");
175                    let _ = fs::remove_file(self.lir_path(&spec_key));
176                    let _ = fs::remove_file(self.meta_path(&spec_key));
177                }
178            }
179        }
180        let pipe = stages::pipeline_for(device, options);
181        let specialized = template.specialize(&pipe, binding);
182        self.put_lir(&spec_key, &specialized.lir)?;
183        Ok(self.compile_lir(device, specialized.lir, options))
184    }
185
186    fn compile_lir(
187        &self,
188        device: Device,
189        lir: LirModule,
190        options: &CompileOptions,
191    ) -> CompiledGraph {
192        let backend = crate::registry::backend_for(device).expect("backend registered");
193        let executable = backend.compile_lir(lir, options);
194        CompiledGraph::new(executable, device)
195    }
196}
197
198fn binding_hash(binding: &DimBinding) -> u64 {
199    use std::collections::hash_map::DefaultHasher;
200    use std::hash::{Hash, Hasher};
201    let mut h = DefaultHasher::new();
202    for (sym, size) in binding.iter() {
203        sym.hash(&mut h);
204        size.hash(&mut h);
205    }
206    h.finish()
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use rlx_ir::DType;
213    use rlx_ir::Shape;
214
215    #[test]
216    fn aot_lir_roundtrip_on_disk() {
217        let dir = std::env::temp_dir().join(format!("rlx_aot_{}", std::process::id()));
218        let cache = AotCache::new(&dir);
219        let mut hir = HirModule::new("aot");
220        let x = hir.input("x", Shape::new(&[1, 4], DType::F32));
221        let w = hir.param("w", Shape::new(&[4, 2], DType::F32));
222        let y = hir.linear(x, w, None, None, Shape::new(&[1, 2], DType::F32));
223        hir.set_outputs(vec![y]);
224        let opts = CompileOptions::new();
225        let _compiled = cache
226            .compile_hir_cached("tiny", Device::Cpu, hir, &opts)
227            .expect("compile + cache");
228        assert!(cache.contains("tiny"));
229        let lir = cache.get_lir("tiny").expect("reload LIR");
230        assert_eq!(lir.name(), "aot");
231        fs::remove_dir_all(&dir).ok();
232    }
233}