Skip to content

Instantly share code, notes, and snippets.

@eileencodes
Created April 16, 2026 13:08
Show Gist options
  • Select an option

  • Save eileencodes/c6ac7e271281fd473d82a78dec539ae4 to your computer and use it in GitHub Desktop.

Select an option

Save eileencodes/c6ac7e271281fd473d82a78dec539ae4 to your computer and use it in GitHub Desktop.

Bisect Summary

All numbers below are from a local bundler-bench run (rails scenario, 164 gems, macOS ARM64, Ruby 4.0.1, local gem server, hyperfine median of 3 iterations). Raw JSON for all 72 runs is available upon request.

A note on terminology: the benchmark's cold run wipes everything (vendor, .bundle, all caches, lockfile). Its warm run wipes only vendor/bundle and .bundle — the download cache, global extracted cache, and lockfile are preserved. From a project's point of view a "warm" run is a fresh project install that hits a populated system cache, which is the most common day-to-day scenario (re-bundling after a Gemfile change, CI with cache restore, switching branches, etc.). Throughout this review, "warm" means that scenario.

  1. Of the nine optimization groups in this PR, five are neutral on macOS (within benchmark noise of master, ±5%). They neither help nor hurt.
  2. Two groups (the Source::Rubygems + RubyGemsGemInstaller rework) account for 100% of the measured performance change — and that change is a regression: ~14% slower on a fresh install with no cache, ~110% slower on a reinstall with a populated cache.
  3. The regression is isolated to a single code path in RubyGemsGemInstaller#fast_cp_r, and we have a clear root cause.

Phase 1 — Baseline: the PR as submitted

Legend:  ✓ faster than master   · neutral (±5%)   ✗ slower   ✗✗ much slower (>50%)

                          cold        warm
20260311_113810      +16.5% ✗   +118.5% ✗✗
20260311_115115      +19.1% ✗   +106.8% ✗✗
20260311_131324       +9.6% ✗   +117.2% ✗✗
20260311_132455      +18.6% ✗   +112.4% ✗✗
20260311_133645      +13.2% ✗   +117.8% ✗✗
20260311_135319      +13.2% ✗   +117.2% ✗✗
20260311_141714      +16.2% ✗   +116.1% ✗✗
                     ─────────  ──────────
              avg:    +15.2%      +115.2%       ← warm install ~2.15× slower

Across seven independent runs, the PR is ~15% slower cold and ~115% slower warm than master on macOS. The variance is small enough to rule out noise.


Phase 2 — Strip the PR to its essentials

The PR touches 27 files / ~1643 lines. We removed everything that wasn't load-bearing on the benchmark:

  • Diagnostic and UX changes (IOTrace module, animated progress spinner, Bundler.ui.silence wrappers)
  • Unrelated feature additions (ignore_ruby_upper_bounds, metadata-matching changes, settings additions)
  • Micro-optimizations that don't move the benchmark (memoization tweaks, inlined helpers, hash-vs-array swaps, cached full_name/hash/lock_name on NameTuple, fast-path empty?)
  • Speculative work (CompactVersion packed-integer system, BLAKE2b fast_digest, dynamic fetcher pool sizing)

This reduced the surface to 12 files / ~984 lines. Re-benchmarking the stripped version:

                          cold        warm
20260311_144501      +15.0% ✗   +107.4% ✗✗
20260311_150931      +18.7% ✗   +109.3% ✗✗
20260311_152945      +26.5% ✗   +115.9% ✗✗
20260311_154555      +25.2% ✗   +114.8% ✗✗
                     ─────────  ──────────
              avg:    +21.4%      +111.9%

Stripping didn't change anything. The regression is in the remaining 12 files.


Phase 3 — Incremental rebuild from master

We then went the other direction: started from master HEAD and reapplied the optimizations one logical group at a time, benchmarking after each.

                                cold        warm
Group 1   definition.rb       +5.5% ·     +5.8% ·       neutral
Group 2   compact index       +5.3% ·     +2.8% ·       neutral
Group 3   resolver            +3.8% ·     +0.6% ·       neutral
Group 4   write_lock          +4.5% ·     +0.6% ·       neutral
Group 5   parallel installer  +5.2% ·     +2.3% ·       neutral
─────────────────────────────────────────────────────────────────
Group 6+7 Source::Rubygems   +20.0% ✗  +108.0% ✗✗      ★ regression introduced
          + RubyGemsGemInstaller
─────────────────────────────────────────────────────────────────
Group 8+9 Git source +       +17.7% ✗  +111.1% ✗✗      regression persists
          rubygems_integration

Groups 1–5 are within noise of master. The regression is introduced by Groups 6+7 — changes to Source::Rubygems and RubyGemsGemInstaller.


Phase 4 — Bisect within Groups 6+7

                                                  cold        warm
