Skip to content

Commit 276a41f

Browse files
committed
Merge monotonic busy-time accounting
2 parents 7b943ba + 85d90c6 commit 276a41f

2 files changed

Lines changed: 112 additions & 18 deletions

File tree

README.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -157,12 +157,13 @@ control.
157157

158158
The optional `rapidgzip-core/busy-time-accounting` feature supplies an exact,
159159
live cumulative integral of decoder executing regions, including work in
160-
progress at snapshot time. It reads the monotonic clock and updates one relaxed
161-
wrapping balance at each existing executing-region boundary. Applications can
162-
use this allocation-sensitive signal for scheduling without polling
163-
`busy_workers`. Feature-off builds compile out the clocks, balance, and branch
164-
entirely. Feature-on/off release benchmarks are therefore a required gate for
165-
changes to this instrumentation.
160+
progress at snapshot time. It reads the monotonic clock, updates a relaxed
161+
wrapping balance, and publishes a transition epoch at each existing
162+
executing-region boundary. The epoch lets readers reject count/balance ABA
163+
without locking workers. Applications can use this allocation-sensitive signal
164+
for scheduling without polling `busy_workers`. Feature-off builds compile out
165+
the clocks, counters, and branch entirely. Feature-on/off release benchmarks are
166+
therefore a required gate for changes to this instrumentation.
166167

167168
For ordinary gzip, zlib, and raw-DEFLATE files, telemetry may briefly report
168169
`DecoderPath::MarkerAdmission`. This bounded input-aware screen compares exact

crates/rapidgzip-core/src/runtime.rs

Lines changed: 105 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ use std::sync::{Condvar, Mutex};
1010
use std::time::{Duration, Instant};
1111

1212
const NO_BEST_WORKER_COUNT: usize = usize::MAX;
13+
#[cfg(feature = "busy-time-accounting")]
14+
const ACCOUNTED_TRANSITION_ACTIVE_MASK: u64 = u32::MAX as u64;
15+
#[cfg(feature = "busy-time-accounting")]
16+
const ACCOUNTED_TRANSITION_FINISH: u64 = (1u64 << 32) - 1;
1317

1418
/// Decoder implementation selected for the current input.
1519
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
@@ -171,12 +175,12 @@ pub struct DecoderStats {
171175
///
172176
/// This is `Some` only with the `busy-time-accounting` feature.
173177
/// That feature reads the monotonic clock and updates one relaxed counter at
174-
/// every existing `busy_workers` begin/end boundary. It is intended as an
175-
/// cumulative signal for scheduling and for validating external occupancy
176-
/// samplers. The snapshot includes executing regions that are still in
177-
/// progress at the instant it is collected. Feature-off builds contain
178-
/// neither the clock reads, counter, nor a conditional on the decoder hot
179-
/// path.
178+
/// every existing `busy_workers` begin/end boundary. A feature-gated event
179+
/// epoch lets snapshots reject concurrent count/balance transitions without
180+
/// locking workers. It is intended as an exact monotonic cumulative signal
181+
/// for scheduling and for validating external occupancy samplers, including
182+
/// work still in progress at snapshot time. Feature-off builds contain
183+
/// neither the clocks, counters, nor a conditional on the decoder hot path.
180184
pub accounted_busy_time: Option<Duration>,
181185
/// Empirically selected worker count, once calibration has completed.
182186
///
@@ -428,9 +432,15 @@ pub(crate) struct BusyRegistration<'a> {
428432

429433
impl Drop for BusyRegistration<'_> {
430434
fn drop(&mut self) {
431-
self.state.busy_workers.fetch_sub(1, Ordering::Relaxed);
432435
#[cfg(feature = "busy-time-accounting")]
433-
self.state.end_accounted_busy();
436+
{
437+
self.state.begin_accounted_transition();
438+
self.state.busy_workers.fetch_sub(1, Ordering::Relaxed);
439+
self.state.end_accounted_busy();
440+
self.state.finish_accounted_transition();
441+
}
442+
#[cfg(not(feature = "busy-time-accounting"))]
443+
self.state.busy_workers.fetch_sub(1, Ordering::Relaxed);
434444
}
435445
}
436446

