|
#!/usr/bin/env ruby |
|
# frozen_string_literal: true |
|
|
|
require "fileutils" |
|
require "digest" |
|
require "json" |
|
require "optparse" |
|
require "rbconfig" |
|
require "rbs" |
|
require "tmpdir" |
|
|
|
ROOT = __dir__ |
|
SCRIPT = File.expand_path(__FILE__) |
|
TMP_ROOT = File.join(ROOT, "tmp") |
|
DEFAULT_RDOC = File.expand_path("../rdoc", ROOT) |
|
|
|
def elapsed |
|
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) |
|
yield |
|
Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at |
|
end |
|
|
|
def parser_setup(file) |
|
options = RDoc::Options.new |
|
options.quiet = true |
|
store = RDoc::Store.new(options) |
|
top_level = store.add_file(file) |
|
[options, store, top_level, RDoc::Stats.new(store, 0)] |
|
end |
|
|
|
def child_benchmark |
|
require "rdoc" |
|
require "rdoc/parser" |
|
|
|
workload = ARGV.fetch(0) |
|
input = ARGV.fetch(1) |
|
count = Integer(ARGV.fetch(2)) |
|
options, store, top_level, stats = parser_setup(input) |
|
|
|
abort "Unknown child workload: #{workload}" unless %w[rbs_methods rbs_attributes].include?(workload) |
|
|
|
content = File.binread(input) |
|
GC.start |
|
seconds = elapsed { RDoc::Parser::RBS.new(top_level, content, options, stats).scan } |
|
|
|
class_modules = store.all_classes_and_modules.sort_by(&:full_name) |
|
structure = [ |
|
top_level.classes_or_modules.map(&:full_name), |
|
class_modules.map do |class_module| |
|
[ |
|
class_module.full_name, |
|
class_module.method_list.map do |method| |
|
alias_name = method.is_alias_for.name if method.is_alias_for.respond_to?(:name) |
|
[method.name, method.singleton, alias_name, method.type_signature_lines] |
|
end, |
|
class_module.attributes.map do |attribute| |
|
[attribute.name, attribute.singleton, attribute.rw, attribute.type_signature_lines] |
|
end |
|
] |
|
end |
|
] |
|
result = { |
|
workload: workload, |
|
declarations: count, |
|
elapsed_seconds: seconds, |
|
locations: top_level.classes_or_modules.size, |
|
classes: class_modules.size, |
|
methods: class_modules.sum { |class_module| class_module.method_list.size }, |
|
attributes: class_modules.sum { |class_module| class_module.attributes.size }, |
|
structure_sha256: Digest::SHA256.hexdigest(Marshal.dump(structure)) |
|
} |
|
puts JSON.generate(result) |
|
end |
|
|
|
if ENV["RDOC_SET_BENCHMARK_CHILD"] |
|
child_benchmark |
|
exit |
|
end |
|
|
|
def run!(*command, **options) |
|
return if system(*command, **options) |
|
|
|
abort "Command failed: #{command.join(" ")}" |
|
end |
|
|
|
def median(values) |
|
sorted = values.sort |
|
middle = sorted.length / 2 |
|
return sorted[middle] if sorted.length.odd? |
|
|
|
(sorted[middle - 1] + sorted[middle]) / 2 |
|
end |
|
|
|
def speedup(baseline, optimized) |
|
baseline / optimized |
|
end |
|
|
|
def child_result(rdoc, workload, input, declarations) |
|
env = {"RDOC_SET_BENCHMARK_CHILD" => "1"} |
|
command = [RbConfig.ruby, "-I#{File.join(rdoc, "lib")}", SCRIPT, workload, input, declarations.to_s] |
|
output = IO.popen(env, command, err: [:child, :out], &:read) |
|
abort "#{workload} benchmark failed:\n#{output}" unless $?.success? |
|
JSON.parse(output) |
|
end |
|
|
|
def command_output(*command) |
|
output = IO.popen(command, err: [:child, :out], &:read) |
|
abort "Command failed: #{command.join(" ")}\n#{output}" unless $?.success? |
|
output |
|
end |
|
|
|
def working_tree_sha256(rdoc) |
|
digest = Digest::SHA256.new |
|
digest << command_output("git", "-C", rdoc, "diff", "--binary", "HEAD") |
|
untracked = command_output("git", "-C", rdoc, "ls-files", "--others", "--exclude-standard", "-z").split("\0").sort |
|
untracked.each do |relative_path| |
|
digest << relative_path << "\0" << File.binread(File.join(rdoc, relative_path)) |
|
end |
|
digest.hexdigest |
|
end |
|
|
|
def validate_workload!(workload, sample, declarations) |
|
expected = case workload |
|
when "rbs_methods" |
|
[1, 1, declarations, 0] |
|
when "rbs_attributes" |
|
[1, 1, 0, declarations] |
|
end |
|
actual = sample.values_at("locations", "classes", "methods", "attributes") |
|
abort "#{workload} produced #{actual.inspect}, expected #{expected.inspect}" unless actual == expected |
|
end |
|
|
|
def compare_micro(workload, paths, input, declarations, runs) |
|
paths.each_value { |rdoc| child_result(rdoc, workload, input, declarations) } |
|
results = {"baseline" => [], "optimized" => []} |
|
samples = {} |
|
|
|
runs.times do |index| |
|
order = index.even? ? %w[baseline optimized] : %w[optimized baseline] |
|
order.each do |variant| |
|
sample = child_result(paths.fetch(variant), workload, input, declarations) |
|
results.fetch(variant) << sample.fetch("elapsed_seconds") |
|
samples[variant] = sample |
|
end |
|
end |
|
|
|
comparable = samples.fetch("baseline").values_at("classes", "methods", "attributes", "structure_sha256") == |
|
samples.fetch("optimized").values_at("classes", "methods", "attributes", "structure_sha256") |
|
abort "#{workload} parser results differ" unless comparable |
|
samples.each_value { |sample| validate_workload!(workload, sample, declarations) } |
|
|
|
{ |
|
"workload" => workload, |
|
"declarations" => declarations, |
|
"baseline_seconds" => median(results.fetch("baseline")), |
|
"optimized_seconds" => median(results.fetch("optimized")), |
|
"structure_sha256" => samples.fetch("baseline").fetch("structure_sha256"), |
|
"results_match" => true |
|
} |
|
end |
|
|
|
def wall_time(rdoc, inputs, chdir) |
|
command = [ |
|
RbConfig.ruby, |
|
"-I#{File.join(rdoc, "lib")}", |
|
File.join(rdoc, "exe/rdoc"), |
|
"--dry-run", |
|
"--quiet", |
|
*inputs |
|
] |
|
elapsed do |
|
run!(*command, chdir: chdir, out: File::NULL, err: File::NULL) |
|
end |
|
end |
|
|
|
def compare_end_to_end(workload, paths, inputs, chdir, runs) |
|
paths.each_value { |rdoc| wall_time(rdoc, inputs, chdir) } |
|
results = {"baseline" => [], "optimized" => []} |
|
|
|
runs.times do |index| |
|
order = index.even? ? %w[baseline optimized] : %w[optimized baseline] |
|
order.each do |variant| |
|
results.fetch(variant) << wall_time(paths.fetch(variant), inputs, chdir) |
|
end |
|
end |
|
|
|
{ |
|
"workload" => workload, |
|
"baseline_seconds" => median(results.fetch("baseline")), |
|
"optimized_seconds" => median(results.fetch("optimized")) |
|
} |
|
end |
|
|
|
def write_inputs(root, declarations) |
|
FileUtils.mkdir_p(root) |
|
methods_rbs = File.join(root, "methods.rbs") |
|
attributes_rbs = File.join(root, "attributes.rbs") |
|
|
|
File.write(methods_rbs, "class Bench\n" + declarations.times.map { |i| " def m#{i}: () -> void" }.join("\n") + "\nend\n") |
|
File.write(attributes_rbs, "class Bench\n" + declarations.times.map { |i| " attr_reader a#{i}: String" }.join("\n") + "\nend\n") |
|
|
|
{ |
|
"rbs_methods" => methods_rbs, |
|
"rbs_attributes" => attributes_rbs |
|
} |
|
end |
|
|
|
def print_report(report) |
|
puts |
|
puts "Parser-only" |
|
puts "| Workload | Declarations | Baseline | Optimized | Speedup |" |
|
puts "| --- | ---: | ---: | ---: | ---: |" |
|
report.fetch("micro").each do |result| |
|
puts "| #{result.fetch("workload")} | #{result.fetch("declarations")} | #{format("%.4fs", result.fetch("baseline_seconds"))} | #{format("%.4fs", result.fetch("optimized_seconds"))} | #{format("%.1fx", speedup(result.fetch("baseline_seconds"), result.fetch("optimized_seconds")))} |" |
|
end |
|
|
|
return unless report["end_to_end"] |
|
|
|
puts |
|
puts "End-to-end `rdoc --dry-run`" |
|
puts "| Workload | Baseline | Optimized | Change |" |
|
puts "| --- | ---: | ---: | ---: |" |
|
report.fetch("end_to_end").each do |result| |
|
change = (result.fetch("optimized_seconds") / result.fetch("baseline_seconds") - 1) * 100 |
|
puts "| #{result.fetch("workload")} | #{format("%.3fs", result.fetch("baseline_seconds"))} | #{format("%.3fs", result.fetch("optimized_seconds"))} | #{format("%+.1f%%", change)} |" |
|
end |
|
end |
|
|
|
options = { |
|
rdoc: DEFAULT_RDOC, |
|
base_ref: "master", |
|
declarations: 2_000, |
|
runs: 5, |
|
end_to_end: true |
|
} |
|
OptionParser.new do |parser| |
|
parser.banner = "Usage: #{File.basename($PROGRAM_NAME)} [options]" |
|
parser.on("--rdoc PATH", "Optimized RDoc worktree (default: ../rdoc)") { |path| options[:rdoc] = File.expand_path(path) } |
|
parser.on("--base-ref REF", "Baseline Git ref (default: master)") { |ref| options[:base_ref] = ref } |
|
parser.on("--declarations COUNT", Integer, "Members per workload (default: 2000)") { |count| options[:declarations] = count } |
|
parser.on("--runs COUNT", Integer, "Measured runs per variant (default: 5)") { |count| options[:runs] = count } |
|
parser.on("--micro-only", "Skip end-to-end documentation generation") { options[:end_to_end] = false } |
|
parser.on("-h", "--help", "Show this help") { puts parser; exit } |
|
end.parse! |
|
|
|
abort "--declarations must be positive" unless options.fetch(:declarations).positive? |
|
abort "--runs must be positive" unless options.fetch(:runs).positive? |
|
rdoc = options.fetch(:rdoc) |
|
abort "RDoc worktree does not exist: #{rdoc}" unless File.file?(File.join(rdoc, "lib/rdoc.rb")) |
|
|
|
FileUtils.mkdir_p(TMP_ROOT) |
|
run_root = Dir.mktmpdir("run-", TMP_ROOT) |
|
baseline = File.join(run_root, "baseline-rdoc") |
|
baseline_added = false |
|
|
|
begin |
|
run!("git", "-C", rdoc, "worktree", "add", "--detach", baseline, options.fetch(:base_ref)) |
|
baseline_added = true |
|
inputs = write_inputs(File.join(run_root, "inputs"), options.fetch(:declarations)) |
|
paths = {"baseline" => baseline, "optimized" => rdoc} |
|
micro = %w[rbs_methods rbs_attributes].map do |workload| |
|
compare_micro(workload, paths, inputs.fetch(workload), options.fetch(:declarations), options.fetch(:runs)) |
|
end |
|
|
|
report = { |
|
"ruby" => RUBY_DESCRIPTION, |
|
"rbs" => RBS::VERSION, |
|
"base_ref" => options.fetch(:base_ref), |
|
"base_commit" => command_output("git", "-C", baseline, "rev-parse", "HEAD^{commit}").strip, |
|
"optimized_commit" => command_output("git", "-C", rdoc, "rev-parse", "HEAD").strip, |
|
"optimized_worktree_sha256" => working_tree_sha256(rdoc), |
|
"runs" => options.fetch(:runs), |
|
"micro" => micro |
|
} |
|
|
|
if options.fetch(:end_to_end) |
|
report["end_to_end"] = [ |
|
compare_end_to_end("rbs_methods", paths, [inputs.fetch("rbs_methods")], ROOT, options.fetch(:runs)), |
|
compare_end_to_end("rbs_attributes", paths, [inputs.fetch("rbs_attributes")], ROOT, options.fetch(:runs)) |
|
] |
|
end |
|
|
|
report_path = File.join(run_root, "report.json") |
|
File.write(report_path, JSON.pretty_generate(report)) |
|
print_report(report) |
|
puts |
|
puts "JSON report: #{report_path}" |
|
ensure |
|
if baseline_added |
|
run!("git", "-C", rdoc, "worktree", "remove", baseline) |
|
else |
|
FileUtils.rm_rf(baseline) |
|
end |
|
end |