FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

Allow fiber schedulers to offload bcrypt operations by samuel-williams-shopify · Pull Request #304 · bcrypt-ruby/bcrypt-ruby · GitHub

Repository navigation

Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension .c  (1) .csv  (1) .md  (1) .rb  (2) All 4 file types selected
Viewed files
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Unified
Split
Hide whitespace
Diff view
Unified
Split
Hide whitespace
91 changes: 91 additions & 0 deletions benchmark/async/readme.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Async worker pool benchmark

`worker_pool.rb` hashes 64 passwords at cost 10 across eight Async tasks, verifies
every result, and measures elapsed time and the delay of a 5 ms heartbeat timer.
Each configuration runs five times after a warmup. Reported values are medians;
timer lag is the median of each run's maximum lag. Hash verification is included
in the timing, while salt generation, expected-hash calculation, worker creation,
and warmup are excluded.

The benchmark uses Async's `worker_pool:` scheduler option with an
`IO::Event::WorkerPool`. It calls `BCrypt::Engine.hash_secret` normally, allowing
Ruby to offload the native operation through `blocking_operation_wait`. The
pool's call counter reports how many operations were actually offloaded during
each timed batch.

## Reproduce

Use CRuby 3.4 or newer. Install the gem's development dependencies and the
benchmark dependencies, then compile the extension:

```sh
export GEM_HOME="$PWD/tmp/gems"
export GEM_PATH="$GEM_HOME"
bundle install
gem install async -v 2.46.0 --no-document
gem install io-event -v 1.22.1 --no-document
bundle exec rake compile
```

Build unmodified bcrypt from the revision used for this comparison:

```sh
mkdir -p tmp/baseline
git archive deb496eaba56077e84bd95ac9bbcca40b6d5d685 | tar -x -C tmp/baseline
(cd tmp/baseline/ext/mri && ruby extconf.rb && make)
```

Run the same script against each extension, using the same Ruby and dependencies:

```sh
for workers in 0 1 4; do
LABEL=before WORKERS="$workers" ruby -Itmp/baseline/lib -Itmp/baseline/ext/mri benchmark/async/worker_pool.rb
LABEL=after WORKERS="$workers" ruby -Ilib benchmark/async/worker_pool.rb
done
```

Run the benchmark with plain `ruby`: Async is an optional benchmark dependency
and is not included in the main Gemfile. `WORKERS=0` disables the pool;
`WORKERS=1` tests responsiveness with a single worker; `WORKERS=4` also allows
hashes to execute in parallel. `CONCURRENCY`, `HASHES`, `COST`, and `RUNS` override
the defaults. JSON output includes every sample, library versions, and the loaded
extension path so the compared builds can be checked.

## Results

Measured on 2026-09-28 on an Apple M4 Pro (12 CPU cores), macOS 27.0, Ruby 4.0.7
(`229531a6cf`, arm64, JIT disabled), Async 2.46.0, and io-event 1.22.1. Both
extensions used the same Ruby and compiler settings. Configurations ran
sequentially, without concurrent test runs.
The 30 individual samples are recorded in [results.csv](results.csv).

| Build | Workers | Batch time | Hashes/s | Max timer lag | Offloads/batch |
| --- | ---: | ---: | ---: | ---: | ---: |
| Before | Disabled | 2.999 s | 21.3 | 372.45 ms | 0 |
| After | Disabled | 3.051 s | 21.0 | 379.70 ms | 0 |
| Before | 1 | 3.068 s | 20.9 | 379.79 ms | 0 |
| After | 1 | 3.084 s | 20.8 | 1.63 ms | 64 |
| Before | 4 | 3.062 s | 20.9 | 378.98 ms | 0 |
| After | 4 | 0.775 s | 82.6 | 0.77 ms | 64 |

With four workers, throughput increased **3.95 times**. With one worker,
throughput was similar but the event loop remained responsive. Merely enabling
the pool did not help the unmodified extension: no bcrypt calls reached it.
These are local batch measurements; they demonstrate parallelism and scheduler
responsiveness, not a reduction in the CPU work required for an individual hash.

## Safety and compatibility