@@ -455,16 +465,25 @@ impl ReusableTaskSlot<'_> {
455465
self.pool_permit = Some(member.acquire());
456466
}
457467
#[cfg(feature = "busy-time-accounting")]
468+
self.state.begin_accounted_transition();
469+
#[cfg(feature = "busy-time-accounting")]
458470
self.state.begin_accounted_busy();
459471
self.state.busy_workers.fetch_add(1, Ordering::Relaxed);
472+
#[cfg(feature = "busy-time-accounting")]
473+
self.state.finish_accounted_transition();
460474
self.executing = true;
461475
}
462476

463477
pub(crate) fn end(&mut self) {
464478
debug_assert!(self.executing);
479+
#[cfg(feature = "busy-time-accounting")]
480+
self.state.begin_accounted_transition();
465481
self.state.busy_workers.fetch_sub(1, Ordering::Relaxed);
466482
#[cfg(feature = "busy-time-accounting")]
467-
self.state.end_accounted_busy();
483+
{
484+
self.state.end_accounted_busy();
485+
self.state.finish_accounted_transition();
486+
}
468487
self.executing = false;
469488
}
470489

@@ -501,6 +520,8 @@ pub(crate) struct RuntimeState {
501520
cpu_accounting_failures: AtomicUsize,
502521
#[cfg(feature = "busy-time-accounting")]
503522
accounted_busy_balance_nanos: AtomicU64,
523+
#[cfg(feature = "busy-time-accounting")]
524+
accounted_busy_transition_state: AtomicU64,
504525
queued_tasks: AtomicUsize,
505526
best_workers: AtomicUsize,
506527
decompressed_bytes: AtomicU64,
@@ -535,6 +556,8 @@ impl RuntimeState {
535556
cpu_accounting_failures: AtomicUsize::new(0),
536557
#[cfg(feature = "busy-time-accounting")]
537558
accounted_busy_balance_nanos: AtomicU64::new(0),
559+
#[cfg(feature = "busy-time-accounting")]
560+
accounted_busy_transition_state: AtomicU64::new(0),
538561
queued_tasks: AtomicUsize::new(0),
539562
best_workers: AtomicUsize::new(NO_BEST_WORKER_COUNT),
540563
decompressed_bytes: AtomicU64::new(0),
@@ -766,8 +789,12 @@ impl RuntimeState {
766789
pub(crate) fn begin_task(&self) -> BusyRegistration<'_> {
767790
let pool_permit = self.pool_member.as_ref().map(PoolMember::acquire);
768791
#[cfg(feature = "busy-time-accounting")]
792+
self.begin_accounted_transition();
793+
#[cfg(feature = "busy-time-accounting")]
769794
self.begin_accounted_busy();
770795
self.busy_workers.fetch_add(1, Ordering::Relaxed);
796+
#[cfg(feature = "busy-time-accounting")]
797+
self.finish_accounted_transition();
771798
BusyRegistration {
772799
state: self,
773800
_pool_permit: pool_permit,
@@ -808,27 +835,52 @@ impl RuntimeState {
808835
.fetch_sub(now, Ordering::Relaxed);
809836
}
810837

838+
#[cfg(feature = "busy-time-accounting")]
839+
fn begin_accounted_transition(&self) {
840+
let previous = self
841+
.accounted_busy_transition_state
842+
.fetch_add(1, Ordering::AcqRel);
843+
debug_assert_ne!(
844+
previous & ACCOUNTED_TRANSITION_ACTIVE_MASK,
845+
ACCOUNTED_TRANSITION_ACTIVE_MASK
846+
);
847+
}
848+
811849
#[cfg(feature = "busy-time-accounting")]
812850
fn end_accounted_busy(&self) {
813851
let now = self.accounted_now_nanos();
814852
self.accounted_busy_balance_nanos
815853
.fetch_add(now, Ordering::Relaxed);
816854
}
817855

856+
#[cfg(feature = "busy-time-accounting")]
857+
fn finish_accounted_transition(&self) {
858+
// Decrement the low active-transition count and increment the high
859+
// generation in one wrapping addition. Readers therefore detect both
860+
// partial transitions and complete ABA between their state loads.
861+
self.accounted_busy_transition_state
862+
.fetch_add(ACCOUNTED_TRANSITION_FINISH, Ordering::Release);
863+
}
864+
818865
#[cfg(feature = "busy-time-accounting")]
819866
fn accounted_busy_time(&self) -> Duration {
820867
// The wrapping balance contains `-start` for every active interval and
821868
// `end-start` for every completed interval. Add `now` once per active
822869
// interval to obtain an exact cumulative value, including work in
823-
// progress. Double collection rejects a begin/end concurrent with the
824-
// snapshot without adding synchronization to the decoder hot path.
870+
// progress. The epoch closes the ABA hole in the two-counter snapshot.
825871
loop {
872+
let transition_before = self.accounted_busy_transition_state.load(Ordering::Acquire);
826873
let busy_before = self.busy_workers.load(Ordering::Acquire);
827874
let balance_before = self.accounted_busy_balance_nanos.load(Ordering::Acquire);
828875
let now = self.accounted_now_nanos();
829876
let balance_after = self.accounted_busy_balance_nanos.load(Ordering::Acquire);
830877
let busy_after = self.busy_workers.load(Ordering::Acquire);
831-
if busy_before == busy_after && balance_before == balance_after {
878+
let transition_after = self.accounted_busy_transition_state.load(Ordering::Acquire);
879+
if transition_before & ACCOUNTED_TRANSITION_ACTIVE_MASK == 0
880+
&& transition_before == transition_after
881+
&& busy_before == busy_after
882+
&& balance_before == balance_after
883+
{
832884
let nanos = balance_before.wrapping_add((busy_before as u64).wrapping_mul(now));
833885
let plausible_max = now.saturating_mul(self.configured_workers as u64);
834886
if nanos <= plausible_max {
@@ -1144,6 +1196,47 @@ mod tests {
11441196
assert!(handle.stats().accounted_busy_time.unwrap() > first);
11451197
}
11461198

1199+
#[cfg(feature = "busy-time-accounting")]
1200+
#[test]
1201+
fn busy_time_snapshots_never_regress_under_concurrency() {
1202+
use std::sync::atomic::{AtomicUsize, Ordering};
1203+
1204+
let state = RuntimeState::new(4, None);
1205+
let handle = DecoderHandle::new(Arc::clone(&state));
1206+
let remaining = Arc::new(AtomicUsize::new(4));
1207+
let workers: Vec<_> = (0..4)
1208+
.map(|_| {
1209+
let state = Arc::clone(&state);
1210+
let remaining = Arc::clone(&remaining);
1211+
std::thread::spawn(move || {
1212+
for value in 0..10_000 {
1213+
let _busy = state.begin_task();
1214+
std::hint::black_box(value * 3);
1215+
if value % 31 == 0 {
1216+
std::thread::yield_now();
1217+
}
1218+
}
1219+
remaining.fetch_sub(1, Ordering::Release);
1220+
})
1221+
})
1222+
.collect();
1223+
1224+
let mut previous = Duration::ZERO;
1225+
while remaining.load(Ordering::Acquire) != 0 {
1226+
let observed = handle.stats().accounted_busy_time.unwrap();
1227+
assert!(
1228+
observed >= previous,
1229+
"busy time regressed: {previous:?} -> {observed:?}"
1230+
);
1231+
previous = observed;
1232+
std::thread::yield_now();
1233+
}
1234+
for worker in workers {
1235+
worker.join().unwrap();
1236+
}
1237+
assert!(handle.stats().accounted_busy_time.unwrap() >= previous);
1238+
}
1239+
11471240
#[cfg(feature = "cpu-accounting")]
11481241
#[test]
11491242
fn completed_thread_cpu_is_accounted_only_at_lifetime_boundaries() {

0 commit comments

Comments
 (0)