RFC 004: default spin_budget_cycles=2000 (DEFAULT_SPIN_BUDGET_CYCLES, ~0.5us @3.7GHz), gate spinner cap on pool size (n<=1:0, 2..=4:n, n>=5:0) replacing N/2. From spin_sweep fork-join knee: ~4x p50 win at n<=4, pure cost at n>=8. Budget inert on large pools via 0 cap; explicit max_spinners() still overrides.

This commit is contained in:
smarm-agent
2026-06-14 18:27:39 +00:00
parent 7fcc9f2ec5
commit 4c1e5f19f1
+26 -7
View File
@@ -131,6 +131,13 @@ use std::thread;
/// Default capacity of the actor slot table. Slots are ~256 bytes, so the /// Default capacity of the actor slot table. Slots are ~256 bytes, so the
/// default costs ~4 MiB, allocated once at `init`. See [`Config::max_actors`]. /// default costs ~4 MiB, allocated once at `init`. See [`Config::max_actors`].
pub const DEFAULT_MAX_ACTORS: usize = 16_384; pub const DEFAULT_MAX_ACTORS: usize = 16_384;
/// RFC 004: default scheduler spin budget before parking, in TSC cycles.
/// ≈0.5µs at a 3.7 GHz nominal TSC. Sized from the spin_sweep fork-join knee:
/// the fork→join handoff is sub-µs, so ~0.5µs of spin keeps a worker hot across
/// it for a ~4× p50 latency win, and budgets past ~5k cycles buy nothing but
/// idle CPU. Only effective where the default spinner cap permits spinning
/// (small pools, n≤4); see the cap resolution in `init`.
pub const DEFAULT_SPIN_BUDGET_CYCLES: u64 = 2_000;
/// Runtime configuration. /// Runtime configuration.
/// ///
@@ -174,7 +181,7 @@ impl Config {
stack_pool_cap: n * 4, stack_pool_cap: n * 4,
max_actors: DEFAULT_MAX_ACTORS, max_actors: DEFAULT_MAX_ACTORS,
wake_slot: false, wake_slot: false,
spin_budget_cycles: 0, spin_budget_cycles: DEFAULT_SPIN_BUDGET_CYCLES,
max_spinners: None, max_spinners: None,
} }
} }
@@ -193,7 +200,7 @@ impl Config {
stack_pool_cap: max * 4, stack_pool_cap: max * 4,
max_actors: DEFAULT_MAX_ACTORS, max_actors: DEFAULT_MAX_ACTORS,
wake_slot: false, wake_slot: false,
spin_budget_cycles: 0, spin_budget_cycles: DEFAULT_SPIN_BUDGET_CYCLES,
max_spinners: None, max_spinners: None,
} }
} }
@@ -294,7 +301,7 @@ impl Default for Config {
stack_pool_cap: avail * 4, stack_pool_cap: avail * 4,
max_actors: DEFAULT_MAX_ACTORS, max_actors: DEFAULT_MAX_ACTORS,
wake_slot: false, wake_slot: false,
spin_budget_cycles: 0, spin_budget_cycles: DEFAULT_SPIN_BUDGET_CYCLES,
max_spinners: None, max_spinners: None,
} }
} }
@@ -888,10 +895,22 @@ pub struct Runtime {
/// Initialise the runtime with the given config. Returns a reusable handle. /// Initialise the runtime with the given config. Returns a reusable handle.
pub fn init(config: Config) -> Runtime { pub fn init(config: Config) -> Runtime {
let n = config.resolved_thread_count(); let n = config.resolved_thread_count();
// RFC 004: default spinner cap is N/2 (capping at half keeps half the pool // RFC 004: default spinner cap is gated on pool size, from the spin_sweep
// hot, parks the rest). With N=1 this is 0 — a lone thread has no // fork-join data. Spinning is a ~4× p50 latency win at n≤4 workers but pure
// cross-thread handoff to hide, so it never spins. // cost at n≥8 (large pools stay hot on their own, so a spin budget only
let max_spinners = config.max_spinners.unwrap_or((n / 2) as u32); // steals CPU from workers doing real drain). So:
// n == 1 → 0 : a lone thread has no cross-thread handoff to hide.
// 2..=4 → n : the win regime; let every worker spin its budget.
// n >= 5 → 0 : park immediately, exactly the historical behaviour —
// the non-zero DEFAULT_SPIN_BUDGET_CYCLES is inert here
// because no worker is permitted to enter the spin path.
// An explicit Config::max_spinners(_) overrides this (e.g. a future autotune
// utility that picks budget+cap from measured load).
let max_spinners = config.max_spinners.unwrap_or(match n {
0 | 1 => 0,
2..=4 => n as u32,
_ => 0,
});
Runtime { Runtime {
inner: RuntimeInner::new( inner: RuntimeInner::new(
n, n,