nandi/vflutter_ffipublic Fork 0
4506a58
Commits
Clone
git clone https://git.rickub.com/nandi/vflutter_ffi.git
git clone ssh://git@rickub.com/nandi/vflutter_ffi.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Render in parallel on web, via a pool of Web Workers

The web backend split the frame into bands and then rendered them one after
another, because the emscripten module is single-threaded. The usual fix is
-pthread, which needs SharedArrayBuffer, which needs COOP/COEP headers on
whatever serves the app — a real constraint to impose on every consumer.

So instead of threading one module, this runs several instances of it: one per
Web Worker, each with its own linear memory. Nothing is shared, so there is
nothing to synchronise and no headers are required. A module is ~34 KB, which
makes N copies cheap.

Bands are handed out from a queue as workers free up rather than dealt evenly
up front — band costs are wildly uneven (interior rows run the full iteration
cap), so a static split leaves cores idle waiting on the slowest one. Same
reason the native side wants more bands than cores.

Measured in headless Chrome, 8 cores, 800x600 at 500 iterations:
one worker 164 ms, 32 bands over 8 workers 37 ms — 4.5x, and up to 6.3x on a
quieter run.

The pool is JavaScript rather than Dart because dart:js_interop has no
ergonomic Worker binding; Dart sees one promise-returning function.

tool/check_pool.sh (just check-pool, now in just ci) guards both halves: that
it is really parallel, and that the assembled frame is byte-identical to a
single-instance render. A pool that silently falls back to one thread looks
exactly like a working one apart from the clock, and one that assembles bands
out of order produces a plausible but wrong frame.

supportsIsolateParallelism is now true on web, so the UI stops calling the
split sequential; parallelUnitName lets each backend name its own unit, and
the slider reads "workers" on web and "isolates" on native.

