atomic-ruby 0.15.3 → 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: a50be9a8594152f8d07b2338588a01be6aecadf55f993ea6cc4c2431e80e2b76
4
- data.tar.gz: eb9c45d8dff70d0697d3d62ba5a539e3d71e42c21ea7ab6b646d44fde69f39ac
3
+ metadata.gz: 6022f081e7f3e4ce545c40989e5a644beea0697da5d840f645ae3764fbb80e4e
4
+ data.tar.gz: 94b4312e55cfbda05127bde7ac74efd98da8708cb9835e718e7c02641f1d241a
5
5
  SHA512:
6
- metadata.gz: abde2bc85010be3634caeaf66d262acf1c5c70c50af5e9e420a43c87071965bd543a475b01b5d1b0d1569682802595e936a9d224e636896a681e5c3c85d98a77
7
- data.tar.gz: e93d7b35c3ccdba9c82906587c24455c248655ffbe3f9d4141e4cb029705306f80c13b36f6a0ce7193a33701758ad0a9c2f77a7aa0c94ad92e49392ba899c055
6
+ metadata.gz: bbcef6942791f3611e55c7c9806bd5daa4b71dd4bae4bedd255c0d1598e16c0ba50a19ae92423768fca04a4baafd80f464877f60960a7e43be0fc08ed2ffb3a3
7
+ data.tar.gz: '0883a4a401963ad0db23713c6ae6917bec1c3bd1cd3bd8ecff069cc63c99b3000a914ee0c9345cd9f2495f88f0d6da6c35a51057b80a56877409ba19ce3c5e4f'
data/CHANGELOG.md CHANGED
@@ -1,6 +1,15 @@
1
1
  ## [Unreleased]
2
2
 
3
- ## [0.15.2] - 2026-08-31
3
+ ## [0.15.5] - 2026-09-03
4
+
5
+ - Adapt `AtomicThreadPool` scaling to workload pressure
6
+
7
+ ## [0.15.4] - 2026-09-01
8
+
9
+ - Keep temporary `AtomicThreadPool` workers alive for five seconds
10
+ - Speed up `AtomicThreadPool` scaling for blocking work
11
+
12
+ ## [0.15.3] - 2026-08-31
4
13
 
5
14
  - Prevent enqueues from racing `AtomicThreadPool#shutdown`
6
15
  - Remove interrupted waiters from `AtomicConditionVariable`
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.3
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.3
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.3
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.3
522
+ atomic-ruby version: 0.15.5
521
523
 
522
524
  Warming up --------------------------------------
523
525
  Synchronized Queue Push/Pop 184.000 i/100ms
@@ -547,7 +549,7 @@ require "benchmark"
547
549
  require "concurrent-ruby"
548
550
  require_relative "../lib/atomic-ruby"
549
551
 
550
- results = []
552
+ fixed_results = []
551
553
 
552
554
  2.times do |idx|
553
555
  result = Benchmark.measure do
@@ -569,7 +571,36 @@ results = []
569
571
  pool.wait_for_termination if idx == 0
570
572
  end
571
573
 
572
- results << result
574
+ fixed_results << result
575
+ end
576
+
577
+ adaptive_results = []
578
+
579
+ 2.times do |idx|
580
+ result = Benchmark.measure do
581
+ pool = case idx
582
+ when 0 then Concurrent.new_io_executor
583
+ when 1 then AtomicThreadPool.new(size: 1, max_size: Float::INFINITY)
584
+ end
585
+ latch = case idx
586
+ when 0 then Concurrent::CountDownLatch.new(100)
587
+ when 1 then AtomicCountDownLatch.new(100)
588
+ end
589
+
590
+ 100.times do
591
+ pool << proc do
592
+ sleep(0.2)
593
+ latch.count_down
594
+ end
595
+ end
596
+
597
+ latch.wait
598
+ pool.shutdown
599
+ # concurrent-ruby's #shutdown does not wait for threads to terminate
600
+ pool.wait_for_termination if idx == 0
601
+ end
602
+
603
+ adaptive_results << result
573
604
  end
574
605
 
575
606
  puts "\n"
@@ -577,9 +608,13 @@ puts "ruby version: #{RUBY_DESCRIPTION}"
577
608
  puts "concurrent-ruby version: #{Concurrent::VERSION}"
578
609
  puts "atomic-ruby version: #{AtomicRuby::VERSION}"
579
610
  puts "\n"
580
- puts "Benchmark Results:"
581
- puts "Concurrent Ruby Thread Pool: #{results[0].real.round(6)} seconds"
582
- puts "Atomic Ruby Atomic Thread Pool: #{results[1].real.round(6)} seconds"
611
+ puts "Fixed Pool Results:"
612
+ puts "Concurrent Ruby Fixed Thread Pool: #{fixed_results[0].real.round(6)} seconds"
613
+ puts "Atomic Ruby Fixed Thread Pool: #{fixed_results[1].real.round(6)} seconds"
614
+ puts "\n"
615
+ puts "Adaptive Pool Results:"
616
+ puts "Concurrent Ruby IO Thread Pool: #{adaptive_results[0].real.round(6)} seconds"
617
+ puts "Atomic Ruby Adaptive Thread Pool: #{adaptive_results[1].real.round(6)} seconds"
583
618
  ```
584
619
 
585
620
  ```
@@ -587,11 +622,15 @@ puts "Atomic Ruby Atomic Thread Pool: #{results[1].real.round(6)} seconds"
587
622
 
588
623
  ruby version: ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +YJIT +PRISM [arm64-darwin23]
589
624
  concurrent-ruby version: 1.3.8
590
- atomic-ruby version: 0.15.3
625
+ atomic-ruby version: 0.15.5
591
626
 
