From f633bb5e49c12b567b19ba7a09631ecd4bb609df Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Sat, 10 Oct 2026 18:34:41 +0900 Subject: [PATCH 1/3] Restore $VERBOSE when fiddle fails to load $VERBOSE stayed nil after a LoadError from require 'fiddle', which silenced the warning of the caller that rescued it and every later one in the benchmark. Co-Authored-By: Claude Opus 5.5 --- harness/harness-common.rb | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/harness/harness-common.rb b/harness/harness-common.rb index d3e87a44..35dab045 100644 --- a/harness/harness-common.rb +++ b/harness/harness-common.rb @@ -117,8 +117,11 @@ def load_fiddle end # Suppress a warning for Ruby 3.4+ on benchmarks with Gemfile verbose, $VERBOSE = $VERBOSE, nil - require 'fiddle' - $VERBOSE = verbose + begin + require 'fiddle' + ensure + $VERBOSE = verbose + end end # struct rusage_info_v0 is a 16 byte uuid followed by uint64 fields, of which From fad8e73a01bd9d9b14b32310cbc6a3c89556625d Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Sat, 10 Oct 2026 18:35:09 +0900 Subject: [PATCH 2/3] Pin benchmarks to a CPU on Windows Without taskset, the runner passes the CPU through RUBY_BENCH_PIN_CPU and the harness pins itself with SetProcessAffinityMask. On a Ryzen AI 9 HX 370, an unpinned run moves between Zen5 and Zen5c cores, which differ in speed by about 30%. The CPU is chosen as on Linux and is a Zen5 one there. A failure to pin, for example without fiddle, leaves the benchmark running unpinned with a warning. Co-Authored-By: Claude Opus 5.5 --- harness/harness-common.rb | 34 +++++++++++++++++++++++++++++ lib/benchmark_suite.rb | 35 ++++++++++++++++++++---------- test/benchmark_suite_test.rb | 42 ++++++++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 11 deletions(-) diff --git a/harness/harness-common.rb b/harness/harness-common.rb index 35dab045..a17fe9dc 100644 --- a/harness/harness-common.rb +++ b/harness/harness-common.rb @@ -101,6 +101,10 @@ def is_macos RUBY_PLATFORM.match?(/darwin/) end +def is_windows + RUBY_PLATFORM.match?(/mswin|mingw/) +end + def load_fiddle unless $LOAD_PATH.resolve_feature_path("fiddle") # In Ruby 3.5+, fiddle is no longer a default gem. Load the bundled-gem fiddle instead. @@ -163,6 +167,36 @@ def macos_resident_size buffer[RI_RESIDENT_SIZE_OFFSET, 8].unpack1('Q') end +# A dropped Fiddle::Handle is freed by a deferred finalizer, and its interrupt sends YJIT code back to the +# interpreter for the rest of the loop the benchmark was in, so kernel32 stays open. +module WindowsKernel32 + def self.function(name, args, ret) + @handle ||= Fiddle.dlopen("kernel32") + Fiddle::Function.new(@handle[name], args, ret) + end +end + +def pin_to_cpu(cpu) + load_fiddle + # Etc.nprocessors counts the CPUs of every processor group, but the mask holds those of one group only. + if cpu >= Fiddle::SIZEOF_VOIDP * 8 + warn "Not pinning to CPU #{cpu}, which is out of the affinity mask" + return false + end + process = WindowsKernel32.function('GetCurrentProcess', [], Fiddle::TYPE_VOIDP).call + set_affinity = WindowsKernel32.function('SetProcessAffinityMask', [Fiddle::TYPE_VOIDP, Fiddle::TYPE_UINTPTR_T], Fiddle::TYPE_INT) + raise "SetProcessAffinityMask failed" if set_affinity.call(process, 1 << cpu).zero? + true +end + +if is_windows && ENV["RUBY_BENCH_PIN_CPU"] + begin + pin_to_cpu(Integer(ENV["RUBY_BENCH_PIN_CPU"])) + rescue LoadError, StandardError => e + warn "Failed to pin the benchmark: #{e.message}" + end +end + def get_maxrss load_fiddle diff --git a/lib/benchmark_suite.rb b/lib/benchmark_suite.rb index e4bca826..48c474b9 100644 --- a/lib/benchmark_suite.rb +++ b/lib/benchmark_suite.rb @@ -19,6 +19,7 @@ class BenchmarkSuite RACTOR_CATEGORY = ["ractor"].freeze RACTOR_ONLY_CATEGORY = ["ractor-only"].freeze RACTOR_HARNESS = "harness-ractor" + PIN_CPU_ENV = "RUBY_BENCH_PIN_CPU" attr_reader :categories, :name_filters, :excludes, :out_path, :harness, :harness_explicit, :pre_init, :no_pinning, :force_pinning, :bench_dir @@ -47,7 +48,7 @@ def harness_for(benchmark_name) # Run a single benchmark entry on a single executable. # Returns { name:, data: } on success, { name:, failure: } on error. def run_benchmark(entry, ruby:, ruby_description:) - env = benchmark_env(ruby) + env = benchmark_env(ruby).merge(pinning_env(ruby_description, entry.name)) caller_json_path = ENV["RESULT_JSON_PATH"] quiet = ENV['BENCHMARK_QUIET'] == '1' cmd_prefix = base_cmd(ruby_description, entry.name) @@ -272,26 +273,38 @@ def linux? @linux ||= RbConfig::CONFIG['host_os'] =~ /linux/ end + def windows? + @windows ||= RbConfig::CONFIG['host_os'] =~ /mswin|mingw/ + end + # Set up the base command with CPU pinning if needed def base_cmd(ruby_description, benchmark_name) if linux? cmd = setarch_prefix - - # Pin the process to one given core to improve caching and reduce variance on CRuby - # and Spinel, which compiles the benchmark to a single-threaded native binary. - # Other Rubies need to use multiple cores, e.g., for JIT threads - if (ruby_description.start_with?('ruby ') || ruby_description.start_with?('spinel ')) && should_pin?(benchmark_name) - # Up to the last two thirds of Intel CPU cores may be slow E-Cores, so avoid using them. - cpu = [(Etc.nprocessors / 3) - 1, 0].max - cmd.concat(["taskset", "-c", "#{cpu}"]) - end - + cmd.concat(["taskset", "-c", "#{pinned_cpu}"]) if pin?(ruby_description, benchmark_name) cmd else [] end end + # Windows has no taskset, so the harness pins itself to the CPU given in this variable. + def pinning_env(ruby_description, benchmark_name) + { PIN_CPU_ENV => (pinned_cpu.to_s if windows? && pin?(ruby_description, benchmark_name)) } + end + + # Pin the process to one given core to improve caching and reduce variance on CRuby + # and Spinel, which compiles the benchmark to a single-threaded native binary. + # Other Rubies need to use multiple cores, e.g., for JIT threads + def pin?(ruby_description, benchmark_name) + (ruby_description.start_with?('ruby ') || ruby_description.start_with?('spinel ')) && should_pin?(benchmark_name) + end + + # Up to the last two thirds of Intel CPU cores may be slow E-Cores, so avoid using them. + def pinned_cpu + [(Etc.nprocessors / 3) - 1, 0].max + end + def should_pin?(benchmark_name) return false if no_pinning return true if force_pinning diff --git a/test/benchmark_suite_test.rb b/test/benchmark_suite_test.rb index 6b0879b4..27ae3c46 100644 --- a/test/benchmark_suite_test.rb +++ b/test/benchmark_suite_test.rb @@ -737,6 +737,48 @@ def base_cmd_for(ruby_description) assert_equal run_data['simple'].keys.sort, single_result[:data].keys.sort end + + describe 'on Windows' do + before do + File.write('benchmarks/pin_cpu.rb', <<~RUBY) + require 'json' + File.write(ENV['RESULT_JSON_PATH'], JSON.generate('warmup' => [], 'bench' => [0.001], 'pin_cpu' => ENV['RUBY_BENCH_PIN_CPU'])) + RUBY + end + + def pin_cpu_seen_by_benchmark(ruby_description: 'ruby 3.2.0', **options) + suite = BenchmarkSuite.new(categories: [], name_filters: ['pin_cpu'], out_path: @out_path, harness: 'harness', **options) + result = nil + suite.stub(:windows?, true) do + suite.stub(:linux?, false) do + capture_io do + result = suite.run_benchmark(suite.benchmarks.first, ruby: [RbConfig.ruby], ruby_description: ruby_description) + end + end + end + result[:data]['pin_cpu'] + end + + it 'passes the CPU to pin to through the environment' do + assert_equal [(Etc.nprocessors / 3) - 1, 0].max.to_s, pin_cpu_seen_by_benchmark + end + + it 'does not pin with no_pinning' do + assert_nil pin_cpu_seen_by_benchmark(no_pinning: true) + end + + it 'does not pass on a CPU inherited from the environment' do + original = ENV['RUBY_BENCH_PIN_CPU'] + ENV['RUBY_BENCH_PIN_CPU'] = '3' + assert_nil pin_cpu_seen_by_benchmark(no_pinning: true) + ensure + ENV['RUBY_BENCH_PIN_CPU'] = original + end + + it 'does not pin other Ruby implementations' do + assert_nil pin_cpu_seen_by_benchmark(ruby_description: 'truffleruby 24.0.0') + end + end end describe 'integration with BenchmarkFilter' do From 18ddc6d34a9b9290c927186b42d4919ece3d40d2 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Sat, 10 Oct 2026 18:35:18 +0900 Subject: [PATCH 3/3] Raise the priority of pinned benchmarks on Windows A pinned benchmark still shares its CPU with whatever else Windows runs there, and in one pinned run fib and nbody on the interpreter varied by over 25% between iterations. The harness now also takes HIGH_PRIORITY_CLASS, which preempts other processes on that CPU only, and turns off the EXECUTION_SPEED power throttling that Windows 11 may apply to a process without a visible window. Co-Authored-By: Claude Opus 5.5 --- harness/harness-common.rb | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/harness/harness-common.rb b/harness/harness-common.rb index a17fe9dc..073edb04 100644 --- a/harness/harness-common.rb +++ b/harness/harness-common.rb @@ -189,9 +189,21 @@ def pin_to_cpu(cpu) true end +def raise_priority + load_fiddle + process = WindowsKernel32.function('GetCurrentProcess', [], Fiddle::TYPE_VOIDP).call + set_priority_class = WindowsKernel32.function('SetPriorityClass', [Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT], Fiddle::TYPE_INT) + raise "SetPriorityClass failed" if set_priority_class.call(process, 0x80).zero? # HIGH_PRIORITY_CLASS + + # PROCESS_POWER_THROTTLING_STATE that turns off the EXECUTION_SPEED throttling Windows 11 may apply to a windowless process. + set_information = WindowsKernel32.function('SetProcessInformation', [Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT], Fiddle::TYPE_INT) + state = [1, 1, 0].pack('L3') + raise "SetProcessInformation failed" if set_information.call(process, 4, state, state.bytesize).zero? # ProcessPowerThrottling +end + if is_windows && ENV["RUBY_BENCH_PIN_CPU"] begin - pin_to_cpu(Integer(ENV["RUBY_BENCH_PIN_CPU"])) + raise_priority if pin_to_cpu(Integer(ENV["RUBY_BENCH_PIN_CPU"])) rescue LoadError, StandardError => e warn "Failed to pin the benchmark: #{e.message}" end