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#synchronizegoes from about 19.8M to 23.7M calls a second; a plainMutexmanages 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.readused to allocate a buffer, hit EOF, then enlarge and issue a secondread- on the happy path, every time. Read one extra byte up front and the common case stops over-allocating.ObjectSpace.memsize_ofon 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.
fib5x,optcarrot1.5x,liquid-render14%,activerecord5.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
optcarrotat the current--zjit-max-versions=2default. - Setivar on extended RObjects got patched up after a regression earlier in the same series.
optcarrot1384.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
invokeblockfinally gets the specialize-and-compile treatment thatsend,getivarandsetivaralready had.chunky-png17%, aloops-timesmicrobenchmark 2.28x. - The last one is YJIT: a
psych-loadregression 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-dup277.3ms → 132.8ms,string-dup-chilled277.5ms → 131.2ms.
super and blocks
- John Hawthorn moved the callinfo/calldata for
superdispatch onto the stack, fixing a race where two Ractors could fight over the shared copy. Side effect:supercalls passing keyword arguments are 2.20x faster, since they no longer need a heap-allocated calldata. Plain positionalsupercomes out marginally slower on his machine. - Luke Gruber fixed
block.callfalling back to a slower dispatch path thanyield, despite the two costing about the same in principle. No arguments: 32.95M → 57.37M i/s. Two arguments: 31.25M → 51.62M i/s. Both land right next toyield's own numbers.
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+jointhroughput goes 6,224/s → 33,666/s.
Strings and arrays
- Mari Imaizumi added a 256-byte lookup table to
String#inspectso 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#joincanmemcpystraight 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. #upcaseand#downcasehad an ASCII byte-loop fast path.#capitalizedidn'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 toduparray, 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::ResumableParserwas 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_integerso 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_fixnumandgen_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
.newget reset back toClass.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?andthis_year?were walking their whole range viaRange#include?and its#succiteration, which meansthis_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 theRange#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_messagecall.ruby -e1on macOS: 114ms → 30ms. File.staton Windows was opening a full file handle - five-plus syscalls - andrequire's realpath resolution repeats that for every parent directory. On Windows 11 24H2 and later, a singleGetFileInformationByNamereplaces the lot.File.stat3.9x,require "rubocop"1.35x,require "active_support/all"1.55x.
Quick hits
- Damián Le Nouaille merged
rm -rf node_modulesinto the asset-precompile layer of the generated Dockerfile and switchedchown -RtoCOPY --chown. About 13s and about 50s off his builds, respectively. parse_expression_terminatorwalked the entire receiver chain on every infix operator, even when binding power alone already settled the question. Check binding power first: parsing"a"followed by 8000.bcalls drops from 44.8ms to 0.46ms. Shugo Maeda.- Kyle Tate stopped
asyncregenerating a cancellation cause and backtrace for every descendant task during bulk cancellation. Reusing the originating one takes a 5,461-task cancellation from 103,833 allocated objects to 21,929. - Denis Savchuk parallelised the per-mirror
exist?checks and uploads in Rails' storage-mirroring service, which had been running them sequentially despite already owning a thread pool of the right size. With simulated 50ms cloud latency, syncing to five mirrors goes 250ms → 50ms - the bottleneck is round-trips, not bandwidth.
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.