perf: fork the dev backend reload worker instead of using a forkserver
Merged
FA
10d ago+239+239+012345678901234567890123456789-16-16-01234567890123456789
Stacked on #7079 (its commit is included in this diff until it merges). Follow-up to #7112, which measured this change and dropped it so the fork helpers land once, in #7079.
Summary
reflex runnow applies the same start-method rule as the prod supervisor:forkwhere the interpreter already defaults toforkorforkserver(Linux),spawnunderREFLEX_STRICT_HOT_RELOAD, andREFLEX_BACKEND_START_METHODoverrides both. macOS and Windows keepspawn, so nothing changes there.- On Python 3.14 Linux the default is
forkserver, so the supervisor kept a forkserver plus resource tracker alive and the single reload worker re-imported the whole framework privately. Now granian forks the worker from the supervisor and framework pages are shared copy-on-write. - The start method is fixed in
_compile_appbefore the compileProcessPoolExecutorstarts. Otherwise that pool creates the forkserver pair first and it survives for the whole session. - The supervisor runs
gc.collect(); gc.freeze()before granian forks (shared_freeze_for_fork, also used by the prod preload), but never imports the user app. Every reload still imports it fresh in the worker. Verified with a route that changed between reloads: the new value was served 0.5 s after the edit, and the tree stays at two Python processes. - #7049 preloads
reflex.appin the supervisor only when the start method is alreadyfork. With this PR that condition holds on 3.14, so the two compose.
Measurement
Blank app, reflex run, page loaded once in headless Chrome, PSS over the session tree from /proc/<pid>/smaps_rollup, 15 s settle, Python 3.14, granian 2.8.1. Before is the #7079 branch as-is; after adds this commit. Node numbers are unchanged by this PR (see #7112 for the vite side).
| before | after | |
|---|---|---|
| supervisor | 152 MB | 105 MB |
| forkserver + resource tracker | 6 + 19 MB | gone |
| reload worker | 151 MB | 96 MB |
| python total | 330 MB | 201 MB |
| whole tree, cold (2 runs) | 1128 / 1099 MB | 988 / 995 MB |
| whole tree, warm (3 runs) | 737 / 745 / 742 MB | 615 / 609 / 606 MB |
| backend ready | 4.4 s | 3.0 s |
Dev runs one worker, so this is a fixed ~130 MB and ~1.4 s, not a per-worker multiple. On Python 3.13 and older, Linux already forks, so the only change there is the freeze.
Test plan
-
uv run pytest tests/units(8353 passed, 76.15% coverage) -
uv run pre-commit run --all-files - Manual: hot reload picks up an edit with the forked worker;
REFLEX_STRICT_HOT_RELOAD=1still spawns