perf(core): reuse persistent worker subprocesses across translation units - #524
Conversation
f9371c4 to
2f00823
Compare
There was a problem hiding this comment.
Pull request overview
This PR refactors solx-core’s recursive subprocess compilation into a pool of persistent worker subprocesses using a framed CBOR protocol, aiming to eliminate per-translation-unit fork/exec and reduce IPC overhead while keeping outputs byte-identical.
Changes:
- Introduces a framed CBOR channel and splits compilation payload into
Session(sent once) + per-unitJob. - Adds a parent-side
Pool/Workerfor persistent subprocess reuse and a child-side long-lived worker loop. - Updates compilation pipeline call sites and LLVM option handling to avoid cross-job option leakage.
Reviewed changes
Copilot reviewed 16 out of 16 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| solx/tests/cli/recursive_process.rs | Updates CLI expectations for the new worker protocol/argument behavior. |
| solx-core/src/project/mod.rs | Switches multi-pass compilation to dispatch jobs through the persistent worker pool. |
| solx-core/src/project/contract/mod.rs | Adjusts compile_to_evm API to take output_selection by reference; removes per-unit stack handler install. |
| solx-core/src/process/mod.rs | Replaces single-shot subprocess model with new process module structure for persistent workers. |
| solx-core/src/process/channel.rs | Adds framed CBOR send/recv helpers shared by parent/child. |
| solx-core/src/process/session.rs | Defines session payload shared across jobs and sent once per worker. |
| solx-core/src/process/job.rs | Defines per-translation-unit job payload. |
| solx-core/src/process/pool.rs | Adds worker pooling, reuse policy, and retry-on-reused-worker-death behavior. |
| solx-core/src/process/worker.rs | Implements parent-side subprocess spawn + framed I/O. |
| solx-core/src/process/child.rs | Implements child-side long-lived worker loop and stack-too-deep handler response path. |
| solx-core/src/process/output.rs | Minor documentation cleanup for the output type. |
| solx-core/src/lib.rs | Updates exports to expose new pool/session/job and subprocess entrypoint. |
| solx-core/src/compiler.rs | Reorders initialization to avoid building rayon pool in worker subprocess mode. |
| solx-core/src/arguments.rs | Updates --recursive-process semantics/docs and argument validation expectations. |
| solx-codegen-evm/src/target_machine.rs | Ensures -evm-metadata-size is always passed to heal stale process-global LLVM options. |
| solx-codegen-evm/src/codegen/context/mod.rs | Adjusts size-fallback flag handling and propagates metadata sizing into fallback settings. |
2f00823 to
8276ba7
Compare
|
📊 solx Tester Report ➡️ Download |
|
📊 Hardhat Projects Report ➡️ Download |
Compile-time benchmark (
|
| binary | mean ± σ | min … max | vs baseline |
|---|---|---|---|
| pr | 2.983 s ± 0.020 s | 2.961 s … 3.000 s | 0.994× |
| main | 3.002 s ± 0.016 s | 2.985 s … 3.017 s | 1.000× |
| release | 6.353 s ± 0.092 s | 6.291 s … 6.459 s | 2.116× |
openzeppelin-contracts-0.34--solx-legacy-dwarf
| binary | mean ± σ | min … max | vs baseline |
|---|---|---|---|
| pr | 32.754 s ± 0.097 s | 32.645 s … 32.829 s | 0.956× |
| main | 34.258 s ± 0.058 s | 34.198 s … 34.313 s | 1.000× |
| release | 171.965 s ± 18.648 s | 159.021 s … 193.340 s | 5.020× |
uniswap-v4-core-solx--solx-legacy-dwarf
| binary | mean ± σ | min … max | vs baseline |
|---|---|---|---|
| pr | 17.161 s ± 0.110 s | 17.097 s … 17.288 s | 0.996× |
| main | 17.223 s ± 0.027 s | 17.193 s … 17.247 s | 1.000× |
| release | 48.218 s ± 0.811 s | 47.574 s … 49.129 s | 2.800× |
|
📊 Foundry Projects Report ➡️ Download |
|
Not my wheelhouse, so feel free to discard if irrelevant, but here's a drive-by Claude comment. Claude commentNon-blocking, follow-up material: Related, minor: a crashed worker now surfaces as "The worker closed the response channel" without the exit status the old |
Thanks, both sound reasonable, fixed! |
…xit status Return a worker to the idle pool after a per-unit compile error (Error::StandardJson) instead of dropping it on every Err, retiring it only on a transport failure (Error::Generic) or a StackTooDeep reply, after which the child exits. Reap a crashed worker on the closed-channel path and fold its exit status into the error, restoring the diagnosability of the former wait_with_output path. Add From<std::io::Error> for Error so the wait result converts cleanly. Addresses review feedback on #524.
The current tip 1ae05b1 is green and faster than main on both arm64 runners (runs 29374444399, 29399344551), but PR #545's 4h26m xlarge hang was observed at 6bd1976 — before b8469e0 (worker retention) and the 1ae05b1 solx-llvm reset-option-occurrences pin. Re-point the harness at the old tip: a watchdog trip here confirms the hang belonged to that intermediate state and is fixed since. Also adds an Intel path for later: solx-tester rebuilt with nightly to dodge the 1.96.1 enum-niche miscompile (#547), solx kept on the pinned stable, watchdog raised to 75 min for the slow Intel minis. free-disk-space-macos is carried over from main (predates #533 here).
Both arm64 legs are green and faster than main at 1ae05b1, and the old-tip hang is confirmed fixed (run 29401155956). The one untested platform is macOS x86-64, where the pinned 1.96.1 miscompiles the tester on any code (#547): rebuild just solx-tester with nightly (fixed LLVM), keep solx on the pinned stable, and run the suite under the 75-minute Intel watchdog.
macOS validation resultsFollowing up on the concern that persistent-worker changes can regress badly on macOS while looking fine on Linux: I ran this PR's tester suite on macOS runners via a debug harness (#546 — direct
Anatomy of the (already-fixed) hang
So the wedge is inside the top-level macOS x86-64Getting an Intel datapoint required a workaround: solx-tester itself is killed on any Intel mac — including on main — by an unrelated rustc 1.95/1.96 enum-niche miscompilation (#547; fix is the upcoming 1.97.1 toolchain bump). So the harness rebuilt just the tester with nightly rustc while keeping solx on the pinned stable. With that:
Other notes
#545 served its purpose as the first mac experiment and is superseded by the #546 harness; closing it. |
…nits Replace the fork/exec-per-unit model with a pool of persistent workers fed over pipes. The per-unit CBOR payload is split into a Session (project-wide data, sent once per worker) and a slim per-unit Job, and idle workers are held in a std Mutex<Vec> and reused. The process module is one entity per file: channel (the length-prefixed CBOR frame codec, via FrameRead/FrameWrite extension traits), session, job, output, pool (parent-side scheduler), worker (subprocess handle), and child (the subprocess loop). The child runs one long-lived stack-sized thread that owns the deserialized Session and compiles jobs until stdin closes, instead of spawning a thread and re-cloning the session per job. Worker stderr is inherited, so subprocess diagnostics stream straight to the parent. Because LLVM command-line options are process-global and now survive across units in one worker, always emit -evm-metadata-size to heal a stale value, and carry metadata_size into the size-fallback settings; reset IS_SIZE_FALLBACK per job. A worker is retired when its job set a spill-area size (occurrence- gated cl-option) or carried extra llvm_options, and never reused after a stack-too-deep response since its handler has already exited the process. When a reused worker dies mid-job, the job is retried once on a fresh worker so the death is not mis-attributed to the contract. Install the stack-error handler once per worker process instead of per unit, and build the rayon pool only in the parent, after the recursive-process branch, since workers compile a single unit and never use it.
Worker::execute now returns a plain crate::Result<EVMOutput> — a subprocess I/O failure folds into Error::Generic — so the ? drops a dead or errored worker and the pool is just pop-or-spawn, run, and return. This removes the retry, the WorkerError enum, dispatch, and worker_failed. Whether a worker may be reused lives on the data owners: Session::allows_worker_reuse (no extra llvm_options) and Job::allows_worker_reuse (no stack-too-deep spill area), each documenting the process-global cl-option it guards against. The cached reuse_workers field is removed. run_multi_pass_pipeline breaks the loop with its value instead of a declare-then-assign, and no longer threads the contract name.
Reset the process-global LLVM command-line option occurrences before every parse (new LLVMResetAllOptionOccurrences, exposed through llvm-sys and inkwell), so a translation unit never inherits an option a previous one set in the same persistent worker. This removes the whole option-leak layer: the -evm-metadata-size=0 heal is gone, and the pool reuses a worker unconditionally on success — Session/Job::allows_worker_reuse and the spill/llvm_options gating are deleted. Points solx-llvm, llvm-sys, and inkwell at their az-reset-option-occurrences branches. Byte-identical on the EVMLA smoke; CLI suite 397/397.
…xit status Return a worker to the idle pool after a per-unit compile error (Error::StandardJson) instead of dropping it on every Err, retiring it only on a transport failure (Error::Generic) or a StackTooDeep reply, after which the child exits. Reap a crashed worker on the closed-channel path and fold its exit status into the error, restoring the diagnosability of the former wait_with_output path. Add From<std::io::Error> for Error so the wait result converts cleanly. Addresses review feedback on #524.
Bump the solx-llvm submodule to main (bc069547), which merged the LLVMResetAllOptionOccurrences C API, and repoint the inkwell dependency off its feature branch onto the merged rev (c2ac839f); the transitive llvm-sys resolves to its merged rev (1aef1b28). Replaces the orphaned submodule pin (9537dbed) and the branch-tracked inkwell dep. Verified: solx links and compiles a multi-contract unit.
1ae05b1 to
7655308
Compare
Coverage Summary
|
solx-tester has no macOS CI coverage anywhere; on PR #545 it was signal-killed within seconds on macos-15-large and ran for hours on macos-15-xlarge, but with PR #524 included, so mac breakage and #524 regressions were indistinguishable. This branch is plain main, so its results are the macOS baseline. The workflow mirrors test.yaml's build-llvm/build-solc parameters to hit the finished-install caches saved on main, invokes solx-tester directly so a signal death is named instead of collapsing to "subprocess failed without exit code", and samples hung processes before killing them. Crash reports and samples are uploaded as artifacts.
Replaces the fork/exec-per-translation-unit subprocess model with a pool of persistent worker subprocesses fed over pipes, and slims the per-unit IPC payload. Previously every translation unit fork/exec'd the 55 MB LLVM-linked binary, CBOR-encoded the whole payload, and each child built an unused N-thread rayon pool.
Local results with the raw openzeppelin standard JSON (average of 5 runs):
solx-old)solx-new)