Ruby vs JRuby: eight everyday fragments, benchmarked
JRuby runs Ruby source on the JVM: a JIT with decades of tuning behind it, a garbage collector built for big heaps, and threads without a global lock. This article runs the eight fragments from Ruby vs Rust unchanged on CRuby 4.0 (interpreter and YJIT) and on JRuby 10.1, once with its defaults and once with invokedynamic turned off. The source is identical on both engines, so every difference below comes from the runtime and its libraries. The checksums match across all four runtimes.
| Setup | Details |
|---|---|
| CRuby | 4.0.6, ruby:4.0-slim, with and without --yjit · json 2.18.0 · csv 3.3.5 |
| JRuby | 10.1.2.0 (Ruby 4.0 compatible), jruby:10, OpenJDK 21.0.12 · json 2.21.2 · csv 3.3.6 · defaults, and -Xcompile.invokedynamic=false (“indy off”) |
| CPU | AMD Ryzen AI Max+ 395, four cores (docker --cpuset-cpus=4-7) for every runtime: one for the benchmark thread, the rest for the JVM’s JIT and GC threads |
| Method | 10 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 | 10 → 10M items (fib: n = 10 → 35) · one process per point · ops batched so each sample is ≥ 10 ms · ~2 s warm-up (3–200,000 ops) · median of 10 samples (3 when one op takes > 2 s) · checksums match at every size |
| Warm-up | 40 iterations timed from a cold process, no warm-up · one process per runtime and fragment |
| Cold start | ruby -e 0 / jruby -e 0, 10 starts inside one container, container start excluded |
| 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 |
-Xcompile.invokedynamic=false.The scoreboard
| Fragment | Ruby (ms) | + YJIT (ms) | JRuby (ms) | indy off (ms) | JRuby vs YJIT | Peak RSS, MB (YJIT → JRuby) |
|---|---|---|---|---|---|---|
| select.map.sum | 53.9 | 45.0 | 47.6 | 53.7 | 0.95× | 99 → 1318 |
| scan + tally | 193.0 | 189.2 | 221.4 | 217.3 | 0.85× | 148 → 4518 |
| JSON parse+generate | 91.5 | 88.0 | 159.1 | 187.5 | 0.55× | 190 → 2704 |
| downcase.gsub | 251.8 | 242.9 | 150.1 | 132.9 | 1.6× | 42 → 1821 |
| sort_by [-score, name] | 1020.7 | 986.6 | 613.9 | 590.5 | 1.6× | 111 → 943 |
| group_by + sum | 256.2 | 242.8 | 240.4 | 243.6 | 1.0× | 270 → 842 |
| CSV.parse | 1357.5 | 833.8 | 934.8 | 1018.7 | 0.89× | 443 → 10319 |
| fib(30) recursion | 44.4 | 5.4 | 324.2 | 24.5 | 0.017× | 16 → 283 |
Median of 3 process runs; JRuby vs YJIT above 1× means JRuby is faster.
Against YJIT, JRuby wins downcase.gsub and sort_by (1.6× each), ties group_by (1.0×) and loses the other five: select.map.sum 0.95×, CSV 0.89×, scan + tally 0.85×, JSON 0.55×, and fib(30) 0.017× with its defaults (0.22× with indy off). Against the interpreter without YJIT it wins five of eight. Peak RSS is 3–43× CRuby’s in every fragment.
-Xcompile.invokedynamic=false.1. Enumerable chain — select.map.sum
orders.select { |o| o.status == "paid" }.map { |o| o.total_cents }.sum
Ruby 53.9 ms · YJIT 45.0 ms · JRuby 47.6 ms (0.95× YJIT) · indy off 53.7 ms. 1M structs.
Scaling with input size. JRuby is 0.5–0.7× YJIT from 1k to 1M orders and 1.5× faster at 10M (357 vs 538 ms). At 10 orders it loses 22×: 7.8 µs per op against 351 ns.
| orders | Ruby | YJIT | JRuby | indy off | JRuby vs YJIT | RSS, MB (YJIT → JRuby) |
|---|---|---|---|---|---|---|
| 10 | 686 ns | 351 ns | 7.8 µs | 2.7 µs | 0.045× | 16 → 274 |
| 1k | 52 µs | 27 µs | 52 µs | 28 µs | 0.51× | 16 → 275 |
| 100k | 5.0 ms | 2.6 ms | 3.7 ms | 2.3 ms | 0.70× | 25 → 444 |
| 1M | 54 ms | 31 ms | 48 ms | 55 ms | 0.65× | 100 → 1779 |
| 10M | 645 ms | 538 ms | 357 ms | 536 ms | 1.51× | 1028 → 3948 |
- Both engines build two intermediate arrays (~333k elements each) and make 1.33M block calls per iteration.
- Dropping the arrays with
orders.sum { |o| o.status == "paid" ? o.total_cents : 0 }saves CRuby 18–22 % and JRuby 71 %: 14.0 ms on JRuby against 34.9 ms on YJIT, 2.5× faster. On JRuby the arrays, not the blocks, are most of the cost. - JRuby’s three process medians spread from 37.0 to 51.2 ms, the widest spread in the main set; CRuby’s stay within 3.2 ms.
2. Word frequency — scan + tally
counts = text.downcase.scan(/\w+/).tally
Ruby 193.0 ms · YJIT 189.2 ms · JRuby 221.4 ms (0.85× YJIT) · indy off 217.3 ms. 29,104 distinct words.
Scaling with input size. JRuby trails at every size: 0.29× YJIT at 10 lines, 0.85–0.87× from 100k lines up. At 1M lines its RSS is 8.9 GB against 709 MB.
| lines | Ruby | YJIT | JRuby | indy off | JRuby vs YJIT | RSS, MB (YJIT → JRuby) |
|---|---|---|---|---|---|---|
| 10 | 6.0 µs | 6.3 µs | 22 µs | 15 µs | 0.29× | 43 → 275 |
| 1k | 646 µs | 626 µs | 1.2 ms | 1.0 ms | 0.52× | 40 → 390 |
| 100k | 94 ms | 91 ms | 107 ms | 106 ms | 0.85× | 91 → 2203 |
| 1M | 899 ms | 893 ms | 1.0 s | 1.0 s | 0.87× | 709 → 8880 |
- Timed step by step on the same text (median of 3 processes):
downcaseof the 6.3 MB string takes 16.1 ms on JRuby against 33.2 ms on YJIT,scan(/\w+/)155 ms against 127 ms, andtallyover the ~1M words 48.6 ms against 25.2 ms. - JRuby’s regex engine is Joni, a Java port of Oniguruma, the engine Onigmo was forked from. On one long string it is 0.82× Onigmo;
tallyinto a 29k-keyHashis where JRuby loses the most, 0.52×.
3. JSON — parse, reshape, generate
data = JSON.parse(payload)
JSON.generate(data.map { |r| { id: r["id"], city: r["address"]["city"], score: r["score"] } })
Ruby 91.5 ms · YJIT 88.0 ms · JRuby 159.1 ms (0.55× YJIT) · indy off 187.5 ms. 15.2 MB, 100k records.
Scaling with input size. CRuby is faster at every size: JRuby is 0.07× YJIT at 10 records, 0.58× at 100k and 0.70× at 1M. At 1M records JRuby peaks at 18.6 GB of RSS, CRuby at 1.56 GB.
| records | Ruby | YJIT | JRuby | indy off | JRuby vs YJIT | RSS, MB (YJIT → JRuby) |
|---|---|---|---|---|---|---|
| 10 | 6.7 µs | 6.3 µs | 97 µs | 82 µs | 0.065× | 17 → 273 |
| 1k | 703 µs | 668 µs | 2.0 ms | 1.6 ms | 0.33× | 17 → 299 |
| 100k | 86 ms | 86 ms | 148 ms | 179 ms | 0.58× | 190 → 2071 |
| 1M | 1.1 s | 1.3 s | 1.8 s | 1.8 s | 0.70× | 1563 → 18605 |
- On JRuby the json gem is a Java extension (
JSON::Ext::Parseris Java), on CRuby a C extension. Step by step:JSON.parse61 ms on JRuby against 57 ms on YJIT,JSON.generateof the 100k reshaped records 23.6 ms against 14.8 ms. - Parse and generate add up to 85 ms on JRuby and 71 ms on YJIT. The rest of the fragment, the
mapthat builds 100k small hashes and the garbage the parse leaves behind, costs JRuby about 74 ms and YJIT about 16 ms (step medians from separate processes, so read this as a split, not a measurement).
4. Slugify — downcase.gsub
titles.sum { |t| t.downcase.gsub(/[^a-z0-9]+/, "-").bytesize }
Ruby 251.8 ms · YJIT 242.9 ms · JRuby 150.1 ms (1.6× YJIT) · indy off 132.9 ms. 180,682 lines.
Scaling with input size. From 1k lines up JRuby is 1.5–1.8× faster than YJIT; at 10 lines it is 0.39×.
| lines | Ruby | YJIT | JRuby | indy off | JRuby vs YJIT | RSS, MB (YJIT → JRuby) |
|---|---|---|---|---|---|---|
| 10 | 10 µs | 11 µs | 28 µs | 16 µs | 0.39× | 41 → 274 |
| 1k | 1.1 ms | 1.1 ms | 780 µs | 767 µs | 1.46× | 41 → 294 |
| 100k | 119 ms | 115 ms | 76 ms | 84 ms | 1.52× | 42 → 1256 |
| 1M | 1.1 s | 1.1 s | 618 ms | 612 ms | 1.80× | 50 → 1410 |
- Step by step:
downcaseof the 180k lines takes 25.7 ms on JRuby against 14.1 ms on YJIT, so the win is all ingsub: 152 ms against 236 ms, 1.55× faster. - Here Joni works on 180k short strings with a character class and a replacement, and beats Onigmo. On one 6.3 MB string (
scanin fragment 2) it loses. Same engine family, opposite results; the shape of the input decides. - YJIT gains almost nothing over the interpreter (243 vs 252 ms): the time is inside
gsub.
5. Multi-key sort — sort_by { [-score, name] }
top = users.sort_by { |u| [-u.score, u.name] }.first(20)
Ruby 1020.7 ms · YJIT 986.6 ms · JRuby 613.9 ms (1.6× YJIT) · indy off 590.5 ms. 500k users.
Scaling with input size. JRuby is 1.5–2.3× faster than YJIT from 1k to 10M users. At 10M users the sort takes 23.5 s on JRuby, 34.3 s on YJIT, and JRuby peaks at 13.5 GB against 1.84 GB.
| users | Ruby | YJIT | JRuby | indy off | JRuby vs YJIT | RSS, MB (YJIT → JRuby) |
|---|---|---|---|---|---|---|
| 10 | 3.3 µs | 3.1 µs | 74 µs | 7.2 µs | 0.041× | 17 → 272 |
| 1k | 797 µs | 767 µs | 381 µs | 245 µs | 2.02× | 16 → 279 |
| 100k | 149 ms | 141 ms | 61 ms | 61 ms | 2.31× | 45 → 315 |
| 1M | 2.2 s | 2.2 s | 1.5 s | 1.3 s | 1.52× | 243 → 1688 |
| 10M | 34.2 s | 34.3 s | 23.5 s | 23.8 s | 1.46× | 1838 → 13499 |
- The tuple key costs a 2-element
Arrayper element andArray#<=>per comparison on both engines; the JVM runs that generic path 1.6× faster. - The single-key idiom flips the result.
sort_by { |u| -u.score }takes 43.9 ms on YJIT and 258 ms on JRuby, 5.9× slower. CRuby’ssort_bycompares Integer keys through an inlined fast path (OPTIMIZED_CMPinenum.c), with no method call per comparison. The same sort with an explicit block,sort { |a, b| b.score <=> a.score }, takes 540 ms on YJIT and 486 ms on JRuby. max_by(20) { |u| [u.score, u.name] }takes 70.2 ms on YJIT and 713 ms on JRuby, 10× slower, even though it avoids the full sort.
6. Aggregation — group_by + transform_values
totals = sales.group_by { |s| s[:region] }.transform_values { |rows| rows.sum { |r| r[:amount] } }
Ruby 256.2 ms · YJIT 242.8 ms · JRuby 240.4 ms (1.0× YJIT) · indy off 243.6 ms. 1M rows, 50 regions.
Scaling with input size. At 1M and 10M rows all four runtimes are within 5 % of each other (238–250 ms, 2.4–2.5 s). Below that the order changes from size to size: at 100k rows the interpreter without YJIT is fastest, 14 ms against 22 ms for YJIT and 23 ms for JRuby.
| rows | Ruby | YJIT | JRuby | indy off | JRuby vs YJIT | RSS, MB (YJIT → JRuby) |
|---|---|---|---|---|---|---|
| 10 | 2.3 µs | 2.0 µs | 11 µs | 6.0 µs | 0.18× | 17 → 268 |
| 1k | 99 µs | 85 µs | 101 µs | 155 µs | 0.85× | 20 → 274 |
| 100k | 14 ms | 22 ms | 23 ms | 16 ms | 0.93× | 89 → 334 |
| 1M | 250 ms | 238 ms | 243 ms | 239 ms | 0.98× | 264 → 817 |
| 10M | 2.5 s | 2.4 s | 2.4 s | 2.4 s | 0.99× | 2071 → 4988 |
- Both engines do the same work: 50 bucket arrays holding 1M
Hashrows, then a second pass to sum them. Neither YJIT nor invokedynamic changes it much: 256.2 → 242.8 ms on CRuby, 240.4 vs 243.6 ms on JRuby. - The one-pass fold,
each_with_object(Hash.new(0)) { |s, h| h[s[:region]] += s[:amount] }, is faster on both: 76.3 ms on YJIT, 94.0 ms on JRuby (0.81×).
7. CSV — CSV.parse(raw, headers: true)
CSV.parse(raw, headers: true).sum { |row| (row["amount"].to_f * 100).round }
Ruby 1357.5 ms · YJIT 833.8 ms · JRuby 934.8 ms (0.89× YJIT) · indy off 1018.7 ms. 200k rows.
Scaling with input size. The order flips with size. JRuby is 0.04× YJIT at 10 rows and 0.74× at 100k, then 1.74× faster at 1M rows (4.6 s against 8.1 s). Its RSS at 1M rows is 17.1 GB, against 2.2 GB for CRuby.
| rows | Ruby | YJIT | JRuby | indy off | JRuby vs YJIT | RSS, MB (YJIT → JRuby) |
|---|---|---|---|---|---|---|
| 10 | 63 µs | 34 µs | 855 µs | 516 µs | 0.040× | 18 → 279 |
| 1k | 3.2 ms | 2.0 ms | 8.8 ms | 8.4 ms | 0.23× | 20 → 300 |
| 100k | 462 ms | 394 ms | 533 ms | 608 ms | 0.74× | 232 → 6665 |
| 1M | 7.4 s | 8.1 s | 4.6 s | 5.6 s | 1.74× | 2212 → 17050 |
- The
csvgem is pure Ruby on both engines (csv/row.rb,csv/parser.rb…), so this is the fragment where a JIT should matter most. JRuby beats the CRuby interpreter by 1.45× (934.8 vs 1357.5 ms), but YJIT is faster still at 200k rows. - It takes JRuby 33 iterations to settle in the warm-up curves below, and its three process medians spread 897–960 ms.
CSV.parsematerialises every row beforesumsees one. CRuby peaks at 443 MB; JRuby at 10.3 GB, because the JVM grows its heap into whatever the default max allows. The heap section below caps it.
8. Recursion — fib(30)
def fib(n) = n < 2 ? n : fib(n - 1) + fib(n - 2)
Ruby 44.4 ms · YJIT 5.4 ms · JRuby 324.2 ms (0.017× YJIT) · indy off 24.5 ms (0.22×). 2.69M calls.
Scaling with input size. With indy off JRuby is 2–4× slower than YJIT from n=15 up (258 vs 60 ms at n=35) and 26× slower at n=10 (11 µs vs 424 ns). The default column has no shape: 7.4 µs at n=15 (1.07× YJIT), 319 ms at n=30, 109 ms at n=35. The next section explains why.
| n | Ruby | YJIT | JRuby | indy off | JRuby vs YJIT | RSS, MB (YJIT → JRuby) |
|---|---|---|---|---|---|---|
| 10 | 3.0 µs | 424 ns | 3.9 µs | 11 µs | 0.11× | 16 → 268 |
| 15 | 33 µs | 7.9 µs | 7.4 µs | 17 µs | 1.07× | 16 → 273 |
| 20 | 361 µs | 44 µs | 63 µs | 188 µs | 0.69× | 16 → 275 |
| 25 | 4.1 ms | 476 µs | 26 ms | 1.9 ms | 0.019× | 16 → 277 |
| 30 | 45 ms | 5.4 ms | 319 ms | 21 ms | 0.017× | 16 → 283 |
| 35 | 493 ms | 60 ms | 109 ms | 258 ms | 0.55× | 16 → 288 |
- With indy off this is a plain JIT comparison: JRuby’s compiled
fibruns in 24.5 ms, 4.5× YJIT’s 5.4 ms and 1.8× faster than the CRuby interpreter. - With the defaults it is 324 ms, 7× slower than the CRuby interpreter. The main benchmark stops after 20 iterations (10 warm-ups, 10 timed); the section on invokedynamic below shows what happens at iteration 40.
Warm-up
Each line is one cold process timing iterations 1–40 with no warm-up at all.
- JRuby’s first iteration is 1.2–2.9× its steady state, CRuby’s 0.9–2.0×. JRuby interprets first, then compiles hot methods to JVM bytecode, which the JVM’s own JIT compiles again.
- Most fragments settle within 2–12 iterations on JRuby (
sort_by2,group_by2, JSON 4,select.map.sum9,downcase.gsub12).scan + tallytakes 25 and CSV 33. fib(30)with the defaults sits at 271–329 ms for 38 iterations, then drops to 185 and 26 ms. The section on invokedynamic below follows it further.- YJIT also moves late: in this process
select.map.sumdrops from ~44 ms to ~31 ms at iteration 30, after the 20 iterations the main benchmark runs. One process, so read it as a hint, not a number.
Cold start
| Runtime | -e 0, median of 10 | min–max |
|---|---|---|
| Ruby 4.0 | 27 ms | 26–31 ms |
| Ruby 4.0 + YJIT | 25 ms | 24–28 ms |
| JRuby 10.1 | 757 ms | 708–838 ms |
| JRuby 10.1, indy off | 757 ms | 709–833 ms |
JRuby takes 30× longer to start an empty program. That cost lands on every rake task, every rails runner, every test run and every CLI call; a long-running server pays it once.
invokedynamic and fib
- In every cold process
fib(30)takes 229–469 ms per call for iterations 1–38, speeds up during iteration 39 (168–231 ms) and runs at 9–27 ms from iteration 40. That held in 9 of 9 processes: the 3 above (80 iterations), and 3 each with-Xjit.threshold=10and-Xjit.threshold=100(120 iterations). - The drop does not move with JRuby’s method-JIT threshold (default 50 calls), so JRuby compiling
fibto bytecode is not what releases it. These runs don’t identify the mechanism; they pin down when it happens. - After the drop, indy is the fastest JRuby: 9.2–12.9 ms from iteration 41 on, against 22–25 ms with indy off, and 1.7–2.4× YJIT’s 5.4 ms. With indy off
fib(30)takes 58–63 ms on the first call and 22–25 ms from the second. - Threads escape it. In the main suite 4 ×
fib(27)takes 313 ms in sequence with the defaults and 4.4 ms on 4 threads, 71× faster on 4 cores (with indy off: 21.0 ms and 9.4 ms). In the concurrency suite below, 1 and 2 workers stay at 21–25fib(25)tasks/s with the defaults, while 4 and 8 workers reach 3,538 and 6,377. - A fixed warm-up count can land on either side of the drop. The main benchmark (20 iterations) reports 324 ms; the sweep reports 26 ms at n=25 and 319 ms at n=30.
-Xcompile.invokedynamic=falsetrades the 10 ms peak for a predictable 22–25 ms.
The JVM heap
JRuby runs on the JVM’s default max heap, about a quarter of physical RAM: Runtime.maxMemory reports 30,688 MiB of 127,940 MiB physical RAM here. The collector grows the heap when allocation is fast, so RSS follows the allocation rate, not the live data: 10.3 GB to parse a 9.6 MB CSV, 4.5 GB to count words in 6.3 MB of text. Capping the heap with -J-Xmx2g (3 process runs each):
| Fragment | default: time · peak RSS | -J-Xmx2g: time · peak RSS | time | RSS |
|---|---|---|---|---|
CSV.parse | 934.8 ms · 10.3 GB | 1115.8 ms · 2.3 GB | +19 % | 4.5× less |
scan + tally | 221.4 ms · 4.5 GB | 240.2 ms · 2.2 GB | +8 % | 2.1× less |
| JSON | 159.1 ms · 2.7 GB | 216.2 ms · 1.6 GB | +36 % | 1.7× less |
RSS stays above 2 GB because it also counts the JVM’s code cache, metaspace and thread stacks. A JRuby service needs an explicit -Xmx sized to its working set; the default trades memory for fewer collections. CRuby ran the same three fragments in 147–443 MB.
Concurrency
The same three workloads as in Ruby vs Rust, each split across W workers pinned to exactly W cores: cpu (64 × fib(25)), json (256 × parse, reshape and generate a 145 KB payload) and io (64 × a 10 ms sleep plus fib(20)). CRuby runs threads, Ractors and pre-forked workers fed over pipes, all with --yjit; YJIT is a compiler, not a concurrency model. JRuby runs plain threads, which have no global lock.
ioscales to 7.9–8.1× on every model. A sleeping CRuby thread releases the GVL, so threads are enough for I/O-bound work on both engines.- CPU work: CRuby threads stay at 0.9× (GVL); Ractors reach 6.5×, fork 4.2×. JRuby threads with indy off reach 7.6× (430 → 3,253 tasks/s). With the defaults JRuby is stuck at 21 tasks/s with 1 worker and 25 with 2, the
fibslow path again, then jumps to 3,538 at 4 and 6,377 at 8: still below CRuby Ractors (11,979) and fork (8,145). - JSON: JRuby threads reach 6.5× (10,430 tasks/s), close to CRuby fork at 6.9× (11,974); Ractors manage 1.6×.
- Memory at 8 workers: CRuby threads 18 MB ΣPSS, Ractors 31 MB, fork 71 MB (130 MB ΣRSS across 9 processes), JRuby 2.3 GB in one heap.
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 |
| JRuby threads | 21 (21–21) | 25 (24–25) | 3 538 (2 964–3 735) | 6 377 (4 575–9 369) | 308.1× | 3851 % | 322 MB | 322 MB |
| JRuby, indy off | 430 (396–440) | 868 (791–871) | 1 585 (1 516–1 596) | 3 253 (3 069–3 436) | 7.6× | 95 % | 307 MB | 307 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 |
| JRuby threads | 1 598 (1 588–1 619) | 2 620 (2 380–2 761) | 4 864 (4 328–5 351) | 10 430 (7 257–12 291) | 6.5× | 82 % | 2.3 GB | 2.3 GB |
| JRuby, indy off | 1 581 (1 578–1 615) | 2 541 (2 407–2 628) | 5 218 (4 591–5 635) | 10 462 (7 022–11 569) | 6.6× | 83 % | 2.1 GB | 2.1 GB |
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 |
| JRuby threads | 96 (95–96) | 193 (193–195) | 386 (385–389) | 772 (767–775) | 8.0× | 100 % | 277 MB | 277 MB |
| JRuby, indy off | 93 (92–94) | 188 (187–189) | 374 (373–379) | 753 (751–757) | 8.1× | 101 % | 266 MB | 266 MB |
What the numbers say
- Same source, different winners. Against YJIT, JRuby wins
downcase.gsubandsort_bywith a tuple key (1.6× each), tiesgroup_byand loses the rest. The wins come from the library and runtime under the Ruby code: Joni’sgsubon short strings (1.55× in isolation) and the JVM runningArray#<=>. - CRuby’s C fast paths still beat the JVM:
tally(0.52× on JRuby),JSON.generate(0.63×), a single Integersort_bykey (5.9× slower on JRuby) andmax_by(20)(10× slower). - Size decides more on JRuby. At the smallest size JRuby is 0.04–0.39× YJIT in every fragment. At the largest it wins four of eight:
select.map.sumat 10M (1.5×),downcase.gsub(1.8×) and CSV at 1M (1.7×),sort_byat 10M (1.5×). - The JVM costs up front: 757 ms to start an empty program against 25 ms, and a first iteration 1.2–2.9× its steady state.
- invokedynamic is a bet on long-lived processes:
fib(30)runs at 229–469 ms for 38 iterations, then about 10 ms; with indy off it is 22–25 ms from the second call. - Memory: 3–43× CRuby’s peak RSS on the default heap;
-J-Xmx2gcuts it 1.7–4.5× for 8–36 % more time. - Threads without a GVL work, but CRuby has answers: on JSON, JRuby threads reach 6.5× on 8 cores and CRuby fork 6.9×; on CPU work, JRuby with indy off reaches 7.6× and CRuby Ractors 6.5×. JRuby does it in one process with one heap of 2.3 GB; CRuby fork in 9 processes and 71 MB ΣPSS.
Reproduce it
The harness is the one from the Rust article with IMPL labels and 10 warm-ups: bench.rb, alt2.rb, sweep.rb and conc.rb. The same files run on both engines:
docker run --rm --cpuset-cpus=4-7 -v "$PWD":/b -w /b ruby:4.0-slim ruby --yjit bench.rb csv
docker run --rm --cpuset-cpus=4-7 -v "$PWD":/b -w /b -e IMPL=jruby jruby:10 jruby bench.rb csv
docker run --rm --cpuset-cpus=4-7 -v "$PWD":/b -w /b -e IMPL=jruby-noindy jruby:10 jruby -Xcompile.invokedynamic=false bench.rb csv
docker run --rm --cpuset-cpus=4-7 -v "$PWD":/b -w /b -e IMPL=jruby -e CURVE=1 -e CURVE_N=80 jruby:10 jruby bench.rb fib
docker run --rm --cpuset-cpus=8-15 -v "$PWD":/b -w /b -e IMPL=jruby jruby:10 jruby conc.rb threads json 8
CURVE=1 skips the warm-up and prints every iteration. conc.rb takes a model (threads, ractors, fork), a workload (cpu, json, io) and a worker count. Two things the first version of conc.rb got wrong, both fixed before any number here was taken: inside a Ractor, self is the Ractor and Ractor#send sends a message, so jobs are invoked with __send__; and each forked worker inherited the parent’s ends of every earlier worker’s pipes, so closing a worker’s pipe never produced EOF and the parent hung in Process.wait. Each child now closes those inherited ends first.