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.

SetupDetails
Ruby4.0.6, ruby:4.0-slim, with and without --yjit · json 2.18.0 · csv 3.3.5
Rust1.90.0, --release (opt-level = 3) · serde_json 1.0.154 · regex 1.13.1 · csv 1.4.0
CPUAMD Ryzen AI Max+ 395, one core (docker --cpuset-cpus=4)
Method3 warm-ups → 10 timed iterations → median; middle of 3 separate process runs; peak RSS from VmHWM
Data6.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 sweepEach 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
ConcurrencyW = 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
Median wall time per fragment, log scale: Ruby 4.0, Ruby 4.0 with YJIT, Rust 1.90 Ruby 4.0 Ruby 4.0 + YJIT Rust 1.90 1 ms 10 ms 100 ms 1 s select.map.sum 53 ms 44 ms 2.2 ms scan + tally 184 ms 186 ms 65 ms JSON parse+generate 93 ms 82 ms 212 ms (serde_json::Value) 20 ms (typed structs) downcase.gsub 238 ms 230 ms 50 ms sort_by [-score, name] 937 ms 920 ms 51 ms group_by + sum 249 ms 238 ms 11 ms CSV.parse 1223 ms 1033 ms 16 ms fib(30) recursion 44 ms 5.3 ms 1.4 ms
Median of 10 timed iterations after 3 warm-ups, middle of 3 process runs, one pinned core. Log scale: each gridline is 10×.
Peak resident memory per fragment, log scale: Ruby 4.0, Ruby 4.0 with YJIT, Rust 1.90 Ruby 4.0 Ruby 4.0 + YJIT Rust 1.90 1 MB 10 MB 100 MB 1 GB select.map.sum 99 MB 98 MB 17 MB scan + tally 147 MB 148 MB 21 MB JSON parse+generate 212 MB 190 MB 285 MB (serde_json::Value) 31 MB (typed structs) downcase.gsub 41 MB 42 MB 23 MB sort_by [-score, name] 111 MB 111 MB 44 MB group_by + sum 262 MB 263 MB 63 MB CSV.parse 431 MB 447 MB 11 MB fib(30) recursion 15 MB 16 MB 2 MB
Peak RSS (VmHWM), max of 3 process runs, log scale: each gridline is 10×. Ruby includes the ~15 MB interpreter baseline.

The scoreboard

FragmentRuby (ms)+ YJIT (ms)Rust (ms)Rust vs RubyRust vs YJITPeak RSS, MB (Ruby → Rust)
select.map.sum (1M structs)53.043.72.224×20×99 → 17
scan(/\w+/).tally (6.3 MB)184.1185.564.62.9×2.9×147 → 21
JSON parse + generate (15 MB), Value93.381.5212.10.44×0.38×212 → 285
JSON parse + generate, typed structs20.34.6×4.0×212 → 31
downcase.gsub slugify (180k lines)237.7229.850.44.7×4.6×41 → 23
sort_by { [-score, name] } (500k)936.6919.651.418×18×111 → 44
group_by + transform_values (1M)249.5237.811.222×21×262 → 63
CSV.parse (200k rows)1222.81033.515.778×66×431 → 11
fib(30) plain recursion44.25.31.432×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.

Rust speedup factor per fragment versus Ruby 4.0 and versus Ruby 4.0 with YJIT, log scale Rust vs Ruby 4.0 Rust vs Ruby 4.0 + YJIT 0.5× 1× (parity) 2× 5× 10× 20× 50× select.map.sum 24× scan + tally 2.9× JSON (serde_json::Value) 0.44× — Ruby wins JSON (typed structs) 4.6× downcase.gsub 4.7× sort_by [-score, name] 18× group_by + sum 22× CSV.parse 78× fib(30) recursion 32× · 3.8× vs YJIT hot loop is C inside MRI hot loop is Ruby code
Rust speedup per fragment, log scale. Filled dot: versus Ruby 4.0; hollow dot: versus Ruby 4.0 + YJIT. The two shaded bands are the two regimes.

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×).

