|
| 1 | +# Benchmarks for the response-muxer hot-path and self-healing changes. |
| 2 | +# |
| 3 | +# This file measures BOTH the old (baseline) and new (patched) behavior in one |
| 4 | +# process so the speedup/robustness delta is reproducible on CRuby and JRuby |
| 5 | +# without a NATS server: |
| 6 | +# |
| 7 | +# A. Dispatch hot-path cost -- per-message pending_size accounting that was |
| 8 | +# removed (#1). benchmark-ips, lower is better. |
| 9 | +# B. nil-@resp_sub resilience -- busy-spin vs park during a restart window (#3). |
| 10 | +# C. Self-healing counter -- lost updates with a plain int vs AtomicFixnum |
| 11 | +# under concurrent crashes (#4). |
| 12 | +# |
| 13 | +# Usage: |
| 14 | +# bundle exec ruby -Ilib bench/muxer_resilience_bench.rb |
| 15 | + |
| 16 | +require "bundler/setup" |
| 17 | +require "benchmark/ips" |
| 18 | +require "concurrent" |
| 19 | +require "nats/client" # real NATS::Subscription / NATS::Msg |
| 20 | + |
| 21 | +def mono |
| 22 | + ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) |
| 23 | +end |
| 24 | + |
| 25 | +puts "=" * 72 |
| 26 | +puts "protobuf-nats response-muxer resilience bench" |
| 27 | +puts "engine=#{RUBY_ENGINE} #{RUBY_VERSION} processor_count=#{::Concurrent.processor_count}" |
| 28 | +puts "=" * 72 |
| 29 | + |
| 30 | +# -------------------------------------------------------------------------- |
| 31 | +# A. Dispatch hot-path: per-message pending_size accounting (removed in #1). |
| 32 | +# |
| 33 | +# Old dispatch did `sub.synchronize { sub.pending_size -= msg.data.size }` for |
| 34 | +# EVERY response message; the new code does nothing here. We compare the old |
| 35 | +# accounting step against the cheapest real per-message op (a Concurrent::Map |
| 36 | +# lookup, which the dispatcher still does) so the delta is the lock overhead we |
| 37 | +# removed from the hot path. |
| 38 | +# -------------------------------------------------------------------------- |
| 39 | +puts "\nA. Dispatch hot-path per-message overhead (higher ips = better)\n\n" |
| 40 | + |
| 41 | +sub = ::NATS::Subscription.new |
| 42 | +sub.pending_size = 0 |
| 43 | +resp_map = ::Concurrent::Map.new |
| 44 | +resp_map["tok"] = { :queue => ::Queue.new } |
| 45 | +size = 64 |
| 46 | + |
| 47 | +Benchmark.ips do |x| |
| 48 | + x.config(:time => 3, :warmup => 1) |
| 49 | + |
| 50 | + x.report("old: synchronize { pending_size -= n } + map lookup") do |
| 51 | + sub.synchronize { sub.pending_size -= size } |
| 52 | + resp_map["tok"] |
| 53 | + end |
| 54 | + |
| 55 | + x.report("new: map lookup only (accounting removed)") do |
| 56 | + resp_map["tok"] |
| 57 | + end |
| 58 | + |
| 59 | + x.compare! |
| 60 | +end |
| 61 | + |
| 62 | +# -------------------------------------------------------------------------- |
| 63 | +# B. nil-@resp_sub resilience (#3). During a restart @resp_sub can briefly be |
| 64 | +# nil. The old loop dereferenced it unconditionally (NoMethodError every |
| 65 | +# iteration -> busy-spin + a logged error/callback per spin); the new loop |
| 66 | +# parks. We run each for a fixed window and count iterations and "errors that |
| 67 | +# would be logged/dispatched to callbacks". |
| 68 | +# -------------------------------------------------------------------------- |
| 69 | +puts "\nB. Behavior while @resp_sub is nil for #{(WINDOW = 0.5)}s (lower spin = better)\n\n" |
| 70 | + |
| 71 | +def run_old_loop(window) |
| 72 | + resp_sub = nil # the restart window |
| 73 | + iters = 0 |
| 74 | + errors = 0 |
| 75 | + deadline = mono + window |
| 76 | + while mono < deadline |
| 77 | + begin |
| 78 | + resp_sub.pending_queue.pop # NoMethodError on nil |
| 79 | + rescue => _e |
| 80 | + errors += 1 # old code logs + notify_error_callbacks here |
| 81 | + end |
| 82 | + iters += 1 |
| 83 | + end |
| 84 | + [iters, errors] |
| 85 | +end |
| 86 | + |
| 87 | +def run_new_loop(window) |
| 88 | + resp_sub = nil |
| 89 | + iters = 0 |
| 90 | + errors = 0 |
| 91 | + deadline = mono + window |
| 92 | + while mono < deadline |
| 93 | + s = resp_sub |
| 94 | + if s.nil? |
| 95 | + sleep 0.01 # park instead of spinning |
| 96 | + iters += 1 |
| 97 | + next |
| 98 | + end |
| 99 | + begin |
| 100 | + s.pending_queue.pop |
| 101 | + rescue => _e |
| 102 | + errors += 1 |
| 103 | + end |
| 104 | + iters += 1 |
| 105 | + end |
| 106 | + [iters, errors] |
| 107 | +end |
| 108 | + |
| 109 | +old_iters, old_errs = run_old_loop(WINDOW) |
| 110 | +new_iters, new_errs = run_new_loop(WINDOW) |
| 111 | + |
| 112 | +printf(" old loop: %12d iterations, %12d errors logged/dispatched\n", old_iters, old_errs) |
| 113 | +printf(" new loop: %12d iterations, %12d errors logged/dispatched\n", new_iters, new_errs) |
| 114 | +printf(" => new loop does %.5f%% of the old loop's wasted work\n", |
| 115 | + old_iters.zero? ? 0.0 : (new_iters.to_f / old_iters * 100)) |
| 116 | + |
| 117 | +# -------------------------------------------------------------------------- |
| 118 | +# C. Self-healing crash counter (#4). The old counter was a plain Integer |
| 119 | +# mutated by multiple dispatcher threads (`@crash_count = (@crash_count||0)+1`), |
| 120 | +# which loses updates under true parallelism, corrupting the exponential |
| 121 | +# backoff. The new counter is a Concurrent::AtomicFixnum. We have N threads each |
| 122 | +# "crash" K times and check the final count. |
| 123 | +# -------------------------------------------------------------------------- |
| 124 | +puts "\nC. Crash-counter accuracy under concurrent crashes (expected == actual is correct)\n\n" |
| 125 | + |
| 126 | +def hammer(counter, threads, per_thread) |
| 127 | + ts = threads.times.map do |
| 128 | + ::Thread.new do |
| 129 | + per_thread.times { counter.call } |
| 130 | + end |
| 131 | + end |
| 132 | + ts.each(&:join) |
| 133 | +end |
| 134 | + |
| 135 | +threads = [::Concurrent.processor_count, 4].max |
| 136 | +per_thread = 50_000 |
| 137 | +expected = threads * per_thread |
| 138 | + |
| 139 | +# Old: plain integer read-modify-write (racy). |
| 140 | +plain = 0 |
| 141 | +hammer(->{ plain = plain + 1 }, threads, per_thread) |
| 142 | + |
| 143 | +# New: atomic increment. |
| 144 | +atomic = ::Concurrent::AtomicFixnum.new(0) |
| 145 | +hammer(->{ atomic.increment }, threads, per_thread) |
| 146 | + |
| 147 | +printf(" threads=%d per_thread=%d expected=%d\n", threads, per_thread, expected) |
| 148 | +printf(" old plain Integer: %10d (lost %d updates)\n", plain, expected - plain) |
| 149 | +printf(" new AtomicFixnum: %10d (lost %d updates)\n", atomic.value, expected - atomic.value) |
| 150 | + |
| 151 | +puts "\ndone." |
0 commit comments