592
- Benchmark Results:
593
- Concurrent Ruby Thread Pool: 5.012798 seconds
594
- Atomic Ruby Atomic Thread Pool: 4.755237 seconds
627
+ Fixed Pool Results:
628
+ Concurrent Ruby Fixed Thread Pool: 5.05313 seconds
629
+ Atomic Ruby Fixed Thread Pool: 4.753026 seconds
630
+
631
+ Adaptive Pool Results:
632
+ Concurrent Ruby IO Thread Pool: 0.221072 seconds
633
+ Atomic Ruby Adaptive Thread Pool: 0.233328 seconds
595
634
  ```
596
635
 
597
636
  </details>
@@ -645,7 +684,7 @@ puts "Atomic Ruby Atomic Count Down Latch: #{results[1].total.round(6)} CPU seco
645
684
 
646
685
  ruby version: ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +YJIT +PRISM [arm64-darwin23]
647
686
  concurrent-ruby version: 1.3.8
648
- atomic-ruby version: 0.15.3
687
+ atomic-ruby version: 0.15.5
649
688
 
650
689
  Benchmark Results:
651
690
  Concurrent Ruby Count Down Latch: 0.000511 CPU seconds, 2.001465 elapsed seconds
@@ -51,11 +51,11 @@ module AtomicRuby
51
51
  # @note This class is NOT Ractor-safe as it contains mutable thread state
52
52
  # that cannot be safely shared across ractors.
53
53
  class AtomicThreadPool
54
- AUTOSCALE_GROWTH_SAMPLES = 3
55
- AUTOSCALE_IDLE_TIME = 1
56
- AUTOSCALE_INTERVAL = 0.01
57
- AUTOSCALE_WINDOW = 0.05
58
- private_constant :AUTOSCALE_GROWTH_SAMPLES, :AUTOSCALE_IDLE_TIME, :AUTOSCALE_INTERVAL, :AUTOSCALE_WINDOW
54
+ AUTOSCALE_IDLE_TIME = 5
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
59
59
 
60
60
  class Error < StandardError; end
61
61
 
@@ -71,8 +71,9 @@ module AtomicRuby
71
71
  # if work remains queued while its active workers spend most of their time
72
72
  # blocked outside the GVL, but not when Ruby execution is using the
73
73
  # available CPU.
74
- # Temporary workers leave after the queue remains empty, returning the pool
75
- # 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.
76
77
  #
77
78
  # @param size [Integer] The baseline number of worker threads (must be positive)
78
79
  # @param max_size [Integer, Float, nil] Maximum number of worker threads,
@@ -123,7 +124,8 @@ module AtomicRuby
123
124
  @next_thread_number = 0
124
125
  if adaptive?
125
126
  @autoscale_available = AtomicConditionVariable.new
126
- @trim_requested = AtomicBoolean.new(false)
127
+ @idle_trim_requested = AtomicBoolean.new(false)
128
+ @ruby_cpu_trim_requested = AtomicBoolean.new(false)
127
129
  @thread_pool_monitor = ThreadPoolMonitor.new
128
130
  @thread_pool_monitor.start
129
131
  end
@@ -156,7 +158,7 @@ module AtomicRuby
156
158
  # @rbs (Proc work) -> void
157
159
  def <<(work)
158
160
  Thread.handle_interrupt(Exception => :never) do
159
- @trim_requested&.make_false
161
+ @idle_trim_requested&.make_false
160
162
  raise EnqueuedWorkAfterShutdownError unless @queue.push(work)
161
163
 
162
164
  @work_available.signal
@@ -310,7 +312,8 @@ module AtomicRuby
310
312
 
311
313
  # Creates a worker thread.
312
314
  #
313
- # Temporary workers leave after the queue remains empty.
315
+ # Temporary workers retire when Ruby execution becomes the bottleneck or
316
+ # they remain idle.
314
317
  #
315
318
  # @param temporary [true, false] whether the worker belongs above the baseline
316
319
  # @return [Thread]
@@ -334,10 +337,13 @@ module AtomicRuby
334
337
  should_exit = false
335
338
 
336
339
  @work_available.wait do
337
- work = @queue.pop
338
- unless work
339
- should_exit = @shutdown.true? && @queue.empty?
340
- 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
341
347
  end
342
348
  work || should_exit
343
349
  end
@@ -345,6 +351,10 @@ module AtomicRuby
345
351
  break if should_exit
346
352
 
347
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
348
358
  begin
349
359
  @thread_pool_monitor&.start_work
350
360
  work.call
@@ -358,6 +368,14 @@ module AtomicRuby
358
368
  ensure
359
369
  @thread_pool_monitor&.stop_work
360
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
361
379
  end
362
380
  end
363
381
  ensure
@@ -370,7 +388,10 @@ module AtomicRuby
370
388
  thread
371
389
  end
372
390
 
373
- # 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.
374
395
  #
375
396
  # @return [void]
376
397
  #
@@ -379,10 +400,11 @@ module AtomicRuby
379
400
  previous_snapshot = @thread_pool_monitor.snapshot
380
401
  pressure_started_at = nil
381
402
  idle_started_at = nil
382
- growth_samples = 0
383
403
  phase_samples = [0, 0, 0]
404
+ sampling_window = AUTOSCALE_INITIAL_WINDOW
384
405
 
385
406
  loop do
407
+ @ruby_cpu_trim_requested.make_false if @alive_thread_count.value <= @size
386
408
  @autoscale_available.wait do
387
409
  @shutdown.true? || !@queue.empty? || @alive_thread_count.value > @size
388
410
  end
@@ -395,30 +417,37 @@ module AtomicRuby
395
417
  pressure_started_at = nil
396
418
  previous_snapshot = snapshot
397
419
  idle_started_at ||= now
398
- growth_samples = 0
399
420
  phase_samples.fill(0)
421
+ sampling_window = AUTOSCALE_INITIAL_WINDOW
400
422
 
401
423
  if now - idle_started_at >= AUTOSCALE_IDLE_TIME
402
- @trim_requested.make_true
424
+ @idle_trim_requested.make_true
403
425
  @work_available.broadcast
404
426
  end
405
427
  else
406
- @trim_requested.make_false
428
+ @idle_trim_requested.make_false
407
429
  idle_started_at = nil
408
430
 
409
431
  if pressure_started_at.nil?
410
432
  previous_snapshot = snapshot
411
433
  pressure_started_at = now
412
434
  phase_samples.fill(0)
413
- elsif now - pressure_started_at >= AUTOSCALE_WINDOW
414
- if should_grow?(previous_snapshot, snapshot, phase_samples, now - pressure_started_at)
415
- growth_samples += 1
416
- if growth_samples >= AUTOSCALE_GROWTH_SAMPLES
417
- spawn_worker(temporary: true)
418
- growth_samples = 0
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
439
+ worker_count = @threads.length
440
+ workers_to_add = [worker_count, @queue.size, @max_size - worker_count].min
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
419
447
  end
448
+ sampling_window = [sampling_window * 2, AUTOSCALE_MAX_WINDOW].min
420
449
  else
421
- growth_samples = 0
450
+ sampling_window = [sampling_window * 2, AUTOSCALE_MAX_WINDOW].min
422
451
  end
423
452
  previous_snapshot = snapshot
424
453
  pressure_started_at = now
@@ -428,30 +457,28 @@ module AtomicRuby
428
457
  end
429
458
  end
430
459
 
431
- sleep AUTOSCALE_INTERVAL
460
+ sleep((@queue.empty? ? AUTOSCALE_MAX_WINDOW : sampling_window) / AUTOSCALE_SAMPLES_PER_WINDOW)
432
461
  end
433
462
  end
434
463
 
435
- # Returns whether another worker is likely to improve throughput.
464
+ # Returns the direction in which the pool should scale.
436
465
  #
437
466
  # Requiring workers to spend a majority of their time blocked outside the
438
- # GVL recognizes blocking operations. The pool only grows while its workers
439
- # use less than half of one CPU, avoiding extra threads when Ruby
440
- # execution is the bottleneck without mistaking OS scheduling delays for
441
- # 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.
442
470
  #
443
471
  # @param previous_snapshot [Array<Integer>] previous GVL state snapshot
444
472
  # @param snapshot [Array<Integer>] current GVL state snapshot
445
473
  # @param phase_samples [Array<Integer>] sampled current GVL states
446
474
  # @param elapsed_time [Float] seconds covered by the snapshots
447
- # @return [true, false]
475
+ # @return [:up, :down, nil]
448
476
  #
449
- # @rbs (Array[Integer] previous_snapshot, Array[Integer] snapshot, Array[Integer] phase_samples, Float elapsed_time) -> bool
450
- 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)
451
479
  @threads.select!(&:alive?)
452
480
  workers = @threads
453
- return false if workers.length >= @max_size
454
- return false if @active_thread_count.value < workers.length
481
+ return if @active_thread_count.value < workers.length
455
482
 
456
483
  running_time = snapshot[3] - previous_snapshot[3]
457
484
  waiting_time = snapshot[4] - previous_snapshot[4]
@@ -459,15 +486,17 @@ module AtomicRuby
459
486
  running_cpu_time = snapshot[6] - previous_snapshot[6]
460
487
  total_time = running_time + waiting_time + blocked_time
461
488
 
462
- 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
463
490
  if total_time < minimum_measured_time
464
491
  running_time, waiting_time, blocked_time = phase_samples
465
492
  total_time = running_time + waiting_time + blocked_time
466
493
  end
467
494
 
468
- total_time.positive? &&
469
- blocked_time * 2 > total_time &&
470
- 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
471
500
  end
472
501
  end
473
502
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AtomicRuby
4
- VERSION = "0.15.3"
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.3
4
+ version: 0.15.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joshua Young