Skip to content

perf: AST-free preBaseQuery generation (node + browser, skip DuckDB round-trip)#294

Open
zaidjan-devrev wants to merge 5 commits into
mainfrom
perf/ast-free-prebasequery-generation
Open

perf: AST-free preBaseQuery generation (node + browser, skip DuckDB round-trip)#294
zaidjan-devrev wants to merge 5 commits into
mainfrom
perf/ast-free-prebasequery-generation

Conversation

@zaidjan-devrev

@zaidjan-devrev zaidjan-devrev commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Summary

Speeds up cube-to-SQL generation by removing a DuckDB round-trip that exists
purely to convert an AST into a SQL string, in both meerkat-node and
meerkat-browser.

The cubeToDuckdbAST → json_deserialize_sql round-trip only produces the outer
query skeleton (SELECT * FROM REPLACE_BASE_TABLE [WHERE] [GROUP BY] [ORDER BY] [LIMIT]); the SELECT-list / FROM parts are string-replaced downstream anyway.
For queries whose filters contribute no WHERE clause, the skeleton is now
built directly in TypeScript (buildPreBaseQuerySync), skipping the round-trip.
Queries with a real WHERE clause fall back to the AST path, so the filter
operator grammar is untouched.

Also parallelizes the two independent steps in cube-to-sql (node + browser) via
Promise.all: the preBaseQuery build and the PROJECTION_FILTER param
resolution.

Impact — node (in-memory DuckDB)

Isolated preBaseQuery step, same machine, back to back: 0.140ms → 0.004ms
(~31x)
. End-to-end cubeQueryToSQL on the dim_issue shape (375 dims, 14
projected, empty filters): ~0.38ms → ~0.24ms (~37%).

Impact — browser (duckdb-wasm Web Worker)

Measured with the real cubeQueryToSQL against a live duckdb-wasm worker,
driven by puppeteer via a new benchmarking/gen-benchmark route. In the browser
each round-trip is a postMessage hop to the single-threaded worker.

Structural change: the fast path issues 0 worker round-trips for
generation (vs 1 on baseline) — generation is now entirely main-thread.

Sequential (idle worker): ~0.42ms → ~0.21ms (~1.7x). This is the win when
the worker is free; local wasm round-trips are cheap.

Under worker contention — the real win. Fire N generations concurrently
while a heavy data query (~10ms, 200k-row self-join) occupies the worker.
Median of 5, stable across runs:

N concurrent generations Fast path Baseline Speedup
1 0.23ms 10.5ms ~47x
5 0.81ms 10.2ms ~12x
10 1.6ms 10.6ms ~6.4x
25 3.9ms 13.1ms ~3.3x
50 7.6ms 15.7ms ~2.1x
100 9.2ms 21.0ms ~2.3x

How to read it: the fast path scales linearly with N (pure main-thread CPU, runs
concurrently with the busy worker). Baseline is pinned to the worker's ~10ms
queue floor — every generation's round-trip waits behind the in-flight data
query on the single-threaded worker (head-of-line blocking), then behind each
other. The multiplier depends on how heavy the concurrent query is and how often
generation overlaps a busy worker; the durable fact is that the fast path is
immune to worker contention (0 hits).

Correctness

Output is byte-identical. A differential parity test asserts
buildPreBaseQuerySync matches the live DuckDB round-trip across: order-only,
group-by + order, limit/offset, custom-alias quoting ("Weird Name"),
multi-order-key, nested-empty filter groups, and the dim_issue production
shape. The gate returns false when a real filter is present.

Also in this PR

  • Fix type-only re-exports of Graph (export type) in the joins barrel so it
    resolves under Vite/esbuild per-file transpile. Pre-existing latent issue
    surfaced by importing meerkat-core into the Vite dev app.

Scope note

Batch filter-param deserialization was removed from this PR — the target
input has no FILTER_PARAMS placeholders, so it never fired. It can return as its
own PR for filter-heavy schemas.

Test plan

  • nx test meerkat-core — 550 pass
  • nx test meerkat-node — 909 pass (incl. POC parity + perf specs)
  • nx test meerkat-browser — pass
  • nx build meerkat-core / meerkat-browser / meerkat-node
  • Differential byte-parity vs DuckDB round-trip
  • Browser worker benchmark: sequential, parallel sweep (1..100), head-of-line under load
  • Review scope boundary: WHERE/HAVING grammar not ported (falls back to AST)

