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
36 changes: 32 additions & 4 deletions .github/scripts/lint-benchmarks.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,21 @@
# so every file uses the default
# - no Benchmark.ips sits inside a method that never runs from the top of the file,
# which would benchmark nothing at all
# - every Benchmark.ips block states which report should win: exactly one
# report calls `fastest` (else `faster`, else `fast`), and it comes first
#
# Usage: ruby .github/scripts/lint-benchmarks.rb [files...] (needs Ruby 3.3+)
require "prism"

# Calls named `name` that have a block, not looking inside the ones found.
def find_calls(node, name, found = [])
# Calls named `name`, not looking inside the ones found. Only calls with a
# block, unless `with_block: false` (reports can be a code string: x.report(label, code)).
def find_calls(node, name, with_block: true, found: [])
return found unless node

if node.is_a?(Prism::CallNode) && node.name == name && node.block
if node.is_a?(Prism::CallNode) && node.name == name && (node.block || !with_block)
found << node
else
node.compact_child_nodes.each { |child| find_calls(child, name, found) }
node.compact_child_nodes.each { |child| find_calls(child, name, with_block: with_block, found: found) }
end
found
end
Expand All @@ -29,6 +32,28 @@ def any_call?(node, &test)
node.compact_child_nodes.any? { |child| any_call?(child, &test) }
end

CLAIM_METHODS = %i[fastest faster fast].freeze

# Method names called under `node` without a receiver.
def called_names(node, names = [])
return names unless node

names << node.name if node.is_a?(Prism::CallNode) && node.receiver.nil?
node.compact_child_nodes.each { |child| called_names(child, names) }
names
end

def claim_problem(ips)
reports = find_calls(ips.block, :report, with_block: false).map { |r| called_names(r.block) }
top = CLAIM_METHODS.find { |name| reports.any? { |calls| calls.include?(name) } }
return "no claim. Wrap each report in a method and name the winner's `fast` (or `faster`, `fastest`)" unless top

claimed = reports.count { |calls| calls.include?(top) }
return "#{claimed} reports call `#{top}`. Name only the winner `#{top}`" if claimed > 1

"the report calling `#{top}` must come first" unless reports.first.include?(top)
end

TIMING_KEYS = %w[time warmup].freeze

# x.time = 20, x.warmup = 5, or x.config with a time or warmup key,
Expand Down Expand Up @@ -159,6 +184,9 @@ def lint(file)
name = owner.keys.last.delete_prefix("#")
problems << "#{where}: inside `def #{name}`, which never runs from the top of the file"
end

claim = claim_problem(ips)
problems << "#{where}: #{claim}" if claim
end
problems
end
Expand Down
32 changes: 31 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,37 @@ Keep that shape: end every `Benchmark.ips` block with `x.compare!`, keep the
default timing (no `Benchmark.ips(20)`, `x.time = ...` or `x.config(time: ...)`),
so every entry is measured the same way, and make sure
the file actually calls `Benchmark.ips` when it runs
(not only inside a method nothing calls). CI checks all three.
(not only inside a method nothing calls). CI checks these, and the naming below.

The method names say which report should win.
CI checks that exactly one report is named the winner and that it comes first.
Wrap every report in a method, so they all pay the same call cost.
Name them by rank and list the winner first.
The names grow outward from the line between fast and slow: the recommended side uses `fast`, then `faster`, then `fastest`; the other side uses `slow`, then `slower`, then `slowest`.
The names are relative: `slow` only means slower than `fast`.
When a ranking could look odd, say why in one line, like in `code/array/length-vs-size-vs-count.rb`:

```ruby
def faster
ARRAY.length
end

# Array#size is an alias of Array#length, so these two should tie.
def fast
ARRAY.size
end

def slow
ARRAY.count
end

Benchmark.ips do |x|
x.report("Array#length") { faster }
x.report("Array#size") { fast }
x.report("Array#count") { slow }
x.compare!
end
```

Run your result:

