Skip to content

Commit b7084ca

Browse files
committed
Experiment with a new worker pool bench
1 parent 17380a4 commit b7084ca

2 files changed

Lines changed: 160 additions & 0 deletions

File tree

bench/js-transpiler/README.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,37 @@ python3 -m http.server # from the repo root
5050

5151
Serve from the repo root (not `file://`): the page fetches `/src/blosc2/dsl_js.py` (the
5252
local transpiler, newer than the PyPI wheel) at a server-root-absolute path.
53+
54+
## Multithreading ceiling — `worker-pool-bench.mjs`
55+
56+
Throwaway exploration of *how fast the transpiled kernel could go* with real JS
57+
multithreading: pure Node (`worker_threads` + `SharedArrayBuffer`), **no Pyodide, no
58+
blosc2**. Same Newton kernel, partitioned across a persistent worker pool with an Atomics
59+
barrier; reports speedup vs single-thread for 1/2/4/N workers.
60+
61+
```sh
62+
node bench/js-transpiler/worker-pool-bench.mjs
63+
```
64+
65+
Findings (Apple M2, 4 performance + 4 efficiency cores; laptop numbers vary ±10–15% with
66+
thermal/P-vs-E scheduling, so treat these as representative, not exact):
67+
68+
| workers | ms/frame | speedup |
69+
|---|---|---|
70+
| single-thread | ~11.3 | 1.0× |
71+
| ×2 | ~5.9 | ~1.9× (~94% eff) |
72+
| ×4 | ~3.1 | ~3.5× (~88% eff) |
73+
| ×8 | ~2.3 | ~4.8× (~60% eff — E-cores) |
74+
75+
- The worker mechanism is ~free (×1 ≈ 1.0×); scaling is near-linear up to the performance
76+
core count. The ×8 drop-off is the M2's efficiency cores, not overhead.
77+
- **Load balancing is essential.** Contiguous row-bands regress badly (×4 fell to 1.48×)
78+
because the per-pixel early-`break` makes some bands all-max-iter and others trivial.
79+
**Striped** rows (worker `i` → rows `i, i+nw, …`) fix it — that's what the bench uses.
80+
81+
Why this stays a *headroom* result, not a shipped feature: it measures **pure compute**.
82+
The real `jit_backend="js"` path also pays ~8 ms/frame of blosc2 decompress/compress that
83+
does not parallelize this way, plus Pyodide orchestration is single-threaded — so realistic
84+
end-to-end gain is a fraction of 5×. And a browser integration needs pure-JS workers (not the
85+
Pyodide bridge), a SharedArrayBuffer, COOP/COEP cross-origin isolation, and a path to get
86+
decompressed chunks into shared memory. See "Deferred" in [`plans/dsl-js.md`](../../plans/dsl-js.md).
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
// Throwaway: how fast can the transpiled Newton kernel go with real JS multithreading?
2+
// Pure Node (worker_threads + SharedArrayBuffer), no Pyodide, no blosc2 -- isolates the
3+
// compute ceiling and per-dispatch overhead a Web Worker pool would hit in a browser.
4+
// The kernel is the same scalar loop dsl_js emits; hand-written here to avoid Pyodide.
5+
//
6+
// node bench/js-transpiler/worker-pool-bench.mjs
7+
import { Worker, isMainThread, workerData } from "node:worker_threads";
8+
import os from "node:os";
9+
import { performance } from "node:perf_hooks";
10+
11+
const WIDTH = 320, HEIGHT = 213, MAXITER = 48, NFRAMES = 24, SPANX = 3.4;
12+
const ASPECT = HEIGHT / WIDTH;
13+
const N = WIDTH * HEIGHT;
14+
15+
// Striped rows (rowStart, rowStep): worker i does rows i, i+nw, ... so the per-pixel
16+
// early-exit work spreads evenly across workers instead of clumping in contiguous bands.
17+
function newtonBand(A, B, OUT, rowStart, rowStep, H, W, maxIter, relax) {
18+
for (let row = rowStart; row < H; row += rowStep) {
19+
for (let col = 0; col < W; col++) {
20+
const i = row * W + col;
21+
let za = A[i], zb = B[i], it = maxIter;
22+
for (let k = 0; k < maxIter; k++) {
23+
const a2 = za * za, b2 = zb * zb;
24+
const fr = za * a2 - 3 * za * b2 - 1, fi = 3 * a2 * zb - zb * b2;
25+
const dr = 3 * (a2 - b2), di = 6 * za * zb, den = dr * dr + di * di + 1e-12;
26+
const qr = relax * (fr * dr + fi * di) / den, qi = relax * (fi * dr - fr * di) / den;
27+
za -= qr; zb -= qi;
28+
if (qr * qr + qi * qi < 1e-6) { it = k; break; }
29+
}
30+
const d0 = (za - 1) * (za - 1) + zb * zb;
31+
const d1 = (za + 0.5) * (za + 0.5) + (zb - 0.8660254) * (zb - 0.8660254);
32+
const d2 = (za + 0.5) * (za + 0.5) + (zb + 0.8660254) * (zb + 0.8660254);
33+
let root = 0, md = d0;
34+
if (d1 < md) { md = d1; root = 1; }
35+
if (d2 < md) { root = 2; }
36+
OUT[i] = root + 0.9 * (it / maxIter);
37+
}
38+
}
39+
}
40+
41+
// ctrl: Int32[ gen, done ]. params: Float64[ relax, maxIter ].
42+
if (!isMainThread) {
43+
const { ctrlSab, paramsSab, aSab, bSab, outSab, rowStart, rowStep, W } = workerData;
44+
const ctrl = new Int32Array(ctrlSab), params = new Float64Array(paramsSab);
45+
const A = new Float64Array(aSab), B = new Float64Array(bSab), OUT = new Float64Array(outSab);
46+
let gen = 0;
47+
for (;;) {
48+
Atomics.wait(ctrl, 0, gen); // block until main bumps the generation
49+
gen = Atomics.load(ctrl, 0);
50+
if (gen < 0) break; // shutdown
51+
newtonBand(A, B, OUT, rowStart, rowStep, HEIGHT, W, params[1] | 0, params[0]);
52+
Atomics.add(ctrl, 1, 1); // signal this band done
53+
Atomics.notify(ctrl, 1);
54+
}
55+
} else {
56+
main();
57+
}
58+
59+
function buildGrid() {
60+
const aSab = new SharedArrayBuffer(N * 8), bSab = new SharedArrayBuffer(N * 8),
61+
outSab = new SharedArrayBuffer(N * 8);
62+
const A = new Float64Array(aSab), B = new Float64Array(bSab);
63+
const x0 = -SPANX / 2, dx = SPANX / (WIDTH - 1);
64+
const y0 = -SPANX * ASPECT / 2, dy = SPANX * ASPECT / (HEIGHT - 1);
65+
for (let r = 0; r < HEIGHT; r++)
66+
for (let c = 0; c < WIDTH; c++) { A[r * WIDTH + c] = x0 + dx * c; B[r * WIDTH + c] = y0 + dy * r; }
67+
return { aSab, bSab, outSab };
68+
}
69+
70+
function timeBest(fn, runs) {
71+
for (let w = 0; w < 2; w++) fn(); // warm V8 / workers
72+
let best = Infinity;
73+
for (let r = 0; r < runs; r++) { const t = performance.now(); fn(); best = Math.min(best, performance.now() - t); }
74+
return best;
75+
}
76+
77+
async function benchPool(nw, sabs, relaxes) {
78+
const ctrlSab = new SharedArrayBuffer(8), paramsSab = new SharedArrayBuffer(16);
79+
const ctrl = new Int32Array(ctrlSab), params = new Float64Array(paramsSab);
80+
params[1] = MAXITER;
81+
const workers = [];
82+
for (let i = 0; i < nw; i++) {
83+
workers.push(new Worker(new URL(import.meta.url), {
84+
workerData: { ...sabs, ctrlSab, paramsSab, rowStart: i, rowStep: nw, W: WIDTH },
85+
}));
86+
}
87+
const frame = (relax) => {
88+
Atomics.store(ctrl, 1, 0);
89+
params[0] = relax;
90+
Atomics.add(ctrl, 0, 1);
91+
Atomics.notify(ctrl, 0, nw);
92+
let d; // barrier: wait until all bands reported done
93+
while ((d = Atomics.load(ctrl, 1)) < nw) Atomics.wait(ctrl, 1, d);
94+
};
95+
const sweep = () => { for (const rx of relaxes) frame(rx); };
96+
const best = timeBest(sweep, 5);
97+
Atomics.store(ctrl, 0, -1); Atomics.notify(ctrl, 0, nw); // shutdown
98+
await Promise.all(workers.map((w) => w.terminate()));
99+
return best;
100+
}
101+
102+
async function main() {
103+
const cores = os.cpus().length;
104+
const sabs = buildGrid();
105+
const OUT = new Float64Array(sabs.outSab);
106+
const relaxes = Array.from({ length: NFRAMES }, (_, i) => 1.0 + (1.85 - 1.0) * i / (NFRAMES - 1));
107+
108+
// Single-thread baseline on the main thread (no worker overhead at all).
109+
const A = new Float64Array(sabs.aSab), B = new Float64Array(sabs.bSab);
110+
const tSingle = timeBest(() => { for (const rx of relaxes) newtonBand(A, B, OUT, 0, 1, HEIGHT, WIDTH, MAXITER, rx); }, 5);
111+
const ref = Float64Array.from(OUT); // last frame (relax=1.85), for correctness check
112+
113+
console.log(`Newton ${WIDTH}x${HEIGHT}, max_iter=${MAXITER}, ${NFRAMES}-frame sweep | cores=${cores}`);
114+
const per = (ms) => `${ms.toFixed(0)} ms total (${(ms / NFRAMES).toFixed(2)} ms/frame)`;
115+
console.log(`\nsingle-thread (main): ${per(tSingle)}`);
116+
117+
const counts = [...new Set([1, 2, 4, cores])].filter((n) => n >= 1 && n <= cores * 2).sort((a, b) => a - b);
118+
for (const nw of counts) {
119+
const t = await benchPool(nw, sabs, relaxes);
120+
let maxdiff = 0;
121+
for (let i = 0; i < N; i++) maxdiff = Math.max(maxdiff, Math.abs(OUT[i] - ref[i]));
122+
const sp = tSingle / t;
123+
console.log(`pool x${String(nw).padStart(2)} : ${per(t)} | speedup ${sp.toFixed(2)}x` +
124+
` eff ${(100 * sp / nw).toFixed(0)}% | maxdiff ${maxdiff.toExponential(1)}`);
125+
}
126+
}

0 commit comments

Comments
 (0)