disable download phase                        +15.3% ✗  +110.8% ✗✗      not the cause
skip extract/finalize                          +8.8% ·    +1.0% ·       ← warm fixed
disable global caches                          +5.3% ·    +2.5% ·       ← warm fixed
─────────────────────────────────────────────────────────────────────────
★ replace fast_cp_r → plain FileUtils.cp_r   +13.6% ✗   -19.6% ✓✓     ★ root cause

Two converging signals:

  • Both "skip extract/finalize" and "disable global caches" eliminate the warm regression. They both bypass the same code path: place_gem_dir → fast_cp_r.
  • Replacing fast_cp_r with plain FileUtils.cp_r not only eliminates the regression but flips it into a ~20% improvement over master on warm installs. However it adds 13% degradation to cold installs.

Root cause

fast_cp_r was introduced to speed up gem placement by skipping full file copies. On macOS, both of its strategies are slower than cp_r.

def fast_cp_r(src, dest)
  if try_hardlink_tree(src, dest)                       # per-file ln() syscalls
    return
  end
  if CLONEFILE_SUPPORTED && try_clonefile(src, dest)    # forks `cp -cR` per gem
    return
  end
  FileUtils.cp_r(src, dest)                             # only as last fallback
end

try_hardlink_tree issues a FileUtils.ln per file with a rescue Errno::EXDEV, Errno::ENOTSUP, Errno::EPERM fallback. In a typical setup vendor/bundle and the global cache live on different mount points (separate filesystems, FileVault boundaries, etc.), so every file fails with EXDEV and goes through the rescue path. Even when hardlinks succeed, the per-file ln() syscall overhead exceeds bulk cp_r.

try_clonefile does system("cp", "-cR", src, dest) — forking a cp subprocess per gem. With 164 gems that's 164 process forks just for placement. macOS process creation is relatively expensive, and the per-fork cost dominates the COW savings.

The fallback FileUtils.cp_r was already the right primitive on macOS; fast_cp_r made performance worse by trying two slower paths first.


Phase 5 — Cold-install regression

After fixing fast_cp_r, warm installs are ~20% faster than master. But cold installs are still ~14% slower. We tried four approaches to bring cold back in line:

                                          cold        warm
backfill from installed dir (sync)     +13.7% ✗   -17.1% ✓✓     same tradeoff
background thread + at_exit join       +20.3% ✗   -17.6% ✓✓     worse cold
background thread + 1s timeout         +17.3% ✗   -10.8% ✓      cache half-empty
inline backfill in worker threads      +14.9% ✗   -17.4% ✓✓     same tradeoff

All four converged to the same place: ~14–20% slower cold, ~17% faster warm. The cold cost is the work of writing ~164 gem trees into ~/.cache/gem/extracted/ for the first time. It cannot be optimized away — only moved around. Doing it inline, in a thread, in workers, or via backfill all cost the same total I/O.

The tradeoff is structural: a one-time ~15% penalty on the very first install of a project, in exchange for ~17–20% faster every subsequent install. Whether that tradeoff is worth it is a separate question from this PR.

What this means for the PR

The bisect tells a clear story about where the performance budget in this PR is being spent on macOS:

  • Groups 1–5 (definition.rb fast paths, compact index parser optimizations, resolver prefetching, install-needed early return, parallel installer pipeline) are all neutral — within ±5% of master in both cold and warm scenarios. They're not what's producing the "2x faster installs" headline on this platform.
  • Groups 6+7 (Source::Rubygems infrastructure rework + RubyGemsGemInstaller changes) are the only code in the PR that meaningfully changes the macOS benchmark, and they regress it: +20% cold, +108% warm.

On macOS specifically, this PR has a regression in Groups 6+7 plus a lot of code that doesn't move the needle on performance. There's no extractable subset that's a win on macOS.

We have separately confirmed that the PR is faster on Linux. The cross-platform asymmetry comes from how fast_cp_r interacts with the host filesystem: on Linux, same-filesystem hardlinks are cheap and clonefile isn't relevant, so the optimization pays off. On macOS, hardlinks fail with EXDEV across mount boundaries and the clonefile path forks cp per gem — both of which are slower than the plain cp_r they replaced.


Where the next win lives

This bisect pinpoints exactly where the next round of install-perf wins lives: pre-built gems in a shared cache.

Phase 4's "skip extract/finalize" test showed that bypassing the full Gem::Installer extraction pipeline on reinstall takes warm install from +108% slower to +1% — a ~52% absolute reduction in install time, just from not re-extracting. Phase 5 confirmed the same number across four different cache-population strategies. The mechanism is the same in all of them: extract or build a gem once, store the resulting directory tree somewhere durable, and on every subsequent install just directory-copy it into place instead of re-running the full installer pipeline.

For pure-Ruby gems this saves the untar + permission-fix + cache-write work. For native-extension gems it additionally saves recompiling the extension — which on a typical Rails-sized Gemfile is the single most expensive item in bundle install. A future change that focuses on pre-building gems once and reusing them would be a much bigger win.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment