atomic-ruby 0.15.4 → 0.15.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e7c0136cf37fe5133affc1f090fa517636f5bfda0f5f43564a4877eee15bae3f
4
- data.tar.gz: 0ae3a3aadabcc2bb69621df62188800451289a46032fa57a49c1af9326bcc31e
3
+ metadata.gz: 6022f081e7f3e4ce545c40989e5a644beea0697da5d840f645ae3764fbb80e4e
4
+ data.tar.gz: 94b4312e55cfbda05127bde7ac74efd98da8708cb9835e718e7c02641f1d241a
5
5
  SHA512:
6
- metadata.gz: 1786faa43a25379cbe68c9de2372c6168a62ab225436c45bbf5049791c39191e3ed75955ab2f5f6385069ddeb86640aa11ba431ce9f2ab54335005a61bd42092
7
- data.tar.gz: 704c7e7224c9eec351aa0174f597c10c992f4e1594921b8ca91e9ad2df43a6b3eb2c260b9b25ab2d47f8fb0934afa9739ed5312fb00d5d7519d9283c6bf68170
6
+ metadata.gz: bbcef6942791f3611e55c7c9806bd5daa4b71dd4bae4bedd255c0d1598e16c0ba50a19ae92423768fca04a4baafd80f464877f60960a7e43be0fc08ed2ffb3a3
7
+ data.tar.gz: '0883a4a401963ad0db23713c6ae6917bec1c3bd1cd3bd8ecff069cc63c99b3000a914ee0c9345cd9f2495f88f0d6da6c35a51057b80a56877409ba19ce3c5e4f'
data/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.15.5] - 2026-09-03
4
+
5
+ - Adapt `AtomicThreadPool` scaling to workload pressure
6
+
3
7
  ## [0.15.4] - 2026-09-01
4
8
 
5
9
  - Keep temporary `AtomicThreadPool` workers alive for five seconds
data/README.md CHANGED
@@ -122,8 +122,10 @@ p results.sort #=> [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
122
122
 
123
123
  Pass `max_size` to let the pool temporarily add workers when queued work is
124
124
  held up by blocking operations. The pool measures time spent blocked outside
125
- the GVL and CPU time used by its workers, so it does not add more threads when
126
- Ruby execution is the bottleneck. It returns to `size` when the queue drains.
125
+ the GVL and CPU time used while running Ruby, so it does not add more threads
126
+ when Ruby execution is the bottleneck. Temporary workers remain available
127
+ between blocking bursts, but retire when Ruby execution becomes the bottleneck
128
+ or they remain idle.
127
129
 
128
130
  ```ruby
129
131
  pool = AtomicThreadPool.new(size: 4, max_size: 16)
@@ -268,7 +270,7 @@ puts "Atomic Ruby Atomic Bank Account: #{results[2].real.round(6)} seconds"
268
270
 
269
271
  ruby version: ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +YJIT +PRISM [arm64-darwin23]
270
272
  concurrent-ruby version: 1.3.8
271
- atomic-ruby version: 0.15.4
273
+ atomic-ruby version: 0.15.5
272
274
 
273
275
  Balances:
274
276
  Synchronized Bank Account Balance: 975
@@ -359,7 +361,7 @@ end
359
361
 
360
362
  ruby version: ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +YJIT +PRISM [arm64-darwin23]
361
363
  concurrent-ruby version: 1.3.8
362
- atomic-ruby version: 0.15.4
364
+ atomic-ruby version: 0.15.5
363
365
 
364
366
  Warming up --------------------------------------
365
367
  Synchronized Boolean Toggle 165.000 i/100ms
@@ -442,7 +444,7 @@ end
442
444
  > bundle exec rake clobber && bundle exec rake compile && bundle exec ruby examples/atomic_condition_variable_benchmark.rb
443
445
 
444
446
  ruby version: ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +YJIT +PRISM [arm64-darwin23]
445
- atomic-ruby version: 0.15.4
447
+ atomic-ruby version: 0.15.5
446
448
 
447
449
  Warming up --------------------------------------
448
450
  Synchronized Condition Variable Wait/Signal 4.062k i/100ms
@@ -517,7 +519,7 @@ end
517
519
  > bundle exec rake clobber && bundle exec rake compile && bundle exec ruby examples/atomic_queue_benchmark.rb
518
520
 
519
521
  ruby version: ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +YJIT +PRISM [arm64-darwin23]
520
- atomic-ruby version: 0.15.4
522
+ atomic-ruby version: 0.15.5
521
523
 
522
524
  Warming up --------------------------------------
523
525
  Synchronized Queue Push/Pop 184.000 i/100ms
@@ -620,15 +622,15 @@ puts "Atomic Ruby Adaptive Thread Pool: #{adaptive_results[1].real.round(6)} sec
620
622
 
621
623
  ruby version: ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +YJIT +PRISM [arm64-darwin23]
622
624
  concurrent-ruby version: 1.3.8
623
- atomic-ruby version: 0.15.4
625
+ atomic-ruby version: 0.15.5
624
626
 
625
627
  Fixed Pool Results:
626
- Concurrent Ruby Fixed Thread Pool: 5.069464 seconds
627
- Atomic Ruby Fixed Thread Pool: 4.791382 seconds
628
+ Concurrent Ruby Fixed Thread Pool: 5.05313 seconds
629
+ Atomic Ruby Fixed Thread Pool: 4.753026 seconds
628
630
 
629
631
  Adaptive Pool Results:
630
- Concurrent Ruby IO Thread Pool: 0.22309 seconds
631
- Atomic Ruby Adaptive Thread Pool: 0.61561 seconds
632
+ Concurrent Ruby IO Thread Pool: 0.221072 seconds
633
+ Atomic Ruby Adaptive Thread Pool: 0.233328 seconds
632
634
  ```
