From ba0c61eb9b273047bbfac2bea1235656ffef1844 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Fri, 26 Jun 2026 03:43:30 +0000 Subject: [PATCH 01/24] WIP: CI-floor OOM root-cause fix: within-run width fold over-subscribes the 8 --- dsl/gunbc/ci_floor_measurement.dag | 7 ++---- .../ci_floor_measurement_per_shard_test.dag | 23 +++++++++---------- .../test/claim/ci_floor_plan_witness_test.dag | 4 +++- src/v2/workflow/ci_floor_plan.dag | 21 +++++++++++++---- 4 files changed, 33 insertions(+), 22 deletions(-) diff --git a/dsl/gunbc/ci_floor_measurement.dag b/dsl/gunbc/ci_floor_measurement.dag index 035b146cb59..90b331e2dcf 100644 --- a/dsl/gunbc/ci_floor_measurement.dag +++ b/dsl/gunbc/ci_floor_measurement.dag @@ -18,13 +18,10 @@ type PerShardRssPeakMeasurement = PerShardRssPeakMeasured { per_shard_peak: ByteSize, sample_width: Int } | PerShardRssPeakUnmeasured -data gunbc_ci_floor_per_shard_rss_measurement: PerShardRssPeakMeasurement = PerShardRssPeakMeasured { - per_shard_peak: byte_size(2100028416) - sample_width: 4 -} +data gunbc_ci_floor_per_shard_rss_measurement: PerShardRssPeakMeasurement = PerShardRssPeakUnmeasured data gunbc_ci_floor_per_shard_rss_source: Disposition = Terminal { - reason: "Measured: CI run 27893441091, VmHWM=8.4GiB at spawn_width=4 => per-shard = VmHWM/width = 8.4GiB/4 = 2.1GiB; corroborated by run 28003119550 (self-RSS=8.7GiB at width=4, executor base approx 0.7GiB, shard approx 2.0GiB, model: total=base+width*per_shard). Finer measurement path: width-sweep slope (RSS(w1)-RSS(w0))/(w1-w0) or per-shard cgroup isolation once per-thread RSS is available." + reason: "Cgroup-derived (switched from VmHWM-based): per_shard = gunbc_ci_floor_measured_peak.peak / concurrency = 11592347648/4 = 2898086912 (~2.75GiB). Prior VmHWM-based value (2100028416, run 27893441091) was stale and uniform-avg: VmHWM/width is a process self-RSS average across all 4 concurrent shards, which (a) under-counts child processes (rustc, sccache captured by cgroup but not VmHWM) and (b) under-estimates worst-shard peak by averaging. Cgroup-based concurrent_memory_peak_per_share over-estimates the marginal per-shard cost (it includes base/concurrency from the non-parallelizable executor overhead), implicitly compensating for the reserved=0 limitation in memory_bounded_shard_count without requiring a separate base reservation term — fixing all three causes (stale + uniform-avg + reserved=0-compensation) in one authority. Dissolution paths: per-shard cgroup isolation (cgroup.subtree_control + per-thread memory.peak) gives the true marginal per-shard cost and decouples base accounting from per-shard; width-sweep slope (RSS(w+1)-RSS(w))/1 is a lighter alternative." } fn gunbc_ci_floor_per_shard_peak_rss_bytes() -> ByteSize { diff --git a/dsl/test/claim/ci_floor_measurement_per_shard_test.dag b/dsl/test/claim/ci_floor_measurement_per_shard_test.dag index b70ee68800d..93b0ec77b71 100644 --- a/dsl/test/claim/ci_floor_measurement_per_shard_test.dag +++ b/dsl/test/claim/ci_floor_measurement_per_shard_test.dag @@ -19,14 +19,13 @@ fn per_shard_peak_bytes_for(m: PerShardRssPeakMeasurement) -> Int { } } -fn witness_per_shard_uses_direct_measurement() -> Bool { - byte_size_count(b: gunbc_ci_floor_per_shard_peak_rss_bytes()) == 2100028416 +fn witness_per_shard_uses_cgroup_derived() -> Bool { + byte_size_count(b: gunbc_ci_floor_per_shard_peak_rss_bytes()) == + byte_size_count(b: concurrent_memory_peak_per_share(sample: gunbc_ci_floor_measured_peak)) } -fn witness_direct_is_less_than_derived() -> Bool { - let direct = byte_size_count(b: gunbc_ci_floor_per_shard_peak_rss_bytes()) - let derived = byte_size_count(b: concurrent_memory_peak_per_share(sample: gunbc_ci_floor_measured_peak)) - direct < derived +fn witness_cgroup_derived_exceeds_vmhwm_based() -> Bool { + byte_size_count(b: concurrent_memory_peak_per_share(sample: gunbc_ci_floor_measured_peak)) > 2100028416 } fn witness_unmeasured_arm_falls_back_to_derived() -> Bool { @@ -35,16 +34,16 @@ fn witness_unmeasured_arm_falls_back_to_derived() -> Bool { fallback == derived } -fn witness_measured_arm_sample_width_is_four() -> Bool { +fn witness_measurement_mode_is_unmeasured() -> Bool { match gunbc_ci_floor_per_shard_rss_measurement { - PerShardRssPeakMeasured { per_shard_peak: _, sample_width: w } => w == 4 - PerShardRssPeakUnmeasured => false + PerShardRssPeakMeasured { per_shard_peak: _, sample_width: _ } => false + PerShardRssPeakUnmeasured => true } } test fn ci_floor_measurement_per_shard_rss_witnesses() -> Bool { - witness_per_shard_uses_direct_measurement() - && witness_direct_is_less_than_derived() + witness_per_shard_uses_cgroup_derived() + && witness_cgroup_derived_exceeds_vmhwm_based() && witness_unmeasured_arm_falls_back_to_derived() - && witness_measured_arm_sample_width_is_four() + && witness_measurement_mode_is_unmeasured() } diff --git a/src/v2/test/claim/ci_floor_plan_witness_test.dag b/src/v2/test/claim/ci_floor_plan_witness_test.dag index 5df068e7322..9f45f59af29 100644 --- a/src/v2/test/claim/ci_floor_plan_witness_test.dag +++ b/src/v2/test/claim/ci_floor_plan_witness_test.dag @@ -14,6 +14,7 @@ import v2.workflow.ci_floor_plan { witness_floor_width_derived_from_tree_is_positive, witness_floor_spawn_width_tight_budget_backs_off, witness_floor_spawn_width_zero_budget_falls_back, + witness_floor_spawn_width_large_budget_clamped, } import gunbc.ci_layer_roots { witness_discovery_scan_dirs, witness_layer_roots } import v2.std.algebra { length, list_snoc_item } @@ -130,5 +131,6 @@ test fn ci_floor_spawn_width_from_budget_tree_witnesses() -> Bool { test fn ci_floor_spawn_width_live_budget_witnesses() -> Bool { witness_floor_spawn_width_tight_budget_backs_off() && - witness_floor_spawn_width_zero_budget_falls_back() + witness_floor_spawn_width_zero_budget_falls_back() && + witness_floor_spawn_width_large_budget_clamped() } diff --git a/src/v2/workflow/ci_floor_plan.dag b/src/v2/workflow/ci_floor_plan.dag index 6ef64772ef0..d602e0506c0 100644 --- a/src/v2/workflow/ci_floor_plan.dag +++ b/src/v2/workflow/ci_floor_plan.dag @@ -27,6 +27,7 @@ import gunbc.ci_floor_measurement { gunbc_ci_floor_conservative_fallback_width } import gunbc.ci_budget_tree { srv1_floor_memory_budget } +import gunbc.fleet_host_budget { gunbc_per_runner_memory_cap } import extdeps.cpu.ampere { altra_max_m12830_catalog } import std.realization_schedule { RealizationPlan, @@ -255,7 +256,11 @@ fn gunbc_ci_floor_spawn_width_from_tree() -> HardwareThreadCount { } fn gunbc_ci_floor_spawn_width(memory_budget_bytes: Int) -> HardwareThreadCount { - gunbc_ci_floor_spawn_width_for_budget(memory_budget: byte_size(memory_budget_bytes)) + let cap_bytes = byte_size_count(b: gunbc_per_runner_memory_cap) + let effective = + if memory_budget_bytes <= 0 || memory_budget_bytes > cap_bytes { cap_bytes } + else { memory_budget_bytes } + gunbc_ci_floor_spawn_width_for_budget(memory_budget: byte_size(effective)) } fn gunbc_ci_floor_run_envelope(memory_budget_bytes: Int) -> ResourceEnvelope { @@ -305,14 +310,22 @@ fn witness_floor_width_derived_from_tree_is_positive() -> Bool { } fn witness_floor_spawn_width_tight_budget_backs_off() -> Bool { - let tight = 10000000000 + let tight = 4294967296 let width = hardware_thread_count_value(t: gunbc_ci_floor_spawn_width(memory_budget_bytes: tight)) width < gunbc_ci_floor_breadth() } fn witness_floor_spawn_width_zero_budget_falls_back() -> Bool { - let width = hardware_thread_count_value(t: gunbc_ci_floor_spawn_width(memory_budget_bytes: 0)) - width == gunbc_ci_floor_conservative_fallback_width + let width_at_zero = hardware_thread_count_value(t: gunbc_ci_floor_spawn_width(memory_budget_bytes: 0)) + let width_at_cap = hardware_thread_count_value(t: gunbc_ci_floor_spawn_width_for_budget(memory_budget: gunbc_per_runner_memory_cap)) + width_at_zero == width_at_cap && width_at_zero > 0 && width_at_zero < gunbc_ci_floor_breadth() +} + +fn witness_floor_spawn_width_large_budget_clamped() -> Bool { + let large = 137438953472 + let width_large = hardware_thread_count_value(t: gunbc_ci_floor_spawn_width(memory_budget_bytes: large)) + let width_cap = hardware_thread_count_value(t: gunbc_ci_floor_spawn_width_for_budget(memory_budget: gunbc_per_runner_memory_cap)) + width_large == width_cap && width_large < gunbc_ci_floor_breadth() } fn floor_layer_heavy_resolve_count(batch: ExecutorBatch, spec: CiSpec) -> Int { From 3df5f56bffbf696d0cf7c206fab56b95e032eaf7 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Fri, 26 Jun 2026 03:49:44 +0000 Subject: [PATCH 02/24] Fix ci_floor_plan.dag parse error: inline if-expression to satisfy dag parser The multi-line `let effective = \n if ...` form was rejected by the .dag parser ("expected expression, found Newline"). Restructure to call gunbc_ci_floor_spawn_width_for_budget directly in each if branch, which also avoids a Nat/Int type mismatch between the two branches. Co-Authored-By: Claude Sonnet 4.6 --- src/v2/workflow/ci_floor_plan.dag | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/v2/workflow/ci_floor_plan.dag b/src/v2/workflow/ci_floor_plan.dag index d602e0506c0..4859af95587 100644 --- a/src/v2/workflow/ci_floor_plan.dag +++ b/src/v2/workflow/ci_floor_plan.dag @@ -256,11 +256,11 @@ fn gunbc_ci_floor_spawn_width_from_tree() -> HardwareThreadCount { } fn gunbc_ci_floor_spawn_width(memory_budget_bytes: Int) -> HardwareThreadCount { - let cap_bytes = byte_size_count(b: gunbc_per_runner_memory_cap) - let effective = - if memory_budget_bytes <= 0 || memory_budget_bytes > cap_bytes { cap_bytes } - else { memory_budget_bytes } - gunbc_ci_floor_spawn_width_for_budget(memory_budget: byte_size(effective)) + if memory_budget_bytes <= 0 || memory_budget_bytes > byte_size_count(b: gunbc_per_runner_memory_cap) { + gunbc_ci_floor_spawn_width_for_budget(memory_budget: gunbc_per_runner_memory_cap) + } else { + gunbc_ci_floor_spawn_width_for_budget(memory_budget: byte_size(memory_budget_bytes)) + } } fn gunbc_ci_floor_run_envelope(memory_budget_bytes: Int) -> ResourceEnvelope { From 4a299f2a433398b0e10a64f5fd2c844cf5334564 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Fri, 26 Jun 2026 04:10:35 +0000 Subject: [PATCH 03/24] WIP: CI-floor OOM root-cause fix: within-run width fold over-subscribes the 8 --- dsl/gunbc/ci_floor_measurement.dag | 7 +++-- .../ci_floor_measurement_per_shard_test.dag | 28 +++++++++++-------- .../test/claim/ci_floor_plan_witness_test.dag | 4 ++- src/v2/workflow/ci_floor_plan.dag | 4 +++ 4 files changed, 28 insertions(+), 15 deletions(-) diff --git a/dsl/gunbc/ci_floor_measurement.dag b/dsl/gunbc/ci_floor_measurement.dag index 90b331e2dcf..1f871245bbe 100644 --- a/dsl/gunbc/ci_floor_measurement.dag +++ b/dsl/gunbc/ci_floor_measurement.dag @@ -18,10 +18,13 @@ type PerShardRssPeakMeasurement = PerShardRssPeakMeasured { per_shard_peak: ByteSize, sample_width: Int } | PerShardRssPeakUnmeasured -data gunbc_ci_floor_per_shard_rss_measurement: PerShardRssPeakMeasurement = PerShardRssPeakUnmeasured +data gunbc_ci_floor_per_shard_rss_measurement: PerShardRssPeakMeasurement = PerShardRssPeakMeasured { + per_shard_peak: byte_size(4294967296) + sample_width: 2 +} data gunbc_ci_floor_per_shard_rss_source: Disposition = Terminal { - reason: "Cgroup-derived (switched from VmHWM-based): per_shard = gunbc_ci_floor_measured_peak.peak / concurrency = 11592347648/4 = 2898086912 (~2.75GiB). Prior VmHWM-based value (2100028416, run 27893441091) was stale and uniform-avg: VmHWM/width is a process self-RSS average across all 4 concurrent shards, which (a) under-counts child processes (rustc, sccache captured by cgroup but not VmHWM) and (b) under-estimates worst-shard peak by averaging. Cgroup-based concurrent_memory_peak_per_share over-estimates the marginal per-shard cost (it includes base/concurrency from the non-parallelizable executor overhead), implicitly compensating for the reserved=0 limitation in memory_bounded_shard_count without requiring a separate base reservation term — fixing all three causes (stale + uniform-avg + reserved=0-compensation) in one authority. Dissolution paths: per-shard cgroup isolation (cgroup.subtree_control + per-thread memory.peak) gives the true marginal per-shard cost and decouples base accounting from per-shard; width-sweep slope (RSS(w+1)-RSS(w))/1 is a lighter alternative." + reason: "Conservative lower-bound from width=2 OOM observation (CI run 28215907472, 2026-06-26): at spawn_width=2 the runner cgroup (memory.max=8GiB) OOM-killed the executor. Lower bound on per-share: (8GiB - base~0.7GiB) / 2 ~= 3.65GiB; set conservatively to 4GiB (4294967296) so floor(8GiB*4/5 / 4GiB) = floor(1.6) = 1 -> width=1, guaranteed under cap. Prior VmHWM-based value (2100028416, run 27893441091) was stale and under-counted: VmHWM misses child-process RSS (rustc, sccache), and the uniform average (8.4GiB/4) under-estimates the heavy corpus-discovery shard. Dissolution path: run at width=1, capture cgroup peak via --measure-cgroup-peak, compute marginal per_shard via width-sweep slope (RSS(w2)-RSS(w1))/(w2-w1) with width-sweep from 1->2." } fn gunbc_ci_floor_per_shard_peak_rss_bytes() -> ByteSize { diff --git a/dsl/test/claim/ci_floor_measurement_per_shard_test.dag b/dsl/test/claim/ci_floor_measurement_per_shard_test.dag index 93b0ec77b71..ce5406fb920 100644 --- a/dsl/test/claim/ci_floor_measurement_per_shard_test.dag +++ b/dsl/test/claim/ci_floor_measurement_per_shard_test.dag @@ -19,9 +19,19 @@ fn per_shard_peak_bytes_for(m: PerShardRssPeakMeasurement) -> Int { } } -fn witness_per_shard_uses_cgroup_derived() -> Bool { - byte_size_count(b: gunbc_ci_floor_per_shard_peak_rss_bytes()) == - byte_size_count(b: concurrent_memory_peak_per_share(sample: gunbc_ci_floor_measured_peak)) +fn witness_measurement_mode_is_measured() -> Bool { + match gunbc_ci_floor_per_shard_rss_measurement { + PerShardRssPeakMeasured { per_shard_peak: _, sample_width: _ } => true + PerShardRssPeakUnmeasured => false + } +} + +fn witness_per_shard_is_four_gib_conservative_bound() -> Bool { + byte_size_count(b: gunbc_ci_floor_per_shard_peak_rss_bytes()) == 4294967296 +} + +fn witness_per_shard_exceeds_vmhwm_based() -> Bool { + byte_size_count(b: gunbc_ci_floor_per_shard_peak_rss_bytes()) > 2100028416 } fn witness_cgroup_derived_exceeds_vmhwm_based() -> Bool { @@ -34,16 +44,10 @@ fn witness_unmeasured_arm_falls_back_to_derived() -> Bool { fallback == derived } -fn witness_measurement_mode_is_unmeasured() -> Bool { - match gunbc_ci_floor_per_shard_rss_measurement { - PerShardRssPeakMeasured { per_shard_peak: _, sample_width: _ } => false - PerShardRssPeakUnmeasured => true - } -} - test fn ci_floor_measurement_per_shard_rss_witnesses() -> Bool { - witness_per_shard_uses_cgroup_derived() + witness_measurement_mode_is_measured() + && witness_per_shard_is_four_gib_conservative_bound() + && witness_per_shard_exceeds_vmhwm_based() && witness_cgroup_derived_exceeds_vmhwm_based() && witness_unmeasured_arm_falls_back_to_derived() - && witness_measurement_mode_is_unmeasured() } diff --git a/src/v2/test/claim/ci_floor_plan_witness_test.dag b/src/v2/test/claim/ci_floor_plan_witness_test.dag index 9f45f59af29..acdb9a4aaf7 100644 --- a/src/v2/test/claim/ci_floor_plan_witness_test.dag +++ b/src/v2/test/claim/ci_floor_plan_witness_test.dag @@ -15,6 +15,7 @@ import v2.workflow.ci_floor_plan { witness_floor_spawn_width_tight_budget_backs_off, witness_floor_spawn_width_zero_budget_falls_back, witness_floor_spawn_width_large_budget_clamped, + witness_floor_spawn_width_at_cap_is_one, } import gunbc.ci_layer_roots { witness_discovery_scan_dirs, witness_layer_roots } import v2.std.algebra { length, list_snoc_item } @@ -132,5 +133,6 @@ test fn ci_floor_spawn_width_from_budget_tree_witnesses() -> Bool { test fn ci_floor_spawn_width_live_budget_witnesses() -> Bool { witness_floor_spawn_width_tight_budget_backs_off() && witness_floor_spawn_width_zero_budget_falls_back() && - witness_floor_spawn_width_large_budget_clamped() + witness_floor_spawn_width_large_budget_clamped() && + witness_floor_spawn_width_at_cap_is_one() } diff --git a/src/v2/workflow/ci_floor_plan.dag b/src/v2/workflow/ci_floor_plan.dag index 4859af95587..e0bb837ba25 100644 --- a/src/v2/workflow/ci_floor_plan.dag +++ b/src/v2/workflow/ci_floor_plan.dag @@ -328,6 +328,10 @@ fn witness_floor_spawn_width_large_budget_clamped() -> Bool { width_large == width_cap && width_large < gunbc_ci_floor_breadth() } +fn witness_floor_spawn_width_at_cap_is_one() -> Bool { + hardware_thread_count_value(t: gunbc_ci_floor_spawn_width_for_budget(memory_budget: gunbc_per_runner_memory_cap)) == 1 +} + fn floor_layer_heavy_resolve_count(batch: ExecutorBatch, spec: CiSpec) -> Int { fold(batch.jobs, init: 0, f: fn(acc, job) { if floor_node_is_heavy_resolve(node: job.node, spec: spec) { acc + 1 } else { acc } From 8c5746d4684f85196e7af34f7959a6a38cefe08c Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Fri, 26 Jun 2026 04:11:13 +0000 Subject: [PATCH 04/24] Per_shard: conservative 4GiB inferred bound from width=2 OOM -> width=1 At spawn_width=2 the runner (memory.max=8GiB) OOM-killed batch-2 (CI run 28215907472). Lower bound on per-share: (8GiB - base~0.7GiB)/2 ~= 3.65GiB; set conservatively to 4GiB so floor(8GiB*4/5 / 4GiB) = 1 -> width=1. Disposition explicitly marks this as INFERRED (not directly sampled) with sample_width=2 recording the OOM-concurrency. Named dissolution trigger: width-sweep slope to replace the inferred bound with a real measurement. Adds witness_floor_spawn_width_at_cap_is_one as a discriminating assertion that the 8GiB cap produces exactly width=1. Co-Authored-By: Claude Sonnet 4.6 --- dsl/gunbc/ci_floor_measurement.dag | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dsl/gunbc/ci_floor_measurement.dag b/dsl/gunbc/ci_floor_measurement.dag index 1f871245bbe..324a9a8a7ce 100644 --- a/dsl/gunbc/ci_floor_measurement.dag +++ b/dsl/gunbc/ci_floor_measurement.dag @@ -24,7 +24,7 @@ data gunbc_ci_floor_per_shard_rss_measurement: PerShardRssPeakMeasurement = PerS } data gunbc_ci_floor_per_shard_rss_source: Disposition = Terminal { - reason: "Conservative lower-bound from width=2 OOM observation (CI run 28215907472, 2026-06-26): at spawn_width=2 the runner cgroup (memory.max=8GiB) OOM-killed the executor. Lower bound on per-share: (8GiB - base~0.7GiB) / 2 ~= 3.65GiB; set conservatively to 4GiB (4294967296) so floor(8GiB*4/5 / 4GiB) = floor(1.6) = 1 -> width=1, guaranteed under cap. Prior VmHWM-based value (2100028416, run 27893441091) was stale and under-counted: VmHWM misses child-process RSS (rustc, sccache), and the uniform average (8.4GiB/4) under-estimates the heavy corpus-discovery shard. Dissolution path: run at width=1, capture cgroup peak via --measure-cgroup-peak, compute marginal per_shard via width-sweep slope (RSS(w2)-RSS(w1))/(w2-w1) with width-sweep from 1->2." + reason: "INFERRED conservative lower-bound (NOT directly sampled) from width=2 OOM (CI run 28215907472, 2026-06-26, memory.max=8GiB): the executor was OOM-killed at spawn_width=2, proving total > 8GiB at concurrency=2. Lower bound on per-share: (8GiB - base~0.7GiB) / 2 ~= 3.65GiB; set conservatively to 4GiB (4294967296) so floor(8GiB*4/5 / 4GiB) = floor(1.6) = 1 -> width=1. sample_width=2 records the concurrency at which the OOM was observed, NOT a direct per-shard measurement. Prior VmHWM-based value (2100028416) was stale and under-counted: VmHWM misses child-process RSS (rustc, sccache under cgroup), and 8.4GiB/4 averages across heterogeneous shards (corpus-discovery is far heavier than the gate witnesses). Named dissolution trigger: run at width=1, capture cgroup peak via --measure-cgroup-peak to bound the single-shard peak, then width-sweep (RSS(w2)-RSS(w1))/(w2-w1) to get the true marginal per-shard and replace this inferred bound. Follow-up: per-shard-aware scheduling (run heavy corpus-discovery shard alone, parallelize light gate-witness shards) recovers the 3x floor throughput penalty from uniform width=1." } fn gunbc_ci_floor_per_shard_peak_rss_bytes() -> ByteSize { From 0105a477d0c218a052a0557affb4fb70ef52daba Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Fri, 26 Jun 2026 04:52:31 +0000 Subject: [PATCH 05/24] WIP: CI-floor OOM root-cause fix: within-run width fold over-subscribes the 8 --- src/v1/stage0/src/cli_run.rs | 85 +++++++++++++++++++++++++++++++++--- 1 file changed, 78 insertions(+), 7 deletions(-) diff --git a/src/v1/stage0/src/cli_run.rs b/src/v1/stage0/src/cli_run.rs index 53d17e8541e..f68e683c02f 100644 --- a/src/v1/stage0/src/cli_run.rs +++ b/src/v1/stage0/src/cli_run.rs @@ -2972,8 +2972,6 @@ pub fn run_discovery_corpus_with_options( )); } }; - let index = build_multi_entry_index(source_roots); - let diff_outcome = if options.skip_unaffected_node_frontier { match floor_git_diff_range() { Ok(text) => FloorGitDiffOutcome::UnifiedProduced(text), @@ -3010,9 +3008,9 @@ pub fn run_discovery_corpus_with_options( let width = parallel_width.max(1); if width == 1 { - return run_discovery_rows( + return run_discovery_rows_chunked( &rows, - &index, + source_roots, execution_mode, skip_enabled, &frontier_seeds, @@ -3036,10 +3034,9 @@ pub fn run_discovery_corpus_with_options( let seeds = frontier_seeds.clone(); let keys = whole_tree_published_keys.clone(); handles.push(std::thread::spawn(move || { - let index = build_multi_entry_index(&roots); - run_discovery_rows( + run_discovery_rows_chunked( &shard_rows, - &index, + &roots, execution_mode, skip_enabled, &seeds, @@ -3114,6 +3111,80 @@ fn merge_discovery_summaries(summaries: Vec) -> DiscoverySumma merged } +/// Each shard builds a fresh MultiEntryIndex for ~1/DISCOVERY_CHUNK_FACTOR of the corpus. +/// Keeping chunk_size at 1/4 of total entry groups bounds per-chunk peak to the old +/// per-shard-at-width-4 measurement (~2.9 GiB), preventing unbounded accumulation. +const DISCOVERY_CHUNK_FACTOR: usize = 4; + +fn count_entry_groups(rows: &[DiscoveryRow]) -> usize { + let mut count = 0usize; + let mut current: Option<&str> = None; + for row in rows { + if current != Some(row.entry.as_str()) { + count += 1; + current = Some(row.entry.as_str()); + } + } + count +} + +fn chunk_rows_by_entry_groups(rows: &[DiscoveryRow], max_groups: usize) -> Vec> { + let mut chunks: Vec> = Vec::new(); + let mut current_chunk: Vec = Vec::new(); + let mut groups_in_chunk = 0usize; + let mut current_entry: Option = None; + for row in rows { + let new_entry = current_entry.as_deref() != Some(row.entry.as_str()); + if new_entry && groups_in_chunk >= max_groups && !current_chunk.is_empty() { + chunks.push(std::mem::take(&mut current_chunk)); + groups_in_chunk = 0; + } + if new_entry { + groups_in_chunk += 1; + current_entry = Some(row.entry.clone()); + } + current_chunk.push(row.clone()); + } + if !current_chunk.is_empty() { + chunks.push(current_chunk); + } + chunks +} + +fn run_discovery_rows_chunked( + rows: &[DiscoveryRow], + source_roots: &[String], + execution_mode: v1_interpreter::ExecutionMode, + skip_enabled: bool, + frontier_seeds: &NodeFrontierSeeds, + whole_tree_published_keys: Option>, +) -> Result { + let total_groups = count_entry_groups(rows); + let max_groups_per_chunk = + ((total_groups + DISCOVERY_CHUNK_FACTOR - 1) / DISCOVERY_CHUNK_FACTOR).max(1); + let chunks = chunk_rows_by_entry_groups(rows, max_groups_per_chunk); + eprintln!( + "run_discovery_rows_chunked: {} entry groups, {} chunks (max {}/chunk)", + total_groups, + chunks.len(), + max_groups_per_chunk + ); + let mut summaries = Vec::with_capacity(chunks.len()); + for chunk in &chunks { + let index = build_multi_entry_index(source_roots); + summaries.push(run_discovery_rows( + chunk, + &index, + execution_mode, + skip_enabled, + frontier_seeds, + whole_tree_published_keys.clone(), + )?); + // index drops here, freeing parse_cache and typed_module_cache + } + Ok(merge_discovery_summaries(summaries)) +} + fn run_discovery_rows( rows: &[DiscoveryRow], index: &MultiEntryIndex, From 0e092c4224e9602149c88e9fb53370953e22a3be Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Fri, 26 Jun 2026 05:03:12 +0000 Subject: [PATCH 06/24] WIP: CI-floor OOM root-cause fix: within-run width fold over-subscribes the 8 --- dsl/gunbc/ci_floor_measurement.dag | 6 +++--- dsl/test/claim/ci_floor_measurement_per_shard_test.dag | 6 +++--- src/v2/test/claim/ci_floor_plan_witness_test.dag | 4 ++-- src/v2/workflow/ci_floor_plan.dag | 4 ++-- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/dsl/gunbc/ci_floor_measurement.dag b/dsl/gunbc/ci_floor_measurement.dag index 324a9a8a7ce..9ecdb507ac5 100644 --- a/dsl/gunbc/ci_floor_measurement.dag +++ b/dsl/gunbc/ci_floor_measurement.dag @@ -19,12 +19,12 @@ type PerShardRssPeakMeasurement | PerShardRssPeakUnmeasured data gunbc_ci_floor_per_shard_rss_measurement: PerShardRssPeakMeasurement = PerShardRssPeakMeasured { - per_shard_peak: byte_size(4294967296) - sample_width: 2 + per_shard_peak: byte_size(2898086912) + sample_width: 4 } data gunbc_ci_floor_per_shard_rss_source: Disposition = Terminal { - reason: "INFERRED conservative lower-bound (NOT directly sampled) from width=2 OOM (CI run 28215907472, 2026-06-26, memory.max=8GiB): the executor was OOM-killed at spawn_width=2, proving total > 8GiB at concurrency=2. Lower bound on per-share: (8GiB - base~0.7GiB) / 2 ~= 3.65GiB; set conservatively to 4GiB (4294967296) so floor(8GiB*4/5 / 4GiB) = floor(1.6) = 1 -> width=1. sample_width=2 records the concurrency at which the OOM was observed, NOT a direct per-shard measurement. Prior VmHWM-based value (2100028416) was stale and under-counted: VmHWM misses child-process RSS (rustc, sccache under cgroup), and 8.4GiB/4 averages across heterogeneous shards (corpus-discovery is far heavier than the gate witnesses). Named dissolution trigger: run at width=1, capture cgroup peak via --measure-cgroup-peak to bound the single-shard peak, then width-sweep (RSS(w2)-RSS(w1))/(w2-w1) to get the true marginal per-shard and replace this inferred bound. Follow-up: per-shard-aware scheduling (run heavy corpus-discovery shard alone, parallelize light gate-witness shards) recovers the 3x floor throughput penalty from uniform width=1." + reason: "PER-CHUNK peak DERIVED from whole-run cgroup measurement at width=4 (gunbc_ci_floor_measured_peak: 11592347648 bytes total / 4 = 2898086912 bytes per concurrent shard). Root-cause finding (2026-06-26): MultiEntryIndex.\{intern_table,parse_cache,typed_module_cache\} accumulate unboundedly across all entry files in a single run_discovery_rows call (same unbounded-cache class as PR-5815, which fixed the disk-cache sibling). Fix: DISCOVERY_CHUNK_FACTOR=4 in cli_run.rs splits each shard into sequential sub-chunks of ceil(N/4) entry groups, each with a FRESH MultiEntryIndex that drops after the chunk completes. Per-chunk peak = per-old-shard-at-width-4 because each chunk processes ~1/4 of the corpus entry files with a fresh index. At budget=8GiB: floor(8GiB*4/5 / 2.898GiB) = floor(6.87GiB/2.898GiB) = floor(2.37) = 2 -> width=2, recovering from the width=1 forced by the 4GiB inferred bound. sample_width=4 records the parallel concurrency of the source measurement (NOT the current per-chunk concurrency). Named dissolution trigger: run CI with --measure-cgroup-peak after chunking fix lands, capture actual single-chunk peak in isolation, update with measured value and document chunk size." } fn gunbc_ci_floor_per_shard_peak_rss_bytes() -> ByteSize { diff --git a/dsl/test/claim/ci_floor_measurement_per_shard_test.dag b/dsl/test/claim/ci_floor_measurement_per_shard_test.dag index ce5406fb920..e21333df58e 100644 --- a/dsl/test/claim/ci_floor_measurement_per_shard_test.dag +++ b/dsl/test/claim/ci_floor_measurement_per_shard_test.dag @@ -26,8 +26,8 @@ fn witness_measurement_mode_is_measured() -> Bool { } } -fn witness_per_shard_is_four_gib_conservative_bound() -> Bool { - byte_size_count(b: gunbc_ci_floor_per_shard_peak_rss_bytes()) == 4294967296 +fn witness_per_shard_is_chunk_derived_per_share() -> Bool { + byte_size_count(b: gunbc_ci_floor_per_shard_peak_rss_bytes()) == 2898086912 } fn witness_per_shard_exceeds_vmhwm_based() -> Bool { @@ -46,7 +46,7 @@ fn witness_unmeasured_arm_falls_back_to_derived() -> Bool { test fn ci_floor_measurement_per_shard_rss_witnesses() -> Bool { witness_measurement_mode_is_measured() - && witness_per_shard_is_four_gib_conservative_bound() + && witness_per_shard_is_chunk_derived_per_share() && witness_per_shard_exceeds_vmhwm_based() && witness_cgroup_derived_exceeds_vmhwm_based() && witness_unmeasured_arm_falls_back_to_derived() diff --git a/src/v2/test/claim/ci_floor_plan_witness_test.dag b/src/v2/test/claim/ci_floor_plan_witness_test.dag index acdb9a4aaf7..0c84c387d85 100644 --- a/src/v2/test/claim/ci_floor_plan_witness_test.dag +++ b/src/v2/test/claim/ci_floor_plan_witness_test.dag @@ -15,7 +15,7 @@ import v2.workflow.ci_floor_plan { witness_floor_spawn_width_tight_budget_backs_off, witness_floor_spawn_width_zero_budget_falls_back, witness_floor_spawn_width_large_budget_clamped, - witness_floor_spawn_width_at_cap_is_one, + witness_floor_spawn_width_at_cap_is_two, } import gunbc.ci_layer_roots { witness_discovery_scan_dirs, witness_layer_roots } import v2.std.algebra { length, list_snoc_item } @@ -134,5 +134,5 @@ test fn ci_floor_spawn_width_live_budget_witnesses() -> Bool { witness_floor_spawn_width_tight_budget_backs_off() && witness_floor_spawn_width_zero_budget_falls_back() && witness_floor_spawn_width_large_budget_clamped() && - witness_floor_spawn_width_at_cap_is_one() + witness_floor_spawn_width_at_cap_is_two() } diff --git a/src/v2/workflow/ci_floor_plan.dag b/src/v2/workflow/ci_floor_plan.dag index e0bb837ba25..66cb4b44830 100644 --- a/src/v2/workflow/ci_floor_plan.dag +++ b/src/v2/workflow/ci_floor_plan.dag @@ -328,8 +328,8 @@ fn witness_floor_spawn_width_large_budget_clamped() -> Bool { width_large == width_cap && width_large < gunbc_ci_floor_breadth() } -fn witness_floor_spawn_width_at_cap_is_one() -> Bool { - hardware_thread_count_value(t: gunbc_ci_floor_spawn_width_for_budget(memory_budget: gunbc_per_runner_memory_cap)) == 1 +fn witness_floor_spawn_width_at_cap_is_two() -> Bool { + hardware_thread_count_value(t: gunbc_ci_floor_spawn_width_for_budget(memory_budget: gunbc_per_runner_memory_cap)) == 2 } fn floor_layer_heavy_resolve_count(batch: ExecutorBatch, spec: CiSpec) -> Int { From 8a88a08af2eb1dab4ecf005d476a70892d42387a Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Fri, 26 Jun 2026 05:04:48 +0000 Subject: [PATCH 07/24] Fix cli_run.rs doc comment: clarify peak-reduction mechanism in run_discovery_rows_chunked The index itself always scans full source roots; peak reduction comes from dropping parse_cache/typed_module_cache between chunks, not partitioning the on-disk source index. Corrects misdescription flagged in review. Co-Authored-By: Claude Sonnet 4.6 --- src/v1/stage0/src/cli_run.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/v1/stage0/src/cli_run.rs b/src/v1/stage0/src/cli_run.rs index f68e683c02f..f9be27b3e3b 100644 --- a/src/v1/stage0/src/cli_run.rs +++ b/src/v1/stage0/src/cli_run.rs @@ -3111,9 +3111,10 @@ fn merge_discovery_summaries(summaries: Vec) -> DiscoverySumma merged } -/// Each shard builds a fresh MultiEntryIndex for ~1/DISCOVERY_CHUNK_FACTOR of the corpus. -/// Keeping chunk_size at 1/4 of total entry groups bounds per-chunk peak to the old -/// per-shard-at-width-4 measurement (~2.9 GiB), preventing unbounded accumulation. +/// Chunks discovery rows so each chunk's parse_cache/typed_module_cache (in MultiEntryIndex) +/// accumulates at most ~1/DISCOVERY_CHUNK_FACTOR of the corpus before dropping. The index +/// itself always scans the full source roots; peak reduction comes from dropping the in-process +/// caches between chunks, not from partitioning the on-disk source index. const DISCOVERY_CHUNK_FACTOR: usize = 4; fn count_entry_groups(rows: &[DiscoveryRow]) -> usize { From ed29743f9f8002bdd982267f9bd469b3059366ca Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Fri, 26 Jun 2026 05:19:37 +0000 Subject: [PATCH 08/24] WIP: CI-floor OOM root-cause fix: within-run width fold over-subscribes the 8 --- src/v1/tests/src/html_markup_smoke_test.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/v1/tests/src/html_markup_smoke_test.rs b/src/v1/tests/src/html_markup_smoke_test.rs index aadfcd01c10..e3431894000 100644 --- a/src/v1/tests/src/html_markup_smoke_test.rs +++ b/src/v1/tests/src/html_markup_smoke_test.rs @@ -10,7 +10,6 @@ pub fn assert_html_markup_smoke_executes() { let entry = ws.join("dsl/examples/html_markup_smoke/html_markup_smoke.dag"); let std_root = ws.join("dsl/std"); - let extdeps_root = ws.join("dsl/extdeps"); let smoke_root = ws.join("dsl/examples/html_markup_smoke"); let extdeps_root = ws.join("dsl/extdeps"); From fc90168a024994db2fd9d2b87551f816aa61592b Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Fri, 26 Jun 2026 05:36:05 +0000 Subject: [PATCH 09/24] WIP: CI-floor OOM root-cause fix: within-run width fold over-subscribes the 8 --- src/v1/stage0/src/cli_run.rs | 50 +++++++++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/src/v1/stage0/src/cli_run.rs b/src/v1/stage0/src/cli_run.rs index f9be27b3e3b..1c7908331e8 100644 --- a/src/v1/stage0/src/cli_run.rs +++ b/src/v1/stage0/src/cli_run.rs @@ -1993,7 +1993,11 @@ pub fn generate_witness_timing_histogram(summary: &DiscoverySummary) -> String { // performance_receipts and witness_outcomes are both generated in the same discovery pass // with matching cardinality and order, so positional matching is stable across discovery runs. - for (perf, outcome) in summary.performance_receipts.iter().zip(summary.witness_outcomes.iter()) { + for (perf, outcome) in summary + .performance_receipts + .iter() + .zip(summary.witness_outcomes.iter()) + { let resolve_nanos = match entry_resolve_map.get(&outcome.entry).copied() { Some(nanos) => nanos, None => { @@ -2015,19 +2019,31 @@ pub fn generate_witness_timing_histogram(summary: &DiscoverySummary) -> String { let eval_percentiles = compute_percentiles(eval_times); let mut output = String::new(); - output.push_str("╔════════════════════════════════════════════════════════════════════════════╗\n"); - output.push_str("║ WITNESS TIMING HISTOGRAM ║\n"); - output.push_str("║ Per-Witness Resolve+Eval Percentiles ║\n"); - output.push_str("╚════════════════════════════════════════════════════════════════════════════╝\n\n"); + output.push_str( + "╔════════════════════════════════════════════════════════════════════════════╗\n", + ); + output.push_str( + "║ WITNESS TIMING HISTOGRAM ║\n", + ); + output.push_str( + "║ Per-Witness Resolve+Eval Percentiles ║\n", + ); + output.push_str( + "╚════════════════════════════════════════════════════════════════════════════╝\n\n", + ); output.push_str(&format!( "Total witnesses: {} (included in histogram); {} skipped (no entry-resolve timing)\n", included_witnesses, skipped_missing_entry_resolve )); - output.push_str("Note: Resolve times are per-entry-amortized (all witnesses in an entry share the\n"); + output.push_str( + "Note: Resolve times are per-entry-amortized (all witnesses in an entry share the\n", + ); output.push_str("entry's resolve cost). Eval times are per-witness measurements.\n\n"); - output.push_str("┌─ TOTAL TIME (Resolve + Eval) ───────────────────────────────────────────────┐\n"); + output.push_str( + "┌─ TOTAL TIME (Resolve + Eval) ───────────────────────────────────────────────┐\n", + ); output.push_str(&format!( "│ p50: {:>12} | p90: {:>12} | p95: {:>12} | p99: {:>12} | max: {:>12} │\n", format_nanos(total_percentiles.p50), @@ -2036,9 +2052,13 @@ pub fn generate_witness_timing_histogram(summary: &DiscoverySummary) -> String { format_nanos(total_percentiles.p99), format_nanos(total_percentiles.p100), )); - output.push_str("└─────────────────────────────────────────────────────────────────────────────┘\n\n"); + output.push_str( + "└─────────────────────────────────────────────────────────────────────────────┘\n\n", + ); - output.push_str("┌─ RESOLVE TIME ──────────────────────────────────────────────────────────────┐\n"); + output.push_str( + "┌─ RESOLVE TIME ──────────────────────────────────────────────────────────────┐\n", + ); output.push_str(&format!( "│ p50: {:>12} | p90: {:>12} | p95: {:>12} | p99: {:>12} | max: {:>12} │\n", format_nanos(resolve_percentiles.p50), @@ -2047,9 +2067,13 @@ pub fn generate_witness_timing_histogram(summary: &DiscoverySummary) -> String { format_nanos(resolve_percentiles.p99), format_nanos(resolve_percentiles.p100), )); - output.push_str("└─────────────────────────────────────────────────────────────────────────────┘\n\n"); + output.push_str( + "└─────────────────────────────────────────────────────────────────────────────┘\n\n", + ); - output.push_str("┌─ EVAL TIME ─────────────────────────────────────────────────────────────────┐\n"); + output.push_str( + "┌─ EVAL TIME ─────────────────────────────────────────────────────────────────┐\n", + ); output.push_str(&format!( "│ p50: {:>12} | p90: {:>12} | p95: {:>12} | p99: {:>12} | max: {:>12} │\n", format_nanos(eval_percentiles.p50), @@ -2058,7 +2082,9 @@ pub fn generate_witness_timing_histogram(summary: &DiscoverySummary) -> String { format_nanos(eval_percentiles.p99), format_nanos(eval_percentiles.p100), )); - output.push_str("└─────────────────────────────────────────────────────────────────────────────┘\n"); + output.push_str( + "└─────────────────────────────────────────────────────────────────────────────┘\n", + ); output } From cbc7e85cf09301234638f77476548c44531565df Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Fri, 26 Jun 2026 06:03:13 +0000 Subject: [PATCH 10/24] WIP: CI-floor OOM root-cause fix: within-run width fold over-subscribes the 8 --- src/v1/stage0/src/cli_run.rs | 61 ++++++++++++++++++++++++++++++++---- 1 file changed, 55 insertions(+), 6 deletions(-) diff --git a/src/v1/stage0/src/cli_run.rs b/src/v1/stage0/src/cli_run.rs index 1c7908331e8..e0495c6ec76 100644 --- a/src/v1/stage0/src/cli_run.rs +++ b/src/v1/stage0/src/cli_run.rs @@ -347,6 +347,18 @@ pub fn build_multi_entry_index(source_roots: &[String]) -> MultiEntryIndex { } } +impl MultiEntryIndex { + /// Drops the accumulated parse results and typed-module results, releasing + /// the heavy per-entry heap data between discovery chunks. The intern_table + /// and source_files are kept: intern_table is monotonic (content-addressed + /// names only accumulate; no names are ever removed) and source_files is + /// the static file-path index for the source roots. + pub fn clear_entry_caches(&self) { + *self.parse_cache.borrow_mut() = HashMap::new(); + *self.typed_module_cache.borrow_mut() = HashMap::new(); + } +} + pub fn resolve_entry_with_index( index: &MultiEntryIndex, entry_file: &str, @@ -3138,9 +3150,11 @@ fn merge_discovery_summaries(summaries: Vec) -> DiscoverySumma } /// Chunks discovery rows so each chunk's parse_cache/typed_module_cache (in MultiEntryIndex) -/// accumulates at most ~1/DISCOVERY_CHUNK_FACTOR of the corpus before dropping. The index -/// itself always scans the full source roots; peak reduction comes from dropping the in-process -/// caches between chunks, not from partitioning the on-disk source index. +/// accumulates at most ~1/DISCOVERY_CHUNK_FACTOR of the corpus before being cleared. The +/// intern_table and source_files are shared across all chunks: intern_table is monotonic +/// (content-addressed names only accumulate) and source_files is the static file-path scan. +/// Peak reduction comes from clearing the heavy per-entry caches (parsed ASTs, typed-module +/// results) between chunks; the index itself is built once for the whole shard. const DISCOVERY_CHUNK_FACTOR: usize = 4; fn count_entry_groups(rows: &[DiscoveryRow]) -> usize { @@ -3196,9 +3210,11 @@ fn run_discovery_rows_chunked( chunks.len(), max_groups_per_chunk ); + // Build the index once for the whole shard: source_files and intern_table + // are shared. Only parse_cache and typed_module_cache are cleared between chunks. + let index = build_multi_entry_index(source_roots); let mut summaries = Vec::with_capacity(chunks.len()); - for chunk in &chunks { - let index = build_multi_entry_index(source_roots); + for (chunk_idx, chunk) in chunks.iter().enumerate() { summaries.push(run_discovery_rows( chunk, &index, @@ -3207,11 +3223,44 @@ fn run_discovery_rows_chunked( frontier_seeds, whole_tree_published_keys.clone(), )?); - // index drops here, freeing parse_cache and typed_module_cache + // Drop parsed ASTs and typed-module results. The intern_table stays alive + // (monotonic, content-addressed); source_files is the static path index. + index.clear_entry_caches(); + // Ask glibc to return free pages to the OS so the cgroup RSS actually drops. + #[cfg(target_os = "linux")] + extern "C" { + fn malloc_trim(pad: usize) -> i32; + } + #[cfg(target_os = "linux")] + unsafe { + malloc_trim(0); + } + if let Ok(rss_kb) = proc_self_rss_kb() { + eprintln!( + "run_discovery_rows_chunked: after chunk {}/{} rss={}MiB", + chunk_idx + 1, + chunks.len(), + rss_kb / 1024 + ); + } } Ok(merge_discovery_summaries(summaries)) } +fn proc_self_rss_kb() -> Result { + let s = std::fs::read_to_string("/proc/self/status").map_err(|_| ())?; + for line in s.lines() { + if line.starts_with("VmRSS:") { + return line + .split_whitespace() + .nth(1) + .and_then(|v| v.parse().ok()) + .ok_or(()); + } + } + Err(()) +} + fn run_discovery_rows( rows: &[DiscoveryRow], index: &MultiEntryIndex, From f2acf31dfa2a2128f814b60de727b3036bd9e19b Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Fri, 26 Jun 2026 06:13:39 +0000 Subject: [PATCH 11/24] WIP: CI-floor OOM root-cause fix: within-run width fold over-subscribes the 8 --- src/v1/stage0/src/cli_run.rs | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/src/v1/stage0/src/cli_run.rs b/src/v1/stage0/src/cli_run.rs index e0495c6ec76..d313ffc63b1 100644 --- a/src/v1/stage0/src/cli_run.rs +++ b/src/v1/stage0/src/cli_run.rs @@ -3227,14 +3227,7 @@ fn run_discovery_rows_chunked( // (monotonic, content-addressed); source_files is the static path index. index.clear_entry_caches(); // Ask glibc to return free pages to the OS so the cgroup RSS actually drops. - #[cfg(target_os = "linux")] - extern "C" { - fn malloc_trim(pad: usize) -> i32; - } - #[cfg(target_os = "linux")] - unsafe { - malloc_trim(0); - } + heap_trim(); if let Ok(rss_kb) = proc_self_rss_kb() { eprintln!( "run_discovery_rows_chunked: after chunk {}/{} rss={}MiB", @@ -3247,6 +3240,22 @@ fn run_discovery_rows_chunked( Ok(merge_discovery_summaries(summaries)) } +/// Ask glibc to return free heap pages to the OS (Linux only). On non-Linux +/// targets this is a no-op. Calling this after clear_entry_caches() lets the +/// cgroup RSS actually drop between chunks rather than accumulating via retained +/// brk pages. +fn heap_trim() { + #[cfg(target_os = "linux")] + { + extern "C" { + fn malloc_trim(pad: usize) -> i32; + } + unsafe { + malloc_trim(0); + } + } +} + fn proc_self_rss_kb() -> Result { let s = std::fs::read_to_string("/proc/self/status").map_err(|_| ())?; for line in s.lines() { From f21ac7e275dd9b765f31f6ba29573f75e1cbe8e7 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Fri, 26 Jun 2026 06:16:04 +0000 Subject: [PATCH 12/24] Fix OOM: share index across chunks; clear caches between chunks; fix musl cfg gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous approach rebuilt build_multi_entry_index (source_files + intern_table + empty caches) for every chunk. This was wasteful: source_files and intern_table are monotonic/content-addressed and safe to share across chunks. Only parse_cache and typed_module_cache (the heavy per-entry AST data) need clearing. Changes: - Add MultiEntryIndex::clear_entry_caches(): drops parse_cache and typed_module_cache while keeping intern_table and source_files alive across chunks - run_discovery_rows_chunked now builds the index once, runs each chunk against it, clears caches between chunks - heap_trim(): gate changed from cfg(target_os="linux") to cfg(all(target_os="linux", target_env="gnu")) — malloc_trim is glibc-only, absent on musl; the previous gate would break musl linking - Add RSS logging after each chunk via /proc/self/status so CI logs show memory trend - ci_floor_measurement.dag: change gunbc_ci_floor_per_shard_rss_source from Terminal to Scaffold (the Terminal's own prose named a dissolution trigger, contradicting the Terminal disposition — Scaffold is the honest tag for a pending measurement) Co-Authored-By: Claude Sonnet 4.6 --- dsl/gunbc/ci_floor_measurement.dag | 9 +++++++-- src/v1/stage0/src/cli_run.rs | 10 +++++----- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/dsl/gunbc/ci_floor_measurement.dag b/dsl/gunbc/ci_floor_measurement.dag index 9ecdb507ac5..419ec03b7be 100644 --- a/dsl/gunbc/ci_floor_measurement.dag +++ b/dsl/gunbc/ci_floor_measurement.dag @@ -23,8 +23,13 @@ data gunbc_ci_floor_per_shard_rss_measurement: PerShardRssPeakMeasurement = PerS sample_width: 4 } -data gunbc_ci_floor_per_shard_rss_source: Disposition = Terminal { - reason: "PER-CHUNK peak DERIVED from whole-run cgroup measurement at width=4 (gunbc_ci_floor_measured_peak: 11592347648 bytes total / 4 = 2898086912 bytes per concurrent shard). Root-cause finding (2026-06-26): MultiEntryIndex.\{intern_table,parse_cache,typed_module_cache\} accumulate unboundedly across all entry files in a single run_discovery_rows call (same unbounded-cache class as PR-5815, which fixed the disk-cache sibling). Fix: DISCOVERY_CHUNK_FACTOR=4 in cli_run.rs splits each shard into sequential sub-chunks of ceil(N/4) entry groups, each with a FRESH MultiEntryIndex that drops after the chunk completes. Per-chunk peak = per-old-shard-at-width-4 because each chunk processes ~1/4 of the corpus entry files with a fresh index. At budget=8GiB: floor(8GiB*4/5 / 2.898GiB) = floor(6.87GiB/2.898GiB) = floor(2.37) = 2 -> width=2, recovering from the width=1 forced by the 4GiB inferred bound. sample_width=4 records the parallel concurrency of the source measurement (NOT the current per-chunk concurrency). Named dissolution trigger: run CI with --measure-cgroup-peak after chunking fix lands, capture actual single-chunk peak in isolation, update with measured value and document chunk size." +data gunbc_ci_floor_per_shard_rss_source: Disposition = Scaffold { + dissolves_to: SingleAuthority, + bind: DeclarationRef { + module_path: "gunbc.ci_floor_measurement", + decl_name: "gunbc_ci_floor_per_shard_rss_measurement", + field: WholeDeclaration + } } fn gunbc_ci_floor_per_shard_peak_rss_bytes() -> ByteSize { diff --git a/src/v1/stage0/src/cli_run.rs b/src/v1/stage0/src/cli_run.rs index d313ffc63b1..6b174bfee52 100644 --- a/src/v1/stage0/src/cli_run.rs +++ b/src/v1/stage0/src/cli_run.rs @@ -3240,12 +3240,12 @@ fn run_discovery_rows_chunked( Ok(merge_discovery_summaries(summaries)) } -/// Ask glibc to return free heap pages to the OS (Linux only). On non-Linux -/// targets this is a no-op. Calling this after clear_entry_caches() lets the -/// cgroup RSS actually drop between chunks rather than accumulating via retained -/// brk pages. +/// Ask glibc to return free heap pages to the OS. No-op on non-glibc targets +/// (musl exposes no malloc_trim; the cfg guard keeps the link clean). Calling +/// this after clear_entry_caches() lets the cgroup RSS actually drop between +/// chunks rather than accumulating via retained brk pages. fn heap_trim() { - #[cfg(target_os = "linux")] + #[cfg(all(target_os = "linux", target_env = "gnu"))] { extern "C" { fn malloc_trim(pad: usize) -> i32; From c9ac65e682796f4165da3565d52ac1186b97b54d Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Fri, 26 Jun 2026 06:34:44 +0000 Subject: [PATCH 13/24] WIP: CI-floor OOM root-cause fix: within-run width fold over-subscribes the 8 --- dsl/gunbc/ci_floor_measurement.dag | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/dsl/gunbc/ci_floor_measurement.dag b/dsl/gunbc/ci_floor_measurement.dag index 419ec03b7be..f8c2dd21a46 100644 --- a/dsl/gunbc/ci_floor_measurement.dag +++ b/dsl/gunbc/ci_floor_measurement.dag @@ -23,13 +23,8 @@ data gunbc_ci_floor_per_shard_rss_measurement: PerShardRssPeakMeasurement = PerS sample_width: 4 } -data gunbc_ci_floor_per_shard_rss_source: Disposition = Scaffold { - dissolves_to: SingleAuthority, - bind: DeclarationRef { - module_path: "gunbc.ci_floor_measurement", - decl_name: "gunbc_ci_floor_per_shard_rss_measurement", - field: WholeDeclaration - } +data gunbc_ci_floor_per_shard_rss_source: Disposition = Terminal { + reason: "DERIVED: whole-run cgroup peak (11592347648 bytes at sample_width=4) / 4 concurrent shards. Not a direct per-chunk measurement. Update path: re-run CI with --measure-cgroup-peak after chunking fix lands, capture the actual single-chunk peak in isolation, replace per_shard_peak with the measured value and set sample_width=1." } fn gunbc_ci_floor_per_shard_peak_rss_bytes() -> ByteSize { From 7ecae0fcbc7fde61430d006f60e89c895269afe2 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Fri, 26 Jun 2026 07:03:12 +0000 Subject: [PATCH 14/24] WIP: CI-floor OOM root-cause fix: within-run width fold over-subscribes the 8 --- src/v1/stage0/src/cli_run.rs | 39 ++++++++++++------------------------ 1 file changed, 13 insertions(+), 26 deletions(-) diff --git a/src/v1/stage0/src/cli_run.rs b/src/v1/stage0/src/cli_run.rs index 6b174bfee52..52e471263e9 100644 --- a/src/v1/stage0/src/cli_run.rs +++ b/src/v1/stage0/src/cli_run.rs @@ -347,17 +347,6 @@ pub fn build_multi_entry_index(source_roots: &[String]) -> MultiEntryIndex { } } -impl MultiEntryIndex { - /// Drops the accumulated parse results and typed-module results, releasing - /// the heavy per-entry heap data between discovery chunks. The intern_table - /// and source_files are kept: intern_table is monotonic (content-addressed - /// names only accumulate; no names are ever removed) and source_files is - /// the static file-path index for the source roots. - pub fn clear_entry_caches(&self) { - *self.parse_cache.borrow_mut() = HashMap::new(); - *self.typed_module_cache.borrow_mut() = HashMap::new(); - } -} pub fn resolve_entry_with_index( index: &MultiEntryIndex, @@ -3149,12 +3138,14 @@ fn merge_discovery_summaries(summaries: Vec) -> DiscoverySumma merged } -/// Chunks discovery rows so each chunk's parse_cache/typed_module_cache (in MultiEntryIndex) -/// accumulates at most ~1/DISCOVERY_CHUNK_FACTOR of the corpus before being cleared. The -/// intern_table and source_files are shared across all chunks: intern_table is monotonic -/// (content-addressed names only accumulate) and source_files is the static file-path scan. -/// Peak reduction comes from clearing the heavy per-entry caches (parsed ASTs, typed-module -/// results) between chunks; the index itself is built once for the whole shard. +/// Chunks discovery rows so each chunk gets a fresh MultiEntryIndex. Dropping the full +/// index (intern_table + parse_cache + typed_module_cache + source_files) after each +/// chunk, followed by malloc_trim, returns pages to the OS so cgroup RSS falls back to +/// the base process footprint (sawtooth, not staircase). A prior approach that shared +/// the intern_table across chunks produced a staircase: the monotonically-growing +/// intern_table raised glibc's brk watermark each chunk, blocking malloc_trim from +/// recovering pages. Rebuilding fresh per chunk costs 4x source-root scans (fast) and +/// 4x intern_table seeding (fast) in exchange for a flat inter-chunk baseline RSS. const DISCOVERY_CHUNK_FACTOR: usize = 4; fn count_entry_groups(rows: &[DiscoveryRow]) -> usize { @@ -3210,11 +3201,9 @@ fn run_discovery_rows_chunked( chunks.len(), max_groups_per_chunk ); - // Build the index once for the whole shard: source_files and intern_table - // are shared. Only parse_cache and typed_module_cache are cleared between chunks. - let index = build_multi_entry_index(source_roots); let mut summaries = Vec::with_capacity(chunks.len()); for (chunk_idx, chunk) in chunks.iter().enumerate() { + let index = build_multi_entry_index(source_roots); summaries.push(run_discovery_rows( chunk, &index, @@ -3223,10 +3212,8 @@ fn run_discovery_rows_chunked( frontier_seeds, whole_tree_published_keys.clone(), )?); - // Drop parsed ASTs and typed-module results. The intern_table stays alive - // (monotonic, content-addressed); source_files is the static path index. - index.clear_entry_caches(); - // Ask glibc to return free pages to the OS so the cgroup RSS actually drops. + // Drop the full index before malloc_trim so glibc can return all pages. + drop(index); heap_trim(); if let Ok(rss_kb) = proc_self_rss_kb() { eprintln!( @@ -3242,8 +3229,8 @@ fn run_discovery_rows_chunked( /// Ask glibc to return free heap pages to the OS. No-op on non-glibc targets /// (musl exposes no malloc_trim; the cfg guard keeps the link clean). Calling -/// this after clear_entry_caches() lets the cgroup RSS actually drop between -/// chunks rather than accumulating via retained brk pages. +/// this after drop(index) lets the cgroup RSS fall back to the base process +/// footprint between chunks rather than accumulating via retained brk pages. fn heap_trim() { #[cfg(all(target_os = "linux", target_env = "gnu"))] { From 84efbedf0516ee088ac203532d3ebbf8f5d36424 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Fri, 26 Jun 2026 07:14:57 +0000 Subject: [PATCH 15/24] Fix measurement provenance: PerShardRssPeakUnmeasured; drop witness_measurement_mode_is_measured from test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per_shard_rss_measurement carried a *derived* value (whole-run cgroup peak / 4) under the PerShardRssPeakMeasured variant, which contradicts §3: the variant name claims direct measurement. Switch to PerShardRssPeakUnmeasured so gunbc_ci_floor_per_shard_peak_rss_bytes() uses the concurrent_memory_peak_per_share fallback (same computed value: 11592347648/4 = 2898086912). Remove witness_measurement_mode_is_measured() from the test conjunction — it returns false now (correct: we have no direct per-chunk measurement yet); the remaining witnesses still discriminate. Also remove stray double blank line in cli_run.rs left by removing the clear_entry_caches impl block. Co-Authored-By: Claude Sonnet 4.6 --- dsl/gunbc/ci_floor_measurement.dag | 5 +---- dsl/test/claim/ci_floor_measurement_per_shard_test.dag | 3 +-- src/v1/stage0/src/cli_run.rs | 1 - 3 files changed, 2 insertions(+), 7 deletions(-) diff --git a/dsl/gunbc/ci_floor_measurement.dag b/dsl/gunbc/ci_floor_measurement.dag index f8c2dd21a46..546d793941b 100644 --- a/dsl/gunbc/ci_floor_measurement.dag +++ b/dsl/gunbc/ci_floor_measurement.dag @@ -18,10 +18,7 @@ type PerShardRssPeakMeasurement = PerShardRssPeakMeasured { per_shard_peak: ByteSize, sample_width: Int } | PerShardRssPeakUnmeasured -data gunbc_ci_floor_per_shard_rss_measurement: PerShardRssPeakMeasurement = PerShardRssPeakMeasured { - per_shard_peak: byte_size(2898086912) - sample_width: 4 -} +data gunbc_ci_floor_per_shard_rss_measurement: PerShardRssPeakMeasurement = PerShardRssPeakUnmeasured data gunbc_ci_floor_per_shard_rss_source: Disposition = Terminal { reason: "DERIVED: whole-run cgroup peak (11592347648 bytes at sample_width=4) / 4 concurrent shards. Not a direct per-chunk measurement. Update path: re-run CI with --measure-cgroup-peak after chunking fix lands, capture the actual single-chunk peak in isolation, replace per_shard_peak with the measured value and set sample_width=1." diff --git a/dsl/test/claim/ci_floor_measurement_per_shard_test.dag b/dsl/test/claim/ci_floor_measurement_per_shard_test.dag index e21333df58e..ec258db22a1 100644 --- a/dsl/test/claim/ci_floor_measurement_per_shard_test.dag +++ b/dsl/test/claim/ci_floor_measurement_per_shard_test.dag @@ -45,8 +45,7 @@ fn witness_unmeasured_arm_falls_back_to_derived() -> Bool { } test fn ci_floor_measurement_per_shard_rss_witnesses() -> Bool { - witness_measurement_mode_is_measured() - && witness_per_shard_is_chunk_derived_per_share() + witness_per_shard_is_chunk_derived_per_share() && witness_per_shard_exceeds_vmhwm_based() && witness_cgroup_derived_exceeds_vmhwm_based() && witness_unmeasured_arm_falls_back_to_derived() diff --git a/src/v1/stage0/src/cli_run.rs b/src/v1/stage0/src/cli_run.rs index 52e471263e9..10acadfe79c 100644 --- a/src/v1/stage0/src/cli_run.rs +++ b/src/v1/stage0/src/cli_run.rs @@ -347,7 +347,6 @@ pub fn build_multi_entry_index(source_roots: &[String]) -> MultiEntryIndex { } } - pub fn resolve_entry_with_index( index: &MultiEntryIndex, entry_file: &str, From b0c686b53698bf329fcfbdd27d72363babab8d4d Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Fri, 26 Jun 2026 07:49:49 +0000 Subject: [PATCH 16/24] WIP: CI-floor OOM root-cause fix: within-run width fold over-subscribes the 8 --- Cargo.lock | 21 +++++++++++++++++++++ src/v1/stage0/Cargo.toml | 6 ++++++ src/v1/stage0/src/bin/claim_executor.rs | 8 ++++++++ 3 files changed, 35 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index a42aa33a8fe..d76d33b37bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -668,6 +668,26 @@ dependencies = [ "syn", ] +[[package]] +name = "tikv-jemalloc-sys" +version = "0.5.4+5.3.0-patched" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9402443cb8fd499b6f327e40565234ff34dbda27460c5b47db0db77443dd85d1" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "tikv-jemallocator" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965fe0c26be5c56c94e38ba547249074803efd52adfb66de62107d95aab3eaca" +dependencies = [ + "libc", + "tikv-jemalloc-sys", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -761,6 +781,7 @@ dependencies = [ "serde_json", "serde_yaml", "stacker", + "tikv-jemallocator", "unicode-ident", "unicode-properties", "ureq", diff --git a/src/v1/stage0/Cargo.toml b/src/v1/stage0/Cargo.toml index 0dfef8fe442..2c91cfead98 100644 --- a/src/v1/stage0/Cargo.toml +++ b/src/v1/stage0/Cargo.toml @@ -7,6 +7,12 @@ edition = "2021" default = [] text_lookup_work_counter = [] +[target.'cfg(target_os = "linux")'.dependencies] +# jemalloc returns dirty pages via madvise(MADV_DONTNEED) per freed region, +# avoiding the brk fragmentation that prevents malloc_trim from recovering pages +# between discovery chunks (see run_discovery_rows_chunked in cli_run.rs). +tikv-jemallocator = "0.5" + [dependencies] # Persistent value carriers (ctrl#1533 phase 2). im-rc is the Rc-backed # (thread-confined, matching the Value Rc-discipline) crate providing BOTH diff --git a/src/v1/stage0/src/bin/claim_executor.rs b/src/v1/stage0/src/bin/claim_executor.rs index 75a7798c826..67813090965 100644 --- a/src/v1/stage0/src/bin/claim_executor.rs +++ b/src/v1/stage0/src/bin/claim_executor.rs @@ -1,5 +1,13 @@ #![allow(clippy::disallowed_macros)] +// jemalloc returns dirty pages to the OS via madvise(MADV_DONTNEED) per freed +// region rather than accumulating them in glibc's brk pool. This prevents the +// post-chunk RSS staircase that malloc_trim cannot fix (small AST node allocs +// scatter through brk, trapping pages above every live allocation). +#[cfg(target_os = "linux")] +#[global_allocator] +static ALLOC: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc; + use std::fs; use std::path::{Path, PathBuf}; use std::process::ExitCode; From ee2732fa997e511d6db377cfbf605571916dcdac Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Fri, 26 Jun 2026 08:07:32 +0000 Subject: [PATCH 17/24] Configure jemalloc dirty_decay_ms:0 for immediate page return; fix Terminal reason text Without JEMALLOC_SYS_WITH_MALLOC_CONF, jemalloc uses a 10-second dirty-page decay (dirty_decay_ms=10000), keeping freed pages in its arena RSS until the decay timer fires. This raised post-chunk RSS to 942-1242 MiB (vs ~114 MiB with glibc+malloc_trim) because the chunk completes before the 10s decay fires. Setting dirty_decay_ms:0,muzzy_decay_ms:0 makes jemalloc return dirty pages immediately via madvise(MADV_DONTNEED), giving the sawtooth baseline the fix requires. Embedded at build time via JEMALLOC_SYS_WITH_MALLOC_CONF in .cargo/config.toml (same pattern as RUST_MIN_STACK / RUST_TEST_THREADS). Also update the Terminal reason for gunbc_ci_floor_per_shard_rss_source to describe why the measurement is currently absent (chunking fix in flight, PerShardRssPeakUnmeasured fallback in use) rather than narrating the derivation that already lives in gunbc_ci_floor_per_shard_peak_rss_bytes(). Co-Authored-By: Claude Sonnet 4.6 --- .cargo/config.toml | 7 +++++++ dsl/gunbc/ci_floor_measurement.dag | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.cargo/config.toml b/.cargo/config.toml index 9deb6b6e862..832ff7daa88 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -9,3 +9,10 @@ jobs = 2 # max parallel rustc/linker invocations per ca # Committed here so local + CI runners stay aligned (CI inherits this config; ci.yml does not re-set it). RUST_MIN_STACK = { value = "16777216", force = false } RUST_TEST_THREADS = { value = "1", force = false } # max parallel test threads per binary +# Return jemalloc dirty pages immediately (madvise MADV_DONTNEED) instead of the +# default 10-second decay. Without this, post-chunk RSS stays elevated (1.2 GiB +# after chunk 1 vs ~120 MiB with glibc+malloc_trim), preventing the sawtooth the +# OOM fix requires. muzzy_decay_ms:0 extends this to muzzy pages (those already +# advised MADV_FREE but not yet truly freed). Dissolves when a direct per-chunk +# isolation measurement replaces the PerShardRssPeakUnmeasured fallback. +JEMALLOC_SYS_WITH_MALLOC_CONF = { value = "dirty_decay_ms:0,muzzy_decay_ms:0", force = false } diff --git a/dsl/gunbc/ci_floor_measurement.dag b/dsl/gunbc/ci_floor_measurement.dag index 546d793941b..103aac1bddd 100644 --- a/dsl/gunbc/ci_floor_measurement.dag +++ b/dsl/gunbc/ci_floor_measurement.dag @@ -21,7 +21,7 @@ type PerShardRssPeakMeasurement data gunbc_ci_floor_per_shard_rss_measurement: PerShardRssPeakMeasurement = PerShardRssPeakUnmeasured data gunbc_ci_floor_per_shard_rss_source: Disposition = Terminal { - reason: "DERIVED: whole-run cgroup peak (11592347648 bytes at sample_width=4) / 4 concurrent shards. Not a direct per-chunk measurement. Update path: re-run CI with --measure-cgroup-peak after chunking fix lands, capture the actual single-chunk peak in isolation, replace per_shard_peak with the measured value and set sample_width=1." + reason: "ABSENT: no direct per-chunk isolation measurement exists yet. gunbc_ci_floor_per_shard_peak_rss_bytes() falls back to concurrent_memory_peak_per_share(gunbc_ci_floor_measured_peak), deriving the per-shard estimate from the pre-chunking whole-run cgroup peak. Update path: after the chunking fix lands and a full CI run completes, re-run with --measure-cgroup-peak to capture the actual single-shard peak in isolation; replace PerShardRssPeakUnmeasured with PerShardRssPeakMeasured { per_shard_peak: MEASURED, sample_width: 1 }." } fn gunbc_ci_floor_per_shard_peak_rss_bytes() -> ByteSize { From 6389d7b4143d9a6c3b4738ff22560b90ac2e8bbe Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Fri, 26 Jun 2026 08:48:50 +0000 Subject: [PATCH 18/24] =?UTF-8?q?Increase=20DISCOVERY=5FCHUNK=5FFACTOR=204?= =?UTF-8?q?=E2=86=928:=20halve=20per-chunk=20unique-module=20accumulation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With spawn_width=2, both shards run in the same process; /proc/self/status VmRSS combines both shards. Later entry groups import much larger module closures, so typed_module_cache peaks non-linearly across a chunk. Factor=8 (25 groups/chunk vs 50) halves the worst-case unique-module accumulation, targeting combined peak < 8 GiB runner cgroup cap. Co-Authored-By: Claude Sonnet 4.6 --- src/v1/stage0/src/cli_run.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/v1/stage0/src/cli_run.rs b/src/v1/stage0/src/cli_run.rs index 10acadfe79c..fb1a79c6ac5 100644 --- a/src/v1/stage0/src/cli_run.rs +++ b/src/v1/stage0/src/cli_run.rs @@ -3139,13 +3139,13 @@ fn merge_discovery_summaries(summaries: Vec) -> DiscoverySumma /// Chunks discovery rows so each chunk gets a fresh MultiEntryIndex. Dropping the full /// index (intern_table + parse_cache + typed_module_cache + source_files) after each -/// chunk, followed by malloc_trim, returns pages to the OS so cgroup RSS falls back to -/// the base process footprint (sawtooth, not staircase). A prior approach that shared -/// the intern_table across chunks produced a staircase: the monotonically-growing -/// intern_table raised glibc's brk watermark each chunk, blocking malloc_trim from -/// recovering pages. Rebuilding fresh per chunk costs 4x source-root scans (fast) and -/// 4x intern_table seeding (fast) in exchange for a flat inter-chunk baseline RSS. -const DISCOVERY_CHUNK_FACTOR: usize = 4; +/// chunk returns pages to the OS (jemalloc madvise MADV_DONTNEED, sawtooth not staircase). +/// With spawn_width=2 both shards run in parallel; the combined cgroup RSS at any moment +/// is both shards' allocations summed. Later entry groups import much larger module +/// closures, making per-shard per-chunk peak grow non-linearly. Factor=8 (25 groups/chunk) +/// halves the unique-module accumulation per chunk vs factor=4, keeping the worst-case +/// combined peak under the 8 GiB runner cgroup cap. +const DISCOVERY_CHUNK_FACTOR: usize = 8; fn count_entry_groups(rows: &[DiscoveryRow]) -> usize { let mut count = 0usize; From 9f6b5e4658e9bdbe637ddefc7a5f5bbf34aa5e13 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Fri, 26 Jun 2026 09:20:01 +0000 Subject: [PATCH 19/24] =?UTF-8?q?Increase=20DISCOVERY=5FCHUNK=5FFACTOR=208?= =?UTF-8?q?=E2=86=9216:=2012-13=20groups/chunk=20targets=20combined=20peak?= =?UTF-8?q?=20<=204=20GiB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Factor=8 (25 groups/chunk) still OOM-killed: complex entries (src/v2/workflow/) have large import closures, per-shard chunk peak reaching ~3780 MiB combined. Factor=16 (12-13 groups/chunk) halves unique-module accumulation again, targeting single-shard peak < 2 GiB → combined (2 shards) < 4 GiB < 8 GiB cgroup cap. Co-Authored-By: Claude Sonnet 4.6 --- src/v1/stage0/src/cli_run.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/v1/stage0/src/cli_run.rs b/src/v1/stage0/src/cli_run.rs index fb1a79c6ac5..1e845110f1d 100644 --- a/src/v1/stage0/src/cli_run.rs +++ b/src/v1/stage0/src/cli_run.rs @@ -3141,11 +3141,12 @@ fn merge_discovery_summaries(summaries: Vec) -> DiscoverySumma /// index (intern_table + parse_cache + typed_module_cache + source_files) after each /// chunk returns pages to the OS (jemalloc madvise MADV_DONTNEED, sawtooth not staircase). /// With spawn_width=2 both shards run in parallel; the combined cgroup RSS at any moment -/// is both shards' allocations summed. Later entry groups import much larger module -/// closures, making per-shard per-chunk peak grow non-linearly. Factor=8 (25 groups/chunk) -/// halves the unique-module accumulation per chunk vs factor=4, keeping the worst-case -/// combined peak under the 8 GiB runner cgroup cap. -const DISCOVERY_CHUNK_FACTOR: usize = 8; +/// is both shards' allocations summed. Later entry groups (alphabetically later test files, +/// e.g. src/v2/workflow/) import much larger module closures, making per-shard per-chunk +/// peak grow non-linearly across chunks. Factor=16 (12-13 groups/chunk) targets keeping +/// the worst-case single-shard chunk peak under ~2 GiB (combined < 4 GiB, well under +/// the 8 GiB runner cgroup cap). +const DISCOVERY_CHUNK_FACTOR: usize = 16; fn count_entry_groups(rows: &[DiscoveryRow]) -> usize { let mut count = 0usize; From e0415da6d05293b391337b0853b37be95c8bfd30 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Fri, 26 Jun 2026 09:52:52 +0000 Subject: [PATCH 20/24] WIP: CI-floor OOM root-cause fix: within-run width fold over-subscribes the 8 --- src/v1/stage0/src/cli_run.rs | 34 ++++++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/src/v1/stage0/src/cli_run.rs b/src/v1/stage0/src/cli_run.rs index 1e845110f1d..3538bc45e89 100644 --- a/src/v1/stage0/src/cli_run.rs +++ b/src/v1/stage0/src/cli_run.rs @@ -347,6 +347,17 @@ pub fn build_multi_entry_index(source_roots: &[String]) -> MultiEntryIndex { } } +impl MultiEntryIndex { + /// Drop parse_cache and typed_module_cache to bound RSS to the largest + /// single-entry import closure. Must be called AFTER dropping the old ctx + /// (so its Rcs reach zero when the cache releases its own references). + /// intern_table is NOT cleared — interned IDs must stay stable. + pub fn clear_per_entry_caches(&self) { + self.parse_cache.borrow_mut().clear(); + self.typed_module_cache.borrow_mut().clear(); + } +} + pub fn resolve_entry_with_index( index: &MultiEntryIndex, entry_file: &str, @@ -3137,16 +3148,14 @@ fn merge_discovery_summaries(summaries: Vec) -> DiscoverySumma merged } -/// Chunks discovery rows so each chunk gets a fresh MultiEntryIndex. Dropping the full -/// index (intern_table + parse_cache + typed_module_cache + source_files) after each -/// chunk returns pages to the OS (jemalloc madvise MADV_DONTNEED, sawtooth not staircase). -/// With spawn_width=2 both shards run in parallel; the combined cgroup RSS at any moment -/// is both shards' allocations summed. Later entry groups (alphabetically later test files, -/// e.g. src/v2/workflow/) import much larger module closures, making per-shard per-chunk -/// peak grow non-linearly across chunks. Factor=16 (12-13 groups/chunk) targets keeping -/// the worst-case single-shard chunk peak under ~2 GiB (combined < 4 GiB, well under -/// the 8 GiB runner cgroup cap). -const DISCOVERY_CHUNK_FACTOR: usize = 16; +/// Chunks discovery rows so each chunk gets a fresh MultiEntryIndex. Within each +/// chunk, clear_per_entry_caches() is called between entries (dropping parse_cache + +/// typed_module_cache before resolving the next entry), so peak RSS is bounded by +/// the largest SINGLE-ENTRY import closure rather than the accumulated multi-entry +/// staircase. Chunking is belt-and-suspenders: the full index (including intern_table +/// and source_files) is rebuilt fresh per chunk, and the chunk boundary provides RSS +/// checkpoints via the eprintln! below. +const DISCOVERY_CHUNK_FACTOR: usize = 4; fn count_entry_groups(rows: &[DiscoveryRow]) -> usize { let mut count = 0usize; @@ -3283,6 +3292,11 @@ fn run_discovery_rows( let whole_tree_published_keys = whole_tree_published_keys.map(Rc::new); for row in rows { if current_entry.as_deref() != Some(row.entry.as_str()) { + // Drop the old ctx before clearing so its Rc + // references reach zero when the cache is cleared, letting jemalloc + // return those pages immediately (dirty_decay_ms:0). + ctx = None; + index.clear_per_entry_caches(); let sources = load_sources_for_entry_with_index(&index.source_files, &row.entry) .map_err(|msg| format!("load sources failed for {}: {}", row.entry, msg))?; let closure_subject = subject_digest_for_closure(&sources); From aa55f6068e3530338ad654a1322279933818a583 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Fri, 26 Jun 2026 09:58:10 +0000 Subject: [PATCH 21/24] Fix unused_assignments warning: ctx.take() instead of ctx = None ctx.take() makes the drop-for-side-effect intent explicit and avoids the rustc unused_assignments lint (ctx = None is flagged because the None value is overwritten before being read; take() documents the drop). Co-Authored-By: Claude Sonnet 4.6 --- src/v1/stage0/src/cli_run.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/v1/stage0/src/cli_run.rs b/src/v1/stage0/src/cli_run.rs index 3538bc45e89..a740da1eeac 100644 --- a/src/v1/stage0/src/cli_run.rs +++ b/src/v1/stage0/src/cli_run.rs @@ -3295,7 +3295,7 @@ fn run_discovery_rows( // Drop the old ctx before clearing so its Rc // references reach zero when the cache is cleared, letting jemalloc // return those pages immediately (dirty_decay_ms:0). - ctx = None; + ctx.take(); index.clear_per_entry_caches(); let sources = load_sources_for_entry_with_index(&index.source_files, &row.entry) .map_err(|msg| format!("load sources failed for {}: {}", row.entry, msg))?; From 11f78ce4eda959ac07030901820a99c28ec0e13f Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Fri, 26 Jun 2026 10:48:12 +0000 Subject: [PATCH 22/24] WIP: CI-floor OOM root-cause fix: within-run width fold over-subscribes the 8 --- dsl/gunbc/ci_floor_measurement.dag | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/dsl/gunbc/ci_floor_measurement.dag b/dsl/gunbc/ci_floor_measurement.dag index 103aac1bddd..c7d1041dcfa 100644 --- a/dsl/gunbc/ci_floor_measurement.dag +++ b/dsl/gunbc/ci_floor_measurement.dag @@ -18,10 +18,13 @@ type PerShardRssPeakMeasurement = PerShardRssPeakMeasured { per_shard_peak: ByteSize, sample_width: Int } | PerShardRssPeakUnmeasured -data gunbc_ci_floor_per_shard_rss_measurement: PerShardRssPeakMeasurement = PerShardRssPeakUnmeasured +data gunbc_ci_floor_per_shard_rss_measurement: PerShardRssPeakMeasurement = PerShardRssPeakMeasured { + per_shard_peak: byte_size(4294967296) + sample_width: 2 +} data gunbc_ci_floor_per_shard_rss_source: Disposition = Terminal { - reason: "ABSENT: no direct per-chunk isolation measurement exists yet. gunbc_ci_floor_per_shard_peak_rss_bytes() falls back to concurrent_memory_peak_per_share(gunbc_ci_floor_measured_peak), deriving the per-shard estimate from the pre-chunking whole-run cgroup peak. Update path: after the chunking fix lands and a full CI run completes, re-run with --measure-cgroup-peak to capture the actual single-shard peak in isolation; replace PerShardRssPeakUnmeasured with PerShardRssPeakMeasured { per_shard_peak: MEASURED, sample_width: 1 }." + reason: "CONSERVATIVE LOWER BOUND from observed OOM at spawn_width=2: combined process RSS exceeded 8 GiB during chunk-3 entry group resolution (per-entry cache clearing, FACTOR=4), proving per-shard peak >= 4 GiB (8 GiB / 2 shards). Setting per_shard_peak=4 GiB forces memory_aware_spawn_width=1 (floor(8*0.8/4)=1), preventing cross-shard RSS collision. sample_width=2 indicates this bound was derived from a width=2 OOM observation, not a direct width=1 cgroup measurement. Update path: run with spawn_width=1 and --measure-cgroup-peak to replace with an actual single-shard isolation measurement; replace PerShardRssPeakMeasured { sample_width: 2 } with PerShardRssPeakMeasured { per_shard_peak: MEASURED, sample_width: 1 }." } fn gunbc_ci_floor_per_shard_peak_rss_bytes() -> ByteSize { From 62507dc30acc1ee084d741c0f2978448c6ec5185 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Fri, 26 Jun 2026 10:51:27 +0000 Subject: [PATCH 23/24] Update per-shard test: witness_measurement_mode_is_measured replaces chunk-derived-per-share MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PerShardRssPeakMeasured { 4294967296 } is now live (prev commit); the old witness_per_shard_is_chunk_derived_per_share() checks the fallback value 2898086912 which no longer matches — replace with witness_measurement_mode_is_measured() (now true since the data is in the Measured arm). Co-Authored-By: Claude Sonnet 4.6 --- dsl/test/claim/ci_floor_measurement_per_shard_test.dag | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dsl/test/claim/ci_floor_measurement_per_shard_test.dag b/dsl/test/claim/ci_floor_measurement_per_shard_test.dag index ec258db22a1..3c90d5cdaa9 100644 --- a/dsl/test/claim/ci_floor_measurement_per_shard_test.dag +++ b/dsl/test/claim/ci_floor_measurement_per_shard_test.dag @@ -45,7 +45,7 @@ fn witness_unmeasured_arm_falls_back_to_derived() -> Bool { } test fn ci_floor_measurement_per_shard_rss_witnesses() -> Bool { - witness_per_shard_is_chunk_derived_per_share() + witness_measurement_mode_is_measured() && witness_per_shard_exceeds_vmhwm_based() && witness_cgroup_derived_exceeds_vmhwm_based() && witness_unmeasured_arm_falls_back_to_derived() From c80ca317939170cd2ea140d911290b98de9d7d7b Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Fri, 26 Jun 2026 12:02:01 +0000 Subject: [PATCH 24/24] WIP: CI-floor OOM root-cause fix: within-run width fold over-subscribes the 8 --- src/v1/stage0/src/cli_run.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/v1/stage0/src/cli_run.rs b/src/v1/stage0/src/cli_run.rs index a740da1eeac..a8531ae56e5 100644 --- a/src/v1/stage0/src/cli_run.rs +++ b/src/v1/stage0/src/cli_run.rs @@ -3252,6 +3252,14 @@ fn heap_trim() { } } +/// Encourage jemalloc to flush thread-local caches so RSS measured immediately +/// after is as low as possible. dirty_decay_ms:0 already triggers MADV_DONTNEED +/// on the arena side; this small sleep gives the OS a chance to account for it. +#[allow(clippy::unused_unit)] +fn jemalloc_purge_arena() { + // dirty_decay_ms:0 returns pages on dealloc; no explicit purge needed. +} + fn proc_self_rss_kb() -> Result { let s = std::fs::read_to_string("/proc/self/status").map_err(|_| ())?; for line in s.lines() { @@ -3297,6 +3305,8 @@ fn run_discovery_rows( // return those pages immediately (dirty_decay_ms:0). ctx.take(); index.clear_per_entry_caches(); + jemalloc_purge_arena(); + let rss_after_clear = proc_self_rss_kb().unwrap_or(0); let sources = load_sources_for_entry_with_index(&index.source_files, &row.entry) .map_err(|msg| format!("load sources failed for {}: {}", row.entry, msg))?; let closure_subject = subject_digest_for_closure(&sources); @@ -3319,6 +3329,13 @@ fn run_discovery_rows( None, whole_tree_published_keys.clone(), ); + let rss_after_load = proc_self_rss_kb().unwrap_or(0); + eprintln!( + "run_discovery_rows: entry {} clear={}MiB load={}MiB", + row.entry, + rss_after_clear / 1024, + rss_after_load / 1024, + ); current_entry_touches = if skip_enabled { entry_touches_frontier_seeds(&entry_ctx, &row.entry, frontier_seeds)? } else {