All posts

Technical post

Pitchfork: Making Ruby Faster Through Smarter Resource Management

Pitchfork keeps a warm "mold" process so newly spawned workers inherit compiled code through Copy-on-Write. This post reproduces the effect locally with a small Rack app and a benchmark that drops total worker memory from 365 MB to 100 MB.

Pitchfork was built to accelerate Ruby applications through smarter management of compiled code and memory pages. It is a fork of Unicorn, but its defining feature is a reforking mechanism that changes how warmed-up state is shared across worker processes.

The problem it solves is specific to long-lived workers. As a worker serves requests, it loads code, allocates caches, and (with YJIT enabled) compiles hot paths into native machine code. That work is fast, but in a plain process-per-worker model every worker pays for it independently. The same methods get compiled again and again, once per worker, and the same caches get allocated once per worker.

Pitchfork avoids the repetition by keeping a single process warm before spawning the rest.

The mold process

Instead of forking cold workers from the master, Pitchfork promotes one warmed worker to act as a "mold." That worker serves real requests for a while, which gives it time to load code and compile hot paths into its memory pages. New workers are then forked from the mold rather than from a cold parent, and the cold workers are discarded.

Forking an already prepared worker: a cold master forks four workers, one warms up and becomes the mold, the cold workers are discarded, and new workers are forked from the warmed process so they inherit memory pages containing previously compiled code.

Because Linux uses Copy-on-Write, a forked child does not immediately copy its parent's memory. It shares the parent's physical pages until it writes to them. The compiled code and warmed caches live in pages the workers read but rarely modify, so they stay shared instead of being duplicated per worker.

Reproducing it locally

You do not need a Shopify-sized monolith to see the effect. A tiny Rack app that allocates a large cache on first request is enough to stand in for "an app that boots routes, initializers, and i18n, then warms up."

# Gemfile
source 'https://rubygems.org'

gem 'pitchfork'
gem 'rack'
# config.ru
CACHE = {}
RECORD_COUNT = 400_000
RECORD_PADDING = 100

App = Proc.new do |env|
  # simulates Rails booting routes, initializers, i18n, etc.
  unless CACHE[:warmed]
    CACHE[:records] = Array.new(RECORD_COUNT) { |i| "row_#{i}_#{"x" * RECORD_PADDING}" }
    CACHE[:warmed]  = true
  end

  body = "pid=#{Process.pid}"
  [200, { 'content-type' => 'text/plain' }, [body]]
end

run App

The first request a worker serves fills CACHE[:records] with 400,000 padded strings. That is the warmed state we want workers to share rather than each rebuild from scratch.

Two configs: with and without reforking

The only meaningful difference between the two Pitchfork configs is one line: refork_after.

# pitchfork_refork.rb
WORKERS = 4
PORT = 9292
TIMEOUT = 30
REFORK_AFTER = [20, 200]

worker_processes WORKERS
listen PORT
timeout TIMEOUT

# promote the first worker that processes 20 requests as the new mold.
# new workers will be forked from it, already warm, pages shared via CoW.
refork_after REFORK_AFTER
# pitchfork_no_refork.rb
WORKERS = 4
PORT = 9292
TIMEOUT = 30

worker_processes WORKERS
listen PORT
timeout TIMEOUT

refork_after [20, 200] tells Pitchfork to refork after a worker has processed 20 requests, then again at 200. The first reforking is the important one: once a worker is warm, it becomes the mold and the rest of the pool is re-forked from it.

The benchmark

This script starts each server, warms the workers with concurrent requests, waits for reforking to happen, and then measures memory. The key detail is how it measures: it reads Pss (Proportional Set Size) from /proc/<pid>/smaps_rollup, which counts shared pages proportionally. That is exactly what you want here, because naive RSS would double-count the pages that Copy-on-Write is sharing.

# benchmark.rb
require 'net/http'

PITCHFORK_SERVER = URI("http://localhost:9292/")
SUPPRESS_OUTPUT = { out: '/dev/null', err: '/dev/null' }
PSS_PATTERN = /^Pss:\s+(\d+)/
KB_TO_MB = 1024.0

# counts shared memory pages proportionally
def pss_mb(pid)
  File.read("/proc/#{pid}/smaps_rollup").match(PSS_PATTERN)[1].to_i / KB_TO_MB
end

def measure(label, config)
  master = spawn("bundle exec pitchfork -c #{config} config.ru", **SUPPRESS_OUTPUT)
  sleep 3

  # send requests concurrently so all workers get hit and warm up
  200.times.map { Thread.new { Net::HTTP.get(PITCHFORK_SERVER) } }.each(&:join)

  # give Pitchfork time to refork after workers are warm
  sleep 12

  # pgrep -P returns direct children of master (the worker processes)
  pids = `pgrep -P #{master}`.split.map(&:to_i)
  puts "\n#{label}"
  puts pids.map { |pid| "  worker #{pid}: #{pss_mb(pid).round(1)} MB" }
  puts "  total: #{pids.sum { |pid| pss_mb(pid) }.round(1)} MB"

  Process.kill('QUIT', master)
  Process.wait(master)
  sleep 1
end

measure("without reforking", "pitchfork_no_refork.rb")
measure("with reforking",    "pitchfork_refork.rb")

Results

$ ruby benchmark.rb

without reforking
  worker 253506: 9.5 MB
  worker 253512: 89.5 MB
  worker 253518: 88.5 MB
  worker 253524: 89.3 MB
  worker 253530: 88.7 MB
  total: 365.5 MB

with reforking
  worker 254946: 19.9 MB
  worker 254952: 20.1 MB
  worker 254958: 20.1 MB
  worker 254964: 20.1 MB
  worker 254970: 20.1 MB
  total: 100.1 MB

Without reforking, four warmed workers each carry their own ~89 MB copy of the cache, and the total lands at 365.5 MB. With reforking, the workers share the mold's pages through Copy-on-Write, each reports roughly 20 MB of proportional memory, and the total drops to 100.1 MB. That is a 73% reduction on a toy app whose only "warm state" is one big array.

What this looks like in production

Shopify reported the same pattern at scale on their monolith. There the warmed state that matters most is YJIT-compiled machine code: with 36 workers per pod allocating roughly 128 MB of YJIT code each, that is about 4.6 GB spent on compiled code alone. With reforking, those compiled pages are shared from the mold instead of duplicated, and the cost moves much closer to that of a single warmed process.

Their reported numbers: a 30% reduction in memory usage and a 9% reduction in latency, reaching up to 14% during periods without frequent deploys, when workers stay warm long enough to accumulate more shared compiled code.

The trade-off

Reforking relies on Copy-on-Write staying effective, which means the shared pages have to stay shared. Anything that writes into those pages after the fork breaks the sharing for that page and copies it per worker. This is why Pitchfork pairs well with applications that reach a stable warmed state rather than ones that keep mutating global structures at runtime, and why the benefit grows the longer workers stay alive between deploys.

The repository is at github.com/Shopify/pitchfork.