Tag: Ruby

Ruby and Rails Performance Roundup: The Backlog Edition

Yet again instead of tweets, a blog post. The backlog got out of hand - 40 of them this time.

Usual caveat: every number below is whatever the author measured on their own machine with their own workload. Some are microbenchmarks. Don't compare them against each other, and don't assume they'll show up in your app. Click through if you care about methodology.

byroot is still speedrunning Ruby and Rails

Jean Boussier shows up often enough that he gets a section instead of bullets scattered through the post.

  • Make Monitor a core class - giving it access to Ruby's internal routines strips out a chunk of overhead. Monitor#synchronize goes from about 19.8M to 23.7M calls a second; a plain Mutex manages around 25M on the same machine, so most of the gap is closed.
  • Optimize fixtures lookup pattern, extracted from a bigger PR. His own summary is better than anything I'd write: roughly 50% faster, but also much simpler.
  • io.c: read files in a single pass is the one I keep thinking about. File.read used to allocate a buffer, hit EOF, then enlarge and issue a second read - on the happy path, every time. Read one extra byte up front and the common case stops over-allocating. ObjectSpace.memsize_of on a 10-byte file drops from 1075 bytes retained to 40.

JIT corner

Six ZJIT and YJIT PRs from Takashi Kokubun in this batch.

  • Callsites with no profile data now recompile after a handful of side exits instead of sitting unoptimized forever. fib 5x, optcarrot 1.5x, liquid-render 14%, activerecord 5.8%.
  • Monomorphic getivar always specializes now. There was a case where the last compiled version of an ISEQ ignored a perfectly good profiled shape; fixing it is worth 1.086x on optcarrot at the current --zjit-max-versions=2 default.
  • Setivar on extended RObjects got patched up after a regression earlier in the same series. optcarrot 1384.4ms → 1064.3ms.
  • ZJIT can re-profile a whole ISEQ rather than only collecting from side exits, which means non-first compiled versions get optimized too. 1.169x on optcarrot.
  • Polymorphic invokeblock finally gets the specialize-and-compile treatment that send, getivar and setivar already had. chunky-png 17%, a loops-times microbenchmark 2.28x.
  • The last one is YJIT: a psych-load regression fixed by restoring an inline shape-transition write for the common case, an embedded object whose next shape keeps the same capacity. 1152.4ms → 980.8ms.

Two more from outside the JIT team.

  • Felix Bünemann noticed jmp_ptr_bytes() was reserving five instructions per patch point on arm64, when a single branch there reaches ±128MiB - comfortably past the 64MiB default code region. Trim the reservation to what's actually needed: a branch-heavy while loop 1.70x faster, a 3-way if/elsif loop 1.23x.
  • eightbitraptor gave string duplication the same GC-fast-path inlining that allocation already had. string-dup 277.3ms → 132.8ms, string-dup-chilled 277.5ms → 131.2ms.

super and blocks

Ractors get cheaper

Three from Koichi Sasada, all aimed at the same problem: Ractors used to make garbage collection worse the more of them you ran.

  • Per-Ractor GC is the big one. Each Ractor gets its own objspace, local GC runs without stopping the world, and a global GC only kicks in when shareable objects or dead Ractors need reclaiming. On a JSON-parsing benchmark, going from 1 to 16 Ractors cost 10.17x the single-Ractor wall time on master; with this series it's 3.32x. Forked processes manage 3.28x, so that's basically parity. Single-Ractor allocation also picks up 9% with GC disabled, since the per-Ractor newobj cache is gone.
  • The Ractor root scan was marking a thread's execution context and then marking the fiber wrapper around it, and both reach the same machine-stack scan. Skip the redundant one: a 5000-thread flood benchmark drops GC time 0.84s → 0.49s, thread creation 1.17s → 0.81s.
  • Dead Ractors used to leave their Thread/Fiber/ThreadGroup scaffolding lying around until the next major GC. Now the dying thread cleans up after itself. 500 dead Ractors leave 18 heap pages behind instead of 1016, and spawn+join throughput goes 6,224/s → 33,666/s.

Strings and arrays

  • Mari Imaizumi added a 256-byte lookup table to String#inspect so it can skip runs of unescaped ASCII rather than decoding character by character. 12.39x on ASCII text, 3.45x on mixed content. UTF-8 and binary strings unaffected; a fully escape-heavy string comes out about 5% slower.
  • When every element of an array is 7-bit ASCII, or they all share one ASCII-compatible encoding, Array#join can memcpy straight into the result buffer instead of negotiating encodings element by element. Up to 2.94x on a 100,000-element array, 2.48x with no separator at all. Yaroslav Markin.
  • #upcase and #downcase had an ASCII byte-loop fast path. #capitalize didn't, which left it roughly 10x slower than it needed to be on the same input - 5.89x faster on a single character once fixed, 12.70x on a 1000-character string.
  • Same author, second entry: annotating String#ascii_only? and #valid_encoding? as leaf builtins so they skip the CFUNC frame push. 1.2-1.4x in the plain interpreter, up to 2.08x under YJIT. Sampo Kuokkanen both times.
  • My favourite of the batch: [1,2,3].include?(x) compiled to duparray, allocating a throwaway copy of the array on every single call. A million calls now allocate 2 objects total instead of 1,000,004, and the hot path itself is 1.68x faster on top of that. Sergey Fedorov.

