Skip to main content

rlx_runtime/
aot_cache.rs

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