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>
4506a58 parent: 472eb49 modified
README.md +20 -4 | @@ -144,13 +144,29 @@ too, ~2-5% off native for the same frame: | ||
| 144 | 144 | 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 | 168 | * `await v.initialize()` before anything else. A wasm module cannot be |
| 150 | 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 | 171 | ### One trap worth knowing about |
| 156 | 172 | |
| @@ -144,13 +144,29 @@ too, ~2-5% off native for the same frame: | |||
| 144 | 800x600 maxIter 500: 148.8 ms native / 157.2 ms wasm | 144 | 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 be | 168 | * `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 about | 171 | ### One trap worth knowing about |
| 156 | 172 | ||
modified
example/lib/main.dart +9 -7 | @@ -65,7 +65,8 @@ class _ExplorerPageState extends State<ExplorerPage> { | ||
| 65 | 65 | |
| 66 | 66 | /// Bands run on isolates natively and sequentially on web, so the word the |
| 67 | 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 | 71 | @override |
| 71 | 72 | void initState() { |
| @@ -426,18 +427,19 @@ class _ExplorerPageState extends State<ExplorerPage> { | ||
| 426 | 427 | child: Padding( |
| 427 | 428 | padding: const EdgeInsets.all(14), |
| 428 | 429 | child: Text( |
| 429 | - v.supportsIsolateParallelism | |
| 430 | + v.backendName == 'dart:ffi' | |
| 430 | 431 | ? 'The Nim kernel fills a buffer Dart allocates, so no Nim ' |
| 431 | 432 | 'memory crosses the boundary and there is nothing to ' |
| 432 | 433 | '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.' | |
| 435 | 436 | : 'The same Nim kernel, compiled to WebAssembly and reached ' |
| 436 | 437 | 'over dart:js_interop instead of dart:ffi — it fills a ' |
| 437 | 438 | '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.', | |
| 441 | 443 | style: theme.textTheme.bodySmall, |
| 442 | 444 | ), |
| 443 | 445 | ), |
| @@ -65,7 +65,8 @@ class _ExplorerPageState extends State<ExplorerPage> { | |||
| 65 | 65 | ||
| 66 | /// Bands run on isolates natively and sequentially on web, so the word the | 66 | /// 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 | @override | 71 | @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.supportsIsolateParallelism | 430 | + 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 @@ | ||
| 44 | 44 | essentially nothing up front. |
| 45 | 45 | --> |
| 46 | 46 | <script src="nimflutter.js"></script> |
| 47 | + <script src="nimflutter_pool.js"></script> | |
| 47 | 48 | |
| 48 | 49 | <!-- |
| 49 | 50 | 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: | ||
| 41 | 41 | test-wasm: wasm |
| 42 | 42 | 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 | 48 | # Same frame from the wasm and native kernels, compared byte for byte. |
| 45 | 49 | parity: build wasm |
| 46 | 50 | ./tool/check_parity.sh |
| @@ -88,7 +92,7 @@ release: build | ||
| 88 | 92 | cd example && PATH="{{nim_bin}}:{{flutter_bin}}:$PATH" {{flutter}} build linux --release |
| 89 | 93 | |
| 90 | 94 | # 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 | |
| 92 | 96 | |
| 93 | 97 | clean: |
| 94 | 98 | rm -rf build example/build example/web/nimflutter.js example/web/nimflutter.wasm |
| @@ -41,6 +41,10 @@ wasm: | |||
| 41 | test-wasm: wasm | 41 | test-wasm: wasm |
| 42 | node tool/test_wasm.mjs | 42 | 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 wasm | 49 | parity: build wasm |
| 46 | ./tool/check_parity.sh | 50 | ./tool/check_parity.sh |
| @@ -88,7 +92,7 @@ release: build | |||
| 88 | cd example && PATH="{{nim_bin}}:{{flutter_bin}}:$PATH" {{flutter}} build linux --release | 92 | 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-web | 95 | +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.wasm | 98 | 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; | ||
| 23 | 23 | |
| 24 | 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 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; | |
| 29 | 34 | |
| 30 | 35 | /// Whether the bridge is ready to take calls. |
| 31 | 36 | bool get isInitialized => backend.isInitialized; |
| @@ -67,7 +72,7 @@ Uint8List render(FractalView view) => renderBand(view, 0, view.height); | ||
| 67 | 72 | /// Renders [view] as [tiles] horizontal bands. |
| 68 | 73 | /// |
| 69 | 74 | /// 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. | |
| 72 | 77 | Future<Uint8List> renderParallel(FractalView view, {int tiles = 4}) => |
| 73 | 78 | 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 still | 26 | +/// 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 rows | 74 | /// 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 | 75 | +/// — 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'; | ||
| 21 | 21 | /// stop-the-world phase to serialise them. |
| 22 | 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 | 27 | const String _libName = 'nimflutter'; |
| 25 | 28 | |
| 26 | 29 | 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'; | ||
| 19 | 19 | |
| 20 | 20 | const String backendName = 'wasm (emscripten)'; |
| 21 | 21 | |
| 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); | |
| 26 | 42 | |
| 27 | 43 | @JS('createNimFlutterModule') |
| 28 | 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 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. | |
| 160 | 178 | Future<Uint8List> renderParallel(FractalView view, int tiles) async { |
| 161 | 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 | 195 | final out = Uint8List(view.width * view.height * 4); |
| 165 | 196 | var offset = 0; |
| 166 | 197 | 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 still | 22 | +/// True: bands render concurrently in Web Workers. |
| 23 | -/// produces the identical frame, but the bands run one after another, so the | 23 | +/// |
| 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 is | 173 | +/// Renders the frame across the worker pool, falling back to a sequential |
| 158 | -/// identical — but the bands are computed one after another. See | 174 | +/// 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" | ||