Verified: just ci green — 24 tests, wasm smoke checks, native/wasm byte
parity, the pool check, a release web build and a frame drawn in Chrome.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandithebull committed 2026-09-18T19:06:51-07:00 Browse files
4506a58 parent: 472eb49
modified README.md +20 -4
@@ -144,13 +144,29 @@ too, ~2-5% off native for the same frame:
144144 800x600 maxIter 500: 148.8 ms native / 157.2 ms wasm
145145 ```
146146
147-Two differences are real and surfaced in the API:
147+Web renders in parallel too, without `SharedArrayBuffer` or COOP/COEP
148+headers: instead of threading one module, the pool runs *several instances* of
149+it, one per Web Worker, each with its own linear memory. Nothing is shared, so
150+there is nothing to synchronise, and a module is only ~34 KB. Measured in
151+headless Chrome on 8 cores, 800x600 at 500 iterations:
152+
153+```
154+main thread, one band 345 ms
155+1 worker 164 ms
156+32 bands over 8 workers 37 ms (4.5x)
157+```
158+
159+More bands than workers, for the same reason as native: band costs are wildly
160+uneven, so over-decomposing keeps a free worker busy rather than waiting on
161+the slowest one. `just check-pool` re-measures this and asserts the pooled
162+frame is byte-identical to a single-instance render — a pool that quietly
163+falls back to one thread, or assembles bands out of order, otherwise looks
164+just like a working one.
165+
166+One difference remains, and it is surfaced in the API:
148167
149168 * `await v.initialize()` before anything else. A wasm module cannot be
150169 instantiated synchronously. On native it resolves immediately.
151-* `v.supportsIsolateParallelism` is false on web. There are no isolates, so
152- `renderParallel` still splits the frame and still produces identical pixels,
153- but the bands run in sequence.
154170
155171 ### One trap worth knowing about
156172
@@ -144,13 +144,29 @@ too, ~2-5% off native for the same frame:
144 800x600 maxIter 500: 148.8 ms native / 157.2 ms wasm144 800x600 maxIter 500: 148.8 ms native / 157.2 ms wasm
145 ```145 ```
146 146
147-Two differences are real and surfaced in the API:147+Web renders in parallel too, without `SharedArrayBuffer` or COOP/COEP
148+headers: instead of threading one module, the pool runs *several instances* of
149+it, one per Web Worker, each with its own linear memory. Nothing is shared, so
150+there is nothing to synchronise, and a module is only ~34 KB. Measured in
151+headless Chrome on 8 cores, 800x600 at 500 iterations:
152+
153+```
154+main thread, one band 345 ms
155+1 worker 164 ms
156+32 bands over 8 workers 37 ms (4.5x)
157+```
158+
159+More bands than workers, for the same reason as native: band costs are wildly
160+uneven, so over-decomposing keeps a free worker busy rather than waiting on
161+the slowest one. `just check-pool` re-measures this and asserts the pooled
162+frame is byte-identical to a single-instance render — a pool that quietly
163+falls back to one thread, or assembles bands out of order, otherwise looks
164+just like a working one.
165+
166+One difference remains, and it is surfaced in the API:
148 167
149 * `await v.initialize()` before anything else. A wasm module cannot be168 * `await v.initialize()` before anything else. A wasm module cannot be
150 instantiated synchronously. On native it resolves immediately.169 instantiated synchronously. On native it resolves immediately.
151-* `v.supportsIsolateParallelism` is false on web. There are no isolates, so
152- `renderParallel` still splits the frame and still produces identical pixels,
153- but the bands run in sequence.
154 170
155 ### One trap worth knowing about171 ### One trap worth knowing about
156 172
modified example/lib/main.dart +9 -7
@@ -65,7 +65,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
6565
6666 /// Bands run on isolates natively and sequentially on web, so the word the
6767 /// UI uses for them has to follow the backend.
68- String get _unit => v.supportsIsolateParallelism ? 'isolate' : 'band';
68+ String get _unit =>
69+ v.supportsIsolateParallelism ? v.parallelUnitName : 'band';
6970
7071 @override
7172 void initState() {
@@ -426,18 +427,19 @@ class _ExplorerPageState extends State<ExplorerPage> {
426427 child: Padding(
427428 padding: const EdgeInsets.all(14),
428429 child: Text(
429- v.supportsIsolateParallelism
430+ v.backendName == 'dart:ffi'
430431 ? 'The Nim kernel fills a buffer Dart allocates, so no Nim '
431432 'memory crosses the boundary and there is nothing to '
432433 'free. Bands are independent, which is what makes the '
433- 'isolate slider safe: -gc none means no stop-the-world '
434- 'phase to serialise them.'
434+ 'isolate slider safe: ARC has no stop-the-world phase '
435+ 'to serialise them.'
435436 : 'The same Nim kernel, compiled to WebAssembly and reached '
436437 'over dart:js_interop instead of dart:ffi — it fills a '
437438 'buffer in the wasm heap, so nothing needs freeing on '
438- 'the Dart side. The module is single-threaded, so the '
439- 'bands here run in sequence; they still produce the '
440- 'identical frame, byte for byte, as the native build.',
439+ 'the Dart side. Each Web Worker holds its own instance '
440+ 'of the module, so nothing is shared and no '
441+ 'SharedArrayBuffer or COOP/COEP headers are needed. The '
442+ 'frame is identical to the native build, byte for byte.',
441443 style: theme.textTheme.bodySmall,
442444 ),
443445 ),
@@ -65,7 +65,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
65 65
66 /// Bands run on isolates natively and sequentially on web, so the word the66 /// Bands run on isolates natively and sequentially on web, so the word the
67 /// UI uses for them has to follow the backend.67 /// UI uses for them has to follow the backend.
68- String get _unit => v.supportsIsolateParallelism ? 'isolate' : 'band';68+ String get _unit =>
69+ v.supportsIsolateParallelism ? v.parallelUnitName : 'band';
69 70
70 @override71 @override
71 void initState() {72 void initState() {
@@ -426,18 +427,19 @@ class _ExplorerPageState extends State<ExplorerPage> {
426 child: Padding(427 child: Padding(
427 padding: const EdgeInsets.all(14),428 padding: const EdgeInsets.all(14),
428 child: Text(429 child: Text(
429- v.supportsIsolateParallelism430+ v.backendName == 'dart:ffi'
430 ? 'The Nim kernel fills a buffer Dart allocates, so no Nim '431 ? 'The Nim kernel fills a buffer Dart allocates, so no Nim '
431 'memory crosses the boundary and there is nothing to '432 'memory crosses the boundary and there is nothing to '
432 'free. Bands are independent, which is what makes the '433 'free. Bands are independent, which is what makes the '
433- 'isolate slider safe: -gc none means no stop-the-world '434+ 'isolate slider safe: ARC has no stop-the-world phase '
434- 'phase to serialise them.'435+ 'to serialise them.'
435 : 'The same Nim kernel, compiled to WebAssembly and reached '436 : 'The same Nim kernel, compiled to WebAssembly and reached '
436 'over dart:js_interop instead of dart:ffi — it fills a '437 'over dart:js_interop instead of dart:ffi — it fills a '
437 'buffer in the wasm heap, so nothing needs freeing on '438 'buffer in the wasm heap, so nothing needs freeing on '
438- 'the Dart side. The module is single-threaded, so the '439+ 'the Dart side. Each Web Worker holds its own instance '
439- 'bands here run in sequence; they still produce the '440+ 'of the module, so nothing is shared and no '
440- 'identical frame, byte for byte, as the native build.',441+ 'SharedArrayBuffer or COOP/COEP headers are needed. The '
442+ 'frame is identical to the native build, byte for byte.',
441 style: theme.textTheme.bodySmall,443 style: theme.textTheme.bodySmall,
442 ),444 ),
443 ),445 ),
modified example/web/index.html +1 -0
@@ -44,6 +44,7 @@
4444 essentially nothing up front.
4545 -->
4646 <script src="nimflutter.js"></script>
47+ <script src="nimflutter_pool.js"></script>
4748
4849 <!--
4950 You can customize the "flutter_bootstrap.js" script.
@@ -44,6 +44,7 @@
44 essentially nothing up front.44 essentially nothing up front.
45 -->45 -->
46 <script src="nimflutter.js"></script>46 <script src="nimflutter.js"></script>
47+ <script src="nimflutter_pool.js"></script>
47 48
48 <!--49 <!--
49 You can customize the "flutter_bootstrap.js" script.50 You can customize the "flutter_bootstrap.js" script.
added example/web/nimflutter_pool.js +74 -0
new file mode 100644
@@ -0,0 +1,74 @@
1+// A worker pool for band rendering, exposed to Dart as one promise-returning
2+// function.
3+//
4+// The pool lives here rather than in Dart because dart:js_interop has no
5+// ergonomic Worker binding, and because the interesting part — a work queue
6+// feeding N workers — is the same code in either language.
7+(function () {
8+ const workers = [];
9+ const idle = [];
10+ let nextId = 1;
11+ let poolSize = 0;
12+
13+ // More bands than cores is deliberate: bands cost wildly different amounts
14+ // (interior rows run the full iteration cap), so over-decomposing keeps a
15+ // free worker busy instead of waiting on the slowest band. Same finding as
16+ // `just sweep` on native.
17+ const maxWorkers = Math.max(1, (self.navigator && self.navigator.hardwareConcurrency) || 4);
18+
19+ function grow(n) {
20+ const want = Math.min(n, maxWorkers);
21+ while (workers.length < want) {
22+ const w = new Worker('nimflutter_worker.js');
23+ w.pending = new Map();
24+ w.onmessage = (e) => {
25+ const resolve = w.pending.get(e.data.id);
26+ if (!resolve) return;
27+ w.pending.delete(e.data.id);
28+ resolve(e.data);
29+ };
30+ workers.push(w);
31+ idle.push(w);
32+ }
33+ poolSize = workers.length;
34+ }
35+
36+ function run(worker, job) {
37+ return new Promise((resolve) => {
38+ worker.pending.set(job.id, resolve);
39+ worker.postMessage(job);
40+ });
41+ }
42+
43+ self.nimflutterPoolSize = () => poolSize;
44+
45+ self.nimflutterRenderParallel = function (w, h, cx, cy, scale, iter, tiles) {
46+ const count = Math.max(1, Math.min(tiles, h));
47+ grow(count);
48+
49+ const rowsPer = Math.ceil(h / count);
50+ const queue = [];
51+ for (let t = 0; t < count; t++) {
52+ const y0 = t * rowsPer;
53+ const y1 = Math.min((t + 1) * rowsPer, h);
54+ if (y0 >= y1) break;
55+ queue.push({ id: nextId++, w, h, cx, cy, scale, iter, y0, y1 });
56+ }
57+
58+ const out = new Uint8Array(w * h * 4);
59+
60+ // Hand each worker the next band as it frees up, rather than dealing the
61+ // whole deck up front — that is what turns uneven band costs into a
62+ // balanced schedule.
63+ const pump = (worker) => {
64+ const job = queue.shift();
65+ if (!job) return Promise.resolve();
66+ return run(worker, job).then((res) => {
67+ out.set(res.band, res.y0 * w * 4);
68+ return pump(worker);
69+ });
70+ };
71+
72+ return Promise.all(workers.map(pump)).then(() => out);
73+ };
74+})();
new file mode 100644
@@ -0,0 +1,74 @@
1+// A worker pool for band rendering, exposed to Dart as one promise-returning
2+// function.
3+//
4+// The pool lives here rather than in Dart because dart:js_interop has no
5+// ergonomic Worker binding, and because the interesting part — a work queue
6+// feeding N workers — is the same code in either language.
7+(function () {
8+ const workers = [];
9+ const idle = [];
10+ let nextId = 1;
11+ let poolSize = 0;
12+
13+ // More bands than cores is deliberate: bands cost wildly different amounts
14+ // (interior rows run the full iteration cap), so over-decomposing keeps a
15+ // free worker busy instead of waiting on the slowest band. Same finding as
16+ // `just sweep` on native.
17+ const maxWorkers = Math.max(1, (self.navigator && self.navigator.hardwareConcurrency) || 4);
18+
19+ function grow(n) {
20+ const want = Math.min(n, maxWorkers);
21+ while (workers.length < want) {
22+ const w = new Worker('nimflutter_worker.js');
23+ w.pending = new Map();
24+ w.onmessage = (e) => {
25+ const resolve = w.pending.get(e.data.id);
26+ if (!resolve) return;
27+ w.pending.delete(e.data.id);
28+ resolve(e.data);
29+ };
30+ workers.push(w);
31+ idle.push(w);
32+ }
33+ poolSize = workers.length;
34+ }
35+
36+ function run(worker, job) {
37+ return new Promise((resolve) => {
38+ worker.pending.set(job.id, resolve);
39+ worker.postMessage(job);
40+ });
41+ }
42+
43+ self.nimflutterPoolSize = () => poolSize;
44+
45+ self.nimflutterRenderParallel = function (w, h, cx, cy, scale, iter, tiles) {
46+ const count = Math.max(1, Math.min(tiles, h));
47+ grow(count);
48+
49+ const rowsPer = Math.ceil(h / count);
50+ const queue = [];
51+ for (let t = 0; t < count; t++) {
52+ const y0 = t * rowsPer;
53+ const y1 = Math.min((t + 1) * rowsPer, h);
54+ if (y0 >= y1) break;
55+ queue.push({ id: nextId++, w, h, cx, cy, scale, iter, y0, y1 });
56+ }
57+
58+ const out = new Uint8Array(w * h * 4);
59+
60+ // Hand each worker the next band as it frees up, rather than dealing the
61+ // whole deck up front — that is what turns uneven band costs into a
62+ // balanced schedule.
63+ const pump = (worker) => {
64+ const job = queue.shift();
65+ if (!job) return Promise.resolve();
66+ return run(worker, job).then((res) => {
67+ out.set(res.band, res.y0 * w * 4);
68+ return pump(worker);
69+ });
70+ };
71+
72+ return Promise.all(workers.map(pump)).then(() => out);
73+ };
74+})();
added example/web/nimflutter_worker.js +34 -0
new file mode 100644
@@ -0,0 +1,34 @@
1+// One Mandelbrot band, computed in its own wasm instance.
2+//
3+// Each worker instantiates its own copy of the module. That is the whole
4+// reason this needs no SharedArrayBuffer and no COOP/COEP headers: nothing is
5+// shared, so there is nothing to synchronise. The wasm is ~34 KB, so N copies
6+// is cheap.
7+importScripts('nimflutter.js');
8+
9+const SIG = ['number', 'number', 'number', 'number', 'number',
10+ 'number', 'number', 'number', 'number', 'number'];
11+
12+let mod = null;
13+const ready = createNimFlutterModule().then((m) => {
14+ mod = m;
15+ m.ccall('nf_init', null, [], []);
16+});
17+
18+self.onmessage = async (e) => {
19+ await ready;
20+ const { id, w, h, cx, cy, scale, iter, y0, y1 } = e.data;
21+
22+ const bytes = (y1 - y0) * w * 4;
23+ const buf = mod._malloc(bytes);
24+ try {
25+ mod.ccall('nf_mandelbrot', null, SIG,
26+ [buf, bytes, w, h, cx, cy, scale, iter, y0, y1]);
27+ // .slice() copies out of the wasm heap, which may be detached by a later
28+ // growth; the copy is then transferred rather than structured-cloned.
29+ const band = mod.HEAPU8.slice(buf, buf + bytes);
30+ self.postMessage({ id, y0, band }, [band.buffer]);
31+ } finally {
32+ mod._free(buf);
33+ }
34+};
new file mode 100644
@@ -0,0 +1,34 @@
1+// One Mandelbrot band, computed in its own wasm instance.
2+//
3+// Each worker instantiates its own copy of the module. That is the whole
4+// reason this needs no SharedArrayBuffer and no COOP/COEP headers: nothing is
5+// shared, so there is nothing to synchronise. The wasm is ~34 KB, so N copies
6+// is cheap.
7+importScripts('nimflutter.js');
8+
9+const SIG = ['number', 'number', 'number', 'number', 'number',
10+ 'number', 'number', 'number', 'number', 'number'];
11+
12+let mod = null;
13+const ready = createNimFlutterModule().then((m) => {
14+ mod = m;
15+ m.ccall('nf_init', null, [], []);
16+});
17+
18+self.onmessage = async (e) => {
19+ await ready;
20+ const { id, w, h, cx, cy, scale, iter, y0, y1 } = e.data;
21+
22+ const bytes = (y1 - y0) * w * 4;
23+ const buf = mod._malloc(bytes);
24+ try {
25+ mod.ccall('nf_mandelbrot', null, SIG,
26+ [buf, bytes, w, h, cx, cy, scale, iter, y0, y1]);
27+ // .slice() copies out of the wasm heap, which may be detached by a later
28+ // growth; the copy is then transferred rather than structured-cloned.
29+ const band = mod.HEAPU8.slice(buf, buf + bytes);
30+ self.postMessage({ id, y0, band }, [band.buffer]);
31+ } finally {
32+ mod._free(buf);
33+ }
34+};
modified justfile +5 -1
@@ -41,6 +41,10 @@ wasm:
4141 test-wasm: wasm
4242 node tool/test_wasm.mjs
4343
44+# Does the web build really render in parallel, and still get the same pixels?
45+check-pool: wasm
46+ ./tool/check_pool.sh
47+
4448 # Same frame from the wasm and native kernels, compared byte for byte.
4549 parity: build wasm
4650 ./tool/check_parity.sh
@@ -88,7 +92,7 @@ release: build
8892 cd example && PATH="{{nim_bin}}:{{flutter_bin}}:$PATH" {{flutter}} build linux --release
8993
9094 # Everything CI would run.
91-ci: build smoke memcheck test bench test-wasm parity check-web
95+ci: build smoke memcheck test bench test-wasm parity check-pool check-web
9296
9397 clean:
9498 rm -rf build example/build example/web/nimflutter.js example/web/nimflutter.wasm
@@ -41,6 +41,10 @@ wasm:
41 test-wasm: wasm41 test-wasm: wasm
42 node tool/test_wasm.mjs42 node tool/test_wasm.mjs
43 43
44+# Does the web build really render in parallel, and still get the same pixels?
45+check-pool: wasm
46+ ./tool/check_pool.sh
47+
44 # Same frame from the wasm and native kernels, compared byte for byte.48 # Same frame from the wasm and native kernels, compared byte for byte.
45 parity: build wasm49 parity: build wasm
46 ./tool/check_parity.sh50 ./tool/check_parity.sh
@@ -88,7 +92,7 @@ release: build
88 cd example && PATH="{{nim_bin}}:{{flutter_bin}}:$PATH" {{flutter}} build linux --release92 cd example && PATH="{{nim_bin}}:{{flutter_bin}}:$PATH" {{flutter}} build linux --release
89 93
90 # Everything CI would run.94 # Everything CI would run.
91-ci: build smoke memcheck test bench test-wasm parity check-web95+ci: build smoke memcheck test bench test-wasm parity check-pool check-web
92 96
93 clean:97 clean:
94 rm -rf build example/build example/web/nimflutter.js example/web/nimflutter.wasm98 rm -rf build example/build example/web/nimflutter.js example/web/nimflutter.wasm
modified lib/nimflutter_ffi.dart +10 -5
@@ -23,9 +23,14 @@ const String backendName = backend.backendName;
2323
2424 /// Whether bands can genuinely run at the same time.
2525 ///
26-/// False on web: the emscripten module is single-threaded, so a split still
27-/// produces the identical frame but renders it sequentially.
28-const bool supportsIsolateParallelism = backend.supportsIsolateParallelism;
26+/// True on native (isolates) and on web (Web Workers, one wasm instance
27+/// each). False only if the web build is served without nimflutter_pool.js,
28+/// in which case the split still produces identical pixels, sequentially.
29+bool get supportsIsolateParallelism => backend.supportsIsolateParallelism;
30+
31+/// What one unit of parallelism is called here — 'isolate' or 'worker'.
32+/// Purely for display.
33+String get parallelUnitName => backend.parallelUnitName;
2934
3035 /// Whether the bridge is ready to take calls.
3136 bool get isInitialized => backend.isInitialized;
@@ -67,7 +72,7 @@ Uint8List render(FractalView view) => renderBand(view, 0, view.height);
6772 /// Renders [view] as [tiles] horizontal bands.
6873 ///
6974 /// Bands are independent by construction — each call writes only its own rows
70-/// — so on native this is real parallelism across isolates. On web it is the
71-/// same split run sequentially; see [supportsIsolateParallelism].
75+/// — so this is real parallelism: isolates on native, Web Workers on web,
76+/// each with its own wasm instance.
7277 Future<Uint8List> renderParallel(FractalView view, {int tiles = 4}) =>
7378 backend.renderParallel(view, tiles);
@@ -23,9 +23,14 @@ const String backendName = backend.backendName;
23 23
24 /// Whether bands can genuinely run at the same time.24 /// Whether bands can genuinely run at the same time.
25 ///25 ///
26-/// False on web: the emscripten module is single-threaded, so a split still26+/// True on native (isolates) and on web (Web Workers, one wasm instance
27-/// produces the identical frame but renders it sequentially.27+/// each). False only if the web build is served without nimflutter_pool.js,
28-const bool supportsIsolateParallelism = backend.supportsIsolateParallelism;28+/// in which case the split still produces identical pixels, sequentially.
29+bool get supportsIsolateParallelism => backend.supportsIsolateParallelism;
30+
31+/// What one unit of parallelism is called here — 'isolate' or 'worker'.
32+/// Purely for display.
33+String get parallelUnitName => backend.parallelUnitName;
29 34
30 /// Whether the bridge is ready to take calls.35 /// Whether the bridge is ready to take calls.
31 bool get isInitialized => backend.isInitialized;36 bool get isInitialized => backend.isInitialized;
@@ -67,7 +72,7 @@ Uint8List render(FractalView view) => renderBand(view, 0, view.height);
67 /// Renders [view] as [tiles] horizontal bands.72 /// Renders [view] as [tiles] horizontal bands.
68 ///73 ///
69 /// Bands are independent by construction — each call writes only its own rows74 /// Bands are independent by construction — each call writes only its own rows
70-/// — so on native this is real parallelism across isolates. On web it is the75+/// — so this is real parallelism: isolates on native, Web Workers on web,
71-/// same split run sequentially; see [supportsIsolateParallelism].76+/// each with its own wasm instance.
72 Future<Uint8List> renderParallel(FractalView view, {int tiles = 4}) =>77 Future<Uint8List> renderParallel(FractalView view, {int tiles = 4}) =>
73 backend.renderParallel(view, tiles);78 backend.renderParallel(view, tiles);
modified lib/src/backend_native.dart +3 -0
@@ -21,6 +21,9 @@ const String backendName = 'dart:ffi';
2121 /// stop-the-world phase to serialise them.
2222 const bool supportsIsolateParallelism = true;
2323
24+/// What one unit of parallelism is called here. Purely for display.
25+const String parallelUnitName = 'isolate';
26+
2427 const String _libName = 'nimflutter';
2528
2629 DynamicLibrary _open() {
@@ -21,6 +21,9 @@ const String backendName = 'dart:ffi';
21 /// stop-the-world phase to serialise them.21 /// stop-the-world phase to serialise them.
22 const bool supportsIsolateParallelism = true;22 const bool supportsIsolateParallelism = true;
23 23
24+/// What one unit of parallelism is called here. Purely for display.
25+const String parallelUnitName = 'isolate';
26+
24 const String _libName = 'nimflutter';27 const String _libName = 'nimflutter';
25 28
26 DynamicLibrary _open() {29 DynamicLibrary _open() {
modified lib/src/backend_web.dart +39 -8
@@ -19,10 +19,26 @@ import 'fractal_view.dart';
1919
2020 const String backendName = 'wasm (emscripten)';
2121
22-/// The module is single-threaded. Splitting into bands still works and still
23-/// produces the identical frame, but the bands run one after another, so the
24-/// "parallel" timing on web is a sequential one.
25-const bool supportsIsolateParallelism = false;
22+/// True: bands render concurrently in Web Workers.
23+///
24+/// The wasm module itself is single-threaded, so the parallelism comes from
25+/// running several *instances* of it — one per worker, each with its own
26+/// linear memory. Nothing is shared, which is why this needs no
27+/// SharedArrayBuffer and no COOP/COEP headers; the cost is one ~34 KB module
28+/// per worker.
29+///
30+/// Falls back to sequential rendering if nimflutter_pool.js is not loaded.
31+bool get supportsIsolateParallelism => _pool != null;
32+
33+/// What one unit of parallelism is called here. Purely for display.
34+const String parallelUnitName = 'worker';
35+
36+@JS('nimflutterRenderParallel')
37+external JSFunction? get _pool;
38+
39+@JS('nimflutterRenderParallel')
40+external JSPromise<JSUint8Array> _renderParallelInWorkers(int w, int h,
41+ double cx, double cy, double scale, int iter, int tiles);
2642
2743 @JS('createNimFlutterModule')
2844 external JSFunction? get _factory;
@@ -154,13 +170,28 @@ Uint8List renderBand(FractalView view, int y0, int y1) {
154170 }
155171 }
156172
157-/// Splits the frame the same way the native backend does, so the output is
158-/// identical — but the bands are computed one after another. See
159-/// [supportsIsolateParallelism].
173+/// Renders the frame across the worker pool, falling back to a sequential
174+/// split if the pool script is absent.
175+///
176+/// The split is identical to the native backend's, and so is the output: the
177+/// bands are the same bands, just computed in other threads.
160178 Future<Uint8List> renderParallel(FractalView view, int tiles) async {
161179 final count = tiles.clamp(1, view.height);
162- final rowsPer = (view.height / count).ceil();
163180
181+ if (_pool != null && count > 1) {
182+ final result = await _renderParallelInWorkers(
183+ view.width,
184+ view.height,
185+ view.centerX,
186+ view.centerY,
187+ view.scale,
188+ view.maxIter,
189+ count,
190+ ).toDart;
191+ return result.toDart;
192+ }
193+
194+ final rowsPer = (view.height / count).ceil();
164195 final out = Uint8List(view.width * view.height * 4);
165196 var offset = 0;
166197 for (var t = 0; t < count; t++) {
@@ -19,10 +19,26 @@ import 'fractal_view.dart';
19 19
20 const String backendName = 'wasm (emscripten)';20 const String backendName = 'wasm (emscripten)';
21 21
22-/// The module is single-threaded. Splitting into bands still works and still22+/// True: bands render concurrently in Web Workers.
23-/// produces the identical frame, but the bands run one after another, so the23+///
24-/// "parallel" timing on web is a sequential one.24+/// The wasm module itself is single-threaded, so the parallelism comes from
25-const bool supportsIsolateParallelism = false;25+/// running several *instances* of it — one per worker, each with its own
26+/// linear memory. Nothing is shared, which is why this needs no
27+/// SharedArrayBuffer and no COOP/COEP headers; the cost is one ~34 KB module
28+/// per worker.
29+///
30+/// Falls back to sequential rendering if nimflutter_pool.js is not loaded.
31+bool get supportsIsolateParallelism => _pool != null;
32+
33+/// What one unit of parallelism is called here. Purely for display.
34+const String parallelUnitName = 'worker';
35+
36+@JS('nimflutterRenderParallel')
37+external JSFunction? get _pool;
38+
39+@JS('nimflutterRenderParallel')
40+external JSPromise<JSUint8Array> _renderParallelInWorkers(int w, int h,
41+ double cx, double cy, double scale, int iter, int tiles);
26 42
27 @JS('createNimFlutterModule')43 @JS('createNimFlutterModule')
28 external JSFunction? get _factory;44 external JSFunction? get _factory;
@@ -154,13 +170,28 @@ Uint8List renderBand(FractalView view, int y0, int y1) {
154 }170 }
155 }171 }
156 172
157-/// Splits the frame the same way the native backend does, so the output is173+/// Renders the frame across the worker pool, falling back to a sequential
158-/// identical — but the bands are computed one after another. See174+/// split if the pool script is absent.
159-/// [supportsIsolateParallelism].175+///
176+/// The split is identical to the native backend's, and so is the output: the
177+/// bands are the same bands, just computed in other threads.
160 Future<Uint8List> renderParallel(FractalView view, int tiles) async {178 Future<Uint8List> renderParallel(FractalView view, int tiles) async {
161 final count = tiles.clamp(1, view.height);179 final count = tiles.clamp(1, view.height);
162- final rowsPer = (view.height / count).ceil();
163 180
181+ if (_pool != null && count > 1) {
182+ final result = await _renderParallelInWorkers(
183+ view.width,
184+ view.height,
185+ view.centerX,
186+ view.centerY,
187+ view.scale,
188+ view.maxIter,
189+ count,
190+ ).toDart;
191+ return result.toDart;
192+ }
193+
194+ final rowsPer = (view.height / count).ceil();
164 final out = Uint8List(view.width * view.height * 4);195 final out = Uint8List(view.width * view.height * 4);
165 var offset = 0;196 var offset = 0;
166 for (var t = 0; t < count; t++) {197 for (var t = 0; t < count; t++) {
added tool/check_pool.sh +117 -0
new file mode 100755
@@ -0,0 +1,117 @@
1+#!/usr/bin/env bash
2+# Does the web build actually render in parallel, and does it still produce
3+# the right pixels?
4+#
5+# Both halves matter. A worker pool that silently falls back to one thread
6+# looks exactly like a working one except for the clock, and a pool that
7+# assembles bands in the wrong order produces a plausible-looking but wrong
8+# frame. So this times 1 worker against many and compares both against a
9+# straight single-instance render.
10+#
11+# The page POSTs its results back rather than logging them: headless Chrome's
12+# console plumbing is unreliable, and --virtual-time-budget would make
13+# performance.now() meaningless for timing anyway.
14+set -euo pipefail
15+cd "$(dirname "$0")/.."
16+
17+chrome="${CHROME_EXECUTABLE:-$(command -v google-chrome-stable || command -v google-chrome || command -v chromium || true)}"
18+[ -n "$chrome" ] || { echo "no Chrome found; set CHROME_EXECUTABLE"; exit 1; }
19+[ -f example/web/nimflutter.js ] || { echo "run 'just wasm' first"; exit 1; }
20+
21+work="$(mktemp -d)"
22+port="${PORT:-8793}"
23+profile="$work/profile"
24+trap 'rm -rf "$work"' EXIT
25+
26+cp example/web/nimflutter.js example/web/nimflutter.wasm \
27+ example/web/nimflutter_worker.js example/web/nimflutter_pool.js "$work/"
28+
29+cat > "$work/index.html" <<'HTML'
30+<!DOCTYPE html><meta charset="utf-8"><title>pool</title>
31+<script src="nimflutter.js"></script>
32+<script src="nimflutter_pool.js"></script>
33+<script type="module">
34+const W = 800, H = 600, ITER = 500;
35+const SIG = Array(10).fill('number');
36+const report = (lines) =>
37+ fetch('/result', {method: 'POST', body: lines.join('\n')});
38+
39+try {
40+ // Ground truth: one instance, one band, on the main thread.
41+ const M = await createNimFlutterModule();
42+ M.ccall('nf_init', null, [], []);
43+ const n = W * H * 4;
44+ const p = M._malloc(n);
45+ const t0 = performance.now();
46+ M.ccall('nf_mandelbrot', null, SIG, [p, n, W, H, -0.5, 0.0, 3.0, ITER, 0, H]);
47+ const serialMs = performance.now() - t0;
48+ const truth = M.HEAPU8.slice(p, p + n);
49+ M._free(p);
50+
51+ const timed = async (tiles) => {
52+ await nimflutterRenderParallel(W, H, -0.5, 0.0, 3.0, ITER, tiles); // warm
53+ const t = performance.now();
54+ const out = await nimflutterRenderParallel(W, H, -0.5, 0.0, 3.0, ITER, tiles);
55+ return {ms: performance.now() - t, out};
56+ };
57+
58+ const diff = (a, b) => {
59+ if (a.length !== b.length) return -1;
60+ let d = 0;
61+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) d++;
62+ return d;
63+ };
64+
65+ const one = await timed(1);
66+ const many = await timed(32);
67+
68+ await report([
69+ `cores=${navigator.hardwareConcurrency} workers=${nimflutterPoolSize()}`,
70+ `main-thread=${serialMs.toFixed(1)}ms`,
71+ `1-worker=${one.ms.toFixed(1)}ms`,
72+ `32-bands=${many.ms.toFixed(1)}ms`,
73+ `speedup=${(one.ms / many.ms).toFixed(2)}x`,
74+ `diff-1worker=${diff(truth, one.out)}`,
75+ `diff-pool=${diff(truth, many.out)}`,
76+ ]);
77+} catch (e) {
78+ await report([`error=${e && e.message ? e.message : e}`]);
79+}
80+</script>
81+HTML
82+
83+# Serves the directory, and exits after the page POSTs its results to /result.
84+cat > "$work/server.py" <<'PY'
85+import sys, http.server
86+out = sys.argv[1]
87+class H(http.server.SimpleHTTPRequestHandler):
88+ def do_POST(self):
89+ body = self.rfile.read(int(self.headers['Content-Length'])).decode()
90+ open(out, 'w').write(body)
91+ self.send_response(204); self.end_headers()
92+ raise SystemExit(0)
93+ def log_message(self, *a): pass
94+http.server.HTTPServer(('127.0.0.1', int(sys.argv[2])), H).serve_forever()
95+PY
96+
97+(cd "$work" && exec python3 server.py results.txt "$port") >/dev/null 2>&1 &
98+sleep 2
99+
100+"$chrome" --headless=new --no-sandbox --disable-dev-shm-usage \
101+ --user-data-dir="$profile" --disable-gpu \
102+ "http://127.0.0.1:$port/index.html" >/dev/null 2>&1 &
103+chrome_pid=$!
104+
105+for _ in $(seq 1 60); do
106+ [ -s "$work/results.txt" ] && break
107+ sleep 1
108+done
109+kill "$chrome_pid" 2>/dev/null || true
110+kill %1 %2 2>/dev/null || true
111+
112+[ -s "$work/results.txt" ] || { echo "FAIL page produced no result"; exit 1; }
113+{ cat "$work/results.txt"; echo; }
114+grep -q '^error=' "$work/results.txt" && { echo "FAIL page threw"; exit 1; }
115+grep -q 'diff-1worker=0' "$work/results.txt" || { echo "FAIL 1-worker frame differs"; exit 1; }
116+grep -q 'diff-pool=0' "$work/results.txt" || { echo "FAIL pooled frame differs"; exit 1; }
117+echo "ok pooled and single-instance frames are identical"
new file mode 100755
@@ -0,0 +1,117 @@
1+#!/usr/bin/env bash
2+# Does the web build actually render in parallel, and does it still produce
3+# the right pixels?
4+#
5+# Both halves matter. A worker pool that silently falls back to one thread
6+# looks exactly like a working one except for the clock, and a pool that
7+# assembles bands in the wrong order produces a plausible-looking but wrong
8+# frame. So this times 1 worker against many and compares both against a
9+# straight single-instance render.
10+#
11+# The page POSTs its results back rather than logging them: headless Chrome's
12+# console plumbing is unreliable, and --virtual-time-budget would make
13+# performance.now() meaningless for timing anyway.
14+set -euo pipefail
15+cd "$(dirname "$0")/.."
16+
17+chrome="${CHROME_EXECUTABLE:-$(command -v google-chrome-stable || command -v google-chrome || command -v chromium || true)}"
18+[ -n "$chrome" ] || { echo "no Chrome found; set CHROME_EXECUTABLE"; exit 1; }
19+[ -f example/web/nimflutter.js ] || { echo "run 'just wasm' first"; exit 1; }
20+
21+work="$(mktemp -d)"
22+port="${PORT:-8793}"
23+profile="$work/profile"
24+trap 'rm -rf "$work"' EXIT
25+
26+cp example/web/nimflutter.js example/web/nimflutter.wasm \
27+ example/web/nimflutter_worker.js example/web/nimflutter_pool.js "$work/"
28+
29+cat > "$work/index.html" <<'HTML'
30+<!DOCTYPE html><meta charset="utf-8"><title>pool</title>
31+<script src="nimflutter.js"></script>
32+<script src="nimflutter_pool.js"></script>
33+<script type="module">
34+const W = 800, H = 600, ITER = 500;
35+const SIG = Array(10).fill('number');
36+const report = (lines) =>
37+ fetch('/result', {method: 'POST', body: lines.join('\n')});
38+
39+try {
40+ // Ground truth: one instance, one band, on the main thread.
41+ const M = await createNimFlutterModule();
42+ M.ccall('nf_init', null, [], []);
43+ const n = W * H * 4;
44+ const p = M._malloc(n);
45+ const t0 = performance.now();
46+ M.ccall('nf_mandelbrot', null, SIG, [p, n, W, H, -0.5, 0.0, 3.0, ITER, 0, H]);
47+ const serialMs = performance.now() - t0;
48+ const truth = M.HEAPU8.slice(p, p + n);
49+ M._free(p);
50+
51+ const timed = async (tiles) => {
52+ await nimflutterRenderParallel(W, H, -0.5, 0.0, 3.0, ITER, tiles); // warm
53+ const t = performance.now();
54+ const out = await nimflutterRenderParallel(W, H, -0.5, 0.0, 3.0, ITER, tiles);
55+ return {ms: performance.now() - t, out};
56+ };
57+
58+ const diff = (a, b) => {
59+ if (a.length !== b.length) return -1;
60+ let d = 0;
61+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) d++;
62+ return d;
63+ };
64+
65+ const one = await timed(1);
66+ const many = await timed(32);
67+
68+ await report([
69+ `cores=${navigator.hardwareConcurrency} workers=${nimflutterPoolSize()}`,
70+ `main-thread=${serialMs.toFixed(1)}ms`,
71+ `1-worker=${one.ms.toFixed(1)}ms`,
72+ `32-bands=${many.ms.toFixed(1)}ms`,
73+ `speedup=${(one.ms / many.ms).toFixed(2)}x`,
74+ `diff-1worker=${diff(truth, one.out)}`,
75+ `diff-pool=${diff(truth, many.out)}`,
76+ ]);
77+} catch (e) {
78+ await report([`error=${e && e.message ? e.message : e}`]);
79+}
80+</script>
81+HTML
82+
83+# Serves the directory, and exits after the page POSTs its results to /result.
84+cat > "$work/server.py" <<'PY'
85+import sys, http.server
86+out = sys.argv[1]
87+class H(http.server.SimpleHTTPRequestHandler):
88+ def do_POST(self):
89+ body = self.rfile.read(int(self.headers['Content-Length'])).decode()
90+ open(out, 'w').write(body)
91+ self.send_response(204); self.end_headers()
92+ raise SystemExit(0)
93+ def log_message(self, *a): pass
94+http.server.HTTPServer(('127.0.0.1', int(sys.argv[2])), H).serve_forever()
95+PY
96+
97+(cd "$work" && exec python3 server.py results.txt "$port") >/dev/null 2>&1 &
98+sleep 2
99+
100+"$chrome" --headless=new --no-sandbox --disable-dev-shm-usage \
101+ --user-data-dir="$profile" --disable-gpu \
102+ "http://127.0.0.1:$port/index.html" >/dev/null 2>&1 &
103+chrome_pid=$!
104+
105+for _ in $(seq 1 60); do
106+ [ -s "$work/results.txt" ] && break
107+ sleep 1
108+done
109+kill "$chrome_pid" 2>/dev/null || true
110+kill %1 %2 2>/dev/null || true
111+
112+[ -s "$work/results.txt" ] || { echo "FAIL page produced no result"; exit 1; }
113+{ cat "$work/results.txt"; echo; }
114+grep -q '^error=' "$work/results.txt" && { echo "FAIL page threw"; exit 1; }
115+grep -q 'diff-1worker=0' "$work/results.txt" || { echo "FAIL 1-worker frame differs"; exit 1; }
116+grep -q 'diff-pool=0' "$work/results.txt" || { echo "FAIL pooled frame differs"; exit 1; }
117+echo "ok pooled and single-instance frames are identical"