select.map.sum: median wall time and peak memory per runtime Wall time, msbar = median · dots = each of 3 process runs Peak RSS, MBmax of 3 runs Ruby · select.map.sum 53 ms · 24× Rust 99 YJIT · select.map.sum 44 ms · 20× Rust 98 Ruby · sum { ternary } 44 ms · 20× Rust 93 YJIT · sum { ternary } 35 ms · 16× Rust 92 Rust 2.2 ms 17 linear scale, 0–54 ms linear scale, 0–99 MB
select.map.sum: every measured variant. Dashed bars are alternative Ruby idioms, measured the same way.

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.

enum_chain: time per operation and peak memory as input size grows, Ruby, YJIT and Rust Time per operationlog scale · one process per point Peak RSSlog scale Ruby YJIT Rust 10 ns 1 µs 100 µs 10 ms 1 s 1 MB 10 MB 100 MB 1 GB 10 GB 1010 1k1k 100k100k 1M1M 10M10M input size (orders)input size (orders) Ruby 624 ms YJIT 532 ms Rust 22 ms Ruby 1054 MB YJIT 1029 MB Rust 155 MB
Time per operation (left) and peak resident memory (right) at each input size. Small sizes are batched so each sample lasts ≥ 10 ms; median of 10 samples (3 when one op takes over 2 s).
ordersRubyYJITRustRust vs RubyRust vs YJITRSS, MB (Ruby → Rust)
10701 ns634 ns14 ns50×45×16 → 2
1k53 µs27 µs226 ns233×120×15 → 2
100k5.1 ms2.6 ms175 µs29×15×24 → 4
1M54 ms45 ms2.2 ms25×21×101 → 17
10M624 ms532 ms22 ms29×25×1054 → 155

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.

scan.tally: median wall time and peak memory per runtime Wall time, msbar = median · dots = each of 3 process runs Peak RSS, MBmax of 3 runs Ruby · scan.tally 184 ms · 2.9× Rust 147 YJIT · scan.tally 186 ms · 2.9× Rust 148 Rust 65 ms 21 linear scale, 0–194 ms linear scale, 0–148 MB

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.

word_freq: time per operation and peak memory as input size grows, Ruby, YJIT and Rust Time per operationlog scale · one process per point Peak RSSlog scale Ruby YJIT Rust 1 µs 10 µs 100 µs 1 ms 10 ms 100 ms 1 s 1 MB 10 MB 100 MB 1 GB 1010 1k1k 100k100k 1M1M input size (lines)input size (lines) Ruby 901 ms YJIT 900 ms Rust 312 ms Ruby 709 MB YJIT 709 MB Rust 103 MB
Time per operation (left) and peak resident memory (right) at each input size. Small sizes are batched so each sample lasts ≥ 10 ms; median of 10 samples (3 when one op takes over 2 s).
linesRubyYJITRustRust vs RubyRust vs YJITRSS, MB (Ruby → Rust)
105.7 µs5.6 µs3.0 µs1.9×1.9×42 → 8
1k627 µs610 µs294 µs2.1×2.1×40 → 8
100k90 ms91 ms31 ms2.9×3.0×99 → 15
1M901 ms900 ms312 ms2.9×2.9×708 → 103

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×).

JSON: median wall time and peak memory per runtime Wall time, msbar = median · dots = each of 3 process runs Peak RSS, MBmax of 3 runs Ruby · JSON 93 ms 212 YJIT · JSON 82 ms 190 Rust · serde_json::Value 212 ms 285 Rust · typed structs 20 ms 31 linear scale, 0–217 ms linear scale, 0–285 MB

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.

json: time per operation and peak memory as input size grows, Ruby, YJIT and Rust Time per operationlog scale · one process per point Peak RSSlog scale Ruby YJIT Rust Rust typed 1 µs 10 µs 100 µs 1 ms 10 ms 100 ms 1 s 10 s 1 MB 10 MB 100 MB 1 GB 10 GB 1010 1k1k 100k100k 1M1M input size (records)input size (records) Rust 2.2 s YJIT 1.1 s Ruby 1.0 s Rust typed 221 ms Rust 2761 MB Ruby 1475 MB YJIT 1476 MB Rust typed 280 MB
Time per operation (left) and peak resident memory (right) at each input size. Small sizes are batched so each sample lasts ≥ 10 ms; median of 10 samples (3 when one op takes over 2 s).
sizeRubyYJITRust (Value)Rust (typed)Typed vs RubyTyped vs YJITRSS, MB (Ruby → typed)
106.8 µs6.4 µs8.9 µs2.0 µs3.5×3.3×16 → 2
1k666 µs591 µs897 µs192 µs3.5×3.1×16 → 2
100k93 ms91 ms219 ms20 ms4.6×4.5×194 → 31
1M1.0 s1.1 s2.2 s221 ms4.6×5.0×1475 → 280

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.

