Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 51 additions & 2 deletions harness/harness-common.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down
35 changes: 24 additions & 11 deletions lib/benchmark_suite.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions test/benchmark_suite_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading