11//! Automatic `StorageMode` selection from an analytical peak-RAM estimate.
2+ //!
3+ //! `FORCE_DISK_SPILL` env var forces `StorageMode::Disk` regardless of the
4+ //! estimate.
25
36use crate :: tables:: bitwise:: {
47 NUM_ROWS as BITWISE_ROWS , bus_interactions as bitwise_buses, cols:: NUM_COLUMNS as BITWISE_COLS ,
@@ -27,6 +30,7 @@ use crate::tables::register::{
2730} ;
2831use crate :: tables:: shift:: { bus_interactions as shift_buses, cols:: NUM_COLUMNS as SHIFT_COLS } ;
2932use crate :: tables:: trace_builder:: TableLengths ;
33+ use stark:: prover:: table_parallelism;
3034use stark:: storage_mode:: StorageMode ;
3135use sysinfo:: System ;
3236
@@ -38,8 +42,8 @@ const MEMORY_CELL_BYTES: u64 = 32;
3842const INSTRUCTION_MAP_BYTES_PER_ROW : u64 = 32 ;
3943
4044/// 9/10 budget headroom for OS, other processes, and allocator slack.
41- pub ( crate ) const SAFETY_FRACTION_NUM : u64 = 9 ;
42- pub ( crate ) const SAFETY_FRACTION_DEN : u64 = 10 ;
45+ const SAFETY_FRACTION_NUM : u64 = 9 ;
46+ const SAFETY_FRACTION_DEN : u64 = 10 ;
4347
4448/// `(rows, main_cols, aux_cols, num_main_merkle_trees)` for a single table.
4549type TableSpec = ( u64 , u64 , u64 , u64 ) ;
@@ -209,13 +213,22 @@ fn table_specs(lengths: &TableLengths) -> Vec<TableSpec> {
209213 specs
210214}
211215
216+ /// Estimates heap from `lengths` and `blowup_factor`. Picks `Disk` if the
217+ /// estimate is greater than available RAM, else `Ram`. `FORCE_DISK_SPILL` env
218+ /// var forces `Disk`.
219+ pub fn decide ( lengths : & TableLengths , blowup_factor : u8 ) -> StorageMode {
220+ if std:: env:: var ( "FORCE_DISK_SPILL" ) . is_ok ( ) {
221+ log:: info!( "storage_mode: Disk (forced via FORCE_DISK_SPILL)" ) ;
222+ return StorageMode :: Disk ;
223+ }
224+ let estimated = peak_bytes ( lengths, blowup_factor, table_parallelism ( ) ) ;
225+ let mode = select_storage_mode ( estimated, available_ram_bytes ( ) ) ;
226+ log:: info!( "estimated_peak_bytes: {estimated}, storage_mode: {mode:?}" ) ;
227+ mode
228+ }
229+
212230/// Peak RAM estimate in bytes for a proof whose trace shape matches `lengths`.
213- ///
214- /// `blowup_factor` is `ProofOptions::blowup_factor`. `table_parallelism` is the
215- /// `k` used by `multi_prove_with_mode` to chunk rounds 2-4; pass
216- /// `stark::prover::table_parallelism()` so the worst-case-chunk transient term
217- /// matches the runtime.
218- pub fn peak_bytes ( lengths : & TableLengths , blowup_factor : u8 , table_parallelism : usize ) -> u64 {
231+ fn peak_bytes ( lengths : & TableLengths , blowup_factor : u8 , table_parallelism : usize ) -> u64 {
219232 let blowup = blowup_factor as u64 ;
220233 let k = table_parallelism. max ( 1 ) ;
221234 let specs = table_specs ( lengths) ;
@@ -268,53 +281,23 @@ pub fn peak_bytes(lengths: &TableLengths, blowup_factor: u8, table_parallelism:
268281 . saturating_add ( state_total)
269282}
270283
271- /// User cap ∩ OS available, or None if both are unknown.
272- fn effective_budget ( available : Option < u64 > , cap : Option < u64 > ) -> Option < u64 > {
273- match ( cap, available) {
274- ( Some ( c) , Some ( a) ) => Some ( c. min ( a) ) ,
275- ( Some ( c) , None ) => Some ( c) ,
276- ( None , a) => a,
277- }
278- }
279-
280- /// Disk if `estimated` exceeds 90% of the effective budget, else Ram.
281- /// Defaults to Disk when budget is unknown (sysinfo failure + no cap).
282- pub fn select_storage_mode (
283- estimated : u64 ,
284- available : Option < u64 > ,
285- cap : Option < u64 > ,
286- ) -> StorageMode {
287- let Some ( budget) = effective_budget ( available, cap) else {
288- log:: warn!(
289- "Auto disk-spill: sysinfo could not read system memory and no cap set, \
290- defaulting to Disk."
291- ) ;
284+ /// `Disk` if `estimated` exceeds `available` minus a safety margin, else
285+ /// `Ram`. Defaults to `Disk` when `available` is `None`.
286+ fn select_storage_mode ( estimated : u64 , available : Option < u64 > ) -> StorageMode {
287+ let Some ( available) = available else {
288+ log:: warn!( "Auto disk-spill: sysinfo could not read system memory, defaulting to Disk." ) ;
292289 return StorageMode :: Disk ;
293290 } ;
294- let threshold = budget. saturating_mul ( SAFETY_FRACTION_NUM ) / SAFETY_FRACTION_DEN ;
295-
291+ let threshold = available. saturating_mul ( SAFETY_FRACTION_NUM ) / SAFETY_FRACTION_DEN ;
296292 if estimated > threshold {
297293 StorageMode :: Disk
298294 } else {
299- // `cap.is_none()` plus an `effective_budget` that returned `Some` means
300- // `available` must be `Some` (see `effective_budget`).
301- if cap. is_none ( ) && estimated. saturating_mul ( 4 ) >= available. unwrap ( ) . saturating_mul ( 3 ) {
302- log:: warn!(
303- "Auto disk-spill picked Ram with estimated_peak={estimated} bytes near \
304- available={available:?}. Set max_ram_bytes to bound the budget to a \
305- cgroup limit if running in a container."
306- ) ;
307- }
308295 StorageMode :: Ram
309296 }
310297}
311298
312- /// OS-available RAM, or None if sysinfo can't read it (e.g. stripped containers).
313- /// Returns `Some(0)` on near-OOM so callers force Disk rather than fall back to Ram.
314- ///
315- /// Reads host `/proc/meminfo`, not cgroup limits — set `max_ram_bytes` in
316- /// containerized environments to bound the budget to the container's limit.
317- pub fn available_ram_bytes ( ) -> Option < u64 > {
299+ /// OS-available RAM, or `None` if sysinfo can't read it.
300+ fn available_ram_bytes ( ) -> Option < u64 > {
318301 let mut sys = System :: new ( ) ;
319302 sys. refresh_memory ( ) ;
320303 // total_memory == 0 means sysinfo can't read; otherwise available is real.
@@ -400,56 +383,93 @@ mod tests {
400383 #[ test]
401384 fn select_ram_when_estimate_below_threshold ( ) {
402385 // 10 GB estimated, 32 GB available → threshold 28.8 GB → Ram.
403- let mode = select_storage_mode ( 10 * GB , Some ( 32 * GB ) , None ) ;
386+ let mode = select_storage_mode ( 10 * GB , Some ( 32 * GB ) ) ;
404387 assert_eq ! ( mode, StorageMode :: Ram ) ;
405388 }
406389
407390 #[ test]
408391 fn select_disk_when_estimate_exceeds_threshold ( ) {
409392 // 30 GB estimated, 32 GB available → threshold 28.8 GB → Disk.
410- let mode = select_storage_mode ( 30 * GB , Some ( 32 * GB ) , None ) ;
393+ let mode = select_storage_mode ( 30 * GB , Some ( 32 * GB ) ) ;
411394 assert_eq ! ( mode, StorageMode :: Disk ) ;
412395 }
413396
414397 #[ test]
415- fn cap_forces_disk_when_smaller_than_available ( ) {
416- // 10 GB estimated, 64 GB available (would be Ram), but cap=4 GB
417- // → threshold = 4 × 0.9 = 3.6 GB → Disk.
418- let mode = select_storage_mode ( 10 * GB , Some ( 64 * GB ) , Some ( 4 * GB ) ) ;
398+ fn unknown_available_defaults_to_disk ( ) {
399+ let mode = select_storage_mode ( peak_bytes ( & empty_lengths ( ) , 2 , ALL_TABLES ) , None ) ;
419400 assert_eq ! ( mode, StorageMode :: Disk ) ;
420401 }
421402
422- #[ test]
423- fn cap_ignored_when_larger_than_available ( ) {
424- // available=8 GB dominates a cap of 64 GB.
425- // threshold = 8 × 0.9 = 7.2 GB, estimate 10 GB → Disk.
426- let mode = select_storage_mode ( 10 * GB , Some ( 8 * GB ) , Some ( 64 * GB ) ) ;
427- assert_eq ! ( mode, StorageMode :: Disk ) ;
428- }
403+ /// Asserts predicted [`peak_bytes`] does not underestimate jemalloc-measured
404+ /// heap during a proof.
405+ mod calibration {
406+ use super :: * ;
407+ use crate :: tables:: MaxRowsConfig ;
408+ use crate :: tables:: trace_builder:: count_table_lengths;
409+ use crate :: test_utils:: { asm_elf_bytes, run_asm_elf} ;
410+ use stark:: proof:: options:: GoldilocksCubicProofOptions ;
411+ use std:: sync:: Arc ;
412+ use std:: sync:: atomic:: { AtomicBool , AtomicUsize , Ordering } ;
413+ use std:: thread;
414+ use std:: time:: Duration ;
415+ use tikv_jemalloc_ctl:: { epoch, stats} ;
416+
417+ #[ global_allocator]
418+ static ALLOC : tikv_jemallocator:: Jemalloc = tikv_jemallocator:: Jemalloc ;
419+
420+ fn allocated_bytes ( ) -> usize {
421+ epoch:: advance ( ) . ok ( ) ;
422+ stats:: allocated:: read ( ) . unwrap_or ( 0 )
423+ }
429424
430- #[ test]
431- fn tiny_cap_always_forces_disk ( ) {
432- let mode = select_storage_mode (
433- peak_bytes ( & empty_lengths ( ) , 2 , ALL_TABLES ) ,
434- Some ( 64 * GB ) ,
435- Some ( 1_000_000 ) ,
436- ) ;
437- assert_eq ! ( mode, StorageMode :: Disk ) ;
438- }
425+ #[ test]
426+ fn peak_bytes_does_not_underestimate_measured_heap ( ) {
427+ let ( elf, logs, _) = run_asm_elf ( "fib_iterative_372k" ) ;
428+ let elf_bytes = asm_elf_bytes ( "fib_iterative_372k" ) ;
439429
440- #[ test]
441- fn unknown_available_with_no_cap_defaults_to_disk ( ) {
442- // sysinfo failed and no cap was set. Default to Disk: sysinfo fails
443- // in stripped-down containers where Ram would OOM. Pass max_ram_bytes
444- // to opt out on a known-sized machine.
445- let mode = select_storage_mode ( peak_bytes ( & empty_lengths ( ) , 2 , ALL_TABLES ) , None , None ) ;
446- assert_eq ! ( mode, StorageMode :: Disk ) ;
447- }
430+ let max_rows = MaxRowsConfig :: default ( ) ;
431+ let lengths = count_table_lengths ( & elf, & logs, & max_rows, & [ ] )
432+ . expect ( "count_table_lengths succeeds" ) ;
448433
449- #[ test]
450- fn unknown_available_with_cap_uses_cap_as_budget ( ) {
451- // OS can't report; cap is the whole budget.
452- let mode = select_storage_mode ( 10 * GB , None , Some ( 4 * GB ) ) ;
453- assert_eq ! ( mode, StorageMode :: Disk ) ;
434+ let opts = GoldilocksCubicProofOptions :: with_blowup ( 2 ) . expect ( "blowup=2 is valid" ) ;
435+ let predicted = peak_bytes ( & lengths, opts. blowup_factor , table_parallelism ( ) ) as usize ;
436+
437+ drop ( logs) ;
438+
439+ let baseline = allocated_bytes ( ) ;
440+ let peak = Arc :: new ( AtomicUsize :: new ( baseline) ) ;
441+ let stop = Arc :: new ( AtomicBool :: new ( false ) ) ;
442+
443+ let sampler = {
444+ let peak = Arc :: clone ( & peak) ;
445+ let stop = Arc :: clone ( & stop) ;
446+ thread:: spawn ( move || {
447+ while !stop. load ( Ordering :: Relaxed ) {
448+ peak. fetch_max ( allocated_bytes ( ) , Ordering :: Relaxed ) ;
449+ thread:: sleep ( Duration :: from_millis ( 10 ) ) ;
450+ }
451+ } )
452+ } ;
453+
454+ let _proof = crate :: prove_with_options_and_inputs ( & elf_bytes, & [ ] , & opts, & max_rows)
455+ . expect ( "proof succeeds" ) ;
456+
457+ stop. store ( true , Ordering :: Relaxed ) ;
458+ sampler. join ( ) . expect ( "sampler joins" ) ;
459+
460+ let measured = peak. load ( Ordering :: Relaxed ) . saturating_sub ( baseline) ;
461+
462+ eprintln ! (
463+ "peak_bytes calibration: predicted={predicted} bytes, measured_heap={measured} bytes, ratio={:.2}" ,
464+ predicted as f64 / measured as f64
465+ ) ;
466+
467+ let safety_num = SAFETY_FRACTION_NUM as usize ;
468+ let safety_den = SAFETY_FRACTION_DEN as usize ;
469+ assert ! (
470+ predicted. saturating_mul( safety_den) >= measured. saturating_mul( safety_num) ,
471+ "peak_bytes underestimates measured heap: predicted={predicted}, measured={measured}"
472+ ) ;
473+ }
454474 }
455475}
0 commit comments