Ruby vs Rust: eight everyday fragments, benchmarked
“Rust is 50× faster than Ruby” is true for some code and false for other code. The useful question is which code. These are eight fragments that real Ruby and Rails codebases run all the time. Each one is written the idiomatic way in both languages, fed the same data, and timed on the same core. The checksums match across all three runtimes.
| Setup | Details |
|---|---|
| Ruby | 4.0.6, ruby:4.0-slim, with and without --yjit · json 2.18.0 · csv 3.3.5 |
| Rust | 1.90.0, --release (opt-level = 3) · serde_json 1.0.154 · regex 1.13.1 · csv 1.4.0 |
| CPU | AMD Ryzen AI Max+ 395, one core (docker --cpuset-cpus=4) |
| Method | 3 warm-ups → 10 timed iterations → median; middle of 3 separate process runs; peak RSS from VmHWM |
| Data | 6.28 MB real text (all 727 .rb files of the Ruby 4.0 stdlib, 180,682 lines) · 15.2 MB JSON (100k records) · 9.6 MB CSV (200k rows, ~20% quoted fields) · 0.5–1M in-memory structs from a shared seeded LCG |
| Size sweep | Each fragment at 10 → 10M items (fib: n = 10 → 35) · one process per point · ops batched so each sample is ≥ 10 ms · ~0.3 s warm-up (3–20,000 ops; just the calibration op when one op takes > 1 s) · median of 10 samples (3 when one op takes > 2 s) · corpus lines repeat cyclically beyond 180,682 · checksums match at every size |
| Concurrency | W = 1, 2, 4, 8 workers on exactly W cores (--cpuset-cpus=8, 8-9, 8-11, 8-15) · ≥ 2 warm-up reps and ≥ 3 s · median of 5 reps · median of 3 process runs |
The scoreboard
| Fragment | Ruby (ms) | + YJIT (ms) | Rust (ms) | Rust vs Ruby | Rust vs YJIT | Peak RSS, MB (Ruby → Rust) |
|---|---|---|---|---|---|---|
select.map.sum (1M structs) | 53.0 | 43.7 | 2.2 | 24× | 20× | 99 → 17 |
scan(/\w+/).tally (6.3 MB) | 184.1 | 185.5 | 64.6 | 2.9× | 2.9× | 147 → 21 |
JSON parse + generate (15 MB), Value | 93.3 | 81.5 | 212.1 | 0.44× | 0.38× | 212 → 285 |
| JSON parse + generate, typed structs | 20.3 | 4.6× | 4.0× | 212 → 31 | ||
downcase.gsub slugify (180k lines) | 237.7 | 229.8 | 50.4 | 4.7× | 4.6× | 41 → 23 |
sort_by { [-score, name] } (500k) | 936.6 | 919.6 | 51.4 | 18× | 18× | 111 → 44 |
group_by + transform_values (1M) | 249.5 | 237.8 | 11.2 | 22× | 21× | 262 → 63 |
CSV.parse (200k rows) | 1222.8 | 1033.5 | 15.7 | 78× | 66× | 431 → 11 |
fib(30) plain recursion | 44.2 | 5.3 | 1.4 | 32× | 3.8× | 15 → 2 |
Ruby RSS includes the ~15 MB interpreter baseline. The timings include Ruby’s GC, because production code pays for it too.
The spread follows one rule. When the hot loop runs in C inside MRI (regex, tally, the json extension), Rust is 0.4–5× faster. When the hot loop runs Ruby code (blocks, struct readers, a pure-Ruby library), Rust is 18–78× faster. YJIT only closes the gap where Ruby-level method calls dominate.
1. Enumerable chain — select.map.sum
orders.select { |o| o.status == "paid" }
.map { |o| o.total_cents }
.sum
orders.iter()
.filter(|o| o.status == Status::Paid)
.map(|o| o.total_cents)
.sum::<i64>()
Ruby 53.0 ms · YJIT 43.7 ms · Rust 2.2 ms (24×).
Scaling with input size. From 100k to 10M orders the speedup holds at 25–29×. It peaks at 233× for 1k orders, where Rust’s loop takes 226 ns. At 10M, Ruby needs 1.05 GB of RSS and Rust 155 MB.
| orders | Ruby | YJIT | Rust | Rust vs Ruby | Rust vs YJIT | RSS, MB (Ruby → Rust) |
|---|---|---|---|---|---|---|
| 10 | 701 ns | 634 ns | 14 ns | 50× | 45× | 16 → 2 |
| 1k | 53 µs | 27 µs | 226 ns | 233× | 120× | 15 → 2 |
| 100k | 5.1 ms | 2.6 ms | 175 µs | 29× | 15× | 24 → 4 |
| 1M | 54 ms | 45 ms | 2.2 ms | 25× | 21× | 101 → 17 |
| 10M | 624 ms | 532 ms | 22 ms | 29× | 25× | 1054 → 155 |
- Ruby allocates two intermediate arrays (~333k and ~333k elements) and makes 1.33M block calls with a string comparison each.
- Rust iterator adapters are lazy. The chain fuses into one loop over a contiguous
Vec<Order>, andStatusis a one-byte enum, so the comparison is a byte compare. - Ruby-side:
orders.sum { |o| o.status == "paid" ? o.total_cents : 0 }skips both intermediate arrays and measured 43.7 ms (34.8 ms with YJIT) at 93 MB instead of 99 MB. That is an 18–20% saving, still 16× slower than Rust, so the block calls are the cost, not the arrays.
2. Word frequency — scan + tally
counts = text.downcase.scan(/\w+/).tally
let lower = text.to_lowercase();
let mut counts: HashMap<&str, usize> = HashMap::new();
for m in re.find_iter(&lower) { // re = Regex::new(r"[0-9A-Za-z_]+")
*counts.entry(m.as_str()).or_insert(0) += 1;
}
Ruby 184.1 ms · YJIT 185.5 ms · Rust 64.6 ms (2.9×). 29,104 distinct words, 15,375 defs.
Scaling with input size. Rust is 1.9–3.0× faster at every size, because both sides spend their time in C (regex, hashing). At 1M lines, RSS is 708 MB for Ruby and 103 MB for Rust.
| lines | Ruby | YJIT | Rust | Rust vs Ruby | Rust vs YJIT | RSS, MB (Ruby → Rust) |
|---|---|---|---|---|---|---|
| 10 | 5.7 µs | 5.6 µs | 3.0 µs | 1.9× | 1.9× | 42 → 8 |
| 1k | 627 µs | 610 µs | 294 µs | 2.1× | 2.1× | 40 → 8 |
| 100k | 90 ms | 91 ms | 31 ms | 2.9× | 3.0× | 99 → 15 |
| 1M | 901 ms | 900 ms | 312 ms | 2.9× | 2.9× | 708 → 103 |
- Every step on the Ruby side (
downcase, Onigmoscan,tally) is C, so YJIT has nothing to compile. - The remaining gap is allocation. Ruby builds about 1M
Stringobjects for thescanarray. Rust’s keys are&strslices that borrow fromlower, so it allocates nothing per word. - Ruby’s
\wis ASCII-only, so the Rust regex uses[0-9A-Za-z_]to get the same matches. Rust’s\wis Unicode.
3. JSON — parse, reshape, generate
data = JSON.parse(payload)
JSON.generate(data.map { |r| { id: r["id"], city: r["address"]["city"], score: r["score"] } })
// the literal translation: a dynamic tree
let data: Value = serde_json::from_str(&payload)?;
let out: Vec<Value> = data.as_array().unwrap().iter()
.map(|r| json!({ "id": r["id"], "city": r["address"]["city"], "score": r["score"] }))
.collect();
serde_json::to_string(&out)?
// the idiomatic one: #[derive(Deserialize)] struct Rec<'a> { id: u64, score: f64, #[serde(borrow)] address: Addr<'a> }
let data: Vec<Rec> = serde_json::from_str(&payload)?;
Ruby 93.3 ms · YJIT 81.5 ms · Rust Value 212.1 ms (Ruby wins, 2.3×) · Rust typed 20.3 ms (4.6×).
Scaling with input size. Typed Rust is 3.1–5.0× faster at every size. Value is slower than Ruby from 10 records up and uses more memory: at 1M records it needs 2.76 GB, against 1.48 GB for Ruby and 280 MB for typed structs. In the table below, Value and Typed are the two Rust variants, and the speedup and RSS columns compare typed Rust with Ruby.
| size | Ruby | YJIT | Rust (Value) | Rust (typed) | Typed vs Ruby | Typed vs YJIT | RSS, MB (Ruby → typed) |
|---|---|---|---|---|---|---|---|
| 10 | 6.8 µs | 6.4 µs | 8.9 µs | 2.0 µs | 3.5× | 3.3× | 16 → 2 |
| 1k | 666 µs | 591 µs | 897 µs | 192 µs | 3.5× | 3.1× | 16 → 2 |
| 100k | 93 ms | 91 ms | 219 ms | 20 ms | 4.6× | 4.5× | 194 → 31 |
| 1M | 1.0 s | 1.1 s | 2.2 s | 221 ms | 4.6× | 5.0× | 1475 → 280 |
- This is the one fragment where a line-for-line port loses. Ruby’s json 2.x is a heavily tuned C extension.
serde_json::Valuebuilds aBTreeMapfor every object and an ownedStringfor every key and value: several heap allocations per record (285 MB peak, more than Ruby). - Typed deserialization skips the tree. It ignores the fields it doesn’t need (
email,tags,zip) and borrowscitystraight from the input buffer, which gives 10× overValueand 31 MB peak. - Lesson: in Rust, the type is the performance. Porting
Hash-shaped Ruby intoValue-shaped Rust keeps Ruby’s costs without Ruby’s C extension.
4. Slugify — downcase.gsub
titles.sum { |t| t.downcase.gsub(/[^a-z0-9]+/, "-").bytesize }
titles.iter().map(|t| re.replace_all(&t.to_lowercase(), "-").len()).sum::<usize>()
Ruby 237.7 ms · YJIT 229.8 ms · Rust 50.4 ms (4.7×). 180,682 lines.
Scaling with input size. Rust is 4.5–5.4× faster than Ruby at every size (8.3× faster than YJIT at 10 lines, where YJIT is slower than the interpreter). At 1M lines Rust uses more RSS than Ruby (73 vs 49 MB), because the input is a Vec<String> with one heap allocation per line.
| lines | Ruby | YJIT | Rust | Rust vs Ruby | Rust vs YJIT | RSS, MB (Ruby → Rust) |
|---|---|---|---|---|---|---|
| 10 | 10 µs | 16 µs | 1.9 µs | 5.4× | 8.3× | 40 → 8 |
| 1k | 1.2 ms | 1.2 ms | 260 µs | 4.5× | 4.8× | 40 → 8 |
| 100k | 116 ms | 116 ms | 25 ms | 4.7× | 4.7× | 41 → 15 |
| 1M | 1.1 s | 1.1 s | 234 ms | 4.8× | 4.7× | 49 → 73 |
- Per line, Ruby allocates two strings plus a
MatchDataper match, and Onigmo is a backtracking engine. - Rust’s
regexcompiles the character class to a DFA.replace_allreturnsCow::Borrowedand allocates nothing for lines that have no match. - YJIT barely helps here (3%) because the time is spent in
gsub, not in the block.
5. Multi-key sort — sort_by { [-score, name] }
top = users.sort_by { |u| [-u.score, u.name] }.first(20)
let mut v: Vec<&User> = users.iter().collect();
v.sort_by(|a, b| b.score.cmp(&a.score).then_with(|| a.name.cmp(&b.name)));
let top = &v[..20];
Ruby 936.6 ms · YJIT 919.6 ms · Rust 51.4 ms (18×). 500k users.
Scaling with input size. The speedup falls from 113× at 1k users to 5.8× at 10M. Rust’s time grows 45× for 10× the data (145 ms → 6.5 s), because at 727 MB the sort is chasing &User pointers through memory that no longer fits in cache. Ruby takes 37.8 s at 10M and peaks at 1.84 GB.
| users | Ruby | YJIT | Rust | Rust vs Ruby | Rust vs YJIT | RSS, MB (Ruby → Rust) |
|---|---|---|---|---|---|---|
| 10 | 3.2 µs | 3.1 µs | 39 ns | 83× | 79× | 16 → 2 |
| 1k | 775 µs | 780 µs | 6.8 µs | 113× | 114× | 15 → 2 |
| 100k | 140 ms | 137 ms | 5.6 ms | 25× | 25× | 44 → 10 |
| 1M | 2.1 s | 2.1 s | 145 ms | 15× | 15× | 230 → 86 |
| 10M | 37.8 s | 33.9 s | 6.5 s | 5.8× | 5.2× | 1838 → 727 |
sort_byallocates a 2-element keyArrayper element (500k arrays) and then compares them withArray#<=>, which is a generic dispatch through<=>on every element of every pair. That is ~19 comparisons per element × 500k.- Rust compares an
i64and stops. It comparesnameonly on a tie. - Ruby-side, measured:
sort_by { |u| -u.score }(single Integer key) runs in 47.4 ms, 20× faster than the tuple key. It even beats Rust’s two-key sort (51.4 ms), although the two sorts don’t do the same work. The cost is the 500k key arrays andArray#<=>, not sorting itself.max_by(20) { |u| [u.score, u.name] }skips the full sort and runs in 75.8 ms at 73 MB, the lowest memory of the Ruby variants.
6. Aggregation — group_by + transform_values
totals = sales.group_by { |s| s[:region] }
.transform_values { |rows| rows.sum { |r| r[:amount] } }
let mut totals: HashMap<&str, i64> = HashMap::new();
for s in &sales {
*totals.entry(s.region.as_str()).or_insert(0) += s.amount;
}
Ruby 249.5 ms · YJIT 237.8 ms · Rust 11.2 ms (22×). 1M rows, 50 regions.
Scaling with input size. Rust is 6–13× faster up to 100k rows, 20× at 1M and 23× at 10M. Ruby’s time grows 16× from 100k to 1M rows (15 → 246 ms) while Rust’s grows 11×. At 10M rows, RSS is 1.95 GB for Ruby and 612 MB for Rust.
| rows | Ruby | YJIT | Rust | Rust vs Ruby | Rust vs YJIT | RSS, MB (Ruby → Rust) |
|---|---|---|---|---|---|---|
| 10 | 2.3 µs | 2.1 µs | 253 ns | 9.2× | 8.3× | 16 → 2 |
| 1k | 98 µs | 82 µs | 16 µs | 6.2× | 5.2× | 19 → 2 |
| 100k | 15 ms | 11 ms | 1.1 ms | 13× | 9.8× | 49 → 8 |
| 1M | 246 ms | 235 ms | 12 ms | 20× | 19× | 262 → 63 |
| 10M | 2.6 s | 2.5 s | 113 ms | 23× | 22× | 1947 → 612 |
- The idioms differ, and that is part of the result. Ruby’s version materialises 50 bucket arrays holding all 1M rows, then walks them again. Rust’s natural shape is a one-pass fold.
- Each Ruby row is a
Hashwith symbol keys, sos[:region]is a hash lookup, compared with a field offset in Rust’sstruct Sale. - The same fold in Ruby (
each_with_object(Hash.new(0)) { |s, h| h[s[:region]] += s[:amount] }) measured 92.3 ms (82.3 ms with YJIT) at 182 MB instead of 262 MB, 2.7× faster thangroup_byand still 7–8× slower than Rust. The remaining cost is two hash lookups and a block call per row.
7. CSV — CSV.parse(raw, headers: true)
CSV.parse(raw, headers: true).sum { |row| (row["amount"].to_f * 100).round }
let mut rdr = csv::Reader::from_reader(raw.as_bytes());
let col = rdr.headers()?.iter().position(|h| h == "amount").unwrap();
rdr.records().map(|r| (r.unwrap()[col].parse::<f64>().unwrap() * 100.0).round() as i64).sum::<i64>()
Ruby 1222.8 ms · YJIT 1033.5 ms · Rust 15.7 ms (78×). 200k rows, RFC 4180 quoting.
Scaling with input size. The gap widens with size: 6.3× at 10 rows, 84× at 1M. CSV.parse materialises every row, so Ruby’s RSS reaches 1.71 GB at 1M rows, against 48 MB for Rust’s streaming reader.
| rows | Ruby | YJIT | Rust | Rust vs Ruby | Rust vs YJIT | RSS, MB (Ruby → Rust) |
|---|---|---|---|---|---|---|
| 10 | 63 µs | 33 µs | 10 µs | 6.3× | 3.3× | 17 → 2 |
| 1k | 3.3 ms | 2.1 ms | 87 µs | 38× | 25× | 18 → 2 |
| 100k | 441 ms | 337 ms | 8.0 ms | 55× | 42× | 240 → 7 |
| 1M | 6.8 s | 5.7 s | 81 ms | 84× | 71× | 1709 → 48 |
- The largest gap in the set, because Ruby’s
csvis pure Ruby. Its tokenizer, quote handling andCSV::Rowconstruction all run as interpreted Ruby. That is also why YJIT helps here (15%), as it does on the enum chain (18%); onlyfibgains more. CSV.parsematerialises every row as aCSV::Row(1M field strings plus header pairs) beforesumsees the first one: 431 MB peak. The Rust reader streams, reusing oneStringRecordbuffer: 11 MB.- Ruby-side:
CSV.foreachstreams instead of loading everything, which fixes memory but not speed. For bulk imports, the database’s own loader (COPY … FROM) is usually the answer.
8. Recursion — fib(30)
def fib(n) = n < 2 ? n : fib(n - 1) + fib(n - 2)
fn fib(n: u64) -> u64 { if n < 2 { n } else { fib(n - 1) + fib(n - 2) } }
Ruby 44.2 ms · YJIT 5.3 ms (8.3× over the interpreter) · Rust 1.4 ms (3.8× over YJIT). 2.69M calls.
Scaling with input size. Rust is 27–32× faster than Ruby and 3.6–3.8× faster than YJIT at every n. The cost is per call, so the ratio doesn’t move as the call count grows 2,000× from n=10 to n=35.
| n | Ruby | YJIT | Rust | Rust vs Ruby | Rust vs YJIT | RSS, MB (Ruby → Rust) |
|---|---|---|---|---|---|---|
| 10 | 3.0 µs | 413 ns | 111 ns | 27× | 3.7× | 15 → 2 |
| 15 | 33 µs | 3.9 µs | 1.1 µs | 30× | 3.6× | 15 → 2 |
| 20 | 368 µs | 43 µs | 12 µs | 32× | 3.7× | 15 → 2 |
| 25 | 4.0 ms | 473 µs | 130 µs | 31× | 3.6× | 15 → 2 |
| 30 | 44 ms | 5.3 ms | 1.4 ms | 31× | 3.8× | 15 → 2 |
| 35 | 481 ms | 59 ms | 16 ms | 30× | 3.7× | 15 → 2 |
- This is YJIT’s best case. The work is entirely Ruby-level method calls and integer
+/<, which YJIT compiles to machine code with type guards. - What is left, 3.8×, is the cost of those guards, Ruby’s frame push per call, and the overflow checks on
Integer, compared with a nativecall/retand anadd. - The memoised idiom (
Hash.new { |h, n| h[n] = … }) finishesfib(90)in microseconds in both languages. Pick the algorithm first, the language second.
Concurrency
Three workloads, each split across W workers pinned to exactly W cores: cpu (64 × fib(25)), json (256 × parse, reshape and generate a 145 KB payload; serde typed structs on the Rust side) and io (64 × a 10 ms sleep plus fib(20)). CRuby runs three models: threads, Ractors and pre-forked workers fed over pipes, the shape of a Puma cluster. YJIT is a compiler, not a concurrency model, so every CRuby model runs with --yjit. Rust runs std::thread::scope.
- The GVL caps CRuby threads at 1× on
cpu(0.9×) andjson(1.0×). Oniothey reach 7.9×, because a sleeping thread releases the lock. - Ractors scale CPU work, not JSON: 6.5× on
cpu, 1.6× onjson. - fork scales JSON (6.9×) but flattens on
cpupast 4 workers (4.2× at 8), at 71 MB ΣPSS and 130 MB ΣRSS for 9 processes. - Rust threads reach 7.2–8.0× on all three and start 3.2–3.9× ahead at W=1: 54,763
cputasks/s and 41,506jsontasks/s at W=8, against 11,979 (Ractors) and 11,974 (fork) for the best CRuby model, in 3.0 MB.
cpu · 64 × fib(25) — tasks/s, median (min–max of 3 runs)
| Model | W=1 | W=2 | W=4 | W=8 | speedup@8 | eff@8 | ΣPSS@8 | ΣRSS@8 |
|---|---|---|---|---|---|---|---|---|
| CRuby threads | 1 960 (1 944–1 963) | 1 903 (1 902–1 935) | 1 910 (1 837–1 911) | 1 810 (1 690–1 834) | 0.9× | 12 % | 16 MB | 17 MB |
| CRuby Ractors | 1 852 (1 844–1 886) | 3 662 (3 518–3 699) | 7 184 (6 051–7 187) | 11 979 (11 475–13 511) | 6.5× | 81 % | 16 MB | 17 MB |
| CRuby fork | 1 945 (1 937–1 960) | 3 890 (3 862–3 901) | 7 342 (4 207–7 664) | 8 145 (7 729–8 946) | 4.2× | 52 % | 49 MB | 115 MB |
| Rust threads | 7 630 (7 485–7 667) | 14 383 (14 356–14 887) | 28 331 (26 780–28 429) | 54 763 (54 537–55 888) | 7.2× | 90 % | 2.4 MB | 2.4 MB |
json · 256 × 145 KB — tasks/s, median (min–max of 3 runs)
| Model | W=1 | W=2 | W=4 | W=8 | speedup@8 | eff@8 | ΣPSS@8 | ΣRSS@8 |
|---|---|---|---|---|---|---|---|---|
| CRuby threads | 1 718 (1 705–1 740) | 1 648 (1 563–1 662) | 1 631 (1 629–1 649) | 1 659 (1 635–1 758) | 1.0× | 12 % | 18 MB | 19 MB |
| CRuby Ractors | 1 635 (1 609–1 703) | 2 120 (2 097–2 129) | 2 613 (2 484–2 664) | 2 540 (2 506–2 650) | 1.6× | 19 % | 31 MB | 33 MB |
| CRuby fork | 1 731 (1 731–1 733) | 3 405 (3 331–3 425) | 6 577 (6 411–6 662) | 11 974 (7 929–12 232) | 6.9× | 86 % | 71 MB | 130 MB |
| Rust threads | 5 564 (5 542–5 609) | 11 003 (11 001–11 034) | 21 704 (21 667–21 795) | 41 506 (36 459–42 146) | 7.5× | 93 % | 3.0 MB | 3.0 MB |
io · sleep 10 ms + fib(20) — tasks/s, median (min–max of 3 runs)
| Model | W=1 | W=2 | W=4 | W=8 | speedup@8 | eff@8 | ΣPSS@8 | ΣRSS@8 |
|---|---|---|---|---|---|---|---|---|
| CRuby threads | 98 (97–98) | 196 (195–196) | 391 (391–392) | 773 (771–776) | 7.9× | 99 % | 16 MB | 17 MB |
| CRuby Ractors | 98 (97–98) | 194 (193–194) | 386 (385–387) | 771 (767–775) | 7.9× | 98 % | 16 MB | 17 MB |
| CRuby fork | 98 (97–98) | 196 (195–196) | 392 (391–392) | 783 (781–784) | 8.0× | 100 % | 36 MB | 114 MB |
| Rust threads | 98 (98–99) | 198 (196–198) | 394 (393–395) | 790 (781–791) | 8.0× | 101 % | 2.4 MB | 2.4 MB |
What the numbers say
- Ruby is already C where it matters most. JSON, regex and
tallyare C inside MRI. Rewriting those in Rust buys 3–5×, or nothing at all if you port the dynamic shapes literally. - The 20–80× cases come from Ruby code in the hot loop: blocks per element,
Struct/Hashfield access, a pure-Ruby library. These are the cases where a Rust extension (viamagnus/rb-sys) pays for itself. - YJIT is a method-call compiler. It gives 8.3× on
fib, 18% on the enum chain, 15% on CSV, and −1–3% on regex. If a profile shows time in C functions, YJIT won’t move it. - Fix the Ruby idiom before you reach for Rust: a single-key
sort_byis 20× faster than the tuple key, and aneach_with_objectfold is 2.7× faster thangroup_by. Neither change leaves Ruby. - Memory follows allocation: 1.8–39× lower RSS in Rust, except the
Valueport, which used more than Ruby. - Concurrency doesn’t close the gap. The best CRuby model (Ractors on CPU work, fork on JSON) reaches 6.5–6.9× on 8 cores; Rust threads reach 7.2–7.5× from a 3.2–3.9× higher start, so at W=8 Rust does 3.5–4.6× the work in 3 MB.
Reproduce it
The harness is two files: bench.rb and a single-binary main.rs. Both generate in-memory data from the same LCG, so the inputs match exactly:
$x = 7
def rnd(n) = (($x = ($x * 1103515245 + 12345) & 0x7fffffff) >> 8) % n
Each process runs a single fragment: 3 warm-ups, 10 timed runs with CLOCK_MONOTONIC / Instant, then it prints {bench, impl, median_ms, min_ms, checksum, rss_kb} as one JSON line. Run it in throwaway containers pinned to one core:
docker run --rm --cpuset-cpus=4 -v "$PWD":/b -w /b ruby:4.0-slim ruby --yjit bench.rb csv
docker run --rm --cpuset-cpus=4 -v "$PWD":/b -w /b rust:1.90-bookworm rs/target/release/bench csv
The concurrency suite is conc.rb (model, workload, workers) and a conc binary with the same three workloads, checksums and output shape:
docker run --rm --cpuset-cpus=8-15 -v "$PWD":/b -w /b ruby:4.0-slim ruby --yjit conc.rb fork json 8
docker run --rm --cpuset-cpus=8-15 -v "$PWD":/b -w /b rust:1.90-bookworm rs/target/release/conc threads json 8
A first attempt picked region-#{rnd(50)} from the low bits of a power-of-two LCG, and region-7 never came up. The checksum assertion caught it before any numbers were published. That is why the harness compares checksums.