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.

SetupDetails
CRuby4.0.6, ruby:4.0-slim, with and without --yjit · json 2.18.0 · csv 3.3.5
JRuby10.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”)
CPUAMD 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
Method10 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 sweep10 → 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-up40 iterations timed from a cold process, no warm-up · one process per runtime and fragment
Cold startruby -e 0 / jruby -e 0, 10 starts inside one container, container start excluded
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, JRuby 10.1, JRuby 10.1 with invokedynamic off Ruby 4.0 Ruby 4.0 + YJIT JRuby 10.1 JRuby 10.1, indy off 1 ms 10 ms 100 ms 1 s select.map.sum 54 ms 45 ms 48 ms 54 ms scan + tally 193 ms 189 ms 221 ms 217 ms JSON parse+generate 91 ms 88 ms 159 ms 188 ms downcase.gsub 252 ms 243 ms 150 ms 133 ms sort_by [-score, name] 1021 ms 987 ms 614 ms 590 ms group_by + sum 256 ms 243 ms 240 ms 244 ms CSV.parse 1358 ms 834 ms 935 ms 1019 ms fib(30) recursion 44 ms 5.4 ms 324 ms 25 ms
Median of 10 timed iterations after 10 warm-ups, middle of 3 process runs, 4 pinned cores. Log scale: each gridline is 10×. Dashed: JRuby with -Xcompile.invokedynamic=false.
Peak resident memory per fragment, log scale: Ruby 4.0, Ruby 4.0 with YJIT, JRuby 10.1, JRuby 10.1 with invokedynamic off Ruby 4.0 Ruby 4.0 + YJIT JRuby 10.1 JRuby 10.1, indy off 10 MB 100 MB 1 GB 10 GB select.map.sum 103 MB 99 MB 1.3 GB 1.2 GB scan + tally 147 MB 148 MB 4.5 GB 4.5 GB JSON parse+generate 242 MB 190 MB 2.7 GB 2.7 GB downcase.gsub 41 MB 42 MB 1.8 GB 1.4 GB sort_by [-score, name] 111 MB 111 MB 943 MB 1.4 GB group_by + sum 269 MB 270 MB 842 MB 855 MB CSV.parse 428 MB 443 MB 10.3 GB 9.8 GB fib(30) recursion 15 MB 16 MB 283 MB 271 MB
Peak RSS (VmHWM), max of 3 process runs, log scale: each gridline is 10×. JRuby runs with the JVM's default max heap.

The scoreboard

FragmentRuby (ms)+ YJIT (ms)JRuby (ms)indy off (ms)JRuby vs YJITPeak RSS, MB (YJIT → JRuby)
select.map.sum53.945.047.653.70.95×99 → 1318
scan + tally193.0189.2221.4217.30.85×148 → 4518
JSON parse+generate91.588.0159.1187.50.55×190 → 2704
downcase.gsub251.8242.9150.1132.91.6×42 → 1821
sort_by [-score, name]1020.7986.6613.9590.51.6×111 → 943
group_by + sum256.2242.8240.4243.61.0×270 → 842
CSV.parse1357.5833.8934.81018.70.89×443 → 10319
fib(30) recursion44.45.4324.224.50.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.

JRuby speedup factor per fragment versus Ruby 4.0 with YJIT, with invokedynamic on and off, log scale JRuby 10.1 vs Ruby 4.0 + YJIT JRuby, indy off vs YJIT 0.01× 0.02× 0.05× 0.1× 0.2× 0.5× 1× (parity) 2× 5× 10× select.map.sum 0.95× scan + tally 0.85× JSON parse+generate 0.55× downcase.gsub 1.6× sort_by [-score, name] 1.6× group_by + sum 1.0× CSV.parse 0.89× fib(30) recursion 0.017× · indy off 0.22× ← CRuby + YJIT faster JRuby faster →
JRuby speedup per fragment against Ruby 4.0 + YJIT, log scale. Filled dot: JRuby defaults; hollow dot: -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.

select.map.sum: median wall time and peak memory per runtime Wall time, msbar = median · dots = each of 3 process runs Peak RSSmax of 3 runs Ruby · select.map.sum 54 ms 103 MB YJIT · select.map.sum 45 ms 99 MB JRuby · select.map.sum 48 ms 1.3 GB JRuby indy off · select.map.sum 54 ms 1.2 GB Ruby · sum { ternary } 44 ms 93 MB YJIT · sum { ternary } 35 ms 92 MB JRuby · sum { ternary } 14 ms 622 MB JRuby indy off · sum { ternary } 25 ms 794 MB linear scale, 0–59 ms linear scale, 0–1.3 GB
select.map.sum: every measured variant. Dashed bars are alternative Ruby idioms, measured the same way, on all four runtimes.

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.