Expand Down
29 changes: 15 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ using an if statement: 15517955.2 i/s
String#constantize: 10556362.4 i/s - 1.47x slower
```

##### `raise` vs `E2MM#Raise` for raising (and defining) exceptions [code](code/general/raise-vs-e2mmap.rb)
##### `raise` vs `E2MM#Raise` for raising (and defining) exceptions [code](code/general/raise-vs-e2mmap.rb) [custom exception code](code/general/raise-custom-vs-e2mmap.rb)

Ruby's [Exception2MessageMapper module](http://ruby-doc.org/stdlib-2.2.0/libdoc/e2mmap/rdoc/index.html) allows one to define and raise exceptions with predefined messages.

Expand All @@ -155,7 +155,10 @@ Ruby exception: Kernel#raise
Comparison:
Ruby exception: Kernel#raise: 2570660.6 i/s
Ruby exception: E2MM#Raise: 88268.9 i/s - 29.12x slower
```

```
$ ruby -v code/general/raise-custom-vs-e2mmap.rb
ruby 4.0.0 (2025-12-25 revision 553f1675f3) +PRISM [arm64-darwin24]
Warming up --------------------------------------
Custom exception: E2MM#Raise
Expand Down Expand Up @@ -1058,23 +1061,21 @@ Comparison:

```
$ ruby -v code/proc-and-block/proc-call-vs-yield.rb
ruby 4.0.0 (2025-12-25 revision 553f1675f3) +PRISM [arm64-darwin24]
ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [aarch64-linux]
Warming up --------------------------------------
block.call 2.261M i/100ms
block + yield 2.314M i/100ms
unused block 3.025M i/100ms
yield 2.971M i/100ms
yield 2.156M i/100ms
block + yield 1.605M i/100ms
block.call 1.609M i/100ms
Calculating -------------------------------------
block.call 22.057M (± 6.0%) i/s (45.34 ns/i) - 110.796M in 5.043129s
block + yield 23.280M (± 0.6%) i/s (42.96 ns/i) - 117.997M in 5.068779s
unused block 30.609M (± 1.3%) i/s (32.67 ns/i) - 154.268M in 5.040991s
yield 29.921M (± 0.6%) i/s (33.42 ns/i) - 151.512M in 5.063842s
yield 20.615M (±10.0%) i/s (48.51 ns/i) - 103.495M in 5.020450s
block + yield 16.824M (± 9.2%) i/s (59.44 ns/i) - 85.066M in 5.056288s
block.call 14.608M (±14.3%) i/s (68.46 ns/i) - 73.995M in 5.065432s

Comparison:
unused block: 30608512.5 i/s
yield: 29921356.8 i/s - 1.02x slower
block + yield: 23279981.0 i/s - 1.31x slower
block.call: 22056758.6 i/s - 1.39x slower
yield: 20614679.3 i/s
block + yield: 16823796.9 i/s - 1.23x slower
block.call: 14607819.3 i/s - 1.41x slower

```

### String
Expand Down
14 changes: 11 additions & 3 deletions code/array/bsearch-vs-find.rb
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
require 'benchmark/ips'

data = [*0..100_000_000]
NUMBERS = [*0..100_000_000]

def fast
NUMBERS.bsearch { |number| number > 77_777_777 }
end

def slow
NUMBERS.find { |number| number > 77_777_777 }
end

Benchmark.ips do |x|
x.report('find') { data.find { |number| number > 77_777_777 } }
x.report('bsearch') { data.bsearch { |number| number > 77_777_777 } }
x.report('bsearch') { fast }
x.report('find') { slow }
x.compare!
end
20 changes: 11 additions & 9 deletions code/array/insert-vs-unshift.rb
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
require 'benchmark/ips'

Benchmark.ips do |x|
x.report('Array#unshift') do
array = []
100_000.times { |i| array.unshift(i) }
end
def fast
array = []
100_000.times { |i| array.unshift(i) }
end

x.report('Array#insert') do
array = []
100_000.times { |i| array.insert(0, i) }
end
def slow
array = []
100_000.times { |i| array.insert(0, i) }
end

Benchmark.ips do |x|
x.report('Array#unshift') { fast }
x.report('Array#insert') { slow }
x.compare!
end
19 changes: 16 additions & 3 deletions code/array/length-vs-size-vs-count.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,22 @@