Both native callbacks operate on frozen input strings and per-call output
buffers. They do not invoke Ruby APIs or depend on the calling thread's state.
Although the bundled crypt implementation uses `errno` internally, the Ruby
wrapper uses its return value rather than reading `errno` after the call.

The extension uses `rb_nogvl(..., RB_NOGVL_OFFLOAD_SAFE)` when available and keeps
the previous implementation on older Rubies. The regression specs verify that
both callbacks reach the scheduler, preserve their results (including failures),
and retain their inputs across mutation and garbage collection during handoff.

Validation: 44 specs passed on Ruby 4.0.7 and 3.4.4. Ruby 3.3.1 passed with the
five offload-specific specs skipped. All five offload specs fail against the
unmodified extension because its callbacks never reach the scheduler.
31 changes: 31 additions & 0 deletions benchmark/async/results.csv
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
build,workers,sample,seconds,hashes_per_second,max_timer_lag_ms,offloaded_calls
baseline,0,1,2.99434299999848,21.373636887969244,372.4509999155998,0
baseline,0,2,2.98904599994421,21.41151390818159,371.3620000053197,0
baseline,0,3,3.0373780000954866,21.070805147725444,408.2020001951605,0
baseline,0,4,2.999218000099063,21.338895671433722,371.2040001992136,0
baseline,0,5,3.007359999930486,21.281123643820273,374.09300031140447,0
baseline,1,1,3.041761999949813,21.04043643159983,378.13800014555454,0
baseline,1,2,3.6109680000226945,17.723779329974057,580.5160000454634,0
baseline,1,3,3.1576720001176,20.268096242300174,473.3330002054572,0
baseline,1,4,3.0672560001257807,20.86555540110624,379.7300001606345,0
baseline,1,5,3.06819500005804,20.859169641691395,379.78600012138486,0
baseline,4,1,3.065936000086367,20.874538802570285,378.98200028575957,0
baseline,4,2,3.0620180000551045,20.901248783922316,378.5480000078678,0
baseline,4,3,3.0576529998797923,20.93108668724544,378.55500006116927,0
baseline,4,4,3.0567339998669922,20.937379570085206,379.5080001000315,0
baseline,4,5,3.062364999903366,20.898880441103376,379.283000016585,0
patched,0,1,3.0560439999680966,20.942106854701084,379.8650000244379,0
patched,0,2,3.0290810000151396,21.12852049835581,375.1920002978295,0
patched,0,3,3.051293999888003,20.974707780485627,379.70300018787384,0
patched,0,4,3.0479389999527484,20.997795559882327,380.1560001447797,0
patched,0,5,3.0535240001045167,20.959389871443417,378.15300025977194,0
patched,1,1,3.0693379999138415,20.8514018338145,0.7970002479851246,64
patched,1,2,3.138624999905005,20.39109482717338,5.9910002164542675,64
patched,1,3,3.0648910000454634,20.881656149941595,0.7430000696331263,64
patched,1,4,3.085806000046432,20.74012429784536,1.6300000716000795,64
patched,1,5,3.0841349998954684,20.751361403495363,1.9710001070052385,64
patched,4,1,0.7733219999354333,82.75983355619462,1.4780000783503056,64
patched,4,2,0.7703809998929501,83.07577680250844,0.7220001425594091,64
patched,4,3,0.7747490000911057,82.60739928993003,0.7070000283420086,64
patched,4,4,0.7924019999336451,80.76708540028835,1.8990000244230032,64
patched,4,5,0.7779820000287145,82.26411407672391,0.7660002447664738,64
80 changes: 80 additions & 0 deletions benchmark/async/worker_pool.rb
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# frozen_string_literal: true

# Run with the desired bcrypt checkout's lib and compiled extension on $LOAD_PATH.
# Requires Ruby 3.4+, Async and IO::Event::WorkerPool.
require 'async'
require 'bcrypt'
require 'json'

workers = Integer(ENV.fetch('WORKERS', 4))
concurrency = Integer(ENV.fetch('CONCURRENCY', 8))
count = Integer(ENV.fetch('HASHES', 64))
cost = Integer(ENV.fetch('COST', 10))
runs = Integer(ENV.fetch('RUNS', 5))
raise ArgumentError, 'invalid benchmark parameters' unless workers >= 0 && concurrency > 0 && count > 0 && runs > 0 && (4..31).cover?(cost)

secret = 'async worker pool benchmark'
salt = BCrypt::Engine.generate_salt(cost)
expected = BCrypt::Engine.hash_secret(secret, salt)
clock = proc { Process.clock_gettime(Process::CLOCK_MONOTONIC) }

results = Array.new(runs) do
pool = IO::Event::WorkerPool.new(maximum_worker_count: workers) if workers > 0
reactor = Async::Reactor.new(worker_pool: pool)

begin
reactor.run do |task|
# Warm up the extension and workers before starting the timer.
Array.new(concurrency) do
task.async { raise 'incorrect hash' unless BCrypt::Engine.hash_secret(secret, salt) == expected }
end.each(&:wait)

calls_before = pool ? pool.statistics[:call_count] : 0
lags = []
finished = false
heartbeat = task.async do |timer|
loop do
deadline = clock.call + 0.005
timer.sleep(0.005)
lags << [clock.call - deadline, 0].max
break if finished
end
end

started = clock.call
Array.new(concurrency) do |index|
task.async do
index.step(count - 1, concurrency) do
raise 'incorrect hash' unless BCrypt::Engine.hash_secret(secret, salt) == expected
end
end
end.each(&:wait)
elapsed = clock.call - started
finished = true
heartbeat.wait

{
seconds: elapsed,
hashes_per_second: count / elapsed,
max_timer_lag_ms: lags.max * 1000,
offloaded_calls: pool ? pool.statistics[:call_count] - calls_before : 0
}
end.wait
ensure
Fiber.set_scheduler(nil)
end
end