enum_chain: time per operation and peak memory as input size grows, Ruby, YJIT, JRuby and JRuby with invokedynamic off Time per operationlog scale · one process per point Peak RSSlog scale Ruby YJIT JRuby indy off 100 ns 1 µs 10 µs 100 µs 1 ms 10 ms 100 ms 1 s 10 MB 100 MB 1 GB 10 GB 1010 1k1k 100k100k 1M1M 10M10M input size (orders)input size (orders) Ruby 645 ms YJIT 538 ms indy off 536 ms JRuby 357 ms indy off 6585 MB JRuby 3948 MB Ruby 1054 MB YJIT 1029 MB
Time per operation (left) and peak resident memory (right) at each input size. Small sizes are batched so each sample lasts ≥ 10 ms; ~2 s warm-up per point; median of 10 samples (3 when one op takes over 2 s).
ordersRubyYJITJRubyindy offJRuby vs YJITRSS, MB (YJIT → JRuby)
10686 ns351 ns7.8 µs2.7 µs0.045×16 → 274
1k52 µs27 µs52 µs28 µs0.51×16 → 275
100k5.0 ms2.6 ms3.7 ms2.3 ms0.70×25 → 444
1M54 ms31 ms48 ms55 ms0.65×100 → 1779
10M645 ms538 ms357 ms536 ms1.51×1028 → 3948

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.

scan.tally: median wall time and peak memory per runtime Wall time, msbar = median · dots = each of 3 process runs Peak RSSmax of 3 runs Ruby · scan.tally 193 ms 147 MB YJIT · scan.tally 189 ms 148 MB JRuby · scan.tally 221 ms 4.5 GB JRuby indy off · scan.tally 217 ms 4.5 GB linear scale, 0–227 ms linear scale, 0–4.5 GB

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.

word_freq: time per operation and peak memory as input size grows, Ruby, YJIT, JRuby and JRuby with invokedynamic off Time per operationlog scale · one process per point Peak RSSlog scale Ruby YJIT JRuby indy off 1 µs 10 µs 100 µs 1 ms 10 ms 100 ms 1 s 10 s 10 MB 100 MB 1 GB 10 GB 1010 1k1k 100k100k 1M1M input size (lines)input size (lines) indy off 1.0 s JRuby 1.0 s Ruby 899 ms YJIT 893 ms JRuby 8880 MB indy off 8770 MB Ruby 709 MB YJIT 709 MB
Time per operation (left) and peak resident memory (right) at each input size. Small sizes are batched so each sample lasts ≥ 10 ms; ~2 s warm-up per point; median of 10 samples (3 when one op takes over 2 s).
linesRubyYJITJRubyindy offJRuby vs YJITRSS, MB (YJIT → JRuby)
106.0 µs6.3 µs22 µs15 µs0.29×43 → 275
1k646 µs626 µs1.2 ms1.0 ms0.52×40 → 390
100k94 ms91 ms107 ms106 ms0.85×91 → 2203
1M899 ms893 ms1.0 s1.0 s0.87×709 → 8880

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.

JSON: median wall time and peak memory per runtime Wall time, msbar = median · dots = each of 3 process runs Peak RSSmax of 3 runs Ruby · JSON 91 ms 242 MB YJIT · JSON 88 ms 190 MB JRuby · JSON 159 ms 2.7 GB JRuby indy off · JSON 188 ms 2.7 GB linear scale, 0–286 ms linear scale, 0–2.7 GB

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.

json: time per operation and peak memory as input size grows, Ruby, YJIT, JRuby and JRuby with invokedynamic off Time per operationlog scale · one process per point Peak RSSlog scale Ruby YJIT JRuby indy off 1 µs 10 µs 100 µs 1 ms 10 ms 100 ms 1 s 10 s 10 MB 100 MB 1 GB 10 GB 100 GB 1010 1k1k 100k100k 1M1M input size (records)input size (records) indy off 1.8 s JRuby 1.8 s YJIT 1.3 s Ruby 1.1 s JRuby 18606 MB indy off 12274 MB YJIT 1564 MB Ruby 1492 MB
Time per operation (left) and peak resident memory (right) at each input size. Small sizes are batched so each sample lasts ≥ 10 ms; ~2 s warm-up per point; median of 10 samples (3 when one op takes over 2 s).
recordsRubyYJITJRubyindy offJRuby vs YJITRSS, MB (YJIT → JRuby)
106.7 µs6.3 µs97 µs82 µs0.065×17 → 273
1k703 µs668 µs2.0 ms1.6 ms0.33×17 → 299
100k86 ms86 ms148 ms179 ms0.58×190 → 2071
1M1.1 s1.3 s1.8 s1.8 s0.70×1563 → 18605

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.