DevRev

work-item: TBD — no issue ID in branch and the DevRev MCP was unavailable in
this session; please link/create the tracking issue before merge.

Follow-ups (not in this PR)

  • Port the WHERE/HAVING operator grammar to fully remove the round-trip
    (also removes the worker hop for filter-heavy browser queries)
  • Batch filter-param deserialization for filter-heavy schemas
  • Thread AbortSignal into the node generation path (no cancellation today)

🤖 Generated with Claude Code

… preBaseQuery

Optimizes the cube-to-SQL generation pipeline by cutting DuckDB round-trips
that exist purely to convert AST <-> SQL text.

Changes:
- Batch filter-param deserialization: getFilterParamsSQL issued one DuckDB
  round-trip per FILTER_PARAMS placeholder; now packs all ASTs into one SELECT
  with N aliased json_deserialize_sql columns (N->1 round-trips). Early-returns
  when no filter params. ~3x on filter-heavy schemas.
- AST-free preBaseQuery builder: for queries without a projection-filter WHERE
  clause, build the outer skeleton (GROUP BY / ORDER BY / LIMIT) directly in TS,
  skipping the cubeToDuckdbAST -> json_deserialize_sql round-trip entirely.
  Falls back to the AST round-trip when a real WHERE clause is present.
  ~39% faster on wide-schema/no-filter inputs (dim_issue: 0.379ms -> 0.232ms).
- Parallelize independent round-trips in cube-to-sql (node + browser) via
  Promise.all: preBaseQuery build and PROJECTION_FILTER param resolution.
- Tighten getFilterParamsSQL getQueryOutput type (was any).

Output is byte-identical: verified by a differential parity test against the
live DuckDB round-trip across order/group-by/limit/alias-quoting/nested-empty
cases plus the dim_issue production shape.

Tests: meerkat-core 550 pass, meerkat-node 913 pass. No regressions.

work-item: TBD
The sample input this PR targets (wide dim_issue schema, empty filters) has no
FILTER_PARAMS placeholders, so the batching optimization never fires for it.
Revert getFilterParamsSQL and ast-deserializer to main and remove the
batch-focused perf spec, keeping this PR scoped to the AST-free preBaseQuery
fast-path (and the Promise.all parallelization) that actually helps the target
input.

Batching can return as its own PR for filter-heavy schemas.

work-item: TBD
@zaidjan-devrev zaidjan-devrev changed the title perf: speed up SQL generation via batching + AST-free preBaseQuery perf: AST-free preBaseQuery generation (skip DuckDB round-trip) Jul 6, 2026
…chmark

- Wire the AST-free preBaseQuery fast-path into meerkat-browser cubeQueryToSQL,
  mirroring meerkat-node. In the browser each cubeToDuckdbAST -> json_deserialize_sql
  round-trip is a postMessage hop to the duckdb-wasm Web Worker, so skipping it
  matters more than in node.
- Add a browser generation benchmark route (benchmarking/gen-benchmark) that runs
  the real cubeQueryToSQL against a live duckdb-wasm worker connection, driven by
  puppeteer. Measures worker hits, sequential latency, parallel contention, and
  head-of-line blocking behind a heavy data query.
- Fix type-only re-exports of `Graph` (export type) so the joins barrel resolves
  under Vite/esbuild's per-file transpile (isolatedModules). Pre-existing latent
  issue surfaced by importing meerkat-core into the Vite dev app.

Measured (duckdb-wasm worker, dim_issue shape, empty filters):
- Fast path issues 0 worker round-trips for generation vs 1 on baseline
  (generation now fully main-thread).
- Sequential full gen: ~0.42ms -> ~0.21ms (~1.9x).
- Generation latency while the worker is busy with a heavy data query:
  ~12ms (baseline, queued behind the query) -> ~0.22ms (fast path, main thread) — ~55x.

work-item: TBD
@zaidjan-devrev zaidjan-devrev changed the title perf: AST-free preBaseQuery generation (skip DuckDB round-trip) perf: AST-free preBaseQuery generation (node + browser, skip DuckDB round-trip) Jul 6, 2026
…ention

Rework the browser generation benchmark to sweep N concurrent generations
(1..100) fired WHILE a heavy data query occupies the single duckdb-wasm worker,
median of 5 per point. This is the realistic contention scenario: baseline
generations queue behind the data query (and each other) on the worker; the
fast path runs on the main thread.

work-item: TBD
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant