diff --git a/harness/harness-common.rb b/harness/harness-common.rb index d3e87a44..073edb04 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. @@ -117,8 +121,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 @@ -160,6 +167,48 @@ 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 + +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 + 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 +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