633
635
 
634
636
  </details>
@@ -682,7 +684,7 @@ puts "Atomic Ruby Atomic Count Down Latch: #{results[1].total.round(6)} CPU seco
682
684
 
683
685
  ruby version: ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +YJIT +PRISM [arm64-darwin23]
684
686
  concurrent-ruby version: 1.3.8
685
- atomic-ruby version: 0.15.4
687
+ atomic-ruby version: 0.15.5
686
688
 
687
689
  Benchmark Results:
688
690
  Concurrent Ruby Count Down Latch: 0.000511 CPU seconds, 2.001465 elapsed seconds
@@ -52,9 +52,10 @@ module AtomicRuby
52
52
  # that cannot be safely shared across ractors.
53
53
  class AtomicThreadPool
54
54
  AUTOSCALE_IDLE_TIME = 5
55
- AUTOSCALE_INTERVAL = 0.01
56
- AUTOSCALE_WINDOW = 0.05
57
- private_constant :AUTOSCALE_IDLE_TIME, :AUTOSCALE_INTERVAL, :AUTOSCALE_WINDOW
55
+ AUTOSCALE_INITIAL_WINDOW = 0.002
56
+ AUTOSCALE_MAX_WINDOW = 0.05
57
+ AUTOSCALE_SAMPLES_PER_WINDOW = 5
58
+ private_constant :AUTOSCALE_IDLE_TIME, :AUTOSCALE_INITIAL_WINDOW, :AUTOSCALE_MAX_WINDOW, :AUTOSCALE_SAMPLES_PER_WINDOW
58
59
 
59
60
  class Error < StandardError; end
60
61
 
@@ -70,8 +71,9 @@ module AtomicRuby
70
71
  # if work remains queued while its active workers spend most of their time
71
72
  # blocked outside the GVL, but not when Ruby execution is using the
72
73
  # available CPU.
73
- # Temporary workers remain available between bursts before the pool returns
74
- # to its baseline size. Omitting `max_size` creates a fixed-size pool.
74
+ # Temporary workers remain available between blocking bursts, but retire
75
+ # when Ruby execution becomes the bottleneck or they remain idle. Omitting
76
+ # `max_size` creates a fixed-size pool.
75
77
  #
76
78
  # @param size [Integer] The baseline number of worker threads (must be positive)
77
79
  # @param max_size [Integer, Float, nil] Maximum number of worker threads,
@@ -122,7 +124,8 @@ module AtomicRuby
122
124
  @next_thread_number = 0
123
125
  if adaptive?
124
126
  @autoscale_available = AtomicConditionVariable.new
125
- @trim_requested = AtomicBoolean.new(false)
127
+ @idle_trim_requested = AtomicBoolean.new(false)
128
+ @ruby_cpu_trim_requested = AtomicBoolean.new(false)
126
129
  @thread_pool_monitor = ThreadPoolMonitor.new
127
130
  @thread_pool_monitor.start
128
131
  end
@@ -155,7 +158,7 @@ module AtomicRuby
155
158
  # @rbs (Proc work) -> void
156
159
  def <<(work)
157
160
  Thread.handle_interrupt(Exception => :never) do
158
- @trim_requested&.make_false
161
+ @idle_trim_requested&.make_false
159
162
  raise EnqueuedWorkAfterShutdownError unless @queue.push(work)
160
163
 
