Skip to content

P 01 Baseline

Karthik P edited this page Oct 17, 2026 · 1 revision

P-01: Baseline

Context: Milestone 1 deliberately ships a naive workbench: query results are copied out of Arrow into one object per row, kept in React state and rendered as a plain table with every row in the DOM. This page measures it end to end, so every later optimization has an honest "before."

Hypothesis: None. This run establishes the baseline.

Change: Instrumentation only, no optimization (#17). The app records blinkql:import, blinkql:query, blinkql:results-convert and blinkql:results-render User Timing measures, and pnpm bench gained one scenario per S and M dataset. Each scenario drives the UI like a user: it imports the file with the file picker, types SQL into the editor and presses Run. The scenarios and their metrics are described in bench/README.md.

Method:

  • Commit: d73713b (clean working tree), served by vite preview with the production headers.
  • Datasets: generator version 1, default seed. S (100,000 rows) and M (1,000,000 rows), narrow (8 columns) and wide (50 columns), CSV and Parquet.
  • Iterations: 3 warm-up and 10 measured iterations per scenario and mode; median and p95 reported.
  • Modes: the start-up scenarios run cold (fresh browser context) and warm (shared context). The dataset scenarios run warm only, so the engine download doesn't add noise to the ingest and query numbers.
  • Environment: the reference laptop (below), on mains power, Chromium 156.0.8078.4 (Playwright's pinned build, headless).
Machine Lenovo ThinkPad (model 20LS)
CPU Intel Core i5-8250U, 4 cores / 8 threads, 1.6 GHz base
Memory 24 GB
OS Windows 11 Pro (10.0.26300)
Browser Chromium 156.0.8078.4, headless (full browser, not the headless shell)
Node 26.12.0

All times are in milliseconds, given as median / p95.

Cold start

Metric Cold Warm
Time to app shell 561 / 828 536 / 632
Time to engine ready 1,648 / 2,013 1,951 / 2,661
Bytes transferred until engine ready 34.6 MB 34.3 MB

The cold start meets its 4 s budget. The warm start misses its 1 s budget (finding 6).

Ingest

Dataset Import First query (count(*))
S narrow CSV 409 / 477 161 / 276
S narrow Parquet 303 / 318 12 / 13
S wide CSV 1,000 / 2,018 564 / 1,154
S wide Parquet 357 / 534 16 / 25
M narrow CSV 484 / 1,118 513 / 1,359
M narrow Parquet 338 / 591 16 / 40
M wide CSV 1,037 / 1,399 2,587 / 3,090
M wide Parquet 357 / 427 21 / 26

Query set

Each query returns at most 40 rows, so these times are the engine's work.

Dataset Scan Filter Group-by Join Window
S narrow CSV 145 / 340 152 / 394 184 / 305 174 / 257 294 / 580
S narrow Parquet 41 / 42 121 / 128 68 / 71 55 / 56 144 / 162
S wide CSV 559 / 1,163 559 / 1,029 578 / 1,214 593 / 1,206 721 / 979
S wide Parquet 55 / 97 137 / 275 80 / 137 66 / 128 168 / 359
M narrow CSV 576 / 1,675 595 / 1,571 644 / 1,551 670 / 1,334 1,546 / 2,705
M narrow Parquet 163 / 308 1,096 / 1,402 248 / 277 192 / 229 1,011 / 1,226
M wide CSV 2,682 / 3,075 2,658 / 3,201 2,804 / 4,143 2,806 / 3,234 3,556 / 5,334
M wide Parquet 173 / 235 1,052 / 1,458 254 / 347 198 / 245 1,011 / 1,150

Transfer, convert and render (10,000-row result)

SELECT * … LIMIT 10000. The count is the same rows counted in the engine instead, so query − count is roughly the cost of transferring the rows to the main thread. Render runs from the result reaching the main thread until the frame after its rows are committed, so it includes the conversion.

Dataset Query Count Convert Render
S narrow CSV 118 / 233 118 / 176 40 / 74 2,694 / 3,314
S narrow Parquet 43 / 45 16 / 16 37 / 43 2,225 / 2,300
S wide CSV 456 / 816 474 / 676 129 / 280 14,525 / 18,857
S wide Parquet 374 / 817 23 / 42 162 / 273 13,246 / 18,107
M narrow CSV 162 / 321 157 / 334 37 / 73 2,494 / 4,038
M narrow Parquet 43 / 60 18 / 24 38 / 49 2,509 / 4,074
M wide CSV 464 / 755 438 / 920 133 / 173 12,259 / 14,690
M wide Parquet 348 / 488 29 / 44 149 / 201 12,733 / 16,925

Memory (10,000-row result on screen)

From performance.measureUserAgentSpecificMemory(); the spread across iterations is under 1%.

Dataset Page total Engine worker
Narrow (any size or format) 88–96 MB 34.5 MB (CSV), 42.5 MB (Parquet)
Wide (any size or format) 317–324 MB 34.5 MB (CSV), 42.5 MB (Parquet)

Findings

  1. Rendering is the bottleneck, by far. Showing 10,000 rows takes 2.2–2.7 s with 8 columns and 12–15 s with 50 columns, against a budget of 100 ms for first rows. The conversion to row objects is only 37–162 ms of that; the rest is React rendering and laying out every cell (500,000 <td> elements for a wide result). It costs the same for S and M, because the result size is fixed. This is the target of P-03 and P-04 (virtualization).
  2. The DOM holds most of the memory. A wide 10,000-row result adds about 230 MB to the page compared with a narrow one, while the engine worker stays the same size. P-02 to P-04 should bring this down with the render cost.
  3. CSV files are parsed again on every query. Every query on M wide CSV costs about 2.7 s because the view reads the CSV each time, while the same queries on Parquet take 0.17–1 s. This is P-09 (convert CSV to Parquet on import).
  4. Transferring rows is cheap but scales with width. For Parquet, transferring 10,000 rows adds about 25 ms with 8 columns and 320–350 ms with 50 columns.
  5. Filter and window queries on Parquet are slow relative to scans (about 1 s on M, against 163 ms for the scan). The filter reads a string column (status) and a nullable float, and the window sorts within each machine. Worth profiling for P-08 (multithreaded bundle).
  6. The warm start isn't warm. In warm mode the engine still downloads 34.3 MB and is ready no sooner than cold (1.95 s vs 1.65 s), so the WebAssembly and worker aren't being served from cache. The 1 s warm budget is missed. This is P-07 (lazy-load and cache the engine).

Trade-offs: The instrumentation itself is cheap: four performance.measure() calls per import or query. Rendering waits one frame before recording the render measure, and that wait is part of the measurement.

Limitations:

  • Worker memory looks incomplete. It's the same for every size and shape, which suggests Chromium's measurement isn't counting all of DuckDB's WebAssembly memory. Treat the worker column as a lower bound until this is checked against a DevTools heap snapshot.
  • No throttled profile yet. The 4× CPU slowdown profile isn't part of this run.
  • Chromium only. Firefox and WebKit can run the same scenarios (--browser), but have no memory API.
  • Scroll frame times, long tasks and bundle size aren't measured here. Long-task detection is planned in M2 (#25), and bundle size comes with size-limit.

Evidence: Raw results: bench/results/2026-10-17T04-10-23Z-d73713b.json. To reproduce: pnpm bench:data, then pnpm bench.

Conclusion: Keep as the baseline. M2 starts where the numbers point: rendering (P-03, P-04) and holding results as Arrow (P-02).

Clone this wiki locally