Skip to content

[Fuzzing] Default BINARYEN_CORES=4 in fuzz_opt.py - #9304

Open
tlively wants to merge 1 commit into
mainfrom
fuzzer-throughput-cores
Open

tlively wants to merge 1 commit into
mainfrom
fuzzer-throughput-cores

Conversation

@tlively

@tlively tlively commented Oct 24, 2026

Copy link
Copy Markdown
Member

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.

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.
@tlively
tlively requested a review from a team as a code owner October 24, 2026 02:02
@tlively
tlively requested review from aheejin and removed request for a team October 24, 2026 02:02

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@aheejin

aheejin commented Oct 26, 2026 •

Copy link
Copy Markdown
Member

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?

@kripken

kripken commented Oct 26, 2026

Copy link
Copy Markdown
Member

Yes, this seems like it will slow down single-process ./scripts/fuzz_opt.py usage as @aheejin says? Can we set this from the wrapper script?

@tlively

tlively commented Oct 26, 2026

Copy link
Copy Markdown
Member Author

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.

@kripken

kripken commented Oct 26, 2026 •

Copy link
Copy Markdown
Member

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

@tlively

tlively commented Oct 26, 2026

Copy link
Copy Markdown
Member Author

Binaryen ThreadPool Timing & Overhead Report

Direct empirical measurements were gathered using Timer from src/support/timing.h to quantify thread pool overhead in scripts/fuzz_opt.py on a 128-core workstation host.

Two benchmarks were evaluated:

  1. Single-Worker fuzz_opt.py Steady-State Run: 60 identical fuzz iterations, logging timing across all 623 Binaryen tool subprocesses (wasm-opt, wasm-merge, wasm-ctor-eval, wasm-metadce).
  2. Direct wasm-opt -O3 Microbenchmark: 50 runs on a 40 KB module (47 parallel passes per run).

1. Single-Worker fuzz_opt.py (60 Iterations, 623 Tool Subprocesses)

Across 60 iterations, each subprocess executes an average of 7.9 ThreadPool::work() parallel passes (4,914 total parallel barriers):

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

  1. ThreadPool::initialize is expensive (~9.2 ms per subprocess): Spawning 128 OS threads inside ThreadPool::initialize takes 7.7 ms of pthread_create calls holding threadMutex, plus 1.5 ms waiting on condition.wait for 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.
  2. 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 lock threadMutex in notifyThreadIsReady(). This adds 8.28s (30.9%) across 60 fuzzer iterations.
  3. 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=4 cuts this overhead to 0.61s (5.9%)—a 23x reduction.
  4. Why BINARYEN_CORES=4 beats BINARYEN_CORES=1: 4 cores reduces overhead to under 1 ms per process while speeding up active pass execution by 2.7x (1.76s vs 4.77s in doWork), making BINARYEN_CORES=4 faster than both 128 cores and 1 core.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants