From ebf2fc12022de7ef3d64f0548891fa9a1c61211b Mon Sep 17 00:00:00 2001 From: samuelburnham <45365069+samuelburnham@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:23:09 -0400 Subject: [PATCH 01/19] Compile spill steps 0+1: accumulator instrumentation and demote mode Step 0 (instrumentation, IX_QUIET-gated): - Env::const_cache_stats: entries / summed raw_bytes / materialized count, logged at compile completion and once per completed decile so OOM-killed runs still leave a growth curve. - Per-worker KEnv cache sizes, snapshotted at block completion, aggregated at each decile, and dumped per worker at exit. Step 1 (IX_COMPILE_SPILL=demote|mmap): - LazyConstant::from_constant_uncached serializes and drops the structured value (cache: None), matching the lazy-load read policy. - Env::store_const gates on the new SpillMode (parsed once from IX_COMPILE_SPILL; unknown values warn and fall back to off). mmap behaves as demote until the spill file lands (step 2). Compilation never reads stored constants back (audited: dependencies resolve via name_to_addr, aux_gen ingresses from the Lean env), so demote only affects later readers' CPU: ix compile reads bytes only; check/validate re-parse per access like the file-load path. Measured (see docs/compile-spill.md): - InitStd: peak RSS 11.10 -> 8.48 GB, byte-identical .ixe, equal wall time; the dropped caches cost ~22x their 121 MiB serialized form. - FLT: completes under a 50 GB cap for the first time on a 56 GB box (44.9 GB peak, previously OOM at 88.8%), faster at every comparable checkpoint; off-mode extrapolates to ~61-66 GB, matching the 63-68 GB CI measurement. --- crates/compile/src/compile/env.rs | 93 +++++++++++++++++++++++++++++-- crates/ixon/src/env.rs | 79 ++++++++++++++++++++++++-- crates/ixon/src/lazy.rs | 25 +++++++++ 3 files changed, 189 insertions(+), 8 deletions(-) diff --git a/crates/compile/src/compile/env.rs b/crates/compile/src/compile/env.rs index 4b92f7455..17b4be867 100644 --- a/crates/compile/src/compile/env.rs +++ b/crates/compile/src/compile/env.rs @@ -357,6 +357,12 @@ pub fn compile_env_with_options( Arc::new(Mutex::new(Vec::new())); let stop_progress = Arc::new(AtomicBool::new(false)); + // Per-worker kenv size snapshots, refreshed by each worker at block + // completion and aggregated by the reporter's decile stats. One slot + // per worker so updates never contend. + let worker_kenv_sizes: Vec> = + (0..num_threads).map(|_| Mutex::new(Default::default())).collect(); + if !*IX_QUIET { eprintln!( "[compile_env] starting: {total_blocks} blocks, {num_threads} workers" @@ -374,6 +380,7 @@ pub fn compile_env_with_options( let condvar_ref = &work_available; let active_ref = &active; let stop_progress_ref = &stop_progress; + let worker_kenv_sizes_ref = &worker_kenv_sizes; thread::scope(|s| { // Periodic progress reporter. Wakes every IX_PROGRESS_MS to print @@ -394,9 +401,11 @@ pub fn compile_env_with_options( let active_p = Arc::clone(active_ref); let stop_p = Arc::clone(stop_progress_ref); let start = compile_start; + let stats_stt = &stt; s.spawn(move || { let mut last_completed = 0usize; let mut last_print = Instant::now(); + let mut last_stats_decile = 0usize; while !stop_p.load(AtomicOrdering::Relaxed) { thread::sleep(check_interval); if stop_p.load(AtomicOrdering::Relaxed) { @@ -459,12 +468,47 @@ pub fn compile_env_with_options( "[compile_env] {done}/{total} ({pct:.1}%) · STALLED{suffix}" ); } + + // Accumulator composition once per completed decile, so runs + // that die before completion (OOM) still leave a growth curve + // in the log. O(consts) scan, ≤9 times per run. + let decile = if total == 0 { 0 } else { done * 10 / total }; + if decile > last_stats_decile && done > 0 { + last_stats_decile = decile; + let acc = stats_stt.env.const_cache_stats(); + eprintln!( + "[compile_env] accumulator @ {done}/{total}: {} consts · \ + {:.1} MiB serialized bytes · {} materialized caches", + acc.entries, + acc.bytes as f64 / (1024.0 * 1024.0), + acc.materialized, + ); + // Aggregate worker kenv sizes from the per-worker snapshots + // (each refreshed at that worker's last block completion) — + // the accumulating term the consts split doesn't cover. + let mut consts = 0usize; + let mut intern_exprs = 0usize; + let mut ingress = 0usize; + let mut largest = 0usize; + for slot in worker_kenv_sizes_ref { + let s = *slot.lock().unwrap(); + consts += s.consts; + intern_exprs += s.intern_exprs; + ingress += s.ingress; + largest = largest.max(s.max()); + } + eprintln!( + "[compile_env] worker kenvs @ {done}/{total}: \ + consts={consts} intern_exprs={intern_exprs} \ + ingress={ingress} (largest single cache {largest})", + ); + } } }); } // Spawn worker threads - for _ in 0..num_threads { + for worker_id in 0..num_threads { s.spawn(move || { let mut worker_kctx = crate::compile::KernelCtx::new(); loop { @@ -478,7 +522,7 @@ pub fn compile_env_with_options( Some((lo, all)) => { // Check if we should stop due to error if error_ref.lock().unwrap().is_some() { - return; + break; } // Skip if already processed (prevents double-counting from @@ -825,15 +869,22 @@ pub fn compile_env_with_options( } else { condvar_ref.notify_one(); } + + // Refresh this worker's kenv-size snapshot for the + // reporter's decile aggregate. cache_sizes() is ~20 map + // len() reads; the slot is uncontended except during the + // reporter's brief decile sweep. + *worker_kenv_sizes_ref[worker_id].lock().unwrap() = + worker_kctx.kenv.cache_sizes(); }, None => { // No work available - check if we're done if completed_ref.load(AtomicOrdering::SeqCst) == total_blocks { - return; + break; } // Check for errors if error_ref.lock().unwrap().is_some() { - return; + break; } // Wait for new work to become available let queue = ready_queue_ref.lock().unwrap(); @@ -843,6 +894,23 @@ pub fn compile_env_with_options( }, } } + // Per-worker kernel env sizes at exit. The kenv persists across + // every block this worker compiled (nothing clears it during + // compilation), so these counts are the worker's whole-run + // accumulation — the term the accumulator split above does not + // cover (see docs/compile-spill.md, step 0). + if !*IX_QUIET { + let sizes = worker_kctx.kenv.cache_sizes(); + // Workers that never populated their kenv (no aux_gen blocks + // landed on them) have nothing to report. + if sizes.max() > 0 { + eprintln!( + "[compile_env] worker {worker_id} kenv at exit: {sizes} \ + (largest cache {})", + sizes.max(), + ); + } + } }); } @@ -917,6 +985,23 @@ pub fn compile_env_with_options( stt.env.blob_count(), stt.env.comm_count(), ); + // Accumulator composition: how much of `env.consts` is materialized + // `Arc` caches vs serialized bytes. The byte sum is the + // floor the accumulator would shrink to if every cache were dropped + // (see docs/compile-spill.md, step 0). + let acc = stt.env.const_cache_stats(); + let materialized_pct = if acc.entries == 0 { + 0.0 + } else { + 100.0 * acc.materialized as f64 / acc.entries as f64 + }; + eprintln!( + "[compile_env] accumulator: {} consts · {:.1} MiB serialized bytes \ + · {} materialized caches ({materialized_pct:.1}%)", + acc.entries, + acc.bytes as f64 / (1024.0 * 1024.0), + acc.materialized, + ); } Ok(stt) diff --git a/crates/ixon/src/env.rs b/crates/ixon/src/env.rs index 425d9b976..8b7b48255 100644 --- a/crates/ixon/src/env.rs +++ b/crates/ixon/src/env.rs @@ -83,6 +83,53 @@ pub struct LazyNamed { pub hint: Option, } +/// Compile-accumulator spill mode, parsed once from `IX_COMPILE_SPILL`. +/// +/// - `Off` (default): `store_const` keeps a materialized `Arc` +/// cache next to the serialized bytes. +/// - `Demote`: `store_const` stores bytes only; `get_const` re-parses per +/// access (the lazy-load policy — see `LazyConstant` docs). +/// - `Mmap`: demote plus spilling the bytes to a file-backed mapping +/// (see docs/compile-spill.md, step 2). +/// +/// Host-only: the guest builds `Env` via deserialization and never calls +/// `store_const`. +#[cfg(not(target_arch = "riscv64"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SpillMode { + Off, + Demote, + Mmap, +} + +#[cfg(not(target_arch = "riscv64"))] +pub static SPILL_MODE: std::sync::LazyLock = + std::sync::LazyLock::new(|| { + match std::env::var("IX_COMPILE_SPILL").as_deref() { + Ok("demote") => SpillMode::Demote, + Ok("mmap") => SpillMode::Mmap, + Ok("off") | Err(_) => SpillMode::Off, + Ok(other) => { + eprintln!( + "[ixon] IX_COMPILE_SPILL={other:?} not recognized \ + (expected off|demote|mmap); using off" + ); + SpillMode::Off + }, + } + }); + +/// Composition of [`Env::consts`], from [`Env::const_cache_stats`]. +#[derive(Clone, Copy, Debug, Default)] +pub struct ConstCacheStats { + /// Total entries in the consts map. + pub entries: usize, + /// Summed `raw_bytes()` length across all entries. + pub bytes: usize, + /// Entries holding a materialized `Arc` cache. + pub materialized: usize, +} + /// Result of [`Env::parse_lazy_index`]: a metadata-light, zero-copy view of an /// `.ixe` buffer suitable for the anon/lazy check path. Constants are byte /// windows (offsets), `named` is `name → addr` + hint, and `blobs` are copied @@ -165,14 +212,23 @@ impl Env { /// Store a structured constant under `addr`. /// - /// Serializes the constant once and pre-populates the - /// [`LazyConstant`] cache so subsequent `Env::put` is a memcpy and - /// the first `get_const` call is free. + /// Serializes the constant once. In the default [`SpillMode::Off`], + /// the [`LazyConstant`] cache is pre-populated so `get_const` is + /// free; under `IX_COMPILE_SPILL=demote|mmap` the structured value is + /// dropped and the entry costs only its bytes (`get_const` re-parses + /// per access). Compilation never reads stored constants back, so the + /// modes differ only in memory footprint and later readers' CPU cost. /// /// Host-only — see `store_blob`. #[cfg(not(target_arch = "riscv64"))] pub fn store_const(&self, addr: Address, constant: Constant) { - self.consts.insert(addr, LazyConstant::from_constant(constant)); + let lazy = match *SPILL_MODE { + SpillMode::Off => LazyConstant::from_constant(constant), + SpillMode::Demote | SpillMode::Mmap => { + LazyConstant::from_constant_uncached(constant) + }, + }; + self.consts.insert(addr, lazy); } /// Store an already-serialized constant under `addr` (lazy load path). @@ -259,6 +315,21 @@ impl Env { self.consts.len() } + /// Composition of the consts map: entry count, summed serialized byte + /// length, and how many entries hold a materialized `Arc` + /// cache (see [`LazyConstant::is_materialized`]). O(n) scan over the + /// map; used to split the accumulator's footprint into structured-cache + /// vs raw-bytes shares. + pub fn const_cache_stats(&self) -> ConstCacheStats { + let mut stats = ConstCacheStats::default(); + for entry in self.consts.iter() { + stats.entries += 1; + stats.bytes += entry.value().raw_bytes().len(); + stats.materialized += usize::from(entry.value().is_materialized()); + } + stats + } + /// Number of named entries. pub fn named_count(&self) -> usize { self.named.len() diff --git a/crates/ixon/src/lazy.rs b/crates/ixon/src/lazy.rs index f08330e1a..c2fbb9b1a 100644 --- a/crates/ixon/src/lazy.rs +++ b/crates/ixon/src/lazy.rs @@ -140,6 +140,17 @@ impl LazyConstant { } } + /// Like [`Self::from_constant`] but drops the structured value after + /// serializing (`cache: None`), so the entry costs only its bytes and + /// `get()` re-parses per access — the same policy as [`Self::from_bytes`]. + /// Used by `Env::store_const` when `IX_COMPILE_SPILL` demotes the + /// compile accumulator to bytes. + pub fn from_constant_uncached(c: Constant) -> Self { + let mut buf = Vec::new(); + c.put(&mut buf); + LazyConstant { bytes: BytesSource::Heap(buf.into()), cache: None } + } + /// Materialize the `Constant`. /// /// If this entry was built via [`Self::from_constant`], returns the @@ -294,6 +305,20 @@ mod tests { assert!(!lazy.is_materialized()); } + #[test] + fn from_constant_uncached_roundtrips_without_cache() { + let c = defn_constant(); + let (addr, bytes) = c.commit(); + let lazy = LazyConstant::from_constant_uncached(c.clone()); + assert!(!lazy.is_materialized()); + // Same serialized form as the cached constructor / commit(). + assert_eq!(lazy.raw_bytes(), &bytes[..]); + assert!(lazy.verify_address(&addr)); + assert_eq!(*lazy.get().unwrap(), c); + // get() parses fresh and does not populate a cache. + assert!(!lazy.is_materialized()); + } + #[test] fn from_constant_clones_share_cache() { let c = axiom_constant(); From c879d2d291e6f1b7c5bf6cfff100f9e07ef893e7 Mon Sep 17 00:00:00 2001 From: samuelburnham <45365069+samuelburnham@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:40:14 -0400 Subject: [PATCH 02/19] Compile spill step 2: mmap-backed accumulator bytes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under IX_COMPILE_SPILL=mmap, store_const appends each constant's bytes to an anonymous temp file (tempfile_in: O_TMPFILE, no pathname, kernel reclaims on exit even on SIGKILL; dir from IX_COMPILE_SPILL_DIR, default cwd — must be disk-backed, tmpfs pages are swap-backed and cannot evict under MemorySwapMax=0). The file seals in 256 MiB segments (IX_COMPILE_SPILL_SEGMENT_MB to override): each sealed range is mmapped read-only and its entries swap from heap bytes to windows into the mapping via from_mmap_slice, so the kernel can evict them as clean page cache under pressure. Resident accumulator heap is bounded by one unsealed segment. Hot-path details, both measured on InitStd: - Appends stage through an 8 MiB buffer; a write syscall inside the spill mutex convoys the scheduler workers (2.8s -> 10.5s before staging, 3.0s after). - Spill modes skip re-stores of an existing address (alpha-collapsed blocks re-store the shared address once per member; 106k stores vs 90k unique on InitStd) — re-appending duplicated spill bytes and re-inserting would downgrade sealed windows back to heap. Any spill I/O error disables spilling and falls back to heap-backed entries; sealed windows stay valid. Measured: InitStd bit-identical to off/demote across reruns; FLT completes under the 50 GB cap (44.9 GB peak, scheduler 34.6s vs demote 37.8s), 2 segments sealed, spill file 747.8 MiB ~= the accumulator byte sum. FLT byte-comparison surfaced pre-existing run-to-run nondeterminism in the named/metadata section (same first-diff offset across same-mode reruns, any mode; consts section deterministic) — documented in docs/compile-spill.md correctness gates, tracked separately. --- Cargo.lock | 14 +++ Cargo.toml | 1 + crates/compile/src/compile/env.rs | 7 ++ crates/ixon/Cargo.toml | 1 + crates/ixon/src/env.rs | 190 +++++++++++++++++++++++++++++- crates/ixon/src/lib.rs | 2 + crates/ixon/src/spill.rs | 170 ++++++++++++++++++++++++++ crates/kernel/src/ingress.rs | 6 +- 8 files changed, 384 insertions(+), 7 deletions(-) create mode 100644 crates/ixon/src/spill.rs diff --git a/Cargo.lock b/Cargo.lock index 7e44d6c16..4978647a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1853,6 +1853,7 @@ dependencies = [ "rayon", "rustc-hash", "sha2 0.10.9", + "tempfile", "tiny-keccak", ] @@ -3700,6 +3701,19 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "terminal_size" version = "0.4.4" diff --git a/Cargo.toml b/Cargo.toml index 719fee16f..cc2c1cc2c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,6 +58,7 @@ rayon = "1" rustc-hash = "2" serde_json = "1" sha2 = "0.10" +tempfile = "3" tiny-keccak = { version = "2", features = ["keccak"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/crates/compile/src/compile/env.rs b/crates/compile/src/compile/env.rs index 17b4be867..892cd9811 100644 --- a/crates/compile/src/compile/env.rs +++ b/crates/compile/src/compile/env.rs @@ -1002,6 +1002,13 @@ pub fn compile_env_with_options( acc.bytes as f64 / (1024.0 * 1024.0), acc.materialized, ); + if let Some((file_bytes, segments, unsealed)) = stt.env.spill_stats() { + eprintln!( + "[compile_env] spill: {:.1} MiB file · {segments} segments sealed \ + · {unsealed} entries unsealed (heap)", + file_bytes as f64 / (1024.0 * 1024.0), + ); + } } Ok(stt) diff --git a/crates/ixon/Cargo.toml b/crates/ixon/Cargo.toml index d2c0183c4..bfcd57726 100644 --- a/crates/ixon/Cargo.toml +++ b/crates/ixon/Cargo.toml @@ -19,6 +19,7 @@ tiny-keccak = { workspace = true } [target.'cfg(not(target_arch = "riscv64"))'.dependencies] dashmap = { workspace = true, features = ["rayon"] } rayon = { workspace = true } +tempfile = { workspace = true } [dev-dependencies] ix-common = { workspace = true, features = ["quickcheck"] } diff --git a/crates/ixon/src/env.rs b/crates/ixon/src/env.rs index 8b7b48255..3e072272e 100644 --- a/crates/ixon/src/env.rs +++ b/crates/ixon/src/env.rs @@ -178,6 +178,12 @@ pub struct Env { /// supplying them in anon mode does not relax the kernel's /// metadata-free correctness model. pub anon_hints: FxHashMap, + /// Spill file state for `IX_COMPILE_SPILL=mmap` (see the `spill` + /// module docs). `Unopened` until the first spill-mode `store_const`. + /// Not carried by `Clone` — a cloned env starts a fresh spill file; + /// its mmap-backed entries keep their `Arc` windows regardless. + #[cfg(not(target_arch = "riscv64"))] + pub(crate) spill: std::sync::Mutex, } impl Env { @@ -189,6 +195,8 @@ impl Env { names: IxonMap::new(), comms: IxonMap::new(), anon_hints: FxHashMap::default(), + #[cfg(not(target_arch = "riscv64"))] + spill: Default::default(), } } @@ -222,13 +230,113 @@ impl Env { /// Host-only — see `store_blob`. #[cfg(not(target_arch = "riscv64"))] pub fn store_const(&self, addr: Address, constant: Constant) { - let lazy = match *SPILL_MODE { - SpillMode::Off => LazyConstant::from_constant(constant), - SpillMode::Demote | SpillMode::Mmap => { - LazyConstant::from_constant_uncached(constant) + self.store_const_with_mode(addr, constant, *SPILL_MODE); + } + + /// `store_const` with an explicit mode, bypassing `IX_COMPILE_SPILL`. + /// Exists so tests can drive `Demote`/`Mmap` without process-global + /// env-var races. + #[cfg(not(target_arch = "riscv64"))] + pub fn store_const_with_mode( + &self, + addr: Address, + constant: Constant, + mode: SpillMode, + ) { + match mode { + SpillMode::Off => { + self.consts.insert(addr, LazyConstant::from_constant(constant)); }, - }; - self.consts.insert(addr, lazy); + // In the spill modes a re-store of an existing address is a no-op: + // content-addressing guarantees identical bytes, re-inserting + // would downgrade an already-sealed mmap window back to heap, and + // re-appending would duplicate the bytes in the spill file. + // (Alpha-collapsed blocks re-store the shared address once per + // member.) `Off` keeps insert-overwrite: there a re-store can + // upgrade a cache-less lazy-loaded entry to a cached one. + SpillMode::Demote => { + if self.consts.contains_key(&addr) { + return; + } + self + .consts + .insert(addr, LazyConstant::from_constant_uncached(constant)); + }, + SpillMode::Mmap => { + if self.consts.contains_key(&addr) { + return; + } + let mut buf = Vec::new(); + constant.put(&mut buf); + let bytes: Arc<[u8]> = buf.into(); + // Heap entry first so the address is immediately readable; the + // spill seal swaps it to an mmap window later. + self + .consts + .insert(addr.clone(), LazyConstant::from_bytes(bytes.clone())); + self.spill_append(addr, &bytes); + }, + } + } + + /// Append one entry to the spill file, sealing (and swapping the + /// sealed entries to mmap windows) when a segment fills. Any I/O + /// error disables spilling for this env: heap-backed entries remain + /// valid, sealed windows keep their mappings. + #[cfg(not(target_arch = "riscv64"))] + fn spill_append(&self, addr: Address, bytes: &[u8]) { + use crate::spill::{SpillSlot, SpillState}; + let mut slot = self.spill.lock().unwrap(); + if matches!(*slot, SpillSlot::Unopened) { + match SpillState::create() { + Ok(st) => *slot = SpillSlot::Active(st), + Err(e) => { + eprintln!( + "[ixon] spill file creation failed ({e}); \ + falling back to heap-backed entries" + ); + *slot = SpillSlot::Disabled; + }, + } + } + let SpillSlot::Active(st) = &mut *slot else { return }; + match st.append(addr, bytes) { + Ok(None) => {}, + Ok(Some((mmap, entries))) => { + if std::env::var("IX_QUIET").is_err() { + eprintln!( + "[ixon] spill segment {} sealed: {} entries, file {:.1} MiB", + st.segments_sealed, + entries.len(), + st.file_len() as f64 / (1024.0 * 1024.0), + ); + } + for (a, off, len) in entries { + self + .consts + .insert(a, LazyConstant::from_mmap_slice(mmap.clone(), off, len)); + } + }, + Err(e) => { + eprintln!( + "[ixon] spill write failed ({e}); \ + falling back to heap-backed entries" + ); + *slot = SpillSlot::Disabled; + }, + } + } + + /// Spill file observability: `(file_bytes, segments_sealed, + /// unsealed_entry_count)`, or `None` if spilling never activated. + #[cfg(not(target_arch = "riscv64"))] + pub fn spill_stats(&self) -> Option<(u64, usize, usize)> { + match &*self.spill.lock().unwrap() { + crate::spill::SpillSlot::Active(st) => { + Some((st.file_len(), st.segments_sealed, st.pending_count())) + }, + _ => None, + } } /// Store an already-serialized constant under `addr` (lazy load path). @@ -446,6 +554,10 @@ impl Clone for Env { names, comms, anon_hints: self.anon_hints.clone(), + // A cloned env starts a fresh spill file; already-sealed mmap + // windows travel inside the cloned LazyConstants. + #[cfg(not(target_arch = "riscv64"))] + spill: Default::default(), } } } @@ -491,6 +603,72 @@ mod tests { assert_eq!(*got, constant); } + /// Distinct axiom per `lvls` so each store gets its own address. + fn axiom_with_lvls(lvls: u64) -> Constant { + Constant::new(ConstantInfo::Axio(Axiom { + is_unsafe: false, + lvls, + typ: Arc::new(Expr::Sort(0)), + })) + } + + /// Preset an Active spill state with a tiny segment so a handful of + /// stores force seals (bypasses the env-var-driven `SpillState::create`). + fn preset_tiny_spill(env: &Env, segment_bytes: usize) { + let dir = std::env::temp_dir(); + *env.spill.lock().unwrap() = crate::spill::SpillSlot::Active( + crate::spill::SpillState::create_in(dir.to_str().unwrap(), segment_bytes) + .unwrap(), + ); + } + + #[test] + fn store_const_mmap_seals_segments_and_roundtrips() { + let env = Env::new(); + preset_tiny_spill(&env, 256); + let mut stored = Vec::new(); + for i in 0..64 { + let c = axiom_with_lvls(i); + let (addr, _) = c.commit(); + env.store_const_with_mode(addr.clone(), c.clone(), SpillMode::Mmap); + stored.push((addr, c)); + } + let (file_bytes, segments, unsealed) = env.spill_stats().unwrap(); + assert!(segments >= 1, "no segment sealed (file {file_bytes}B)"); + assert!(unsealed < 64, "nothing was sealed"); + // Every entry — mmap-backed or still-heap — verifies and roundtrips. + for (addr, c) in &stored { + let entry = env.consts.get(addr).unwrap(); + assert!(entry.value().verify_address(addr)); + assert!(!entry.value().is_materialized()); + drop(entry); + assert_eq!(*env.get_const(addr).unwrap(), *c); + } + } + + #[test] + fn env_put_identical_across_spill_modes() { + let build = |mode: SpillMode, tiny_spill: bool| { + let env = Env::new(); + if tiny_spill { + preset_tiny_spill(&env, 128); + } + for i in 0..32 { + let c = axiom_with_lvls(i); + let (addr, _) = c.commit(); + env.store_const_with_mode(addr, c, mode); + } + let mut buf = Vec::new(); + env.put(&mut buf).unwrap(); + buf + }; + let off = build(SpillMode::Off, false); + let demote = build(SpillMode::Demote, false); + let mmap = build(SpillMode::Mmap, true); + assert_eq!(off, demote); + assert_eq!(off, mmap); + } + #[test] fn register_and_lookup_name() { let env = Env::new(); diff --git a/crates/ixon/src/lib.rs b/crates/ixon/src/lib.rs index 84a13addb..9825b27e5 100644 --- a/crates/ixon/src/lib.rs +++ b/crates/ixon/src/lib.rs @@ -19,6 +19,8 @@ pub mod metadata; pub mod proof; pub mod serialize; pub mod sharing; +#[cfg(not(target_arch = "riscv64"))] +pub(crate) mod spill; pub mod tag; pub mod univ; diff --git a/crates/ixon/src/spill.rs b/crates/ixon/src/spill.rs new file mode 100644 index 000000000..c32f76c36 --- /dev/null +++ b/crates/ixon/src/spill.rs @@ -0,0 +1,170 @@ +//! File-backed spilling of the compile accumulator's serialized bytes. +//! +//! Under `IX_COMPILE_SPILL=mmap`, `Env::store_const` appends each +//! constant's bytes to an **anonymous temp file** (`tempfile::tempfile_in` +//! — `O_TMPFILE` on Linux, so no pathname ever exists and the kernel +//! reclaims the space when the last fd/mapping drops, even on SIGKILL). +//! The file is sealed in fixed segments: when the unsealed region +//! exceeds the segment size, that range is mmapped read-only and every +//! entry in it is swapped from its heap `BytesSource` to a window into +//! the mapping (`LazyConstant::from_mmap_slice`). Sealed pages are clean +//! file-backed page cache — the kernel can evict them under memory +//! pressure with no swap configured — so the accumulator's resident +//! heap is bounded by one unsealed segment. +//! +//! The spill directory must be **disk-backed**: tmpfs (`/tmp` on most +//! distros) and `memfd_create` are shmem, whose pages are swap-backed +//! anonymous memory and cannot be evicted under `MemorySwapMax=0`, +//! silently defeating the spill. Hence the default is the current +//! working directory, overridable via `IX_COMPILE_SPILL_DIR`. +//! +//! Fixed sealed segments (rather than one growing mapping) keep every +//! window's lifetime trivially correct: remapping on growth would +//! invalidate outstanding windows. + +use std::fs::File; +use std::io::Write; +use std::sync::Arc; + +use memmap2::{Mmap, MmapOptions}; + +use ix_common::address::Address; + +/// Segment start alignment. Mmap offsets must be page-aligned; 64 KiB +/// covers every Linux page size in use (4k / 16k / 64k). +const SEGMENT_ALIGN: usize = 64 * 1024; + +/// Default sealed-segment size. Overridable via +/// `IX_COMPILE_SPILL_SEGMENT_MB` (minimum 1). +const DEFAULT_SEGMENT_SIZE: usize = 256 * 1024 * 1024; + +/// Spill lifecycle slot held by `Env`. `Unopened` until the first +/// spill-mode `store_const`; `Disabled` after any I/O error (already +/// heap-backed entries remain valid, later stores stay heap-backed). +#[derive(Debug, Default)] +pub(crate) enum SpillSlot { + #[default] + Unopened, + Active(SpillState), + Disabled, +} + +/// One sealed segment: the read-only mapping plus the entries it +/// contains as `(addr, offset_within_mapping, len)`. +pub(crate) type SealedSegment = (Arc, Vec<(Address, usize, usize)>); + +/// Staged bytes are flushed to the file once this much accumulates, so +/// the caller's lock hold per append is a memcpy, not a syscall — the +/// store path is called from every scheduler worker and a per-append +/// `write` measurably convoys them. +const FLUSH_SIZE: usize = 8 * 1024 * 1024; + +#[derive(Debug)] +pub(crate) struct SpillState { + file: File, + /// Appended but not yet written to the file. + staging: Vec, + /// Next virtual write offset (file bytes + staging bytes). + offset: usize, + /// Start of the unsealed segment; `SEGMENT_ALIGN`-aligned. + segment_start: usize, + /// `(addr, virtual_offset, len)` of entries in the unsealed segment. + pending: Vec<(Address, usize, usize)>, + segment_size: usize, + pub(crate) segments_sealed: usize, +} + +impl SpillState { + pub(crate) fn create() -> std::io::Result { + let dir = + std::env::var("IX_COMPILE_SPILL_DIR").unwrap_or_else(|_| ".".to_string()); + let segment_size = std::env::var("IX_COMPILE_SPILL_SEGMENT_MB") + .ok() + .and_then(|s| s.parse::().ok()) + .filter(|&n| n > 0) + .map(|n| n * 1024 * 1024) + .unwrap_or(DEFAULT_SEGMENT_SIZE); + Self::create_in(&dir, segment_size) + } + + pub(crate) fn create_in( + dir: &str, + segment_size: usize, + ) -> std::io::Result { + let file = tempfile::tempfile_in(dir)?; + Ok(SpillState { + file, + staging: Vec::with_capacity(FLUSH_SIZE), + offset: 0, + segment_start: 0, + pending: Vec::new(), + segment_size, + segments_sealed: 0, + }) + } + + /// Write staged bytes through to the file. + fn flush(&mut self) -> std::io::Result<()> { + if !self.staging.is_empty() { + self.file.write_all(&self.staging)?; + self.staging.clear(); + } + Ok(()) + } + + /// Append one entry's bytes (a memcpy into staging; the file write is + /// amortized to every `FLUSH_SIZE`). When this fills the segment, + /// seal it: flush, pad so the next segment starts aligned, mmap the + /// sealed range, and return it with its entries (offsets rebased to + /// the mapping) for the caller to swap into the consts map. + pub(crate) fn append( + &mut self, + addr: Address, + bytes: &[u8], + ) -> std::io::Result> { + self.staging.extend_from_slice(bytes); + self.pending.push((addr, self.offset, bytes.len())); + self.offset += bytes.len(); + + if self.offset - self.segment_start < self.segment_size { + if self.staging.len() >= FLUSH_SIZE { + self.flush()?; + } + return Ok(None); + } + + let data_end = self.offset; + let next_start = data_end.div_ceil(SEGMENT_ALIGN) * SEGMENT_ALIGN; + self.staging.resize(self.staging.len() + (next_start - data_end), 0); + self.offset = next_start; + self.flush()?; + // Safety: the fd is a private anonymous temp file no other process + // can open or truncate; write() and mmap go through the same page + // cache on Linux, so the sealed range reads back what was written. + let mmap = unsafe { + MmapOptions::new() + .offset(self.segment_start as u64) + .len(data_end - self.segment_start) + .map(&self.file)? + }; + let seg_start = self.segment_start; + self.segment_start = next_start; + self.segments_sealed += 1; + let entries = std::mem::take(&mut self.pending) + .into_iter() + .map(|(a, off, len)| (a, off - seg_start, len)) + .collect(); + Ok(Some((Arc::new(mmap), entries))) + } + + /// Entries appended but not yet sealed into a mapping. + pub(crate) fn pending_count(&self) -> usize { + self.pending.len() + } + + /// Virtual spill size: data plus alignment padding, including staged + /// bytes not yet written through. + pub(crate) fn file_len(&self) -> u64 { + self.offset as u64 + } +} diff --git a/crates/kernel/src/ingress.rs b/crates/kernel/src/ingress.rs index 5b17aa012..a1bfcfdf5 100644 --- a/crates/kernel/src/ingress.rs +++ b/crates/kernel/src/ingress.rs @@ -3684,7 +3684,11 @@ fn drop_ixon_env(ixon_env: IxonEnv, quiet: bool) { // `anon_hints` is a small FxHashMap (one entry per Def from the .ixe's // Named metadata); dropping it inline alongside the bookkeeping below // is negligible compared to the DashMap dropdance. - let IxonEnv { consts, named, blobs, names, comms, anon_hints: _ } = ixon_env; + // `..` also drops the env's private spill slot (closes the spill fd; + // sealed mmap windows live on inside the consts entries until those + // drop below). + let IxonEnv { consts, named, blobs, names, comms, anon_hints: _, .. } = + ixon_env; let consts_len = consts.len(); let named_len = named.len(); let names_len = names.len(); From 6a564f9bb332936d8d02ac0629dd720b50f46266 Mon Sep 17 00:00:00 2001 From: samuelburnham <45365069+samuelburnham@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:35:05 -0400 Subject: [PATCH 03/19] Compile spill: RSS decomposition instrumentation + worker kenv clearing Instrumentation (IX_QUIET-gated): sample /proc/self/status (VmRSS/RssAnon/RssFile) at rs_compile_env entry, after decode_env, at scheduler start, per decile, and at completion. The anon/file split is the signal the spill work changes: anon can only leave RAM via swap, file RSS is reclaimable page cache. IX_COMPILE_KENV_CLEAR_EVERY=N (default 0 = never, today's behavior) clears each worker's kernel env every N completed blocks via the existing clear_releasing_memory. The kenv is a pure cache of Lean-env-derived data (ensure_in_kenv re-ingresses on demand), so block-boundary clearing is semantics-free; InitStd output is byte-identical with N=64 and the suites pass with N=2. Measured on Mathlib under a 50 GB cap (24 workers): progress ladder off 48.3% -> mmap 66% -> mmap+clear=64 72.5%, no wall-time cost. The decomposition pins the remaining OOM gap on the decoded Rust LeanEnv (25.2 GiB anon, whole-run) and structured named/names metadata (~13 GiB anon by 62%); spilled accumulator pages and mmapped oleans are observably evicted under pressure (file RSS 6.3 -> 1.3 GiB). Roadmap with measured budgets: docs/compile-spill.md, "Mathlib on a 56 GB box". --- crates/compile/src/compile.rs | 2 +- crates/compile/src/compile/env.rs | 62 +++++++++++++++++++++++++++++-- crates/ffi/src/compile.rs | 16 ++++++++ 3 files changed, 76 insertions(+), 4 deletions(-) diff --git a/crates/compile/src/compile.rs b/crates/compile/src/compile.rs index 0200d9afa..7f8f0a50b 100644 --- a/crates/compile/src/compile.rs +++ b/crates/compile/src/compile.rs @@ -4120,7 +4120,7 @@ mod env; pub mod mutual; pub mod nat_conv; pub mod surgery; -pub use env::{compile_env, compile_env_with_options}; +pub use env::{compile_env, compile_env_with_options, self_rss_kb}; #[cfg(test)] mod tests { diff --git a/crates/compile/src/compile/env.rs b/crates/compile/src/compile/env.rs index 892cd9811..455df3783 100644 --- a/crates/compile/src/compile/env.rs +++ b/crates/compile/src/compile/env.rs @@ -48,6 +48,47 @@ static IX_PROGRESS_MS: LazyLock = LazyLock::new(|| { .unwrap_or(2000) }); +/// Clear each worker's kernel env every N completed blocks (releasing +/// allocations), trading re-ingress CPU for bounded per-worker cache +/// growth. `0` (default) never clears — today's behavior. The kenv is +/// a pure cache of Lean-env-derived data (`ensure_in_kenv` re-ingresses +/// on demand), so clearing at block boundaries is semantics-free. +static IX_COMPILE_KENV_CLEAR_EVERY: LazyLock = LazyLock::new(|| { + std::env::var("IX_COMPILE_KENV_CLEAR_EVERY") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(0) +}); + +/// `(VmRSS, RssAnon, RssFile)` of this process in KiB, from +/// `/proc/self/status`. Anonymous memory can only leave RAM via swap; +/// file-backed RSS is reclaimable page cache — the split is what the +/// spill work changes, so the instrumentation reports both. +pub fn self_rss_kb() -> Option<(u64, u64, u64)> { + let status = std::fs::read_to_string("/proc/self/status").ok()?; + let field = |key: &str| { + status + .lines() + .find(|l| l.starts_with(key)) + .and_then(|l| l.split_whitespace().nth(1)) + .and_then(|v| v.parse::().ok()) + }; + Some((field("VmRSS:")?, field("RssAnon:")?, field("RssFile:")?)) +} + +/// Render `self_rss_kb` for the progress logs. +fn rss_log_suffix() -> String { + match self_rss_kb() { + Some((vm, anon, file)) => format!( + " · rss {:.1} GiB (anon {:.1}, file {:.1})", + vm as f64 / (1024.0 * 1024.0), + anon as f64 / (1024.0 * 1024.0), + file as f64 / (1024.0 * 1024.0), + ), + None => String::new(), + } +} + /// Recover a short string description from a panic payload. fn panic_message(panic: &(dyn std::any::Any + Send)) -> String { panic @@ -365,7 +406,8 @@ pub fn compile_env_with_options( if !*IX_QUIET { eprintln!( - "[compile_env] starting: {total_blocks} blocks, {num_threads} workers" + "[compile_env] starting: {total_blocks} blocks, {num_threads} workers{}", + rss_log_suffix(), ); } @@ -478,10 +520,11 @@ pub fn compile_env_with_options( let acc = stats_stt.env.const_cache_stats(); eprintln!( "[compile_env] accumulator @ {done}/{total}: {} consts · \ - {:.1} MiB serialized bytes · {} materialized caches", + {:.1} MiB serialized bytes · {} materialized caches{}", acc.entries, acc.bytes as f64 / (1024.0 * 1024.0), acc.materialized, + rss_log_suffix(), ); // Aggregate worker kenv sizes from the per-worker snapshots // (each refreshed at that worker's last block completion) — @@ -511,6 +554,7 @@ pub fn compile_env_with_options( for worker_id in 0..num_threads { s.spawn(move || { let mut worker_kctx = crate::compile::KernelCtx::new(); + let mut worker_blocks_done = 0usize; loop { // Try to get work from the ready queue let work = { @@ -870,6 +914,17 @@ pub fn compile_env_with_options( condvar_ref.notify_one(); } + // Bounded per-worker kenv growth: drop the caches every N + // blocks when configured. Block boundary only — nothing + // holds kenv references across blocks; `ensure_in_kenv` + // re-ingresses on demand. + worker_blocks_done += 1; + if *IX_COMPILE_KENV_CLEAR_EVERY > 0 + && worker_blocks_done % *IX_COMPILE_KENV_CLEAR_EVERY == 0 + { + worker_kctx.kenv.clear_releasing_memory(); + } + // Refresh this worker's kenv-size snapshot for the // reporter's decile aggregate. cache_sizes() is ~20 map // len() reads; the slot is uncontended except during the @@ -978,12 +1033,13 @@ pub fn compile_env_with_options( let total_elapsed = compile_start.elapsed().as_secs_f64(); eprintln!( "[compile_env] complete in {total_elapsed:.1}s · \ - env: {} consts, {} named, {} names, {} blobs, {} comms", + env: {} consts, {} named, {} names, {} blobs, {} comms{}", stt.env.const_count(), stt.env.named_count(), stt.env.name_count(), stt.env.blob_count(), stt.env.comm_count(), + rss_log_suffix(), ); // Accumulator composition: how much of `env.consts` is materialized // `Arc` caches vs serialized bytes. The byte sum is the diff --git a/crates/ffi/src/compile.rs b/crates/ffi/src/compile.rs index cf9285114..5ec0af6c3 100644 --- a/crates/ffi/src/compile.rs +++ b/crates/ffi/src/compile.rs @@ -291,8 +291,24 @@ pub extern "C" fn rs_compile_env( ) -> LeanIOResult { { let quiet = std::env::var("IX_QUIET").is_ok(); + // RSS here ≈ the Lean-side floor (imports + elaborated env); the + // post-decode delta is the owned Rust copy of the environment. + let rss_gib = |label: &str| { + if !quiet + && let Some((vm, anon, file)) = ix_compile::compile::self_rss_kb() + { + eprintln!( + "[rs_compile_env] rss {label}: {:.1} GiB (anon {:.1}, file {:.1})", + vm as f64 / (1024.0 * 1024.0), + anon as f64 / (1024.0 * 1024.0), + file as f64 / (1024.0 * 1024.0), + ); + } + }; + rss_gib("at entry"); let rust_env = decode_env(env_consts_ptr); let rust_env = Arc::new(rust_env); + rss_gib("after decode_env"); let compile_stt = match compile_env_with_options(&rust_env, CompileOptions::default()) { From e7c232bb80431414bfbee55255ed8e5c54f3e99f Mon Sep 17 00:00:00 2001 From: samuelburnham <45365069+samuelburnham@users.noreply.github.com> Date: Thu, 9 Jul 2026 17:27:49 -0400 Subject: [PATCH 04/19] Compile spill lever 2: demote named metadata to serialized bytes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IX_COMPILE_META=demote (default structured = today's behavior) stores each registered Named's metadata as its self-contained serialized form instead of a structured ConstantMeta DAG, decoded on demand. - metadata.rs: name references are written through a NamePut/NameGet coder — Indexed (the .ixe named-section form, u64 into the env name index) or Raw (32-byte addresses, self-contained). Same wire format otherwise, so ConstantMeta::put_raw bytes re-encode through the index bit-identically at Env::put. Public entry points renamed put_indexed/get_indexed -> put_with/get_with; put_raw/get_raw added. - env.rs: Named's meta/original fields are private behind a MetaRepr (Structured(Arc) | Bytes(Arc<[u8]>)) with accessors meta()/original()/has_original()/set_original()/demote(). register_name demotes under the flag; set_original follows the entry's repr so promote_aux keeps demoted entries demoted. - Callers across kernel ingress, decompile, kernel egress, ffi, and ixvm-codegen migrated from field access to accessors (behavior preserved; structured mode is an Arc clone per access). Measured under the 50 GB cap, 24 workers, with IX_COMPILE_SPILL=mmap + IX_COMPILE_KENV_CLEAR_EVERY=64: - InitStd: peak RSS 8.2 -> 5.6 GiB, .ixe byte-identical, ~+10% wall (Env::put re-encode of 106k metas). - Mathlib: compile-phase anon growth flattens from 29->42+ GiB (OOM at 72.5%) to 28->32.4 GiB, and Mathlib COMPLETES on the 56 GB dev box for the first time: 45.1 GB peak, 726,513 blocks, 45.5s scheduler / 134s total, 2.9 GB .ixe. Remaining peak is the end-of-run serialization spike (~6.5 GiB) — lever 4's target. --- crates/compile/src/compile.rs | 8 +- crates/compile/src/decompile.rs | 88 ++++++------ crates/compile/src/kernel_egress.rs | 17 +-- crates/ffi/src/compile.rs | 4 +- crates/ffi/src/kernel.rs | 4 +- crates/ffi/src/lean_env.rs | 8 +- crates/ffi/src/lean_ixon/env.rs | 2 +- crates/ffi/src/lean_ixon/meta.rs | 9 +- crates/ixon/src/env.rs | 167 ++++++++++++++++++++-- crates/ixon/src/metadata.rs | 190 ++++++++++++++++---------- crates/ixon/src/serialize.rs | 41 +++--- crates/ixvm-codegen/src/env_handle.rs | 2 +- crates/kernel/src/ingress.rs | 23 ++-- 13 files changed, 385 insertions(+), 178 deletions(-) diff --git a/crates/compile/src/compile.rs b/crates/compile/src/compile.rs index 7f8f0a50b..304fe43ea 100644 --- a/crates/compile/src/compile.rs +++ b/crates/compile/src/compile.rs @@ -312,7 +312,7 @@ impl CompileState { self.name_to_addr.insert(name.clone(), aux_addr.clone()); } if let Some(mut entry) = self.env.named.get_mut(name) { - entry.value_mut().original = Some((orig_addr, orig_meta)); + entry.value_mut().set_original(orig_addr, orig_meta); } Ok(()) } @@ -798,11 +798,7 @@ pub fn compile_expr( cache.compiling.as_ref().is_some_and(|c| { crate::decompile::is_aux_gen_suffix(c) && (stt.aux_name_to_addr.contains_key(c) - || stt - .env - .named - .get(c) - .is_some_and(|n| n.original.is_some())) + || stt.env.named.get(c).is_some_and(|n| n.has_original())) }); if !compiling_is_aux_regen { if let Some(plan) = stt.call_site_plans.get(name) diff --git a/crates/compile/src/decompile.rs b/crates/compile/src/decompile.rs index 56a7ab62b..2c30d6d4a 100644 --- a/crates/compile/src/decompile.rs +++ b/crates/compile/src/decompile.rs @@ -1609,9 +1609,8 @@ fn decompile_inductive( // structure (e.g., alpha-collapsed with fewer motives) than the // expression being decompiled. The original metadata matches the // un-collapsed block structure. - n.original - .as_ref() - .map_or_else(|| n.meta.clone(), |(_, m)| m.clone()) + n.original() + .map_or_else(|| (*n.meta()).clone(), |(_, m)| (*m).clone()) }) .unwrap_or_default() } else { @@ -1707,7 +1706,8 @@ fn decompile_projection( dstt: &DecompileState, ) -> Result<(), DecompileError> { // Build ctx from metadata's ctx field - let ctx_addrs = get_ctx_from_meta(&named.meta); + let named_meta = named.meta(); + let ctx_addrs = get_ctx_from_meta(&named_meta); let ctx_names: Vec = ctx_addrs .iter() .map(|a| decompile_name(a, stt)) @@ -1727,7 +1727,7 @@ fn decompile_projection( // every `_sizeOf_N` — which is a DPrj into its mutual block and // whose body's `.rec` surgery produces `Collapsed` entries under // alpha-collapse — would fail with shape mismatches on decompile. - cache.load_meta_extensions(&named.meta); + cache.load_meta_extensions(&named_meta); // Each projection variant must land on the matching `MutConst` kind // at its block index. A silent fall-through would leave `name` @@ -1737,7 +1737,7 @@ fn decompile_projection( ConstantInfo::DPrj(proj) => match mutuals.get(proj.idx as usize) { Some(MutConst::Defn(def)) => { let info = - decompile_definition(def, &named.meta, &mut cache, stt, dstt)?; + decompile_definition(def, &named_meta, &mut cache, stt, dstt)?; dstt.env.insert(name.clone(), info); }, other => { @@ -1755,7 +1755,7 @@ fn decompile_projection( ConstantInfo::IPrj(proj) => match mutuals.get(proj.idx as usize) { Some(MutConst::Indc(ind)) => { let (ind_val, ctors) = - decompile_inductive(ind, &named.meta, &mut cache, stt, dstt)?; + decompile_inductive(ind, &named_meta, &mut cache, stt, dstt)?; dstt.env.insert(name.clone(), LeanConstantInfo::InductInfo(ind_val)); for ctor in ctors { dstt @@ -1777,7 +1777,7 @@ fn decompile_projection( ConstantInfo::RPrj(proj) => match mutuals.get(proj.idx as usize) { Some(MutConst::Recr(rec)) => { - let info = decompile_recursor(rec, &named.meta, &mut cache, stt, dstt)?; + let info = decompile_recursor(rec, &named_meta, &mut cache, stt, dstt)?; dstt.env.insert(name.clone(), info); }, other => { @@ -1812,8 +1812,7 @@ fn projection_mismatch_error( ) -> DecompileError { let has_addr = stt.name_to_addr.contains_key(name); let has_aux = stt.aux_name_to_addr.contains_key(name); - let has_original = - stt.env.named.get(name).is_some_and(|n| n.original.is_some()); + let has_original = stt.env.named.get(name).is_some_and(|n| n.has_original()); DecompileError::BadConstantFormat { msg: format!( "{kind} '{}' idx={idx} landed on {:?} (mutuals.len={mutuals_len}, \ @@ -1834,7 +1833,8 @@ fn decompile_const( let cnst = read_const(&named.addr, stt)?; // Build ctx from metadata's all field - let all_addrs = get_all_from_meta(&named.meta); + let named_meta = named.meta(); + let all_addrs = get_all_from_meta(&named_meta); let all_names: Vec = all_addrs .iter() .map(|a| decompile_name(a, stt)) @@ -1852,8 +1852,8 @@ fn decompile_const( current_const: current_const.clone(), ..Default::default() }; - cache.load_meta_extensions(&named.meta); - let info = decompile_definition(def, &named.meta, &mut cache, stt, dstt)?; + cache.load_meta_extensions(&named_meta); + let info = decompile_definition(def, &named_meta, &mut cache, stt, dstt)?; dstt.env.insert(name.clone(), info); }, @@ -1871,8 +1871,8 @@ fn decompile_const( // Defn branch above — omitting this desyncs // `CallSiteEntry::Collapsed.sharing_idx` from the intended // `meta_sharing` slot. - cache.load_meta_extensions(&named.meta); - let info = decompile_recursor(rec, &named.meta, &mut cache, stt, dstt)?; + cache.load_meta_extensions(&named_meta); + let info = decompile_recursor(rec, &named_meta, &mut cache, stt, dstt)?; dstt.env.insert(name.clone(), info); }, @@ -1887,8 +1887,8 @@ fn decompile_const( }; // Axioms have only a type (no body), so no surgery today — but // load extensions for consistency with the other branches. - cache.load_meta_extensions(&named.meta); - let info = decompile_axiom(ax, &named.meta, &mut cache, stt, dstt)?; + cache.load_meta_extensions(&named_meta); + let info = decompile_axiom(ax, &named_meta, &mut cache, stt, dstt)?; dstt.env.insert(name.clone(), info); }, @@ -1903,8 +1903,8 @@ fn decompile_const( }; // Quotient types have only a type signature — same story as // axioms. Load extensions for consistency. - cache.load_meta_extensions(&named.meta); - let info = decompile_quotient(quot, &named.meta, &mut cache, stt, dstt)?; + cache.load_meta_extensions(&named_meta); + let info = decompile_quotient(quot, &named_meta, &mut cache, stt, dstt)?; dstt.env.insert(name.clone(), info); }, @@ -2680,8 +2680,8 @@ fn roundtrip_block( let orig_addr = if singleton { // Singleton: compare directly against the constant's original address. stt.env.named.get(&first_name).map(|named| { - if let Some((ref orig_a, _)) = named.original { - orig_a.clone() + if let Some((orig_a, _)) = named.original() { + orig_a } else { named.addr.clone() } @@ -2690,12 +2690,12 @@ fn roundtrip_block( // Mutual block: compare against the original block address. // The original block addr is stored in the projection's block field. stt.env.named.get(&first_name).and_then(|named| { - let addr = if let Some((ref orig_a, _)) = named.original { + let addr = if let Some((orig_a, _)) = named.original() { orig_a } else { - &named.addr + named.addr.clone() }; - stt.env.get_const(addr).map(|c| match &c.info { + stt.env.get_const(&addr).map(|c| match &c.info { ConstantInfo::RPrj(p) => p.block.clone(), ConstantInfo::DPrj(p) => p.block.clone(), ConstantInfo::IPrj(p) => p.block.clone(), @@ -2929,15 +2929,16 @@ fn roundtrip_block( // Look up original metadata from compile_const_no_aux. If not // available, fall back to Phase A metadata from the current compilation. let orig_meta = match stt.env.named.get(&name) { - Some(ref named) if named.original.is_some() => { + Some(ref named) if named.has_original() => { + let (orig_addr, orig_meta) = named.original().unwrap(); if std::env::var_os("IX_ROUNDTRIP_DEBUG").is_some() { eprintln!( "[orig_meta] {}: using named.original (addr={:.12})", name.pretty(), - named.original.as_ref().unwrap().0.hex(), + orig_addr.hex(), ); } - named.original.as_ref().unwrap().1.clone() + (*orig_meta).clone() }, s => { if std::env::var_os("IX_ROUNDTRIP_DEBUG").is_some() { @@ -3125,7 +3126,7 @@ fn roundtrip_block( if is_primary && !is_aux_gen_suffix(&n) && let Some(ref named) = stt.env.named.get(&n) - && let Some((ref orig_addr, _)) = named.original + && let Some((orig_addr, _)) = named.original() { let proj_addr = match cnst { LeanMutConst::Recr(_) => { @@ -3151,14 +3152,14 @@ fn roundtrip_block( ixon_content_address(&proj) }, }; - if &proj_addr != orig_addr { + if proj_addr != orig_addr { // The original might be a singleton (bare constant, not // Muts-wrapped projection) while roundtrip always wraps in // Muts. Skip the mismatch if the original is a singleton // (non-projection) or not stored (compile_const_no_aux // with aux=false doesn't store singleton constants). let orig_is_singleton = - stt.env.get_const(orig_addr).is_none_or(|c| { + stt.env.get_const(&orig_addr).is_none_or(|c| { !matches!( &c.info, ConstantInfo::IPrj(_) @@ -3174,7 +3175,7 @@ fn roundtrip_block( // `eprintln!` and swallowed; now propagated so callers // don't silently commit a mismatched constant. let orig_detail = - stt.env.get_const(orig_addr).map(|c| match &c.info { + stt.env.get_const(&orig_addr).map(|c| match &c.info { ConstantInfo::RPrj(p) => format!( "RPrj(idx={}, block={:.12})", p.idx, @@ -3376,7 +3377,7 @@ fn print_rec_comparison( /// Decompile a single named constant (non-aux_gen) into the decompile state. /// /// Dispatches on the constant kind (definition, recursor, axiom, quotient, -/// projection). Constants with `named.original.is_some()` and a recognized +/// projection). Constants with `named.has_original()` and a recognized /// aux_gen suffix are skipped — they'll be regenerated by `decompile_block_aux_gen`. fn decompile_named_const( name: &Name, @@ -3385,7 +3386,7 @@ fn decompile_named_const( dstt: &DecompileState, ) -> Result<(), DecompileError> { // Skip aux_gen constants (regenerated separately) - if named.original.is_some() && is_aux_gen_suffix(name) { + if named.has_original() && is_aux_gen_suffix(name) { return Ok(()); } @@ -3484,7 +3485,8 @@ fn rehydrate_aux_perms_from_env(stt: &CompileState) { // number of mutual blocks in the env, not their sizes. for muts_entry in stt.env.named.iter() { let muts_named = muts_entry.value(); - let (muts_all, aux_layout) = match &muts_named.meta.info { + let muts_meta = muts_named.meta(); + let (muts_all, aux_layout) = match &muts_meta.info { ConstantMetaInfo::Muts { all, aux_layout: Some(layout) } => { n_muts += 1; n_muts_with_layout += 1; @@ -3518,7 +3520,8 @@ fn rehydrate_aux_perms_from_env(stt: &CompileState) { // version whose Indc.all is also source-order; we prefer the // canonical-entry `Indc.all` since it's the same source-order list // under spec §10.2.) - let source_all: Option<&[Address]> = match &rep_named.meta.info { + let rep_meta = rep_named.meta(); + let source_all: Option<&[Address]> = match &rep_meta.info { ConstantMetaInfo::Indc { all, .. } => Some(all.as_slice()), _ => None, }; @@ -3598,7 +3601,7 @@ fn names_from_addrs( fn indc_source_all(name: &Name, stt: &CompileState) -> Option> { let named = stt.env.named.get(name)?; - match &named.meta.info { + match &named.meta().info { ConstantMetaInfo::Indc { all, .. } => names_from_addrs(all, stt), _ => None, } @@ -3613,9 +3616,8 @@ fn stored_plan_blocks_for_original_all( let mut seen: FxHashSet> = FxHashSet::default(); for muts_entry in stt.env.named.iter() { - let ConstantMetaInfo::Muts { all, aux_layout } = - &muts_entry.value().meta.info - else { + let muts_meta = muts_entry.value().meta(); + let ConstantMetaInfo::Muts { all, aux_layout } = &muts_meta.info else { continue; }; @@ -3808,14 +3810,14 @@ fn recover_aux_from_original( dstt: &DecompileState, ) -> bool { let original = match stt.env.named.get(name) { - Some(named) => named.original.clone(), + Some(named) => named.original(), None => None, }; let Some((orig_addr, orig_meta)) = original else { return false; }; let had_entry = dstt.env.contains_key(name); - let synthetic = Named { addr: orig_addr, meta: orig_meta, original: None }; + let synthetic = Named::new(orig_addr, (*orig_meta).clone()); if decompile_named_const(name, &synthetic, stt, dstt).is_err() { return false; } @@ -4379,7 +4381,7 @@ fn decompile_block_aux_gen( }; let orig_info: Option<(String, String)> = stt.env.named.get(&d.name).and_then(|named| { - let (addr, _) = named.original.as_ref()?.clone(); + let (addr, _) = named.original()?; let kind = stt .env .get_const(&addr) @@ -4753,7 +4755,7 @@ pub fn decompile_env( for entry in stt.env.named.iter() { let (name, named) = (entry.key(), entry.value()); - if named.original.is_none() { + if !named.has_original() { continue; } let Some((kind, root)) = classify_aux_gen(name) else { diff --git a/crates/compile/src/kernel_egress.rs b/crates/compile/src/kernel_egress.rs index a0ba4184e..6af61aaa6 100644 --- a/crates/compile/src/kernel_egress.rs +++ b/crates/compile/src/kernel_egress.rs @@ -790,15 +790,15 @@ fn build_mut_const( /// the original's `meta` and `original` (aux_gen regeneration hint) fields /// but with an updated `addr`. /// -/// Decompile's Pass 2 relies on `named.original.is_some()` to decide which +/// Decompile's Pass 2 relies on `named.has_original()` to decide which /// entries are aux_gen-regenerated — we MUST copy that field over, or /// otherwise every `.brecOn*` / `.below` / `.brecOn_N.eq` gets dropped. fn rebuild_named(addr: Address, original: &Named) -> Named { - Named { - addr, - meta: original.meta.clone(), - original: original.original.clone(), + let mut named = Named::new(addr, (*original.meta()).clone()); + if let Some((orig_addr, orig_meta)) = original.original() { + named.set_original(orig_addr, (*orig_meta).clone()); } + named } /// Register a member `Named` pointing at the appropriate address: @@ -976,7 +976,7 @@ fn egress_muts_block( // Register the synthetic Muts Named entry at the new block_addr. Preserve // the original `meta` / `original` fields — decompile's Pass 2 keys off - // `named.original.is_some()` to identify aux_gen entries. + // `named.has_original()` to identify aux_gen entries. out.register_name( muts_name.clone(), rebuild_named(block_addr.clone(), muts_named), @@ -1198,7 +1198,7 @@ pub fn ixon_egress( for entry in original_env.named.iter() { let name = entry.key().clone(); let named = entry.value().clone(); - match &named.meta.info { + match &named.meta().info { ConstantMetaInfo::Muts { .. } => muts_entries.push((name, named)), _ => { let orig_const = original_env.get_const(&named.addr); @@ -1229,7 +1229,8 @@ pub fn ixon_egress( let t_muts = std::time::Instant::now(); muts_entries.par_iter().try_for_each( |(muts_name, muts_named)| -> Result<(), String> { - let all: &[Vec
] = match &muts_named.meta.info { + let muts_meta = muts_named.meta(); + let all: &[Vec
] = match &muts_meta.info { ConstantMetaInfo::Muts { all, .. } => all.as_slice(), _ => unreachable!("partitioned above"), }; diff --git a/crates/ffi/src/compile.rs b/crates/ffi/src/compile.rs index 5ec0af6c3..a2ae2048d 100644 --- a/crates/ffi/src/compile.rs +++ b/crates/ffi/src/compile.rs @@ -458,7 +458,7 @@ pub extern "C" fn rs_compile_phases( .collect(); let named_arr = LeanArray::alloc(named.len()); for (i, (name, n)) in named.iter().enumerate() { - named_arr.set(i, build_raw_named(&mut cache, name, &n.addr, &n.meta)); + named_arr.set(i, build_raw_named(&mut cache, name, &n.addr, &n.meta())); } let blobs: Vec<_> = compile_stt @@ -554,7 +554,7 @@ pub extern "C" fn rs_compile_env_to_ixon( .collect(); let named_arr = LeanArray::alloc(named.len()); for (i, (name, n)) in named.iter().enumerate() { - named_arr.set(i, build_raw_named(&mut cache, name, &n.addr, &n.meta)); + named_arr.set(i, build_raw_named(&mut cache, name, &n.addr, &n.meta())); } let blobs: Vec<_> = compile_stt diff --git a/crates/ffi/src/kernel.rs b/crates/ffi/src/kernel.rs index d7d1c2577..475669efb 100644 --- a/crates/ffi/src/kernel.rs +++ b/crates/ffi/src/kernel.rs @@ -754,7 +754,7 @@ pub extern "C" fn rs_prim_addrs_canonical() -> LeanIOResult { fn all_checkable_ixon_names(ixon_env: &IxonEnv) -> Vec { let mut names = Vec::with_capacity(ixon_env.named_count()); for entry in ixon_env.named.iter() { - if matches!(entry.value().meta.info, ConstantMetaInfo::Muts { .. }) { + if matches!(entry.value().meta().info, ConstantMetaInfo::Muts { .. }) { continue; } names.push(entry.key().clone()); @@ -984,7 +984,7 @@ fn check_schedule_block_addr( return None; } let named = ixon_env.lookup_name(name)?; - if matches!(named.meta.info, ConstantMetaInfo::Muts { .. }) { + if matches!(named.meta().info, ConstantMetaInfo::Muts { .. }) { return None; } let constant = ixon_env.get_const(&named.addr)?; diff --git a/crates/ffi/src/lean_env.rs b/crates/ffi/src/lean_env.rs index 776070f63..b30c7a747 100644 --- a/crates/ffi/src/lean_env.rs +++ b/crates/ffi/src/lean_env.rs @@ -2410,7 +2410,7 @@ extern "C" fn rs_compile_validate_aux( stt.env.named.par_iter().for_each(|entry| { let named = entry.value(); - if let Some((orig_addr, _)) = &named.original { + if let Some((orig_addr, _)) = &named.original() { if *orig_addr != named.addr && stt.env.consts.contains_key(orig_addr) && !canonical_addrs.contains(orig_addr) @@ -3703,7 +3703,7 @@ extern "C" fn rs_compile_validate_aux( fresh_stt .name_to_addr .insert(entry.key().clone(), entry.value().addr.clone()); - if entry.value().original.is_some() { + if entry.value().has_original() { n_original += 1; } } @@ -4165,7 +4165,7 @@ fn compute_const_size_breakdown( // Metadata size let meta_size = if let Some(named) = stt.env.named.get(name) { - serialized_meta_size(&named.meta, name_index) + serialized_meta_size(&named.meta(), name_index) } else { 0 }; @@ -4181,7 +4181,7 @@ fn serialized_meta_size( ) -> usize { let mut buf = Vec::new(); meta - .put_indexed(name_index, &mut buf) + .put_with(ixon::metadata::NamePut::Indexed(name_index), &mut buf) .expect("metadata serialization failed"); buf.len() } diff --git a/crates/ffi/src/lean_ixon/env.rs b/crates/ffi/src/lean_ixon/env.rs index 398ada44e..edac79ad2 100644 --- a/crates/ffi/src/lean_ixon/env.rs +++ b/crates/ffi/src/lean_ixon/env.rs @@ -359,7 +359,7 @@ pub fn ixon_env_to_decoded(env: &IxonEnv) -> Result { .map(|e| DecodedRawNamed { name: e.key().clone(), addr: e.value().addr.clone(), - const_meta: e.value().meta.clone(), + const_meta: (*e.value().meta()).clone(), }) .collect(); let blobs = env diff --git a/crates/ffi/src/lean_ixon/meta.rs b/crates/ffi/src/lean_ixon/meta.rs index 13db8ec0e..5c8af7c00 100644 --- a/crates/ffi/src/lean_ixon/meta.rs +++ b/crates/ffi/src/lean_ixon/meta.rs @@ -686,7 +686,11 @@ impl LeanIxonNamed { tag => panic!("Invalid Option tag for Named.original: {tag}"), } }; - Named { addr, meta, original } + let mut named = Named::new(addr, meta); + if let Some((orig_addr, orig_meta)) = original { + named.set_original(orig_addr, orig_meta); + } + named } } @@ -774,5 +778,6 @@ pub extern "C" fn rs_roundtrip_ixon_named( obj: LeanIxonNamed>, ) -> LeanIxonNamed { let named = obj.decode(); - LeanIxonNamed::build(&named.addr, &named.meta, &named.original) + let original = named.original().map(|(a, m)| (a, (*m).clone())); + LeanIxonNamed::build(&named.addr, &named.meta(), &original) } diff --git a/crates/ixon/src/env.rs b/crates/ixon/src/env.rs index 3e072272e..2d5dd9c40 100644 --- a/crates/ixon/src/env.rs +++ b/crates/ixon/src/env.rs @@ -13,27 +13,117 @@ use super::lazy::LazyConstant; use super::map::IxonMap; use super::metadata::{ConstantMeta, ConstantMetaInfo}; +/// Metadata representation inside [`Named`]: structured (default) or +/// demoted to its self-contained serialized form +/// ([`ConstantMeta::put_raw`]), which costs a fraction of the +/// pointer-rich structured DAG and is decoded on demand. The demoted +/// form is chosen at registration under `IX_COMPILE_META=demote`. +#[derive(Clone, Debug)] +enum MetaRepr { + Structured(Arc), + Bytes(Arc<[u8]>), +} + +impl MetaRepr { + fn structured(meta: ConstantMeta) -> Self { + MetaRepr::Structured(Arc::new(meta)) + } + + fn demoted(meta: &ConstantMeta) -> Self { + let mut buf = Vec::new(); + meta + .put_raw(&mut buf) + .expect("ConstantMeta::put_raw cannot fail on in-memory metadata"); + MetaRepr::Bytes(buf.into()) + } + + /// Materialize. Cheap `Arc` clone for `Structured`; a fresh decode per + /// call for `Bytes` (nothing is cached — mirroring `LazyConstant`). + fn decode(&self) -> Arc { + match self { + MetaRepr::Structured(m) => m.clone(), + MetaRepr::Bytes(b) => { + let mut slice: &[u8] = b; + Arc::new( + ConstantMeta::get_raw(&mut slice) + .expect("Named meta bytes produced by put_raw failed to decode"), + ) + }, + } + } +} + /// A named constant with metadata. #[derive(Clone, Debug)] pub struct Named { /// Address of the constant (in consts map) pub addr: Address, - /// Typed metadata for this constant (includes mutual context in `all` field) - pub meta: ConstantMeta, + /// Typed metadata for this constant (includes mutual context in `all` + /// field). Private repr: structured, or demoted to serialized bytes — + /// read through [`Self::meta`]. + meta: MetaRepr, /// For aux_gen-rewritten constants: the original Lean constant's compiled /// form (address + metadata). Ingress uses `addr`/`meta` (the canonical /// aux_gen form). Decompile uses `original` for faithful roundtrip of /// binder names and other cosmetic metadata. - pub original: Option<(Address, ConstantMeta)>, + original: Option<(Address, MetaRepr)>, } impl Named { pub fn new(addr: Address, meta: ConstantMeta) -> Self { - Named { addr, meta, original: None } + Named { addr, meta: MetaRepr::structured(meta), original: None } } pub fn with_addr(addr: Address) -> Self { - Named { addr, meta: ConstantMeta::default(), original: None } + Named { + addr, + meta: MetaRepr::structured(ConstantMeta::default()), + original: None, + } + } + + /// The constant's metadata. Cheap (`Arc` clone) for structured + /// entries; a fresh decode per call for demoted ones. + pub fn meta(&self) -> Arc { + self.meta.decode() + } + + /// The aux_gen original form, if recorded (see field docs). + pub fn original(&self) -> Option<(Address, Arc)> { + self.original.as_ref().map(|(a, m)| (a.clone(), m.decode())) + } + + pub fn has_original(&self) -> bool { + self.original.is_some() + } + + /// Record the aux_gen original form. Stored in the same repr as + /// `self.meta`, so demoted entries stay fully demoted. + pub fn set_original(&mut self, addr: Address, meta: ConstantMeta) { + let repr = match &self.meta { + MetaRepr::Structured(_) => MetaRepr::structured(meta), + MetaRepr::Bytes(_) => MetaRepr::demoted(&meta), + }; + self.original = Some((addr, repr)); + } + + pub fn clear_original(&mut self) { + self.original = None; + } + + /// Convert both metadata slots to the serialized-bytes repr. + pub fn demote(&mut self) { + if let MetaRepr::Structured(m) = &self.meta { + self.meta = MetaRepr::demoted(m); + } + if let Some((a, MetaRepr::Structured(m))) = &self.original { + self.original = Some((a.clone(), MetaRepr::demoted(m))); + } + } + + /// Whether the primary metadata slot holds structured metadata. + pub fn is_meta_structured(&self) -> bool { + matches!(self.meta, MetaRepr::Structured(_)) } } @@ -102,6 +192,25 @@ pub enum SpillMode { Mmap, } +/// `IX_COMPILE_META=demote` stores registered names' metadata as +/// serialized bytes instead of structured `ConstantMeta` (see +/// [`Named::demote`]). Default: structured — today's behavior. +#[cfg(not(target_arch = "riscv64"))] +pub static META_DEMOTE: std::sync::LazyLock = + std::sync::LazyLock::new(|| { + match std::env::var("IX_COMPILE_META").as_deref() { + Ok("demote") => true, + Ok("structured") | Err(_) => false, + Ok(other) => { + eprintln!( + "[ixon] IX_COMPILE_META={other:?} not recognized \ + (expected structured|demote); using structured" + ); + false + }, + } + }); + #[cfg(not(target_arch = "riscv64"))] pub static SPILL_MODE: std::sync::LazyLock = std::sync::LazyLock::new(|| { @@ -385,9 +494,16 @@ impl Env { self.consts.get(addr).map(|r| Arc::from(r.value().raw_bytes())) } - /// Register a named constant. Host-only — see `store_blob`. + /// Register a named constant. Under `IX_COMPILE_META=demote` the + /// entry's metadata is stored in its serialized-bytes form (see + /// [`Named::demote`]) — the structured DAG costs a large multiple of + /// its encoding and compilation never reads it back. + /// Host-only — see `store_blob`. #[cfg(not(target_arch = "riscv64"))] - pub fn register_name(&self, name: Name, named: Named) { + pub fn register_name(&self, name: Name, mut named: Named) { + if *META_DEMOTE { + named.demote(); + } self.named.insert(name, named); } @@ -452,7 +568,9 @@ impl Env { self .named .iter() - .filter(|e| !matches!(e.value().meta.info, ConstantMetaInfo::Muts { .. })) + .filter(|e| { + !matches!(e.value().meta().info, ConstantMetaInfo::Muts { .. }) + }) .map(|e| e.value().addr.clone()) .collect() } @@ -646,6 +764,39 @@ mod tests { } } + #[test] + fn demoted_named_roundtrips_and_serializes_identically() { + let addr = Address::hash(b"demoted-named-target"); + // Empty metadata: no name references, so `Env::put` needs no name + // index entries. Name-ref-rich raw/indexed equivalence is covered + // by `metadata::tests::test_constant_meta_indexed_roundtrip`. + let meta = ConstantMeta::default(); + let mut structured = Named::new(addr.clone(), meta.clone()); + structured.set_original(addr.clone(), meta.clone()); + let mut demoted = structured.clone(); + demoted.demote(); + + assert!(structured.is_meta_structured()); + assert!(!demoted.is_meta_structured()); + // Accessors agree between reprs. + assert_eq!(*structured.meta(), *demoted.meta()); + assert!(demoted.has_original()); + let (sa, sm) = structured.original().unwrap(); + let (da, dm) = demoted.original().unwrap(); + assert_eq!(sa, da); + assert_eq!(*sm, *dm); + + // Envs whose named entries differ only in repr serialize identically. + let mk_env = |named: &Named| { + let env = Env::new(); + env.register_name(n("target"), named.clone()); + let mut buf = Vec::new(); + env.put(&mut buf).unwrap(); + buf + }; + assert_eq!(mk_env(&structured), mk_env(&demoted)); + } + #[test] fn env_put_identical_across_spill_modes() { let build = |mode: SpillMode, tiny_spill: bool| { diff --git a/crates/ixon/src/metadata.rs b/crates/ixon/src/metadata.rs index e76992672..4ac1c972c 100644 --- a/crates/ixon/src/metadata.rs +++ b/crates/ixon/src/metadata.rs @@ -254,12 +254,12 @@ impl ConstantMeta { /// Delegate indexed serialization to the inner enum, then serialize /// extension tables. - pub fn put_indexed( + pub fn put_with( &self, - idx: &NameIndex, + idx: NamePut<'_>, buf: &mut Vec, ) -> Result<(), String> { - self.info.put_indexed(idx, buf)?; + self.info.put_with(idx, buf)?; // Extension tables (backward-compatible: 0-length for old constants) put_vec_len(self.meta_sharing.len(), buf); for expr in &self.meta_sharing { @@ -276,13 +276,23 @@ impl ConstantMeta { Ok(()) } + /// Self-contained encoding: name references as raw 32-byte addresses, + /// no index required. This is the demoted in-memory form + /// (`IX_COMPILE_META=demote`), NOT the `.ixe` named-section encoding — + /// `Env::put` re-encodes through the name index. + pub fn put_raw(&self, buf: &mut Vec) -> Result<(), String> { + self.put_with(NamePut::Raw, buf) + } + + /// Decode the [`Self::put_raw`] encoding. + pub fn get_raw(buf: &mut &[u8]) -> Result { + Self::get_with(buf, NameGet::Raw) + } + /// Delegate indexed deserialization, then deserialize extension tables. - pub fn get_indexed( - buf: &mut &[u8], - rev: &NameReverseIndex, - ) -> Result { - let info = ConstantMetaInfo::get_indexed(buf, rev)?; - // Extension tables: always present (put_indexed always writes them, + pub fn get_with(buf: &mut &[u8], rev: NameGet<'_>) -> Result { + let info = ConstantMetaInfo::get_with(buf, rev)?; + // Extension tables: always present (put_with always writes them, // even when empty — three zero-length vectors). let sharing_len = get_vec_len(buf)?; let mut meta_sharing = Vec::with_capacity(sharing_len); @@ -631,36 +641,65 @@ pub type NameIndex = HashMap; /// Reverse name index for deserialization: position -> Address pub type NameReverseIndex = Vec
; +/// How name references are written: compressed through the env-level +/// name index (the `.ixe` named-section form), or as raw 32-byte +/// addresses — a self-contained encoding that needs no index, used by +/// the demoted in-memory metadata form (`IX_COMPILE_META=demote`). +#[derive(Clone, Copy)] +pub enum NamePut<'a> { + Indexed(&'a NameIndex), + Raw, +} + +/// Decoding counterpart of [`NamePut`]. +#[derive(Clone, Copy)] +pub enum NameGet<'a> { + Indexed(&'a NameReverseIndex), + Raw, +} + pub(super) fn put_idx( addr: &Address, - idx: &NameIndex, + idx: NamePut<'_>, buf: &mut Vec, ) -> Result<(), String> { - let i = idx.get(addr).copied().ok_or_else(|| { - format!( - "put_idx: address {:?} not in name index (index has {} entries)", - addr, - idx.len() - ) - })?; - put_u64(i, buf); - Ok(()) + match idx { + NamePut::Indexed(map) => { + let i = map.get(addr).copied().ok_or_else(|| { + format!( + "put_idx: address {:?} not in name index (index has {} entries)", + addr, + map.len() + ) + })?; + put_u64(i, buf); + Ok(()) + }, + NamePut::Raw => { + put_address_raw(addr, buf); + Ok(()) + }, + } } pub(super) fn get_idx( buf: &mut &[u8], - rev: &NameReverseIndex, + rev: NameGet<'_>, ) -> Result { - let i = get_u64(buf)? as usize; - rev - .get(i) - .cloned() - .ok_or_else(|| format!("invalid name index {i}, max {}", rev.len())) + match rev { + NameGet::Indexed(v) => { + let i = get_u64(buf)? as usize; + v.get(i) + .cloned() + .ok_or_else(|| format!("invalid name index {i}, max {}", v.len())) + }, + NameGet::Raw => get_address_raw(buf), + } } fn put_idx_vec( addrs: &[Address], - idx: &NameIndex, + idx: NamePut<'_>, buf: &mut Vec, ) -> Result<(), String> { put_vec_len(addrs.len(), buf); @@ -672,7 +711,7 @@ fn put_idx_vec( fn get_idx_vec( buf: &mut &[u8], - rev: &NameReverseIndex, + rev: NameGet<'_>, ) -> Result, String> { let len = get_vec_len(buf)?; let mut v = Vec::with_capacity(len); @@ -687,9 +726,9 @@ fn get_idx_vec( // =========================================================================== impl DataValue { - pub fn put_indexed( + pub fn put_with( &self, - idx: &NameIndex, + idx: NamePut<'_>, buf: &mut Vec, ) -> Result<(), String> { match self { @@ -723,10 +762,7 @@ impl DataValue { Ok(()) } - pub fn get_indexed( - buf: &mut &[u8], - rev: &NameReverseIndex, - ) -> Result { + pub fn get_with(buf: &mut &[u8], rev: NameGet<'_>) -> Result { match get_u8(buf)? { 0 => Ok(Self::OfString(get_address_raw(buf)?)), 1 => Ok(Self::OfBool(get_bool(buf)?)), @@ -745,32 +781,32 @@ impl DataValue { fn put_kvmap_indexed( kvmap: &KVMap, - idx: &NameIndex, + idx: NamePut<'_>, buf: &mut Vec, ) -> Result<(), String> { put_vec_len(kvmap.len(), buf); for (k, v) in kvmap { put_idx(k, idx, buf)?; - v.put_indexed(idx, buf)?; + v.put_with(idx, buf)?; } Ok(()) } fn get_kvmap_indexed( buf: &mut &[u8], - rev: &NameReverseIndex, + rev: NameGet<'_>, ) -> Result { let len = get_vec_len(buf)?; let mut kvmap = Vec::with_capacity(len); for _ in 0..len { - kvmap.push((get_idx(buf, rev)?, DataValue::get_indexed(buf, rev)?)); + kvmap.push((get_idx(buf, rev)?, DataValue::get_with(buf, rev)?)); } Ok(kvmap) } fn put_mdata_stack_indexed( mdata: &[KVMap], - idx: &NameIndex, + idx: NamePut<'_>, buf: &mut Vec, ) -> Result<(), String> { put_vec_len(mdata.len(), buf); @@ -782,7 +818,7 @@ fn put_mdata_stack_indexed( fn get_mdata_stack_indexed( buf: &mut &[u8], - rev: &NameReverseIndex, + rev: NameGet<'_>, ) -> Result, String> { let len = get_vec_len(buf)?; let mut mdata = Vec::with_capacity(len); @@ -805,9 +841,9 @@ impl ExprMetaData { // Tag 8: Prj { struct_name_idx, child: u32 } // Tag 9: Mdata { kvmap_count, kvmaps..., child: u32 } - pub fn put_indexed( + pub fn put_with( &self, - idx: &NameIndex, + idx: NamePut<'_>, buf: &mut Vec, ) -> Result<(), String> { match self { @@ -883,10 +919,7 @@ impl ExprMetaData { Ok(()) } - pub fn get_indexed( - buf: &mut &[u8], - rev: &NameReverseIndex, - ) -> Result { + pub fn get_with(buf: &mut &[u8], rev: NameGet<'_>) -> Result { match get_u8(buf)? { 0 => Ok(Self::Leaf), 1 => { @@ -972,26 +1005,23 @@ impl ExprMetaData { // =========================================================================== impl ExprMeta { - pub fn put_indexed( + pub fn put_with( &self, - idx: &NameIndex, + idx: NamePut<'_>, buf: &mut Vec, ) -> Result<(), String> { put_vec_len(self.nodes.len(), buf); for node in &self.nodes { - node.put_indexed(idx, buf)?; + node.put_with(idx, buf)?; } Ok(()) } - pub fn get_indexed( - buf: &mut &[u8], - rev: &NameReverseIndex, - ) -> Result { + pub fn get_with(buf: &mut &[u8], rev: NameGet<'_>) -> Result { let len = get_vec_len(buf)?; let mut nodes = Vec::with_capacity(len); for _ in 0..len { - nodes.push(ExprMetaData::get_indexed(buf, rev)?); + nodes.push(ExprMetaData::get_with(buf, rev)?); } Ok(ExprMeta { nodes }) } @@ -1018,9 +1048,9 @@ fn get_u64_vec(buf: &mut &[u8]) -> Result, String> { // =========================================================================== impl ConstantMetaInfo { - pub fn put_indexed( + pub fn put_with( &self, - idx: &NameIndex, + idx: NamePut<'_>, buf: &mut Vec, ) -> Result<(), String> { match self { @@ -1041,7 +1071,7 @@ impl ConstantMetaInfo { hints.put_ser(buf); put_idx_vec(all, idx, buf)?; put_idx_vec(ctx, idx, buf)?; - arena.put_indexed(idx, buf)?; + arena.put_with(idx, buf)?; put_u64(*type_root, buf); put_u64(*value_root, buf); }, @@ -1049,14 +1079,14 @@ impl ConstantMetaInfo { put_u8(1, buf); put_idx(name, idx, buf)?; put_idx_vec(lvls, idx, buf)?; - arena.put_indexed(idx, buf)?; + arena.put_with(idx, buf)?; put_u64(*type_root, buf); }, Self::Quot { name, lvls, arena, type_root } => { put_u8(2, buf); put_idx(name, idx, buf)?; put_idx_vec(lvls, idx, buf)?; - arena.put_indexed(idx, buf)?; + arena.put_with(idx, buf)?; put_u64(*type_root, buf); }, Self::Indc { name, lvls, ctors, all, ctx, arena, type_root } => { @@ -1066,7 +1096,7 @@ impl ConstantMetaInfo { put_idx_vec(ctors, idx, buf)?; put_idx_vec(all, idx, buf)?; put_idx_vec(ctx, idx, buf)?; - arena.put_indexed(idx, buf)?; + arena.put_with(idx, buf)?; put_u64(*type_root, buf); }, Self::Ctor { name, lvls, induct, arena, type_root } => { @@ -1074,7 +1104,7 @@ impl ConstantMetaInfo { put_idx(name, idx, buf)?; put_idx_vec(lvls, idx, buf)?; put_idx(induct, idx, buf)?; - arena.put_indexed(idx, buf)?; + arena.put_with(idx, buf)?; put_u64(*type_root, buf); }, Self::Rec { @@ -1093,7 +1123,7 @@ impl ConstantMetaInfo { put_idx_vec(rules, idx, buf)?; put_idx_vec(all, idx, buf)?; put_idx_vec(ctx, idx, buf)?; - arena.put_indexed(idx, buf)?; + arena.put_with(idx, buf)?; put_u64(*type_root, buf); put_u64_vec(rule_roots, buf); }, @@ -1125,10 +1155,7 @@ impl ConstantMetaInfo { Ok(()) } - pub fn get_indexed( - buf: &mut &[u8], - rev: &NameReverseIndex, - ) -> Result { + pub fn get_with(buf: &mut &[u8], rev: NameGet<'_>) -> Result { match get_u8(buf)? { 255 => Ok(Self::Empty), 0 => Ok(Self::Def { @@ -1137,20 +1164,20 @@ impl ConstantMetaInfo { hints: ReducibilityHints::get_ser(buf)?, all: get_idx_vec(buf, rev)?, ctx: get_idx_vec(buf, rev)?, - arena: ExprMeta::get_indexed(buf, rev)?, + arena: ExprMeta::get_with(buf, rev)?, type_root: get_u64(buf)?, value_root: get_u64(buf)?, }), 1 => Ok(Self::Axio { name: get_idx(buf, rev)?, lvls: get_idx_vec(buf, rev)?, - arena: ExprMeta::get_indexed(buf, rev)?, + arena: ExprMeta::get_with(buf, rev)?, type_root: get_u64(buf)?, }), 2 => Ok(Self::Quot { name: get_idx(buf, rev)?, lvls: get_idx_vec(buf, rev)?, - arena: ExprMeta::get_indexed(buf, rev)?, + arena: ExprMeta::get_with(buf, rev)?, type_root: get_u64(buf)?, }), 3 => Ok(Self::Indc { @@ -1159,14 +1186,14 @@ impl ConstantMetaInfo { ctors: get_idx_vec(buf, rev)?, all: get_idx_vec(buf, rev)?, ctx: get_idx_vec(buf, rev)?, - arena: ExprMeta::get_indexed(buf, rev)?, + arena: ExprMeta::get_with(buf, rev)?, type_root: get_u64(buf)?, }), 4 => Ok(Self::Ctor { name: get_idx(buf, rev)?, lvls: get_idx_vec(buf, rev)?, induct: get_idx(buf, rev)?, - arena: ExprMeta::get_indexed(buf, rev)?, + arena: ExprMeta::get_with(buf, rev)?, type_root: get_u64(buf)?, }), 5 => Ok(Self::Rec { @@ -1175,7 +1202,7 @@ impl ConstantMetaInfo { rules: get_idx_vec(buf, rev)?, all: get_idx_vec(buf, rev)?, ctx: get_idx_vec(buf, rev)?, - arena: ExprMeta::get_indexed(buf, rev)?, + arena: ExprMeta::get_with(buf, rev)?, type_root: get_u64(buf)?, rule_roots: get_u64_vec(buf)?, }), @@ -1282,10 +1309,22 @@ mod tests { }); let mut buf = Vec::new(); - meta.put_indexed(&idx, &mut buf).unwrap(); + meta.put_with(NamePut::Indexed(&idx), &mut buf).unwrap(); let recovered = - ConstantMeta::get_indexed(&mut buf.as_slice(), &rev).unwrap(); + ConstantMeta::get_with(&mut buf.as_slice(), NameGet::Indexed(&rev)) + .unwrap(); assert_eq!(meta, recovered); + + // Raw (self-contained) encoding roundtrips the same value without + // any index, and re-encoding the recovered value through the index + // matches the indexed bytes exactly. + let mut raw = Vec::new(); + meta.put_raw(&mut raw).unwrap(); + let from_raw = ConstantMeta::get_raw(&mut raw.as_slice()).unwrap(); + assert_eq!(meta, from_raw); + let mut reindexed = Vec::new(); + from_raw.put_with(NamePut::Indexed(&idx), &mut reindexed).unwrap(); + assert_eq!(buf, reindexed); } #[test] @@ -1307,8 +1346,9 @@ mod tests { let _ = mdata; let mut buf = Vec::new(); - arena.put_indexed(&idx, &mut buf).unwrap(); - let recovered = ExprMeta::get_indexed(&mut buf.as_slice(), &rev).unwrap(); + arena.put_with(NamePut::Indexed(&idx), &mut buf).unwrap(); + let recovered = + ExprMeta::get_with(&mut buf.as_slice(), NameGet::Indexed(&rev)).unwrap(); assert_eq!(arena, recovered); } } diff --git a/crates/ixon/src/serialize.rs b/crates/ixon/src/serialize.rs index 2e2fd9b86..92b061a0d 100644 --- a/crates/ixon/src/serialize.rs +++ b/crates/ixon/src/serialize.rs @@ -1019,7 +1019,9 @@ fn get_name_component( // ============================================================================ use super::env::{AuxLayout, Named}; -use super::metadata::{ConstantMeta, NameIndex, NameReverseIndex}; +use super::metadata::{ + ConstantMeta, NameGet, NameIndex, NamePut, NameReverseIndex, +}; /// Serialize an `AuxLayout` side-table entry. /// @@ -1059,14 +1061,17 @@ pub fn put_named_indexed( buf: &mut Vec, ) -> Result<(), String> { put_address(&named.addr, buf); - named.meta.put_indexed(idx, buf)?; + // Demoted entries decode here and re-encode through the name index — + // the raw and indexed encodings share the wire format, differing only + // in how name references are written. + named.meta().put_with(NamePut::Indexed(idx), buf)?; // Serialize original as Option: 0 = None, 1 = Some(addr, meta) - match &named.original { + match named.original() { None => buf.push(0), Some((addr, meta)) => { buf.push(1); - put_address(addr, buf); - meta.put_indexed(idx, buf)?; + put_address(&addr, buf); + meta.put_with(NamePut::Indexed(idx), buf)?; }, } Ok(()) @@ -1078,17 +1083,18 @@ pub fn get_named_indexed( rev: &NameReverseIndex, ) -> Result { let addr = get_address(buf)?; - let meta = ConstantMeta::get_indexed(buf, rev)?; - let original = match get_u8(buf)? { - 0 => None, + let meta = ConstantMeta::get_with(buf, NameGet::Indexed(rev))?; + let mut named = Named::new(addr, meta); + match get_u8(buf)? { + 0 => {}, 1 => { let orig_addr = get_address(buf)?; - let orig_meta = ConstantMeta::get_indexed(buf, rev)?; - Some((orig_addr, orig_meta)) + let orig_meta = ConstantMeta::get_with(buf, NameGet::Indexed(rev))?; + named.set_original(orig_addr, orig_meta); }, x => return Err(format!("Named.original: invalid tag {x}")), - }; - Ok(Named { addr, meta, original }) + } + Ok(named) } // ============================================================================ @@ -1598,7 +1604,7 @@ impl Env { let name = names_lookup.get(&name_addr).cloned().ok_or_else(|| { format!("parse_lazy_index: missing name for addr {:?}", name_addr) })?; - let hint = match &named.meta.info { + let hint = match &named.meta().info { super::metadata::ConstantMetaInfo::Def { hints, .. } => Some(*hints), _ => None, }; @@ -1736,7 +1742,7 @@ impl Env { let _name_addr = get_address(buf)?; let named = get_named_indexed(buf, &name_reverse_index)?; if let super::metadata::ConstantMetaInfo::Def { hints, .. } = - &named.meta.info + &named.meta().info { env.anon_hints.insert(named.addr.clone(), *hints); } @@ -1939,7 +1945,7 @@ impl Env { let _name_addr = get_address(&mut buf)?; let named = get_named_indexed(&mut buf, &name_reverse_index)?; if let super::metadata::ConstantMetaInfo::Def { hints, .. } = - &named.meta.info + &named.meta().info { env.anon_hints.insert(named.addr.clone(), *hints); } @@ -2271,7 +2277,10 @@ mod tests { } else { None }; - let named = Named { addr: addr.clone(), meta, original }; + let mut named = Named::new(addr.clone(), meta); + if let Some((orig_addr, orig_meta)) = original { + named.set_original(orig_addr, orig_meta); + } env.named.insert(name, named); } } diff --git a/crates/ixvm-codegen/src/env_handle.rs b/crates/ixvm-codegen/src/env_handle.rs index d92c546cd..f2c0354e0 100644 --- a/crates/ixvm-codegen/src/env_handle.rs +++ b/crates/ixvm-codegen/src/env_handle.rs @@ -41,7 +41,7 @@ impl EnvHandle { .iter() .filter_map(|entry| { let named = entry.value(); - if let ConstantMetaInfo::Def { hints, .. } = &named.meta.info { + if let ConstantMetaInfo::Def { hints, .. } = &named.meta().info { Some((named.addr.clone(), *hints)) } else { None diff --git a/crates/kernel/src/ingress.rs b/crates/kernel/src/ingress.rs index a1bfcfdf5..d27273f7d 100644 --- a/crates/kernel/src/ingress.rs +++ b/crates/kernel/src/ingress.rs @@ -1895,8 +1895,9 @@ fn ingress_muts_inductive( ) })?; + let ctor_named_meta = ctor_named.meta(); let (ctor_lvl_params, ctor_arena, ctor_type_root) = - match &ctor_named.meta.info { + match &ctor_named_meta.info { ConstantMetaInfo::Ctor { lvls, arena, type_root, .. } => { (resolve_level_params(lvls, names), arena, *type_root) }, @@ -1998,7 +1999,7 @@ fn ingress_muts_block( format!("Muts member '{member_name}' not found in named entries") })?; let member_addr = &member_named.addr; - let member_meta = &member_named.meta; + let member_meta = &member_named.meta(); let self_id: KId = KId::new(member_addr.clone(), M::meta_field(member_name.clone())); @@ -2586,8 +2587,9 @@ pub fn ingress_compiled_names( let mut stats = ConvertStats::default(); // Check if this is a Muts entry (mutual block) — handle differently - if matches!(&named.meta.info, ConstantMetaInfo::Muts { .. }) { - if let ConstantMetaInfo::Muts { all, .. } = &named.meta.info + let named_meta = named.meta(); + if matches!(&named_meta.info, ConstantMetaInfo::Muts { .. }) { + if let ConstantMetaInfo::Muts { all, .. } = &named_meta.info && let Ok(entries) = ingress_muts_block( name, &named.addr, @@ -2630,7 +2632,7 @@ pub fn ingress_compiled_names( name, &named.addr, &constant, - &named.meta, + &named_meta, ixon_env, name_map, addr_map, @@ -3160,7 +3162,7 @@ pub fn build_ixon_ingress_lookups(ixon_env: &IxonEnv) -> IxonIngressLookups { .addr_to_name .entry(named.addr.clone()) .or_insert_with(|| name.clone()); - if let ConstantMetaInfo::Muts { all, .. } = &named.meta.info { + if let ConstantMetaInfo::Muts { all, .. } = &named.meta().info { lookups .muts_by_addr .entry(named.addr.clone()) @@ -3364,7 +3366,7 @@ fn ingress_addr_set_into_kenv( const_name, &addr, &constant, - &named.meta, + &named.meta(), ixon_env, &lookups.names, &lookups.name_to_addr, @@ -3801,7 +3803,7 @@ fn ixon_ingress_inner( for entry in ixon_env.named.iter() { let const_name = entry.key().clone(); let named = entry.value(); - match &named.meta.info { + match &named.meta().info { ConstantMetaInfo::Muts { .. } => { work_items.push(IngressWorkItem::Muts(const_name)); }, @@ -3899,7 +3901,7 @@ fn ixon_ingress_inner( &const_name, &named.addr, &constant, - &named.meta, + &named.meta(), ixon_env, &names, &name_to_addr, @@ -3935,7 +3937,8 @@ fn ixon_ingress_inner( timing.lookup_ns += elapsed_ns(lookup_start); } - let all = match &named.meta.info { + let named_meta = named.meta(); + let all = match &named_meta.info { ConstantMetaInfo::Muts { all, .. } => all, _ => { timing.convert_stats = convert_stats; From adb9a0205583d72b4d33f4a369347b493e51d111 Mon Sep 17 00:00:00 2001 From: samuelburnham <45365069+samuelburnham@users.noreply.github.com> Date: Thu, 9 Jul 2026 17:37:48 -0400 Subject: [PATCH 05/19] Compile spill lever 4: stream the .ixe from Rust (IX_COMPILE_STREAM=1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Env::put_file serializes the environment straight to a file, entry by entry through a reusable staging buffer — nothing proportional to env size is allocated (Env::put builds one env-sized Vec, and the FFI then copies it into a Lean ByteArray before Lean writes the file). Sections and encoding are identical to Env::put; equivalence is enforced by the put_file_matches_put quickcheck test and the InitStd cmp oracle. The write goes to .tmp followed by an atomic rename, so a crash cannot leave a truncated .ixe (an improvement over IO.FS.writeBinFile). rs_compile_env_to_file drives it behind the FFI and returns the byte count; CompileCmd.lean selects it under IX_COMPILE_STREAM=1 (default off — the buffered path is unchanged). Measured under the 50 GB cap with the full spill stack (IX_COMPILE_SPILL=mmap IX_COMPILE_META=demote IX_COMPILE_KENV_CLEAR_EVERY=64): - InitStd: peak RSS 5.6 -> 4.9 GiB, byte-identical output. - Mathlib: peak RSS 45.1 -> 41.8 GB, total wall 134 -> 94 s (29.6 s buffered serialize + 3.8 s ByteArray copy + 6.1 s Lean write collapse into a 22.2 s stream); RSS after serialization equals the compile plateau — the end-of-run spike is eliminated. --- Ix/Cli/CompileCmd.lean | 26 ++++++ Ix/CompileM.lean | 9 ++ crates/ffi/src/compile.rs | 73 +++++++++++++++ crates/ixon/src/serialize.rs | 170 +++++++++++++++++++++++++++++++++++ 4 files changed, 278 insertions(+) diff --git a/Ix/Cli/CompileCmd.lean b/Ix/Cli/CompileCmd.lean index edf323db4..3d86329fa 100644 --- a/Ix/Cli/CompileCmd.lean +++ b/Ix/Cli/CompileCmd.lean @@ -134,6 +134,32 @@ def runCompileCmd (p : Cli.Parsed) : IO UInt32 := do if benched then TracingTexray.startSampler TracingTexray.resetPeakTreeRss + + -- IX_COMPILE_STREAM=1: Rust writes the `.ixe` directly (streamed; + -- avoids ~2× env size of peak RAM for the buffered ByteArray path). + -- Both paths produce byte-identical files. + let stream := (← IO.getEnv "IX_COMPILE_STREAM") == some "1" + if stream then + let start ← IO.monoMsNow + let size ← Ix.CompileM.rsCompileEnvToFileFFI constList outPath + let elapsed := (← IO.monoMsNow) - start + println! "Compiled and wrote {fmtBytes size} env to {outPath} in {elapsed.formatMs}" + IO.println s!"##benchmark## {elapsed} {size} {totalConsts}" + if let some flag := p.flag? "json" then + let key := (p.flag? "json-name").map (·.as! String) + |>.getD ((FilePath.mk pathStr).fileStem.getD "env") + let secs := elapsed.toFloat / 1000.0 + let tput := if elapsed > 0 + then totalConsts.toFloat * 1000.0 / elapsed.toFloat else 0.0 + let peakRss ← TracingTexray.peakTreeRssBytes + Ix.Benchmark.Results.writeRow (flag.as! String) key "ok" + [ ("compile-time", Ix.Benchmark.Results.jsonRound 3 secs) + , ("file-size", Lean.toJson size) + , ("constants", Lean.toJson totalConsts) + , ("throughput", Ix.Benchmark.Results.jsonRound 2 tput) + , ("peak-rss", Lean.toJson peakRss) ] + return 0 + let start ← IO.monoMsNow let bytes ← Ix.CompileM.rsCompileEnvBytesFFI constList let elapsed := (← IO.monoMsNow) - start diff --git a/Ix/CompileM.lean b/Ix/CompileM.lean index b803d1394..8e1563f5a 100644 --- a/Ix/CompileM.lean +++ b/Ix/CompileM.lean @@ -1920,6 +1920,15 @@ def compileEnvParallel (env : Ix.Environment) (blocks : Ix.CondensedBlocks) @[extern "rs_compile_env"] opaque rsCompileEnvBytesFFI : @& List (Lean.Name × Lean.ConstantInfo) → IO ByteArray +/-- FFI: Compile a Lean environment and write the serialized Ixon.Env + straight to `outPath` from Rust (streamed; no env-sized ByteArray + crosses the FFI). Writes to `.tmp` then renames, so a crash + cannot leave a truncated file. Returns the byte count written. + Byte-identical to writing `rsCompileEnvBytesFFI`'s result. -/ +@[extern "rs_compile_env_to_file"] +opaque rsCompileEnvToFileFFI + : @& List (Lean.Name × Lean.ConstantInfo) → @& String → IO Nat + /-- FFI: 8-phase validation of the aux_gen compile pipeline (compile + decompile + roundtrip + alpha-equivalence + nested-detect checks). Returns total failure count across all phases. diff --git a/crates/ffi/src/compile.rs b/crates/ffi/src/compile.rs index a2ae2048d..de732d870 100644 --- a/crates/ffi/src/compile.rs +++ b/crates/ffi/src/compile.rs @@ -396,6 +396,79 @@ pub extern "C" fn rs_compile_env( } } +/// FFI: compile a Lean environment and stream the serialized Ixon.Env +/// straight to `out_path` (see `Env::put_file`) — no env-sized `Vec` or +/// Lean `ByteArray` is built. Writes `.tmp`, then renames, so +/// a crash cannot leave a truncated file. Returns bytes written (Nat). +/// Byte-identical output to `rs_compile_env` + `IO.FS.writeBinFile`. +#[unsafe(no_mangle)] +pub extern "C" fn rs_compile_env_to_file( + env_consts_ptr: LeanList>, + out_path: LeanString>, +) -> LeanIOResult { + let quiet = std::env::var("IX_QUIET").is_ok(); + let rss_gib = |label: &str| { + if !quiet && let Some((vm, anon, file)) = ix_compile::compile::self_rss_kb() + { + eprintln!( + "[rs_compile_env_to_file] rss {label}: {:.1} GiB (anon {:.1}, file {:.1})", + vm as f64 / (1024.0 * 1024.0), + anon as f64 / (1024.0 * 1024.0), + file as f64 / (1024.0 * 1024.0), + ); + } + }; + rss_gib("at entry"); + let rust_env = decode_env(env_consts_ptr); + let rust_env = Arc::new(rust_env); + rss_gib("after decode_env"); + + let compile_stt = + match compile_env_with_options(&rust_env, CompileOptions::default()) { + Ok(stt) => stt, + Err(e) => { + let msg = + format!("rs_compile_env_to_file: Rust compilation failed: {:?}", e); + return LeanIOResult::error_string(&msg); + }, + }; + + let path = std::path::PathBuf::from(out_path.as_str()); + let tmp = { + let mut s = path.clone().into_os_string(); + s.push(".tmp"); + std::path::PathBuf::from(s) + }; + let written = match compile_stt.env.put_file(&tmp) { + Ok(n) => n, + Err(e) => { + std::fs::remove_file(&tmp).ok(); + let msg = format!("rs_compile_env_to_file: serialization failed: {e}"); + return LeanIOResult::error_string(&msg); + }, + }; + if let Err(e) = std::fs::rename(&tmp, &path) { + std::fs::remove_file(&tmp).ok(); + let msg = format!( + "rs_compile_env_to_file: rename {} -> {}: {e}", + tmp.display(), + path.display() + ); + return LeanIOResult::error_string(&msg); + } + rss_gib("after put_file"); + + // Same destructor skip as `rs_compile_env` — one-shot CLI process. + if std::env::var("IX_SKIP_DROPS").ok().as_deref() != Some("0") { + std::mem::forget(compile_stt); + std::mem::forget(rust_env); + } else { + drop(compile_stt); + drop(rust_env); + } + LeanIOResult::ok(LeanOwned::from_nat_u64(written)) +} + /// Round-trip a RawEnv: decode from Lean, re-encode via builder. /// This performs a full decode/build cycle to verify FFI correctness. #[cfg(feature = "test-ffi")] diff --git a/crates/ixon/src/serialize.rs b/crates/ixon/src/serialize.rs index 92b061a0d..d4efaf72e 100644 --- a/crates/ixon/src/serialize.rs +++ b/crates/ixon/src/serialize.rs @@ -1373,6 +1373,159 @@ impl Env { Ok(()) } + /// Serialize the environment directly to a file, streaming entry by + /// entry through a reusable staging buffer — nothing proportional to + /// env size is allocated (contrast [`Env::put`], whose output `Vec` + /// is ~env size). Returns the byte count written. + /// + /// MUST remain byte-identical with [`Env::put`] — checked by the + /// `put_file_matches_put` test and the A/B `cmp` oracle. Any format + /// change lands in both or not at all. + #[cfg(not(target_arch = "riscv64"))] + pub fn put_file(&self, path: &std::path::Path) -> Result { + use rayon::slice::ParallelSliceMut; + use std::io::Write; + let quiet = std::env::var("IX_QUIET").is_ok(); + let overall_start = std::time::Instant::now(); + let file = std::fs::File::create(path) + .map_err(|e| format!("Env::put_file: create {}: {e}", path.display()))?; + let mut w = std::io::BufWriter::with_capacity(8 * 1024 * 1024, file); + let mut written: u64 = 0; + let mut buf: Vec = Vec::with_capacity(64 * 1024); + // Drain the staging buffer to the writer. Large constant bodies + // bypass staging and go straight to the writer. + macro_rules! emit { + () => { + if !buf.is_empty() { + w.write_all(&buf) + .map_err(|e| format!("Env::put_file: write: {e}"))?; + written += buf.len() as u64; + buf.clear(); + } + }; + } + + // Header: Tag4 + canonical merkle root over consts.keys(). + Tag4::new(Self::FLAG, 0).put(&mut buf); + let mut const_addrs: Vec
= + self.consts.iter().map(|e| e.key().clone()).collect(); + const_addrs.par_sort_unstable(); + let root = merkle_root_canonical(&const_addrs).unwrap_or_else(zero_address); + put_address(&root, &mut buf); + + // Section 1: Blobs + let mut blob_addrs: Vec
= + self.blobs.iter().map(|e| e.key().clone()).collect(); + blob_addrs.par_sort_unstable(); + put_u64(blob_addrs.len() as u64, &mut buf); + for addr in &blob_addrs { + if let Some(entry) = self.blobs.get(addr) { + let bytes = entry.value(); + put_address(addr, &mut buf); + put_u64(bytes.len() as u64, &mut buf); + buf.extend_from_slice(bytes); + emit!(); + } + } + + // Section 2: Consts — the dominant bytes; raw_bytes stream straight + // through (under IX_COMPILE_SPILL=mmap this is page cache → page + // cache). + let sec_start = std::time::Instant::now(); + put_u64(const_addrs.len() as u64, &mut buf); + for addr in &const_addrs { + if let Some(entry) = self.consts.get(addr) { + put_address(addr, &mut buf); + let bytes = entry.value().raw_bytes(); + Tag0::new(bytes.len() as u64).put(&mut buf); + emit!(); + w.write_all(bytes) + .map_err(|e| format!("Env::put_file: write const: {e}"))?; + written += bytes.len() as u64; + } + } + if !quiet { + eprintln!( + "[Env::put_file] consts streamed: {} entries in {:.1}s \ + ({written} bytes so far)", + const_addrs.len(), + sec_start.elapsed().as_secs_f64(), + ); + } + + // Section 3: Names (topologically sorted; builds the name index the + // Named section encodes through). + let sorted_names = topological_sort_names(&self.names); + let mut name_index: NameIndex = NameIndex::new(); + put_u64(sorted_names.len() as u64, &mut buf); + for (i, (addr, name)) in sorted_names.iter().enumerate() { + name_index.insert(addr.clone(), i as u64); + put_address(addr, &mut buf); + put_name_component(name, &mut buf); + emit!(); + } + + // Section 4: Named — the largest per-entry section; demoted entries + // decode and re-encode through the name index one at a time. + let sec_start = std::time::Instant::now(); + let mut named_keys: Vec = + self.named.iter().map(|e| e.key().clone()).collect(); + named_keys.par_sort_unstable_by(|a, b| { + a.get_hash().as_bytes().cmp(b.get_hash().as_bytes()) + }); + put_u64(named_keys.len() as u64, &mut buf); + for name in &named_keys { + if let Some(entry) = self.named.get(name) { + put_bytes(name.get_hash().as_bytes(), &mut buf); + put_named_indexed(entry.value(), &name_index, &mut buf)?; + emit!(); + } + } + if !quiet { + eprintln!( + "[Env::put_file] named streamed: {} entries in {:.1}s \ + ({written} bytes so far)", + named_keys.len(), + sec_start.elapsed().as_secs_f64(), + ); + } + + // Section 5: Comms + let mut comm_addrs: Vec
= + self.comms.iter().map(|e| e.key().clone()).collect(); + comm_addrs.par_sort_unstable(); + put_u64(comm_addrs.len() as u64, &mut buf); + for addr in &comm_addrs { + if let Some(entry) = self.comms.get(addr) { + put_address(addr, &mut buf); + entry.value().put(&mut buf); + emit!(); + } + } + + // Optional trailing anon_hints section — same condition as `put`. + if !self.anon_hints.is_empty() { + let mut hint_addrs: Vec
= + self.anon_hints.keys().cloned().collect(); + hint_addrs.sort_unstable(); + put_u64(hint_addrs.len() as u64, &mut buf); + for addr in &hint_addrs { + put_address(addr, &mut buf); + self.anon_hints[addr].put_ser(&mut buf); + } + } + + emit!(); + w.flush().map_err(|e| format!("Env::put_file: flush: {e}"))?; + if !quiet { + eprintln!( + "[Env::put_file] ALL DONE: {written} bytes in {:.1}s", + overall_start.elapsed().as_secs_f64(), + ); + } + Ok(written) + } + /// Deserialize an Env from bytes. pub fn get(buf: &mut &[u8]) -> Result { // Header @@ -2147,6 +2300,23 @@ mod tests { bools == unpack_bools(bools.len(), pack_bools(bools.clone())) } + #[test] + fn put_file_matches_put() { + let mut g = Gen::new(24); + for i in 0..8 { + let env = gen_env(&mut g); + let mut buf = Vec::new(); + env.put(&mut buf).unwrap(); + let path = std::env::temp_dir() + .join(format!("ixon_put_file_test_{}_{i}.ixe", std::process::id())); + let written = env.put_file(&path).unwrap(); + let from_file = std::fs::read(&path).unwrap(); + std::fs::remove_file(&path).ok(); + assert_eq!(written as usize, from_file.len()); + assert_eq!(buf, from_file, "put and put_file bytes diverge"); + } + } + #[test] fn test_pack_bools_specific() { assert_eq!(pack_bools([true, false, true]), 0b101); From f5d97aedd2c9247dbd75d67213d592ccc3e62003 Mon Sep 17 00:00:00 2001 From: samuelburnham <45365069+samuelburnham@users.noreply.github.com> Date: Thu, 9 Jul 2026 18:28:51 -0400 Subject: [PATCH 06/19] Compile spill lever 1: lazy LeanEnv decode (IX_COMPILE_LEAN_ENV=lazy) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skip the eager decode_env copy of the Lean environment — measured at 25.6 GiB of whole-run anonymous heap on Mathlib. Lazy mode decodes only names eagerly, keeps one LeanShared handle per constant, and decodes ConstantInfos on demand through a bounded cache. - ix_common: Env is now a struct (was a FxHashMap type alias) with an eager variant (default; the only variant on the guest) and a host-only lazy variant (name index + injected fetch + 64-shard bounded cache with oldest-biased eviction, IX_COMPILE_LEAN_ENV_CACHE entries, default 65536). Env::get returns an EnvEntry deref-guard: a plain borrow for eager (zero cost), an Arc for lazy. iter() bypasses the cache (whole-env passes are single-visit). - ffi: decode_env_lazy builds the index in parallel and injects the fetch closure; decode_env_auto dispatches on IX_COMPILE_LEAN_ENV. Thread safety is the eager path's own mechanism, unchanged: decode_env already MT-marks the reachable graph via LeanShared (lean_mark_mt -> atomic refcounting) and decodes in parallel; lean-ffi structural accessors are refcount-silent, and each element's owned handle keeps its objects alive for the Env lifetime. - Call sites: EnvEntry's Deref absorbed most accesses; the remainder migrated mechanically (as_deref() at pattern scrutinees, bound guards where borrows escape). Measured under the 50 GB cap with all levers (IX_COMPILE_LEAN_ENV=lazy IX_COMPILE_STREAM=1 IX_COMPILE_SPILL=mmap IX_COMPILE_META=demote IX_COMPILE_KENV_CLEAR_EVERY=64): - InitStd: peak RSS 4.9 -> 4.0 GiB, .ixe byte-identical, decode-phase RSS delta zero (was +2.7 GiB). - Mathlib: peak RSS 41.8 -> 19.4 GB (decode +0.1 GiB, was +25.6; compile-plateau anon 11.2 GiB), wall 94 -> 149 s at the default cache (42% hit rate) — the explicit RAM<->CPU knob; eager default keeps today's wall time. Progress ladder on the 56 GB box, Mathlib under a 50 GB cap: off OOM@48% -> spill 66% -> +kenv-clear 72.5% -> +meta demote completes @45.1 GB -> +stream 41.8 GB -> +lazy env 19.4 GB. --- crates/common/src/env.rs | 248 +++++++++++++++++- crates/compile/src/compile.rs | 38 ++- crates/compile/src/compile/aux_gen.rs | 8 +- crates/compile/src/compile/aux_gen/below.rs | 14 +- crates/compile/src/compile/aux_gen/brecon.rs | 10 +- .../compile/src/compile/aux_gen/cases_on.rs | 6 +- .../compile/src/compile/aux_gen/expr_utils.rs | 4 +- crates/compile/src/compile/aux_gen/nested.rs | 49 ++-- .../compile/src/compile/aux_gen/recursor.rs | 28 +- crates/compile/src/compile/env.rs | 9 + crates/compile/src/compile/mutual.rs | 2 +- crates/compile/src/compile/surgery.rs | 35 +-- crates/compile/src/decompile.rs | 70 +++-- crates/compile/src/graph.rs | 15 +- crates/compile/src/ground.rs | 17 +- crates/compile/src/kernel_egress.rs | 2 +- crates/ffi/src/compile.rs | 15 +- crates/ffi/src/ix/env.rs | 2 +- crates/ffi/src/lean_env.rs | 123 +++++++-- crates/kernel/src/ingress.rs | 14 +- 20 files changed, 536 insertions(+), 173 deletions(-) diff --git a/crates/common/src/env.rs b/crates/common/src/env.rs index b2d6675a6..acd10a7eb 100644 --- a/crates/common/src/env.rs +++ b/crates/common/src/env.rs @@ -1462,8 +1462,252 @@ impl ConstantInfo { } } -/// The Lean kernel environment: a map from names to their constant declarations. -pub type Env = FxHashMap; +/// Guard returned by [`Env::get`]: derefs to [`ConstantInfo`]. +/// +/// Eager environments hand out plain borrows (zero cost); lazy +/// environments hand out `Arc`s so the entry stays alive even if the +/// backing cache evicts it concurrently. +pub enum EnvEntry<'a> { + Borrowed(&'a ConstantInfo), + Owned(Arc), +} + +impl std::ops::Deref for EnvEntry<'_> { + type Target = ConstantInfo; + fn deref(&self) -> &ConstantInfo { + match self { + EnvEntry::Borrowed(c) => c, + EnvEntry::Owned(c) => c, + } + } +} + +impl EnvEntry<'_> { + /// Owned copy of the constant. Cheap only when a caller genuinely + /// needs ownership — prefer deref for reads. + pub fn cloned(&self) -> ConstantInfo { + (**self).clone() + } +} + +/// Host-only lazy backing for [`Env`]: a name index plus an injected +/// fetch that decodes one constant on demand (in practice from Lean +/// objects held as `LeanShared` handles — see docs/compile-spill.md, +/// lever 1), fronted by a sharded bounded cache. +#[cfg(not(target_arch = "riscv64"))] +pub struct LazyEnv { + /// All names, in the source env's iteration order. + names: Vec, + /// Membership index into `names`. + index: FxHashMap, + /// Decode one constant. Must be pure and thread-safe. + fetch: Box Option + Send + Sync>, + /// Sharded cache; per-shard capacity bounds resident decoded + /// constants. Eviction is `swap_remove_index(0)` — cheap + /// oldest-biased pseudo-FIFO, adequate for the scheduler's + /// topological locality. + shards: Vec>>>, + cap_per_shard: usize, + hits: std::sync::atomic::AtomicU64, + misses: std::sync::atomic::AtomicU64, +} + +#[cfg(not(target_arch = "riscv64"))] +impl LazyEnv { + const SHARDS: usize = 64; + + fn shard_for(&self, name: &Name) -> usize { + let bytes = name.get_hash().as_bytes(); + let word = u64::from_le_bytes(bytes[..8].try_into().unwrap()); + (word as usize) % Self::SHARDS + } + + fn get(&self, name: &Name) -> Option> { + use std::sync::atomic::Ordering; + if !self.index.contains_key(name) { + return None; + } + let shard_idx = self.shard_for(name); + if let Some(hit) = self.shards[shard_idx].lock().unwrap().get(name) { + self.hits.fetch_add(1, Ordering::Relaxed); + return Some(hit.clone()); + } + // Decode outside the shard lock: fetches can be slow and other + // names hashing to this shard shouldn't wait on them. + self.misses.fetch_add(1, Ordering::Relaxed); + let decoded = Arc::new((self.fetch)(name)?); + let mut shard = self.shards[shard_idx].lock().unwrap(); + if shard.len() >= self.cap_per_shard { + shard.swap_remove_index(0); + } + shard.insert(name.clone(), decoded.clone()); + Some(decoded) + } + + /// `(hits, misses)` counters for instrumentation. + fn stats(&self) -> (u64, u64) { + use std::sync::atomic::Ordering; + (self.hits.load(Ordering::Relaxed), self.misses.load(Ordering::Relaxed)) + } +} + +/// The Lean kernel environment: a map from names to their constant +/// declarations. Eager (an owned map — the default, and the only +/// variant on the guest) or, on the host, a lazy on-demand view (see +/// [`LazyEnv`]). +/// +/// Was `pub type Env = FxHashMap`; the struct keeps +/// the map API shape, with [`Env::get`] returning the [`EnvEntry`] +/// guard instead of a plain borrow. +#[derive(Default)] +pub struct Env { + eager: FxHashMap, + #[cfg(not(target_arch = "riscv64"))] + lazy: Option, +} + +impl std::fmt::Debug for Env { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Env").field("len", &self.len()).finish() + } +} + +impl Clone for Env { + /// Clones the eager map. Lazy environments are read-only views and + /// are never cloned (the compile pipeline shares them via `Arc`). + fn clone(&self) -> Self { + #[cfg(not(target_arch = "riscv64"))] + assert!( + self.lazy.is_none(), + "Env::clone on a lazy environment (share via Arc instead)" + ); + Env { + eager: self.eager.clone(), + #[cfg(not(target_arch = "riscv64"))] + lazy: None, + } + } +} + +impl Env { + /// Build a lazy env from a name list and a fetch function. + /// `cache_entries` bounds resident decoded constants (total across + /// shards; minimum one per shard). + #[cfg(not(target_arch = "riscv64"))] + pub fn new_lazy( + names: Vec, + fetch: Box Option + Send + Sync>, + cache_entries: usize, + ) -> Self { + let index: FxHashMap = + names.iter().enumerate().map(|(i, n)| (n.clone(), i)).collect(); + let cap_per_shard = (cache_entries / LazyEnv::SHARDS).max(1); + let shards = (0..LazyEnv::SHARDS) + .map(|_| { + std::sync::Mutex::new(indexmap::IndexMap::with_capacity( + cap_per_shard.min(4096), + )) + }) + .collect(); + Env { + eager: FxHashMap::default(), + lazy: Some(LazyEnv { + names, + index, + fetch, + shards, + cap_per_shard, + hits: std::sync::atomic::AtomicU64::new(0), + misses: std::sync::atomic::AtomicU64::new(0), + }), + } + } + + /// Cache hit/miss counters (lazy mode only). + #[cfg(not(target_arch = "riscv64"))] + pub fn lazy_cache_stats(&self) -> Option<(u64, u64)> { + self.lazy.as_ref().map(LazyEnv::stats) + } + + pub fn get(&self, name: &Name) -> Option> { + #[cfg(not(target_arch = "riscv64"))] + if let Some(lazy) = &self.lazy { + return lazy.get(name).map(EnvEntry::Owned); + } + self.eager.get(name).map(EnvEntry::Borrowed) + } + + pub fn contains_key(&self, name: &Name) -> bool { + #[cfg(not(target_arch = "riscv64"))] + if let Some(lazy) = &self.lazy { + return lazy.index.contains_key(name); + } + self.eager.contains_key(name) + } + + pub fn len(&self) -> usize { + #[cfg(not(target_arch = "riscv64"))] + if let Some(lazy) = &self.lazy { + return lazy.names.len(); + } + self.eager.len() + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Insert a constant. Eager only — lazy environments are read-only + /// views. + pub fn insert( + &mut self, + name: Name, + info: ConstantInfo, + ) -> Option { + #[cfg(not(target_arch = "riscv64"))] + assert!(self.lazy.is_none(), "Env::insert on a lazy environment"); + self.eager.insert(name, info) + } + + pub fn get_mut(&mut self, name: &Name) -> Option<&mut ConstantInfo> { + #[cfg(not(target_arch = "riscv64"))] + assert!(self.lazy.is_none(), "Env::get_mut on a lazy environment"); + self.eager.get_mut(name) + } + + /// All names, in map order (eager) or source order (lazy). No + /// decode. + pub fn keys(&self) -> Box + '_> { + #[cfg(not(target_arch = "riscv64"))] + if let Some(lazy) = &self.lazy { + return Box::new(lazy.names.iter()); + } + Box::new(self.eager.keys()) + } + + /// Iterate `(name, constant)`. Lazy mode decodes each entry fresh, + /// bypassing the cache — whole-env passes are single-visit, and + /// caching them would just churn the shards. + pub fn iter(&self) -> Box)> + '_> { + #[cfg(not(target_arch = "riscv64"))] + if let Some(lazy) = &self.lazy { + return Box::new(lazy.names.iter().filter_map(move |n| { + (lazy.fetch)(n).map(|ci| (n, EnvEntry::Owned(Arc::new(ci)))) + })); + } + Box::new(self.eager.iter().map(|(n, c)| (n, EnvEntry::Borrowed(c)))) + } +} + +impl FromIterator<(Name, ConstantInfo)> for Env { + fn from_iter>(it: T) -> Self { + Env { + eager: it.into_iter().collect(), + #[cfg(not(target_arch = "riscv64"))] + lazy: None, + } + } +} #[cfg(any(test, feature = "quickcheck"))] pub mod arbitrary { diff --git a/crates/compile/src/compile.rs b/crates/compile/src/compile.rs index 304fe43ea..d09b0a8df 100644 --- a/crates/compile/src/compile.rs +++ b/crates/compile/src/compile.rs @@ -2570,7 +2570,9 @@ pub fn mk_indc( ) -> Result { let mut ctors = Vec::with_capacity(ind.ctors.len()); for ctor_name in &ind.ctors { - if let Some(LeanConstantInfo::CtorInfo(c)) = env.as_ref().get(ctor_name) { + if let Some(LeanConstantInfo::CtorInfo(c)) = + env.as_ref().get(ctor_name).as_deref() + { ctors.push(c.clone()); } else { return Err(CompileError::MissingConstant { @@ -3250,7 +3252,7 @@ pub fn compile_const_no_aux( let mut lean_all: Vec = Vec::new(); for n in all { if let Some(ci) = lean_env.get(n) { - let block_all = match ci { + let block_all = match &*ci { LeanConstantInfo::InductInfo(v) => &v.all, LeanConstantInfo::RecInfo(v) => &v.all, LeanConstantInfo::DefnInfo(v) => &v.all, @@ -3277,7 +3279,7 @@ pub fn compile_const_no_aux( if !stt.aux_gen_extra_names.contains(n) { return None; } - match lean_env.get(n) { + match lean_env.get(n).as_deref() { Some(LeanConstantInfo::RecInfo(_)) => { // Distinguish .rec from .below.rec if matches!(n.as_data(), NameData::Str(p, _, _) if p.last_str() == Some("below")) @@ -3315,7 +3317,10 @@ pub fn compile_const_no_aux( // SCC including rec_N names. for n in all { if stt.aux_gen_extra_names.contains(n) - && matches!(lean_env.get(n), Some(LeanConstantInfo::RecInfo(_))) + && matches!( + lean_env.get(n).as_deref(), + Some(LeanConstantInfo::RecInfo(_)) + ) { filtered.insert(n.clone()); } @@ -3324,10 +3329,13 @@ pub fn compile_const_no_aux( Phase::BelowIndc => { // Use .below's own .all, keep only inductives + their ctors. for n in all { - if let Some(LeanConstantInfo::InductInfo(v)) = lean_env.get(n) { + if let Some(LeanConstantInfo::InductInfo(v)) = + lean_env.get(n).as_deref() + { for a in &v.all { if stt.aux_gen_extra_names.contains(a) - && let Some(LeanConstantInfo::InductInfo(bi)) = lean_env.get(a) + && let Some(LeanConstantInfo::InductInfo(bi)) = + lean_env.get(a).as_deref() { filtered.insert(a.clone()); for ctor in &bi.ctors { @@ -3344,7 +3352,10 @@ pub fn compile_const_no_aux( // (from DefnInfo.all = [EqC.below]), so use directly. for a in &lean_all { if stt.aux_gen_extra_names.contains(a) - && matches!(lean_env.get(a), Some(LeanConstantInfo::DefnInfo(_))) + && matches!( + lean_env.get(a).as_deref(), + Some(LeanConstantInfo::DefnInfo(_)) + ) { filtered.insert(a.clone()); } @@ -3357,7 +3368,7 @@ pub fn compile_const_no_aux( let below_rec = Name::str(ind_name.clone(), "rec".to_string()); if stt.aux_gen_extra_names.contains(&below_rec) && matches!( - lean_env.get(&below_rec), + lean_env.get(&below_rec).as_deref(), Some(LeanConstantInfo::RecInfo(_)) ) { @@ -3645,7 +3656,9 @@ fn compile_const_inner( LeanConstantInfo::CtorInfo(val) => { // Constructors are compiled as part of their inductive - if let Some(LeanConstantInfo::InductInfo(_)) = lean_env.get(&val.induct) { + if let Some(LeanConstantInfo::InductInfo(_)) = + lean_env.get(&val.induct).as_deref() + { let _ = compile_mutual(&val.induct, all, lean_env, cache, stt, kctx, aux)?; stt @@ -3687,10 +3700,9 @@ fn compile_mutual( // Collect all constants in the mutual block let mut cs = Vec::new(); for n in all { - // `lean_env` is an `FxHashMap` (see `Env` alias in env.rs); `.get()` - // returns a plain reference, so there's no read guard to release — - // just clone the value and move on. - let Some(const_info) = lean_env.get(n).cloned() else { + // Clone out of the `EnvEntry` guard so the block owns its constants + // and no env borrow is held across the compile below. + let Some(const_info) = lean_env.get(n).map(|e| e.cloned()) else { return Err(CompileError::MissingConstant { name: n.pretty(), caller: "compile_mutual".into(), diff --git a/crates/compile/src/compile/aux_gen.rs b/crates/compile/src/compile/aux_gen.rs index 9e90449e7..103bc200d 100644 --- a/crates/compile/src/compile/aux_gen.rs +++ b/crates/compile/src/compile/aux_gen.rs @@ -256,7 +256,7 @@ pub fn generate_aux_patches( expanded_probe.types.len() > expanded_probe.n_originals; let metadata_has_nested = original_all.iter().any(|name| { matches!( - lean_env.get(name), + lean_env.get(name).as_deref(), Some(ix_common::env::ConstantInfo::InductInfo(v)) if crate::compile::nat_conv::nat_to_usize(&v.num_nested) > 0 ) @@ -759,13 +759,13 @@ pub fn generate_aux_patches( Some(PatchedConstant::BelowIndc(_)) ) { - let rep_ctors = match lean_env.get(rep) { + let rep_ctors = match lean_env.get(rep).as_deref() { Some(ix_common::env::ConstantInfo::InductInfo(v)) => { v.ctors.clone() }, _ => vec![], }; - let alias_ctors = match lean_env.get(alias) { + let alias_ctors = match lean_env.get(alias).as_deref() { Some(ix_common::env::ConstantInfo::InductInfo(v)) => { v.ctors.clone() }, @@ -964,7 +964,7 @@ pub fn generate_aux_patches( // are outside the supported rewrite domain; skipping leaves the // original compile, which kernel-check reports per constant. let target_ok = matches!( - lean_env.get(&target_name), + lean_env.get(&target_name).as_deref(), Some(ix_common::env::ConstantInfo::RecInfo(r)) if crate::compile::nat_conv::nat_to_usize(&r.num_motives) == 1 ); diff --git a/crates/compile/src/compile/aux_gen/below.rs b/crates/compile/src/compile/aux_gen/below.rs index 407dc5e7d..2d68204db 100644 --- a/crates/compile/src/compile/aux_gen/below.rs +++ b/crates/compile/src/compile/aux_gen/below.rs @@ -127,7 +127,7 @@ pub fn generate_below_constants( let class_rep = &sorted_classes[ci][0]; let ind_ref = lean_env.get(class_rep); - let ind = match ind_ref { + let ind = match ind_ref.as_deref() { Some(ConstantInfo::InductInfo(v)) => v, _ => { return Err(CompileError::MissingConstant { @@ -181,7 +181,7 @@ pub fn generate_below_constants( if n_aux > 0 { let first_class_name = &sorted_classes[0][0]; let first_ind_ref = lean_env.get(first_class_name); - let first_ind = match first_ind_ref { + let first_ind = match first_ind_ref.as_deref() { Some(ConstantInfo::InductInfo(v)) => v, _ => { return Err(CompileError::MissingConstant { @@ -413,7 +413,7 @@ fn extract_major_head_ind( }; let (head, _) = decompose_apps(major_dom); match head.as_data() { - ExprData::Const(name, _, _) => match lean_env.get(name) { + ExprData::Const(name, _, _) => match lean_env.get(name).as_deref() { Some(ConstantInfo::InductInfo(v)) => Some(v.clone()), _ => None, }, @@ -639,7 +639,7 @@ fn build_below_indc( for class_idx in 0..n_classes { let class_rep = &sorted_classes[class_idx][0]; let class_ind_ref = lean_env.get(class_rep); - let class_ind = match class_ind_ref { + let class_ind = match class_ind_ref.as_deref() { Some(ConstantInfo::InductInfo(v)) => v, _ => { return Err(CompileError::MissingConstant { @@ -656,7 +656,7 @@ fn build_below_indc( if class_idx == ci { // This ctor belongs to our class — build a .below ctor for it let ctor_ref = lean_env.get(ctor_name); - let ctor = match ctor_ref { + let ctor = match ctor_ref.as_deref() { Some(ConstantInfo::CtorInfo(c)) => c, _ => { return Err(CompileError::MissingConstant { @@ -803,7 +803,7 @@ fn build_below_indc_ctor( let orig_below_ctor_name = below_name.append_components(&ctor_suffix); let orig_field_names: Vec = lean_env .get(&orig_below_ctor_name) - .and_then(|ci| match ci { + .and_then(|ci| match &*ci { ConstantInfo::CtorInfo(cv) => { let mut names = Vec::new(); let mut ty = cv.cnst.typ.clone(); @@ -871,7 +871,7 @@ fn build_below_indc_ctor( let all_ind_names: Vec<(Name, usize)> = (0..n_classes) .flat_map(|j| { sorted_classes[j].iter().filter_map(move |name| { - lean_env.get(name).map(|ci| match ci { + lean_env.get(name).map(|ci| match &*ci { ConstantInfo::InductInfo(v) => (v.cnst.name.clone(), j), _ => (name.clone(), j), }) diff --git a/crates/compile/src/compile/aux_gen/brecon.rs b/crates/compile/src/compile/aux_gen/brecon.rs index fdcd0582b..6f340cd13 100644 --- a/crates/compile/src/compile/aux_gen/brecon.rs +++ b/crates/compile/src/compile/aux_gen/brecon.rs @@ -79,7 +79,7 @@ pub fn generate_brecon_constants( let (_, rec_val) = &canonical_recs[ci]; let class_rep = &sorted_classes[ci][0]; let ind_ref = lean_env.get(class_rep); - let ind = match ind_ref { + let ind = match ind_ref.as_deref() { Some(ConstantInfo::InductInfo(v)) => v, _ => { return Err(CompileError::MissingConstant { @@ -147,7 +147,7 @@ pub fn generate_brecon_constants( if n_aux > 0 { // all[0] from the first class's inductive — Lean hangs _N names here. let first_class_name = &sorted_classes[0][0]; - let all0 = match lean_env.get(first_class_name) { + let all0 = match lean_env.get(first_class_name).as_deref() { Some(ConstantInfo::InductInfo(v)) => v.all[0].clone(), _ => first_class_name.clone(), }; @@ -991,7 +991,7 @@ fn build_type_brecon_fvar( // NestedParam.RoseA α: List.casesOn needs (α := RoseA α). let cases_on_spec: Vec = if ci >= n_classes { let (_, major_args) = decompose_apps(&major_decls[0].domain); - let ext_n_params = match lean_env.get(&target_ind_name) { + let ext_n_params = match lean_env.get(&target_ind_name).as_deref() { Some(ConstantInfo::InductInfo(v)) => try_nat_to_usize(&v.num_params)?, _ => 0, }; @@ -1500,7 +1500,7 @@ fn build_type_brecon_eq_fvar( let (head, _) = decompose_apps(&last_dom); match head.as_data() { ExprData::Const(name, _, _) | ExprData::Fvar(name, _) => { - match lean_env.get(name) { + match lean_env.get(name).as_deref() { Some(ConstantInfo::InductInfo(v)) => v.ctors.len(), _ => 0, } @@ -1509,7 +1509,7 @@ fn build_type_brecon_eq_fvar( } }) .collect(); - let target_ctors: Vec = match lean_env.get(target_ind_name) { + let target_ctors: Vec = match lean_env.get(target_ind_name).as_deref() { Some(ConstantInfo::InductInfo(v)) => v.ctors.clone(), _ => vec![], }; diff --git a/crates/compile/src/compile/aux_gen/cases_on.rs b/crates/compile/src/compile/aux_gen/cases_on.rs index b14a41638..bad60eee0 100644 --- a/crates/compile/src/compile/aux_gen/cases_on.rs +++ b/crates/compile/src/compile/aux_gen/cases_on.rs @@ -75,7 +75,7 @@ pub fn generate_cases_on( let target_idx = rec_val.all.iter().position(|n| *n == target_ind)?; // Determine elimination level - let ind_n_lparams = match lean_env.get(&target_ind) { + let ind_n_lparams = match lean_env.get(&target_ind).as_deref() { Some(ConstantInfo::InductInfo(v)) => v.cnst.level_params.len(), _ => return None, }; @@ -90,7 +90,7 @@ pub fn generate_cases_on( let ctor_counts: Vec = rec_val .all .iter() - .map(|ind_name| match lean_env.get(ind_name) { + .map(|ind_name| match lean_env.get(ind_name).as_deref() { Some(ConstantInfo::InductInfo(v)) => v.ctors.len(), _ => 0, }) @@ -355,7 +355,7 @@ fn get_minor_name( lean_env: &LeanEnv, ) -> Name { let ctor_idx = minor_idx - target_range.start; - if let Some(ConstantInfo::InductInfo(v)) = lean_env.get(target_ind) + if let Some(ConstantInfo::InductInfo(v)) = lean_env.get(target_ind).as_deref() && let Some(ctor_name) = v.ctors.get(ctor_idx) { // Strip prefix to get suffix (e.g., "A.mk" → "mk") diff --git a/crates/compile/src/compile/aux_gen/expr_utils.rs b/crates/compile/src/compile/aux_gen/expr_utils.rs index ec024ee52..5d7ca9f78 100644 --- a/crates/compile/src/compile/aux_gen/expr_utils.rs +++ b/crates/compile/src/compile/aux_gen/expr_utils.rs @@ -1969,7 +1969,7 @@ fn ensure_in_kenv_of_inner_env( } } - let Some(ci) = lean_env.get(name).cloned() else { return }; + let Some(ci) = lean_env.get(name).map(|e| e.cloned()) else { return }; // Helper: convert a LeanExpr to KExpr with the given level param names, // using the KEnv's persistent ingress cache. Callers are top-level, so // we start with an empty binder-name stack. @@ -1998,7 +1998,7 @@ fn ensure_in_kenv_of_inner_env( let ty_z = to_z(&ind.cnst.typ, lp, kenv); let mut ctor_zids = Vec::new(); for ctor_name in &ind.ctors { - if let Some(LCI::CtorInfo(ctor)) = lean_env.get(ctor_name) { + if let Some(LCI::CtorInfo(ctor)) = lean_env.get(ctor_name).as_deref() { let ctor_zid = KId::new( resolve_lean_name_addr(ctor_name, n2a, aux_n2a), ctor_name.clone(), diff --git a/crates/compile/src/compile/aux_gen/nested.rs b/crates/compile/src/compile/aux_gen/nested.rs index b7841a091..6629dc70a 100644 --- a/crates/compile/src/compile/aux_gen/nested.rs +++ b/crates/compile/src/compile/aux_gen/nested.rs @@ -247,7 +247,7 @@ impl<'a> ExpandCtx<'a> { // Verify head is an external inductive. let ext_ind_ref = self.lean_env.get(&head_name); - let ext_ind = match ext_ind_ref { + let ext_ind = match ext_ind_ref.as_deref() { Some(ConstantInfo::InductInfo(v)) => v, _ => return None, }; @@ -313,7 +313,7 @@ impl<'a> ExpandCtx<'a> { for j_name in &ext_all { let j_info_ref = self.lean_env.get(j_name); - let j_info = match j_info_ref { + let j_info = match j_info_ref.as_deref() { Some(ConstantInfo::InductInfo(v)) => v, _ => continue, }; @@ -356,7 +356,7 @@ impl<'a> ExpandCtx<'a> { let mut aux_ctors: Vec = Vec::new(); for j_ctor_name in &j_info.ctors { let j_ctor_ref = self.lean_env.get(j_ctor_name); - let j_ctor = match j_ctor_ref { + let j_ctor = match j_ctor_ref.as_deref() { Some(ConstantInfo::CtorInfo(c)) => c, _ => continue, }; @@ -436,7 +436,7 @@ pub fn expand_nested_block( } })?; let first_ind_ref = lean_env.get(first_name); - let first_ind = match first_ind_ref { + let first_ind = match first_ind_ref.as_deref() { Some(ConstantInfo::InductInfo(v)) => v, _ => { return Err(CompileError::MissingConstant { @@ -481,7 +481,7 @@ pub fn expand_nested_block( // Seed with original inductives. for name in ordered_originals { let ind_ref = lean_env.get(name); - let ind = match ind_ref { + let ind = match ind_ref.as_deref() { Some(ConstantInfo::InductInfo(v)) => v, _ => { return Err(CompileError::MissingConstant { @@ -493,7 +493,7 @@ pub fn expand_nested_block( let ctors: Vec = ind .ctors .iter() - .filter_map(|cn| match lean_env.get(cn) { + .filter_map(|cn| match lean_env.get(cn).as_deref() { Some(ConstantInfo::CtorInfo(c)) => Some(ExpandedCtor { name: c.cnst.name.clone(), typ: c.cnst.typ.clone(), @@ -1694,7 +1694,7 @@ pub fn build_compile_flat_block_with_overlay( let first_ind_ref = overlay .and_then(|o| o.get(first_name)) .or_else(|| lean_env.get(first_name)); - let first_ind = match first_ind_ref { + let first_ind = match first_ind_ref.as_deref() { Some(ConstantInfo::InductInfo(v)) => v, _ => { return Err(CompileError::MissingConstant { @@ -1729,7 +1729,7 @@ pub fn build_compile_flat_block_with_overlay( for name in ordered_originals { let ind_ref = overlay.and_then(|o| o.get(name)).or_else(|| lean_env.get(name)); - let ind = match ind_ref { + let ind = match ind_ref.as_deref() { Some(ConstantInfo::InductInfo(v)) => v, _ => { return Err(CompileError::MissingConstant { @@ -1764,7 +1764,7 @@ pub fn build_compile_flat_block_with_overlay( let member_ref = overlay .and_then(|o| o.get(&member.name)) .or_else(|| lean_env.get(&member.name)); - let (ctor_names, level_params) = match member_ref { + let (ctor_names, level_params) = match member_ref.as_deref() { Some(ConstantInfo::InductInfo(v)) => { (v.ctors.clone(), v.cnst.level_params.clone()) }, @@ -1775,7 +1775,7 @@ pub fn build_compile_flat_block_with_overlay( let ctor_ref = overlay .and_then(|o| o.get(ctor_name)) .or_else(|| lean_env.get(ctor_name)); - let (ctor_n_fields, ctor_typ) = match ctor_ref { + let (ctor_n_fields, ctor_typ) = match ctor_ref.as_deref() { Some(ConstantInfo::CtorInfo(c)) => { let fields = nat_to_usize(&c.num_fields); (fields, c.cnst.typ.clone()) @@ -1989,7 +1989,7 @@ fn try_detect_nested_fvar( let head_ref = overlay .and_then(|o| o.get(&head_name)) .or_else(|| lean_env.get(&head_name)); - let (ext_n_params, ext_n_indices) = match head_ref { + let (ext_n_params, ext_n_indices) = match head_ref.as_deref() { Some(ConstantInfo::InductInfo(v)) => { let p = nat_to_usize(&v.num_params); let i = nat_to_usize(&v.num_indices); @@ -2110,29 +2110,34 @@ pub fn compute_lean_ind_flags( /// Validate that every inductive group in `lean_env` carries exactly the flags /// `compute_lean_ind_flags` recomputes. pub fn validate_lean_ind_flags(lean_env: &LeanEnv) -> Result<(), CompileError> { - let mut groups: FxHashMap<&Name, &[Name]> = FxHashMap::default(); - for ci in lean_env.values() { - if let ConstantInfo::InductInfo(v) = ci + let mut groups: FxHashMap> = FxHashMap::default(); + for (_, ci) in lean_env.iter() { + if let ConstantInfo::InductInfo(v) = &*ci && let Some(first) = v.all.first() { - groups.entry(first).or_insert(v.all.as_slice()); + groups.entry(first.clone()).or_insert_with(|| v.all.clone()); } } //let groups: Vec<(&Name, &[Name])> = groups.into_iter().collect(); groups.par_iter().try_for_each(|(_, all)| { for member in all.iter() { - let Some(ConstantInfo::InductInfo(v)) = lean_env.get(member) else { + let entry = lean_env.get(member); + let Some(ConstantInfo::InductInfo(v)) = entry.as_deref() else { return Ok(()); }; for cn in &v.ctors { - if !matches!(lean_env.get(cn), Some(ConstantInfo::CtorInfo(_))) { + if !matches!( + lean_env.get(cn).as_deref(), + Some(ConstantInfo::CtorInfo(_)) + ) { return Ok(()); } } } let flags = compute_lean_ind_flags(all, lean_env)?; for member in all.iter() { - let Some(ConstantInfo::InductInfo(v)) = lean_env.get(member) else { + let entry = lean_env.get(member); + let Some(ConstantInfo::InductInfo(v)) = entry.as_deref() else { continue; // unreachable }; if v.is_rec != flags.is_rec @@ -2525,8 +2530,12 @@ mod tests { fn validate_lean_ind_flags_skips_unresolvable_group() { // Wrong flags, but the ctor entry is removed → group unresolvable → // skipped (grounding owns partial envs), not rejected. - let mut env = flags_env(false, false, 0); - env.remove(&mk_name_for("N.mk")); + let full = flags_env(false, false, 0); + let env: LeanEnv = full + .iter() + .filter(|(n, _)| **n != mk_name_for("N.mk")) + .map(|(n, ci)| (n.clone(), ci.cloned())) + .collect(); assert!(validate_lean_ind_flags(&env).is_ok()); } } diff --git a/crates/compile/src/compile/aux_gen/recursor.rs b/crates/compile/src/compile/aux_gen/recursor.rs index 1eb607c9d..111f25a4e 100644 --- a/crates/compile/src/compile/aux_gen/recursor.rs +++ b/crates/compile/src/compile/aux_gen/recursor.rs @@ -79,7 +79,7 @@ pub fn generate_recursors_from_expanded( // the correct `RecursorVal::is_unsafe` / `DefinitionSafety`. let block_is_unsafe = original_names .first() - .and_then(|n| match lean_env.get(n) { + .and_then(|n| match lean_env.get(n).as_deref() { Some(ConstantInfo::InductInfo(v)) => Some(v.is_unsafe), _ => None, }) @@ -93,7 +93,7 @@ pub fn generate_recursors_from_expanded( // when available. For auxiliary types (not in lean_env), fall back to // block-wide defaults. let (all_field, is_rec, is_reflexive, ind_is_unsafe) = - match lean_env.get(&member.name) { + match lean_env.get(&member.name).as_deref() { Some(ConstantInfo::InductInfo(orig)) => { (orig.all.clone(), orig.is_rec, orig.is_reflexive, orig.is_unsafe) }, @@ -121,7 +121,7 @@ pub fn generate_recursors_from_expanded( // Look up original ctor's safety when available; fall back to the // containing inductive's flag (ctor safety always matches its parent // inductive — the kernel rejects unsafe ctors on safe inductives). - let ctor_is_unsafe = match lean_env.get(&ctor.name) { + let ctor_is_unsafe = match lean_env.get(&ctor.name).as_deref() { Some(ConstantInfo::CtorInfo(orig)) => orig.is_unsafe, _ => ind_is_unsafe, }; @@ -478,8 +478,8 @@ pub fn generate_canonical_recursors_with_layout( // Lookup helper: check overlay first, then base env. let env_get = |name: &Name| -> Option { overlay - .and_then(|o| o.get(name).cloned()) - .or_else(|| lean_env.get(name).cloned()) + .and_then(|o| o.get(name).map(|e| e.cloned())) + .or_else(|| lean_env.get(name).map(|e| e.cloned())) }; let mut classes: Vec = sorted_classes @@ -954,8 +954,8 @@ fn build_rec_type( ) -> LeanExpr { let env_get = |name: &Name| -> Option { overlay - .and_then(|o| o.get(name).cloned()) - .or_else(|| lean_env.get(name).cloned()) + .and_then(|o| o.get(name).map(|e| e.cloned())) + .or_else(|| lean_env.get(name).map(|e| e.cloned())) }; let n_flat = flat.len(); @@ -1234,7 +1234,9 @@ fn build_motive_type_aux( ) -> LeanExpr { // Look up the external inductive (check overlay first for expanded aux types). let env_get_local = |n: &Name| -> Option { - overlay.and_then(|o| o.get(n).cloned()).or_else(|| lean_env.get(n).cloned()) + overlay + .and_then(|o| o.get(n).map(|e| e.cloned())) + .or_else(|| lean_env.get(n).map(|e| e.cloned())) }; let ind = match env_get_local(&member.name) { Some(ConstantInfo::InductInfo(v)) => v, @@ -2558,7 +2560,7 @@ fn ingress_target_type_deps( continue; } if let Some(ci) = lean_env.get(&name) { - ingress_aux_gen_dep(&name, ci, lean_env, stt, kctx, &mut queue); + ingress_aux_gen_dep(&name, &ci, lean_env, stt, kctx, &mut queue); } } } @@ -2588,7 +2590,7 @@ fn ingress_field_deps( } let Some(ci) = lean_env.get(&name) else { continue }; - ingress_aux_gen_dep(&name, ci, lean_env, stt, kctx, &mut queue); + ingress_aux_gen_dep(&name, &ci, lean_env, stt, kctx, &mut queue); } } @@ -2610,7 +2612,9 @@ fn ingress_aux_gen_dep( super::expr_utils::ensure_full_in_kenv_of(name, lean_env, stt, kctx); collect_const_refs(&v.cnst.typ, queue); for ctor_name in &v.ctors { - if let Some(ConstantInfo::CtorInfo(ctor)) = lean_env.get(ctor_name) { + if let Some(ConstantInfo::CtorInfo(ctor)) = + lean_env.get(ctor_name).as_deref() + { collect_const_refs(&ctor.cnst.typ, queue); } } @@ -3566,7 +3570,7 @@ mod tests { let mut cs = Vec::new(); for name in [&a, &b] { - match env.get(name) { + match env.get(name).as_deref() { Some(LeanCI::InductInfo(v)) => { cs.push(MutConst::Indc( mk_indc(v, &std::sync::Arc::new(env.clone())).unwrap(), diff --git a/crates/compile/src/compile/env.rs b/crates/compile/src/compile/env.rs index 455df3783..9a7c1f224 100644 --- a/crates/compile/src/compile/env.rs +++ b/crates/compile/src/compile/env.rs @@ -1065,6 +1065,15 @@ pub fn compile_env_with_options( file_bytes as f64 / (1024.0 * 1024.0), ); } + if let Some((hits, misses)) = lean_env.lazy_cache_stats() { + let total = hits + misses; + let hit_pct = + if total == 0 { 0.0 } else { 100.0 * hits as f64 / total as f64 }; + eprintln!( + "[compile_env] lazy lean env: {hits} hits · {misses} misses \ + ({hit_pct:.1}% hit rate)", + ); + } } Ok(stt) diff --git a/crates/compile/src/compile/mutual.rs b/crates/compile/src/compile/mutual.rs index 68bbca19b..db478df0d 100644 --- a/crates/compile/src/compile/mutual.rs +++ b/crates/compile/src/compile/mutual.rs @@ -617,7 +617,7 @@ pub fn generate_and_compile_aux_recursors( let mut source_ctor_counts: Vec = Vec::with_capacity(src_order.len()); for (head, _) in &src_order { - match lean_env.get(head) { + match lean_env.get(head).as_deref() { Some(LeanConstantInfo::InductInfo(v)) => { source_ctor_counts.push(v.ctors.len()); }, diff --git a/crates/compile/src/compile/surgery.rs b/crates/compile/src/compile/surgery.rs index de8d70caa..f54b1e784 100644 --- a/crates/compile/src/compile/surgery.rs +++ b/crates/compile/src/compile/surgery.rs @@ -294,7 +294,7 @@ pub fn compute_call_site_plans( // counts are not included here; they're handled separately below. let ctor_counts: Vec = original_all .iter() - .map(|n| match lean_env.get(n) { + .map(|n| match lean_env.get(n).as_deref() { Some(LeanConstantInfo::InductInfo(v)) => v.ctors.len(), _ => 0, }) @@ -314,7 +314,7 @@ pub fn compute_call_site_plans( .iter() .find_map(|n| { let rec_name = Name::str(n.clone(), "rec".to_string()); - match lean_env.get(&rec_name) { + match lean_env.get(&rec_name).as_deref() { Some(LeanConstantInfo::RecInfo(r)) => Some(( nat_to_usize(&r.num_params), nat_to_usize(&r.num_indices), @@ -428,7 +428,7 @@ pub fn compute_call_site_plans( .iter() .map(|class| { let rep = &class[0]; - match lean_env.get(rep) { + match lean_env.get(rep).as_deref() { Some(LeanConstantInfo::InductInfo(v)) => v.ctors.len(), _ => 0, } @@ -749,7 +749,7 @@ pub fn compute_call_site_plans( } let target_rec = Name::str(ext_head.clone(), "rec".to_string()); let target_ok = matches!( - lean_env.get(&target_rec), + lean_env.get(&target_rec).as_deref(), Some(LeanConstantInfo::RecInfo(r)) if nat_to_usize(&r.num_motives) == 1 ); @@ -758,7 +758,7 @@ pub fn compute_call_site_plans( } // Index count comes from the aux recursor itself (the external // inductive's indices), not the block-wide default. - let rec_n_indices = match lean_env.get(&rec_name) { + let rec_n_indices = match lean_env.get(&rec_name).as_deref() { Some(LeanConstantInfo::RecInfo(r)) => nat_to_usize(&r.num_indices), _ => n_indices, }; @@ -847,7 +847,7 @@ pub fn adapt_split_minor( } let rec_info = lean_env.get(rec_name)?; - let rec = match rec_info { + let rec = match &*rec_info { LeanConstantInfo::RecInfo(rec) => rec, _ => return None, }; @@ -948,14 +948,14 @@ fn source_ctor_for_minor( let mut offset = 0usize; for (source_pos, ind_name) in rec.all.iter().enumerate() { let ind_info = lean_env.get(ind_name)?; - let ind = match ind_info { + let ind = match &*ind_info { LeanConstantInfo::InductInfo(ind) => ind, _ => return None, }; let n_ctors = ind.ctors.len(); if src_minor_idx < offset + n_ctors { let ctor_name = &ind.ctors[src_minor_idx - offset]; - let ctor = match lean_env.get(ctor_name)? { + let ctor = match &*lean_env.get(ctor_name)? { LeanConstantInfo::CtorInfo(ctor) => ctor.clone(), _ => return None, }; @@ -967,14 +967,14 @@ fn source_ctor_for_minor( // order. The ctor list is the external inductive's own (the aux is the // external applied at spec args, so field counts match). for sig in aux_sigs { - let Some(LeanConstantInfo::InductInfo(ind)) = lean_env.get(&sig.ext_name) - else { + let ext_entry = lean_env.get(&sig.ext_name); + let Some(LeanConstantInfo::InductInfo(ind)) = ext_entry.as_deref() else { return None; }; let n_ctors = ind.ctors.len(); if src_minor_idx < offset + n_ctors { let ctor_name = &ind.ctors[src_minor_idx - offset]; - let ctor = match lean_env.get(ctor_name)? { + let ctor = match &*lean_env.get(ctor_name)? { LeanConstantInfo::CtorInfo(ctor) => ctor.clone(), _ => return None, }; @@ -1042,7 +1042,7 @@ fn aux_motive_sigs( let (head, t_args) = decompose_apps(&t); if let ExprData::Const(ext_name, _, _) = head.as_data() && let Some(LeanConstantInfo::InductInfo(ind)) = - lean_env.get(ext_name) + lean_env.get(ext_name).as_deref() { let ext_n_params = nat_to_usize(&ind.num_params); if t_args.len() >= ext_n_params { @@ -1081,7 +1081,8 @@ pub fn derive_head_rewrite_app( motives: &[LeanExpr], lean_env: &LeanEnv, ) -> Result<(Vec, Vec), String> { - let Some(LeanConstantInfo::RecInfo(rec)) = lean_env.get(rec_name) else { + let rec_entry = lean_env.get(rec_name); + let Some(LeanConstantInfo::RecInfo(rec)) = rec_entry.as_deref() else { return Err(format!("'{}' is not a recursor", rec_name.pretty())); }; let sigs = aux_motive_sigs(rec, rec_levels, params, motives, lean_env); @@ -1135,8 +1136,8 @@ pub fn derive_head_rewrite_app( _ => return Err("major type head is not a constant".into()), } }; - let Some(LeanConstantInfo::RecInfo(target)) = lean_env.get(&hr.target_rec) - else { + let target_entry = lean_env.get(&hr.target_rec); + let Some(LeanConstantInfo::RecInfo(target)) = target_entry.as_deref() else { return Err(format!( "target recursor '{}' missing from env", hr.target_rec.pretty() @@ -1261,7 +1262,7 @@ fn find_source_rec_target( return None; }; if let Some(source_pos) = original_all.iter().position(|n| n == target_name) { - let target_n_params = match lean_env.get(target_name)? { + let target_n_params = match &*lean_env.get(target_name)? { LeanConstantInfo::InductInfo(ind) => nat_to_usize(&ind.num_params), _ => return None, }; @@ -1436,7 +1437,7 @@ fn dump_plan_state( // Dump Lean's source recursor telescope, labelled per binder section. let first_rec = original_all.iter().find_map(|n| { let rec_name = Name::str(n.clone(), "rec".to_string()); - match lean_env.get(&rec_name) { + match lean_env.get(&rec_name).as_deref() { Some(LeanConstantInfo::RecInfo(r)) => { Some((rec_name, r.cnst.typ.clone())) }, diff --git a/crates/compile/src/decompile.rs b/crates/compile/src/decompile.rs index 2c30d6d4a..536b13816 100644 --- a/crates/compile/src/decompile.rs +++ b/crates/compile/src/decompile.rs @@ -2024,7 +2024,7 @@ fn build_block_env(all_names: &[Name], lean_env: &LeanEnv) -> LeanEnv { for ind_name in all_names { if let Some(ci) = lean_env.get(ind_name) { env.insert(ind_name.clone(), ci.clone()); - if let LeanConstantInfo::InductInfo(v) = ci { + if let LeanConstantInfo::InductInfo(v) = &*ci { for ctor_name in &v.ctors { if let Some(ctor_ci) = lean_env.get(ctor_name) { env.insert(ctor_name.clone(), ctor_ci.clone()); @@ -2200,7 +2200,7 @@ fn print_const_comparison( ) { let Some(orig_env) = orig_env else { return }; let Some(lean_ci_ref) = orig_env.get(name) else { return }; - let lean_ci = lean_ci_ref; + let lean_ci = &*lean_ci_ref; if std::mem::discriminant(decompiled) != std::mem::discriminant(lean_ci) { eprintln!( "[aux_gen diff] {}: kind decompiled={} original={}", @@ -2741,7 +2741,7 @@ fn roundtrip_block( if let Some(orig_env) = orig_env && let Some(lean_ci_ref) = orig_env.get(&nm) { - let lean_ci = lean_ci_ref; + let lean_ci = &*lean_ci_ref; eprintln!(" -- lean {} --", nm.pretty()); eprintln!(" type: {}", lean_ci.get_type().pretty()); if let Some(v) = get_value(lean_ci) { @@ -2762,7 +2762,7 @@ fn roundtrip_block( if let Some(orig_env) = orig_env && let Some(ci) = orig_env.get(&nm) { - match ci { + match &*ci { LeanConstantInfo::RecInfo(rv) => eprintln!( " -- lean {} RecInfo: k={} unsafe={} lvls={} params={} \ indices={} motives={} minors={} rule_fields={:?}", @@ -2807,7 +2807,7 @@ fn roundtrip_block( // divergence is contextual, not a regen defect. if singleton && let Some(oenv) = orig_env { let nm = consts[0].name(); - let omc: Option = match oenv.get(&nm) { + let omc: Option = match oenv.get(&nm).as_deref() { Some(LeanConstantInfo::RecInfo(rv)) => { Some(LeanMutConst::Recr(rv.clone())) }, @@ -2992,12 +2992,18 @@ fn roundtrip_block( let (mut iv, cvs) = decompile_inductive(ind, &orig_meta, &mut dec_cache, stt, dstt)?; // Recompute the lean flags, which are not stored by Ixon - let flags = compute_lean_ind_flags(&iv.all, generated_consts) - .map_err(|e| DecompileError::BadConstantFormat { - msg: format!( - "roundtrip ind-flags for '{}': {e}", - name.pretty() - ), + let flags_env: LeanEnv = generated_consts + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + let flags = + compute_lean_ind_flags(&iv.all, &flags_env).map_err(|e| { + DecompileError::BadConstantFormat { + msg: format!( + "roundtrip ind-flags for '{}': {e}", + name.pretty() + ), + } })?; iv.num_nested = Nat::from(flags.num_nested); iv.is_rec = flags.is_rec; @@ -3036,7 +3042,7 @@ fn roundtrip_block( && let Some(lean_ci_ref) = orig.get(&n) && ci.get_hash() != lean_ci_ref.get_hash() { - let lean_ci = lean_ci_ref; + let lean_ci = &*lean_ci_ref; if std::env::var_os("IX_ROUNDTRIP_DEBUG").is_some() { eprintln!( "[lean hash mismatch] {}: generated_ci_hash={:x?} lean_ci_hash={:x?}", @@ -3245,7 +3251,7 @@ fn print_rec_comparison( ) { let Some(orig_env) = orig_env else { return }; let orig_ci = orig_env.get(rec_name); - let Some(LeanConstantInfo::RecInfo(lean_rv)) = orig_ci else { + let Some(LeanConstantInfo::RecInfo(lean_rv)) = orig_ci.as_deref() else { return; }; @@ -3557,7 +3563,8 @@ fn block_mut_consts_from_env( ) -> Result, DecompileError> { let mut cs = Vec::with_capacity(all_names.len()); for name in all_names { - let Some(LeanConstantInfo::InductInfo(ind)) = env.get(name) else { + let ind_entry = env.get(name); + let Some(LeanConstantInfo::InductInfo(ind)) = ind_entry.as_deref() else { return Err(DecompileError::BadConstantFormat { msg: format!( "decompile aux plan: block member '{}' is not an inductive", @@ -3567,7 +3574,7 @@ fn block_mut_consts_from_env( }; let mut ctors = Vec::with_capacity(ind.ctors.len()); for ctor_name in &ind.ctors { - match env.get(ctor_name) { + match env.get(ctor_name).as_deref() { Some(LeanConstantInfo::CtorInfo(ctor)) => ctors.push(ctor.clone()), _ => { return Err(DecompileError::BadConstantFormat { @@ -3890,7 +3897,7 @@ fn decompile_block_aux_gen( use crate::graph::get_constant_info_references; for ind_name in all_names { if let Some(ci) = env.get(ind_name) { - for ref_name in get_constant_info_references(ci) { + for ref_name in get_constant_info_references(&ci) { expr_utils::ensure_in_kenv_of(&ref_name, env, stt, kctx); } } @@ -4015,16 +4022,18 @@ fn decompile_block_aux_gen( // Sync generated .rec constants into env and dstt.env so later phases can find them. for (n, rv) in &canonical_recs { - env - .entry(n.clone()) - .or_insert_with(|| LeanConstantInfo::RecInfo(rv.clone())); + if !env.contains_key(n) { + env.insert(n.clone(), LeanConstantInfo::RecInfo(rv.clone())); + } dstt .env .entry(n.clone()) .or_insert_with(|| LeanConstantInfo::RecInfo(rv.clone())); } for (n, ci) in &generated_consts { - env.entry(n.clone()).or_insert_with(|| ci.clone()); + if !env.contains_key(n) { + env.insert(n.clone(), ci.clone()); + } dstt.env.entry(n.clone()).or_insert_with(|| ci.clone()); } @@ -4048,7 +4057,7 @@ fn decompile_block_aux_gen( _ => continue, }; let rec_name = Name::str(ind_name.clone(), "rec".to_string()); - let rec_val = match env.get(&rec_name) { + let rec_val = match env.get(&rec_name).as_deref() { Some(LeanConstantInfo::RecInfo(rv)) => rv.clone(), _ => { // Try dstt.env (may have been inserted above) @@ -4145,7 +4154,7 @@ fn decompile_block_aux_gen( _ => continue, }; let rec_name = Name::str(ind_name, "rec".to_string()); - let rec_val = match env.get(&rec_name) { + let rec_val = match env.get(&rec_name).as_deref() { Some(LeanConstantInfo::RecInfo(rv)) => rv.clone(), _ => match dstt.env.get(&rec_name).as_deref() { Some(LeanConstantInfo::RecInfo(rv)) => rv.clone(), @@ -4281,7 +4290,9 @@ fn decompile_block_aux_gen( // Sync generated constants into env and dstt.env for subsequent phases. for (n, ci) in &generated_consts { - env.entry(n.clone()).or_insert_with(|| ci.clone()); + if !env.contains_key(n) { + env.insert(n.clone(), ci.clone()); + } dstt.env.entry(n.clone()).or_insert_with(|| ci.clone()); } @@ -4373,7 +4384,7 @@ fn decompile_block_aux_gen( if std::env::var_os("IX_ROUNDTRIP_DEBUG").is_some() && let Some(ref lean_env) = stt.lean_env { - let lean_all = match lean_env.get(&d.name) { + let lean_all = match lean_env.get(&d.name).as_deref() { Some(LeanConstantInfo::DefnInfo(v)) => Some(v.all.clone()), Some(LeanConstantInfo::ThmInfo(v)) => Some(v.all.clone()), Some(LeanConstantInfo::OpaqueInfo(v)) => Some(v.all.clone()), @@ -4530,7 +4541,9 @@ fn decompile_block_aux_gen( // Sync generated constants (below, below.rec) into env and dstt.env for brecOn. for (n, ci) in &generated_consts { - env.entry(n.clone()).or_insert_with(|| ci.clone()); + if !env.contains_key(n) { + env.insert(n.clone(), ci.clone()); + } dstt.env.entry(n.clone()).or_insert_with(|| ci.clone()); } @@ -4643,7 +4656,8 @@ fn decompile_block_aux_gen( if let Some(orig) = orig_env { for (name, generated_ci) in &generated_consts { if let Some(orig_ci) = orig.get(name) - && let Err(e) = crate::congruence::const_alpha_eq(generated_ci, orig_ci) + && let Err(e) = + crate::congruence::const_alpha_eq(generated_ci, &orig_ci) { aux_gen_errors.push(( name.clone(), @@ -4875,7 +4889,7 @@ pub fn decompile_env( } expr_utils::ensure_in_kenv_of(&name, &work_env, stt, &mut kctx); if let Some(ci) = work_env.get(&name) { - for ref_name in get_constant_info_references(ci) { + for ref_name in get_constant_info_references(&ci) { if !ingressed.contains(&ref_name) { stack.push(ref_name); } @@ -4998,7 +5012,7 @@ pub fn check_decompile( if is_aux_gen_suffix(name) { return Ok::<(), DecompileError>(()); } - match original.get(name) { + match original.get(name).as_deref() { Some(orig_info) if orig_info.get_hash() == info.get_hash() => { matches.fetch_add(1, Ordering::Relaxed); Ok::<(), DecompileError>(()) diff --git a/crates/compile/src/graph.rs b/crates/compile/src/graph.rs index e10bd777a..51f48cf90 100644 --- a/crates/compile/src/graph.rs +++ b/crates/compile/src/graph.rs @@ -5,7 +5,7 @@ //! compute SCCs (strongly connected components) for mutual block detection. //! Construction is parallelized via rayon. -use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; +use rayon::iter::{IntoParallelIterator, ParallelIterator}; use rustc_hash::{FxHashMap, FxHashSet}; use std::collections::hash_map::Entry; @@ -78,14 +78,15 @@ pub fn build_ref_graph(env: &Env) -> RefGraph { bigger }; - let (out_refs, in_refs) = env - .par_iter() - .map(|entry| { - let (name, constant) = entry; - let deps = get_constant_info_references(constant); + let names: Vec<&Name> = env.keys().collect(); + let (out_refs, in_refs) = names + .into_par_iter() + .filter_map(|name| { + let constant = env.get(name)?; + let deps = get_constant_info_references(&constant); let in_refs = mk_in_refs(name, &deps); let out_refs = RefMap::from_iter([(name.clone(), deps)]); - (out_refs, in_refs) + Some((out_refs, in_refs)) }) .reduce( || (RefMap::default(), RefMap::default()), diff --git a/crates/compile/src/ground.rs b/crates/compile/src/ground.rs index c854a3117..d53a79646 100644 --- a/crates/compile/src/ground.rs +++ b/crates/compile/src/ground.rs @@ -5,7 +5,7 @@ //! propagates through the reference graph: if A references ungrounded B, then A //! is also ungrounded. -use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; +use rayon::iter::{IntoParallelIterator, ParallelIterator}; use rustc_hash::{FxHashMap, FxHashSet}; use std::collections::hash_map::Entry; @@ -47,13 +47,14 @@ pub fn ground_consts( in_refs: &RefMap, ) -> FxHashMap { // Collect immediate ungrounded constants. - let mut ungrounded: FxHashMap<_, _> = env - .par_iter() - .filter_map(|entry| { - let (name, constant) = entry; - let univs = const_univs(constant); + let names: Vec<&Name> = env.keys().collect(); + let mut ungrounded: FxHashMap<_, _> = names + .into_par_iter() + .filter_map(|name| { + let constant = env.get(name)?; + let univs = const_univs(&constant); let mut stt = GroundState::default(); - if let Err(err) = ground_const(constant, env, univs, 0, &mut stt) { + if let Err(err) = ground_const(&constant, env, univs, 0, &mut stt) { Some((name.clone(), err)) } else { None @@ -125,7 +126,7 @@ fn ground_const( }, ConstantInfo::InductInfo(val) => { for ctor in &val.ctors { - let ci = env.get(ctor).cloned(); + let ci = env.get(ctor).map(|e| e.cloned()); match ci.as_ref() { Some(ConstantInfo::CtorInfo(_)) => (), _ => { diff --git a/crates/compile/src/kernel_egress.rs b/crates/compile/src/kernel_egress.rs index 6af61aaa6..fba24c421 100644 --- a/crates/compile/src/kernel_egress.rs +++ b/crates/compile/src/kernel_egress.rs @@ -1692,7 +1692,7 @@ mod tests { assert_eq!(le.len(), 3); for name in ["A", "B", "C"] { let ci = le.get(&mk_name(name)).expect("missing name"); - assert!(matches!(ci, LeanCI::AxiomInfo(..))); + assert!(matches!(&*ci, LeanCI::AxiomInfo(..))); } } } diff --git a/crates/ffi/src/compile.rs b/crates/ffi/src/compile.rs index de732d870..1646499d4 100644 --- a/crates/ffi/src/compile.rs +++ b/crates/ffi/src/compile.rs @@ -38,7 +38,6 @@ use lean_ffi::object::{ use crate::builder::LeanBuildCache; use crate::lean::LeanIxAddress; -use crate::lean_env::decode_env; use crate::lean_ixon::env::decoded_to_ixon_env; #[cfg(feature = "test-ffi")] @@ -195,7 +194,7 @@ pub extern "C" fn rs_compile_env_full( ) -> LeanIOResult { { // Phase 1: Decode Lean environment - let rust_env = decode_env(env_consts_ptr); + let rust_env = crate::lean_env::decode_env_auto(env_consts_ptr); let env_len = rust_env.len(); let rust_env = Arc::new(rust_env); @@ -306,7 +305,7 @@ pub extern "C" fn rs_compile_env( } }; rss_gib("at entry"); - let rust_env = decode_env(env_consts_ptr); + let rust_env = crate::lean_env::decode_env_auto(env_consts_ptr); let rust_env = Arc::new(rust_env); rss_gib("after decode_env"); @@ -419,7 +418,7 @@ pub extern "C" fn rs_compile_env_to_file( } }; rss_gib("at entry"); - let rust_env = decode_env(env_consts_ptr); + let rust_env = crate::lean_env::decode_env_auto(env_consts_ptr); let rust_env = Arc::new(rust_env); rss_gib("after decode_env"); @@ -486,7 +485,7 @@ pub extern "C" fn rs_compile_phases( env_consts_ptr: LeanList>, ) -> LeanIOResult { { - let rust_env = decode_env(env_consts_ptr); + let rust_env = crate::lean_env::decode_env_auto(env_consts_ptr); let env_len = rust_env.len(); let rust_env = Arc::new(rust_env); @@ -590,7 +589,7 @@ pub extern "C" fn rs_compile_env_to_ixon( env_consts_ptr: LeanList>, ) -> LeanIOResult { { - let rust_env = decode_env(env_consts_ptr); + let rust_env = crate::lean_env::decode_env_auto(env_consts_ptr); let rust_env = Arc::new(rust_env); let compile_stt = @@ -680,7 +679,7 @@ pub extern "C" fn rs_canonicalize_env_to_ix( env_consts_ptr: LeanList>, ) -> LeanIOResult { { - let rust_env = decode_env(env_consts_ptr); + let rust_env = crate::lean_env::decode_env_auto(env_consts_ptr); let mut cache = LeanBuildCache::with_capacity(rust_env.len()); let raw_env = LeanIxRawEnvironment::build(&mut cache, &rust_env); LeanIOResult::ok(raw_env) @@ -707,7 +706,7 @@ pub extern "C" fn rs_canonicalize_env_to_ix( pub extern "C" fn rs_leon_hashes( env_consts_ptr: LeanList>, ) -> LeanIOResult { - let rust_env = decode_env(env_consts_ptr); + let rust_env = crate::lean_env::decode_env_auto(env_consts_ptr); let mut cache = LeanBuildCache::with_capacity(rust_env.len()); let arr = LeanArray::alloc(rust_env.len()); diff --git a/crates/ffi/src/ix/env.rs b/crates/ffi/src/ix/env.rs index 3800bc614..ccaef3dc6 100644 --- a/crates/ffi/src/ix/env.rs +++ b/crates/ffi/src/ix/env.rs @@ -147,7 +147,7 @@ impl LeanIxRawEnvironment { for (i, entry) in consts.iter().enumerate() { let (name, info) = entry; let key_obj = LeanIxName::build(cache, name); - let val_obj = LeanIxConstantInfo::build(cache, info); + let val_obj = LeanIxConstantInfo::build(cache, &info); // Build pair (Name × ConstantInfo) let pair = LeanProd::new(key_obj, val_obj); consts_arr.set(i, pair); diff --git a/crates/ffi/src/lean_env.rs b/crates/ffi/src/lean_env.rs index b30c7a747..76e04c847 100644 --- a/crates/ffi/src/lean_env.rs +++ b/crates/ffi/src/lean_env.rs @@ -81,14 +81,14 @@ fn build_aux_perm_ctx( use ix_compile::congruence::perm::{PermCtx, RecHeadInfo, RecHeadKind}; let first = all.first()?; - let n_params = match env.get(first) { + let n_params = match env.get(first).as_deref() { Some(LeanCI::InductInfo(v)) => v.num_params.to_u64().unwrap_or(0) as usize, _ => return None, }; let n_primary = all.len(); let primary_ctor_counts: Vec = all .iter() - .map(|n| match env.get(n) { + .map(|n| match env.get(n).as_deref() { Some(LeanCI::InductInfo(v)) => v.ctors.len(), _ => 0, }) @@ -99,7 +99,7 @@ fn build_aux_perm_ctx( }; let source_aux_ctor_counts: Vec = source_aux_order .iter() - .map(|(head, _)| match env.get(head) { + .map(|(head, _)| match env.get(head).as_deref() { Some(LeanCI::InductInfo(v)) => v.ctors.len(), _ => 0, }) @@ -122,7 +122,7 @@ fn build_aux_perm_ctx( source_aux_ctor_counts: source_aux_ctor_counts.clone(), aux_perm: perm.to_vec(), }; - let n_indices_for = |rec_name: &Name| match env.get(rec_name) { + let n_indices_for = |rec_name: &Name| match env.get(rec_name).as_deref() { Some(LeanCI::RecInfo(r)) => r.num_indices.to_u64().unwrap_or(0) as usize, _ => 0, }; @@ -175,7 +175,7 @@ fn build_aux_perm_ctx( for suffix in ["rec", "casesOn", "recOn", "below", "brecOn"] { add_addr(&Name::str(member.clone(), suffix.to_string())); } - if let Some(LeanCI::InductInfo(v)) = env.get(member) { + if let Some(LeanCI::InductInfo(v)) = env.get(member).as_deref() { for ctor in &v.ctors { add_addr(ctor); } @@ -308,7 +308,7 @@ fn build_collapse_const_map( // Constructors: positional mapping. Both members are alpha-collapsed, // so they have the same number of constructors in the same order. if let (Some(LeanCI::InductInfo(m_ind)), Some(LeanCI::InductInfo(r_ind))) = - (env.get(member), env.get(rep)) + (env.get(member).as_deref(), env.get(rep).as_deref()) && m_ind.ctors.len() == r_ind.ctors.len() { for (m_ctor, r_ctor) in m_ind.ctors.iter().zip(r_ind.ctors.iter()) { @@ -543,7 +543,7 @@ fn build_aux_compare_contexts( let mut by_name = FxHashMap::default(); let mut seen_blocks: FxHashSet> = FxHashSet::default(); for (name, ci) in env.iter() { - let all = match ci { + let all = match &*ci { LeanCI::InductInfo(v) => &v.all, _ => continue, }; @@ -1124,6 +1124,71 @@ fn decode_name_constant_info( (name, constant_info) } +// Decode a Lean environment in parallel with hybrid caching. +/// Decode dispatch for the compile FFI entries: eager (default) or, +/// under `IX_COMPILE_LEAN_ENV=lazy`, an on-demand view that avoids +/// materializing the full Rust copy of the environment +/// (`IX_COMPILE_LEAN_ENV_CACHE` bounds resident decoded constants; +/// see docs/compile-spill.md, lever 1). +pub fn decode_env_auto(list: LeanList>) -> Env { + match std::env::var("IX_COMPILE_LEAN_ENV").as_deref() { + Ok("lazy") => { + let cache_entries = std::env::var("IX_COMPILE_LEAN_ENV_CACHE") + .ok() + .and_then(|s| s.parse::().ok()) + .filter(|&n| n > 0) + .unwrap_or(65536); + decode_env_lazy(list, cache_entries) + }, + Ok("eager") | Err(_) => decode_env(list), + Ok(other) => { + eprintln!( + "[ffi] IX_COMPILE_LEAN_ENV={other:?} not recognized \ + (expected eager|lazy); using eager" + ); + decode_env(list) + }, + } +} + +/// Lazy variant of [`decode_env`]: decode only the *names* eagerly, +/// keep a per-constant `LeanShared` handle, and decode `ConstantInfo`s +/// on demand through `Env`'s bounded cache. +/// +/// Thread-safety is identical to the eager path, which already decodes +/// in parallel: `lean_mark_mt` (via `LeanShared::new`) transitions the +/// reachable graph to atomic refcounting, structural reads through +/// `LeanBorrowed` accessors are refcount-silent, and each element's +/// owned handle keeps its Lean objects alive for the `Env`'s lifetime +/// regardless of what the Lean side does after the FFI call. +pub fn decode_env_lazy( + list: LeanList>, + cache_entries: usize, +) -> Env { + let shared_list = LeanShared::new(list.inner().to_owned_ref()); + let objs = collect_list_shared(shared_list.borrow().as_list()); + let global = Arc::new(GlobalCache::with_capacity(objs.len() * 3)); + + // Name index (parallel; bodies untouched). Each element is a + // `Prod (Name × ConstantInfo)` ctor — field 0 is the name. + let named: Vec<(Name, LeanShared)> = objs + .into_par_iter() + .map(|o| { + let name = decode_name(o.borrow().as_ctor().get(0), &global); + (name, o) + }) + .collect(); + let names: Vec = named.iter().map(|(n, _)| n.clone()).collect(); + let handles: FxHashMap = named.into_iter().collect(); + + let fetch = move |name: &Name| { + let obj = handles.get(name)?; + let mut cache = Cache::new(&global); + Some(decode_constant_info(obj.borrow().as_ctor().get(1), &mut cache)) + }; + Env::new_lazy(names, Box::new(fetch), cache_entries) +} + // Decode a Lean environment in parallel with hybrid caching. pub fn decode_env(list: LeanList>) -> Env { // Phase 1: Mark entire list graph as MT, then collect elements as LeanShared. @@ -1226,7 +1291,7 @@ extern "C" fn rs_tmp_decode_const_map( use ix_compile::congruence::perm::{PermCtx, RecHeadInfo, RecHeadKind}; let first = all.first()?; - let n_params = match env.get(first) { + let n_params = match env.get(first).as_deref() { Some(LeanCI::InductInfo(v)) => { v.num_params.to_u64().unwrap_or(0) as usize }, @@ -1235,7 +1300,7 @@ extern "C" fn rs_tmp_decode_const_map( let n_primary = all.len(); let primary_ctor_counts: Vec = all .iter() - .map(|n| match env.get(n) { + .map(|n| match env.get(n).as_deref() { Some(LeanCI::InductInfo(v)) => v.ctors.len(), _ => 0, }) @@ -1246,7 +1311,7 @@ extern "C" fn rs_tmp_decode_const_map( }; let source_aux_ctor_counts: Vec = source_aux_order .iter() - .map(|(head, _)| match env.get(head) { + .map(|(head, _)| match env.get(head).as_deref() { Some(LeanCI::InductInfo(v)) => v.ctors.len(), _ => 0, }) @@ -1269,7 +1334,7 @@ extern "C" fn rs_tmp_decode_const_map( source_aux_ctor_counts: source_aux_ctor_counts.clone(), aux_perm: perm.to_vec(), }; - let n_indices_for = |rec_name: &Name| match env.get(rec_name) { + let n_indices_for = |rec_name: &Name| match env.get(rec_name).as_deref() { Some(LeanCI::RecInfo(r)) => { r.num_indices.to_u64().unwrap_or(0) as usize }, @@ -1323,7 +1388,7 @@ extern "C" fn rs_tmp_decode_const_map( for suffix in ["rec", "casesOn", "recOn", "below", "brecOn"] { add_addr(&Name::str(member.clone(), suffix.to_string())); } - if let Some(LeanCI::InductInfo(v)) = env.get(member) { + if let Some(LeanCI::InductInfo(v)) = env.get(member).as_deref() { for ctor in &v.ctors { add_addr(ctor); } @@ -1811,7 +1876,8 @@ extern "C" fn rs_compile_validate_aux( let fails = AtomicUsize::new(0); let fail_msgs: Mutex> = Mutex::new(Vec::new()); - env.par_iter().for_each(|(name, _)| { + let env_names: Vec<&Name> = env.keys().collect(); + env_names.par_iter().for_each(|&name| { if stt.ungrounded.contains_key(name) { return; } @@ -1870,7 +1936,7 @@ extern "C" fn rs_compile_validate_aux( let work: Vec<(Name, Vec, Vec)> = env .iter() .filter_map(|(name, ci)| { - let all = match ci { + let all = match &*ci { LeanCI::InductInfo(v) => v.all.clone(), _ => return None, }; @@ -1884,7 +1950,7 @@ extern "C" fn rs_compile_validate_aux( } let original_cs: Vec = all .iter() - .filter_map(|n| match env.get(n) { + .filter_map(|n| match env.get(n).as_deref() { Some(LeanCI::InductInfo(v)) => { Some(MutConst::Indc(mk_indc(v, &env).ok()?)) }, @@ -1939,7 +2005,7 @@ extern "C" fn rs_compile_validate_aux( continue; } if let Some(ci) = env.get(&name) { - for ref_name in get_constant_info_references(ci) { + for ref_name in get_constant_info_references(&ci) { if !p2_ingressed.contains(&ref_name) { stack.push(ref_name); } @@ -1985,7 +2051,7 @@ extern "C" fn rs_compile_validate_aux( use rustc_hash::FxHashMap; let first = all.first()?; - let n_params = match env.get(first) { + let n_params = match env.get(first).as_deref() { Some(LeanCI::InductInfo(v)) => { v.num_params.to_u64().unwrap_or(0) as usize }, @@ -1994,7 +2060,7 @@ extern "C" fn rs_compile_validate_aux( let n_primary = all.len(); let primary_ctor_counts: Vec = all .iter() - .map(|n| match env.get(n) { + .map(|n| match env.get(n).as_deref() { Some(LeanCI::InductInfo(v)) => v.ctors.len(), _ => 0, }) @@ -2006,7 +2072,7 @@ extern "C" fn rs_compile_validate_aux( }; let source_aux_ctor_counts: Vec = source_aux_order .iter() - .map(|(head, _)| match env.get(head) { + .map(|(head, _)| match env.get(head).as_deref() { Some(LeanCI::InductInfo(v)) => v.ctors.len(), _ => 0, }) @@ -2047,7 +2113,7 @@ extern "C" fn rs_compile_validate_aux( // Helper: look up `n_indices` for a specific recursor, falling // back to 0 when the rec isn't in env (e.g., if Lean didn't // generate it for this aux — the entry is benign in that case). - let n_indices_for = |rec_name: &Name| match env.get(rec_name) { + let n_indices_for = |rec_name: &Name| match env.get(rec_name).as_deref() { Some(LeanCI::RecInfo(r)) => { r.num_indices.to_u64().unwrap_or(0) as usize }, @@ -2118,7 +2184,7 @@ extern "C" fn rs_compile_validate_aux( for suffix in ["rec", "casesOn", "recOn", "below", "brecOn"] { add_addr(&Name::str(member.clone(), suffix.to_string())); } - if let Some(LeanCI::InductInfo(v)) = env.get(member) { + if let Some(LeanCI::InductInfo(v)) = env.get(member).as_deref() { for ctor in &v.ctors { add_addr(ctor); } @@ -2345,7 +2411,7 @@ extern "C" fn rs_compile_validate_aux( let Some(orig_ci_ref) = env.get(patch_name) else { continue; // Synthetic name — no Lean original. }; - let orig_ci: &LeanCI = orig_ci_ref; + let orig_ci: &LeanCI = &orig_ci_ref; let eq_result = match &perm_ctx { Some(ctx) => { @@ -3560,7 +3626,7 @@ extern "C" fn rs_compile_validate_aux( let eq_result = aux_congruence_result( name, dec_ci.value(), - orig_ci, + &orig_ci, aux_compare_contexts.get(name), ); match eq_result { @@ -3780,7 +3846,10 @@ extern "C" fn rs_compile_validate_aux( // present). Aux-generated constants get an alpha-collapse-aware // semantic fallback when exact source-shape comparison fails. // `get_hash()` reads are pure — ok to run concurrently. - orig.par_iter().for_each(|(name, orig_ci)| match dstt2.env.get(name) { + let orig_names: Vec<&Name> = orig.keys().collect(); + orig_names.par_iter().for_each(|&name| { + let Some(orig_ci) = orig.get(name) else { return }; + match dstt2.env.get(name) { Some(dec_entry) => { let dec_ci = dec_entry.value(); let type_ok = @@ -3796,7 +3865,7 @@ extern "C" fn rs_compile_validate_aux( Some(aux_congruence_result( name, dec_ci, - orig_ci, + &orig_ci, aux_compare_contexts.get(name), )) } else { @@ -3841,7 +3910,7 @@ extern "C" fn rs_compile_validate_aux( .push(format!("{}: missing from roundtripped env", name.pretty(),)); } }, - }); + }}); p7b.pass = passes.load(Ordering::Relaxed); p7b.fail = fails.load(Ordering::Relaxed); @@ -3921,7 +3990,7 @@ extern "C" fn rs_compile_validate_aux( // Skip if any name is missing from the env (fixture not compiled). let all_present = originals .iter() - .all(|n| matches!(env.get(n), Some(ConstantInfo::InductInfo(_)))); + .all(|n| matches!(env.get(n).as_deref(), Some(ConstantInfo::InductInfo(_)))); if !all_present { continue; } diff --git a/crates/kernel/src/ingress.rs b/crates/kernel/src/ingress.rs index d27273f7d..0ca09d8c2 100644 --- a/crates/kernel/src/ingress.rs +++ b/crates/kernel/src/ingress.rs @@ -2716,7 +2716,7 @@ pub fn build_leon_addr_map(lean_env: &LeanEnv) -> DashMap { // phase from `src/ix/compile/aux_gen.rs:823`. Splitting the two into // different types would propagate a signature change through ~5 // functions with no matching perf win. - let entries: Vec<(&Name, &LeanCI)> = lean_env.iter().collect(); + let entries: Vec<_> = lean_env.iter().collect(); let map = DashMap::with_capacity(lean_env.len()); entries.par_iter().for_each(|(name, ci)| { map.insert((*name).clone(), Address::from_blake3_hash(ci.get_hash())); @@ -2977,7 +2977,7 @@ pub fn lean_ingress(lean_env: &LeanEnv) -> KEnv { let t = Instant::now(); for (name, ci) in lean_env.iter() { let kid = KId::new(leon_addr_of(name, &n2a), name.clone()); - let kc = lean_const_to_kconst(name, ci, &mut kenv, &n2a); + let kc = lean_const_to_kconst(name, &ci, &mut kenv, &n2a); kenv.insert(kid, kc); } if !quiet { @@ -3051,12 +3051,12 @@ pub fn lean_ingress(lean_env: &LeanEnv) -> KEnv { let t = Instant::now(); let mut seeded: FxHashSet> = FxHashSet::default(); for (name, ci) in lean_env.iter() { - let block_id = block_rep(name, ci); + let block_id = block_rep(name, &ci); if !seeded.insert(block_id.clone()) { continue; } let all = - lean_constant_all(ci).cloned().unwrap_or_else(|| vec![name.clone()]); + lean_constant_all(&ci).cloned().unwrap_or_else(|| vec![name.clone()]); let members: Vec> = all.iter().map(|n| KId::new(leon_addr_of(n, &n2a), n.clone())).collect(); kenv.blocks.insert(block_id, members); @@ -3075,9 +3075,9 @@ pub fn lean_ingress(lean_env: &LeanEnv) -> KEnv { // (`find_peer_recursors` for recs). let t = Instant::now(); for (name, ci) in lean_env.iter() { - match ci { + match &*ci { LeanCI::InductInfo(v) => { - let block_id = block_rep(name, ci); + let block_id = block_rep(name, &ci); for ctor_name in &v.ctors { let ctor_kid: KId = KId::new(leon_addr_of(ctor_name, &n2a), ctor_name.clone()); @@ -3085,7 +3085,7 @@ pub fn lean_ingress(lean_env: &LeanEnv) -> KEnv { } }, LeanCI::RecInfo(_) => { - let block_id = block_rep(name, ci); + let block_id = block_rep(name, &ci); let self_kid = KId::new(leon_addr_of(name, &n2a), name.clone()); kenv.blocks.entry(block_id).or_default().push(self_kid); }, From 87a96f2142ac07fac826590c7a4345fcce02422f Mon Sep 17 00:00:00 2001 From: samuelburnham <45365069+samuelburnham@users.noreply.github.com> Date: Thu, 9 Jul 2026 18:50:56 -0400 Subject: [PATCH 07/19] Compile: fuse the three whole-env setup sweeps into one scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit compile_env's setup decoded the entire Lean environment three times back-to-back: build_ref_graph, ground_consts' immediate scan, and validate_lean_ind_flags' group collection. Free when the env is an eager map; under IX_COMPILE_LEAN_ENV=lazy each sweep re-decodes every constant, and the triple sweep dominated lazy mode's wall regression. graph::setup_scan is one parallel pass producing all three outputs (ref graph, immediately-ungrounded set, inductive groups) with one decode per constant. ground_consts splits into ground_const_check + proliferate_ungrounded; validate_lean_ind_flags splits out validate_ind_groups. The unfused functions remain (other callers, tests) and the fused path is behavior-identical. Measured (full lever stack, 50 GB cap): lazy-mode wall regression eliminated — Mathlib 149 -> 93.4 s (eager: 94.4 s), InitStd 12.0 -> 6.9 s (eager: 7.0 s); .ixe byte-identical on InitStd; peak RSS unchanged (Mathlib 19.5 GB). Lazy mode now matches eager wall time while using less than half the RAM of the pre-lever-1 stack. --- crates/compile/src/compile/aux_gen/nested.rs | 11 +- crates/compile/src/compile/env.rs | 36 +++--- crates/compile/src/graph.rs | 110 +++++++++++++++++++ crates/compile/src/ground.rs | 33 ++++-- 4 files changed, 165 insertions(+), 25 deletions(-) diff --git a/crates/compile/src/compile/aux_gen/nested.rs b/crates/compile/src/compile/aux_gen/nested.rs index 6629dc70a..3ab2f77aa 100644 --- a/crates/compile/src/compile/aux_gen/nested.rs +++ b/crates/compile/src/compile/aux_gen/nested.rs @@ -2118,7 +2118,16 @@ pub fn validate_lean_ind_flags(lean_env: &LeanEnv) -> Result<(), CompileError> { groups.entry(first.clone()).or_insert_with(|| v.all.clone()); } } - //let groups: Vec<(&Name, &[Name])> = groups.into_iter().collect(); + validate_ind_groups(&groups, lean_env) +} + +/// Per-group half of [`validate_lean_ind_flags`], for callers that +/// already collected the inductive groups (the fused setup scan — see +/// `graph::setup_scan`). +pub fn validate_ind_groups( + groups: &FxHashMap>, + lean_env: &LeanEnv, +) -> Result<(), CompileError> { groups.par_iter().try_for_each(|(_, all)| { for member in all.iter() { let entry = lean_env.get(member); diff --git a/crates/compile/src/compile/env.rs b/crates/compile/src/compile/env.rs index 9a7c1f224..67b02582f 100644 --- a/crates/compile/src/compile/env.rs +++ b/crates/compile/src/compile/env.rs @@ -16,12 +16,11 @@ use rustc_hash::FxHashSet; use crate::compile::{ BlockCache, CompileOptions, CompileState, - aux_gen::nested::validate_lean_ind_flags, compile_const, - compile_const_no_aux, + aux_gen::nested::validate_ind_groups, compile_const, compile_const_no_aux, }; use crate::condense::compute_sccs; -use crate::graph::{NameSet, build_ref_graph}; -use crate::ground::ground_consts; +use crate::graph::{NameSet, setup_scan}; +use crate::ground::proliferate_ungrounded; use ix_common::address::Address; use ix_common::env::{Env as LeanEnv, Name}; use ixon::CompileError; @@ -154,25 +153,30 @@ pub fn compile_env_with_options( options: CompileOptions, ) -> Result { let setup_start = Instant::now(); + // Fused whole-env scan: ref graph + immediate groundedness + + // inductive groups in one decode per constant (three separate sweeps + // triple the decode cost under IX_COMPILE_LEAN_ENV=lazy). let phase_start = Instant::now(); - let graph = build_ref_graph(lean_env.as_ref()); + let scan = setup_scan(lean_env.as_ref()); + let graph = scan.graph; if !*IX_QUIET { eprintln!( - "[compile_env] setup 1/7 build_ref_graph: {:.2}s", + "[compile_env] setup 1/7 setup_scan (graph+ground+groups): {:.2}s", phase_start.elapsed().as_secs_f32() ); } - // Grounding pass: identify constants whose transitive Const-refs can't all - // be resolved. These are collected into `stt.ungrounded` and filtered from - // the SCC input so they don't clog the scheduler. Callers (e.g. the kernel - // check FFI) inspect `stt.ungrounded` per-constant to report them as - // compile-side rejections without aborting the whole batch. + // Grounding: constants whose transitive Const-refs can't all be + // resolved are collected into `stt.ungrounded` and filtered from the + // SCC input so they don't clog the scheduler. Callers (e.g. the + // kernel check FFI) inspect `stt.ungrounded` per-constant to report + // them as compile-side rejections without aborting the whole batch. let phase_start = Instant::now(); - let ungrounded = ground_consts(lean_env.as_ref(), &graph.in_refs); + let ungrounded = + proliferate_ungrounded(scan.immediate_ungrounded, &graph.in_refs); if !*IX_QUIET { eprintln!( - "[compile_env] setup 2/7 ground_consts: {:.2}s", + "[compile_env] setup 2/7 proliferate_ungrounded: {:.2}s", phase_start.elapsed().as_secs_f32() ); } @@ -220,9 +224,11 @@ pub fn compile_env_with_options( ); } - // Domain restriction: reject environments with non-canonical inductive flags + // Domain restriction: reject environments with non-canonical inductive + // flags. Groups come from the fused scan; only inductive families are + // re-read here. let phase_start = Instant::now(); - validate_lean_ind_flags(lean_env.as_ref())?; + validate_ind_groups(&scan.ind_groups, lean_env.as_ref())?; if !*IX_QUIET { eprintln!( "[compile_env] setup 4/7 validate_ind_flags: {:.2}s", diff --git a/crates/compile/src/graph.rs b/crates/compile/src/graph.rs index 51f48cf90..a5bd39e89 100644 --- a/crates/compile/src/graph.rs +++ b/crates/compile/src/graph.rs @@ -47,6 +47,116 @@ pub struct RefGraph { /// /// For each constant, extracts the set of names it references (from types, values, constructors, /// and recursor rules), then assembles both the forward and reverse edge maps. +/// Everything the compile-env setup needs from a whole-env pass: the +/// reference graph, the immediately-ungrounded set (before transitive +/// proliferation), and the inductive mutual-block groups +/// (`all[0] → all`, for flag validation). +pub struct SetupScan { + pub graph: RefGraph, + pub immediate_ungrounded: FxHashMap, + pub ind_groups: FxHashMap>, +} + +/// Fused whole-env setup pass: one decode per constant feeding the ref +/// graph, the groundedness check, and inductive-group collection. +/// Replaces three back-to-back full-env sweeps (`build_ref_graph`, +/// `ground_consts`' scan, `validate_lean_ind_flags`' scan) — under +/// `IX_COMPILE_LEAN_ENV=lazy` each sweep re-decodes every constant, so +/// fusing them cuts the setup decode count to a third. Outputs are +/// identical to the separate passes. +pub fn setup_scan(env: &Env) -> SetupScan { + struct Acc { + out_refs: RefMap, + in_refs: RefMap, + ungrounded: FxHashMap, + ind_groups: FxHashMap>, + } + impl Default for Acc { + fn default() -> Self { + Acc { + out_refs: RefMap::default(), + in_refs: RefMap::default(), + ungrounded: FxHashMap::default(), + ind_groups: FxHashMap::default(), + } + } + } + + let names: Vec<&Name> = env.keys().collect(); + let acc = names + .into_par_iter() + .filter_map(|name| { + let constant = env.get(name)?; + let deps = get_constant_info_references(&constant); + let mut acc = Acc { + in_refs: mk_in_refs(name, &deps), + out_refs: RefMap::from_iter([(name.clone(), deps)]), + ..Acc::default() + }; + if let Err(err) = crate::ground::ground_const_check(&constant, env) { + acc.ungrounded.insert(name.clone(), err); + } + // Members of one mutual family share the same `all`, so + // first-wins insertion is value-identical regardless of which + // member lands first (the same invariant the unfused scan + // relied on). + if let ConstantInfo::InductInfo(v) = &*constant + && let Some(first) = v.all.first() + { + acc.ind_groups.entry(first.clone()).or_insert_with(|| v.all.clone()); + } + Some(acc) + }) + .reduce(Acc::default, |mut l, r| { + l.out_refs = merge_ref_maps(l.out_refs, r.out_refs); + l.in_refs = merge_ref_maps(l.in_refs, r.in_refs); + l.ungrounded.extend(r.ungrounded); + for (k, v) in r.ind_groups { + l.ind_groups.entry(k).or_insert(v); + } + l + }); + + SetupScan { + graph: RefGraph { out_refs: acc.out_refs, in_refs: acc.in_refs }, + immediate_ungrounded: acc.ungrounded, + ind_groups: acc.ind_groups, + } +} + +/// `name → {name} ∪ deps`-shaped reverse-edge fragment for one +/// constant, merged across the parallel scan. +fn mk_in_refs(name: &Name, deps: &NameSet) -> RefMap { + let mut in_refs = RefMap::from_iter([(name.clone(), NameSet::default())]); + for dep in deps { + match in_refs.entry(dep.clone()) { + Entry::Vacant(entry) => { + entry.insert(NameSet::from_iter([name.clone()])); + }, + Entry::Occupied(mut entry) => { + entry.get_mut().insert(name.clone()); + }, + } + } + in_refs +} + +/// Size-aware map union (drain the smaller side into the bigger). +fn merge_ref_maps(l: RefMap, r: RefMap) -> RefMap { + let (smaller, mut bigger) = if l.len() < r.len() { (l, r) } else { (r, l) }; + for (name, set) in smaller { + match bigger.entry(name) { + Entry::Vacant(entry) => { + entry.insert(set); + }, + Entry::Occupied(mut entry) => { + entry.get_mut().extend(set); + }, + } + } + bigger +} + pub fn build_ref_graph(env: &Env) -> RefGraph { let mk_in_refs = |name: &Name, deps: &NameSet| -> RefMap { let mut in_refs = RefMap::from_iter([(name.clone(), NameSet::default())]); diff --git a/crates/compile/src/ground.rs b/crates/compile/src/ground.rs index d53a79646..c6548ccbb 100644 --- a/crates/compile/src/ground.rs +++ b/crates/compile/src/ground.rs @@ -48,21 +48,37 @@ pub fn ground_consts( ) -> FxHashMap { // Collect immediate ungrounded constants. let names: Vec<&Name> = env.keys().collect(); - let mut ungrounded: FxHashMap<_, _> = names + let ungrounded: FxHashMap<_, _> = names .into_par_iter() .filter_map(|name| { let constant = env.get(name)?; - let univs = const_univs(&constant); - let mut stt = GroundState::default(); - if let Err(err) = ground_const(&constant, env, univs, 0, &mut stt) { - Some((name.clone(), err)) - } else { - None + match ground_const_check(&constant, env) { + Err(err) => Some((name.clone(), err)), + Ok(()) => None, } }) .collect(); + proliferate_ungrounded(ungrounded, in_refs) +} - // Proliferate ungroundedness through in-refs. +/// Per-constant groundedness check, for callers that already hold a +/// decoded constant (the fused setup scan — see `graph::setup_scan`). +pub fn ground_const_check( + constant: &ConstantInfo, + env: &Env, +) -> Result<(), GroundError> { + let univs = const_univs(constant); + let mut stt = GroundState::default(); + ground_const(constant, env, univs, 0, &mut stt) +} + +/// Spread ungroundedness from the immediately-ungrounded set through +/// the reverse-reference graph: anything referencing an ungrounded +/// constant is itself ungrounded. +pub fn proliferate_ungrounded( + mut ungrounded: FxHashMap, + in_refs: &RefMap, +) -> FxHashMap { let mut stack: Vec<_> = ungrounded.keys().cloned().collect(); while let Some(popped) = stack.pop() { let Some(in_ref_set) = in_refs.get(&popped) else { @@ -75,7 +91,6 @@ pub fn ground_consts( } } } - ungrounded } From 2d15020fcdb2c0c05e9278e577c159fabdd2e0b0 Mon Sep 17 00:00:00 2001 From: samuelburnham <45365069+samuelburnham@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:14:08 -0400 Subject: [PATCH 08/19] Compile spill: move design-doc content into module docs The scheduler module gains a "Memory tuning" overview documenting the IX_COMPILE_* knobs and the measured Mathlib outcome, the lazy-env types get self-contained doc comments, and comments that pointed at the working design doc now point at the relevant module docs instead. --- crates/common/src/env.rs | 7 +- crates/compile/src/compile/env.rs | 40 +++++++++- crates/ffi/src/lean_env.rs | 128 +++++++++++++++--------------- crates/ixon/src/env.rs | 2 +- 4 files changed, 109 insertions(+), 68 deletions(-) diff --git a/crates/common/src/env.rs b/crates/common/src/env.rs index acd10a7eb..0ea93cd86 100644 --- a/crates/common/src/env.rs +++ b/crates/common/src/env.rs @@ -1492,8 +1492,11 @@ impl EnvEntry<'_> { /// Host-only lazy backing for [`Env`]: a name index plus an injected /// fetch that decodes one constant on demand (in practice from Lean -/// objects held as `LeanShared` handles — see docs/compile-spill.md, -/// lever 1), fronted by a sharded bounded cache. +/// objects held as `LeanShared` handles — see `decode_env_lazy` in the +/// ffi crate), fronted by a sharded bounded cache. Avoids materializing +/// the full owned copy of the environment up front, which costs a large +/// multiple of the working set the compile pipeline actually touches at +/// any one time. #[cfg(not(target_arch = "riscv64"))] pub struct LazyEnv { /// All names, in the source env's iteration order. diff --git a/crates/compile/src/compile/env.rs b/crates/compile/src/compile/env.rs index 67b02582f..06afc4c23 100644 --- a/crates/compile/src/compile/env.rs +++ b/crates/compile/src/compile/env.rs @@ -1,6 +1,42 @@ //! Top-level environment compilation with work-stealing parallelism. //! //! Extracted from `compile.rs` to keep the scheduler independently readable. +//! +//! # Memory tuning +//! +//! Compiling a large environment (Mathlib-scale) peaks at tens of GB by +//! default. A set of opt-in, independently toggleable env vars trades +//! bounded CPU for RAM; each defaults to today's fastest behavior, and +//! every combination produces a bit-identical `.ixe`: +//! +//! - `IX_COMPILE_WORKERS=N` — scheduler worker count (default: all +//! cores). Scales the per-worker transients. +//! - `IX_COMPILE_KENV_CLEAR_EVERY=N` — clear each worker's kernel env +//! every N blocks (default 0 = never). The kenv is a pure cache; +//! clearing at block boundaries is semantics-free. +//! - `IX_COMPILE_SPILL=off|demote|mmap` — accumulator constants keep a +//! materialized cache next to their bytes (`off`, default), keep +//! bytes only (`demote`; the structured form costs ~20× its +//! encoding), or additionally spill the bytes to an anonymous temp +//! file in sealed mmap segments the kernel can evict under pressure +//! (`mmap`; see `ixon`'s `spill` module; `IX_COMPILE_SPILL_DIR` +//! must be disk-backed, default cwd; `IX_COMPILE_SPILL_SEGMENT_MB`). +//! - `IX_COMPILE_META=structured|demote` — store registered names' +//! metadata structured (default) or as self-contained serialized +//! bytes decoded on demand (same ~20× trade as the accumulator). +//! - `IX_COMPILE_LEAN_ENV=eager|lazy` + `IX_COMPILE_LEAN_ENV_CACHE` — +//! materialize the whole Lean environment as owned Rust data up +//! front (default) or decode constants on demand from the Lean +//! objects behind a bounded cache (the decoded copy is the single +//! largest term at Mathlib scale). Measured at wall-time parity. +//! - `IX_COMPILE_STREAM=1` (read by the Lean CLI) — stream the `.ixe` +//! to disk from Rust instead of returning an env-sized ByteArray. +//! +//! With everything on, Mathlib compiles in ~19 GB peak RSS (vs OOM on +//! a 50 GB budget by default) at equal wall time. Progress telemetry +//! (`IX_QUIET`, `IX_PROGRESS_MS`, `IX_LOG_BLOCKS`) reports RSS +//! anon/file splits, accumulator composition, worker-kenv sizes, and +//! lazy-env cache hit rates per decile to guide tuning. use std::panic::{AssertUnwindSafe, catch_unwind}; use std::sync::{ @@ -959,7 +995,7 @@ pub fn compile_env_with_options( // every block this worker compiled (nothing clears it during // compilation), so these counts are the worker's whole-run // accumulation — the term the accumulator split above does not - // cover (see docs/compile-spill.md, step 0). + // cover. if !*IX_QUIET { let sizes = worker_kctx.kenv.cache_sizes(); // Workers that never populated their kenv (no aux_gen blocks @@ -1050,7 +1086,7 @@ pub fn compile_env_with_options( // Accumulator composition: how much of `env.consts` is materialized // `Arc` caches vs serialized bytes. The byte sum is the // floor the accumulator would shrink to if every cache were dropped - // (see docs/compile-spill.md, step 0). + // dropped. let acc = stt.env.const_cache_stats(); let materialized_pct = if acc.entries == 0 { 0.0 diff --git a/crates/ffi/src/lean_env.rs b/crates/ffi/src/lean_env.rs index 76e04c847..5244bcaee 100644 --- a/crates/ffi/src/lean_env.rs +++ b/crates/ffi/src/lean_env.rs @@ -1124,12 +1124,11 @@ fn decode_name_constant_info( (name, constant_info) } -// Decode a Lean environment in parallel with hybrid caching. /// Decode dispatch for the compile FFI entries: eager (default) or, /// under `IX_COMPILE_LEAN_ENV=lazy`, an on-demand view that avoids /// materializing the full Rust copy of the environment -/// (`IX_COMPILE_LEAN_ENV_CACHE` bounds resident decoded constants; -/// see docs/compile-spill.md, lever 1). +/// (`IX_COMPILE_LEAN_ENV_CACHE` bounds resident decoded constants — +/// see [`decode_env_lazy`]). pub fn decode_env_auto(list: LeanList>) -> Env { match std::env::var("IX_COMPILE_LEAN_ENV").as_deref() { Ok("lazy") => { @@ -3850,67 +3849,70 @@ extern "C" fn rs_compile_validate_aux( orig_names.par_iter().for_each(|&name| { let Some(orig_ci) = orig.get(name) else { return }; match dstt2.env.get(name) { - Some(dec_entry) => { - let dec_ci = dec_entry.value(); - let type_ok = - dec_ci.get_type().get_hash() == orig_ci.get_type().get_hash(); - let val_ok = match (dec_ci.get_value(), orig_ci.get_value()) { - (Some(d), Some(o)) => d.get_hash() == o.get_hash(), - (None, None) => true, - _ => false, - }; - let aux_eq_result = if ix_compile::decompile::is_aux_gen_suffix(name) - && !(type_ok && val_ok) - { - Some(aux_congruence_result( - name, - dec_ci, - &orig_ci, - aux_compare_contexts.get(name), - )) - } else { - None - }; - let ok = match aux_eq_result.as_ref() { - Some(Ok(())) => true, - Some(Err(_)) => false, - None => type_ok && val_ok, - }; - if ok { - passes.fetch_add(1, Ordering::Relaxed); - } else { + Some(dec_entry) => { + let dec_ci = dec_entry.value(); + let type_ok = + dec_ci.get_type().get_hash() == orig_ci.get_type().get_hash(); + let val_ok = match (dec_ci.get_value(), orig_ci.get_value()) { + (Some(d), Some(o)) => d.get_hash() == o.get_hash(), + (None, None) => true, + _ => false, + }; + let aux_eq_result = if ix_compile::decompile::is_aux_gen_suffix(name) + && !(type_ok && val_ok) + { + Some(aux_congruence_result( + name, + dec_ci, + &orig_ci, + aux_compare_contexts.get(name), + )) + } else { + None + }; + let ok = match aux_eq_result.as_ref() { + Some(Ok(())) => true, + Some(Err(_)) => false, + None => type_ok && val_ok, + }; + if ok { + passes.fetch_add(1, Ordering::Relaxed); + } else { + fails.fetch_add(1, Ordering::Relaxed); + let mut msgs = fail_msgs.lock().unwrap(); + if msgs.len() < 20 { + let mut parts = Vec::new(); + match aux_eq_result { + Some(Err(e)) => parts.push(format!("aux congruence: {e}")), + _ => { + if !type_ok { + parts.push(format!( + "type: dec={} orig={}", + dec_ci.get_type().pretty(), + orig_ci.get_type().pretty(), + )); + } + if !val_ok { + parts.push("value hash mismatch".to_string()); + } + }, + } + msgs.push(format!("{}: {}", name.pretty(), parts.join("; "))); + } + } + }, + None => { fails.fetch_add(1, Ordering::Relaxed); let mut msgs = fail_msgs.lock().unwrap(); if msgs.len() < 20 { - let mut parts = Vec::new(); - match aux_eq_result { - Some(Err(e)) => parts.push(format!("aux congruence: {e}")), - _ => { - if !type_ok { - parts.push(format!( - "type: dec={} orig={}", - dec_ci.get_type().pretty(), - orig_ci.get_type().pretty(), - )); - } - if !val_ok { - parts.push("value hash mismatch".to_string()); - } - }, - } - msgs.push(format!("{}: {}", name.pretty(), parts.join("; "))); + msgs.push(format!( + "{}: missing from roundtripped env", + name.pretty(), + )); } - } - }, - None => { - fails.fetch_add(1, Ordering::Relaxed); - let mut msgs = fail_msgs.lock().unwrap(); - if msgs.len() < 20 { - msgs - .push(format!("{}: missing from roundtripped env", name.pretty(),)); - } - }, - }}); + }, + } + }); p7b.pass = passes.load(Ordering::Relaxed); p7b.fail = fails.load(Ordering::Relaxed); @@ -3988,9 +3990,9 @@ extern "C" fn rs_compile_validate_aux( original_strs.iter().map(|s| mk_name(s)).collect(); // Skip if any name is missing from the env (fixture not compiled). - let all_present = originals - .iter() - .all(|n| matches!(env.get(n).as_deref(), Some(ConstantInfo::InductInfo(_)))); + let all_present = originals.iter().all(|n| { + matches!(env.get(n).as_deref(), Some(ConstantInfo::InductInfo(_))) + }); if !all_present { continue; } diff --git a/crates/ixon/src/env.rs b/crates/ixon/src/env.rs index 2d5dd9c40..7c920b4ff 100644 --- a/crates/ixon/src/env.rs +++ b/crates/ixon/src/env.rs @@ -180,7 +180,7 @@ pub struct LazyNamed { /// - `Demote`: `store_const` stores bytes only; `get_const` re-parses per /// access (the lazy-load policy — see `LazyConstant` docs). /// - `Mmap`: demote plus spilling the bytes to a file-backed mapping -/// (see docs/compile-spill.md, step 2). +/// (see the `spill` module docs). /// /// Host-only: the guest builds `Env` via deserialization and never calls /// `store_const`. From bde1789e38eb84aaec12792bb9f8ce39c84b005a Mon Sep 17 00:00:00 2001 From: samuelburnham <45365069+samuelburnham@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:36:43 -0400 Subject: [PATCH 09/19] Compile memory: consolidate to two knobs, always-on defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The measured wall-time cost of the memory reductions is zero at scale (Mathlib 86.5 s / 19.5 GB with pure defaults vs OOM >50 GB before; the .ixe is byte-identical), so they stop being opt-in: - Lazy Lean-env decode is the compile FFI entries' only path (decode_env_for_compile; fixed 65536-entry cache — wall parity measured InitStd through Mathlib, nothing to tune). Test/roundtrip entries keep the eager decode_env. - The compile CLI always streams the .ixe from Rust (rs_compile_env_to_file); the buffered branch is gone from CompileCmd. rs_compile_env remains for callers that want bytes in memory. - Worker kenvs clear unconditionally every 64 blocks (measured at zero wall cost, several GB bounded on Mathlib). - The mmap spill file is removed outright (crates/ixon/src/spill.rs, tempfile dep): sealed segments made only the accumulator's ~1 GB of bytes evictable — noise next to the demotions — and cost a Mutex on the store path plus a file lifecycle. One tradeoff remains env-tunable, plus parallelism: - IX_COMPILE_DEMOTE (default on; =0 to disable) covers both demotions — accumulator constants and named metadata as serialized bytes (~20x smaller than structured). Off buys free post-compile structural reads for in-process flows (ix check / ix validate). - IX_COMPILE_WORKERS unchanged. Replaces IX_COMPILE_SPILL{,_DIR,_SEGMENT_MB}, IX_COMPILE_META, IX_COMPILE_KENV_CLEAR_EVERY, IX_COMPILE_LEAN_ENV{,_CACHE}, and IX_COMPILE_STREAM. --- Cargo.lock | 14 -- Cargo.toml | 1 - Ix/Cli/CompileCmd.lean | 48 +--- crates/common/src/env.rs | 6 +- crates/compile/src/compile/aux_gen/nested.rs | 3 +- crates/compile/src/compile/env.rs | 95 +++----- crates/compile/src/graph.rs | 11 +- crates/compile/src/ground.rs | 2 +- crates/ffi/src/compile.rs | 14 +- crates/ffi/src/lean_env.rs | 40 ++- crates/ixon/Cargo.toml | 1 - crates/ixon/src/env.rs | 242 ++++--------------- crates/ixon/src/lazy.rs | 6 +- crates/ixon/src/lib.rs | 2 - crates/ixon/src/metadata.rs | 4 +- crates/ixon/src/serialize.rs | 5 +- crates/ixon/src/spill.rs | 170 ------------- crates/kernel/src/ingress.rs | 4 +- 18 files changed, 129 insertions(+), 539 deletions(-) delete mode 100644 crates/ixon/src/spill.rs diff --git a/Cargo.lock b/Cargo.lock index 4978647a9..7e44d6c16 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1853,7 +1853,6 @@ dependencies = [ "rayon", "rustc-hash", "sha2 0.10.9", - "tempfile", "tiny-keccak", ] @@ -3701,19 +3700,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.4.2", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] - [[package]] name = "terminal_size" version = "0.4.4" diff --git a/Cargo.toml b/Cargo.toml index cc2c1cc2c..719fee16f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,7 +58,6 @@ rayon = "1" rustc-hash = "2" serde_json = "1" sha2 = "0.10" -tempfile = "3" tiny-keccak = { version = "2", features = ["keccak"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/Ix/Cli/CompileCmd.lean b/Ix/Cli/CompileCmd.lean index 3d86329fa..4983d9ba9 100644 --- a/Ix/Cli/CompileCmd.lean +++ b/Ix/Cli/CompileCmd.lean @@ -135,36 +135,16 @@ def runCompileCmd (p : Cli.Parsed) : IO UInt32 := do TracingTexray.startSampler TracingTexray.resetPeakTreeRss - -- IX_COMPILE_STREAM=1: Rust writes the `.ixe` directly (streamed; - -- avoids ~2× env size of peak RAM for the buffered ByteArray path). - -- Both paths produce byte-identical files. - let stream := (← IO.getEnv "IX_COMPILE_STREAM") == some "1" - if stream then - let start ← IO.monoMsNow - let size ← Ix.CompileM.rsCompileEnvToFileFFI constList outPath - let elapsed := (← IO.monoMsNow) - start - println! "Compiled and wrote {fmtBytes size} env to {outPath} in {elapsed.formatMs}" - IO.println s!"##benchmark## {elapsed} {size} {totalConsts}" - if let some flag := p.flag? "json" then - let key := (p.flag? "json-name").map (·.as! String) - |>.getD ((FilePath.mk pathStr).fileStem.getD "env") - let secs := elapsed.toFloat / 1000.0 - let tput := if elapsed > 0 - then totalConsts.toFloat * 1000.0 / elapsed.toFloat else 0.0 - let peakRss ← TracingTexray.peakTreeRssBytes - Ix.Benchmark.Results.writeRow (flag.as! String) key "ok" - [ ("compile-time", Ix.Benchmark.Results.jsonRound 3 secs) - , ("file-size", Lean.toJson size) - , ("constants", Lean.toJson totalConsts) - , ("throughput", Ix.Benchmark.Results.jsonRound 2 tput) - , ("peak-rss", Lean.toJson peakRss) ] - return 0 - + -- Rust compiles and writes the `.ixe` directly (streamed — no + -- env-sized ByteArray crosses the FFI; `.tmp` + atomic rename). + -- The file is the canonical `Ixon.Env::put` format and round-trips + -- through `Ixon.Env::get`, so later runs (e.g. `ix check-ixon`) can + -- skip the Lean → IxOn compile step. let start ← IO.monoMsNow - let bytes ← Ix.CompileM.rsCompileEnvBytesFFI constList + let size ← Ix.CompileM.rsCompileEnvToFileFFI constList outPath let elapsed := (← IO.monoMsNow) - start - - println! "Compiled {fmtBytes bytes.size} env in {elapsed.formatMs}" + println! "Compiled and wrote {fmtBytes size} env to {outPath} in {elapsed.formatMs}" + IO.println s!"##benchmark## {elapsed} {size} {totalConsts}" if let some flag := p.flag? "json" then let key := (p.flag? "json-name").map (·.as! String) |>.getD ((FilePath.mk pathStr).fileStem.getD "env") @@ -174,20 +154,10 @@ def runCompileCmd (p : Cli.Parsed) : IO UInt32 := do let peakRss ← TracingTexray.peakTreeRssBytes Ix.Benchmark.Results.writeRow (flag.as! String) key "ok" [ ("compile-time", Ix.Benchmark.Results.jsonRound 3 secs) - , ("file-size", Lean.toJson bytes.size) + , ("file-size", Lean.toJson size) , ("constants", Lean.toJson totalConsts) , ("throughput", Ix.Benchmark.Results.jsonRound 2 tput) , ("peak-rss", Lean.toJson peakRss) ] - - -- Persist the serialized IxonEnv (`Env::put` bytes) to disk so subsequent - -- runs (e.g. `ix check-ixon`) can skip the Lean → IxOn compile step. The - -- resulting file is the canonical streaming format produced by - -- `Ixon.Env::put` (see `src/ix/ixon/serialize.rs:1093-1297`); it round-trips - -- through `Ixon.Env::get`. - let writeStart ← IO.monoMsNow - IO.FS.writeBinFile outPath bytes - let writeMs := (← IO.monoMsNow) - writeStart - println! "Wrote {fmtBytes bytes.size} to {outPath} in {writeMs.formatMs}" return 0 diff --git a/crates/common/src/env.rs b/crates/common/src/env.rs index 0ea93cd86..7ba903688 100644 --- a/crates/common/src/env.rs +++ b/crates/common/src/env.rs @@ -1559,9 +1559,9 @@ impl LazyEnv { /// variant on the guest) or, on the host, a lazy on-demand view (see /// [`LazyEnv`]). /// -/// Was `pub type Env = FxHashMap`; the struct keeps -/// the map API shape, with [`Env::get`] returning the [`EnvEntry`] -/// guard instead of a plain borrow. +/// Keeps the plain-map API shape, with [`Env::get`] returning the +/// [`EnvEntry`] guard instead of a plain borrow, so both variants +/// serve reads through one signature. #[derive(Default)] pub struct Env { eager: FxHashMap, diff --git a/crates/compile/src/compile/aux_gen/nested.rs b/crates/compile/src/compile/aux_gen/nested.rs index 3ab2f77aa..1ddaf41b3 100644 --- a/crates/compile/src/compile/aux_gen/nested.rs +++ b/crates/compile/src/compile/aux_gen/nested.rs @@ -2122,8 +2122,7 @@ pub fn validate_lean_ind_flags(lean_env: &LeanEnv) -> Result<(), CompileError> { } /// Per-group half of [`validate_lean_ind_flags`], for callers that -/// already collected the inductive groups (the fused setup scan — see -/// `graph::setup_scan`). +/// already hold the inductive groups from a wider env pass. pub fn validate_ind_groups( groups: &FxHashMap>, lean_env: &LeanEnv, diff --git a/crates/compile/src/compile/env.rs b/crates/compile/src/compile/env.rs index 06afc4c23..b86d451e2 100644 --- a/crates/compile/src/compile/env.rs +++ b/crates/compile/src/compile/env.rs @@ -2,41 +2,32 @@ //! //! Extracted from `compile.rs` to keep the scheduler independently readable. //! -//! # Memory tuning +//! # Memory //! -//! Compiling a large environment (Mathlib-scale) peaks at tens of GB by -//! default. A set of opt-in, independently toggleable env vars trades -//! bounded CPU for RAM; each defaults to today's fastest behavior, and -//! every combination produces a bit-identical `.ixe`: +//! Peak RSS is kept a small multiple of the environment's serialized +//! size (Mathlib: ~19 GB for a 3 GB `.ixe`) by holding compact +//! representations everywhere the structured forms aren't actually +//! read: the Lean environment decodes on demand behind a bounded cache +//! (`decode_env_lazy` in the ffi crate) rather than as a whole-env +//! owned copy, accumulator constants and named metadata live as their +//! serialized bytes rather than ~20×-larger structured forms, worker +//! kernel envs clear periodically, the setup passes share one +//! whole-env decode, and the `.ixe` streams to disk from Rust rather +//! than crossing the FFI as an env-sized ByteArray. All of it is +//! always on, and every mode produces a bit-identical `.ixe`. //! +//! Two knobs: //! - `IX_COMPILE_WORKERS=N` — scheduler worker count (default: all //! cores). Scales the per-worker transients. -//! - `IX_COMPILE_KENV_CLEAR_EVERY=N` — clear each worker's kernel env -//! every N blocks (default 0 = never). The kenv is a pure cache; -//! clearing at block boundaries is semantics-free. -//! - `IX_COMPILE_SPILL=off|demote|mmap` — accumulator constants keep a -//! materialized cache next to their bytes (`off`, default), keep -//! bytes only (`demote`; the structured form costs ~20× its -//! encoding), or additionally spill the bytes to an anonymous temp -//! file in sealed mmap segments the kernel can evict under pressure -//! (`mmap`; see `ixon`'s `spill` module; `IX_COMPILE_SPILL_DIR` -//! must be disk-backed, default cwd; `IX_COMPILE_SPILL_SEGMENT_MB`). -//! - `IX_COMPILE_META=structured|demote` — store registered names' -//! metadata structured (default) or as self-contained serialized -//! bytes decoded on demand (same ~20× trade as the accumulator). -//! - `IX_COMPILE_LEAN_ENV=eager|lazy` + `IX_COMPILE_LEAN_ENV_CACHE` — -//! materialize the whole Lean environment as owned Rust data up -//! front (default) or decode constants on demand from the Lean -//! objects behind a bounded cache (the decoded copy is the single -//! largest term at Mathlib scale). Measured at wall-time parity. -//! - `IX_COMPILE_STREAM=1` (read by the Lean CLI) — stream the `.ixe` -//! to disk from Rust instead of returning an env-sized ByteArray. +//! - `IX_COMPILE_DEMOTE=0` — keep materialized caches next to the +//! accumulator's bytes instead of demoting to bytes-only. Spends +//! RAM to make post-compile structural reads free, which only pays +//! in flows that re-read the compiled env in-process (`ix check` / +//! `ix validate`); `ix compile` itself never reads them back. //! -//! With everything on, Mathlib compiles in ~19 GB peak RSS (vs OOM on -//! a 50 GB budget by default) at equal wall time. Progress telemetry -//! (`IX_QUIET`, `IX_PROGRESS_MS`, `IX_LOG_BLOCKS`) reports RSS -//! anon/file splits, accumulator composition, worker-kenv sizes, and -//! lazy-env cache hit rates per decile to guide tuning. +//! Progress telemetry (`IX_QUIET`, `IX_PROGRESS_MS`, `IX_LOG_BLOCKS`) +//! reports RSS anon/file splits, accumulator composition, worker-kenv +//! sizes, and lazy-env cache hit rates per decile. use std::panic::{AssertUnwindSafe, catch_unwind}; use std::sync::{ @@ -83,22 +74,18 @@ static IX_PROGRESS_MS: LazyLock = LazyLock::new(|| { .unwrap_or(2000) }); -/// Clear each worker's kernel env every N completed blocks (releasing -/// allocations), trading re-ingress CPU for bounded per-worker cache -/// growth. `0` (default) never clears — today's behavior. The kenv is -/// a pure cache of Lean-env-derived data (`ensure_in_kenv` re-ingresses -/// on demand), so clearing at block boundaries is semantics-free. -static IX_COMPILE_KENV_CLEAR_EVERY: LazyLock = LazyLock::new(|| { - std::env::var("IX_COMPILE_KENV_CLEAR_EVERY") - .ok() - .and_then(|s| s.parse().ok()) - .unwrap_or(0) -}); +/// Clear each worker's kernel env every this many completed blocks +/// (releasing allocations). The kenv is a pure cache of +/// Lean-env-derived data (`ensure_in_kenv` re-ingresses on demand), so +/// clearing at block boundaries is semantics-free; unbounded it grows +/// to several GB on Mathlib-scale envs, and this cadence measures at +/// zero wall-clock cost there. +const KENV_CLEAR_EVERY: usize = 64; /// `(VmRSS, RssAnon, RssFile)` of this process in KiB, from /// `/proc/self/status`. Anonymous memory can only leave RAM via swap; -/// file-backed RSS is reclaimable page cache — the split is what the -/// spill work changes, so the instrumentation reports both. +/// file-backed RSS is reclaimable page cache — the split tells the two +/// apart, so the instrumentation reports both. pub fn self_rss_kb() -> Option<(u64, u64, u64)> { let status = std::fs::read_to_string("/proc/self/status").ok()?; let field = |key: &str| { @@ -189,9 +176,9 @@ pub fn compile_env_with_options( options: CompileOptions, ) -> Result { let setup_start = Instant::now(); - // Fused whole-env scan: ref graph + immediate groundedness + - // inductive groups in one decode per constant (three separate sweeps - // triple the decode cost under IX_COMPILE_LEAN_ENV=lazy). + // Whole-env scan: ref graph + immediate groundedness + inductive + // groups in one decode per constant — the env decodes lazily, so + // each additional full sweep would decode every constant again. let phase_start = Instant::now(); let scan = setup_scan(lean_env.as_ref()); let graph = scan.graph; @@ -956,14 +943,9 @@ pub fn compile_env_with_options( condvar_ref.notify_one(); } - // Bounded per-worker kenv growth: drop the caches every N - // blocks when configured. Block boundary only — nothing - // holds kenv references across blocks; `ensure_in_kenv` - // re-ingresses on demand. + // Bounded per-worker kenv growth (see KENV_CLEAR_EVERY). worker_blocks_done += 1; - if *IX_COMPILE_KENV_CLEAR_EVERY > 0 - && worker_blocks_done % *IX_COMPILE_KENV_CLEAR_EVERY == 0 - { + if worker_blocks_done % KENV_CLEAR_EVERY == 0 { worker_kctx.kenv.clear_releasing_memory(); } @@ -1100,13 +1082,6 @@ pub fn compile_env_with_options( acc.bytes as f64 / (1024.0 * 1024.0), acc.materialized, ); - if let Some((file_bytes, segments, unsealed)) = stt.env.spill_stats() { - eprintln!( - "[compile_env] spill: {:.1} MiB file · {segments} segments sealed \ - · {unsealed} entries unsealed (heap)", - file_bytes as f64 / (1024.0 * 1024.0), - ); - } if let Some((hits, misses)) = lean_env.lazy_cache_stats() { let total = hits + misses; let hit_pct = diff --git a/crates/compile/src/graph.rs b/crates/compile/src/graph.rs index a5bd39e89..99111221e 100644 --- a/crates/compile/src/graph.rs +++ b/crates/compile/src/graph.rs @@ -59,11 +59,11 @@ pub struct SetupScan { /// Fused whole-env setup pass: one decode per constant feeding the ref /// graph, the groundedness check, and inductive-group collection. -/// Replaces three back-to-back full-env sweeps (`build_ref_graph`, +/// One pass instead of separate `build_ref_graph` / /// `ground_consts`' scan, `validate_lean_ind_flags`' scan) — under -/// `IX_COMPILE_LEAN_ENV=lazy` each sweep re-decodes every constant, so -/// fusing them cuts the setup decode count to a third. Outputs are -/// identical to the separate passes. +/// (the compile path'''s default) decodes a constant per access, so +/// visiting the whole env once instead of three times cuts the setup +/// decode count to a third. Outputs are identical to the separate passes. pub fn setup_scan(env: &Env) -> SetupScan { struct Acc { out_refs: RefMap, @@ -98,8 +98,7 @@ pub fn setup_scan(env: &Env) -> SetupScan { } // Members of one mutual family share the same `all`, so // first-wins insertion is value-identical regardless of which - // member lands first (the same invariant the unfused scan - // relied on). + // member lands first. if let ConstantInfo::InductInfo(v) = &*constant && let Some(first) = v.all.first() { diff --git a/crates/compile/src/ground.rs b/crates/compile/src/ground.rs index c6548ccbb..18f973f20 100644 --- a/crates/compile/src/ground.rs +++ b/crates/compile/src/ground.rs @@ -62,7 +62,7 @@ pub fn ground_consts( } /// Per-constant groundedness check, for callers that already hold a -/// decoded constant (the fused setup scan — see `graph::setup_scan`). +/// decoded constant and check as part of a wider pass. pub fn ground_const_check( constant: &ConstantInfo, env: &Env, diff --git a/crates/ffi/src/compile.rs b/crates/ffi/src/compile.rs index 1646499d4..43c98b235 100644 --- a/crates/ffi/src/compile.rs +++ b/crates/ffi/src/compile.rs @@ -194,7 +194,7 @@ pub extern "C" fn rs_compile_env_full( ) -> LeanIOResult { { // Phase 1: Decode Lean environment - let rust_env = crate::lean_env::decode_env_auto(env_consts_ptr); + let rust_env = crate::lean_env::decode_env(env_consts_ptr); let env_len = rust_env.len(); let rust_env = Arc::new(rust_env); @@ -305,7 +305,7 @@ pub extern "C" fn rs_compile_env( } }; rss_gib("at entry"); - let rust_env = crate::lean_env::decode_env_auto(env_consts_ptr); + let rust_env = crate::lean_env::decode_env_for_compile(env_consts_ptr); let rust_env = Arc::new(rust_env); rss_gib("after decode_env"); @@ -418,7 +418,7 @@ pub extern "C" fn rs_compile_env_to_file( } }; rss_gib("at entry"); - let rust_env = crate::lean_env::decode_env_auto(env_consts_ptr); + let rust_env = crate::lean_env::decode_env_for_compile(env_consts_ptr); let rust_env = Arc::new(rust_env); rss_gib("after decode_env"); @@ -485,7 +485,7 @@ pub extern "C" fn rs_compile_phases( env_consts_ptr: LeanList>, ) -> LeanIOResult { { - let rust_env = crate::lean_env::decode_env_auto(env_consts_ptr); + let rust_env = crate::lean_env::decode_env(env_consts_ptr); let env_len = rust_env.len(); let rust_env = Arc::new(rust_env); @@ -589,7 +589,7 @@ pub extern "C" fn rs_compile_env_to_ixon( env_consts_ptr: LeanList>, ) -> LeanIOResult { { - let rust_env = crate::lean_env::decode_env_auto(env_consts_ptr); + let rust_env = crate::lean_env::decode_env(env_consts_ptr); let rust_env = Arc::new(rust_env); let compile_stt = @@ -679,7 +679,7 @@ pub extern "C" fn rs_canonicalize_env_to_ix( env_consts_ptr: LeanList>, ) -> LeanIOResult { { - let rust_env = crate::lean_env::decode_env_auto(env_consts_ptr); + let rust_env = crate::lean_env::decode_env(env_consts_ptr); let mut cache = LeanBuildCache::with_capacity(rust_env.len()); let raw_env = LeanIxRawEnvironment::build(&mut cache, &rust_env); LeanIOResult::ok(raw_env) @@ -706,7 +706,7 @@ pub extern "C" fn rs_canonicalize_env_to_ix( pub extern "C" fn rs_leon_hashes( env_consts_ptr: LeanList>, ) -> LeanIOResult { - let rust_env = crate::lean_env::decode_env_auto(env_consts_ptr); + let rust_env = crate::lean_env::decode_env(env_consts_ptr); let mut cache = LeanBuildCache::with_capacity(rust_env.len()); let arr = LeanArray::alloc(rust_env.len()); diff --git a/crates/ffi/src/lean_env.rs b/crates/ffi/src/lean_env.rs index 5244bcaee..0f7077c3e 100644 --- a/crates/ffi/src/lean_env.rs +++ b/crates/ffi/src/lean_env.rs @@ -1124,30 +1124,22 @@ fn decode_name_constant_info( (name, constant_info) } -/// Decode dispatch for the compile FFI entries: eager (default) or, -/// under `IX_COMPILE_LEAN_ENV=lazy`, an on-demand view that avoids -/// materializing the full Rust copy of the environment -/// (`IX_COMPILE_LEAN_ENV_CACHE` bounds resident decoded constants — -/// see [`decode_env_lazy`]). -pub fn decode_env_auto(list: LeanList>) -> Env { - match std::env::var("IX_COMPILE_LEAN_ENV").as_deref() { - Ok("lazy") => { - let cache_entries = std::env::var("IX_COMPILE_LEAN_ENV_CACHE") - .ok() - .and_then(|s| s.parse::().ok()) - .filter(|&n| n > 0) - .unwrap_or(65536); - decode_env_lazy(list, cache_entries) - }, - Ok("eager") | Err(_) => decode_env(list), - Ok(other) => { - eprintln!( - "[ffi] IX_COMPILE_LEAN_ENV={other:?} not recognized \ - (expected eager|lazy); using eager" - ); - decode_env(list) - }, - } +/// Resident-decoded-constant bound for [`decode_env_lazy`]'s cache. +/// Wall time measured at parity with the eager decode at this size on +/// InitStd through Mathlib (the setup scan decodes each constant +/// exactly once regardless; compile-phase misses hide in the parallel +/// schedule), so there is nothing to tune — a bigger cache buys no +/// measured speed, a smaller one risks thrash. +const LAZY_ENV_CACHE_ENTRIES: usize = 65536; + +/// Decode for the production compile FFI entries: an on-demand view +/// that avoids materializing the full Rust copy of the environment — +/// the eager copy is the single largest memory term at Mathlib scale +/// (~25 GB) and compile wall time is at parity without it. Test and +/// roundtrip entries keep [`decode_env`] (they re-read the env +/// structurally throughout). +pub fn decode_env_for_compile(list: LeanList>) -> Env { + decode_env_lazy(list, LAZY_ENV_CACHE_ENTRIES) } /// Lazy variant of [`decode_env`]: decode only the *names* eagerly, diff --git a/crates/ixon/Cargo.toml b/crates/ixon/Cargo.toml index bfcd57726..d2c0183c4 100644 --- a/crates/ixon/Cargo.toml +++ b/crates/ixon/Cargo.toml @@ -19,7 +19,6 @@ tiny-keccak = { workspace = true } [target.'cfg(not(target_arch = "riscv64"))'.dependencies] dashmap = { workspace = true, features = ["rayon"] } rayon = { workspace = true } -tempfile = { workspace = true } [dev-dependencies] ix-common = { workspace = true, features = ["quickcheck"] } diff --git a/crates/ixon/src/env.rs b/crates/ixon/src/env.rs index 7c920b4ff..35f32283c 100644 --- a/crates/ixon/src/env.rs +++ b/crates/ixon/src/env.rs @@ -13,11 +13,11 @@ use super::lazy::LazyConstant; use super::map::IxonMap; use super::metadata::{ConstantMeta, ConstantMetaInfo}; -/// Metadata representation inside [`Named`]: structured (default) or -/// demoted to its self-contained serialized form -/// ([`ConstantMeta::put_raw`]), which costs a fraction of the -/// pointer-rich structured DAG and is decoded on demand. The demoted -/// form is chosen at registration under `IX_COMPILE_META=demote`. +/// Metadata representation inside [`Named`]: structured, or demoted to +/// its self-contained serialized form ([`ConstantMeta::put_raw`]), +/// which costs a fraction of the pointer-rich structured DAG and is +/// decoded on demand. The demoted form is chosen at registration under +/// [`DEMOTE`] (the default). #[derive(Clone, Debug)] enum MetaRepr { Structured(Arc), @@ -173,60 +173,19 @@ pub struct LazyNamed { pub hint: Option, } -/// Compile-accumulator spill mode, parsed once from `IX_COMPILE_SPILL`. -/// -/// - `Off` (default): `store_const` keeps a materialized `Arc` -/// cache next to the serialized bytes. -/// - `Demote`: `store_const` stores bytes only; `get_const` re-parses per -/// access (the lazy-load policy — see `LazyConstant` docs). -/// - `Mmap`: demote plus spilling the bytes to a file-backed mapping -/// (see the `spill` module docs). -/// -/// Host-only: the guest builds `Env` via deserialization and never calls -/// `store_const`. -#[cfg(not(target_arch = "riscv64"))] -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum SpillMode { - Off, - Demote, - Mmap, -} - -/// `IX_COMPILE_META=demote` stores registered names' metadata as -/// serialized bytes instead of structured `ConstantMeta` (see -/// [`Named::demote`]). Default: structured — today's behavior. -#[cfg(not(target_arch = "riscv64"))] -pub static META_DEMOTE: std::sync::LazyLock = - std::sync::LazyLock::new(|| { - match std::env::var("IX_COMPILE_META").as_deref() { - Ok("demote") => true, - Ok("structured") | Err(_) => false, - Ok(other) => { - eprintln!( - "[ixon] IX_COMPILE_META={other:?} not recognized \ - (expected structured|demote); using structured" - ); - false - }, - } - }); - +/// `IX_COMPILE_DEMOTE` (default **on**; set `0` to disable): store the +/// compile accumulator's constants and registered names' metadata as +/// serialized bytes instead of keeping the structured forms resident. +/// The structured forms cost a large multiple of their encodings +/// (~20× measured) and compilation never reads them back; the `.ixe` +/// output is byte-identical either way. Disabling trades that RAM for +/// materialized-on-store caches, which only helps in-process flows +/// that re-read the env structurally after compiling (`ix check` / +/// `ix validate`). #[cfg(not(target_arch = "riscv64"))] -pub static SPILL_MODE: std::sync::LazyLock = - std::sync::LazyLock::new(|| { - match std::env::var("IX_COMPILE_SPILL").as_deref() { - Ok("demote") => SpillMode::Demote, - Ok("mmap") => SpillMode::Mmap, - Ok("off") | Err(_) => SpillMode::Off, - Ok(other) => { - eprintln!( - "[ixon] IX_COMPILE_SPILL={other:?} not recognized \ - (expected off|demote|mmap); using off" - ); - SpillMode::Off - }, - } - }); +pub static DEMOTE: std::sync::LazyLock = std::sync::LazyLock::new(|| { + std::env::var("IX_COMPILE_DEMOTE").as_deref() != Ok("0") +}); /// Composition of [`Env::consts`], from [`Env::const_cache_stats`]. #[derive(Clone, Copy, Debug, Default)] @@ -287,12 +246,6 @@ pub struct Env { /// supplying them in anon mode does not relax the kernel's /// metadata-free correctness model. pub anon_hints: FxHashMap, - /// Spill file state for `IX_COMPILE_SPILL=mmap` (see the `spill` - /// module docs). `Unopened` until the first spill-mode `store_const`. - /// Not carried by `Clone` — a cloned env starts a fresh spill file; - /// its mmap-backed entries keep their `Arc` windows regardless. - #[cfg(not(target_arch = "riscv64"))] - pub(crate) spill: std::sync::Mutex, } impl Env { @@ -304,8 +257,6 @@ impl Env { names: IxonMap::new(), comms: IxonMap::new(), anon_hints: FxHashMap::default(), - #[cfg(not(target_arch = "riscv64"))] - spill: Default::default(), } } @@ -329,9 +280,9 @@ impl Env { /// Store a structured constant under `addr`. /// - /// Serializes the constant once. In the default [`SpillMode::Off`], + /// Serializes the constant once. With demotion disabled, /// the [`LazyConstant`] cache is pre-populated so `get_const` is - /// free; under `IX_COMPILE_SPILL=demote|mmap` the structured value is + /// free; under [`DEMOTE`] (the default) the structured value is /// dropped and the entry costs only its bytes (`get_const` re-parses /// per access). Compilation never reads stored constants back, so the /// modes differ only in memory footprint and later readers' CPU cost. @@ -339,112 +290,31 @@ impl Env { /// Host-only — see `store_blob`. #[cfg(not(target_arch = "riscv64"))] pub fn store_const(&self, addr: Address, constant: Constant) { - self.store_const_with_mode(addr, constant, *SPILL_MODE); + self.store_const_demoted(addr, constant, *DEMOTE); } - /// `store_const` with an explicit mode, bypassing `IX_COMPILE_SPILL`. - /// Exists so tests can drive `Demote`/`Mmap` without process-global - /// env-var races. + /// `store_const` with an explicit demotion choice, bypassing + /// `IX_COMPILE_DEMOTE`. Exists so tests can drive both reprs without + /// process-global env-var races. #[cfg(not(target_arch = "riscv64"))] - pub fn store_const_with_mode( + pub fn store_const_demoted( &self, addr: Address, constant: Constant, - mode: SpillMode, + demote: bool, ) { - match mode { - SpillMode::Off => { - self.consts.insert(addr, LazyConstant::from_constant(constant)); - }, - // In the spill modes a re-store of an existing address is a no-op: - // content-addressing guarantees identical bytes, re-inserting - // would downgrade an already-sealed mmap window back to heap, and - // re-appending would duplicate the bytes in the spill file. - // (Alpha-collapsed blocks re-store the shared address once per - // member.) `Off` keeps insert-overwrite: there a re-store can - // upgrade a cache-less lazy-loaded entry to a cached one. - SpillMode::Demote => { - if self.consts.contains_key(&addr) { - return; - } - self - .consts - .insert(addr, LazyConstant::from_constant_uncached(constant)); - }, - SpillMode::Mmap => { - if self.consts.contains_key(&addr) { - return; - } - let mut buf = Vec::new(); - constant.put(&mut buf); - let bytes: Arc<[u8]> = buf.into(); - // Heap entry first so the address is immediately readable; the - // spill seal swaps it to an mmap window later. - self - .consts - .insert(addr.clone(), LazyConstant::from_bytes(bytes.clone())); - self.spill_append(addr, &bytes); - }, - } - } - - /// Append one entry to the spill file, sealing (and swapping the - /// sealed entries to mmap windows) when a segment fills. Any I/O - /// error disables spilling for this env: heap-backed entries remain - /// valid, sealed windows keep their mappings. - #[cfg(not(target_arch = "riscv64"))] - fn spill_append(&self, addr: Address, bytes: &[u8]) { - use crate::spill::{SpillSlot, SpillState}; - let mut slot = self.spill.lock().unwrap(); - if matches!(*slot, SpillSlot::Unopened) { - match SpillState::create() { - Ok(st) => *slot = SpillSlot::Active(st), - Err(e) => { - eprintln!( - "[ixon] spill file creation failed ({e}); \ - falling back to heap-backed entries" - ); - *slot = SpillSlot::Disabled; - }, + if demote { + // A re-store of an existing address is a no-op: content + // addressing guarantees identical bytes. (Alpha-collapsed blocks + // re-store the shared address once per member.) The cached mode + // keeps insert-overwrite: there a re-store can upgrade a + // cache-less lazy-loaded entry to a cached one. + if self.consts.contains_key(&addr) { + return; } - } - let SpillSlot::Active(st) = &mut *slot else { return }; - match st.append(addr, bytes) { - Ok(None) => {}, - Ok(Some((mmap, entries))) => { - if std::env::var("IX_QUIET").is_err() { - eprintln!( - "[ixon] spill segment {} sealed: {} entries, file {:.1} MiB", - st.segments_sealed, - entries.len(), - st.file_len() as f64 / (1024.0 * 1024.0), - ); - } - for (a, off, len) in entries { - self - .consts - .insert(a, LazyConstant::from_mmap_slice(mmap.clone(), off, len)); - } - }, - Err(e) => { - eprintln!( - "[ixon] spill write failed ({e}); \ - falling back to heap-backed entries" - ); - *slot = SpillSlot::Disabled; - }, - } - } - - /// Spill file observability: `(file_bytes, segments_sealed, - /// unsealed_entry_count)`, or `None` if spilling never activated. - #[cfg(not(target_arch = "riscv64"))] - pub fn spill_stats(&self) -> Option<(u64, usize, usize)> { - match &*self.spill.lock().unwrap() { - crate::spill::SpillSlot::Active(st) => { - Some((st.file_len(), st.segments_sealed, st.pending_count())) - }, - _ => None, + self.consts.insert(addr, LazyConstant::from_constant_uncached(constant)); + } else { + self.consts.insert(addr, LazyConstant::from_constant(constant)); } } @@ -494,14 +364,14 @@ impl Env { self.consts.get(addr).map(|r| Arc::from(r.value().raw_bytes())) } - /// Register a named constant. Under `IX_COMPILE_META=demote` the + /// Register a named constant. Under [`DEMOTE`] (the default) the /// entry's metadata is stored in its serialized-bytes form (see /// [`Named::demote`]) — the structured DAG costs a large multiple of /// its encoding and compilation never reads it back. /// Host-only — see `store_blob`. #[cfg(not(target_arch = "riscv64"))] pub fn register_name(&self, name: Name, mut named: Named) { - if *META_DEMOTE { + if *DEMOTE { named.demote(); } self.named.insert(name, named); @@ -672,10 +542,6 @@ impl Clone for Env { names, comms, anon_hints: self.anon_hints.clone(), - // A cloned env starts a fresh spill file; already-sealed mmap - // windows travel inside the cloned LazyConstants. - #[cfg(not(target_arch = "riscv64"))] - spill: Default::default(), } } } @@ -732,29 +598,16 @@ mod tests { /// Preset an Active spill state with a tiny segment so a handful of /// stores force seals (bypasses the env-var-driven `SpillState::create`). - fn preset_tiny_spill(env: &Env, segment_bytes: usize) { - let dir = std::env::temp_dir(); - *env.spill.lock().unwrap() = crate::spill::SpillSlot::Active( - crate::spill::SpillState::create_in(dir.to_str().unwrap(), segment_bytes) - .unwrap(), - ); - } - #[test] - fn store_const_mmap_seals_segments_and_roundtrips() { + fn store_const_demoted_roundtrips_uncached() { let env = Env::new(); - preset_tiny_spill(&env, 256); let mut stored = Vec::new(); for i in 0..64 { let c = axiom_with_lvls(i); let (addr, _) = c.commit(); - env.store_const_with_mode(addr.clone(), c.clone(), SpillMode::Mmap); + env.store_const_demoted(addr.clone(), c.clone(), true); stored.push((addr, c)); } - let (file_bytes, segments, unsealed) = env.spill_stats().unwrap(); - assert!(segments >= 1, "no segment sealed (file {file_bytes}B)"); - assert!(unsealed < 64, "nothing was sealed"); - // Every entry — mmap-backed or still-heap — verifies and roundtrips. for (addr, c) in &stored { let entry = env.consts.get(addr).unwrap(); assert!(entry.value().verify_address(addr)); @@ -798,26 +651,19 @@ mod tests { } #[test] - fn env_put_identical_across_spill_modes() { - let build = |mode: SpillMode, tiny_spill: bool| { + fn env_put_identical_across_demotion() { + let build = |demote: bool| { let env = Env::new(); - if tiny_spill { - preset_tiny_spill(&env, 128); - } for i in 0..32 { let c = axiom_with_lvls(i); let (addr, _) = c.commit(); - env.store_const_with_mode(addr, c, mode); + env.store_const_demoted(addr, c, demote); } let mut buf = Vec::new(); env.put(&mut buf).unwrap(); buf }; - let off = build(SpillMode::Off, false); - let demote = build(SpillMode::Demote, false); - let mmap = build(SpillMode::Mmap, true); - assert_eq!(off, demote); - assert_eq!(off, mmap); + assert_eq!(build(false), build(true)); } #[test] diff --git a/crates/ixon/src/lazy.rs b/crates/ixon/src/lazy.rs index c2fbb9b1a..0b2630993 100644 --- a/crates/ixon/src/lazy.rs +++ b/crates/ixon/src/lazy.rs @@ -142,9 +142,9 @@ impl LazyConstant { /// Like [`Self::from_constant`] but drops the structured value after /// serializing (`cache: None`), so the entry costs only its bytes and - /// `get()` re-parses per access — the same policy as [`Self::from_bytes`]. - /// Used by `Env::store_const` when `IX_COMPILE_SPILL` demotes the - /// compile accumulator to bytes. + /// `get()` re-parses per access — the same policy as + /// [`Self::from_bytes`]. The demoted accumulator repr (see + /// `env::DEMOTE`). pub fn from_constant_uncached(c: Constant) -> Self { let mut buf = Vec::new(); c.put(&mut buf); diff --git a/crates/ixon/src/lib.rs b/crates/ixon/src/lib.rs index 9825b27e5..84a13addb 100644 --- a/crates/ixon/src/lib.rs +++ b/crates/ixon/src/lib.rs @@ -19,8 +19,6 @@ pub mod metadata; pub mod proof; pub mod serialize; pub mod sharing; -#[cfg(not(target_arch = "riscv64"))] -pub(crate) mod spill; pub mod tag; pub mod univ; diff --git a/crates/ixon/src/metadata.rs b/crates/ixon/src/metadata.rs index 4ac1c972c..c126fafb0 100644 --- a/crates/ixon/src/metadata.rs +++ b/crates/ixon/src/metadata.rs @@ -278,7 +278,7 @@ impl ConstantMeta { /// Self-contained encoding: name references as raw 32-byte addresses, /// no index required. This is the demoted in-memory form - /// (`IX_COMPILE_META=demote`), NOT the `.ixe` named-section encoding — + /// (see `env::DEMOTE`), NOT the `.ixe` named-section encoding — /// `Env::put` re-encodes through the name index. pub fn put_raw(&self, buf: &mut Vec) -> Result<(), String> { self.put_with(NamePut::Raw, buf) @@ -644,7 +644,7 @@ pub type NameReverseIndex = Vec
; /// How name references are written: compressed through the env-level /// name index (the `.ixe` named-section form), or as raw 32-byte /// addresses — a self-contained encoding that needs no index, used by -/// the demoted in-memory metadata form (`IX_COMPILE_META=demote`). +/// the demoted in-memory metadata form (see `env::DEMOTE`). #[derive(Clone, Copy)] pub enum NamePut<'a> { Indexed(&'a NameIndex), diff --git a/crates/ixon/src/serialize.rs b/crates/ixon/src/serialize.rs index d4efaf72e..9f32fa02d 100644 --- a/crates/ixon/src/serialize.rs +++ b/crates/ixon/src/serialize.rs @@ -1379,7 +1379,7 @@ impl Env { /// is ~env size). Returns the byte count written. /// /// MUST remain byte-identical with [`Env::put`] — checked by the - /// `put_file_matches_put` test and the A/B `cmp` oracle. Any format + /// `put_file_matches_put` test. Any format /// change lands in both or not at all. #[cfg(not(target_arch = "riscv64"))] pub fn put_file(&self, path: &std::path::Path) -> Result { @@ -1429,8 +1429,7 @@ impl Env { } // Section 2: Consts — the dominant bytes; raw_bytes stream straight - // through (under IX_COMPILE_SPILL=mmap this is page cache → page - // cache). + // through with no intermediate copy of the constant bodies let sec_start = std::time::Instant::now(); put_u64(const_addrs.len() as u64, &mut buf); for addr in &const_addrs { diff --git a/crates/ixon/src/spill.rs b/crates/ixon/src/spill.rs deleted file mode 100644 index c32f76c36..000000000 --- a/crates/ixon/src/spill.rs +++ /dev/null @@ -1,170 +0,0 @@ -//! File-backed spilling of the compile accumulator's serialized bytes. -//! -//! Under `IX_COMPILE_SPILL=mmap`, `Env::store_const` appends each -//! constant's bytes to an **anonymous temp file** (`tempfile::tempfile_in` -//! — `O_TMPFILE` on Linux, so no pathname ever exists and the kernel -//! reclaims the space when the last fd/mapping drops, even on SIGKILL). -//! The file is sealed in fixed segments: when the unsealed region -//! exceeds the segment size, that range is mmapped read-only and every -//! entry in it is swapped from its heap `BytesSource` to a window into -//! the mapping (`LazyConstant::from_mmap_slice`). Sealed pages are clean -//! file-backed page cache — the kernel can evict them under memory -//! pressure with no swap configured — so the accumulator's resident -//! heap is bounded by one unsealed segment. -//! -//! The spill directory must be **disk-backed**: tmpfs (`/tmp` on most -//! distros) and `memfd_create` are shmem, whose pages are swap-backed -//! anonymous memory and cannot be evicted under `MemorySwapMax=0`, -//! silently defeating the spill. Hence the default is the current -//! working directory, overridable via `IX_COMPILE_SPILL_DIR`. -//! -//! Fixed sealed segments (rather than one growing mapping) keep every -//! window's lifetime trivially correct: remapping on growth would -//! invalidate outstanding windows. - -use std::fs::File; -use std::io::Write; -use std::sync::Arc; - -use memmap2::{Mmap, MmapOptions}; - -use ix_common::address::Address; - -/// Segment start alignment. Mmap offsets must be page-aligned; 64 KiB -/// covers every Linux page size in use (4k / 16k / 64k). -const SEGMENT_ALIGN: usize = 64 * 1024; - -/// Default sealed-segment size. Overridable via -/// `IX_COMPILE_SPILL_SEGMENT_MB` (minimum 1). -const DEFAULT_SEGMENT_SIZE: usize = 256 * 1024 * 1024; - -/// Spill lifecycle slot held by `Env`. `Unopened` until the first -/// spill-mode `store_const`; `Disabled` after any I/O error (already -/// heap-backed entries remain valid, later stores stay heap-backed). -#[derive(Debug, Default)] -pub(crate) enum SpillSlot { - #[default] - Unopened, - Active(SpillState), - Disabled, -} - -/// One sealed segment: the read-only mapping plus the entries it -/// contains as `(addr, offset_within_mapping, len)`. -pub(crate) type SealedSegment = (Arc, Vec<(Address, usize, usize)>); - -/// Staged bytes are flushed to the file once this much accumulates, so -/// the caller's lock hold per append is a memcpy, not a syscall — the -/// store path is called from every scheduler worker and a per-append -/// `write` measurably convoys them. -const FLUSH_SIZE: usize = 8 * 1024 * 1024; - -#[derive(Debug)] -pub(crate) struct SpillState { - file: File, - /// Appended but not yet written to the file. - staging: Vec, - /// Next virtual write offset (file bytes + staging bytes). - offset: usize, - /// Start of the unsealed segment; `SEGMENT_ALIGN`-aligned. - segment_start: usize, - /// `(addr, virtual_offset, len)` of entries in the unsealed segment. - pending: Vec<(Address, usize, usize)>, - segment_size: usize, - pub(crate) segments_sealed: usize, -} - -impl SpillState { - pub(crate) fn create() -> std::io::Result { - let dir = - std::env::var("IX_COMPILE_SPILL_DIR").unwrap_or_else(|_| ".".to_string()); - let segment_size = std::env::var("IX_COMPILE_SPILL_SEGMENT_MB") - .ok() - .and_then(|s| s.parse::().ok()) - .filter(|&n| n > 0) - .map(|n| n * 1024 * 1024) - .unwrap_or(DEFAULT_SEGMENT_SIZE); - Self::create_in(&dir, segment_size) - } - - pub(crate) fn create_in( - dir: &str, - segment_size: usize, - ) -> std::io::Result { - let file = tempfile::tempfile_in(dir)?; - Ok(SpillState { - file, - staging: Vec::with_capacity(FLUSH_SIZE), - offset: 0, - segment_start: 0, - pending: Vec::new(), - segment_size, - segments_sealed: 0, - }) - } - - /// Write staged bytes through to the file. - fn flush(&mut self) -> std::io::Result<()> { - if !self.staging.is_empty() { - self.file.write_all(&self.staging)?; - self.staging.clear(); - } - Ok(()) - } - - /// Append one entry's bytes (a memcpy into staging; the file write is - /// amortized to every `FLUSH_SIZE`). When this fills the segment, - /// seal it: flush, pad so the next segment starts aligned, mmap the - /// sealed range, and return it with its entries (offsets rebased to - /// the mapping) for the caller to swap into the consts map. - pub(crate) fn append( - &mut self, - addr: Address, - bytes: &[u8], - ) -> std::io::Result> { - self.staging.extend_from_slice(bytes); - self.pending.push((addr, self.offset, bytes.len())); - self.offset += bytes.len(); - - if self.offset - self.segment_start < self.segment_size { - if self.staging.len() >= FLUSH_SIZE { - self.flush()?; - } - return Ok(None); - } - - let data_end = self.offset; - let next_start = data_end.div_ceil(SEGMENT_ALIGN) * SEGMENT_ALIGN; - self.staging.resize(self.staging.len() + (next_start - data_end), 0); - self.offset = next_start; - self.flush()?; - // Safety: the fd is a private anonymous temp file no other process - // can open or truncate; write() and mmap go through the same page - // cache on Linux, so the sealed range reads back what was written. - let mmap = unsafe { - MmapOptions::new() - .offset(self.segment_start as u64) - .len(data_end - self.segment_start) - .map(&self.file)? - }; - let seg_start = self.segment_start; - self.segment_start = next_start; - self.segments_sealed += 1; - let entries = std::mem::take(&mut self.pending) - .into_iter() - .map(|(a, off, len)| (a, off - seg_start, len)) - .collect(); - Ok(Some((Arc::new(mmap), entries))) - } - - /// Entries appended but not yet sealed into a mapping. - pub(crate) fn pending_count(&self) -> usize { - self.pending.len() - } - - /// Virtual spill size: data plus alignment padding, including staged - /// bytes not yet written through. - pub(crate) fn file_len(&self) -> u64 { - self.offset as u64 - } -} diff --git a/crates/kernel/src/ingress.rs b/crates/kernel/src/ingress.rs index 0ca09d8c2..b7349bd0b 100644 --- a/crates/kernel/src/ingress.rs +++ b/crates/kernel/src/ingress.rs @@ -3686,9 +3686,7 @@ fn drop_ixon_env(ixon_env: IxonEnv, quiet: bool) { // `anon_hints` is a small FxHashMap (one entry per Def from the .ixe's // Named metadata); dropping it inline alongside the bookkeeping below // is negligible compared to the DashMap dropdance. - // `..` also drops the env's private spill slot (closes the spill fd; - // sealed mmap windows live on inside the consts entries until those - // drop below). + // `..` covers the env's private fields. let IxonEnv { consts, named, blobs, names, comms, anon_hints: _, .. } = ixon_env; let consts_len = consts.len(); From a29a34ef77cdd0c67402f3efc9f8275ce813f3f9 Mon Sep 17 00:00:00 2001 From: samuelburnham <45365069+samuelburnham@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:49:34 -0400 Subject: [PATCH 10/19] Compile FFI: rs_compile_env compiles straight to a file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ByteArray-returning variant had no callers — every consumer of a compiled env reads .ixe files or content addresses — so the streaming implementation takes over the rs_compile_env symbol and the rsCompileEnvBytes/rsCompileEnvBytesFFI names, now taking the output path and returning the byte count. --- Ix/Cli/CompileCmd.lean | 2 +- Ix/CompileM.lean | 24 ++++--- crates/ffi/src/compile.rs | 131 +++----------------------------------- 3 files changed, 22 insertions(+), 135 deletions(-) diff --git a/Ix/Cli/CompileCmd.lean b/Ix/Cli/CompileCmd.lean index 4983d9ba9..c9c2464ff 100644 --- a/Ix/Cli/CompileCmd.lean +++ b/Ix/Cli/CompileCmd.lean @@ -141,7 +141,7 @@ def runCompileCmd (p : Cli.Parsed) : IO UInt32 := do -- through `Ixon.Env::get`, so later runs (e.g. `ix check-ixon`) can -- skip the Lean → IxOn compile step. let start ← IO.monoMsNow - let size ← Ix.CompileM.rsCompileEnvToFileFFI constList outPath + let size ← Ix.CompileM.rsCompileEnvBytesFFI constList outPath let elapsed := (← IO.monoMsNow) - start println! "Compiled and wrote {fmtBytes size} env to {outPath} in {elapsed.formatMs}" IO.println s!"##benchmark## {elapsed} {size} {totalConsts}" diff --git a/Ix/CompileM.lean b/Ix/CompileM.lean index 8e1563f5a..701608a55 100644 --- a/Ix/CompileM.lean +++ b/Ix/CompileM.lean @@ -1916,17 +1916,13 @@ def compileEnvParallel (env : Ix.Environment) (blocks : Ix.CondensedBlocks) /-! ## Rust Compilation FFI -/ -/-- FFI: Compile a Lean environment to serialized Ixon.Env bytes using Rust. -/ -@[extern "rs_compile_env"] -opaque rsCompileEnvBytesFFI : @& List (Lean.Name × Lean.ConstantInfo) → IO ByteArray - /-- FFI: Compile a Lean environment and write the serialized Ixon.Env - straight to `outPath` from Rust (streamed; no env-sized ByteArray - crosses the FFI). Writes to `.tmp` then renames, so a crash - cannot leave a truncated file. Returns the byte count written. - Byte-identical to writing `rsCompileEnvBytesFFI`'s result. -/ -@[extern "rs_compile_env_to_file"] -opaque rsCompileEnvToFileFFI + bytes straight to `outPath` from Rust (streamed; no env-sized + ByteArray crosses the FFI). Writes to `.tmp` then renames, + so a crash cannot leave a truncated file. Returns the byte count + written. -/ +@[extern "rs_compile_env"] +opaque rsCompileEnvBytesFFI : @& List (Lean.Name × Lean.ConstantInfo) → @& String → IO Nat /-- FFI: 8-phase validation of the aux_gen compile pipeline (compile + @@ -1941,10 +1937,12 @@ opaque rsCompileEnvToFileFFI opaque rsCompileValidateAuxFFI : @& List (Lean.Name × Lean.ConstantInfo) → USize -/-- Compile a Lean environment to Ixon.Env bytes using the Rust compiler. -/ -def rsCompileEnvBytes (leanEnv : Lean.Environment) : IO ByteArray := do +/-- Compile a Lean environment and write the serialized Ixon.Env bytes + to `outPath` using the Rust compiler. Returns the byte count. -/ +def rsCompileEnvBytes (leanEnv : Lean.Environment) (outPath : String) + : IO Nat := do let constList := leanEnv.constants.toList - rsCompileEnvBytesFFI constList + rsCompileEnvBytesFFI constList outPath -- Re-export RawEnv types from Ixon for backwards compatibility export Ixon (RawConst RawNamed RawBlob RawComm RawEnv) diff --git a/crates/ffi/src/compile.rs b/crates/ffi/src/compile.rs index 43c98b235..c0a9f8f0d 100644 --- a/crates/ffi/src/compile.rs +++ b/crates/ffi/src/compile.rs @@ -1,7 +1,7 @@ //! FFI bridge between Lean and Rust for the Ixon compilation/decompilation pipeline. //! //! Provides `extern "C"` functions callable from Lean via `@[extern]`: -//! - `rs_compile_env_full` / `rs_compile_env`: compile a Lean environment to Ixon +//! - `rs_compile_env` / `rs_compile_env_full`: compile a Lean environment to Ixon //! - `rs_compile_phases`: run individual pipeline phases (canon, condense, graph, compile) //! - `rs_decompile_env`: decompile Ixon back to Lean environment //! - `rs_roundtrip_*`: roundtrip FFI tests for Lean↔Rust type conversions @@ -283,125 +283,14 @@ pub extern "C" fn rs_compile_env_full( } } -/// FFI function to compile a Lean environment to serialized Ixon.Env bytes. -#[unsafe(no_mangle)] -pub extern "C" fn rs_compile_env( - env_consts_ptr: LeanList>, -) -> LeanIOResult { - { - let quiet = std::env::var("IX_QUIET").is_ok(); - // RSS here ≈ the Lean-side floor (imports + elaborated env); the - // post-decode delta is the owned Rust copy of the environment. - let rss_gib = |label: &str| { - if !quiet - && let Some((vm, anon, file)) = ix_compile::compile::self_rss_kb() - { - eprintln!( - "[rs_compile_env] rss {label}: {:.1} GiB (anon {:.1}, file {:.1})", - vm as f64 / (1024.0 * 1024.0), - anon as f64 / (1024.0 * 1024.0), - file as f64 / (1024.0 * 1024.0), - ); - } - }; - rss_gib("at entry"); - let rust_env = crate::lean_env::decode_env_for_compile(env_consts_ptr); - let rust_env = Arc::new(rust_env); - rss_gib("after decode_env"); - - let compile_stt = - match compile_env_with_options(&rust_env, CompileOptions::default()) { - Ok(stt) => stt, - Err(e) => { - let msg = format!("rs_compile_env: Rust compilation failed: {:?}", e); - return LeanIOResult::error_string(&msg); - }, - }; - - // Serialize the compiled Env to bytes - if !quiet { - eprintln!("[rs_compile_env] starting serialization"); - } - let ser_start = std::time::Instant::now(); - let mut buf = Vec::new(); - if let Err(e) = compile_stt.env.put(&mut buf) { - let msg = format!("rs_compile_env: Env serialization failed: {}", e); - return LeanIOResult::error_string(&msg); - } - if !quiet { - eprintln!( - "[rs_compile_env] serialization done in {:.1}s: {} bytes", - ser_start.elapsed().as_secs_f64(), - buf.len(), - ); - } - - // Build Lean ByteArray - if !quiet { - eprintln!( - "[rs_compile_env] building Lean ByteArray ({} bytes)", - buf.len() - ); - } - let ba_start = std::time::Instant::now(); - let ba = LeanByteArray::from_bytes(&buf); - if !quiet { - eprintln!( - "[rs_compile_env] ByteArray built in {:.1}s", - ba_start.elapsed().as_secs_f64(), - ); - } - - // Skip destructors on the CLI path. `rs_compile_env` is called from - // one-shot commands (lake exe ix compile, serve/connect init) where the - // process exits shortly after returning the ByteArray. Running ~millions - // of Arc chain-drops serially across DashMap shards costs 70+ - // seconds of wall time on Mathlib and accomplishes nothing — the OS - // reclaims the allocations instantly at process exit. - // - // Safety: `mem::forget` on `Arc` leaks one strong refcount; the - // underlying allocation is never freed but also never accessed. The - // `LeanEnv` inside `rust_env` was decoded into owned Rust data (no - // borrow lifetimes from Lean), so there's no UB risk from leaking it. - // - // Escape hatch: set `IX_SKIP_DROPS=0` to run destructors (for tests - // that assert clean teardown; not used by any production path). - if std::env::var("IX_SKIP_DROPS").ok().as_deref() != Some("0") { - if !quiet { - eprintln!("[rs_compile_env] skipping destructors (IX_SKIP_DROPS)"); - } - std::mem::forget(compile_stt); - std::mem::forget(rust_env); - std::mem::forget(buf); - } else { - if !quiet { - eprintln!("[rs_compile_env] running destructors (IX_SKIP_DROPS=0)"); - } - let drop_start = std::time::Instant::now(); - drop(buf); - drop(compile_stt); - drop(rust_env); - if !quiet { - eprintln!( - "[rs_compile_env] destructors done in {:.2}s", - drop_start.elapsed().as_secs_f64(), - ); - } - } - if !quiet { - eprintln!("[rs_compile_env] returning ByteArray to Lean"); - } - LeanIOResult::ok(ba) - } -} - /// FFI: compile a Lean environment and stream the serialized Ixon.Env /// straight to `out_path` (see `Env::put_file`) — no env-sized `Vec` or /// Lean `ByteArray` is built. Writes `.tmp`, then renames, so /// a crash cannot leave a truncated file. Returns bytes written (Nat). -/// Byte-identical output to `rs_compile_env` + `IO.FS.writeBinFile`. +/// The file is the canonical `Env::put` encoding (see `put_file`'s +/// equivalence test). #[unsafe(no_mangle)] -pub extern "C" fn rs_compile_env_to_file( +pub extern "C" fn rs_compile_env( env_consts_ptr: LeanList>, out_path: LeanString>, ) -> LeanIOResult { @@ -410,7 +299,7 @@ pub extern "C" fn rs_compile_env_to_file( if !quiet && let Some((vm, anon, file)) = ix_compile::compile::self_rss_kb() { eprintln!( - "[rs_compile_env_to_file] rss {label}: {:.1} GiB (anon {:.1}, file {:.1})", + "[rs_compile_env] rss {label}: {:.1} GiB (anon {:.1}, file {:.1})", vm as f64 / (1024.0 * 1024.0), anon as f64 / (1024.0 * 1024.0), file as f64 / (1024.0 * 1024.0), @@ -426,8 +315,7 @@ pub extern "C" fn rs_compile_env_to_file( match compile_env_with_options(&rust_env, CompileOptions::default()) { Ok(stt) => stt, Err(e) => { - let msg = - format!("rs_compile_env_to_file: Rust compilation failed: {:?}", e); + let msg = format!("rs_compile_env: Rust compilation failed: {:?}", e); return LeanIOResult::error_string(&msg); }, }; @@ -442,14 +330,14 @@ pub extern "C" fn rs_compile_env_to_file( Ok(n) => n, Err(e) => { std::fs::remove_file(&tmp).ok(); - let msg = format!("rs_compile_env_to_file: serialization failed: {e}"); + let msg = format!("rs_compile_env: serialization failed: {e}"); return LeanIOResult::error_string(&msg); }, }; if let Err(e) = std::fs::rename(&tmp, &path) { std::fs::remove_file(&tmp).ok(); let msg = format!( - "rs_compile_env_to_file: rename {} -> {}: {e}", + "rs_compile_env: rename {} -> {}: {e}", tmp.display(), path.display() ); @@ -457,7 +345,8 @@ pub extern "C" fn rs_compile_env_to_file( } rss_gib("after put_file"); - // Same destructor skip as `rs_compile_env` — one-shot CLI process. + // Skip destructors: one-shot CLI process; the OS reclaims at exit and + // dropping the maps costs tens of seconds at Mathlib scale. if std::env::var("IX_SKIP_DROPS").ok().as_deref() != Some("0") { std::mem::forget(compile_stt); std::mem::forget(rust_env); From d25fba325246c10809cb8bdbb8128e6ef0707e2b Mon Sep 17 00:00:00 2001 From: samuelburnham <45365069+samuelburnham@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:04:30 -0400 Subject: [PATCH 11/19] Compile: decode-time counters for the lazy env LazyEnvStats gains thread-summed durations for miss decodes and cache-bypassing iter sweeps, reported in the compile completion log next to the hit/miss counters, to size decode CPU against wall time. --- crates/common/src/env.rs | 51 +++++++++++++++++++++++++++---- crates/compile/src/compile/env.rs | 17 ++++++++--- 2 files changed, 57 insertions(+), 11 deletions(-) diff --git a/crates/common/src/env.rs b/crates/common/src/env.rs index 7ba903688..bc7424564 100644 --- a/crates/common/src/env.rs +++ b/crates/common/src/env.rs @@ -1513,6 +1513,24 @@ pub struct LazyEnv { cap_per_shard: usize, hits: std::sync::atomic::AtomicU64, misses: std::sync::atomic::AtomicU64, + /// Nanoseconds spent inside `fetch` on cache misses (summed across + /// threads, so it can exceed wall time under parallelism). + miss_nanos: std::sync::atomic::AtomicU64, + /// Nanoseconds spent inside `fetch` during cache-bypassing `iter` + /// sweeps (same summed-across-threads caveat). + iter_nanos: std::sync::atomic::AtomicU64, +} + +/// [`LazyEnv`] instrumentation counters, from [`Env::lazy_cache_stats`]. +#[cfg(not(target_arch = "riscv64"))] +#[derive(Clone, Copy, Debug)] +pub struct LazyEnvStats { + pub hits: u64, + pub misses: u64, + /// Thread-summed time inside miss decodes. + pub miss_decode: std::time::Duration, + /// Thread-summed time inside `iter` sweep decodes. + pub iter_decode: std::time::Duration, } #[cfg(not(target_arch = "riscv64"))] @@ -1538,7 +1556,12 @@ impl LazyEnv { // Decode outside the shard lock: fetches can be slow and other // names hashing to this shard shouldn't wait on them. self.misses.fetch_add(1, Ordering::Relaxed); - let decoded = Arc::new((self.fetch)(name)?); + let fetch_start = std::time::Instant::now(); + let decoded = (self.fetch)(name); + self + .miss_nanos + .fetch_add(fetch_start.elapsed().as_nanos() as u64, Ordering::Relaxed); + let decoded = Arc::new(decoded?); let mut shard = self.shards[shard_idx].lock().unwrap(); if shard.len() >= self.cap_per_shard { shard.swap_remove_index(0); @@ -1547,10 +1570,18 @@ impl LazyEnv { Some(decoded) } - /// `(hits, misses)` counters for instrumentation. - fn stats(&self) -> (u64, u64) { + fn stats(&self) -> LazyEnvStats { use std::sync::atomic::Ordering; - (self.hits.load(Ordering::Relaxed), self.misses.load(Ordering::Relaxed)) + LazyEnvStats { + hits: self.hits.load(Ordering::Relaxed), + misses: self.misses.load(Ordering::Relaxed), + miss_decode: std::time::Duration::from_nanos( + self.miss_nanos.load(Ordering::Relaxed), + ), + iter_decode: std::time::Duration::from_nanos( + self.iter_nanos.load(Ordering::Relaxed), + ), + } } } @@ -1621,6 +1652,8 @@ impl Env { shards, cap_per_shard, hits: std::sync::atomic::AtomicU64::new(0), + miss_nanos: std::sync::atomic::AtomicU64::new(0), + iter_nanos: std::sync::atomic::AtomicU64::new(0), misses: std::sync::atomic::AtomicU64::new(0), }), } @@ -1628,7 +1661,7 @@ impl Env { /// Cache hit/miss counters (lazy mode only). #[cfg(not(target_arch = "riscv64"))] - pub fn lazy_cache_stats(&self) -> Option<(u64, u64)> { + pub fn lazy_cache_stats(&self) -> Option { self.lazy.as_ref().map(LazyEnv::stats) } @@ -1695,7 +1728,13 @@ impl Env { #[cfg(not(target_arch = "riscv64"))] if let Some(lazy) = &self.lazy { return Box::new(lazy.names.iter().filter_map(move |n| { - (lazy.fetch)(n).map(|ci| (n, EnvEntry::Owned(Arc::new(ci)))) + let fetch_start = std::time::Instant::now(); + let decoded = (lazy.fetch)(n); + lazy.iter_nanos.fetch_add( + fetch_start.elapsed().as_nanos() as u64, + std::sync::atomic::Ordering::Relaxed, + ); + decoded.map(|ci| (n, EnvEntry::Owned(Arc::new(ci)))) })); } Box::new(self.eager.iter().map(|(n, c)| (n, EnvEntry::Borrowed(c)))) diff --git a/crates/compile/src/compile/env.rs b/crates/compile/src/compile/env.rs index b86d451e2..30369d7a8 100644 --- a/crates/compile/src/compile/env.rs +++ b/crates/compile/src/compile/env.rs @@ -1082,13 +1082,20 @@ pub fn compile_env_with_options( acc.bytes as f64 / (1024.0 * 1024.0), acc.materialized, ); - if let Some((hits, misses)) = lean_env.lazy_cache_stats() { - let total = hits + misses; + if let Some(lz) = lean_env.lazy_cache_stats() { + let total = lz.hits + lz.misses; let hit_pct = - if total == 0 { 0.0 } else { 100.0 * hits as f64 / total as f64 }; + if total == 0 { 0.0 } else { 100.0 * lz.hits as f64 / total as f64 }; + // Decode durations are summed across threads — divide by the + // worker count for a rough wall-clock bound. eprintln!( - "[compile_env] lazy lean env: {hits} hits · {misses} misses \ - ({hit_pct:.1}% hit rate)", + "[compile_env] lazy lean env: {} hits · {} misses \ + ({hit_pct:.1}% hit rate) · miss decode {:.1}s · \ + scan decode {:.1}s (thread-summed)", + lz.hits, + lz.misses, + lz.miss_decode.as_secs_f64(), + lz.iter_decode.as_secs_f64(), ); } } From f973793e9a3ffc4da1dac2a9de885b0a67f0b5c3 Mon Sep 17 00:00:00 2001 From: samuelburnham <45365069+samuelburnham@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:15:45 -0400 Subject: [PATCH 12/19] Compile: fix all-features clippy and test-ffi fallout The lazy-env EnvEntry API and the decode-time counters broke code that only compiles under --features test-ffi, which CI's clippy --all-targets --all-features gate builds but local default builds skip: the test-only FFI entries needed deref-guard adaptation, and the new instrumentation tripped pedantic cast lints (fixed by sharing the scheduler's RSS formatter, a saturating nanos helper, and a single-byte shard selector rather than allow attributes). Also records why the rust-decompile suite stays disabled: Rust decompile of synthesized _sparseCasesOn aux constants fails with "missing Ref metadata" (their aux_gen metadata arena misaligns with the serialized expr, and pure-aux constants have no Named.original sidecar to recover from). Reproduces at the merge base, so the bug is upstream of this branch. Measured under a 50G cap while here: rust-compile passes at 8.9 GiB peak; rust-decompile reaches its (pre-existing) failure at 18.1 GiB on this branch vs 24.8 GiB at the merge base. --- Tests/Main.lean | 6 +++++- crates/common/src/env.rs | 19 ++++++++++++------- crates/compile/src/compile.rs | 4 +++- crates/compile/src/compile/env.rs | 9 +++++---- crates/compile/src/graph.rs | 11 +---------- crates/ffi/src/compile.rs | 15 ++++++--------- crates/ffi/src/kernel.rs | 2 +- crates/ffi/src/lean_env.rs | 9 +++++---- crates/ixon/src/env.rs | 2 +- crates/ixon/src/lazy.rs | 4 ++-- 10 files changed, 41 insertions(+), 40 deletions(-) diff --git a/Tests/Main.lean b/Tests/Main.lean index 3606f5d35..165805972 100644 --- a/Tests/Main.lean +++ b/Tests/Main.lean @@ -60,10 +60,14 @@ def ignoredSuites : Std.HashMap String (List LSpec.TestSeq) := .ofList [ ("parallel-canon-roundtrip", Tests.CanonM.parallelSuiteIO), ("graph-cross", Tests.Ix.GraphM.suiteIO), ("condense-cross", Tests.Ix.CondenseM.suiteIO), - -- Lean compilation & kernel tests currently broken, disabled + -- Lean-side compilation/decompilation currently broken, disabled --("compile", Tests.Compile.compileSuiteIO), --("decompile", Tests.Decompile.decompileSuiteIO), ("rust-serialize", Tests.RustSerialize.rustSerializeSuiteIO), + -- Rust decompile of synthesized `_sparseCasesOn` aux constants fails + -- ("missing Ref metadata": their aux_gen metadata arena misaligns + -- with the serialized expr, and pure-aux constants have no + -- `Named.original` sidecar to recover from), disabled --("rust-decompile", Tests.RustDecompile.rustDecompileSuiteIO), ("commit-io", Tests.Commit.suiteIO), ("kernel-ixon-roundtrip", Tests.Ix.Kernel.Roundtrip.suite), diff --git a/crates/common/src/env.rs b/crates/common/src/env.rs index bc7424564..28bf1e175 100644 --- a/crates/common/src/env.rs +++ b/crates/common/src/env.rs @@ -1533,14 +1533,21 @@ pub struct LazyEnvStats { pub iter_decode: std::time::Duration, } +/// Nanosecond counters saturate to `u64` (~584 years of accumulated +/// decode time) rather than paying `u128` atomics. +#[cfg(not(target_arch = "riscv64"))] +fn elapsed_nanos(start: std::time::Instant) -> u64 { + u64::try_from(start.elapsed().as_nanos()).unwrap_or(u64::MAX) +} + #[cfg(not(target_arch = "riscv64"))] impl LazyEnv { const SHARDS: usize = 64; + /// One hash byte picks the shard: `SHARDS` divides 256, so the + /// low byte of a uniform hash is itself uniform over shards. fn shard_for(&self, name: &Name) -> usize { - let bytes = name.get_hash().as_bytes(); - let word = u64::from_le_bytes(bytes[..8].try_into().unwrap()); - (word as usize) % Self::SHARDS + usize::from(name.get_hash().as_bytes()[0]) % Self::SHARDS } fn get(&self, name: &Name) -> Option> { @@ -1558,9 +1565,7 @@ impl LazyEnv { self.misses.fetch_add(1, Ordering::Relaxed); let fetch_start = std::time::Instant::now(); let decoded = (self.fetch)(name); - self - .miss_nanos - .fetch_add(fetch_start.elapsed().as_nanos() as u64, Ordering::Relaxed); + self.miss_nanos.fetch_add(elapsed_nanos(fetch_start), Ordering::Relaxed); let decoded = Arc::new(decoded?); let mut shard = self.shards[shard_idx].lock().unwrap(); if shard.len() >= self.cap_per_shard { @@ -1731,7 +1736,7 @@ impl Env { let fetch_start = std::time::Instant::now(); let decoded = (lazy.fetch)(n); lazy.iter_nanos.fetch_add( - fetch_start.elapsed().as_nanos() as u64, + elapsed_nanos(fetch_start), std::sync::atomic::Ordering::Relaxed, ); decoded.map(|ci| (n, EnvEntry::Owned(Arc::new(ci)))) diff --git a/crates/compile/src/compile.rs b/crates/compile/src/compile.rs index d09b0a8df..83344e864 100644 --- a/crates/compile/src/compile.rs +++ b/crates/compile/src/compile.rs @@ -4128,7 +4128,9 @@ mod env; pub mod mutual; pub mod nat_conv; pub mod surgery; -pub use env::{compile_env, compile_env_with_options, self_rss_kb}; +pub use env::{ + compile_env, compile_env_with_options, rss_log_suffix, self_rss_kb, +}; #[cfg(test)] mod tests { diff --git a/crates/compile/src/compile/env.rs b/crates/compile/src/compile/env.rs index 30369d7a8..08c094b08 100644 --- a/crates/compile/src/compile/env.rs +++ b/crates/compile/src/compile/env.rs @@ -99,7 +99,7 @@ pub fn self_rss_kb() -> Option<(u64, u64, u64)> { } /// Render `self_rss_kb` for the progress logs. -fn rss_log_suffix() -> String { +pub fn rss_log_suffix() -> String { match self_rss_kb() { Some((vm, anon, file)) => format!( " · rss {:.1} GiB (anon {:.1}, file {:.1})", @@ -580,7 +580,8 @@ pub fn compile_env_with_options( } // Spawn worker threads - for worker_id in 0..num_threads { + for (worker_id, kenv_size_slot) in worker_kenv_sizes_ref.iter().enumerate() + { s.spawn(move || { let mut worker_kctx = crate::compile::KernelCtx::new(); let mut worker_blocks_done = 0usize; @@ -945,7 +946,7 @@ pub fn compile_env_with_options( // Bounded per-worker kenv growth (see KENV_CLEAR_EVERY). worker_blocks_done += 1; - if worker_blocks_done % KENV_CLEAR_EVERY == 0 { + if worker_blocks_done.is_multiple_of(KENV_CLEAR_EVERY) { worker_kctx.kenv.clear_releasing_memory(); } @@ -953,7 +954,7 @@ pub fn compile_env_with_options( // reporter's decile aggregate. cache_sizes() is ~20 map // len() reads; the slot is uncontended except during the // reporter's brief decile sweep. - *worker_kenv_sizes_ref[worker_id].lock().unwrap() = + *kenv_size_slot.lock().unwrap() = worker_kctx.kenv.cache_sizes(); }, None => { diff --git a/crates/compile/src/graph.rs b/crates/compile/src/graph.rs index 99111221e..50acfa2a7 100644 --- a/crates/compile/src/graph.rs +++ b/crates/compile/src/graph.rs @@ -65,22 +65,13 @@ pub struct SetupScan { /// visiting the whole env once instead of three times cuts the setup /// decode count to a third. Outputs are identical to the separate passes. pub fn setup_scan(env: &Env) -> SetupScan { + #[derive(Default)] struct Acc { out_refs: RefMap, in_refs: RefMap, ungrounded: FxHashMap, ind_groups: FxHashMap>, } - impl Default for Acc { - fn default() -> Self { - Acc { - out_refs: RefMap::default(), - in_refs: RefMap::default(), - ungrounded: FxHashMap::default(), - ind_groups: FxHashMap::default(), - } - } - } let names: Vec<&Name> = env.keys().collect(); let acc = names diff --git a/crates/ffi/src/compile.rs b/crates/ffi/src/compile.rs index c0a9f8f0d..eac4f14c1 100644 --- a/crates/ffi/src/compile.rs +++ b/crates/ffi/src/compile.rs @@ -296,14 +296,11 @@ pub extern "C" fn rs_compile_env( ) -> LeanIOResult { let quiet = std::env::var("IX_QUIET").is_ok(); let rss_gib = |label: &str| { - if !quiet && let Some((vm, anon, file)) = ix_compile::compile::self_rss_kb() - { - eprintln!( - "[rs_compile_env] rss {label}: {:.1} GiB (anon {:.1}, file {:.1})", - vm as f64 / (1024.0 * 1024.0), - anon as f64 / (1024.0 * 1024.0), - file as f64 / (1024.0 * 1024.0), - ); + if !quiet { + let suffix = ix_compile::compile::rss_log_suffix(); + if !suffix.is_empty() { + eprintln!("[rs_compile_env] {label}{suffix}"); + } } }; rss_gib("at entry"); @@ -650,7 +647,7 @@ extern "C" fn rs_compile_env_rust_first( env_consts_ptr: LeanList>, ) -> *mut RustCompiledEnv { // Decode Lean environment - let lean_env = decode_env(env_consts_ptr); + let lean_env = crate::lean_env::decode_env(env_consts_ptr); let lean_env = Arc::new(lean_env); // Compile with Rust diff --git a/crates/ffi/src/kernel.rs b/crates/ffi/src/kernel.rs index 475669efb..41c1dbade 100644 --- a/crates/ffi/src/kernel.rs +++ b/crates/ffi/src/kernel.rs @@ -3673,7 +3673,7 @@ fn compare_envs( find_diff(orig_ci.get_type(), egressed_ci.get_type(), "type"); errors.push(format!("{name}: {diff}")); } - match (orig_ci, egressed_ci) { + match (&*orig_ci, &*egressed_ci) { (LCI::DefnInfo(a), LCI::DefnInfo(b)) if a.value.get_hash() != b.value.get_hash() => { diff --git a/crates/ffi/src/lean_env.rs b/crates/ffi/src/lean_env.rs index 0f7077c3e..af003b15d 100644 --- a/crates/ffi/src/lean_env.rs +++ b/crates/ffi/src/lean_env.rs @@ -1465,7 +1465,7 @@ extern "C" fn rs_tmp_decode_const_map( let mut seen_blocks: FxHashSet> = FxHashSet::default(); for (name, ci) in env.iter() { - let all = match ci { + let all = match &*ci { LeanCI::InductInfo(v) => &v.all, _ => continue, }; @@ -1484,8 +1484,9 @@ extern "C" fn rs_tmp_decode_const_map( // longer required at this call site. Still verify the block has at // least one ingress-able inductive so we don't waste work on // broken envs. - let has_indc = - all.iter().any(|n| matches!(env.get(n), Some(LeanCI::InductInfo(_)))); + let has_indc = all + .iter() + .any(|n| matches!(env.get(n).as_deref(), Some(LeanCI::InductInfo(_)))); if !has_indc { continue; } @@ -1586,7 +1587,7 @@ extern "C" fn rs_tmp_decode_const_map( let Some(orig_ci_ref) = env.get(patch_name) else { continue; }; - let orig_ci: &LeanCI = orig_ci_ref; + let orig_ci: &LeanCI = &orig_ci_ref; let eq_result = match &perm_ctx_1b { Some(ctx) => ix_compile::congruence::perm::const_alpha_eq_with_perm( &gen_ci, orig_ci, ctx, diff --git a/crates/ixon/src/env.rs b/crates/ixon/src/env.rs index 35f32283c..67d0a5591 100644 --- a/crates/ixon/src/env.rs +++ b/crates/ixon/src/env.rs @@ -312,7 +312,7 @@ impl Env { if self.consts.contains_key(&addr) { return; } - self.consts.insert(addr, LazyConstant::from_constant_uncached(constant)); + self.consts.insert(addr, LazyConstant::from_constant_uncached(&constant)); } else { self.consts.insert(addr, LazyConstant::from_constant(constant)); } diff --git a/crates/ixon/src/lazy.rs b/crates/ixon/src/lazy.rs index 0b2630993..edfa356d4 100644 --- a/crates/ixon/src/lazy.rs +++ b/crates/ixon/src/lazy.rs @@ -145,7 +145,7 @@ impl LazyConstant { /// `get()` re-parses per access — the same policy as /// [`Self::from_bytes`]. The demoted accumulator repr (see /// `env::DEMOTE`). - pub fn from_constant_uncached(c: Constant) -> Self { + pub fn from_constant_uncached(c: &Constant) -> Self { let mut buf = Vec::new(); c.put(&mut buf); LazyConstant { bytes: BytesSource::Heap(buf.into()), cache: None } @@ -309,7 +309,7 @@ mod tests { fn from_constant_uncached_roundtrips_without_cache() { let c = defn_constant(); let (addr, bytes) = c.commit(); - let lazy = LazyConstant::from_constant_uncached(c.clone()); + let lazy = LazyConstant::from_constant_uncached(&c); assert!(!lazy.is_materialized()); // Same serialized form as the cached constructor / commit(). assert_eq!(lazy.raw_bytes(), &bytes[..]); From f216728c1ea594f6bad310b10d8eeae42abd8479 Mon Sep 17 00:00:00 2001 From: samuelburnham <45365069+samuelburnham@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:05:25 -0400 Subject: [PATCH 13/19] Compile: drop the measurement instrumentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The RAM work needed a lot of scaffolding to find where the bytes were; none of it earns a place in the shipped code now that the answers are in. Removed: lazy-env hit/miss and decode-time counters (LazyEnvStats), the RSS anon/file log suffix and /proc reader, per-decile accumulator composition and worker-kenv telemetry in the scheduler reporter, per-worker kenv snapshot slots and exit logs, Env::put_file section logging, ixon ConstCacheStats, the rs_compile_env RSS trace, and the IX_SKIP_DROPS escape hatch (the skip is now unconditional — the compile CLI is one-shot). Env::put keeps its pre-existing section logging; the improvements (lazy decode, streaming, demotion, kenv clearing, fused scan) are untouched, and the lazy-env shard count gains a sizing rationale. --- crates/common/src/env.rs | 78 ++-------------- crates/compile/src/compile.rs | 4 +- crates/compile/src/compile/env.rs | 150 ++---------------------------- crates/ffi/src/compile.rs | 28 ++---- crates/ixon/src/env.rs | 26 ------ crates/ixon/src/serialize.rs | 28 ------ 6 files changed, 19 insertions(+), 295 deletions(-) diff --git a/crates/common/src/env.rs b/crates/common/src/env.rs index 28bf1e175..f7516dca4 100644 --- a/crates/common/src/env.rs +++ b/crates/common/src/env.rs @@ -1511,37 +1511,13 @@ pub struct LazyEnv { /// topological locality. shards: Vec>>>, cap_per_shard: usize, - hits: std::sync::atomic::AtomicU64, - misses: std::sync::atomic::AtomicU64, - /// Nanoseconds spent inside `fetch` on cache misses (summed across - /// threads, so it can exceed wall time under parallelism). - miss_nanos: std::sync::atomic::AtomicU64, - /// Nanoseconds spent inside `fetch` during cache-bypassing `iter` - /// sweeps (same summed-across-threads caveat). - iter_nanos: std::sync::atomic::AtomicU64, -} - -/// [`LazyEnv`] instrumentation counters, from [`Env::lazy_cache_stats`]. -#[cfg(not(target_arch = "riscv64"))] -#[derive(Clone, Copy, Debug)] -pub struct LazyEnvStats { - pub hits: u64, - pub misses: u64, - /// Thread-summed time inside miss decodes. - pub miss_decode: std::time::Duration, - /// Thread-summed time inside `iter` sweep decodes. - pub iter_decode: std::time::Duration, -} - -/// Nanosecond counters saturate to `u64` (~584 years of accumulated -/// decode time) rather than paying `u128` atomics. -#[cfg(not(target_arch = "riscv64"))] -fn elapsed_nanos(start: std::time::Instant) -> u64 { - u64::try_from(start.elapsed().as_nanos()).unwrap_or(u64::MAX) } #[cfg(not(target_arch = "riscv64"))] impl LazyEnv { + /// Lock-striping width: comfortably above the scheduler's worker + /// count (one per core) so concurrent `get`s rarely contend on a + /// shard mutex. Must divide 256 — `shard_for` selects by hash byte. const SHARDS: usize = 64; /// One hash byte picks the shard: `SHARDS` divides 256, so the @@ -1551,22 +1527,16 @@ impl LazyEnv { } fn get(&self, name: &Name) -> Option> { - use std::sync::atomic::Ordering; if !self.index.contains_key(name) { return None; } let shard_idx = self.shard_for(name); if let Some(hit) = self.shards[shard_idx].lock().unwrap().get(name) { - self.hits.fetch_add(1, Ordering::Relaxed); return Some(hit.clone()); } // Decode outside the shard lock: fetches can be slow and other // names hashing to this shard shouldn't wait on them. - self.misses.fetch_add(1, Ordering::Relaxed); - let fetch_start = std::time::Instant::now(); - let decoded = (self.fetch)(name); - self.miss_nanos.fetch_add(elapsed_nanos(fetch_start), Ordering::Relaxed); - let decoded = Arc::new(decoded?); + let decoded = Arc::new((self.fetch)(name)?); let mut shard = self.shards[shard_idx].lock().unwrap(); if shard.len() >= self.cap_per_shard { shard.swap_remove_index(0); @@ -1574,20 +1544,6 @@ impl LazyEnv { shard.insert(name.clone(), decoded.clone()); Some(decoded) } - - fn stats(&self) -> LazyEnvStats { - use std::sync::atomic::Ordering; - LazyEnvStats { - hits: self.hits.load(Ordering::Relaxed), - misses: self.misses.load(Ordering::Relaxed), - miss_decode: std::time::Duration::from_nanos( - self.miss_nanos.load(Ordering::Relaxed), - ), - iter_decode: std::time::Duration::from_nanos( - self.iter_nanos.load(Ordering::Relaxed), - ), - } - } } /// The Lean kernel environment: a map from names to their constant @@ -1650,26 +1606,10 @@ impl Env { .collect(); Env { eager: FxHashMap::default(), - lazy: Some(LazyEnv { - names, - index, - fetch, - shards, - cap_per_shard, - hits: std::sync::atomic::AtomicU64::new(0), - miss_nanos: std::sync::atomic::AtomicU64::new(0), - iter_nanos: std::sync::atomic::AtomicU64::new(0), - misses: std::sync::atomic::AtomicU64::new(0), - }), + lazy: Some(LazyEnv { names, index, fetch, shards, cap_per_shard }), } } - /// Cache hit/miss counters (lazy mode only). - #[cfg(not(target_arch = "riscv64"))] - pub fn lazy_cache_stats(&self) -> Option { - self.lazy.as_ref().map(LazyEnv::stats) - } - pub fn get(&self, name: &Name) -> Option> { #[cfg(not(target_arch = "riscv64"))] if let Some(lazy) = &self.lazy { @@ -1733,13 +1673,7 @@ impl Env { #[cfg(not(target_arch = "riscv64"))] if let Some(lazy) = &self.lazy { return Box::new(lazy.names.iter().filter_map(move |n| { - let fetch_start = std::time::Instant::now(); - let decoded = (lazy.fetch)(n); - lazy.iter_nanos.fetch_add( - elapsed_nanos(fetch_start), - std::sync::atomic::Ordering::Relaxed, - ); - decoded.map(|ci| (n, EnvEntry::Owned(Arc::new(ci)))) + (lazy.fetch)(n).map(|ci| (n, EnvEntry::Owned(Arc::new(ci)))) })); } Box::new(self.eager.iter().map(|(n, c)| (n, EnvEntry::Borrowed(c)))) diff --git a/crates/compile/src/compile.rs b/crates/compile/src/compile.rs index 83344e864..63a4cc6b4 100644 --- a/crates/compile/src/compile.rs +++ b/crates/compile/src/compile.rs @@ -4128,9 +4128,7 @@ mod env; pub mod mutual; pub mod nat_conv; pub mod surgery; -pub use env::{ - compile_env, compile_env_with_options, rss_log_suffix, self_rss_kb, -}; +pub use env::{compile_env, compile_env_with_options}; #[cfg(test)] mod tests { diff --git a/crates/compile/src/compile/env.rs b/crates/compile/src/compile/env.rs index 08c094b08..9029ae370 100644 --- a/crates/compile/src/compile/env.rs +++ b/crates/compile/src/compile/env.rs @@ -24,10 +24,6 @@ //! RAM to make post-compile structural reads free, which only pays //! in flows that re-read the compiled env in-process (`ix check` / //! `ix validate`); `ix compile` itself never reads them back. -//! -//! Progress telemetry (`IX_QUIET`, `IX_PROGRESS_MS`, `IX_LOG_BLOCKS`) -//! reports RSS anon/file splits, accumulator composition, worker-kenv -//! sizes, and lazy-env cache hit rates per decile. use std::panic::{AssertUnwindSafe, catch_unwind}; use std::sync::{ @@ -82,35 +78,6 @@ static IX_PROGRESS_MS: LazyLock = LazyLock::new(|| { /// zero wall-clock cost there. const KENV_CLEAR_EVERY: usize = 64; -/// `(VmRSS, RssAnon, RssFile)` of this process in KiB, from -/// `/proc/self/status`. Anonymous memory can only leave RAM via swap; -/// file-backed RSS is reclaimable page cache — the split tells the two -/// apart, so the instrumentation reports both. -pub fn self_rss_kb() -> Option<(u64, u64, u64)> { - let status = std::fs::read_to_string("/proc/self/status").ok()?; - let field = |key: &str| { - status - .lines() - .find(|l| l.starts_with(key)) - .and_then(|l| l.split_whitespace().nth(1)) - .and_then(|v| v.parse::().ok()) - }; - Some((field("VmRSS:")?, field("RssAnon:")?, field("RssFile:")?)) -} - -/// Render `self_rss_kb` for the progress logs. -pub fn rss_log_suffix() -> String { - match self_rss_kb() { - Some((vm, anon, file)) => format!( - " · rss {:.1} GiB (anon {:.1}, file {:.1})", - vm as f64 / (1024.0 * 1024.0), - anon as f64 / (1024.0 * 1024.0), - file as f64 / (1024.0 * 1024.0), - ), - None => String::new(), - } -} - /// Recover a short string description from a panic payload. fn panic_message(panic: &(dyn std::any::Any + Send)) -> String { panic @@ -427,16 +394,9 @@ pub fn compile_env_with_options( Arc::new(Mutex::new(Vec::new())); let stop_progress = Arc::new(AtomicBool::new(false)); - // Per-worker kenv size snapshots, refreshed by each worker at block - // completion and aggregated by the reporter's decile stats. One slot - // per worker so updates never contend. - let worker_kenv_sizes: Vec> = - (0..num_threads).map(|_| Mutex::new(Default::default())).collect(); - if !*IX_QUIET { eprintln!( - "[compile_env] starting: {total_blocks} blocks, {num_threads} workers{}", - rss_log_suffix(), + "[compile_env] starting: {total_blocks} blocks, {num_threads} workers" ); } @@ -451,7 +411,6 @@ pub fn compile_env_with_options( let condvar_ref = &work_available; let active_ref = &active; let stop_progress_ref = &stop_progress; - let worker_kenv_sizes_ref = &worker_kenv_sizes; thread::scope(|s| { // Periodic progress reporter. Wakes every IX_PROGRESS_MS to print @@ -472,11 +431,9 @@ pub fn compile_env_with_options( let active_p = Arc::clone(active_ref); let stop_p = Arc::clone(stop_progress_ref); let start = compile_start; - let stats_stt = &stt; s.spawn(move || { let mut last_completed = 0usize; let mut last_print = Instant::now(); - let mut last_stats_decile = 0usize; while !stop_p.load(AtomicOrdering::Relaxed) { thread::sleep(check_interval); if stop_p.load(AtomicOrdering::Relaxed) { @@ -539,49 +496,12 @@ pub fn compile_env_with_options( "[compile_env] {done}/{total} ({pct:.1}%) · STALLED{suffix}" ); } - - // Accumulator composition once per completed decile, so runs - // that die before completion (OOM) still leave a growth curve - // in the log. O(consts) scan, ≤9 times per run. - let decile = if total == 0 { 0 } else { done * 10 / total }; - if decile > last_stats_decile && done > 0 { - last_stats_decile = decile; - let acc = stats_stt.env.const_cache_stats(); - eprintln!( - "[compile_env] accumulator @ {done}/{total}: {} consts · \ - {:.1} MiB serialized bytes · {} materialized caches{}", - acc.entries, - acc.bytes as f64 / (1024.0 * 1024.0), - acc.materialized, - rss_log_suffix(), - ); - // Aggregate worker kenv sizes from the per-worker snapshots - // (each refreshed at that worker's last block completion) — - // the accumulating term the consts split doesn't cover. - let mut consts = 0usize; - let mut intern_exprs = 0usize; - let mut ingress = 0usize; - let mut largest = 0usize; - for slot in worker_kenv_sizes_ref { - let s = *slot.lock().unwrap(); - consts += s.consts; - intern_exprs += s.intern_exprs; - ingress += s.ingress; - largest = largest.max(s.max()); - } - eprintln!( - "[compile_env] worker kenvs @ {done}/{total}: \ - consts={consts} intern_exprs={intern_exprs} \ - ingress={ingress} (largest single cache {largest})", - ); - } } }); } // Spawn worker threads - for (worker_id, kenv_size_slot) in worker_kenv_sizes_ref.iter().enumerate() - { + for _ in 0..num_threads { s.spawn(move || { let mut worker_kctx = crate::compile::KernelCtx::new(); let mut worker_blocks_done = 0usize; @@ -596,7 +516,7 @@ pub fn compile_env_with_options( Some((lo, all)) => { // Check if we should stop due to error if error_ref.lock().unwrap().is_some() { - break; + return; } // Skip if already processed (prevents double-counting from @@ -949,22 +869,15 @@ pub fn compile_env_with_options( if worker_blocks_done.is_multiple_of(KENV_CLEAR_EVERY) { worker_kctx.kenv.clear_releasing_memory(); } - - // Refresh this worker's kenv-size snapshot for the - // reporter's decile aggregate. cache_sizes() is ~20 map - // len() reads; the slot is uncontended except during the - // reporter's brief decile sweep. - *kenv_size_slot.lock().unwrap() = - worker_kctx.kenv.cache_sizes(); }, None => { // No work available - check if we're done if completed_ref.load(AtomicOrdering::SeqCst) == total_blocks { - break; + return; } // Check for errors if error_ref.lock().unwrap().is_some() { - break; + return; } // Wait for new work to become available let queue = ready_queue_ref.lock().unwrap(); @@ -974,23 +887,6 @@ pub fn compile_env_with_options( }, } } - // Per-worker kernel env sizes at exit. The kenv persists across - // every block this worker compiled (nothing clears it during - // compilation), so these counts are the worker's whole-run - // accumulation — the term the accumulator split above does not - // cover. - if !*IX_QUIET { - let sizes = worker_kctx.kenv.cache_sizes(); - // Workers that never populated their kenv (no aux_gen blocks - // landed on them) have nothing to report. - if sizes.max() > 0 { - eprintln!( - "[compile_env] worker {worker_id} kenv at exit: {sizes} \ - (largest cache {})", - sizes.max(), - ); - } - } }); } @@ -1058,47 +954,13 @@ pub fn compile_env_with_options( let total_elapsed = compile_start.elapsed().as_secs_f64(); eprintln!( "[compile_env] complete in {total_elapsed:.1}s · \ - env: {} consts, {} named, {} names, {} blobs, {} comms{}", + env: {} consts, {} named, {} names, {} blobs, {} comms", stt.env.const_count(), stt.env.named_count(), stt.env.name_count(), stt.env.blob_count(), stt.env.comm_count(), - rss_log_suffix(), - ); - // Accumulator composition: how much of `env.consts` is materialized - // `Arc` caches vs serialized bytes. The byte sum is the - // floor the accumulator would shrink to if every cache were dropped - // dropped. - let acc = stt.env.const_cache_stats(); - let materialized_pct = if acc.entries == 0 { - 0.0 - } else { - 100.0 * acc.materialized as f64 / acc.entries as f64 - }; - eprintln!( - "[compile_env] accumulator: {} consts · {:.1} MiB serialized bytes \ - · {} materialized caches ({materialized_pct:.1}%)", - acc.entries, - acc.bytes as f64 / (1024.0 * 1024.0), - acc.materialized, ); - if let Some(lz) = lean_env.lazy_cache_stats() { - let total = lz.hits + lz.misses; - let hit_pct = - if total == 0 { 0.0 } else { 100.0 * lz.hits as f64 / total as f64 }; - // Decode durations are summed across threads — divide by the - // worker count for a rough wall-clock bound. - eprintln!( - "[compile_env] lazy lean env: {} hits · {} misses \ - ({hit_pct:.1}% hit rate) · miss decode {:.1}s · \ - scan decode {:.1}s (thread-summed)", - lz.hits, - lz.misses, - lz.miss_decode.as_secs_f64(), - lz.iter_decode.as_secs_f64(), - ); - } } Ok(stt) diff --git a/crates/ffi/src/compile.rs b/crates/ffi/src/compile.rs index eac4f14c1..23fb3f74b 100644 --- a/crates/ffi/src/compile.rs +++ b/crates/ffi/src/compile.rs @@ -294,19 +294,8 @@ pub extern "C" fn rs_compile_env( env_consts_ptr: LeanList>, out_path: LeanString>, ) -> LeanIOResult { - let quiet = std::env::var("IX_QUIET").is_ok(); - let rss_gib = |label: &str| { - if !quiet { - let suffix = ix_compile::compile::rss_log_suffix(); - if !suffix.is_empty() { - eprintln!("[rs_compile_env] {label}{suffix}"); - } - } - }; - rss_gib("at entry"); let rust_env = crate::lean_env::decode_env_for_compile(env_consts_ptr); let rust_env = Arc::new(rust_env); - rss_gib("after decode_env"); let compile_stt = match compile_env_with_options(&rust_env, CompileOptions::default()) { @@ -340,17 +329,12 @@ pub extern "C" fn rs_compile_env( ); return LeanIOResult::error_string(&msg); } - rss_gib("after put_file"); - - // Skip destructors: one-shot CLI process; the OS reclaims at exit and - // dropping the maps costs tens of seconds at Mathlib scale. - if std::env::var("IX_SKIP_DROPS").ok().as_deref() != Some("0") { - std::mem::forget(compile_stt); - std::mem::forget(rust_env); - } else { - drop(compile_stt); - drop(rust_env); - } + + // Skip destructors: the compile CLI is a one-shot process, the OS + // reclaims at exit, and dropping the maps costs tens of seconds at + // Mathlib scale. + std::mem::forget(compile_stt); + std::mem::forget(rust_env); LeanIOResult::ok(LeanOwned::from_nat_u64(written)) } diff --git a/crates/ixon/src/env.rs b/crates/ixon/src/env.rs index 67d0a5591..3a5f46f58 100644 --- a/crates/ixon/src/env.rs +++ b/crates/ixon/src/env.rs @@ -187,17 +187,6 @@ pub static DEMOTE: std::sync::LazyLock = std::sync::LazyLock::new(|| { std::env::var("IX_COMPILE_DEMOTE").as_deref() != Ok("0") }); -/// Composition of [`Env::consts`], from [`Env::const_cache_stats`]. -#[derive(Clone, Copy, Debug, Default)] -pub struct ConstCacheStats { - /// Total entries in the consts map. - pub entries: usize, - /// Summed `raw_bytes()` length across all entries. - pub bytes: usize, - /// Entries holding a materialized `Arc` cache. - pub materialized: usize, -} - /// Result of [`Env::parse_lazy_index`]: a metadata-light, zero-copy view of an /// `.ixe` buffer suitable for the anon/lazy check path. Constants are byte /// windows (offsets), `named` is `name → addr` + hint, and `blobs` are copied @@ -409,21 +398,6 @@ impl Env { self.consts.len() } - /// Composition of the consts map: entry count, summed serialized byte - /// length, and how many entries hold a materialized `Arc` - /// cache (see [`LazyConstant::is_materialized`]). O(n) scan over the - /// map; used to split the accumulator's footprint into structured-cache - /// vs raw-bytes shares. - pub fn const_cache_stats(&self) -> ConstCacheStats { - let mut stats = ConstCacheStats::default(); - for entry in self.consts.iter() { - stats.entries += 1; - stats.bytes += entry.value().raw_bytes().len(); - stats.materialized += usize::from(entry.value().is_materialized()); - } - stats - } - /// Number of named entries. pub fn named_count(&self) -> usize { self.named.len() diff --git a/crates/ixon/src/serialize.rs b/crates/ixon/src/serialize.rs index 9f32fa02d..af60fd5f0 100644 --- a/crates/ixon/src/serialize.rs +++ b/crates/ixon/src/serialize.rs @@ -1385,8 +1385,6 @@ impl Env { pub fn put_file(&self, path: &std::path::Path) -> Result { use rayon::slice::ParallelSliceMut; use std::io::Write; - let quiet = std::env::var("IX_QUIET").is_ok(); - let overall_start = std::time::Instant::now(); let file = std::fs::File::create(path) .map_err(|e| format!("Env::put_file: create {}: {e}", path.display()))?; let mut w = std::io::BufWriter::with_capacity(8 * 1024 * 1024, file); @@ -1430,7 +1428,6 @@ impl Env { // Section 2: Consts — the dominant bytes; raw_bytes stream straight // through with no intermediate copy of the constant bodies - let sec_start = std::time::Instant::now(); put_u64(const_addrs.len() as u64, &mut buf); for addr in &const_addrs { if let Some(entry) = self.consts.get(addr) { @@ -1443,15 +1440,6 @@ impl Env { written += bytes.len() as u64; } } - if !quiet { - eprintln!( - "[Env::put_file] consts streamed: {} entries in {:.1}s \ - ({written} bytes so far)", - const_addrs.len(), - sec_start.elapsed().as_secs_f64(), - ); - } - // Section 3: Names (topologically sorted; builds the name index the // Named section encodes through). let sorted_names = topological_sort_names(&self.names); @@ -1466,7 +1454,6 @@ impl Env { // Section 4: Named — the largest per-entry section; demoted entries // decode and re-encode through the name index one at a time. - let sec_start = std::time::Instant::now(); let mut named_keys: Vec = self.named.iter().map(|e| e.key().clone()).collect(); named_keys.par_sort_unstable_by(|a, b| { @@ -1480,15 +1467,6 @@ impl Env { emit!(); } } - if !quiet { - eprintln!( - "[Env::put_file] named streamed: {} entries in {:.1}s \ - ({written} bytes so far)", - named_keys.len(), - sec_start.elapsed().as_secs_f64(), - ); - } - // Section 5: Comms let mut comm_addrs: Vec
= self.comms.iter().map(|e| e.key().clone()).collect(); @@ -1516,12 +1494,6 @@ impl Env { emit!(); w.flush().map_err(|e| format!("Env::put_file: flush: {e}"))?; - if !quiet { - eprintln!( - "[Env::put_file] ALL DONE: {written} bytes in {:.1}s", - overall_start.elapsed().as_secs_f64(), - ); - } Ok(written) } From fc29c3fa55364483b81ceb613ba314538fbc1601 Mon Sep 17 00:00:00 2001 From: samuelburnham <45365069+samuelburnham@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:05:25 -0400 Subject: [PATCH 14/19] Compile: size the lazy-env cache by sweep, 65536 -> 16384 entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FLT sweep (511k consts, 50G cap): wall is flat from 4096 through 1M cache entries — 60.7/65.8/60.1/65.5/67.0 s at 4k/16k/64k/256k/1M — because misses hide in the scheduler's dependency-stall slack, while peak RSS climbs 11.5 -> 11.9 -> 13.0 -> 18.2 -> 24.7 GiB. Mathlib (737k consts) confirms at 16k: 87.5 s / 17.0 GiB vs 87.3 s / 18.3 GiB at 64k. The cache buys RAM, not speed; 16384 sits at the small end with 4x headroom over the smallest size measured not to thrash. Outputs byte-identical across all sizes (cmp-verified on FLT). The cache's lock partitions are renamed shards -> segments ("shard" already means proving shards in this repo) and their count now scales with the machine instead of hardcoding: 4x the hardware threads rounded to a power of two (two-byte hash window masked to the count), so a worker per thread on large SMT boxes keeps low expected collision rates without overpaying on small ones. FLT parity re-measured (63.5 s / 11.7 GiB, identical bytes). --- crates/common/src/env.rs | 67 ++++++++++++++++++++++---------------- crates/ffi/src/lean_env.rs | 16 +++++---- 2 files changed, 49 insertions(+), 34 deletions(-) diff --git a/crates/common/src/env.rs b/crates/common/src/env.rs index f7516dca4..d42026bc7 100644 --- a/crates/common/src/env.rs +++ b/crates/common/src/env.rs @@ -1493,7 +1493,7 @@ impl EnvEntry<'_> { /// Host-only lazy backing for [`Env`]: a name index plus an injected /// fetch that decodes one constant on demand (in practice from Lean /// objects held as `LeanShared` handles — see `decode_env_lazy` in the -/// ffi crate), fronted by a sharded bounded cache. Avoids materializing +/// ffi crate), fronted by a lock-partitioned bounded cache. Avoids materializing /// the full owned copy of the environment up front, which costs a large /// multiple of the working set the compile pipeline actually touches at /// any one time. @@ -1505,43 +1505,53 @@ pub struct LazyEnv { index: FxHashMap, /// Decode one constant. Must be pure and thread-safe. fetch: Box Option + Send + Sync>, - /// Sharded cache; per-shard capacity bounds resident decoded - /// constants. Eviction is `swap_remove_index(0)` — cheap + /// Independently locked cache segments so concurrent + /// `get`s rarely contend; per-segment capacity bounds resident + /// decoded constants. Eviction is `swap_remove_index(0)` — cheap /// oldest-biased pseudo-FIFO, adequate for the scheduler's /// topological locality. - shards: Vec>>>, - cap_per_shard: usize, + segments: Vec>>>, + cap_per_segment: usize, } #[cfg(not(target_arch = "riscv64"))] impl LazyEnv { - /// Lock-striping width: comfortably above the scheduler's worker - /// count (one per core) so concurrent `get`s rarely contend on a - /// shard mutex. Must divide 256 — `shard_for` selects by hash byte. - const SHARDS: usize = 64; - - /// One hash byte picks the shard: `SHARDS` divides 256, so the - /// low byte of a uniform hash is itself uniform over shards. - fn shard_for(&self, name: &Name) -> usize { - usize::from(name.get_hash().as_bytes()[0]) % Self::SHARDS + /// Segment count: 4× the hardware threads (the scheduler runs at + /// most one worker per thread), rounded up to a power of two so the + /// hash mask in `segment_for` stays uniform. The 4× headroom keeps + /// expected pairwise collisions low when every worker is in `get` + /// at once; a mutexed segment costs ~40 bytes, so overprovisioning + /// is free. + fn segment_count() -> usize { + let threads = std::thread::available_parallelism().map_or(1, usize::from); + // Capped at the two-byte selector's range (`segment_for`). + (threads * 4).next_power_of_two().min(1 << 16) + } + + /// Two low hash bytes masked down to the segment count: uniform by + /// hash uniformity because the count is a power of two. + fn segment_for(&self, name: &Name) -> usize { + let bytes = name.get_hash().as_bytes(); + let window = usize::from(u16::from_le_bytes([bytes[0], bytes[1]])); + window & (self.segments.len() - 1) } fn get(&self, name: &Name) -> Option> { if !self.index.contains_key(name) { return None; } - let shard_idx = self.shard_for(name); - if let Some(hit) = self.shards[shard_idx].lock().unwrap().get(name) { + let segment_idx = self.segment_for(name); + if let Some(hit) = self.segments[segment_idx].lock().unwrap().get(name) { return Some(hit.clone()); } - // Decode outside the shard lock: fetches can be slow and other - // names hashing to this shard shouldn't wait on them. + // Decode outside the segment lock: fetches can be slow and other + // names hashing to this segment shouldn't wait on them. let decoded = Arc::new((self.fetch)(name)?); - let mut shard = self.shards[shard_idx].lock().unwrap(); - if shard.len() >= self.cap_per_shard { - shard.swap_remove_index(0); + let mut segment = self.segments[segment_idx].lock().unwrap(); + if segment.len() >= self.cap_per_segment { + segment.swap_remove_index(0); } - shard.insert(name.clone(), decoded.clone()); + segment.insert(name.clone(), decoded.clone()); Some(decoded) } } @@ -1587,7 +1597,7 @@ impl Clone for Env { impl Env { /// Build a lazy env from a name list and a fetch function. /// `cache_entries` bounds resident decoded constants (total across - /// shards; minimum one per shard). + /// segments; minimum one per segment). #[cfg(not(target_arch = "riscv64"))] pub fn new_lazy( names: Vec, @@ -1596,17 +1606,18 @@ impl Env { ) -> Self { let index: FxHashMap = names.iter().enumerate().map(|(i, n)| (n.clone(), i)).collect(); - let cap_per_shard = (cache_entries / LazyEnv::SHARDS).max(1); - let shards = (0..LazyEnv::SHARDS) + let segment_count = LazyEnv::segment_count(); + let cap_per_segment = (cache_entries / segment_count).max(1); + let segments = (0..segment_count) .map(|_| { std::sync::Mutex::new(indexmap::IndexMap::with_capacity( - cap_per_shard.min(4096), + cap_per_segment.min(4096), )) }) .collect(); Env { eager: FxHashMap::default(), - lazy: Some(LazyEnv { names, index, fetch, shards, cap_per_shard }), + lazy: Some(LazyEnv { names, index, fetch, segments, cap_per_segment }), } } @@ -1668,7 +1679,7 @@ impl Env { /// Iterate `(name, constant)`. Lazy mode decodes each entry fresh, /// bypassing the cache — whole-env passes are single-visit, and - /// caching them would just churn the shards. + /// caching them would just churn the segments. pub fn iter(&self) -> Box)> + '_> { #[cfg(not(target_arch = "riscv64"))] if let Some(lazy) = &self.lazy { diff --git a/crates/ffi/src/lean_env.rs b/crates/ffi/src/lean_env.rs index af003b15d..6b7c600a9 100644 --- a/crates/ffi/src/lean_env.rs +++ b/crates/ffi/src/lean_env.rs @@ -1125,12 +1125,16 @@ fn decode_name_constant_info( } /// Resident-decoded-constant bound for [`decode_env_lazy`]'s cache. -/// Wall time measured at parity with the eager decode at this size on -/// InitStd through Mathlib (the setup scan decodes each constant -/// exactly once regardless; compile-phase misses hide in the parallel -/// schedule), so there is nothing to tune — a bigger cache buys no -/// measured speed, a smaller one risks thrash. -const LAZY_ENV_CACHE_ENTRIES: usize = 65536; +/// +/// Sized by sweep, not by hit rate: on FLT (511k consts) wall time is +/// flat from 4096 through 1M entries (60–67 s; misses hide in the +/// scheduler's dependency-stall slack) while peak RSS climbs from +/// 11.5 GiB (4k) → 13.0 (64k) → 24.7 (1M); Mathlib (737k consts) +/// confirms parity at this size (87.5 s / 17.0 GiB vs 87.3 s / +/// 18.3 GiB at 64k). So the cache buys RAM, not speed, and the bound +/// sits at the small end with headroom over the 4k floor measured to +/// still not thrash. +const LAZY_ENV_CACHE_ENTRIES: usize = 16384; /// Decode for the production compile FFI entries: an on-demand view /// that avoids materializing the full Rust copy of the environment — From 1379125b9de7d185efb2d84a347ec6ea4041767b Mon Sep 17 00:00:00 2001 From: samuelburnham <45365069+samuelburnham@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:58:33 -0400 Subject: [PATCH 15/19] sp1 guest: read Named metadata through the accessor Named's meta field went private behind MetaRepr (structured or demoted-to-bytes); the guest's Muts filter now calls meta(). On the guest every entry is structured, so the call is an Arc clone. Verified via the CI-equivalent sp1-host build (guest ELF via build.rs). --- sp1/guest/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sp1/guest/src/main.rs b/sp1/guest/src/main.rs index 1ebee5aa8..48b91c16f 100644 --- a/sp1/guest/src/main.rs +++ b/sp1/guest/src/main.rs @@ -105,7 +105,7 @@ pub fn main() { .named .iter() .filter(|e| { - !matches!(e.value().meta.info, ConstantMetaInfo::Muts { .. }) + !matches!(e.value().meta().info, ConstantMetaInfo::Muts { .. }) }) .map(|e| KId::::new(e.value().addr.clone(), e.key().clone())), ); From b4446f580e390f8d011019dc218e871d99f65074 Mon Sep 17 00:00:00 2001 From: samuelburnham <45365069+samuelburnham@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:17:09 -0400 Subject: [PATCH 16/19] Compile: parallelize put_file's named-section encode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The named section is the only one whose per-entry encode is CPU-heavy (demoted entries decode and re-encode through the name index), and it was written strictly sequentially — the bulk of the CI wall regression vs main. Chunks of 4096 now encode in parallel into per-entry buffers and drain to the writer in order, bounding staged memory to a few MiB. Bytes unchanged (put_file_matches_put). InitStd defaults 7.1 -> 5.8 s (now faster than the eager, undemoted configuration was); Mathlib 87.5 -> 74.7 s at 17.7 GiB under the 50G cap. --- crates/ixon/src/serialize.rs | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/crates/ixon/src/serialize.rs b/crates/ixon/src/serialize.rs index af60fd5f0..08ee0856e 100644 --- a/crates/ixon/src/serialize.rs +++ b/crates/ixon/src/serialize.rs @@ -1383,7 +1383,7 @@ impl Env { /// change lands in both or not at all. #[cfg(not(target_arch = "riscv64"))] pub fn put_file(&self, path: &std::path::Path) -> Result { - use rayon::slice::ParallelSliceMut; + use rayon::prelude::*; use std::io::Write; let file = std::fs::File::create(path) .map_err(|e| format!("Env::put_file: create {}: {e}", path.display()))?; @@ -1452,19 +1452,33 @@ impl Env { emit!(); } - // Section 4: Named — the largest per-entry section; demoted entries - // decode and re-encode through the name index one at a time. + // Section 4: Named — the largest per-entry section, and the only + // one whose per-entry encode is CPU-heavy (demoted entries decode + // and re-encode through the name index). Chunks encode in parallel + // into per-entry buffers and drain to the writer in order; the + // chunk size bounds staged memory to a few MiB. let mut named_keys: Vec = self.named.iter().map(|e| e.key().clone()).collect(); named_keys.par_sort_unstable_by(|a, b| { a.get_hash().as_bytes().cmp(b.get_hash().as_bytes()) }); put_u64(named_keys.len() as u64, &mut buf); - for name in &named_keys { - if let Some(entry) = self.named.get(name) { - put_bytes(name.get_hash().as_bytes(), &mut buf); - put_named_indexed(entry.value(), &name_index, &mut buf)?; - emit!(); + emit!(); + for chunk in named_keys.chunks(4096) { + let encoded: Vec> = chunk + .into_par_iter() + .map(|name| { + let mut b = Vec::new(); + if let Some(entry) = self.named.get(name) { + put_bytes(name.get_hash().as_bytes(), &mut b); + put_named_indexed(entry.value(), &name_index, &mut b)?; + } + Ok::<_, String>(b) + }) + .collect::>()?; + for b in &encoded { + w.write_all(b).map_err(|e| format!("Env::put_file: write: {e}"))?; + written += b.len() as u64; } } // Section 5: Comms From eade07dbd84e11be704618dd317f2ec5b6af9a2f Mon Sep 17 00:00:00 2001 From: samuelburnham <45365069+samuelburnham@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:17:09 -0400 Subject: [PATCH 17/19] Compile: IX_COMPILE_EAGER=1 knob for RAM-rich machines The on-demand Lean-env decode costs a few percent of wall vs the up-front eager copy (measured +0.3 s on InitStd's 6.4 s; per-constant fetches vs one batched parallel decode). Machines with RAM to spare can buy it back: IX_COMPILE_EAGER=1 restores the eager decode at the cost of the single largest memory term (~25 GB at Mathlib scale). Byte-identical output either way (cmp-verified); InitStd 5.8 -> 5.3 s at 2.7 -> 4.7 GiB. --- crates/compile/src/compile/env.rs | 7 ++++++- crates/ffi/src/lean_env.rs | 15 +++++++++++---- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/crates/compile/src/compile/env.rs b/crates/compile/src/compile/env.rs index 9029ae370..42ad67a10 100644 --- a/crates/compile/src/compile/env.rs +++ b/crates/compile/src/compile/env.rs @@ -16,7 +16,7 @@ //! than crossing the FFI as an env-sized ByteArray. All of it is //! always on, and every mode produces a bit-identical `.ixe`. //! -//! Two knobs: +//! Three knobs: //! - `IX_COMPILE_WORKERS=N` — scheduler worker count (default: all //! cores). Scales the per-worker transients. //! - `IX_COMPILE_DEMOTE=0` — keep materialized caches next to the @@ -24,6 +24,11 @@ //! RAM to make post-compile structural reads free, which only pays //! in flows that re-read the compiled env in-process (`ix check` / //! `ix validate`); `ix compile` itself never reads them back. +//! - `IX_COMPILE_EAGER=1` — decode the whole Lean environment up +//! front instead of on demand. Spends the single largest memory +//! term (FLT: +13 GiB for −8 % wall; Mathlib needs a large-memory +//! machine) to shave the last few percent on hardware with RAM to +//! spare. use std::panic::{AssertUnwindSafe, catch_unwind}; use std::sync::{ diff --git a/crates/ffi/src/lean_env.rs b/crates/ffi/src/lean_env.rs index 6b7c600a9..a053ee737 100644 --- a/crates/ffi/src/lean_env.rs +++ b/crates/ffi/src/lean_env.rs @@ -1138,11 +1138,18 @@ const LAZY_ENV_CACHE_ENTRIES: usize = 16384; /// Decode for the production compile FFI entries: an on-demand view /// that avoids materializing the full Rust copy of the environment — -/// the eager copy is the single largest memory term at Mathlib scale -/// (~25 GB) and compile wall time is at parity without it. Test and -/// roundtrip entries keep [`decode_env`] (they re-read the env -/// structurally throughout). +/// the eager copy is the single largest memory term at scale. The +/// on-demand decode costs a few percent of wall (per-constant fetches +/// vs one batched decode); `IX_COMPILE_EAGER=1` buys that back on +/// machines with RAM to spare. Measured on FLT: −8 % wall for +/// +13 GiB peak; Mathlib eager exceeds a 56 GB machine outright +/// (the copy sits alongside the Lean-held env). Test and roundtrip +/// entries keep [`decode_env`] (they re-read the env structurally +/// throughout). pub fn decode_env_for_compile(list: LeanList>) -> Env { + if std::env::var("IX_COMPILE_EAGER").as_deref() == Ok("1") { + return decode_env(list); + } decode_env_lazy(list, LAZY_ENV_CACHE_ENTRIES) } From 011107f4a64c8b6bbdceeeac19d3601b9f35987c Mon Sep 17 00:00:00 2001 From: samuelburnham <45365069+samuelburnham@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:59:03 -0400 Subject: [PATCH 18/19] Compile: pin the hottest constants in the lazy env The bounded cache's misses are dominated by foundational constants (high in-degree, referenced from blocks spread across the whole schedule) that any evicting cache keeps churning. The ref graph from setup_scan gives exact reference counts before the scheduler starts, so the top 16384 names by in-degree go into a never-evicted overlay: each decodes once on first access, reads after that are lock-free, and everything else falls through to the segments. Measured (24-core box, 50G cap, interleaved runs): Lean 12.3 -> 10.5 s (-15 %), FLT 55.8 -> 53.4 s (-4 %), InitStd and Mathlib neutral; peak RSS +0.3 GiB at most. A 4x/16x larger pin set bought RAM, not speed. Outputs byte-identical throughout. --- crates/common/src/env.rs | 34 ++++++++++++++++++++++++++++++- crates/compile/src/compile/env.rs | 17 ++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/crates/common/src/env.rs b/crates/common/src/env.rs index d42026bc7..ff4a57b21 100644 --- a/crates/common/src/env.rs +++ b/crates/common/src/env.rs @@ -1512,6 +1512,14 @@ pub struct LazyEnv { /// topological locality. segments: Vec>>>, cap_per_segment: usize, + /// Never-evicted overlay for hot names, installed once via + /// [`Env::pin`] before concurrent readers start. Each slot decodes + /// on first access and stays resident; reads after that are + /// lock-free. Names outside the overlay fall through to the + /// segments. + pinned: std::sync::OnceLock< + FxHashMap>>>, + >, } #[cfg(not(target_arch = "riscv64"))] @@ -1540,6 +1548,11 @@ impl LazyEnv { if !self.index.contains_key(name) { return None; } + if let Some(pinned) = self.pinned.get() + && let Some(slot) = pinned.get(name) + { + return slot.get_or_init(|| (self.fetch)(name).map(Arc::new)).clone(); + } let segment_idx = self.segment_for(name); if let Some(hit) = self.segments[segment_idx].lock().unwrap().get(name) { return Some(hit.clone()); @@ -1617,7 +1630,26 @@ impl Env { .collect(); Env { eager: FxHashMap::default(), - lazy: Some(LazyEnv { names, index, fetch, segments, cap_per_segment }), + lazy: Some(LazyEnv { + names, + index, + fetch, + segments, + cap_per_segment, + pinned: std::sync::OnceLock::new(), + }), + } + } + + /// Pin `names` in the lazy cache: they decode on first access and + /// are never evicted. Install once, before concurrent readers start + /// (later calls are ignored); no-op on eager envs. + #[cfg(not(target_arch = "riscv64"))] + pub fn pin(&self, names: impl IntoIterator) { + if let Some(lazy) = &self.lazy { + let overlay: FxHashMap<_, _> = + names.into_iter().map(|n| (n, std::sync::OnceLock::new())).collect(); + let _ = lazy.pinned.set(overlay); } } diff --git a/crates/compile/src/compile/env.rs b/crates/compile/src/compile/env.rs index 42ad67a10..1afba1661 100644 --- a/crates/compile/src/compile/env.rs +++ b/crates/compile/src/compile/env.rs @@ -175,6 +175,23 @@ pub fn compile_env_with_options( phase_start.elapsed().as_secs_f32() ); } + // Pin the highest-in-degree constants in the lazy env: the ref + // graph gives exact reference counts, and the distribution is + // heavily skewed, so a small never-evicted set absorbs repeat + // decodes of foundational constants that otherwise churn through + // the bounded cache. At this size the overlay costs ≤0.3 GiB on + // every measured env and wall time is Lean −15 %, FLT −4 %, + // InitStd/Mathlib neutral (24-core box); larger sets bought RAM, + // not speed, on the same sweep. + const PIN_HOT_CONSTANTS: usize = 16384; + if !graph.in_refs.is_empty() { + let mut by_deg: Vec<(&Name, usize)> = + graph.in_refs.iter().map(|(n, refs)| (n, refs.len())).collect(); + let k = PIN_HOT_CONSTANTS.min(by_deg.len()); + by_deg.select_nth_unstable_by(k - 1, |a, b| b.1.cmp(&a.1)); + lean_env.pin(by_deg[..k].iter().map(|(n, _)| (*n).clone())); + } + let ungrounded_map: DashMap = ungrounded.iter().map(|(n, e)| (n.clone(), format!("{e:?}"))).collect(); if !ungrounded.is_empty() && !*IX_QUIET { From 30592689777950e3083cbb8747b91929acb8f95c Mon Sep 17 00:00:00 2001 From: samuelburnham <45365069+samuelburnham@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:16:07 -0400 Subject: [PATCH 19/19] bench-pr: IX_COMPILE_* knob passthrough to the measured compile \!benchmark's passthrough allowlist gains IX_COMPILE_EAGER / IX_COMPILE_DEMOTE / IX_COMPILE_WORKERS, and the compile job now applies passthrough env before the measured `ix compile` (previously only the prover cells did). The .ixe/row cache keys hash the passthrough content, so a knob run on the same commit measures and publishes its own row instead of silently reusing the default run's. \!benchmark compile BENCH_ENVS=Mathlib IX_COMPILE_EAGER=1 --- .github/workflows/bench-pr.yml | 28 ++++++++++++++++++++-------- Ix/Cli/BenchReport.lean | 14 ++++++++++---- 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/.github/workflows/bench-pr.yml b/.github/workflows/bench-pr.yml index 37ad84b23..7e1c05d83 100644 --- a/.github/workflows/bench-pr.yml +++ b/.github/workflows/bench-pr.yml @@ -9,6 +9,10 @@ # BENCH_SHARD=1 # restrict to the multi-shard target constants # BENCH_PHASES=1 # add per-constant phase drill-downs to the comment # RUST_LOG=info # passthrough env (allowlisted) +# IX_COMPILE_EAGER=1 # compile-knob passthrough (also IX_COMPILE_DEMOTE / +# # IX_COMPILE_WORKERS); reaches the measured `ix +# # compile` and keys its caches, so a knob run gets +# # its own row instead of the default run's # # Mode defaults per backend (the registry's defaultMode): `aiur` runs # `prove` — the real-workload simulation, whose report also carries the @@ -197,14 +201,22 @@ jobs: ref: ${{ inputs.head-sha }} # The job runs PR code; never leave the token in .git. persist-credentials: false - # Re-running !benchmark on the same commit: the .ixe is already - # published — nothing to do. + # Allowlisted KEY=VALUE lines from the !benchmark comment. Applied + # here so IX_COMPILE_* knobs reach the measured compile, and staged + # to a file so the cache keys below can hash it — a knob run must + # not reuse (or overwrite) the default run's published .ixe/row. + - name: Apply passthrough env + env: + PTENV: ${{ needs.build.outputs.passthrough-env }} + run: printf '%s\n' "$PTENV" | sed '/^[[:space:]]*$/d' | tee ptenv.txt >> "$GITHUB_ENV" + # Re-running !benchmark on the same commit with the same config: the + # .ixe is already published — nothing to do. - name: Check for published .ixe id: pr-ixe uses: actions/cache/restore@v5 with: path: ${{ matrix.env }}.ixe - key: bench-pr-ixe-${{ inputs.head-sha }}-${{ matrix.env }} + key: bench-pr-ixe-${{ inputs.head-sha }}-${{ matrix.env }}-${{ hashFiles('ptenv.txt') }} lookup-only: true - name: Restore PR binaries if: steps.pr-ixe.outputs.cache-hit != 'true' @@ -242,7 +254,7 @@ jobs: uses: actions/cache/save@v5 with: path: ${{ matrix.env }}.ixe - key: bench-pr-ixe-${{ inputs.head-sha }}-${{ matrix.env }} + key: bench-pr-ixe-${{ inputs.head-sha }}-${{ matrix.env }}-${{ hashFiles('ptenv.txt') }} # The measured row: the compile cell reuses it as its PR side (same # runner class, same binaries, same command it would run itself). - name: Publish compile row @@ -250,7 +262,7 @@ jobs: uses: actions/cache/save@v5 with: path: compile.json - key: bench-pr-row-${{ inputs.head-sha }}-${{ matrix.env }} + key: bench-pr-row-${{ inputs.head-sha }}-${{ matrix.env }}-${{ hashFiles('ptenv.txt') }} benchmark: # Explicit name: the default would append EVERY matrix value (backend, @@ -294,7 +306,7 @@ jobs: - name: Apply passthrough env env: PTENV: ${{ needs.build.outputs.passthrough-env }} - run: printf '%s\n' "$PTENV" | sed '/^[[:space:]]*$/d' >> "$GITHUB_ENV" + run: printf '%s\n' "$PTENV" | sed '/^[[:space:]]*$/d' | tee ptenv.txt >> "$GITHUB_ENV" # Restore the once-built PR binaries (see the build job) into the PR # tree's own bin dir: `ix bench run` resolves the measured tools from # /.lake/build/bin first, then PATH, so staging in-tree keeps the @@ -334,7 +346,7 @@ jobs: uses: actions/cache/restore@v5 with: path: ${{ matrix.cell.env }}.ixe - key: bench-pr-ixe-${{ env.HEAD_SHA }}-${{ matrix.cell.env }} + key: bench-pr-ixe-${{ env.HEAD_SHA }}-${{ matrix.cell.env }}-${{ hashFiles('ptenv.txt') }} fail-on-cache-miss: true # Compile cells reuse the compile job's measured row as their PR side. - name: Restore compile row @@ -342,7 +354,7 @@ jobs: uses: actions/cache/restore@v5 with: path: compile.json - key: bench-pr-row-${{ env.HEAD_SHA }}-${{ matrix.cell.env }} + key: bench-pr-row-${{ env.HEAD_SHA }}-${{ matrix.cell.env }}-${{ hashFiles('ptenv.txt') }} fail-on-cache-miss: true # zkVM cells additionally need the Rust toolchain + the backend's toolchain # and system deps (the shared composite install actions). diff --git a/Ix/Cli/BenchReport.lean b/Ix/Cli/BenchReport.lean index 01c58940f..c9e963629 100644 --- a/Ix/Cli/BenchReport.lean +++ b/Ix/Cli/BenchReport.lean @@ -595,9 +595,13 @@ def parseError (msg : String) : IO UInt32 := do BENCH_FULL=1 (full curated set, not just primary) BENCH_SHARD=1 (only the multi-shard target constants) BENCH_PHASES=1 / RUST_LOG=… / WITHOUT_VK_VERIFICATION=… / - RUSTFLAGS=… (passthrough; BENCH_PHASES=1 adds the + RUSTFLAGS=… / IX_COMPILE_EAGER=… / IX_COMPILE_DEMOTE=… / + IX_COMPILE_WORKERS=… (passthrough; BENCH_PHASES=1 adds the per-constant phase drill-downs to the - comment) + comment; the IX_COMPILE_* knobs reach + the measured `ix compile` and key its + caches, so knob runs don't reuse a + default run's published row) The KEY=VALUE config may sit on its own lines below the command (the comment form) or inline on the command line, whitespace-separated @@ -694,13 +698,15 @@ def runParseCmd (p : Cli.Parsed) : IO UInt32 := do | "BENCH_FULL" => if val == "1" then full := "1" | k => if ["BENCH_PHASES", "RUST_LOG", "WITHOUT_VK_VERIFICATION", - "RUSTFLAGS"].contains k then + "RUSTFLAGS", "IX_COMPILE_EAGER", "IX_COMPILE_DEMOTE", + "IX_COMPILE_WORKERS"].contains k then passthrough := passthrough.push s!"{k}={val}" else if strict then return ← parseError s!"unknown config key `{k}` in the \ benchmark command (expected BENCH_ENVS / BENCH_FULL / \ BENCH_SHARD, or passthrough: BENCH_PHASES, RUST_LOG, \ - WITHOUT_VK_VERIFICATION, RUSTFLAGS)" + WITHOUT_VK_VERIFICATION, RUSTFLAGS, IX_COMPILE_EAGER, \ + IX_COMPILE_DEMOTE, IX_COMPILE_WORKERS)" | [] => continue if envs.isEmpty then envs := #["InitStd"]