@@ -10,6 +10,10 @@ use std::sync::{Condvar, Mutex};
1010use std:: time:: { Duration , Instant } ;
1111
1212const 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
429433impl 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