gsub: median wall time and peak memory per runtime Wall time, msbar = median · dots = each of 3 process runs Peak RSSmax of 3 runs Ruby · gsub 252 ms 41 MB YJIT · gsub 243 ms 42 MB JRuby · gsub 150 ms 1.8 GB JRuby indy off · gsub 133 ms 1.4 GB linear scale, 0–326 ms linear scale, 0–1.8 GB

Scaling with input size. From 1k lines up JRuby is 1.5–1.8× faster than YJIT; at 10 lines it is 0.39×.

slugify: time per operation and peak memory as input size grows, Ruby, YJIT, JRuby and JRuby with invokedynamic off Time per operationlog scale · one process per point Peak RSSlog scale Ruby YJIT JRuby indy off 10 µs 100 µs 1 ms 10 ms 100 ms 1 s 10 s 10 MB 100 MB 1 GB 10 GB 1010 1k1k 100k100k 1M1M input size (lines)input size (lines) Ruby 1.1 s YJIT 1.1 s JRuby 618 ms indy off 612 ms JRuby 1410 MB indy off 1404 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; ~2 s warm-up per point; median of 10 samples (3 when one op takes over 2 s).
linesRubyYJITJRubyindy offJRuby vs YJITRSS, MB (YJIT → JRuby)
1010 µs11 µs28 µs16 µs0.39×41 → 274
1k1.1 ms1.1 ms780 µs767 µs1.46×41 → 294
100k119 ms115 ms76 ms84 ms1.52×42 → 1256
1M1.1 s1.1 s618 ms612 ms1.80×50 → 1410

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.

sort_by { tuple }: median wall time and peak memory per runtime Wall time, msbar = median · dots = each of 3 process runs Peak RSSmax of 3 runs Ruby · sort_by { tuple } 1021 ms 111 MB YJIT · sort_by { tuple } 987 ms 111 MB JRuby · sort_by { tuple } 614 ms 943 MB JRuby indy off · sort_by { tuple } 590 ms 1.4 GB Ruby · sort_by { -score } 49 ms 135 MB YJIT · sort_by { -score } 44 ms 135 MB JRuby · sort_by { -score } 258 ms 975 MB JRuby indy off · sort_by { -score } 250 ms 899 MB Ruby · max_by(20) { tuple } 77 ms 73 MB YJIT · max_by(20) { tuple } 70 ms 73 MB JRuby · max_by(20) { tuple } 713 ms 1.2 GB JRuby indy off · max_by(20) { tuple } 703 ms 1.1 GB linear scale, 0–1062 ms linear scale, 0–1.4 GB
sort_by { tuple }: every measured variant. Dashed bars are alternative Ruby idioms, measured the same way, on all four runtimes.

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.

sort_by: time per operation and peak memory as input size grows, Ruby, YJIT, JRuby and JRuby with invokedynamic off Time per operationlog scale · one process per point Peak RSSlog scale Ruby YJIT JRuby indy off 1 µs 100 µs 10 ms 1 s 100 s 10 MB 100 MB 1 GB 10 GB 100 GB 1010 1k1k 100k100k 1M1M 10M10M input size (users)input size (users) YJIT 34.3 s Ruby 34.2 s indy off 23.8 s JRuby 23.5 s JRuby 13499 MB indy off 13324 MB YJIT 1839 MB Ruby 1838 MB
Time per operation (left) and peak resident memory (right) at each input size. Small sizes are batched so each sample lasts ≥ 10 ms; ~2 s warm-up per point; median of 10 samples (3 when one op takes over 2 s).
usersRubyYJITJRubyindy offJRuby vs YJITRSS, MB (YJIT → JRuby)
103.3 µs3.1 µs74 µs7.2 µs0.041×17 → 272
1k797 µs767 µs381 µs245 µs2.02×16 → 279
100k149 ms141 ms61 ms61 ms2.31×45 → 315
1M2.2 s2.2 s1.5 s1.3 s1.52×243 → 1688
10M34.2 s34.3 s23.5 s23.8 s1.46×1838 → 13499

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.