median = proc { |key| results.map { |result| result.fetch(key) }.sort.then { |values| (values[(runs - 1) / 2] + values[runs / 2]) / 2.0 } }
puts JSON.pretty_generate(
label: ENV.fetch('LABEL', 'bcrypt'),
ruby: RUBY_DESCRIPTION,
bcrypt_extension: $LOADED_FEATURES.find { |path| path.match?(/bcrypt_ext\.(bundle|so)$/) },
async: Async::VERSION,
io_event: IO::Event::VERSION,
workers: workers, concurrency: concurrency, hashes: count, cost: cost,
median_seconds: median.call(:seconds),
median_hashes_per_second: median.call(:hashes_per_second),
median_max_timer_lag_ms: median.call(:max_timer_lag_ms),
samples: results
)
10 changes: 8 additions & 2 deletions ext/mri/bcrypt_ext.c
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,10 @@ static VALUE bc_salt(VALUE self, VALUE prefix, VALUE count, VALUE input) {
args.input = NIL_P(input) ? NULL : StringValuePtr(input);
args.size = NIL_P(input) ? 0 : RSTRING_LEN(input);

#ifdef HAVE_RUBY_THREAD_H
#ifdef RB_NOGVL_OFFLOAD_SAFE
/* Per-call buffers and frozen inputs are safe to use on a worker thread. */
salt = rb_nogvl(bc_salt_nogvl, &args, NULL, NULL, RB_NOGVL_OFFLOAD_SAFE);
#elif defined(HAVE_RUBY_THREAD_H)
salt = rb_thread_call_without_gvl(bc_salt_nogvl, &args, NULL, NULL);
#else
salt = bc_salt_nogvl((void *)&args);
Expand Down Expand Up @@ -92,7 +95,10 @@ static VALUE bc_crypt(VALUE self, VALUE key, VALUE setting) {
args.key = NIL_P(key) ? NULL : StringValueCStr(key);
args.setting = NIL_P(setting) ? NULL : StringValueCStr(setting);

#ifdef HAVE_RUBY_THREAD_H
#ifdef RB_NOGVL_OFFLOAD_SAFE
/* Per-call buffers and frozen inputs are safe to use on a worker thread. */
value = rb_nogvl(bc_crypt_nogvl, &args, NULL, NULL, RB_NOGVL_OFFLOAD_SAFE);
#elif defined(HAVE_RUBY_THREAD_H)
value = rb_thread_call_without_gvl(bc_crypt_nogvl, &args, NULL, NULL);
#else
value = bc_crypt_nogvl((void *)&args);
Expand Down
107 changes: 107 additions & 0 deletions spec/bcrypt/offload_spec.rb
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
require File.expand_path('../spec_helper', __dir__)

# Only implements the hooks needed to run a native operation on another thread.
class BCryptOffloadScheduler
attr_reader :calls
attr_accessor :before_work

def initialize
@calls = 0
end

def blocking_operation_wait(work)
@calls += 1
@before_work.call if @before_work
Fiber.new(blocking: true) { Thread.new { work.call }.value }.resume
end

def block(*)
raise 'unexpected block'
end

def unblock(*)
raise 'unexpected unblock'
end

def kernel_sleep(*)
raise 'unexpected sleep'
end

def io_wait(*)
raise 'unexpected IO'
end

def fiber_interrupt(*)
raise 'unexpected interrupt'
end
end

describe 'BCrypt fiber scheduler offloading' do
before do
skip 'requires CRuby 3.4 or later' unless RUBY_ENGINE == 'ruby' && Gem::Version.new(RUBY_VERSION) >= Gem::Version.new('3.4')
@scheduler = BCryptOffloadScheduler.new
Fiber.set_scheduler(@scheduler)
end

after do
Fiber.set_scheduler(nil) if @scheduler
end

it 'generates the same salt on a worker thread' do
input = '0123456789abcdef'
expected = BCrypt::Engine.send(:__bc_salt, '$2a$', 4, input)
actual = Fiber.new { BCrypt::Engine.send(:__bc_salt, '$2a$', 4, input) }.resume

expect(actual).to eq(expected)
expect(@scheduler.calls).to eq(1)
end

it 'hashes a known test vector on a worker thread' do
actual = Fiber.new { BCrypt::Engine.hash_secret('U*U', '$2a$05$CCCCCCCCCCCCCCCCCCCCC.') }.resume

expect(actual).to eq('$2a$05$CCCCCCCCCCCCCCCCCCCCC.E5YPO9kmyuRGyh0XouQYb4YMJKvyOeW')
expect(@scheduler.calls).to eq(1)
end

it 'keeps salt inputs alive and unchanged while handing work to the scheduler' do
prefix = '$2a$'.dup
input = '0123456789abcdef'.dup
expected = BCrypt::Engine.send(:__bc_salt, prefix, 4, input)
@scheduler.before_work = proc do
prefix.replace('changed')
input.replace('changed')
GC.start
GC.compact if GC.respond_to?(:compact)
end

actual = Fiber.new { BCrypt::Engine.send(:__bc_salt, prefix, 4, input) }.resume

expect(actual).to eq(expected)
expect(@scheduler.calls).to eq(1)
end

it 'keeps hash inputs alive and unchanged while handing work to the scheduler' do
secret = 'U*U'.dup
salt = '$2a$05$CCCCCCCCCCCCCCCCCCCCC.'.dup
@scheduler.before_work = proc do
secret.replace('changed')
salt.replace('changed')
GC.start
GC.compact if GC.respond_to?(:compact)
end

actual = Fiber.new { BCrypt::Engine.hash_secret(secret, salt) }.resume

expect(actual).to eq('$2a$05$CCCCCCCCCCCCCCCCCCCCC.E5YPO9kmyuRGyh0XouQYb4YMJKvyOeW')
expect(@scheduler.calls).to eq(1)
end

it 'preserves native failure results on a worker thread' do
salt = Fiber.new { BCrypt::Engine.send(:__bc_salt, '$2a$', 32, '0123456789abcdef') }.resume
hash = Fiber.new { BCrypt::Engine.send(:__bc_crypt, 'secret', '$2a$03$CCCCCCCCCCCCCCCCCCCCC.') }.resume

expect(salt).to be_nil
expect(hash).to be_nil
expect(@scheduler.calls).to eq(2)
end
end

Back | FazBrowse Home | New Git URL