gsub: median wall time and peak memory per runtime Wall time, msbar = median · dots = each of 3 process runs Peak RSS, MBmax of 3 runs Ruby · gsub 238 ms · 4.7× Rust 41 YJIT · gsub 230 ms · 4.6× Rust 42 Rust 50 ms 23 linear scale, 0–244 ms linear scale, 0–42 MB

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.

slugify: time per operation and peak memory as input size grows, Ruby, YJIT and Rust Time per operationlog scale · one process per point Peak RSSlog scale Ruby YJIT Rust 1 µs 10 µs 100 µs 1 ms 10 ms 100 ms 1 s 10 s 1 MB 10 MB 100 MB 1010 1k1k 100k100k 1M1M input size (lines)input size (lines) Ruby 1.1 s YJIT 1.1 s Rust 234 ms Rust 74 MB YJIT 50 MB Ruby 49 MB
Time per operation (left) and peak resident memory (right) at each input size. Small sizes are batched so each sample lasts ≥ 10 ms; median of 10 samples (3 when one op takes over 2 s).
linesRubyYJITRustRust vs RubyRust vs YJITRSS, MB (Ruby → Rust)
1010 µs16 µs1.9 µs5.4×8.3×40 → 8
1k1.2 ms1.2 ms260 µs4.5×4.8×40 → 8
100k116 ms116 ms25 ms4.7×4.7×41 → 15
1M1.1 s1.1 s234 ms4.8×4.7×49 → 73

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.

sort_by { tuple }: median wall time and peak memory per runtime Wall time, msbar = median · dots = each of 3 process runs Peak RSS, MBmax of 3 runs Ruby · sort_by { tuple } 937 ms · 18× Rust 111 YJIT · sort_by { tuple } 920 ms · 18× Rust 111 Ruby · sort_by { -score } 47 ms 105 YJIT · sort_by { -score } 45 ms 108 Ruby · max_by(20) { tuple } 76 ms · 1.5× Rust 73 YJIT · max_by(20) { tuple } 78 ms · 1.5× Rust 73 Rust 51 ms 44 linear scale, 0–1056 ms linear scale, 0–111 MB
sort_by { tuple }: every measured variant. Dashed bars are alternative Ruby idioms, measured the same way.

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.

sort_by: time per operation and peak memory as input size grows, Ruby, YJIT and Rust Time per operationlog scale · one process per point Peak RSSlog scale Ruby YJIT Rust 10 ns 1 µs 100 µs 10 ms 1 s 100 s 1 MB 10 MB 100 MB 1 GB 10 GB 1010 1k1k 100k100k 1M1M 10M10M input size (users)input size (users) Ruby 37.8 s YJIT 33.9 s Rust 6.5 s Ruby 1838 MB YJIT 1763 MB Rust 727 MB
Time per operation (left) and peak resident memory (right) at each input size. Small sizes are batched so each sample lasts ≥ 10 ms; median of 10 samples (3 when one op takes over 2 s).
usersRubyYJITRustRust vs RubyRust vs YJITRSS, MB (Ruby → Rust)
103.2 µs3.1 µs39 ns83×79×16 → 2
1k775 µs780 µs6.8 µs113×114×15 → 2
100k140 ms137 ms5.6 ms25×25×44 → 10
1M2.1 s2.1 s145 ms15×15×230 → 86
10M37.8 s33.9 s6.5 s5.8×5.2×1838 → 727

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.

group_by: median wall time and peak memory per runtime Wall time, msbar = median · dots = each of 3 process runs Peak RSS, MBmax of 3 runs Ruby · group_by 249 ms · 22× Rust 262 YJIT · group_by 238 ms · 21× Rust 263 Ruby · each_with_object 92 ms · 8.2× Rust 182 YJIT · each_with_object 82 ms · 7.3× Rust 182 Rust 11 ms 63 linear scale, 0–256 ms linear scale, 0–263 MB
group_by: every measured variant. Dashed bars are alternative Ruby idioms, measured the same way.

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.

