Skip to main content

cubecl_runtime/
debug_source.rs

1//! The compile directory of the kernel debug data, found when a kernel compiles.
2//!
3//! `file!()` gives a path relative to the rustc working directory, usually the workspace root.
4//! A debugger or a profiler finds such a file only when it runs in that directory. The binary
5//! keeps the relative path, so `--remap-path-prefix` and `trim-paths` apply to it. When a kernel
6//! compiles, cubecl looks for the file, and gives the directory that contains it to the debug
7//! data. Thus only the debug data of the kernel has the absolute path, on the computer that has
8//! the source.
9//!
10//! If no directory has the files, and the kernel has their text (`Full`), cubecl can write the
11//! text into the source cache (`CUBECL_SOURCE_CACHE`) and give that directory.
12//!
13//! The search occurs one time for each file in each process, not for each kernel.
14
15use alloc::{
16    string::{String, ToString},
17    sync::Arc,
18    vec::Vec,
19};
20use core::{fmt::Write, hash::BuildHasher};
21use cubecl_ir::{debug::DebugState, settings::DebugInfo};
22use md5::{Digest, Md5};
23use std::{
24    collections::HashMap,
25    path::{Component, Path, PathBuf},
26    sync::{Mutex, OnceLock, PoisonError},
27};
28
29/// The variable that names the root, for a binary that does not run in its source tree.
30pub const SOURCE_ROOT_VAR: &str = "CUBECL_SOURCE_ROOT";
31
32/// The MD5 of the source text of each file of a kernel at `level`, by the path of the file. Empty
33/// below [`DebugInfo::Full`], as the macro records the texts only at `Full`.
34#[must_use]
35pub fn source_md5s(debug: &DebugState, level: DebugInfo) -> HashMap<&str, Arc<str>> {
36    match level {
37        DebugInfo::Full => debug
38            .sources()
39            .iter()
40            .map(|(path, text)| (path.as_str(), source_md5(text)))
41            .collect(),
42        _ => HashMap::new(),
43    }
44}
45
46/// The directory for the relative source paths of a kernel with the debug data `debug`. `md5s`
47/// are the MD5s of the texts that the kernel has ([`source_md5s`]). A file with an MD5 matches
48/// only a file with the same text.
49///
50/// The search roots are `CUBECL_SOURCE_ROOT`, then the working directory and its parents. If no
51/// root has a file, the directory is the [source cache](crate::config::compilation::CompilationConfig::source_cache),
52/// when it is set and the kernel has texts.
53/// Returns `None` when no directory has the files, or the directory is not UTF-8.
54#[must_use]
55pub fn kernel_source_root<S: BuildHasher>(
56    debug: &DebugState,
57    md5s: &HashMap<&str, Arc<str>, S>,
58) -> Option<String> {
59    let files = debug
60        .files()
61        .iter()
62        .map(|path| (path.as_str(), md5s.get(path.as_str()).map(AsRef::as_ref)));
63    source_root(files)
64        .or_else(|| {
65            let texts = debug
66                .sources()
67                .iter()
68                .filter_map(|(path, text)| Some((path.as_str(), *text, md5s.get(path.as_str())?)));
69            cached_root(source_cache()?, texts)
70        })
71        .and_then(|root| root.to_str().map(str::to_string))
72}
73
74/// The root of the first file in `files` that exists under a search root. Each item is a path
75/// and, if the kernel has the text of the file, the MD5 of that text.
76fn source_root<'a>(files: impl IntoIterator<Item = (&'a str, Option<&'a str>)>) -> Option<PathBuf> {
77    /// The root of each path and MD5, or `None` if no root has the file.
78    type Found = HashMap<(String, Option<String>), Option<PathBuf>>;
79    static FOUND: OnceLock<Mutex<Found>> = OnceLock::new();
80    let found = FOUND.get_or_init(Mutex::default);
81
82    files
83        .into_iter()
84        .filter(|(path, _)| Path::new(path).is_relative())
85        .find_map(|(path, md5)| {
86            let key = (path.to_string(), md5.map(str::to_string));
87            found
88                .lock()
89                .unwrap_or_else(PoisonError::into_inner)
90                .entry(key)
91                .or_insert_with(|| find_root(path, md5, search_roots()))
92                .clone()
93        })
94}
95
96/// `CUBECL_SOURCE_ROOT`, then the working directory and its parents. They are read one time.
97fn search_roots() -> &'static [PathBuf] {
98    static ROOTS: OnceLock<Vec<PathBuf>> = OnceLock::new();
99    ROOTS.get_or_init(|| {
100        let var = std::env::var_os(SOURCE_ROOT_VAR).map(PathBuf::from);
101        let cwd = std::env::current_dir().ok();
102        let parents = cwd
103            .iter()
104            .flat_map(|cwd| cwd.ancestors().map(Path::to_path_buf));
105        var.into_iter().chain(parents).collect()
106    })
107}
108
109/// The first of `roots` that contains the file `path`. With `md5`, the file must have that MD5.
110fn find_root<'a>(
111    path: &str,
112    md5: Option<&str>,
113    roots: impl IntoIterator<Item = &'a PathBuf>,
114) -> Option<PathBuf> {
115    roots
116        .into_iter()
117        .find(|root| {
118            let file = root.join(path);
119            match md5 {
120                None => file.is_file(),
121                Some(md5) => std::fs::read(&file).is_ok_and(|bytes| md5_hex(&bytes) == md5),
122            }
123        })
124        .cloned()
125}
126
127/// The source cache of the configuration, read one time.
128fn source_cache() -> Option<&'static Path> {
129    use crate::config::{CubeClRuntimeConfig, RuntimeConfig};
130
131    static CACHE: OnceLock<Option<PathBuf>> = OnceLock::new();
132    CACHE
133        .get_or_init(|| CubeClRuntimeConfig::get().compilation.source_cache.clone())
134        .as_deref()
135}
136
137/// The directory in `cache` that has the files `texts`, as `<cache>/<tree>/<path>`. Each item is
138/// a relative path, its text and the MD5 of the text. `<tree>` is the MD5 of all paths and MD5s,
139/// so a different text gets a different directory, and the same files share one.
140///
141/// A path that is not under its directory, as `../k.rs`, is not written. Returns `None` when no
142/// file is in the directory.
143fn cached_root<'a>(
144    cache: &Path,
145    texts: impl IntoIterator<Item = (&'a str, &'a str, &'a Arc<str>)>,
146) -> Option<PathBuf> {
147    /// The directory of each cache and tree, or `None` if no file could be written.
148    type Trees = HashMap<(PathBuf, String), Option<PathBuf>>;
149    static TREES: OnceLock<Mutex<Trees>> = OnceLock::new();
150
151    let mut texts: Vec<_> = texts
152        .into_iter()
153        .filter(|(path, ..)| is_under(Path::new(path)))
154        .collect();
155    if texts.is_empty() {
156        return None;
157    }
158    texts.sort_unstable_by_key(|(path, ..)| *path);
159    let tree = md5_hex(texts.iter().fold(String::new(), |mut key, (path, _, md5)| {
160        let _ = writeln!(key, "{path}\0{md5}");
161        key
162    }));
163    TREES
164        .get_or_init(Mutex::default)
165        .lock()
166        .unwrap_or_else(PoisonError::into_inner)
167        .entry((cache.to_path_buf(), tree))
168        .or_insert_with_key(|(cache, tree)| {
169            let root = cache.join(tree);
170            let written = texts
171                .iter()
172                .filter(|(path, text, _)| write_once(&root.join(path), text))
173                .count();
174            (written > 0).then_some(root)
175        })
176        .clone()
177}
178
179/// True when the relative path `path` names a file under its directory.
180fn is_under(path: &Path) -> bool {
181    path.components().all(|c| matches!(c, Component::Normal(_)))
182        && path.components().next().is_some()
183}
184
185/// Writes `text` to `file`, unless the file exists. Another process can write the same file, so
186/// the text goes to a temporary file first. Returns whether the file exists after the call.
187fn write_once(file: &Path, text: &str) -> bool {
188    if file.is_file() {
189        return true;
190    }
191    let mut temporary = std::ffi::OsString::from(file);
192    temporary.push(alloc::format!(".{}.tmp", std::process::id()));
193    let result = file
194        .parent()
195        .map_or(Ok(()), std::fs::create_dir_all)
196        .and_then(|()| std::fs::write(&temporary, text))
197        .and_then(|()| std::fs::rename(&temporary, file));
198    if let Err(err) = result {
199        let _ = std::fs::remove_file(&temporary);
200        log::warn!("The source cache cannot write {}: {err}", file.display());
201    }
202    file.is_file()
203}
204
205/// The MD5 of the source text `text`, calculated one time for each text in each process.
206///
207/// The text is a `&'static str` from `include_str!`, so its address identifies it. One MD5 takes
208/// approximately 25 µs for a text of 20 KB. If the first compile is too slow, `cubecl-macros` can
209/// calculate the MD5 at build time and give it to `debug_source_expand` with the text.
210fn source_md5(text: &'static str) -> Arc<str> {
211    /// The MD5 of each text, by its address and length.
212    type Md5s = HashMap<(usize, usize), Arc<str>>;
213    static MD5S: OnceLock<Mutex<Md5s>> = OnceLock::new();
214    MD5S.get_or_init(Mutex::default)
215        .lock()
216        .unwrap_or_else(PoisonError::into_inner)
217        .entry((text.as_ptr().addr(), text.len()))
218        .or_insert_with(|| md5_hex(text).into())
219        .clone()
220}
221
222/// The MD5 of `text`, in lowercase hexadecimal.
223pub fn md5_hex(text: impl AsRef<[u8]>) -> String {
224    Md5::digest(text)
225        .iter()
226        .fold(String::with_capacity(32), |mut hex, byte| {
227            let _ = write!(hex, "{byte:02x}");
228            hex
229        })
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    /// A new directory `name` under the temporary directory.
237    fn temporary(name: &str) -> PathBuf {
238        let dir = std::env::temp_dir().join(alloc::format!(
239            "cubecl-debug-source-{}-{name}",
240            std::process::id()
241        ));
242        let _ = std::fs::remove_dir_all(&dir);
243        std::fs::create_dir_all(&dir).unwrap();
244        dir
245    }
246
247    /// A new directory with the file `src/k.rs`, which has the text `text`.
248    fn tree(name: &str, text: &str) -> PathBuf {
249        let root = temporary(name);
250        std::fs::create_dir_all(root.join("src")).unwrap();
251        std::fs::write(root.join("src/k.rs"), text).unwrap();
252        root
253    }
254
255    #[test]
256    fn the_first_root_that_has_the_file() {
257        let root = tree("first", "fn k() {}");
258        let roots = [root.join("src"), root.clone(), PathBuf::from("/")];
259        assert_eq!(find_root("src/k.rs", None, &roots), Some(root.clone()));
260        assert_eq!(find_root("src/none.rs", None, &roots), None);
261        std::fs::remove_dir_all(root).unwrap();
262    }
263
264    /// With the MD5 of the compiled text, a file that was changed is not the source.
265    #[test]
266    fn a_file_with_a_different_text_is_not_the_source() {
267        let old = tree("old", "fn k() {}");
268        let new = tree("new", "fn k() { changed }");
269        let md5 = md5_hex("fn k() {}");
270        let roots = [new.clone(), old.clone()];
271        assert_eq!(find_root("src/k.rs", Some(&md5), &roots), Some(old.clone()));
272        std::fs::remove_dir_all(old).unwrap();
273        std::fs::remove_dir_all(new).unwrap();
274    }
275
276    /// An absolute path needs no directory. The compiler tests check that the search finds the
277    /// workspace root for a relative path.
278    #[test]
279    fn an_absolute_path_has_no_root() {
280        assert_eq!(source_root([("/abs/k.rs", None)]), None);
281    }
282
283    /// The cache gets each text under one directory for the files, and the same files, in any
284    /// order, get the same directory. A different text gets a different directory.
285    #[test]
286    fn the_cache_has_the_texts_under_one_directory() {
287        let cache = temporary("cache");
288        let (k, f) = ("fn k() {}", "fn f() {}");
289        let (k_md5, f_md5): (Arc<str>, Arc<str>) = (md5_hex(k).into(), md5_hex(f).into());
290        let files = [("src/k.rs", k, &k_md5), ("lib/f.rs", f, &f_md5)];
291
292        let root = cached_root(&cache, files).expect("a directory");
293        assert!(root.starts_with(&cache), "{}", root.display());
294        assert_eq!(std::fs::read_to_string(root.join("src/k.rs")).unwrap(), k);
295        assert_eq!(std::fs::read_to_string(root.join("lib/f.rs")).unwrap(), f);
296        let [k_file, f_file] = files;
297        assert_eq!(cached_root(&cache, [f_file, k_file]), Some(root.clone()));
298
299        let changed = "fn k() { changed }";
300        let changed_md5: Arc<str> = md5_hex(changed).into();
301        let other = cached_root(&cache, [("src/k.rs", changed, &changed_md5)]);
302        assert!(other.is_some_and(|other| other != root));
303        std::fs::remove_dir_all(cache).unwrap();
304    }
305
306    /// A path out of its directory is not written, so the cache writes only under itself.
307    #[test]
308    fn the_cache_writes_only_under_itself() {
309        let cache = temporary("escape");
310        let text = "fn k() {}";
311        let md5: Arc<str> = md5_hex(text).into();
312        for path in ["../k.rs", "/k.rs", "./k.rs", ""] {
313            assert_eq!(cached_root(&cache.join("c"), [(path, text, &md5)]), None);
314        }
315        assert!(!cache.join("k.rs").exists());
316        assert!(!cache.join("c").exists());
317        std::fs::remove_dir_all(cache).unwrap();
318    }
319}