group_by: median wall time and peak memory per runtime Wall time, msbar = median · dots = each of 3 process runs Peak RSSmax of 3 runs Ruby · group_by 256 ms 269 MB YJIT · group_by 243 ms 270 MB JRuby · group_by 240 ms 842 MB JRuby indy off · group_by 244 ms 855 MB Ruby · each_with_object 95 ms 182 MB YJIT · each_with_object 76 ms 183 MB JRuby · each_with_object 94 ms 825 MB JRuby indy off · each_with_object 102 ms 805 MB linear scale, 0–268 ms linear scale, 0–855 MB
group_by: every measured variant. Dashed bars are alternative Ruby idioms, measured the same way, on all four runtimes.

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.

group_by: time per operation and peak memory as input size grows, Ruby, YJIT, JRuby and JRuby with invokedynamic off Time per operationlog scale · one process per point Peak RSSlog scale Ruby YJIT JRuby indy off 1 µs 10 µs 100 µs 1 ms 10 ms 100 ms 1 s 10 s 10 MB 100 MB 1 GB 10 GB 1010 1k1k 100k100k 1M1M 10M10M input size (rows)input size (rows) Ruby 2.5 s JRuby 2.4 s YJIT 2.4 s indy off 2.4 s JRuby 4988 MB indy off 4684 MB YJIT 2072 MB Ruby 2058 MB
Time per operation (left) and peak resident memory (right) at each input size. Small sizes are batched so each sample lasts ≥ 10 ms; ~2 s warm-up per point; median of 10 samples (3 when one op takes over 2 s).
rowsRubyYJITJRubyindy offJRuby vs YJITRSS, MB (YJIT → JRuby)
102.3 µs2.0 µs11 µs6.0 µs0.18×17 → 268
1k99 µs85 µs101 µs155 µs0.85×20 → 274
100k14 ms22 ms23 ms16 ms0.93×89 → 334
1M250 ms238 ms243 ms239 ms0.98×264 → 817
10M2.5 s2.4 s2.4 s2.4 s0.99×2071 → 4988

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.

CSV.parse: median wall time and peak memory per runtime Wall time, msbar = median · dots = each of 3 process runs Peak RSSmax of 3 runs Ruby · CSV.parse 1358 ms 428 MB YJIT · CSV.parse 834 ms 443 MB JRuby · CSV.parse 935 ms 10.3 GB JRuby indy off · CSV.parse 1019 ms 9.8 GB linear scale, 0–1664 ms linear scale, 0–10.3 GB

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.

csv: time per operation and peak memory as input size grows, Ruby, YJIT, JRuby and JRuby with invokedynamic off Time per operationlog scale · one process per point Peak RSSlog scale Ruby YJIT JRuby indy off 10 µs 100 µs 1 ms 10 ms 100 ms 1 s 10 s 10 MB 100 MB 1 GB 10 GB 100 GB 1010 1k1k 100k100k 1M1M input size (rows)input size (rows) YJIT 8.1 s Ruby 7.4 s indy off 5.6 s JRuby 4.6 s JRuby 17050 MB indy off 15538 MB YJIT 2213 MB Ruby 1747 MB
Time per operation (left) and peak resident memory (right) at each input size. Small sizes are batched so each sample lasts ≥ 10 ms; ~2 s warm-up per point; median of 10 samples (3 when one op takes over 2 s).
rowsRubyYJITJRubyindy offJRuby vs YJITRSS, MB (YJIT → JRuby)
1063 µs34 µs855 µs516 µs0.040×18 → 279
1k3.2 ms2.0 ms8.8 ms8.4 ms0.23×20 → 300
100k462 ms394 ms533 ms608 ms0.74×232 → 6665
1M7.4 s8.1 s4.6 s5.6 s1.74×2212 → 17050

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.

fib(30): median wall time and peak memory per runtime Wall time, msbar = median · dots = each of 3 process runs Peak RSSmax of 3 runs Ruby · fib(30) 44 ms 15 MB YJIT · fib(30) 5.4 ms 16 MB JRuby · fib(30) 324 ms 283 MB JRuby indy off · fib(30) 25 ms 271 MB linear scale, 0–341 ms linear scale, 0–283 MB

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.

