-
Notifications
You must be signed in to change notification settings - Fork 1
fix: use CPU-aware prover scheduler #897
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
62301c8
9dddd91
7b95345
b5256ee
55a8b7b
4c00552
5bba8d4
3b3cc86
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,6 +19,8 @@ use math::{ | |
| polynomial::Polynomial, | ||
| }; | ||
|
|
||
| #[cfg(all(feature = "parallel", not(feature = "cuda")))] | ||
| use rayon::prelude::IntoParallelRefIterator; | ||
| #[cfg(feature = "parallel")] | ||
| use rayon::prelude::{IntoParallelIterator, ParallelIterator}; | ||
|
|
||
|
|
@@ -700,17 +702,25 @@ fn estimate_table_vram_bytes(main_cols: usize, aux_cols: usize, lde_size: usize) | |
| /// Only OS driver threads block here (see `run_admitted`) — never rayon | ||
| /// workers, whose pool the admitted tables use internally and which a | ||
| /// blocked worker would starve. | ||
| /// | ||
| /// Device-only mechanism: CPU builds have no device allocation to admit | ||
| /// against, so their `run_admitted` arms take the gate and ignore it. That is | ||
| /// what the `allow(dead_code)` attributes here are for — `multi_prove` still | ||
| /// constructs the gate unconditionally so the three arms share one signature. | ||
| #[cfg_attr(not(feature = "cuda"), allow(dead_code))] | ||
| struct VramGate { | ||
| used: std::sync::Mutex<u64>, | ||
| freed: std::sync::Condvar, | ||
| budget: u64, | ||
| } | ||
|
|
||
| #[cfg_attr(not(feature = "cuda"), allow(dead_code))] | ||
| struct VramPermit<'a> { | ||
| gate: &'a VramGate, | ||
| bytes: u64, | ||
| } | ||
|
|
||
| #[cfg_attr(not(feature = "cuda"), allow(dead_code))] | ||
| impl VramGate { | ||
| fn new(budget: u64) -> Self { | ||
| Self { | ||
|
|
@@ -741,18 +751,40 @@ impl Drop for VramPermit<'_> { | |
| } | ||
| } | ||
|
|
||
| /// Debug-only contract check for `run_admitted`: the result slots are addressed | ||
| /// by `order`'s *values*, so every value must index `estimates` and appear at | ||
| /// most once. Callers pass either a full permutation or one group of one, and a | ||
| /// grouping bug would otherwise surface as a confusing `take()` panic inside a | ||
| /// task rather than here. | ||
| fn debug_check_order(order: &[usize], estimates: &[u64]) { | ||
| if cfg!(debug_assertions) { | ||
| let mut seen = vec![false; estimates.len()]; | ||
| for &idx in order { | ||
| assert!( | ||
| idx < estimates.len(), | ||
| "run_admitted: order names table {idx}, estimates has {}", | ||
| estimates.len() | ||
| ); | ||
| assert!(!seen[idx], "run_admitted: order names table {idx} twice"); | ||
| seen[idx] = true; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// Run `task` once per table index on `workers` OS driver threads, admitting | ||
| /// each index through `gate` with its estimated bytes. `order` fixes the | ||
| /// start order (heaviest table first, so the long pole starts early and small | ||
| /// tables fill around it — the fixed chunks this replaces made every table | ||
| /// wait for the slowest of its chunk). Returns one slot per original index. | ||
| #[cfg(feature = "cuda")] | ||
| fn run_admitted<T: Send>( | ||
| order: &[usize], | ||
| estimates: &[u64], | ||
| gate: &VramGate, | ||
| workers: usize, | ||
| task: impl Fn(usize) -> T + Sync, | ||
| ) -> Vec<Option<T>> { | ||
| debug_check_order(order, estimates); | ||
| let results: Vec<std::sync::Mutex<Option<T>>> = estimates | ||
| .iter() | ||
| .map(|_| std::sync::Mutex::new(None)) | ||
|
|
@@ -781,6 +813,54 @@ fn run_admitted<T: Send>( | |
| .collect() | ||
| } | ||
|
|
||
| /// CPU version of the table scheduler. There is no device admission to wait | ||
| /// on here, and the work underneath each table already uses Rayon internally. | ||
| /// Keep the outer scheduling inside the same Rayon pool instead of creating | ||
| /// driver OS threads: otherwise every table task launches nested Rayon work | ||
| /// from outside the pool and loses the work-stealing behavior of the original | ||
| /// phase scheduler. | ||
| #[cfg(all(not(feature = "cuda"), feature = "parallel"))] | ||
| fn run_admitted<T: Send>( | ||
| order: &[usize], | ||
| estimates: &[u64], | ||
| _gate: &VramGate, | ||
| workers: usize, | ||
| task: impl Fn(usize) -> T + Sync, | ||
| ) -> Vec<Option<T>> { | ||
| debug_check_order(order, estimates); | ||
| // No interior mutability here, unlike the `cuda` arm: `collect()` joins | ||
| // each chunk, so every slot is written from this thread. | ||
| let mut results: Vec<Option<T>> = (0..estimates.len()).map(|_| None).collect(); | ||
|
|
||
| for chunk in order.chunks(workers.max(1)) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Medium (performance) — Keeping the work inside the Rayon pool (the stated goal of this arm) does not require chunking. The same atomic-cursor loop as the CUDA version, driven by let cursor = std::sync::atomic::AtomicUsize::new(0);
rayon::scope(|scope| {
for _ in 0..workers.max(1).min(order.len().max(1)) {
scope.spawn(|_| loop {
let pos = cursor.fetch_add(1, Ordering::Relaxed);
if pos >= order.len() { return; }
let idx = order[pos];
*results[idx].lock().unwrap() = Some(task(idx));
});
}
});Nested Rayon work from a pool thread participates in work-stealing rather than blocking it, so the oversubscription this PR is fixing does not come back. If the chunked form was chosen because it measured faster than this, please say so in the comment — otherwise the barrier looks like collateral damage rather than intent. |
||
| let chunk_results: Vec<(usize, T)> = | ||
| chunk.par_iter().map(|&idx| (idx, task(idx))).collect(); | ||
| for (idx, result) in chunk_results { | ||
| results[idx] = Some(result); | ||
| } | ||
| } | ||
|
|
||
| results | ||
| } | ||
|
|
||
| /// Sequential fallback when the prover is built without its default | ||
| /// `parallel` feature. | ||
| #[cfg(all(not(feature = "cuda"), not(feature = "parallel")))] | ||
| fn run_admitted<T: Send>( | ||
| order: &[usize], | ||
| estimates: &[u64], | ||
| _gate: &VramGate, | ||
| _workers: usize, | ||
| task: impl Fn(usize) -> T + Sync, | ||
| ) -> Vec<Option<T>> { | ||
| debug_check_order(order, estimates); | ||
| let mut results: Vec<Option<T>> = (0..estimates.len()).map(|_| None).collect(); | ||
| for &idx in order { | ||
| results[idx] = Some(task(idx)); | ||
| } | ||
| results | ||
| } | ||
|
|
||
| /// Table indices sorted heaviest-first by estimate. | ||
| fn heaviest_first(estimates: &[u64]) -> Vec<usize> { | ||
| let mut order: Vec<usize> = (0..estimates.len()).collect(); | ||
|
|
@@ -4483,6 +4563,15 @@ pub trait IsStarkProver< | |
| &boundary_coefficients, | ||
| )?; | ||
|
|
||
| // Round 2's sub-op timings travel in a thread-local, so take them here | ||
| // instead of after round 4: this thread re-enters rayon in rounds 3 and | ||
| // 4, and a worker that blocks there can run another table's whole | ||
| // scheduler task on top of this frame and consume the slot. Keeping | ||
| // store and take adjacent is what pins the pair to one thread — see | ||
| // `instruments::reset_all`. | ||
| #[cfg(feature = "instruments")] | ||
| let r2_sub = crate::instruments::take_r2_sub(); | ||
|
|
||
| // >>>> Send commitments: [H₁], [H₂] | ||
| transcript.append_bytes(&round_2_result.composition_poly_root); | ||
|
|
||
|
|
@@ -4579,8 +4668,7 @@ pub trait IsStarkProver< | |
| #[cfg(feature = "instruments")] | ||
| { | ||
| let zero = Duration::ZERO; | ||
| let (r2_constraints, r2_fft, r2_merkle) = | ||
| crate::instruments::take_r2_sub().unwrap_or((zero, zero, zero)); | ||
| let (r2_constraints, r2_fft, r2_merkle) = r2_sub.unwrap_or((zero, zero, zero)); | ||
| let (r4_fft, r4_merkle, r4_deep_comp, r4_queries) = | ||
| crate::instruments::take_r4_sub().unwrap_or((zero, zero, zero, zero)); | ||
| crate::instruments::store_round_sub_ops(crate::instruments::TableSubOps { | ||
|
|
@@ -4740,3 +4828,73 @@ fn print_bus_balance_report<FieldExtension>( | |
| } | ||
| } | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod scheduler_tests { | ||
| use super::*; | ||
| use std::sync::atomic::{AtomicUsize, Ordering}; | ||
|
|
||
| /// Runs `run_admitted` over `order` with `n` tables and returns how many | ||
| /// times each table's task ran, plus the slots it wrote. | ||
| fn calls_and_results( | ||
| order: &[usize], | ||
| n: usize, | ||
| workers: usize, | ||
| ) -> (Vec<usize>, Vec<Option<usize>>) { | ||
| let estimates = vec![1u64; n]; | ||
| let gate = VramGate::new(u64::MAX); | ||
| let calls: Vec<AtomicUsize> = (0..n).map(|_| AtomicUsize::new(0)).collect(); | ||
| let out = run_admitted(order, &estimates, &gate, workers, |idx| { | ||
| calls[idx].fetch_add(1, Ordering::Relaxed); | ||
| idx | ||
| }); | ||
| ( | ||
| calls.iter().map(|c| c.load(Ordering::Relaxed)).collect(), | ||
| out, | ||
| ) | ||
| } | ||
|
|
||
| /// The scheduler's contract, independent of how many tables run at once: | ||
| /// each table's task runs exactly once and its result lands on its own | ||
| /// index. Nothing else pins this, and a chunking or sizing bug would only | ||
| /// show up on hosts with a particular core count. | ||
| #[test] | ||
| fn every_table_runs_once_and_lands_on_its_own_slot() { | ||
| for n in [1usize, 2, 5, 21] { | ||
| // Heaviest-first, which is the order `multi_prove` passes. | ||
| let estimates: Vec<u64> = (0..n).map(|i| (n - i) as u64).collect(); | ||
| let order = heaviest_first(&estimates); | ||
| for workers in [1usize, 2, 3, n, n + 5] { | ||
| let (calls, out) = calls_and_results(&order, n, workers); | ||
| assert!( | ||
| calls.iter().all(|&c| c == 1), | ||
| "n={n} workers={workers} calls={calls:?}" | ||
| ); | ||
| for (idx, slot) in out.iter().enumerate() { | ||
| assert_eq!(*slot, Some(idx), "n={n} workers={workers}"); | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// What the `Staged(depth)` arm passes once the barrier runs over groups: | ||
| /// a subset of the permutation. The untouched tables must stay empty. | ||
| #[test] | ||
| fn a_group_fills_only_its_own_slots() { | ||
| let n = 9; | ||
| let group = [7usize, 1, 4]; | ||
| let (calls, out) = calls_and_results(&group, n, 2); | ||
| for idx in 0..n { | ||
| let ran = group.contains(&idx); | ||
| assert_eq!(calls[idx], ran as usize, "table {idx}"); | ||
| assert_eq!(out[idx], ran.then_some(idx), "table {idx}"); | ||
| } | ||
| } | ||
|
|
||
| #[test] | ||
| fn an_empty_order_runs_nothing() { | ||
| let (calls, out) = calls_and_results(&[], 4, 3); | ||
| assert!(calls.iter().all(|&c| c == 0)); | ||
| assert!(out.iter().all(|s| s.is_none())); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Low (dead code) — with
run_admittednowcuda-only, the whole admission mechanism (VramGate::acquire,VramPermit, itsDrop) is unreachable on CPU builds; the three#[cfg_attr(not(feature = "cuda"), allow(dead_code))]attributes exist only to silence that. The_gate: &VramGateparameter on both new arms is threaded through purely as a no-op, andVramGate::newis still called unconditionally at the top ofmulti_prove.Since the gate is now a device concept only,
#[cfg(feature = "cuda")]on the struct/impl plus dropping the parameter from the CPU arms would delete the attributes and the vestigial argument outright. That needs the two call sites cfg-gated on the arg, so it may not be worth it — but if you keep it as-is, please note in theVramGatedoc that it is a CUDA-only mechanism, otherwise theallow(dead_code)attributes read as unexplained.Also, the
+ Sendadded to this signature isn't required: the spawned workers capture&task, soSyncalone suffices. Harmless if it's for signature symmetry across the three arms, just flagging it isn't load-bearing.