ARRAY = [*1..100]

def faster
ARRAY.length
end

# Array#size is an alias of Array#length, so these two should tie.
def fast
ARRAY.size
end

def slow
ARRAY.count
end

Benchmark.ips do |x|
x.report("Array#length") { ARRAY.length }
x.report("Array#size") { ARRAY.size }
x.report("Array#count") { ARRAY.count }
x.report("Array#length") { faster }
x.report("Array#size") { fast }
x.report("Array#count") { slow }
x.compare!
end
10 changes: 5 additions & 5 deletions code/array/shuffle-first-vs-sample.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@

ARRAY = [*1..100]

def slow
ARRAY.shuffle.first
end

def fast
ARRAY.sample
end

def slow
ARRAY.shuffle.first
end

Benchmark.ips do |x|
x.report('Array#shuffle.first') { slow }
x.report('Array#sample') { fast }
x.report('Array#shuffle.first') { slow }
x.compare!
end
10 changes: 5 additions & 5 deletions code/enumerable/each-push-vs-map.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,17 @@

ARRAY = (1..100).to_a

def fast
ARRAY.map { |i| i }
end

def slow
array = []
ARRAY.each { |i| array.push i }
end

def fast
ARRAY.map { |i| i }
end

Benchmark.ips do |x|
x.report('Array#each + push') { slow }
x.report('Array#map') { fast }
x.report('Array#each + push') { slow }
x.compare!
end
10 changes: 5 additions & 5 deletions code/enumerable/each-vs-for-loop.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,20 @@

ARRAY = [*1..100]

def slow
for number in ARRAY do
def fast
ARRAY.each do |number|
number
end
end

def fast
ARRAY.each do |number|
def slow
for number in ARRAY do
number
end
end

Benchmark.ips do |x|
x.report('For loop') { slow }
x.report('#each') { fast }
x.report('For loop') { slow }
x.compare!
end
7 changes: 3 additions & 4 deletions code/enumerable/inject-symbol-vs-block.rb
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
require "rubygems"
require "benchmark/ips"

ARRAY = (1..1000).to_a

def fastest
def faster
ARRAY.inject(:+)
end

# Symbol#to_proc beats the block on plain CRuby up to 3.4; the block wins with YJIT or ZJIT, on 4.0 and newer, and on JRuby.
def fast
ARRAY.inject(&:+)
end
Expand All @@ -16,9 +16,8 @@ def slow
end

Benchmark.ips do |x|
x.report('inject symbol') { fastest }
x.report('inject symbol') { faster }
x.report('inject to_proc') { fast }
x.report('inject block') { slow }

x.compare!
end
18 changes: 9 additions & 9 deletions code/enumerable/map-flatten-vs-flat_map.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,21 @@

ARRAY = (1..100).to_a

def slow_flatten_1
ARRAY.map { |e| [e, e] }.flatten(1)
def fast
ARRAY.flat_map { |e| [e, e] }
end

def slow_flatten
ARRAY.map { |e| [e, e] }.flatten
def slow
ARRAY.map { |e| [e, e] }.flatten(1)
end

def fast
ARRAY.flat_map { |e| [e, e] }
def slower
ARRAY.map { |e| [e, e] }.flatten
end

Benchmark.ips do |x|
x.report('Array#map.flatten(1)') { slow_flatten_1 }
x.report('Array#map.flatten') { slow_flatten }
x.report('Array#flat_map') { fast }
x.report('Array#flat_map') { fast }
x.report('Array#map.flatten(1)') { slow }
x.report('Array#map.flatten') { slower }
x.compare!
end
10 changes: 5 additions & 5 deletions code/enumerable/reverse-each-vs-reverse_each.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@

ARRAY = (1..100).to_a

def slow
ARRAY.reverse.each{|x| x}
end

def fast
ARRAY.reverse_each{|x| x}
end

def slow
ARRAY.reverse.each{|x| x}
end

Benchmark.ips do |x|
x.report('Array#reverse.each') { slow }
x.report('Array#reverse_each') { fast }
x.report('Array#reverse.each') { slow }
x.compare!
end
Loading
Loading