JSON

  • JSON::ResumableParser was fully decoding an incomplete number on every chunk that extended it - building a bignum each time and throwing it away until the number was finally complete. Defer the decode and a quadratic cost goes linear: a 128,000-digit number fed in 128-byte chunks, 3.07s → 8.13ms. That's Masataka "Pocke" Kuwabara, and at roughly 378x it's the largest ratio on this list.
  • Scott Myron ported the C parser to Java and dropped the ragel-generated one, with a SWAR and Vector API string scanner and frozen hash keys via fastASet. JRuby users get somewhere between 4.97x and 10.39x depending on the test file, best on citm_catalog.json.
  • He also widened json_decode_integer so more 19-20 digit integers hit the fast path instead of falling through to bignum. 2.04x on a file of large integers.

Two more GC fast paths

  • Ranges between two fixnums, or with a nil endpoint, don't need the generic allocation call, so Peter Zhu gave them a dedicated fast path in both new_range_fixnum and gen_new_range. 3.41x and 3.56x on tight range-allocation loops.

Rails: caches, inserts, and the little things

  • Non-STI models that don't override .new get reset back to Class.new, which drops the STI type check and unlocks Ruby 4.0's fast-path allocation. 15-17% on Ruby 4, 8-17% on 3.4 and 3.3. Mike Dalessio.
  • Andrew Novoselac batched the statements involved in creating tables instead of executing each one immediately. On his 1000-plus-table schema, load time went from about two minutes to about 25 seconds.
  • When a parameter filter is an anchored exact-match regexp like /^email$/, you can pull the literal out and check a Hash instead of testing every regexp in turn. 4.5x when all the filters are exact matches. Alex Watt.
  • this_week?, this_month? and this_year? were walking their whole range via Range#include? and its #succ iteration, which means this_year? was stepping through roughly 365 dates on every call. Range#cover? just compares endpoints. 10-100x depending on the period.
  • Gannon McGibbon deferred locale-path filtering to reload time rather than running file-stat checks eagerly at boot, which doesn't scale to apps with thousands of locale files. i18n loading in his app went 400ms → about 250ms; a synthetic benchmark with 2000+ locale files puts the old path 1.59x behind.
  • Nick Pezza found default Action Cable stream handlers being dispatched twice - once onto Action Cable's executor, then again onto the connection worker pool. Running them where they already are takes 750 subscribers from failing outright at 10 messages/second to handling it, a 35% lift over the previous 7/second ceiling.
  • ActionController::Parameters#deep_transform_keys! was rebuilding the whole parameters hash instead of mutating it, leaving an in-place helper that someone had already written sitting there uncalled. Wire it up and a 200-entry, 3-level params hash allocates 1,612 objects instead of 5,825. Kenta Ishizaki, who also has the Range#cover? fix above.

BigDecimal gets a proper algorithm

tomoya ishida replaced the naive expansion in BigMath.erf and erfc with repeated Taylor expansions at increasing precision, binary splitting each step. The gains scale with precision, so the numbers get silly at the top end: BigMath.erf(10, 100000) goes from 13.38s to 1.04s, and the worst case in the PR - erfc of a full-precision number at 100,000 digits - from 1137s, nearly nineteen minutes, to 5.27s.

Boot time and Windows I/O

Two from Hiroshi SHIBATA.

  • error_highlight, did_you_mean and syntax_suggest only enhance error output, so there's no reason to load them at boot rather than on the first Exception#detailed_message call. ruby -e1 on macOS: 114ms → 30ms.
  • File.stat on Windows was opening a full file handle - five-plus syscalls - and require's realpath resolution repeats that for every parent directory. On Windows 11 24H2 and later, a single GetFileInformationByName replaces the lot. File.stat 3.9x, require "rubocop" 1.35x, require "active_support/all" 1.55x.

Quick hits


That's the backlog cleared. Go read the ones that caught your eye; the methodology sections are usually more interesting than the numbers. Thanks to everyone above for doing the work and then writing it up.

Small PRs, big speedups: The Ruby performance work you almost missed

Normally I just fire off a tweet when I spot a nice performance PR landing in Ruby. Lately I've been catching up on a backlog of Ruby performance work I'd bookmarked and never gotten around to - so some of what's below isn't brand new, with a few PRs dating back to 2025. There were so many of them - some headline-grabbing, some small but delightfully clever - that a thread won't cut it. So here's a roundup instead, both the recent landings and the ones I'm late to.

A few ground rules: every PR below ships a concrete benchmark number, so when I say "Nx faster" it's the author's own measurement, not vibes. Numbers come from different machines and workloads, so treat them as "here's the win on the benchmark that motivated the change," not cross-comparable lab results. Click through to any PR for the full picture - most authors document their methodology beautifully.

Let's go.

Strings & text

  • String#scrub skips ASCII runs - Instead of decoding a string character-by-character, scrub now jumps over ASCII runs using the same search_nonascii trick valid_encoding? uses. On English HTML it's up to 45.55x faster, on Japanese HTML 22.71x, and ~3.5x on the general case - with no regression on the worst case. Beautiful work by FletcherDares, who's been on a string-performance tear.
  • String#codepoints ASCII hot path - Same author, same instinct: add a local fast path for ASCII bytes inside mostly-ASCII UTF-8 strings. Result: ~1.9x faster on mixed ASCII content, neutral on pure multibyte.
  • String#gsub! stops copying on no-match - gsub! was eagerly copying shared backing storage even when nothing matched. Defer that copy until the first real match (like sub! already does) and you get 2.33x faster no-match calls - and the allocation on a 100k-char shared string drops from 100,041 bytes to 40 bytes.

Files & directories (the byroot file-IO spree)

byroot (Jean Boussier) went on a tear through Ruby's file primitives, and the numbers are spicy:

GC & object allocation

  • Clear page bits in one shot - jhawthorn (John Hawthorn) turned age bits into a bit plane so age + wb_unprotected bits clear for a whole 64-slot page at once during sweep. ~14% off object-new.
  • Move rb_class_allocate_instance into gc.c - Also jhawthorn: relocating the function lets allocation helpers inline with newobj. ~10–15% faster Object.allocate (1.15x).
  • Remove the class alloc check - jhawthorn again, demoting a runtime allocation-class check to a debug-only assert and unlocking tail-call optimization. ~10% faster Object.new (1.12x).

Concurrency & core classes

  • Speed up TypedData_Get_Struct - byroot added an inlinable fast path to rb_check_typeddata, which makes Mutex#synchronize and Monitor#synchronize ~1.54x / ~1.55x faster respectively.
  • Thread::Queue uses a ring buffer - Swapping the backing array for a ring buffer removes array-function overhead: ~23% faster (1.24x). byroot.
  • Give the hot thread scheduler priority - jpl-coconut reworked thread switching to avoid an intermediate monitor-thread hop. On a 2-core setup the motivating benchmark went from 1.455s to 0.231s (and a heavier scenario from 36.7s to 4.1s).

Parser & build

  • Parallelize bundled gem tests - Not a runtime win, but st0012 (Stan Lo) made CI run gem tests through a thread pool tied into the make jobserver, shaving ~40% off that CI step across platforms.
  • Prism parser optimizations - kddnewton (Kevin Newton) packed in fast/slow path splitting, scope bloom filters, SIMD/SWAR strpbrk, a wyhash word-at-a-time constant pool, and a parser arena. ~22% faster parsing at roughly the same memory. (The matching ruby/ruby side is #16418.)
  • Optimize the Prism Ruby visitor - Replace the array-allocating compact_child_nodes with an each_child_node that yields directly. Visiting the Rails codebase came out ~21% faster on the interpreter and roughly 2.3x faster under YJIT.
  • Lazily deserialize DefNode - Defer DefNode deserialization in the Java loader so JRuby/TruffleRuby don't pay for method bodies up front: ~1.5x faster on the parsing-core metric.

BigDecimal goes brrr

tompng (Tomoya Ishida) has been quietly doing extraordinary things to BigDecimal:

  • NTT multiplication + Newton-Raphson division - O(n log n) multiplication via a three-prime Number Theoretic Transform. The headline is almost comical: up to 800,000x faster multiplication. A squaring that was estimated at 270 days now runs in 29 seconds. This is the kind of PR you frame on a wall.
  • Increase VpMult batch size - Bumping the divmod batch from 8 to 16 makes mid-size multiplications ~1.8x faster. tompng.
  • Optimize BigDecimal#to_s - byroot replaced two snprintf calls with a lean integer-to-ASCII routine: ~2.6x faster for small numbers, ~3.8x for large ones.

JIT corner

Quick hits

A few more that are smaller in scope but very much worth a click - and a thank-you to each author:

Closing

If you like performance magic, go read these. And if you maintain a gem, read them twice - a lot of what's here (back-to-front scanning, single-byte fast paths, deferring copies, avoiding stat) is worth learning from.

Thanks to everyone credited here for the work.

Copyright © 2026 Closer to Code

Theme by Anders NorenUp ↑