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
18 changes: 12 additions & 6 deletions BENCHMARK.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ bundle exec ruby --yjit -Ilib exe/rubyboy-bench \

Each trial starts with a fresh emulator and runs the CPU, timer, PPU and APU
without input, saves, drawing, audio output or real-time synchronization.
The command prints each trial's duration and the aggregate FPS (total measured
frames divided by total measured time).
The command prints each trial's duration, the aggregate FPS (total measured
frames divided by total measured time) and a checksum of the final frame.

## Spinel

Expand All @@ -49,14 +49,20 @@ runtime. Use a warmup so that YJIT has compiled the hot code before measurement.
Record the Ruby version (`ruby -v`) or Spinel revision (`spinel --version`) and the
Rubyboy revision with each result.

The checksum depends only on the ROM and the total of `--warmup-frames` and `--frames`,
so every runtime must print the same value. A different value means that runtime
emulated incorrectly. For example, with the bundled ROM, `--frames 600 --count 1`
prints `Checksum: 661048662`.

## Results

Measured on 2026-10-10 with Rubyboy `3484036` on a MacBook Air (Apple M4, macOS 15.5)
Measured on 2026-10-10 with Rubyboy 1.7.0 (`44f834f`) on a MacBook Air (Apple M4, macOS 15.5)
on AC power with other applications closed, using the commands above
(`tobu.gb`, `--frames 1500 --warmup-frames 1500 --count 5`).
All three printed `Checksum: 3889316277`.

| Runtime | Version | FPS |
| --- | --- | ---: |
| CRuby | 4.0.7 | 114 |
| CRuby + YJIT | 4.0.7 | 297 |
| Spinel (native) | `2810a23` | 603 |
| CRuby | 4.0.7 | 113 |
| CRuby + YJIT | 4.0.7 | 294 |
| Spinel (native) | `7d999f50c` | 799 |
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
## [Unreleased]
## [1.7.0] - 2026-10-10

- Require Ruby 4.0 or higher
- Keep audio output with --unlimited by dropping audio that cannot be played in time
- Remove the --no-audio player option
- Remove rubyboy-bench --render and --json, and the SDL 2.0.18 requirement
- Print a checksum of the final frame from rubyboy-bench to check that a runtime emulated correctly
- Run rubyboy-bench without loading SDL
- Run the bundled Tobu Tobu Girl ROM when rubyboy or rubyboy-bench gets no ROM path, from any directory
- Read ROM files in binary mode, which fixes loading them on Windows
- Make the APU duty tables shareable between Ractors
Expand Down
2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
rubyboy (1.6.0)
rubyboy (1.7.0)
ffi (~> 1.16, >= 1.16.3)

GEM
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@ next to the ROM file.

## Benchmark

On an Apple M4, Rubyboy runs at about 110 FPS on CRuby, 300 FPS with YJIT and
600 FPS when compiled with Spinel. See [BENCHMARK.md](BENCHMARK.md) for the method and details.
On an Apple M4, Rubyboy runs at about 110 FPS on CRuby, 290 FPS with YJIT and
800 FPS when compiled with Spinel. See [BENCHMARK.md](BENCHMARK.md) for the method and details.

## Other ways to run

Expand Down
1 change: 0 additions & 1 deletion exe/rubyboy-bench
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
#!/usr/bin/env ruby
# frozen_string_literal: true

require 'rubyboy'
require 'bench'
require 'optparse'

Expand Down
9 changes: 9 additions & 0 deletions lib/bench.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ def run(count: 3, frames: 1500, rom_path: Rom::DEFAULT_PATH, warmup_frames: 0)
validate_integer(:warmup_frames, warmup_frames, 0)

time_sum = 0
frame_checksum = 0
count.times do |i|
emulator = Rubyboy::EmulatorHeadless.new(rom_path)
run_frames(emulator, warmup_frames)
Expand All @@ -19,9 +20,12 @@ def run(count: 3, frames: 1500, rom_path: Rom::DEFAULT_PATH, warmup_frames: 0)
puts "#{i + 1}: #{time / 1_000_000_000.0} sec"

time_sum += time
frame_checksum = checksum(emulator.framebuffer)
end

puts "FPS: #{frames * count * 1_000_000_000.0 / time_sum}"
# Every trial ends on the same frame. A different value on another runtime means it emulated incorrectly.
puts "Checksum: #{frame_checksum}"
end

private
Expand All @@ -33,6 +37,11 @@ def validate_integer(name, value, minimum)
raise ArgumentError, "#{name} must be #{requirement}"
end

# Integer arithmetic only, so CRuby and ahead-of-time compilers such as Spinel produce the same value.
def checksum(framebuffer)
framebuffer.reduce(0) { |hash, pixel| ((hash * 31) + pixel) & 0xffffffff }
end

def run_frames(emulator, frames)
frame_count = 0
while frame_count < frames
Expand Down
4 changes: 4 additions & 0 deletions lib/rubyboy/emulator_headless.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ def initialize(rom_path)
@cpu = Cpu.new(bus, interrupt)
end

def framebuffer
@ppu.buffer
end

def step
loop do
cycles = @cpu.exec
Expand Down
2 changes: 1 addition & 1 deletion lib/rubyboy/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module Rubyboy
VERSION = '1.6.0'
VERSION = '1.7.0'
end
13 changes: 10 additions & 3 deletions spec/bench_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,20 @@
require 'rbconfig'

RSpec.describe Rubyboy::Bench do
let(:emulator) { instance_double(Rubyboy::EmulatorHeadless, step: nil) }
let(:emulator) { instance_double(Rubyboy::EmulatorHeadless, step: nil, framebuffer: [1, 2]) }

before do
allow(Rubyboy::EmulatorHeadless).to receive(:new).and_return(emulator)
end

it 'starts each trial from a fresh emulator, warms up identically, and aggregates measured durations' do
second_emulator = instance_double(Rubyboy::EmulatorHeadless, step: nil)
second_emulator = instance_double(Rubyboy::EmulatorHeadless, step: nil, framebuffer: [1, 2])
allow(Rubyboy::EmulatorHeadless).to receive(:new).with('test.gb').and_return(emulator, second_emulator)
allow(Process).to receive(:clock_gettime).with(Process::CLOCK_MONOTONIC, :nanosecond).and_return(1_000_000_000, 2_000_000_000, 3_000_000_000, 5_000_000_000)

expect do
described_class.new.run(count: 2, frames: 3, warmup_frames: 2, rom_path: 'test.gb')
end.to output("1: 1.0 sec\n2: 2.0 sec\nFPS: 2.0\n").to_stdout
end.to output("1: 1.0 sec\n2: 2.0 sec\nFPS: 2.0\nChecksum: 33\n").to_stdout

expect(Rubyboy::EmulatorHeadless).to have_received(:new).with('test.gb').twice
expect(emulator).to have_received(:step).exactly(5).times
Expand Down Expand Up @@ -59,6 +59,13 @@
expect(stdout).to match(/^FPS: /)
end

it 'prints the same checksum of the final frame on every run' do
stdout, stderr, status = Open3.capture3(RbConfig.ruby, '-Ilib', 'exe/rubyboy-bench', '--count', '2', '--frames', '60', chdir: repository_root)

expect(status.success?).to be(true), stderr
expect(stdout).to end_with("Checksum: 4059938609\n")
end

it 'rejects an invalid count with a failing exit status' do
_stdout, stderr, status = Open3.capture3(RbConfig.ruby, '-Ilib', 'exe/rubyboy-bench', '--count', '0', chdir: repository_root)

Expand Down
Loading