Repository navigation
P 08 Multithreaded Engine
Context: Blinkql runs DuckDB-wasm's single-threaded bundle (eh, or mvp where WebAssembly exceptions aren't supported), so a query uses one core however many the machine has. DuckDB-wasm also ships a multithreaded bundle, coi, which runs extra threads as Web Workers that share the engine's memory through a SharedArrayBuffer. It needs cross-origin isolation, which Blinkql has had from the start for this experiment (ADR-007). Large scans and aggregations are where a user waits longest, and the reference laptop has 8 logical cores.
Hypothesis: The multithreaded bundle makes large scans and aggregations faster on multi-core machines, without stability or compatibility cost.
Change: #54. No pull request: the change was built for the measurement only and isn't merged.
-
Flag:
?threads=1in the workbench's URL adds thecoibundle to the bundlesselectBundle()chooses from, but only when the page is cross-origin isolated. Without it, the engine is single-threaded as before. -
How:
bundlesFor()insrc/engine/bundles.tsaddscoiwith its pthread worker.boot()insrc/engine/engine.tspasses the pthread worker toinstantiate()and opens the database withmaximumThreadsset tonavigator.hardwareConcurrency(capped at 16). DuckDB-wasm defaults to 4 threads whatever the machine. -
Measure: the time from sending a query until its Arrow result is on the main thread (
blinkql:query), as in the dataset scenarios.
Method:
-
Queries: three queries that need no dataset, so file reading and extensions are out of the picture:
-
sum(hash):SELECT sum(hash(range) % 1000) FROM range(200000000) - GROUP BY, 1,000 groups:
SELECT count(*) FROM (SELECT range % 1000 AS k, avg(range) FROM range(50000000) GROUP BY k) - GROUP BY, 1,000,003 groups:
SELECT count(*) FROM (SELECT range % 1000003 AS k, avg(range) FROM range(50000000) GROUP BY k)
-
-
In-memory table: the same three queries over
t, a 50,000,000-row table created first withCREATE TABLE t AS SELECT range FROM range(50000000), sosum(hash)reads 50 million rows instead of 200 million. Added after the first run, becauserange()produces its rows on one thread and the queries over it can't be split (see Findings). -
Modes:
/app/(single-threaded,current_setting('threads')= 1) and/app/?threads=1(multithreaded,threads= 8), each in a fresh page. -
Build:
mainat3639160with the change above uncommitted, built withpnpm buildand served byvite previewwith production headers. Both modes are the same build; only the URL differs. -
Iterations: 3 warm-up and 10 measured runs of each query in each mode, one mode after the other, in one session:
range()queries in both modes, then the in-memory table in both modes. With 10 samples, p95 is the slowest run. -
Environment: the reference laptop from P-01 (Intel Core i5-8250U, 4 cores / 8 threads, 24 GB, Windows 11 Pro 10.0.26300), Chromium 156.0.8078.4 (Playwright's pinned build), headless. The page was cross-origin isolated and reported
navigator.hardwareConcurrency= 8. No other benchmark ran at the same time; other apps were open.
All times are in milliseconds, given as median / p95.
| Metric | Before: single-threaded | After: 8 threads | Change (median / p95) |
|---|---|---|---|
sum(hash), 200M rows |
6,506 / 10,730 | 8,628 / 12,424 | +32.6% / +15.8% |
| GROUP BY, 1,000 groups, 50M rows | 2,053 / 2,685 | 2,584 / 7,434 | +25.8% / +176.9% |
| GROUP BY, 1,000,003 groups, 50M rows | 3,717 / 9,082 | 7,595 / 11,626 | +104.3% / +28.0% |
| Failed runs (of 13) | 0 | 1 (GROUP BY, 1,000,003 groups) |
| Metric | Before: single-threaded | After: 8 threads | Change (median / p95) |
|---|---|---|---|
sum(hash), 50M rows |
1,714 / 2,536 | 796 / 1,120 | −53.6% / −55.9% |
| GROUP BY, 1,000 groups | 1,939 / 2,879 | 875 / 1,182 | −54.9% / −58.9% |
| GROUP BY, 1,000,003 groups | 3,839 / 4,395 | 3,489 / 4,276 | −9.1% / −2.7% |
Creating t (one run) |
843 | 2,262 | +168% |
| Failed runs (of 13) | 0 | 0 |
-
Multithreading is faster only where DuckDB can split the work. Over an in-memory table,
sum(hash)and the small GROUP BY take about half as long with 8 threads (−54% and −55% at the median, and similar at p95). That is about 2.2×, on a machine with 4 physical cores. The GROUP BY with a million groups gains only 9%, because merging a million-group hash table from each thread takes as long as the work it saves. -
Over
range(), it's slower.range()produces its rows on one thread, so the other threads have nothing to read and only add coordination. Every query was slower with 8 threads, by 26% to 104% at the median. Many real queries in Blinkql read from one file, and whether DuckDB-wasm splits that read depends on the format and the reader, so the gain on real tables isn't known (see Limitations). -
The large GROUP BY can crash the multithreaded engine. In the second warm-up run of
GROUP BYwith 1,000,003 groups overrange(), the workbench reportedCannot read properties of undefined (reading 'fields'). The engine then stopped answering and the page had to be reloaded. The other 12 runs succeeded. In earlier exploration, the same query failed this way in Chromium, Firefox and WebKit, all with threads on; single-threaded, it has never failed. The crash is intermittent, so it can't be ruled out by a passing run. -
Parquet and JSON don't load with threads on. The multithreaded engine needs extensions built for
wasm_threads, and Blinkql's build only serveswasm_ehandwasm_mvp(ADR-008). Importingtelemetry-s-narrow.parquetwith?threads=1failed with "Extension … could not be loaded because its signature is either missing or invalid". In earlier exploration, thewasm_threadsextensions for DuckDB v1.4.3, served by hand, passed the signature check but failed to link:LinkError: … Import "env" "memory": mismatch in shared state of memory. Reading the modules confirms it: the v1.4.3wasm_threadsparquetandjsonextensions import a non-shared memory, whileduckdb-coi.wasmdeclares a shared one, so they can't be instantiated together. -
Files registered by handle aren't visible to the extra threads. A 1,000,000-row CSV (
telemetry-m-narrow.csv) imported with?threads=1, but its first query failed:No HTML5 file registered with name: telemetry_m_narrow.csv, followed byRangeError: offset is out of bounds. A 100,000-row CSV worked, and so did both files single-threaded. Every local import in Blinkql registers the file by handle (registerFileHandle()withBROWSER_FILEREADER), which only the engine's main worker can read. - The hypothesis held only for in-memory data. Scans and aggregations that DuckDB can split are about twice as fast, but with Parquet and JSON unavailable, large local CSVs failing and an intermittent crash, the multithreaded engine can't run Blinkql's main use today.
Trade-offs:
- Format support. Parquet and JSON stop working with threads on (Finding 4). Parquet is Blinkql's main format.
- Local files. CSV files large enough to be read in parallel fail (Finding 5).
- Stability. An intermittent engine crash on a large GROUP BY (Finding 3).
- Start-up and memory. Creating the 50M-row table took 2.7× as long with threads on, in one run. Each thread is a worker with its own buffers inside the shared WebAssembly memory. Engine start-up and memory weren't measured separately.
- Hosting. Multithreading needs cross-origin isolation, which Blinkql already sends, and a third set of engine files.
Limitations:
- No real datasets. The benchmark uses generated rows only, because Parquet and large CSVs don't work with threads on. How much a scan of a real file gains is unknown.
- Chromium only. Firefox and WebKit were cross-origin isolated and booted the multithreaded engine with 8 threads in earlier exploration, but weren't benchmarked.
-
One session on the working laptop. Other apps were open. The single-threaded
sum(hash)overrange()ranged from 5,675 to 10,730 ms, so its p95 is wide. Each mode ran once, one after the other, not interleaved.
Evidence:
No code pull request was made, so the raw results are kept here rather than in bench/results. Measured runs in milliseconds, in order:
| Source | Mode | Query | Samples |
|---|---|---|---|
range() |
single | sum(hash) |
5,802, 6,127, 5,675, 5,712, 6,174, 8,663, 6,838, 8,818, 10,730, 7,463 |
range() |
single | GROUP BY, 1,000 | 2,244, 1,823, 2,685, 2,129, 1,978, 2,172, 1,854, 2,488, 1,890, 1,976 |
range() |
single | GROUP BY, 1,000,003 | 9,082, 3,575, 3,672, 3,728, 3,583, 3,874, 3,723, 3,792, 3,579, 3,711 |
range() |
8 threads | sum(hash) |
9,152, 10,005, 8,105, 8,453, 8,574, 12,424, 8,728, 8,121, 7,847, 8,683 |
range() |
8 threads | GROUP BY, 1,000 | 6,753, 7,434, 2,451, 2,366, 2,079, 2,467, 2,107, 2,701, 3,789, 2,888 |
range() |
8 threads | GROUP BY, 1,000,003 | 7,137, 8,633, 7,668, 6,397, 9,092, 6,947, 11,626, 5,870, 11,317, 7,521 |
table t
|
single | sum(hash) |
1,723, 1,705, 1,689, 1,727, 1,680, 1,650, 1,804, 1,692, 1,723, 2,536 |
table t
|
single | GROUP BY, 1,000 | 2,455, 2,879, 1,945, 1,876, 1,983, 2,036, 1,932, 1,820, 1,909, 1,897 |
table t
|
single | GROUP BY, 1,000,003 | 3,849, 3,927, 3,982, 3,744, 3,828, 3,810, 3,810, 4,395, 4,019, 3,765 |
table t
|
8 threads | sum(hash) |
659, 616, 674, 821, 678, 771, 1,120, 1,034, 973, 1,087 |
table t
|
8 threads | GROUP BY, 1,000 | 1,067, 812, 826, 790, 872, 889, 1,182, 816, 946, 878 |
table t
|
8 threads | GROUP BY, 1,000,003 | 3,528, 4,276, 3,715, 3,880, 3,349, 3,736, 3,419, 3,337, 3,449, 3,133 |
The one failure was warm-up run 2 of GROUP BY with 1,000,003 groups over range(), 8 threads: Cannot read properties of undefined (reading 'fields'), after which the page was reloaded.
Conclusion: Don't adopt the multithreaded engine; Blinkql stays single-threaded (ADR-007). Threads halve the time of scans and aggregations that DuckDB can split, but three blockers rule it out today:
- The
wasm_threadsparquetandjsonextensions for DuckDB v1.4.3 import non-shared memory and can't link into the multithreaded engine. - Files registered by handle, which is how every local import works, aren't visible to the extra threads, so large local files fail.
- A GROUP BY with many groups intermittently crashes the engine (
Cannot read properties of undefined (reading 'fields')) in Chromium, Firefox and WebKit.
Check again on the next DuckDB-wasm upgrade: rebuild the ?threads=1 change, confirm the three blockers are fixed, then measure real Parquet and CSV scans as well as the queries above.
⚡ Blinkql: queries in the blink of an eye · MIT licensed · Live app · Issues · Discussions
Project
Engineering
- Tech Stack
- Architecture
- Performance & Benchmarks
- Decision Records (ADRs)
- Privacy & Security
- Browser Support & Limitations
Design
Community