Repository navigation
Conversation
Every wasm-opt, wasm-merge, wasm-ctor-eval, and wasm-metadce subprocess spawned by fuzz_opt.py initializes a Binaryen ThreadPool sized to hardware_concurrency() (e.g. 128 threads on a 128-core workstation) and synchronizes across condition variables for each function-parallel pass. On small fuzzer modules, thread creation and barrier synchronization dominate execution time and cause severe contention when running multiple fuzzer workers in parallel. Default BINARYEN_CORES to 4 so parallel pass execution is still exercised without spawning a thread per hardware core in every subprocess. Steady-state throughput increases by +97.7% (1.37 -> 2.71 iters/s, 1.98x) for a single worker and +186.4% (25.77 -> 73.78 iters/s, 2.86x) across 32 parallel workers on a 128-thread machine.
There was a problem hiding this comment.
Code Review
This pull request updates scripts/fuzz_opt.py to set a default value of '4' for the BINARYEN_CORES environment variable. This limits the default thread pool size per subprocess, ensuring parallel passes are exercised without spawning a thread for every hardware core on each invocation. There are no review comments to address, and I have no additional feedback to provide.
|
Does this assume we always run the fuzzer in parallel? If we don't do that, is this decreasing the throughput? But it looks it also increased it for a single worker. Why is that? |
|
Yes, this seems like it will slow down single-process |
|
Ah, no, as the commit message says, this gets almost 2x improvement even for a single worker (at least on my 128-core machine). For the relatively small modules produced by the fuzzer, the contention from just setting up and synchronizing the worker pool dominates. |
|
Oh, wow, just setting up the pool? I'm surprised that is so slow. Do you have absolute measurements on that? If creating threads is so slow, perhaps we should create them on demand...? But I've not heard of thread pools doing that iirc |
Binaryen ThreadPool Timing & Overhead ReportDirect empirical measurements were gathered using Two benchmarks were evaluated:
1. Single-Worker
|
| Metric | BINARYEN_CORES=128 (Default) |
BINARYEN_CORES=4 |
BINARYEN_CORES=1 |
|---|---|---|---|
| Total Worker Wall Time | 38.05s (1.58 iters/s) | 19.51s (3.08 iters/s, 1.95x) | 21.33s (2.81 iters/s) |
ThreadPool::initialize Mean |
9.24 ms | 0.44 ms (21x faster) | 0.00 ms |
- Spawn Loop (pthread_create) |
7.73 ms | 0.32 ms | 0.00 ms |
- Wait Ready (areThreadsReady) |
1.51 ms | 0.12 ms | 0.00 ms |
| Cumulative Setup Time | 5.76s (21.5% of C++ proc) | 0.27s (2.7% of C++ proc) | 0.00s (0.0%) |
| Per-Barrier Overhead Mean | 1.69 ms / call | 0.067 ms / call (25x faster) | 0.00 ms |
| Cumulative Barrier Overhead | 8.28s (30.9% of C++ proc) | 0.33s (3.2% of C++ proc) | 0.00s (0.0%) |
| Total ThreadPool Overhead (Init + Barrier) | 14.04s (52.3% of C++ proc) | 0.61s (5.9% of C++ proc) | 0.00s (0.0%) |
Active Pass Work (doWork max) |
4.19s (15.6%) | 1.76s (17.2%) | 4.77s (38.9%) |
| Total C++ Subprocess Time | 26.82s (100.0%) | 10.21s (100.0%) | 12.27s (100.0%) |
2. Direct wasm-opt a.wasm -O3 Microbenchmark (50 Runs, 47 Barriers/Run)
| Metric | BINARYEN_CORES=128 |
BINARYEN_CORES=4 |
BINARYEN_CORES=1 |
|---|---|---|---|
| Wall Time per Run | 142.3 ms / run | 68.1 ms / run (2.09x faster) | 140.5 ms / run |
ThreadPool::initialize Setup |
9.39 ms (7.2% of proc) | 0.54 ms (0.9% of proc) | 0.00 ms |
ThreadPool::work Barrier Overhead |
81.47 ms (62.4% of proc) | 4.33 ms (7.3% of proc) | 0.00 ms |
| - Mean Barrier Overhead / Pass | 1.73 ms / call | 0.092 ms / call (19x faster) | 0.00 ms / call |
| Total ThreadPool Overhead | 90.86 ms (69.6% of proc) | 4.86 ms (8.2% of proc) | 0.00 ms (0.0%) |
Active Pass Work (doWork max) |
21.58 ms | 38.59 ms | 120.01 ms |
| Total C++ Process Lifetime | 130.5 ms | 59.6 ms | 132.2 ms |
3. Key Findings
ThreadPool::initializeis expensive (~9.2 ms per subprocess): Spawning 128 OS threads insideThreadPool::initializetakes 7.7 ms ofpthread_createcalls holdingthreadMutex, plus 1.5 ms waiting oncondition.waitfor all 128 threads to wake and report ready. In the fuzzer, where median subprocess execution is under 10 ms, pool creation alone accounts for 21.5% of all C++ execution time.- Per-pass barrier synchronization costs an additional ~1.69 ms per call: Every parallel pass (
ThreadPool::work()) wakes 128 threads and waits for all 128 to lockthreadMutexinnotifyThreadIsReady(). This adds 8.28s (30.9%) across 60 fuzzer iterations. - Combined ThreadPool overhead accounts for >52% of C++ runtime: Total pool overhead (setup + barrier contention) is 14.04s out of 26.82s (52.3%) with 128 cores. Switching to
BINARYEN_CORES=4cuts this overhead to 0.61s (5.9%)—a 23x reduction. - Why
BINARYEN_CORES=4beatsBINARYEN_CORES=1: 4 cores reduces overhead to under 1 ms per process while speeding up active pass execution by 2.7x (1.76svs4.77sindoWork), makingBINARYEN_CORES=4faster than both 128 cores and 1 core.
Every wasm-opt, wasm-merge, wasm-ctor-eval, and wasm-metadce subprocess spawned by fuzz_opt.py initializes a Binaryen ThreadPool sized to hardware_concurrency() (e.g. 128 threads on a 128-core workstation) and synchronizes across condition variables for each function-parallel pass. On small fuzzer modules, thread creation and barrier synchronization dominate execution time and cause severe contention when running multiple fuzzer workers in parallel. Default BINARYEN_CORES to 4 so parallel pass execution is still exercised without spawning a thread per hardware core in every subprocess.
Steady-state throughput increases by +97.7% (1.37 -> 2.71 iters/s, 1.98x) for a single worker and +186.4% (25.77 -> 73.78 iters/s, 2.86x) across 32 parallel workers on a 128-thread machine.