Pre-fork workers so requests don't wait on fork - #67
Merged
Merged
Conversation
flavorjones
force-pushed
the
prefork-warmup-543
branch
from
September 28, 2026 16:53
3eb26ed to
1e83f72
Compare
flavorjones
force-pushed
the
prefork-warmup-543
branch
from
September 28, 2026 19:58
1e83f72 to
d954bfa
Compare
flavorjones
force-pushed
the
prefork-warmup-543
branch
from
September 28, 2026 20:08
d954bfa to
999fa0a
Compare
With `max_requests_per_worker: 1`, the supervisor forked a worker only when a request arrived. Each request waited for the fork and for the worker to start. Fork a worker into each free slot at boot. When a worker that served a request is reaped, fork a replacement immediately. Run `Process.warmup` one time, at boot, before the first fork. This change made a client race common. A full cell writes `capacity` and closes the connection without reading the request. If the close happened before the client finished its write, the write failed with EPIPE. The client then returned `unavailable` and did not read the `capacity` answer. Read the answer when the write fails with EPIPE or ECONNRESET. Median per request, sequential transforms 1s apart, the cell in ruby:3.4-slim with the accessory's flags, concurrency 2: 60x40 png thumbnail master 23.8ms this 17.1ms 3000x2000 jpg to 800x600 master 99.9ms this 99.3ms (within noise)
flavorjones
force-pushed
the
prefork-warmup-543
branch
from
September 28, 2026 20:11
999fa0a to
32fe94b
Compare
This was referenced Oct 1, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
With the default
max_requests_per_worker: 1, a cell's supervisor forks one worker process per request. On master, the supervisor forks that worker only when a request arrives. Each request waits for theforkand for the new worker to start. Only then does the operation run. ADR 0001 measured theforkalone at 2.8ms. It also noted that pre-forking would recover that time.Details
Supervisor#runforks a worker into each free slot before it starts its loop. The first requests find workers that are ready.Supervisor#reapreaps a worker that served a request, it forks a replacement immediately. The next request does not wait for a fork.Supervisor#reapdoes not replace a worker that exited without serving a request. This guards against a worker that crashes during its startup, for example after a bad deploy. A replacement would crash too, and the supervisor would fork again and again, with no requests arriving. Instead, the next request forks its own worker, as on master.Process.warmupruns one time, at boot, inSupervisor#boot. It runs after the operations load and before the first fork. It compacts the supervisor's Ruby heap. Each worker then copies fewer inherited pages when its garbage collector writes to them.Benchmark
Each run boots a cell with concurrency 2. The run sends Active Storage transforms through
activestorage-hotcell-client, one at a time. It pauses between requests to simulate idle time. Each table cell shows the range of the median across runs.The cell in
ruby:3.4-slim(Ruby 3.4.11, libvips 8.16) with the accessory's flags:Process.warmupOn the host (Ruby 4.0.3, libvips 8.18):
Process.warmupOn the small image, pre-forking and
Process.warmupeach save time. With 1s pauses, the ranges do not overlap. On the large image, the image work takes most of the time. The differences are within the run-to-run variation.Additional information
This PR also fixes a client race. Pre-forking made the race common. A client connects to the cell's socket. Then the client writes its request. When the cell is full, the supervisor accepts the connection and writes
capacity. Then the supervisor closes the connection without reading the request (Supervisor#refuse). If the close happens before the client writes, the write fails withEPIPE. The client then returnedunavailable. It did not read thecapacityanswer that was already on its socket. NowTransport::Socketreads the answer when its write fails withEPIPEorECONNRESET. The new test inclassification_test.rbsends a request that is larger than the socket buffer. The test server answers and closes without reading. So the write fails every time.