161
164
  @work_available.signal
@@ -309,7 +312,8 @@ module AtomicRuby
309
312
 
310
313
  # Creates a worker thread.
311
314
  #
312
- # Temporary workers leave after the queue remains empty.
315
+ # Temporary workers retire when Ruby execution becomes the bottleneck or
316
+ # they remain idle.
313
317
  #
314
318
  # @param temporary [true, false] whether the worker belongs above the baseline
315
319
  # @return [Thread]
@@ -333,10 +337,13 @@ module AtomicRuby
333
337
  should_exit = false
334
338
 
335
339
  @work_available.wait do
336
- work = @queue.pop
337
- unless work
338
- should_exit = @shutdown.true? && @queue.empty?
339
- should_exit ||= temporary && @trim_requested.true? && @queue.empty?
340
+ should_exit = temporary && @ruby_cpu_trim_requested.true?
341
+ unless should_exit
342
+ work = @queue.pop
343
+ unless work
344
+ should_exit = @shutdown.true? && @queue.empty?
345
+ should_exit ||= temporary && @idle_trim_requested.true? && @queue.empty?
346
+ end
340
347
  end
341
348
  work || should_exit
342
349
  end
@@ -344,6 +351,10 @@ module AtomicRuby
344
351
  break if should_exit
345
352
 
346
353
  @active_thread_count.swap { |current_count| current_count + 1 }
354
+ if temporary
355
+ work_started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
356
+ running_cpu_time_at_start = @thread_pool_monitor.snapshot[6]
357
+ end
347
358
  begin
348
359
  @thread_pool_monitor&.start_work
349
360
  work.call
@@ -357,6 +368,14 @@ module AtomicRuby
357
368
  ensure
358
369
  @thread_pool_monitor&.stop_work
359
370
  @active_thread_count.swap { |current_count| current_count - 1 }
371
+ if temporary && !@queue.empty?
372
+ elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - work_started_at
373
+ running_cpu_time = @thread_pool_monitor.snapshot[6] - running_cpu_time_at_start
374
+ if running_cpu_time * 2 >= elapsed_time * 1_000_000_000
375
+ @ruby_cpu_trim_requested.make_true
376
+ @work_available.broadcast
377
+ end
378
+ end
360
379
  end
361
380
  end
362
381
  ensure
@@ -369,7 +388,10 @@ module AtomicRuby
369
388
  thread
370
389
  end
371
390
 
372
- # Adds temporary workers while queued work is held up by blocked workers.
391
+ # Scales temporary workers in response to workload pressure.
392
+ #
393
+ # Sampling starts aggressively so the pool can react to short bursts, then
394
+ # backs off when more workers would not improve throughput.
373
395
  #
374
396
  # @return [void]
375
397
  #
@@ -379,8 +401,10 @@ module AtomicRuby
379
401
  pressure_started_at = nil
380
402
  idle_started_at = nil
381
403
  phase_samples = [0, 0, 0]
404
+ sampling_window = AUTOSCALE_INITIAL_WINDOW
382
405
 
383
406
  loop do
407
+ @ruby_cpu_trim_requested.make_false if @alive_thread_count.value <= @size
384
408
  @autoscale_available.wait do
385
409
  @shutdown.true? || !@queue.empty? || @alive_thread_count.value > @size
386
410
  end
@@ -394,24 +418,36 @@ module AtomicRuby
394
418
  previous_snapshot = snapshot
395
419
  idle_started_at ||= now
396
420
  phase_samples.fill(0)
421
+ sampling_window = AUTOSCALE_INITIAL_WINDOW
397
422
 
398
423
  if now - idle_started_at >= AUTOSCALE_IDLE_TIME
399
- @trim_requested.make_true
424
+ @idle_trim_requested.make_true
400
425
  @work_available.broadcast
401
426
  end
402
427
  else
403
- @trim_requested.make_false
428
+ @idle_trim_requested.make_false
404
429
  idle_started_at = nil
405
430
 
406
431
  if pressure_started_at.nil?
407
432
  previous_snapshot = snapshot
408
433
  pressure_started_at = now
409
434
  phase_samples.fill(0)
410
- elsif now - pressure_started_at >= AUTOSCALE_WINDOW
411
- if should_grow?(previous_snapshot, snapshot, phase_samples, now - pressure_started_at)
435
+ elsif now - pressure_started_at >= sampling_window
436
+ case scaling_direction(previous_snapshot, snapshot, phase_samples, now - pressure_started_at)
437
+ when :up
438
+ @ruby_cpu_trim_requested.make_false
412
439
  worker_count = @threads.length
413
440
  workers_to_add = [worker_count, @queue.size, @max_size - worker_count].min
414
441
  workers_to_add.times { spawn_worker(temporary: true) }
442
+ sampling_window = AUTOSCALE_INITIAL_WINDOW
443
+ when :down
444
+ unless @ruby_cpu_trim_requested.true?
445
+ @ruby_cpu_trim_requested.make_true
446
+ @work_available.broadcast
447
+ end
448
+ sampling_window = [sampling_window * 2, AUTOSCALE_MAX_WINDOW].min
449
+ else
450
+ sampling_window = [sampling_window * 2, AUTOSCALE_MAX_WINDOW].min
415
451
  end
416
452
  previous_snapshot = snapshot
417
453
  pressure_started_at = now
@@ -421,30 +457,28 @@ module AtomicRuby
421
457
  end
422
458
  end
423
459
 
424
- sleep AUTOSCALE_INTERVAL
460
+ sleep((@queue.empty? ? AUTOSCALE_MAX_WINDOW : sampling_window) / AUTOSCALE_SAMPLES_PER_WINDOW)
425
461
  end
426
462
  end
427
463
 
428
- # Returns whether another worker is likely to improve throughput.
464
+ # Returns the direction in which the pool should scale.
429
465
  #
430
466
  # Requiring workers to spend a majority of their time blocked outside the
431
- # GVL recognizes blocking operations. The pool only grows while its workers
432
- # use less than half of one CPU, avoiding extra threads when Ruby
433
- # execution is the bottleneck without mistaking OS scheduling delays for
434
- # GVL contention.
467
+ # GVL recognizes blocking operations. The pool grows while Ruby execution
468
+ # uses less than half of one CPU and shrinks when Ruby execution becomes
469
+ # the bottleneck, without mistaking OS scheduling delays for GVL contention.
435
470
  #
436
471
  # @param previous_snapshot [Array<Integer>] previous GVL state snapshot
437
472
  # @param snapshot [Array<Integer>] current GVL state snapshot
438
473
  # @param phase_samples [Array<Integer>] sampled current GVL states
439
474
  # @param elapsed_time [Float] seconds covered by the snapshots
440
- # @return [true, false]
475
+ # @return [:up, :down, nil]
441
476
  #
442
- # @rbs (Array[Integer] previous_snapshot, Array[Integer] snapshot, Array[Integer] phase_samples, Float elapsed_time) -> bool
443
- def should_grow?(previous_snapshot, snapshot, phase_samples, elapsed_time)
477
+ # @rbs (Array[Integer] previous_snapshot, Array[Integer] snapshot, Array[Integer] phase_samples, Float elapsed_time) -> (:up | :down)?
478
+ def scaling_direction(previous_snapshot, snapshot, phase_samples, elapsed_time)
444
479
  @threads.select!(&:alive?)
445
480
  workers = @threads
446
- return false if workers.length >= @max_size
447
- return false if @active_thread_count.value < workers.length
481
+ return if @active_thread_count.value < workers.length
448
482
 
449
483
  running_time = snapshot[3] - previous_snapshot[3]
450
484
  waiting_time = snapshot[4] - previous_snapshot[4]
@@ -452,15 +486,17 @@ module AtomicRuby
452
486
  running_cpu_time = snapshot[6] - previous_snapshot[6]
453
487
  total_time = running_time + waiting_time + blocked_time
454
488
 
455
- minimum_measured_time = (AUTOSCALE_WINDOW * 1_000_000_000 * workers.length / 2).to_i
489
+ minimum_measured_time = (elapsed_time * 1_000_000_000 * workers.length / 2).to_i
456
490
  if total_time < minimum_measured_time
457
491
  running_time, waiting_time, blocked_time = phase_samples
458
492
  total_time = running_time + waiting_time + blocked_time
459
493
  end
460
494
 
461
- total_time.positive? &&
462
- blocked_time * 2 > total_time &&
463
- running_cpu_time * 2 < elapsed_time * 1_000_000_000
495
+ ruby_cpu_bound = running_cpu_time * 2 >= elapsed_time * 1_000_000_000
496
+ blocking = total_time.positive? && blocked_time * 2 > total_time && !ruby_cpu_bound
497
+
498
+ return :up if blocking && workers.length < @max_size
499
+ return :down if ruby_cpu_bound && workers.length > @size
464
500
  end
465
501
  end
466
502
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AtomicRuby
4
- VERSION = "0.15.4"
4
+ VERSION = "0.15.5"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: atomic-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.15.4
4
+ version: 0.15.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joshua Young