fib: time per operation and peak memory as input size grows, Ruby, YJIT, JRuby and JRuby with invokedynamic off Time per operationlog scale · one process per point Peak RSSlog scale Ruby YJIT JRuby indy off 100 ns 1 µs 10 µs 100 µs 1 ms 10 ms 100 ms 1 s 10 MB 100 MB 1 GB 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 493 ms indy off 258 ms JRuby 109 ms YJIT 60 ms JRuby 288 MB indy off 276 MB YJIT 16 MB Ruby 16 MB
Time per operation (left) and peak resident memory (right) at each input size. Small sizes are batched so each sample lasts ≥ 10 ms; ~2 s warm-up per point; median of 10 samples (3 when one op takes over 2 s).
nRubyYJITJRubyindy offJRuby vs YJITRSS, MB (YJIT → JRuby)
103.0 µs424 ns3.9 µs11 µs0.11×16 → 268
1533 µs7.9 µs7.4 µs17 µs1.07×16 → 273
20361 µs44 µs63 µs188 µs0.69×16 → 275
254.1 ms476 µs26 ms1.9 ms0.019×16 → 277
3045 ms5.4 ms319 ms21 ms0.017×16 → 283
35493 ms60 ms109 ms258 ms0.55×16 → 288

Warm-up

Time per iteration for the first 40 iterations of each fragment from a cold process, four runtimes Warm-up: the first 40 iterationsms per iteration from a cold process · linear y from 0 · one process per line Ruby 4.0 Ruby 4.0 + YJIT JRuby 10.1 JRuby, indy off select.map.sum 119 ms 140 scan + tally 307 ms 140 JSON 443 ms 140 downcase.gsub 296 ms 140 sort_by tuple 1 s 140 group_by + sum 389 ms 140 CSV.parse 2.2 s 140 fib(30) 329 ms 140
Each line is one cold process timing iterations 1–40 with no warm-up. The number top right of each panel is its largest value; the gridline under it marks that value.

Each line is one cold process timing iterations 1–40 with no warm-up at all.

Cold start

Runtime-e 0, median of 10min–max
Ruby 4.027 ms26–31 ms
Ruby 4.0 + YJIT25 ms24–28 ms
JRuby 10.1757 ms708–838 ms
JRuby 10.1, indy off757 ms709–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

fib(30) time per iteration over 80 iterations from a cold JVM, JRuby with invokedynamic on and off fib(30) per iteration, 80 iterations from a cold JVMlog y · 3 processes per mode · no warm-up JRuby 10.1 JRuby, indy off 1 ms 10 ms 100 ms 1 s 1 20 40 60 80 iteration

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):

Fragmentdefault: time · peak RSS-J-Xmx2g: time · peak RSStimeRSS
CSV.parse934.8 ms · 10.3 GB1115.8 ms · 2.3 GB+19 %4.5× less
scan + tally221.4 ms · 4.5 GB240.2 ms · 2.2 GB+8 %2.1× less
JSON159.1 ms · 2.7 GB216.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.

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 JRuby threads JRuby, indy off cpu · 64 × fib(25) 0× 2× 4× 6× 8× 1 2 4 8 workers W no gain ideal ▲ off scale: 171× at W=4 · 308× at W=8 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 % JRuby threads 6 377 /s · 308× over a W = 1 stuck at 21 /s JRuby, indy off 3 253 /s · 7.6× · 95 % 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 % JRuby threads 10 430 /s · 6.5× · 82 % JRuby, indy off 10 462 /s · 6.6× · 83 % 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 % JRuby threads 772 /s · 8.0× · 100 % JRuby, indy off 753 /s · 8.1× · 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 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 JRuby threads ΣPSS 2.3 GB · RSS 2.3 GB · 293 MB/worker · 1 heap JRuby, indy off ΣPSS 2.1 GB · RSS 2.1 GB · 264 MB/worker · 1 heap JVM RSS is heap the collector has grown into (default max heap ≈ ¼ of RAM), not the live working set.
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
JRuby threads21
(21–21)
25
(24–25)
3 538
(2 964–3 735)
6 377
(4 575–9 369)
308.1×3851 %322 MB322 MB
JRuby, indy off430
(396–440)
868
(791–871)
1 585
(1 516–1 596)
3 253
(3 069–3 436)
7.6×95 %307 MB307 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
JRuby threads1 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 GB2.3 GB
JRuby, indy off1 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 GB2.1 GB

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
JRuby threads96
(95–96)
193
(193–195)
386
(385–389)
772
(767–775)
8.0×100 %277 MB277 MB
JRuby, indy off93
(92–94)
188
(187–189)
374
(373–379)
753
(751–757)
8.1×101 %266 MB266 MB

What the numbers say

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.

Questions? Let's Chat :)

ruby