group_by: time per operation and peak memory as input size grows, Ruby, YJIT and Rust Time per operationlog scale · one process per point Peak RSSlog scale Ruby YJIT Rust 100 ns 10 µs 1 ms 100 ms 10 s 1 MB 10 MB 100 MB 1 GB 10 GB 1010 1k1k 100k100k 1M1M 10M10M input size (rows)input size (rows) Ruby 2.6 s YJIT 2.5 s Rust 113 ms Ruby 1947 MB YJIT 1950 MB Rust 613 MB
Time per operation (left) and peak resident memory (right) at each input size. Small sizes are batched so each sample lasts ≥ 10 ms; median of 10 samples (3 when one op takes over 2 s).
rowsRubyYJITRustRust vs RubyRust vs YJITRSS, MB (Ruby → Rust)
102.3 µs2.1 µs253 ns9.2×8.3×16 → 2
1k98 µs82 µs16 µs6.2×5.2×19 → 2
100k15 ms11 ms1.1 ms13×9.8×49 → 8
1M246 ms235 ms12 ms20×19×262 → 63
10M2.6 s2.5 s113 ms23×22×1947 → 612

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.

CSV.parse: median wall time and peak memory per runtime Wall time, msbar = median · dots = each of 3 process runs Peak RSS, MBmax of 3 runs Ruby · CSV.parse 1223 ms · 78× Rust 431 YJIT · CSV.parse 1033 ms · 66× Rust 447 Rust 16 ms 11 linear scale, 0–1268 ms linear scale, 0–447 MB

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.

csv: time per operation and peak memory as input size grows, Ruby, YJIT and Rust Time per operationlog scale · one process per point Peak RSSlog scale Ruby YJIT Rust 1 µs 10 µs 100 µs 1 ms 10 ms 100 ms 1 s 10 s 1 MB 10 MB 100 MB 1 GB 10 GB 1010 1k1k 100k100k 1M1M input size (rows)input size (rows) Ruby 6.8 s YJIT 5.7 s Rust 81 ms YJIT 1711 MB Ruby 1710 MB Rust 49 MB
Time per operation (left) and peak resident memory (right) at each input size. Small sizes are batched so each sample lasts ≥ 10 ms; median of 10 samples (3 when one op takes over 2 s).
rowsRubyYJITRustRust vs RubyRust vs YJITRSS, MB (Ruby → Rust)
1063 µs33 µs10 µs6.3×3.3×17 → 2
1k3.3 ms2.1 ms87 µs38×25×18 → 2
100k441 ms337 ms8.0 ms55×42×240 → 7
1M6.8 s5.7 s81 ms84×71×1709 → 48

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.

fib(30): median wall time and peak memory per runtime Wall time, msbar = median · dots = each of 3 process runs Peak RSS, MBmax of 3 runs Ruby · fib(30) 44 ms · 32× Rust 15 YJIT · fib(30) 5.3 ms · 3.8× Rust 16 Rust 1.4 ms 2 linear scale, 0–46 ms linear scale, 0–16 MB

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.

fib: time per operation and peak memory as input size grows, Ruby, YJIT and Rust Time per operationlog scale · one process per point Peak RSSlog scale Ruby YJIT Rust 100 ns 1 µs 10 µs 100 µs 1 ms 10 ms 100 ms 1 s 1 MB 10 MB 100 MB n=10n=10 n=15n=15 n=20n=20 n=25n=25 n=30n=30 n=35n=35 input size (n)input size (n) Ruby 481 ms YJIT 59 ms Rust 16 ms YJIT 16 MB Ruby 16 MB Rust 3 MB
Time per operation (left) and peak resident memory (right) at each input size. Small sizes are batched so each sample lasts ≥ 10 ms; median of 10 samples (3 when one op takes over 2 s).
nRubyYJITRustRust vs RubyRust vs YJITRSS, MB (Ruby → Rust)
103.0 µs413 ns111 ns27×3.7×15 → 2
1533 µs3.9 µs1.1 µs30×3.6×15 → 2
20368 µs43 µs12 µs32×3.7×15 → 2
254.0 ms473 µs130 µs31×3.6×15 → 2
3044 ms5.3 ms1.4 ms31×3.8×15 → 2
35481 ms59 ms16 ms30×3.7×15 → 2

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.

