Parallelizing a Loop Across Workers with Shared Memory
This page answers one task: a WebAssembly function loops over a large array — filtering pixels, transforming points, summing values — and takes too long on one thread. You want to run the loop on several workers at once, all reading and writing the same shared memory, without copying the array to each worker.
Prerequisites
- [ ] A cross-origin isolated page so
SharedArrayBufferand sharedWebAssembly.Memoryare available. - [ ] A module that imports its memory and was built with shared-memory support (atomics and bulk-memory features).
- [ ] A loop whose iterations are independent, or nearly so.
The shape of a parallel loop
A loop parallelises well when each iteration touches different data: processing pixel i does not depend on pixel j. The plan is to split the index range into chunks, let each worker process its chunks on the same shared memory, and wait until all are done. Because the memory is shared, the input is read in place and results are written in place — no copies. What must be coordinated is small: which chunks each worker takes, and when everyone has finished.
There are two ways to assign chunks. Static partitioning gives each of N workers one contiguous range of 1/N of the data — simple, with no coordination during the run, but uneven if some ranges are more expensive. Dynamic scheduling keeps a shared counter of the next chunk; each worker atomically takes the next chunk until none remain — slightly more coordination, but it balances uneven work automatically.
Step 1 — create the shared memory and start the workers
// main.js
const memory = new WebAssembly.Memory({ initial: 512, maximum: 4096, shared: true });
const module = await WebAssembly.compileStreaming(fetch("/filter.wasm"));
const N = Math.min(navigator.hardwareConcurrency - 1, 8);
const control = new Int32Array(new SharedArrayBuffer(16)); // [nextChunk, done, generation, _]
const workers = Array.from({ length: N }, () => {
const w = new Worker(new URL("./filter-worker.js", import.meta.url), { type: "module" });
w.postMessage({ module, memory, control, n: N }); // Module and shared memory are shareable
return w;
});
Every worker instantiates the same compiled module with the same memory. For modules with their own stack and thread-local storage (Rust or C built
with threads), each instance must also be given its own stack region; toolchain thread support (wasm-bindgen-rayon, Emscripten pthreads) handles that,
and for hand-built pools the module needs a per-thread initialisation export.
Step 2 — process chunks in each worker
// filter-worker.js
let inst, control, n;
self.onmessage = async ({ data }) => {
control = data.control; n = data.n;
inst = await WebAssembly.instantiate(data.module, { env: { memory: data.memory } });
inst.exports.thread_init?.();
for (let gen = 1; ; gen++) {
Atomics.wait(control, 2, gen - 1); // sleep until a new job generation
const { ptr, len, chunk } = readJob(); // job parameters in shared memory
for (;;) {
const c = Atomics.add(control, 0, 1); // take the next chunk
const start = c * chunk;
if (start >= len) break;
inst.exports.filter_range(ptr, start, Math.min(start + chunk, len));
}
if (Atomics.add(control, 1, 1) + 1 === n) Atomics.notify(control, 1); // last one out
}
};
filter_range is the original loop body restricted to a range. Workers stay alive between jobs, sleeping on the generation word, so starting a job costs
a notify rather than a worker startup.
Step 3 — start a job and wait for completion
async function runFilter(ptr, len, chunk = 16384) {
writeJob({ ptr, len, chunk });
Atomics.store(control, 0, 0); // next chunk
Atomics.store(control, 1, 0); // done count
Atomics.add(control, 2, 1); // new generation
Atomics.notify(control, 2); // wake all workers
while (Atomics.load(control, 1) < N) {
const r = Atomics.waitAsync(control, 1, Atomics.load(control, 1));
if (r.async) await r.value; // main thread must not block
}
}
The main thread waits with Atomics.waitAsync, because blocking Atomics.wait is not allowed there. Alternatively, run the coordinator itself in a
worker and post one message to the main thread when done.
Step 4 — combine per-worker results for reductions
Loops that produce one value — a sum, a histogram, a maximum — need a reduction. Do not have every worker update one shared accumulator with atomics per element; contention destroys the speed-up. Give each worker its own accumulator slot (or its own histogram) in shared memory, and combine them after all workers finish:
#[no_mangle]
pub unsafe extern "C" fn histogram_range(pixels: *const u8, start: usize, end: usize, out: *mut u32) {
let local = core::slice::from_raw_parts_mut(out, 256); // this worker's private histogram
for i in start..end { local[*pixels.add(i) as usize] += 1; }
}
Pad per-worker slots to separate cache lines (64 bytes) so workers do not slow each other through false sharing.
Step 5 — choose chunk size and worker count
Chunks too small spend time on coordination; chunks too large leave workers idle at the end while one finishes a big chunk. Start with chunks that take
around 0.1–1 ms each and measure. For worker count, hardwareConcurrency - 1 leaves a core for the main thread; on phones, efficiency cores may make fewer
workers faster. Always compare against the single-threaded version: speed-up is limited by memory bandwidth for simple per-element loops, and by the
serial parts of the program (Amdahl’s law) for everything else.
Memory growth with shared memory
Shared memories can grow, but views created in other threads do not update automatically: after memory.grow, each thread’s memory.buffer reflects the
new size only when re-read, and typed arrays created before must be recreated. Grow memory before starting a parallel job, not during it, so workers never
race against growth. Size buffers for the largest input up front where possible.
When to use a library instead
Hand-built pools are educational and sometimes necessary, but toolchains offer ready ones: wasm-bindgen-rayon gives Rust code par_iter() over a
worker pool with shared memory, and Emscripten pthreads plus OpenMP-style loops (via -fopenmp with a supporting runtime, or manual pthreads) cover C and
C++. They handle per-thread stacks, thread-local storage and initialisation, which are the error-prone parts. See
building Rust Wasm with threads using rayon.
Loops with dependencies between iterations
Not every loop is embarrassingly parallel. Prefix sums, running filters and dynamic programming tables carry values from one iteration to the next, and splitting them naively gives wrong answers. Many such loops still parallelise with a two-pass approach: each worker processes its range independently and records a summary — the range’s total for a prefix sum, its final state for a recurrence — then a short serial step combines the summaries, and a second parallel pass applies each range’s offset. Image filters that read neighbouring pixels need overlap: each worker reads a few rows beyond its range (the “halo”) but writes only its own rows, with input and output in separate buffers so reads never see partially written results. Recognising which pattern a loop follows is most of the work; the mechanics of chunks and counters stay the same.
Debugging a parallel loop
When the parallel version produces different results from the serial one, run it with a single worker first — if it still differs, the bug is in the range logic (off-by-one chunk boundaries are the usual culprit), not in concurrency. Then run with two workers and a fixed static partition, which makes failures reproducible. Compare outputs element by element and print the first differing index; its position relative to chunk boundaries usually reveals the problem immediately. Keep the serial path in the code permanently, behind a flag, as the reference implementation for tests.
Expected output
A 24-megapixel filter that took 220 ms on one thread completes in 68 ms on six workers; workers sleep between jobs and start a new job within 0.1 ms; the histogram reduction uses per-worker slots with no atomic contention; and results are bit-identical to the single-threaded version.
Gotchas
- Blocking the main thread while waiting. Use
Atomics.waitAsyncor coordinate from a worker. - One shared accumulator. Contention kills speed-up. Use per-worker slots.
- False sharing. Adjacent slots on one cache line slow everyone. Pad to 64 bytes.
- Growing memory mid-job. Views go stale. Grow before starting.
- Too many workers. Efficiency cores and memory bandwidth limit gains. Measure.
Performance note
On an 8-core laptop, the filter scaled to 3.2× with six workers; a memory-bound copy loop scaled to only 1.6×, limited by memory bandwidth rather than CPU.
Frequently Asked Questions
Can I parallelise without shared memory? Yes, by copying chunks to workers with messages; it costs copies but needs no isolation headers.
Does every worker need its own module instance? Yes — instances are per thread; they share memory, not instances.
Are results deterministic? For independent elements, yes; floating-point reductions combined in different orders can differ slightly.
What about Node?
worker_threads share SharedArrayBuffer and memories without isolation headers.
Why does my parallel result differ from the serial one? Check chunk boundaries with one worker first; then look for loop-carried dependencies or shared accumulators.
Related
- Building a Wasm thread pool — the pool itself.
- Sharing memory between Wasm and Web Workers — the setup.
- Implementing a mutex with Atomics — when chunks are not independent.
- Benchmarking memory bandwidth in Wasm — the limit on speed-up.
← Back to SharedArrayBuffer, Atomics & Threading