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.