Speedup versus number of workers for each concurrency model, one panel per workload Speedup vs workerstasks/s at W ÷ the same model's tasks/s at W = 1 · W workers on W pinned cores CRuby threads CRuby Ractors CRuby fork Rust threads cpu · 64 × fib(25) 0× 2× 4× 6× 8× 1 2 4 8 workers W no gain ideal json · 256 × 145 KB 0× 2× 4× 6× 8× 1 2 4 8 workers W ideal io · sleep 10 ms + fib(20) 0× 2× 4× 6× 8× 1 2 4 8 workers W ideal
Median of 3 process runs; whiskers span the 3 run medians where they differ by more than 5 %. Markers are nudged sideways so equal values stay visible.
Throughput at 8 workers: tasks per second per model and workload, log scale Throughput at 8 workersbar = W = 8 · tick = W = 1 · label: tasks/s · speedup · efficiency (speedup ÷ 8) 10 /s 100 /s 1 000 /s 10 000 /s 100 000 /s cpu · 64 × fib(25) CRuby threads 1 810 /s · 0.9× · 12 % CRuby Ractors 11 979 /s · 6.5× · 81 % CRuby fork 8 145 /s · 4.2× · 52 % Rust threads 54 763 /s · 7.2× · 90 % json · 256 × 145 KB CRuby threads 1 659 /s · 1.0× · 12 % CRuby Ractors 2 540 /s · 1.6× · 19 % CRuby fork 11 974 /s · 6.9× · 86 % Rust threads 41 506 /s · 7.5× · 93 % io · sleep 10 ms + fib(20) CRuby threads 773 /s · 7.9× · 99 % CRuby Ractors 771 /s · 7.9× · 98 % CRuby fork 783 /s · 8.0× · 100 % Rust threads 790 /s · 8.0× · 101 %
Median of 3 process runs at W = 8 on cores 8–15. Log scale: each gridline is 10×.
Memory at 8 workers: summed PSS and summed peak RSS, json workload, log scale Memory at 8 workerssolid = ΣPSS at W = 8 · dashed = up to ΣRSS · tick = PSS at W = 1 · /worker = ΣPSS ÷ 8 1 MB 10 MB 100 MB 1 GB 10 GB CRuby threads ΣPSS 18 MB · RSS 19 MB · 2.2 MB/worker CRuby Ractors ΣPSS 31 MB · RSS 33 MB · 3.9 MB/worker CRuby fork ΣPSS 71 MB · RSS 130 MB · 8.9 MB/worker Rust threads ΣPSS 3.0 MB · RSS 3.0 MB · 0.4 MB/worker
json workload, W = 8. Fork multiplies memory, PSS divides the shared part back: each forked page shared by k processes counts 1/k in PSS.

cpu · 64 × fib(25) — tasks/s, median (min–max of 3 runs)

ModelW=1W=2W=4W=8speedup@8eff@8ΣPSS@8ΣRSS@8
CRuby threads1 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 MB17 MB
CRuby Ractors1 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 MB17 MB
CRuby fork1 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 MB115 MB
Rust threads7 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 MB2.4 MB

json · 256 × 145 KB — tasks/s, median (min–max of 3 runs)

ModelW=1W=2W=4W=8speedup@8eff@8ΣPSS@8ΣRSS@8
CRuby threads1 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 MB19 MB
CRuby Ractors1 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 MB33 MB
CRuby fork1 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 MB130 MB
Rust threads5 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 MB3.0 MB

io · sleep 10 ms + fib(20) — tasks/s, median (min–max of 3 runs)

ModelW=1W=2W=4W=8speedup@8eff@8ΣPSS@8ΣRSS@8
CRuby threads98
(97–98)
196
(195–196)
391
(391–392)
773
(771–776)
7.9×99 %16 MB17 MB
CRuby Ractors98
(97–98)
194
(193–194)
386
(385–387)
771
(767–775)
7.9×98 %16 MB17 MB
CRuby fork98
(97–98)
196
(195–196)
392
(391–392)
783
(781–784)
8.0×100 %36 MB114 MB
Rust threads98
(98–99)
198
(196–198)
394
(393–395)
790
(781–791)
8.0×101 %2.4 MB2.4 MB

What the numbers say

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.

Questions? Let's Chat :)

ruby