Skip to main content

hrx/
loom.rs

1//! In-process Loom compilation with reusable indexed modules and verified disk artifacts.
2#[allow(
3    non_camel_case_types,
4    non_snake_case,
5    non_upper_case_globals,
6    dead_code,
7    missing_docs,
8    unsafe_op_in_unsafe_fn,
9    clippy::all
10)]
11mod ffi;
12mod native;
13mod report;
14mod source;
15use crate::{
16    Error, Result,
17    bundle::{self, Lock},
18};
19pub use report::{
20    CompileReport, EntryChange, EntryResources, Expansion, ReportGuidance, ReportSuggestion,
21    WaitCounts, WaitReason, WaitReasonChange,
22};
23use serde::{Deserialize, Serialize};
24pub use source::{CxxSource, CxxStandard, Source};
25use std::{
26    collections::{BTreeMap, HashMap, HashSet},
27    fs,
28    num::NonZeroUsize,
29    path::{Path, PathBuf},
30    sync::{Arc, Mutex},
31};
32
33/// How seriously the compiler meant a diagnostic. Ordered, so callers can
34/// filter with a comparison rather than a magic number: a spill remark and a
35/// type error arrive in the same list, and only one of them is worth a reader.
36#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
37#[serde(rename_all = "lowercase")]
38pub enum Severity {
39    /// A remark: informational, including backend resource notes.
40    Note,
41    /// A warning: compilation continued.
42    Warning,
43    /// An error: compilation of this specialization failed.
44    Error,
45}
46impl From<u32> for Severity {
47    /// Unknown native severities are treated as errors, so a future compiler
48    /// cannot make a diagnostic disappear from a caller's error filter.
49    fn from(native: u32) -> Self {
50        match native {
51            0 => Self::Note,
52            1 => Self::Warning,
53            _ => Self::Error,
54        }
55    }
56}
57
58/// A compiler diagnostic copied out of native result storage.
59#[derive(Clone, Debug, Serialize, Deserialize)]
60pub struct Diagnostic {
61    /// How seriously the compiler meant this diagnostic.
62    pub severity: Severity,
63    /// Stable compiler diagnostic code.
64    pub code: String,
65    /// Rendered diagnostic message.
66    pub message: String,
67    /// Full compiler-rendered diagnostic, including source context when available.
68    pub formatted_text: String,
69    /// Named values in diagnostic-definition order.
70    pub parameters: Vec<DiagnosticParameter>,
71    /// Diagnostic source identifier, when supplied by the compiler.
72    #[serde(default)]
73    pub source: String,
74    /// One-based source line, or zero when unavailable.
75    pub line: u32,
76    /// One-based source column, or zero when unavailable.
77    pub column: u32,
78    /// Labeled locations providing context, in compiler order.
79    #[serde(default)]
80    pub related_locations: Vec<RelatedLocation>,
81    /// Additional locations omitted by the compiler's diagnostic limit.
82    #[serde(default)]
83    pub related_location_omitted_count: usize,
84}
85
86/// One compiler-rendered diagnostic argument.
87#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
88pub struct DiagnosticParameter {
89    /// Stable name from the compiler diagnostic definition.
90    pub name: String,
91    /// Canonical rendered value.
92    pub value: String,
93}
94
95/// A source location related to a compiler diagnostic, owned by Rust.
96#[derive(Clone, Debug, Serialize, Deserialize)]
97pub struct RelatedLocation {
98    /// Relationship to the primary diagnostic, such as a previous use.
99    pub label: String,
100    /// Source identifier, even when the compiler did not retain source text.
101    pub source: String,
102    /// One-based starting line, or zero when unavailable.
103    pub line: u32,
104    /// One-based starting column, or zero when unavailable.
105    pub column: u32,
106    /// One-based ending line, or zero when unavailable.
107    pub end_line: u32,
108    /// One-based ending column, or zero when unavailable.
109    pub end_column: u32,
110}
111/// AMDGPU workgroup scheduling domain. Explicit modes require GFX11/GFX12.
112#[derive(Clone, Copy, Debug, Default, Hash, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(rename_all = "snake_case")]
114pub enum ProcessorMode {
115    /// Preserve the compiler's default for the selected target.
116    #[default]
117    Default,
118    /// Schedule each workgroup within a single compute unit (GFX11/GFX12).
119    ComputeUnit,
120    /// Schedule across a workgroup processor (GFX11/GFX12).
121    WorkgroupProcessor,
122}
123
124/// Assertion classes inserted at the target's semantic assertion boundary.
125#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
126pub struct SanitizerChecks {
127    /// Memory access assertions (requires the target's access-shadow runtime).
128    pub access: bool,
129    /// SSA value assertions.
130    pub value: bool,
131    /// Operation assertions.
132    pub operation: bool,
133    /// Data-race observations (requires the target's race-shadow runtime).
134    pub race: bool,
135}
136impl SanitizerChecks {
137    pub(super) fn bits(self) -> u64 {
138        u64::from(self.access)
139            | (u64::from(self.value) << 1)
140            | (u64::from(self.operation) << 2)
141            | (u64::from(self.race) << 3)
142    }
143}
144/// Target behavior on a failed sanitizer assertion.
145#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
146pub enum SanitizerReporting {
147    /// Native target's structured report path.
148    #[default]
149    Default,
150    /// Direct fatal trap without structured reports.
151    Trap,
152    /// Structured report without a direct trap.
153    ReportOnly,
154}
155/// Native compiler instrumentation. Runtime-required globals are described by
156/// the artifact manifest; compiling does not supply a sanitizer runtime.
157#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
158pub struct SanitizerOptions {
159    /// Independent classes of assertions.
160    pub checks: SanitizerChecks,
161    /// Behavior of failed assertions.
162    pub reporting: SanitizerReporting,
163}
164
165/// Compiler target and limits for concurrent workspaces and cached modules.
166#[derive(Clone, Debug)]
167pub struct CompilerOptions {
168    /// Maximum number of simultaneous compilations through this compiler.
169    pub workers: NonZeroUsize,
170    /// Architecture used by the Loom target profile and artifact cache.
171    pub target: crate::Target,
172    /// Maximum retained source modules; zero disables module caching.
173    /// Eviction chooses an arbitrary entry, not the least recently used module.
174    pub module_cache_capacity: usize,
175    /// Workgroup scheduling policy for the selected AMDGPU target.
176    pub processor_mode: ProcessorMode,
177    /// Instrumentation included in pipeline, shared compiler, and artifact identities.
178    pub sanitizer: SanitizerOptions,
179}
180impl Default for CompilerOptions {
181    fn default() -> Self {
182        Self {
183            target: crate::Target::default(),
184            module_cache_capacity: 64,
185            processor_mode: ProcessorMode::Default,
186            sanitizer: SanitizerOptions::default(),
187            workers: NonZeroUsize::new(
188                std::thread::available_parallelism()
189                    .map_or(1, NonZeroUsize::get)
190                    .min(4),
191            )
192            .unwrap(),
193        }
194    }
195}
196impl CompilerOptions {
197    /// Default compiler limits for an explicit device target.
198    #[must_use]
199    pub fn for_target(target: &crate::Target) -> Self {
200        Self {
201            target: target.clone(),
202            ..Self::default()
203        }
204    }
205    /// Default compiler limits targeting the device behind `stream`.
206    #[must_use]
207    pub fn for_stream(stream: &crate::Stream) -> Self {
208        Self::for_target(stream.target())
209    }
210}
211struct Inner {
212    // Indexes are released before prepared compiler state when the session ends.
213    modules: Mutex<HashMap<String, Arc<ModuleData>>>,
214    native: native::Prepared,
215    target: crate::Target,
216    workers: NonZeroUsize,
217    module_cache_capacity: usize,
218    processor_mode: ProcessorMode,
219    sanitizer: SanitizerOptions,
220    path: PathBuf,
221    identity: String,
222}
223
224#[derive(Hash, PartialEq, Eq)]
225struct SharedCompilerKey {
226    path: PathBuf,
227    target: String,
228    workers: NonZeroUsize,
229    module_cache_capacity: usize,
230    processor_mode: ProcessorMode,
231    sanitizer: SanitizerOptions,
232}
233/// A pinned compiler library and reusable native state, shared by cheap clones.
234#[derive(Clone)]
235pub struct Compiler(Arc<Inner>);
236impl std::fmt::Debug for Compiler {
237    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
238        f.debug_struct("Compiler")
239            .field("library", &self.0.path)
240            .field("identity", &self.0.identity)
241            .finish_non_exhaustive()
242    }
243}
244impl Compiler {
245    /// Select an explicit library, HRX_LOOM_LIBRARY, or the pinned native bundle.
246    /// This loads CPU compiler code but never opens a GPU. Library mappings stay
247    /// resident until process exit; contexts and workspaces remain session-owned.
248    /// To upgrade a loaded library, select a new path or restart the process.
249    pub fn resolve(library: Option<&Path>) -> Result<Self> {
250        Self::with_options(library, CompilerOptions::default())
251    }
252    /// Resolve the shared compiler configured for a stream's actual device target.
253    pub fn for_stream(library: Option<&Path>, stream: &crate::Stream) -> Result<Self> {
254        Self::shared(library, CompilerOptions::for_stream(stream))
255    }
256    /// Resolve the shared compiler configured for an explicit device target.
257    pub fn for_target(library: Option<&Path>, target: &crate::Target) -> Result<Self> {
258        Self::shared(library, CompilerOptions::for_target(target))
259    }
260    /// The compiler for this library, target and limits, resolved once per process.
261    ///
262    /// Resolving is not cheap: it canonicalizes the library path and digests
263    /// the whole shared object, twice. A consumer that asks for a compiler per
264    /// kernel pays that per kernel, so every consumer memoized it themselves.
265    ///
266    /// The cache includes all compiler options. `HRX_LOOM_LIBRARY` is resolved
267    /// on each call rather than frozen at the first.
268    pub fn shared(library: Option<&Path>, options: CompilerOptions) -> Result<Self> {
269        static RESOLVED: Mutex<Option<HashMap<SharedCompilerKey, Compiler>>> = Mutex::new(None);
270        let key = SharedCompilerKey {
271            path: Self::resolved_library(library)?,
272            target: options.target.as_str().to_owned(),
273            workers: options.workers,
274            module_cache_capacity: options.module_cache_capacity,
275            processor_mode: options.processor_mode,
276            sanitizer: options.sanitizer,
277        };
278        let mut cache = RESOLVED
279            .lock()
280            .map_err(|_| Error::Message("compiler cache poisoned".into()))?;
281        let cache = cache.get_or_insert_with(HashMap::new);
282        if let Some(compiler) = cache.get(&key) {
283            return Ok(compiler.clone());
284        }
285        let compiler = Self::with_options(Some(&key.path), options)?;
286        cache.insert(key, compiler.clone());
287        Ok(compiler)
288    }
289
290    /// The library a given override, `HRX_LOOM_LIBRARY` or the bundle selects.
291    fn resolved_library(library: Option<&Path>) -> Result<PathBuf> {
292        match library
293            .map(Path::to_path_buf)
294            .or_else(|| std::env::var_os("HRX_LOOM_LIBRARY").map(PathBuf::from))
295        {
296            Some(p) => fs::canonicalize(&p)
297                .map_err(|e| Error::from(e).context(format!("compiler library {}", p.display()))),
298            None => Ok(fs::canonicalize(bundle::resolve()?.join("libloomc.so"))?),
299        }
300    }
301
302    /// Resolve a compiler with a bounded number of exclusive workspaces.
303    pub fn with_options(library: Option<&Path>, options: CompilerOptions) -> Result<Self> {
304        let path = Self::resolved_library(library)?;
305        let identity = bundle::file_digest(&path)?;
306        let native = native::Prepared::open(
307            &path,
308            &identity,
309            options.workers.get(),
310            &options.target,
311            options.processor_mode,
312            options.sanitizer,
313        )?;
314        if bundle::file_digest(&path)? != identity {
315            return Err(Error::Message(
316                "compiler library changed while loading".into(),
317            ));
318        }
319        Ok(Self(Arc::new(Inner {
320            modules: Mutex::new(HashMap::new()),
321            native,
322            target: options.target,
323            workers: options.workers,
324            module_cache_capacity: options.module_cache_capacity,
325            processor_mode: options.processor_mode,
326            sanitizer: options.sanitizer,
327            path,
328            identity,
329        })))
330    }
331    /// Maximum simultaneous compilations, and the workspace pool's bound.
332    pub fn workers(&self) -> NonZeroUsize {
333        self.0.workers
334    }
335
336    /// Compile several specializations, up to [`CompilerOptions::workers`] at a
337    /// time, and return their outcomes in request order.
338    ///
339    /// [`Module::compile`] blocks, so a single-threaded caller never reaches the
340    /// workspace pool's bound. This is the entry point that does: it owns the
341    /// thread budget rather than leaving every consumer to rebuild the same pool.
342    /// Each request is independent; one failure does not cancel the others.
343    pub fn compile_all(&self, requests: &[(&Module, &Specialization)]) -> Vec<Result<Artifact>> {
344        let limit = self.0.workers.get().min(requests.len());
345        if limit <= 1 {
346            return requests
347                .iter()
348                .map(|(module, spec)| module.compile(spec))
349                .collect();
350        }
351        let next = std::sync::atomic::AtomicUsize::new(0);
352        let slots: Vec<Mutex<Option<Result<Artifact>>>> =
353            requests.iter().map(|_| Mutex::new(None)).collect();
354        std::thread::scope(|scope| {
355            for _ in 0..limit {
356                scope.spawn(|| {
357                    loop {
358                        let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
359                        let Some((module, spec)) = requests.get(index) else {
360                            return;
361                        };
362                        let outcome = module.compile(spec);
363                        *slots[index].lock().unwrap_or_else(|e| e.into_inner()) = Some(outcome);
364                    }
365                });
366            }
367        });
368        slots
369            .into_iter()
370            .map(|slot| {
371                slot.into_inner()
372                    .unwrap_or_else(|e| e.into_inner())
373                    .expect("every request slot is filled before the scope ends")
374            })
375            .collect()
376    }
377
378    /// Architecture selected for every compilation by this compiler.
379    pub fn target(&self) -> &crate::Target {
380        &self.0.target
381    }
382    /// Content identity of the loaded compiler library.
383    pub fn identity(&self) -> &str {
384        &self.0.identity
385    }
386    /// Selected compiler library path.
387    pub fn library_path(&self) -> &Path {
388        &self.0.path
389    }
390    /// Retain a source module, sharing its parsed index across specializations.
391    /// Parsing is deferred until the first artifact-cache miss.
392    pub fn module(&self, source: &str) -> Module {
393        self.source_module(vec![Source::loom("kernel.loom", source)])
394    }
395    /// Link named Loom and C/C++ inputs in one compiler-owned source module.
396    /// Source bytes, headers, macro order and import options all enter its identity.
397    pub fn sources(&self, mut sources: Vec<Source>) -> Result<Module> {
398        if sources.is_empty() {
399            return Err(Error::Message(
400                "a compilation module requires source inputs".into(),
401            ));
402        }
403        let mut identifiers = std::collections::BTreeSet::new();
404        for source in &mut sources {
405            source.validate()?;
406            let identifier = match source {
407                Source::Cxx(unit) => {
408                    unit.normalize()?;
409                    &unit.identifier
410                }
411                Source::Loom { identifier, .. } => identifier,
412            };
413            if !identifiers.insert(identifier.clone()) {
414                return Err(Error::Message("duplicate source identifier".into()));
415            }
416        }
417        Ok(self.source_module(sources))
418    }
419    /// Import a translation unit; parsing is deferred until compilation.
420    pub fn import_cxx(&self, source: CxxSource) -> Result<Module> {
421        self.sources(vec![Source::Cxx(source)])
422    }
423    fn source_module(&self, sources: Vec<Source>) -> Module {
424        let digest = bundle::digest(
425            &serde_json::to_vec(&sources).expect("source inputs contain only JSON-safe values"),
426        );
427        let mut modules = self.0.modules.lock().unwrap_or_else(|e| e.into_inner());
428        let data = if let Some(data) = modules.get(&digest) {
429            data.clone()
430        } else {
431            let data = Arc::new(ModuleData {
432                sources,
433                digest: digest.clone(),
434                index: Mutex::new(None),
435            });
436            if self.0.module_cache_capacity != 0 {
437                if modules.len() >= self.0.module_cache_capacity {
438                    let victim = modules.keys().next().cloned().unwrap();
439                    modules.remove(&victim);
440                }
441                modules.insert(digest, data.clone());
442            }
443            data
444        };
445        Module {
446            compiler: self.clone(),
447            data,
448        }
449    }
450    /// Release idle workspace blocks and cached module references. Existing
451    /// Module handles remain valid; active compilations are unaffected.
452    pub fn trim(&self) {
453        self.0
454            .modules
455            .lock()
456            .unwrap_or_else(|e| e.into_inner())
457            .clear();
458        self.0.native.trim();
459    }
460}
461/// Render the diagnostic a caller should act on, not the whole cascade.
462///
463/// A single rejected construct makes the rest of its block unparseable, so the
464/// compiler reports one real error followed by a run of consequences. Leading
465/// with all of them buries the cause; every diagnostic stays available through
466/// [`Error::Compile::diagnostics`].
467fn summarize(diagnostics: &[Diagnostic]) -> String {
468    let errors = || diagnostics.iter().filter(|d| d.severity >= Severity::Error);
469    let Some(first) = errors().next() else {
470        return diagnostics
471            .iter()
472            .map(|d| d.message.as_str())
473            .collect::<Vec<_>>()
474            .join("\n");
475    };
476    let mut message = if first.line == 0 {
477        first.message.clone()
478    } else {
479        format!("{}:{}: {}", first.line, first.column, first.message)
480    };
481    if let Some(hint) = hint(&first.message) {
482        message.push_str("\nhint: ");
483        message.push_str(hint);
484    }
485    for related in &first.related_locations {
486        message.push_str(&format!(
487            "\n{}:{}:{}: {}",
488            related.source, related.line, related.column, related.label
489        ));
490    }
491    if first.related_location_omitted_count != 0 {
492        message.push_str(&format!(
493            "\n({} additional related location(s) omitted)",
494            first.related_location_omitted_count
495        ));
496    }
497    let suppressed = errors().count() - 1;
498    if suppressed > 0 {
499        message.push_str(&format!(
500            "\n({suppressed} later error(s) suppressed as cascade; see Error::Compile diagnostics)"
501        ));
502    }
503    message
504}
505
506/// Diagnostics whose cause is a configuration mistake rather than a source bug.
507fn hint(message: &str) -> Option<&'static str> {
508    if message.contains("Low representation contract") && message.contains(".generic.") {
509        return Some(
510            "a generic target has no low-asm contract; name a bare architecture \
511             such as gfx1151 in amdgpu.target<...> to use hand-written asm",
512        );
513    }
514    None
515}
516
517#[cfg(test)]
518mod diagnostic_tests {
519    use super::{Diagnostic, Severity, summarize};
520    fn diagnostic(severity: u32, line: u32, message: &str) -> Diagnostic {
521        Diagnostic {
522            severity: Severity::from(severity),
523            code: String::new(),
524            message: message.into(),
525            formatted_text: message.into(),
526            parameters: Vec::new(),
527            source: String::new(),
528            line,
529            column: 1,
530            related_locations: Vec::new(),
531            related_location_omitted_count: 0,
532        }
533    }
534    #[test]
535    fn the_first_error_leads_and_its_cascade_is_counted_not_printed() {
536        let mut all = vec![
537            diagnostic(1, 3, "unused binding"),
538            diagnostic(
539                2,
540                7,
541                "unknown Low representation contract amdgpu.gfx11.generic.core",
542            ),
543        ];
544        all.extend((0..12).map(|i| diagnostic(2, 8 + i, "expected expression")));
545        let rendered = summarize(&all);
546        assert!(rendered.starts_with("7:1: unknown Low representation contract"));
547        assert!(rendered.contains("hint: a generic target has no low-asm contract"));
548        assert!(rendered.contains("12 later error(s) suppressed"));
549        assert!(!rendered.contains("expected expression"));
550        // A cascade-free failure gains no suppression line and no hint.
551        let single = summarize(&[diagnostic(2, 4, "type mismatch")]);
552        assert_eq!(single, "4:1: type mismatch");
553    }
554}
555
556struct ModuleData {
557    sources: Vec<Source>,
558    digest: String,
559    index: Mutex<Option<(Arc<native::Index>, Vec<Diagnostic>)>>,
560}
561/// Immutable source plus compiler ownership. Clones share the frozen native index.
562#[derive(Clone)]
563pub struct Module {
564    // Release the index before the last compiler/context owner.
565    data: Arc<ModuleData>,
566    compiler: Compiler,
567}
568/// Amount of compiler evidence collected alongside an executable.
569#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
570#[serde(rename_all = "snake_case")]
571pub enum ReportMode {
572    /// Emit no report and avoid detail collection.
573    #[default]
574    None,
575    /// Collect artifact facts and summary compiler analysis.
576    Summary,
577    /// Include detailed provenance, scheduling, and resource evidence.
578    Details,
579}
580
581/// Compiler-formatted pass trace representation.
582#[derive(Clone, Copy, Debug, Default)]
583pub enum TraceFormat {
584    /// Human-readable metadata followed by source.
585    Text,
586    /// One JSON object per pass event.
587    #[default]
588    JsonLines,
589}
590/// Bounded streaming diagnostics for a fresh compile invocation.
591#[derive(Clone, Debug)]
592pub struct TraceOptions {
593    /// Serialization chosen by the compiler.
594    pub format: TraceFormat,
595    /// Maximum bytes forwarded to the writer; exceeding this aborts compilation.
596    pub max_bytes: usize,
597    /// Pass keys or compiler stage names to capture before execution.
598    pub before: Vec<String>,
599    /// Pass keys or compiler stage names to capture after execution.
600    /// Both filter lists empty selects every after boundary.
601    pub after: Vec<String>,
602}
603impl Default for TraceOptions {
604    fn default() -> Self {
605        Self {
606            format: TraceFormat::JsonLines,
607            max_bytes: 16 * 1024 * 1024,
608            before: Vec::new(),
609            after: Vec::new(),
610        }
611    }
612}
613
614/// One export and its exact configuration within a module.
615#[derive(Clone, Debug, Default)]
616pub struct Specialization {
617    /// Export/root symbol to compile.
618    symbol: String,
619    /// Fully qualified configuration keys and Loom value spellings.
620    config: BTreeMap<String, String>,
621    /// Select compiler analysis collected beside the executable.
622    report: ReportMode,
623}
624impl Specialization {
625    /// Select an export with no configuration overrides.
626    pub fn new(symbol: impl Into<String>) -> Self {
627        Self {
628            symbol: symbol.into(),
629            ..Self::default()
630        }
631    }
632    /// Set one fully-qualified Loom configuration value.
633    #[must_use]
634    pub fn with_config(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
635        self.config.insert(key.into(), value.into());
636        self
637    }
638    /// Set one fully-qualified Loom configuration value in place.
639    pub fn set_config(&mut self, key: impl Into<String>, value: impl Into<String>) -> &mut Self {
640        self.config.insert(key.into(), value.into());
641        self
642    }
643    /// Select an export/root symbol in place.
644    pub fn set_symbol(&mut self, symbol: impl Into<String>) -> &mut Self {
645        self.symbol = symbol.into();
646        self
647    }
648    /// Replace all fully-qualified Loom configuration values.
649    pub fn replace_config(&mut self, config: BTreeMap<String, String>) -> &mut Self {
650        self.config = config;
651        self
652    }
653    /// Request or suppress the compiler resource report.
654    #[must_use]
655    pub fn with_report(mut self, report: ReportMode) -> Self {
656        self.report = report;
657        self
658    }
659    /// Request or suppress the compiler resource report in place.
660    pub fn set_report(&mut self, report: ReportMode) -> &mut Self {
661        self.report = report;
662        self
663    }
664    /// Export/root symbol selected by this specialization.
665    #[must_use]
666    pub fn symbol(&self) -> &str {
667        &self.symbol
668    }
669    /// Fully-qualified compiler configuration.
670    #[must_use]
671    pub fn configuration(&self) -> &BTreeMap<String, String> {
672        &self.config
673    }
674    /// Compiler evidence requested by this specialization.
675    #[must_use]
676    pub fn report_mode(&self) -> ReportMode {
677        self.report
678    }
679    fn validate(&self) -> Result<()> {
680        let valid = |s: &str| {
681            !s.is_empty()
682                && s.bytes()
683                    .all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b))
684        };
685        if !valid(&self.symbol) || self.config.keys().any(|k| !valid(k)) {
686            return Err(Error::Message(
687                "invalid Loom export or configuration key".into(),
688            ));
689        }
690        Ok(())
691    }
692}
693#[derive(Clone, Debug, Serialize, Deserialize)]
694struct Record {
695    key: String,
696    compiler: String,
697    target: String,
698    #[serde(default)]
699    processor_mode: ProcessorMode,
700    symbol: String,
701    sha256: String,
702    diagnostics: Vec<Diagnostic>,
703    report: Option<CompileReport>,
704    manifest: Option<serde_json::Value>,
705    launch_sha256: Option<String>,
706}
707/// Owned native executable bytes and their verified compilation metadata.
708#[derive(Clone, Debug)]
709pub struct Artifact {
710    bytes: Arc<[u8]>,
711    launch_bytes: Option<Arc<[u8]>>,
712    launch_loader: native::LaunchLoader,
713    path: PathBuf,
714    record: Record,
715}
716impl Artifact {
717    /// Native executable bytes, independent of compiler/result lifetimes.
718    pub fn bytes(&self) -> &[u8] {
719        &self.bytes
720    }
721    /// Compiler-owned description of exports, arguments, globals, and target facts.
722    pub fn manifest(&self) -> Option<&serde_json::Value> {
723        self.record.manifest.as_ref()
724    }
725    /// Serialized host launch program produced alongside these executable bytes.
726    pub fn launch_bytes(&self) -> Option<&[u8]> {
727        self.launch_bytes.as_deref()
728    }
729    /// Bind an exact executable export to an independent launch evaluator.
730    /// The matching native evaluator library stays owned by the artifact.
731    pub fn launch_program(&self, export: &str) -> Result<LaunchProgram> {
732        self.launch_loader.load(
733            self.launch_bytes.as_deref().ok_or_else(|| {
734                Error::Unsupported("this target has no host launch program".into())
735            })?,
736            export,
737        )
738    }
739    /// Durable verified cache artifact path.
740    pub fn path(&self) -> &Path {
741        &self.path
742    }
743    /// Compiler library content identity.
744    pub fn compiler_identity(&self) -> &str {
745        &self.record.compiler
746    }
747    /// Exact GPU target key.
748    pub fn target(&self) -> &str {
749        &self.record.target
750    }
751    /// Scheduling domain used to compile this artifact.
752    pub fn processor_mode(&self) -> ProcessorMode {
753        self.record.processor_mode
754    }
755    /// Export compiled into this artifact.
756    pub fn symbol(&self) -> &str {
757        &self.record.symbol
758    }
759    /// Diagnostics retained from successful preparation and compilation.
760    pub fn diagnostics(&self) -> &[Diagnostic] {
761        &self.record.diagnostics
762    }
763    /// Optional structured compiler evidence, in the pinned compiler's schema.
764    pub fn report(&self) -> Option<&CompileReport> {
765        self.record.report.as_ref()
766    }
767}
768/// Concrete launch properties evaluated from compiler-authored workload arguments.
769#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
770pub struct LaunchConfig {
771    /// Workgroup count along each axis.
772    pub workgroup_count: [u32; 3],
773    /// Invocations per workgroup along each axis.
774    pub workgroup_size: [u32; 3],
775    /// Cooperative cluster dimensions; [1, 1, 1] for an ordinary launch.
776    pub workgroup_cluster_size: [u32; 3],
777    /// Hardware subgroup width, or zero when the target does not fix it.
778    pub subgroup_size: u32,
779    /// Total workgroup-local storage, including statically allocated storage.
780    pub workgroup_storage_bytes: u64,
781}
782
783/// One bound launch function with exclusively owned invocation scratch.
784/// Create one per independently prepared execution slot.
785pub struct LaunchProgram(native::LaunchProgram);
786impl LaunchProgram {
787    /// Evaluate positional raw scalar bits without compiling source or looking up names.
788    /// Evaluation may grow scratch; perform it while preparing a dispatch.
789    pub fn evaluate(&mut self, workload_argument_bits: &[u64]) -> Result<LaunchConfig> {
790        self.0.evaluate(workload_argument_bits)
791    }
792}
793
794struct Compiled {
795    bytes: Arc<[u8]>,
796    diagnostics: Vec<Diagnostic>,
797    report: Option<serde_json::Value>,
798    manifest: Option<serde_json::Value>,
799    launch_bytes: Option<Arc<[u8]>>,
800}
801impl Module {
802    /// Identity of the exact source bytes.
803    pub fn identity(&self) -> &str {
804        &self.data.digest
805    }
806    /// Cache identity including compiler, pipeline contract, source and specialization.
807    pub fn key(&self, spec: &Specialization) -> Result<String> {
808        spec.validate()?;
809        let base = bundle::digest(&serde_json::to_vec(&(
810            "loomc-v5-sanitizers",
811            self.compiler.0.sanitizer,
812            self.compiler.identity(),
813            self.identity(),
814            &spec.symbol,
815            self.compiler.0.target.as_str(),
816            &spec.config,
817            spec.report,
818        ))?);
819        // Retain existing default artifacts; explicit execution modes own a
820        // separate namespace even if their output bytes happen to match.
821        if self.compiler.0.processor_mode == ProcessorMode::Default {
822            Ok(base)
823        } else {
824            Ok(bundle::digest(&serde_json::to_vec(&(
825                "loomc-processor-mode-v1",
826                base,
827                self.compiler.0.processor_mode,
828            ))?))
829        }
830    }
831    /// Compile in process or return verified cached bytes. Publication is atomic
832    /// and serialized per key across threads and processes; failures are retryable.
833    pub fn compile(&self, spec: &Specialization) -> Result<Artifact> {
834        self.compile_impl(spec, None)
835    }
836    /// Recompile and stream compiler-owned pass traces, even on a cache hit.
837    /// A writer error, panic, or exhausted byte limit aborts compilation and
838    /// leaves any existing cached artifact intact. The writer is not retained.
839    pub fn compile_traced(
840        &self,
841        spec: &Specialization,
842        options: &TraceOptions,
843        writer: &mut dyn std::io::Write,
844    ) -> Result<Artifact> {
845        self.compile_impl(spec, Some((options, writer)))
846    }
847    fn compile_impl(
848        &self,
849        spec: &Specialization,
850        trace: Option<(&TraceOptions, &mut dyn std::io::Write)>,
851    ) -> Result<Artifact> {
852        let key = self.key(spec)?;
853        let cache = &bundle::kernel_cache()?;
854        let dir = cache.join(&key);
855        if trace.is_none()
856            && let Some(a) = cached(&dir, &key, &self.compiler.0.native)
857        {
858            return Ok(a);
859        }
860        bundle::create_cache_dir(cache)?;
861        let _lock = Lock::acquire(&cache.join(format!("{key}.lock")))?;
862        if trace.is_none()
863            && let Some(a) = cached(&dir, &key, &self.compiler.0.native)
864        {
865            return Ok(a);
866        }
867        let (index, mut diagnostics) = {
868            let mut slot = self.data.index.lock().unwrap_or_else(|e| e.into_inner());
869            if slot.is_none() {
870                let (index, diagnostics) = self.compiler.0.native.index(&self.data.sources)?;
871                *slot = Some((Arc::new(index), diagnostics));
872            }
873            slot.as_ref().unwrap().clone()
874        };
875        let compiled = self
876            .compiler
877            .0
878            .native
879            .compile(&index, spec, trace)
880            .map_err(|e| {
881                e.context(format!(
882                    "compiling {} with {}",
883                    spec.symbol,
884                    self.compiler.library_path().display()
885                ))
886            })?;
887        diagnostics.extend(compiled.diagnostics);
888        let record = Record {
889            key,
890            compiler: self.compiler.identity().into(),
891            target: self.compiler.0.target.as_str().into(),
892            processor_mode: self.compiler.0.processor_mode,
893            symbol: spec.symbol.clone(),
894            sha256: bundle::digest(&compiled.bytes),
895            launch_sha256: compiled.launch_bytes.as_deref().map(bundle::digest),
896            manifest: compiled.manifest,
897            diagnostics,
898            report: compiled
899                .report
900                .map(|json| {
901                    CompileReport::new(
902                        self.compiler.identity().into(),
903                        self.compiler.0.target.as_str().into(),
904                        self.compiler.0.processor_mode,
905                        json,
906                    )
907                })
908                .transpose()?,
909        };
910        let staging = tempfile::tempdir_in(cache)?;
911        let filename = self.compiler.target().artifact_filename();
912        let output = staging.path().join(filename);
913        fs::write(&output, &compiled.bytes)?;
914        if let Some(bytes) = &compiled.launch_bytes {
915            fs::write(staging.path().join("launch.loombc"), bytes)?;
916            fs::File::open(staging.path().join("launch.loombc"))?.sync_all()?;
917        }
918        let metadata = serde_json::to_vec(&record)?;
919        fs::write(staging.path().join("artifact.json"), &metadata)?;
920        fs::write(
921            staging.path().join("artifact.sha256"),
922            bundle::digest(&metadata),
923        )?;
924        for file in [filename, "artifact.json", "artifact.sha256"] {
925            fs::File::open(staging.path().join(file))?.sync_all()?;
926        }
927        fs::File::open(staging.path())?.sync_all()?;
928        if dir.exists() {
929            fs::remove_dir_all(&dir)?;
930        }
931        bundle::publish(staging, &dir)?;
932        fs::File::open(cache)?.sync_all()?;
933        Ok(Artifact {
934            bytes: compiled.bytes,
935            launch_bytes: compiled.launch_bytes,
936            launch_loader: self.compiler.0.native.launch_loader(),
937            path: dir.join(filename),
938            record,
939        })
940    }
941}
942fn cached(dir: &Path, key: &str, native: &native::Prepared) -> Option<Artifact> {
943    let metadata = fs::read(dir.join("artifact.json")).ok()?;
944    if fs::read_to_string(dir.join("artifact.sha256")).ok()? != bundle::digest(&metadata) {
945        return None;
946    }
947    let record: Record = serde_json::from_slice(&metadata).ok()?;
948    if record.key != key {
949        return None;
950    }
951    // `bundle::collect` evicts on access time. Reading the artifact below updates
952    // it on a normal mount, but a `noatime` mount never would, so set it here:
953    // utimensat is unaffected by the mount option that suppresses the implicit
954    // update, and without this a busy cache would look idle to the collector.
955    if let Ok(file) = fs::File::open(dir.join("artifact.json")) {
956        let _ = file.set_times(fs::FileTimes::new().set_accessed(std::time::SystemTime::now()));
957    }
958    let target = crate::Target::new(&record.target).ok()?;
959    let path = dir.join(target.artifact_filename());
960    let bytes = fs::read(&path).ok()?;
961    if bytes.is_empty() || bundle::digest(&bytes) != record.sha256 {
962        return None;
963    }
964    let launch_bytes = if let Some(hash) = &record.launch_sha256 {
965        let bytes = fs::read(dir.join("launch.loombc")).ok()?;
966        if bytes.is_empty() || &bundle::digest(&bytes) != hash {
967            return None;
968        }
969        Some(Arc::from(bytes))
970    } else {
971        None
972    };
973    Some(Artifact {
974        bytes: bytes.into(),
975        launch_bytes,
976        launch_loader: native.launch_loader(),
977        path,
978        record,
979    })
980}
981
982#[cfg(test)]
983mod tests {
984    use super::*;
985
986    #[test]
987    #[ignore = "requires libloomc.so, no GPU"]
988    fn shared_compilers_respect_all_limits_and_reuse_matching_options() -> Result<()> {
989        let options = CompilerOptions {
990            workers: NonZeroUsize::new(2).unwrap(),
991            module_cache_capacity: 2,
992            ..Default::default()
993        };
994        let first = Compiler::shared(None, options.clone())?;
995        let same = Compiler::shared(None, options.clone())?;
996        assert!(Arc::ptr_eq(&first.0, &same.0));
997        let serial_options = CompilerOptions {
998            workers: NonZeroUsize::new(1).unwrap(),
999            ..options
1000        };
1001        let serial = Compiler::shared(None, serial_options.clone())?;
1002        assert_eq!(serial.workers().get(), 1);
1003        assert!(!Arc::ptr_eq(&first.0, &serial.0));
1004        let uncached = Compiler::shared(
1005            None,
1006            CompilerOptions {
1007                module_cache_capacity: 0,
1008                ..serial_options
1009            },
1010        )?;
1011        assert!(!Arc::ptr_eq(&serial.0, &uncached.0));
1012        serial.module("// retained");
1013        uncached.module("// not retained");
1014        assert!(!serial.0.modules.lock().unwrap().is_empty());
1015        assert!(uncached.0.modules.lock().unwrap().is_empty());
1016        Ok(())
1017    }
1018
1019    #[test]
1020    #[ignore = "requires libloomc.so, no GPU"]
1021    fn processor_modes_have_distinct_compilers_and_artifacts() -> Result<()> {
1022        let default = Compiler::shared(None, CompilerOptions::default())?;
1023        let options = CompilerOptions {
1024            processor_mode: ProcessorMode::ComputeUnit,
1025            ..Default::default()
1026        };
1027        let cu = Compiler::shared(None, options.clone())?;
1028        let same = Compiler::shared(None, options)?;
1029        assert!(Arc::ptr_eq(&cu.0, &same.0));
1030        assert!(!Arc::ptr_eq(&default.0, &cu.0));
1031        let source = include_str!("../tests/kernels/euler.loom");
1032        let spec = euler_spec();
1033        let wgp = default.module(source).compile(&spec)?;
1034        let unit = cu.module(source).compile(&spec)?;
1035        assert_ne!(wgp.path(), unit.path());
1036        assert_eq!(unit.processor_mode(), ProcessorMode::ComputeUnit);
1037        let cached = cu.module(source).compile(&spec)?;
1038        assert_eq!(cached.processor_mode(), ProcessorMode::ComputeUnit);
1039        assert_eq!(unit.bytes(), cached.bytes());
1040        let differences: Vec<_> = wgp
1041            .bytes()
1042            .iter()
1043            .zip(unit.bytes())
1044            .filter(|(a, b)| a != b)
1045            .collect();
1046        assert_eq!(wgp.bytes().len(), unit.bytes().len());
1047        assert_eq!(differences.len(), 1);
1048        assert_eq!(*differences[0].0 ^ *differences[0].1, 0x20);
1049        Ok(())
1050    }
1051
1052    fn euler_spec() -> Specialization {
1053        let mut spec = Specialization::new("krea2_euler");
1054        spec.set_config("krea2.euler.grid_x", "1");
1055        spec.set_config("krea2.euler.grid_y", "1");
1056        spec
1057    }
1058
1059    #[test]
1060    #[ignore = "requires libloomc.so, no GPU"]
1061    fn reporting_preserves_shared_requests_and_can_be_replaced_from_a_callback() -> Result<()> {
1062        use std::sync::atomic::{AtomicUsize, Ordering};
1063        let kernels = Kernels::new(Compiler::resolve(None)?);
1064        let clone = kernels.clone();
1065        let pending =
1066            kernels.request(include_str!("../tests/kernels/euler.loom"), &euler_spec())?;
1067        let calls = Arc::new(AtomicUsize::new(0));
1068        let reported = calls.clone();
1069        let weak = Arc::downgrade(&kernels.0);
1070        let kernels = kernels.reporting(move |_| {
1071            reported.fetch_add(1, Ordering::SeqCst);
1072            // Replacing a reporter must not hold its lock while invoking it.
1073            // A weak reference avoids a callback/cache ownership cycle.
1074            let next = reported.clone();
1075            Kernels(weak.upgrade().unwrap()).reporting(move |_| {
1076                next.fetch_add(10, Ordering::SeqCst);
1077            });
1078        });
1079        assert!(Arc::ptr_eq(&kernels.0, &clone.0));
1080        assert!(Arc::ptr_eq(&kernels.0, &pending.cache));
1081        for cache in [&clone.0, &pending.cache] {
1082            let mut attempts = 0;
1083            let outcome = cache.build_with(1, |artifact| {
1084                attempts += 1;
1085                assert_eq!(artifact.record.key, pending.key);
1086                Err(Error::Message("injected load failure".into()))
1087            });
1088            assert_eq!(outcome.unwrap_err().to_string(), "injected load failure");
1089            assert_eq!(attempts, 1);
1090        }
1091        assert_eq!(calls.load(Ordering::SeqCst), 11);
1092        assert_eq!(kernels.0.locked()?.waiting.len(), 1);
1093        Ok(())
1094    }
1095
1096    #[test]
1097    #[ignore = "requires libloomc.so, no GPU"]
1098    fn requests_during_build_are_deduplicated_and_new_requests_survive() -> Result<()> {
1099        let kernels = Kernels::new(Compiler::resolve(None)?);
1100        let source = include_str!("../tests/kernels/euler.loom");
1101        let spec = euler_spec();
1102        let pending = kernels.request(source, &spec)?;
1103        let mut other_spec = spec.clone();
1104        other_spec.set_config("krea2.euler.grid_x", "2");
1105        let other_key = kernels.compiler().module(source).key(&other_spec)?;
1106        let outcome = kernels.0.build_with(1, |_| {
1107            // Another caller requests both the active kernel and a new one
1108            // while loading is paused here. No native GPU load is performed.
1109            std::thread::scope(|scope| {
1110                scope
1111                    .spawn(|| {
1112                        for _ in 0..2 {
1113                            let duplicate = kernels.request(source, &spec).unwrap();
1114                            assert_eq!(duplicate.key, pending.key);
1115                            kernels.request(source, &other_spec).unwrap();
1116                        }
1117                    })
1118                    .join()
1119                    .unwrap();
1120            });
1121            assert_eq!(kernels.0.locked().unwrap().waiting.len(), 1);
1122            Err(Error::Message("injected load failure".into()))
1123        });
1124        assert_eq!(outcome.unwrap_err().to_string(), "injected load failure");
1125        {
1126            let state = kernels.0.locked()?;
1127            let waiting: Vec<_> = state.waiting.iter().map(|r| r.0.as_str()).collect();
1128            assert_eq!(waiting, vec![pending.key.as_str(), other_key.as_str()]);
1129            assert!(state.active.is_empty());
1130        }
1131        let mut attempts = Vec::new();
1132        let outcome = kernels.0.build_with(1, |artifact| {
1133            attempts.push(artifact.record.key.clone());
1134            Err(Error::Message("injected retry failure".into()))
1135        });
1136        assert!(outcome.is_err());
1137        assert_eq!(attempts, vec![pending.key, other_key]);
1138        assert_eq!(kernels.0.locked()?.waiting.len(), 2);
1139        Ok(())
1140    }
1141
1142    #[test]
1143    #[ignore = "requires libloomc.so, no GPU"]
1144    fn compile_and_load_failures_remain_requested_for_retry() -> Result<()> {
1145        let kernels = Kernels::new(Compiler::resolve(None)?);
1146        let source = include_str!("../tests/kernels/euler.loom");
1147        let bad = kernels.request(source, &Specialization::new("missing_export"))?;
1148        let good = kernels.request(source, &euler_spec())?;
1149        for _ in 0..2 {
1150            let mut attempted = Vec::new();
1151            let result = kernels.0.build_with(1, |artifact| {
1152                attempted.push(artifact.record.key.clone());
1153                Err(Error::Message("injected transient load failure".into()))
1154            });
1155            let error = result.unwrap_err().to_string();
1156            assert!(
1157                !error.contains("injected transient load failure"),
1158                "the first compile failure must be returned: {error}"
1159            );
1160            assert_eq!(
1161                attempted,
1162                vec![good.key.clone()],
1163                "the later request was attempted"
1164            );
1165            let state = kernels.0.locked()?;
1166            let waiting: Vec<_> = state.waiting.iter().map(|r| r.0.as_str()).collect();
1167            assert_eq!(waiting, vec![bad.key.as_str(), good.key.as_str()]);
1168        }
1169        assert!(good.built().is_none());
1170        Ok(())
1171    }
1172
1173    #[test]
1174    #[ignore = "requires libloomc.so, no GPU"]
1175    fn concurrent_build_waits_for_the_active_batch_and_retries_its_failure() -> Result<()> {
1176        use std::sync::mpsc;
1177        use std::time::Duration;
1178        let kernels = Kernels::new(Compiler::resolve(None)?);
1179        let pending =
1180            kernels.request(include_str!("../tests/kernels/euler.loom"), &euler_spec())?;
1181        let (entered_tx, entered_rx) = mpsc::channel();
1182        let (release_tx, release_rx) = mpsc::channel();
1183        let (started_tx, started_rx) = mpsc::channel();
1184        let (done_tx, done_rx) = mpsc::channel();
1185        std::thread::scope(|scope| {
1186            let first_cache = &kernels.0;
1187            let first = scope.spawn(move || {
1188                first_cache.build_with(1, |_| {
1189                    entered_tx.send(()).unwrap();
1190                    release_rx.recv_timeout(Duration::from_secs(10)).unwrap();
1191                    Err(Error::Message("first load failed".into()))
1192                })
1193            });
1194            entered_rx.recv_timeout(Duration::from_secs(10)).unwrap();
1195            assert!(
1196                kernels.0.locked().unwrap().waiting.is_empty(),
1197                "batch has drained"
1198            );
1199            let second = scope.spawn(|| {
1200                started_tx.send(()).unwrap();
1201                let result = kernels
1202                    .0
1203                    .build_with(1, |_| Err(Error::Message("retry failed".into())));
1204                done_tx.send(result.map_err(|e| e.to_string())).unwrap();
1205            });
1206            started_rx.recv_timeout(Duration::from_secs(10)).unwrap();
1207            let early = done_rx.recv_timeout(Duration::from_millis(100));
1208            release_tx.send(()).unwrap();
1209            assert!(first.join().unwrap().is_err());
1210            second.join().unwrap();
1211            assert!(
1212                matches!(early, Err(mpsc::RecvTimeoutError::Timeout)),
1213                "build returned before the active batch finished"
1214            );
1215            assert_eq!(done_rx.recv().unwrap(), Err("retry failed".into()));
1216        });
1217        assert!(pending.built().is_none());
1218        assert_eq!(kernels.0.locked()?.waiting.len(), 1);
1219        Ok(())
1220    }
1221
1222    #[test]
1223    #[ignore = "requires libloomc.so, no GPU"]
1224    fn module_cache_eviction_preserves_live_modules() -> Result<()> {
1225        let compiler = Compiler::with_options(
1226            None,
1227            CompilerOptions {
1228                module_cache_capacity: 2,
1229                ..Default::default()
1230            },
1231        )?;
1232        let first = compiler.module(include_str!("../tests/kernels/euler.loom"));
1233        let first_id = first.identity().to_owned();
1234        for i in 0..8 {
1235            compiler.module(&format!("// source {i}"));
1236            assert!(compiler.0.modules.lock().unwrap().len() <= 2);
1237        }
1238        compiler.trim();
1239        assert!(compiler.0.modules.lock().unwrap().is_empty());
1240        assert_eq!(first.identity(), first_id);
1241        let mut spec = Specialization::new("krea2_euler");
1242        spec.set_config("krea2.euler.grid_x", "1");
1243        spec.set_config("krea2.euler.grid_y", "1");
1244        assert!(!first.compile(&spec)?.bytes().is_empty());
1245        Ok(())
1246    }
1247}
1248
1249/// Loaded kernels, keyed by the artifact their specialization compiles to.
1250///
1251/// The compiler already caches *compilation* on disk. This caches the loaded
1252/// executable, which is what a dispatch loop needs: turning a cached artifact
1253/// into a [`crate::gpu::Kernel`] is a native load every time, and a model that dispatches
1254/// the same kernel per block would pay it per block. Every consumer of this
1255/// crate wrote this cache, so it lives here instead.
1256///
1257/// The key is the artifact's own identity — the same digest the disk cache uses
1258/// — rather than whatever the caller derived a request from, so two requests
1259/// that compile to the same artifact share one executable.
1260///
1261/// Kernels are device-scoped, so a cache serves one device and refuses another.
1262#[derive(Clone)]
1263pub struct Kernels(Arc<Cache>);
1264
1265struct Cache {
1266    compiler: Compiler,
1267    state: Mutex<Loaded>,
1268    /// Covers draining, compiling, publishing and requeuing one complete batch.
1269    build: Mutex<()>,
1270    report: Mutex<Option<Reporter>>,
1271}
1272
1273/// Called with each artifact a [`Kernels`] builds. See [`Kernels::reporting`].
1274type Reporter = Arc<dyn Fn(&Artifact) + Send + Sync>;
1275
1276#[derive(Default)]
1277struct Loaded {
1278    /// Set by the first request; kernels from one device are useless on another.
1279    device: Option<usize>,
1280    ready: HashMap<String, crate::Kernel>,
1281    /// Requested but not yet built, in request order.
1282    waiting: Vec<(String, String, Specialization)>,
1283    /// Keep drained requests known until their results are published or requeued.
1284    active: HashSet<String>,
1285}
1286
1287/// A kernel that has been asked for but not yet built.
1288///
1289/// Compiling is what a cold cache costs and requests are independent, so
1290/// [`Kernels::request`] records one and returns this rather than stopping to
1291/// build it. The outstanding set is then built together, on as many threads as
1292/// the compiler allows, by [`Kernels::build`] or by the first
1293/// [`Pending::resolve`] that needs any one of them.
1294pub struct Pending {
1295    key: String,
1296    cache: Arc<Cache>,
1297    /// This handle's own copy, so a built kernel can be borrowed for as long as
1298    /// the handle lives. A graph records `&'g Kernel`, and the cache's own copy
1299    /// is behind a lock with no lifetime to lend.
1300    cell: std::sync::OnceLock<crate::Kernel>,
1301}
1302
1303impl Clone for Pending {
1304    fn clone(&self) -> Self {
1305        Self {
1306            key: self.key.clone(),
1307            cache: self.cache.clone(),
1308            cell: self.cell.clone(),
1309        }
1310    }
1311}
1312
1313impl Pending {
1314    /// The kernel, if its batch has already been built.
1315    ///
1316    /// For callers with no stream to build one on — recording into a graph,
1317    /// which cannot load an executable. Everywhere else wants
1318    /// [`Pending::resolve`].
1319    pub fn built(&self) -> Option<&crate::Kernel> {
1320        if self.cell.get().is_none() {
1321            let found = self.cache.locked().ok()?.ready.get(&self.key).cloned()?;
1322            let _ = self.cell.set(found);
1323        }
1324        self.cell.get()
1325    }
1326
1327    /// The kernel, building everything outstanding if this is the first call
1328    /// that needs it.
1329    ///
1330    /// # Safety
1331    /// As [`Kernels::get`], for every source passed to [`Kernels::request`].
1332    pub unsafe fn resolve(&self, stream: &crate::Stream) -> Result<&crate::Kernel> {
1333        if self.built().is_some() {
1334            return Ok(self.cell.get().expect("just built"));
1335        }
1336        // A batch reports the first kernel in it that would not build, which is
1337        // not necessarily this one, so ask again before passing that failure on
1338        // as though it were ours.
1339        // Safety: the caller vouched for every requested source.
1340        let outcome = unsafe { self.cache.build(stream) };
1341        match self.built() {
1342            Some(_) => Ok(self.cell.get().expect("just built")),
1343            None => Err(outcome
1344                .err()
1345                .unwrap_or_else(|| Error::Message("kernel was never requested".into()))),
1346        }
1347    }
1348}
1349
1350impl Kernels {
1351    /// Index this cache by a caller's compact request key.
1352    ///
1353    /// Source hashing and specialization construction happen only on a key
1354    /// miss. Distinct caller keys that name the same artifact still share one
1355    /// loaded executable. Include every input that changes the source or
1356    /// specialization in the key.
1357    pub fn keyed<K>(self) -> KeyedKernels<K> {
1358        KeyedKernels {
1359            kernels: self,
1360            keys: Mutex::new(HashMap::new()),
1361        }
1362    }
1363
1364    /// An empty cache over a compiler. Clone it to share one cache.
1365    pub fn new(compiler: Compiler) -> Self {
1366        Self(Arc::new(Cache {
1367            compiler,
1368            state: Mutex::new(Loaded::default()),
1369            build: Mutex::new(()),
1370            report: Mutex::new(None),
1371        }))
1372    }
1373
1374    /// Call `report` with each artifact as it is built, once, before it loads.
1375    ///
1376    /// A cache that returned only kernels would swallow what the compiler said
1377    /// about them: warnings, and the backend remarks that are the only warning
1378    /// a kernel is spilling registers. Cache hits report nothing, having built
1379    /// nothing.
1380    /// Installing a reporter updates every clone and existing pending handle.
1381    /// An invocation already in progress may finish using the previous reporter.
1382    /// The callback must not build or resolve requests on this same cache.
1383    pub fn reporting(self, report: impl Fn(&Artifact) + Send + Sync + 'static) -> Self {
1384        let previous = self
1385            .0
1386            .report
1387            .lock()
1388            .unwrap_or_else(|poisoned| poisoned.into_inner())
1389            .replace(Arc::new(report));
1390        // Captured values can have arbitrary destructors; drop outside the lock.
1391        drop(previous);
1392        self
1393    }
1394
1395    /// The compiler these were built with.
1396    pub fn compiler(&self) -> &Compiler {
1397        &self.0.compiler
1398    }
1399
1400    /// How many kernels are loaded.
1401    pub fn len(&self) -> usize {
1402        self.0.locked().map_or(0, |state| state.ready.len())
1403    }
1404
1405    /// Whether any kernel is loaded.
1406    pub fn is_empty(&self) -> bool {
1407        self.len() == 0
1408    }
1409
1410    /// Compile `spec` from `source` if needed and load it, or return the
1411    /// loaded kernel. Outstanding [`Kernels::request`]s are left alone.
1412    ///
1413    /// # Safety
1414    /// `source` must be trusted Loom: its compiled output is loaded as native
1415    /// code, as for [`crate::Stream::load_artifact`].
1416    pub unsafe fn get(
1417        &self,
1418        stream: &crate::Stream,
1419        source: &str,
1420        spec: &Specialization,
1421    ) -> Result<crate::Kernel> {
1422        let module = self.0.compiler.module(source);
1423        let key = module.key(spec)?;
1424        // Safety: the caller vouched for this module's source.
1425        unsafe { self.get_module(stream, &module, key, spec) }
1426    }
1427
1428    unsafe fn get_module(
1429        &self,
1430        stream: &crate::Stream,
1431        module: &Module,
1432        key: String,
1433        spec: &Specialization,
1434    ) -> Result<crate::Kernel> {
1435        {
1436            let mut state = self.0.locked()?;
1437            state.claim(stream)?;
1438            if let Some(kernel) = state.ready.get(&key) {
1439                return Ok(kernel.clone());
1440            }
1441        }
1442        // Share the batch publication lock so direct requests cannot load a
1443        // second executable while a batch or another direct miss is in flight.
1444        let _build = self
1445            .0
1446            .build
1447            .lock()
1448            .map_err(|_| Error::Message("kernel build poisoned".into()))?;
1449        if let Some(kernel) = self.0.locked()?.ready.get(&key) {
1450            return Ok(kernel.clone());
1451        }
1452        let artifact = module.compile(spec)?;
1453        self.0.reported(&artifact);
1454        // Safety: the caller vouched for the source this artifact came from.
1455        let kernel = unsafe { stream.load_artifact(&artifact) }?;
1456        let mut state = self.0.locked()?;
1457        state.waiting.retain(|(waiting, ..)| *waiting != key);
1458        state.ready.insert(key, kernel.clone());
1459        Ok(kernel)
1460    }
1461
1462    /// Ask for a kernel without building it. Nothing is compiled until
1463    /// [`Kernels::build`] or the first [`Pending::resolve`], and then every
1464    /// outstanding request is built at once across the compiler's workspaces —
1465    /// so a consumer can name its whole set first and pay for it in one batch.
1466    pub fn request(&self, source: &str, spec: &Specialization) -> Result<Pending> {
1467        let key = self.0.compiler.module(source).key(spec)?;
1468        let mut state = self.0.locked()?;
1469        let known = state.ready.contains_key(&key)
1470            || state.active.contains(&key)
1471            || state.waiting.iter().any(|(waiting, ..)| *waiting == key);
1472        if !known {
1473            state
1474                .waiting
1475                .push((key.clone(), source.to_owned(), spec.clone()));
1476        }
1477        drop(state);
1478        Ok(Pending {
1479            key,
1480            cache: self.0.clone(),
1481            cell: std::sync::OnceLock::new(),
1482        })
1483    }
1484
1485    /// Build every outstanding request, in one batch.
1486    /// Concurrent builds wait for the active batch before checking for more work.
1487    ///
1488    /// # Safety
1489    /// As [`Kernels::get`], for every source passed to [`Kernels::request`].
1490    pub unsafe fn build(&self, stream: &crate::Stream) -> Result<()> {
1491        // Safety: the caller vouched for every requested source.
1492        unsafe { self.0.build(stream) }
1493    }
1494}
1495
1496impl Cache {
1497    fn locked(&self) -> Result<std::sync::MutexGuard<'_, Loaded>> {
1498        self.state
1499            .lock()
1500            .map_err(|_| Error::Message("kernel cache poisoned".into()))
1501    }
1502
1503    fn reported(&self, artifact: &Artifact) {
1504        let report = self
1505            .report
1506            .lock()
1507            .unwrap_or_else(|poisoned| poisoned.into_inner())
1508            .clone();
1509        // A reporter may replace itself or inspect the cache.
1510        if let Some(report) = report {
1511            report(artifact);
1512        }
1513    }
1514
1515    /// Compile off the device, then load in order.
1516    ///
1517    /// A specialization that will not build is that one's failure and no one
1518    /// else's, so the batch carries on and the rest is loaded. The failure goes
1519    /// back on the queue still wanted: dropping it would leave the handles that
1520    /// asked for it waiting on nothing, with no way to say why, while returning
1521    /// it means the next attempt reports the same failure again.
1522    unsafe fn build(&self, stream: &crate::Stream) -> Result<()> {
1523        self.build_with(stream.device_id(), |artifact| {
1524            // Safety: the caller vouched for every requested source.
1525            unsafe { stream.load_artifact(artifact) }
1526        })
1527    }
1528
1529    fn build_with(
1530        &self,
1531        device: usize,
1532        mut load: impl FnMut(&Artifact) -> Result<crate::Kernel>,
1533    ) -> Result<()> {
1534        let _build = self
1535            .build
1536            .lock()
1537            .map_err(|_| Error::Message("kernel build poisoned".into()))?;
1538        let waiting = {
1539            let mut state = self.locked()?;
1540            state.claim_device(device)?;
1541            let waiting = std::mem::take(&mut state.waiting);
1542            state
1543                .active
1544                .extend(waiting.iter().map(|(key, ..)| key.clone()));
1545            waiting
1546        };
1547        if waiting.is_empty() {
1548            return Ok(());
1549        }
1550        let modules: Vec<Module> = waiting
1551            .iter()
1552            .map(|(_, source, _)| self.compiler.module(source))
1553            .collect();
1554        let requests: Vec<(&Module, &Specialization)> = modules
1555            .iter()
1556            .zip(waiting.iter().map(|(_, _, spec)| spec))
1557            .collect();
1558        let built = self.compiler.compile_all(&requests);
1559
1560        let mut failure = None;
1561        let mut unbuilt = Vec::new();
1562        for (request, outcome) in waiting.into_iter().zip(built) {
1563            let loaded = outcome.and_then(|artifact| {
1564                self.reported(&artifact);
1565                load(&artifact)
1566            });
1567            match loaded {
1568                Ok(kernel) => {
1569                    self.locked()?.ready.insert(request.0.clone(), kernel);
1570                }
1571                Err(error) => {
1572                    unbuilt.push(request);
1573                    failure = failure.or(Some(error));
1574                }
1575            }
1576        }
1577        {
1578            let mut state = self.locked()?;
1579            unbuilt.append(&mut state.waiting);
1580            state.waiting = unbuilt;
1581            state.active.clear();
1582        }
1583        match failure {
1584            Some(error) => Err(error),
1585            None => Ok(()),
1586        }
1587    }
1588}
1589
1590/// A caller-key index over HRX's artifact-keyed loaded kernels.
1591///
1592/// Create with [`Kernels::keyed`]. A hit checks device ownership and returns a
1593/// shared executable without hashing source, serializing a specialization or
1594/// invoking the request factory. The index stores artifact identities, not a
1595/// second collection of executables.
1596pub struct KeyedKernels<K> {
1597    kernels: Kernels,
1598    keys: Mutex<HashMap<K, String>>,
1599}
1600
1601impl<K: Eq + std::hash::Hash> KeyedKernels<K> {
1602    /// The underlying batch cache and its compiler.
1603    pub fn kernels(&self) -> &Kernels {
1604        &self.kernels
1605    }
1606
1607    /// Index a pending request without compiling. Hits do not call the factory
1608    /// or hash source. Failed compilation remains queued for a later retry.
1609    /// The factory must not reenter this index.
1610    ///
1611    /// # Safety
1612    /// Equal keys must describe the same source and specialization. Loading is
1613    /// still governed by [`Pending::resolve`]'s trusted-code contract.
1614    pub unsafe fn request_or_insert_with<S: AsRef<str>>(
1615        &self,
1616        stream: &crate::Stream,
1617        key: K,
1618        request: impl FnOnce(&K) -> Result<(S, Specialization)>,
1619    ) -> Result<Pending> {
1620        let mut keys = self
1621            .keys
1622            .lock()
1623            .map_err(|_| Error::Message("kernel key index poisoned".into()))?;
1624        self.kernels.0.locked()?.claim(stream)?;
1625        if let Some(artifact) = keys.get(&key) {
1626            return Ok(Pending {
1627                key: artifact.clone(),
1628                cache: self.kernels.0.clone(),
1629                cell: std::sync::OnceLock::new(),
1630            });
1631        }
1632        let (source, spec) = request(&key)?;
1633        let pending = self.kernels.request(source.as_ref(), &spec)?;
1634        keys.insert(key, pending.key.clone());
1635        Ok(pending)
1636    }
1637
1638    /// Return the kernel named by `key`, constructing its request only on a miss.
1639    ///
1640    /// Failed requests are not indexed and can be retried. The factory is run
1641    /// under the index lock; it and the reporting callback must not reenter
1642    /// this index. The underlying cache's device restriction applies to hits too.
1643    ///
1644    /// # Safety
1645    /// As [`Kernels::get`]. Equal keys must always describe the same source and
1646    /// specialization, including compiler-report settings. The factory is not
1647    /// evaluated on a hit, so changing it does not change a cached kernel.
1648    pub unsafe fn get_or_insert_with<'s>(
1649        &self,
1650        stream: &crate::Stream,
1651        key: K,
1652        request: impl FnOnce(&K) -> Result<(&'s str, Specialization)>,
1653    ) -> Result<crate::Kernel> {
1654        let mut keys = self
1655            .keys
1656            .lock()
1657            .map_err(|_| Error::Message("kernel key index poisoned".into()))?;
1658        {
1659            let mut state = self.kernels.0.locked()?;
1660            state.claim(stream)?;
1661            if let Some(artifact) = keys.get(&key) {
1662                if let Some(kernel) = state.ready.get(artifact) {
1663                    return Ok(kernel.clone());
1664                }
1665                let pending = Pending {
1666                    key: artifact.clone(),
1667                    cache: self.kernels.0.clone(),
1668                    cell: std::sync::OnceLock::new(),
1669                };
1670                drop(state);
1671                drop(keys);
1672                // Safety: equal keys identify the source vouched for by the caller.
1673                return unsafe { pending.resolve(stream) }.cloned();
1674            }
1675        }
1676        let (source, spec) = request(&key)?;
1677        let module = self.kernels.compiler().module(source);
1678        let artifact = module.key(&spec)?;
1679        // Safety: the caller vouched for the source and the request key.
1680        let kernel = unsafe {
1681            self.kernels
1682                .get_module(stream, &module, artifact.clone(), &spec)
1683        }?;
1684        keys.insert(key, artifact);
1685        Ok(kernel)
1686    }
1687}
1688
1689impl Loaded {
1690    /// Bind this cache to a device on first use, and hold it there.
1691    fn claim(&mut self, stream: &crate::Stream) -> Result<()> {
1692        self.claim_device(stream.device_id())
1693    }
1694
1695    fn claim_device(&mut self, device: usize) -> Result<()> {
1696        match self.device {
1697            Some(owner) if owner != device => Err(Error::Message(
1698                "a kernel cache serves one device: an executable loaded on another is not \
1699                 dispatchable here"
1700                    .into(),
1701            )),
1702            Some(_) => Ok(()),
1703            None => {
1704                self.device = Some(device);
1705                Ok(())
1706            }
1707        }
1708    }
1709}
1710
1711impl std::fmt::Debug for Kernels {
1712    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1713        f.debug_struct("Kernels")
1714            .field("loaded", &self.len())
1715            .finish_non_exhaustive()
1716    }
1717}