Skip to content

perf(zql): stop MemorySource#fetch being a generator - #6509

Closed
arv wants to merge 1 commit into
mainfrom
arv/ivm-fetch-iterators
Closed

perf(zql): stop MemorySource#fetch being a generator#6509
arv wants to merge 1 commit into
mainfrom
arv/ivm-fetch-iterators

Conversation

@arv

@arv arv commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Chained generators are the most expensive way to move a row through the IVM
pipeline, on both engines we care about. This removes them from
MemorySource's fetch path.

Why

Profiling the ZQL benchmarks on Hermes (harness in #6507) put 5.4% of
hydrate: issues with creator in generator resume plumbing alone — next and
generatorPrototypeResume frames — before counting what hides inside the
generator bodies themselves. A four-deep pipeline over 5000 rows, measured in
the app's own runtime:

Hermes V8
4 chained generators 31.9 ms 4.0 ms
4 chained manual iterators 16.2 ms (−49%) 1.2 ms (−69%)
4 chained push callbacks 12.5 ms 0.8 ms

Relative to a plain loop, four chained generators cost 31x on Hermes and
74x on V8. V8 optimizes plain loops and hand-written iterators very
aggressively and generators are a barrier it will not cross, so removing a
generator layer pays more there. This is not a Hermes-specific fix.

What changed

All of it confined to the fetch path in memory-source.ts:

  • generateRows was a pure yield* passthrough over something that is
    already an IterableIterator. That whole generator layer bought nothing and
    cost a resume per row. It now returns the BTree's iterator directly.
    Single-use semantics are unchanged: an IterableIterator returns itself from
    [Symbol.iterator](), exactly as a generator does.

  • #fetch is no longer a generator. Its setup contains no yields, so the
    three exits become plain returns. The hot path — no overlay, no start, no
    filters, which is what a plain scan and every join child-lookup take — is now
    a hand-written ConstrainedRowIterator rather than a generator loop.

  • generateRows picked its method with data[reverse ? a : b](). The
    computed member forces a dynamic lookup Hermes cannot inline-cache; the
    ternary-of-calls form measured 18% faster there. See the caveat below.

The part worth reviewing closely

#fetch keeps its laziness. A generator body does not run until the first
next(), and this one reads #overlay and conn.lastPushedEpoch — a caller
may legitimately fetch() and only iterate after a push. Making #fetch an
ordinary eager function would move that snapshot earlier and change overlay
behaviour in exactly the cases the surrounding comments warn about. LazyStream
preserves the timing exactly: setup on first next(), single-use,
[Symbol.iterator]() returns itself.

It also forwards return(), so early termination still closes the underlying
scan. The mergeSortedStreams comment is explicit about why that matters: a
leaked SQLite cursor makes later writes on the same connection fail with
"database connection is busy executing a query".

Measurements

Android emulator, median of 3 runs (rn-bench --repeat 3, both sides rebuilt;
baseline spreads 0.3–3.5% except add comment at 6.9%). V8 is
pnpm --filter zql-benchmarks run bench ivm-memory -t hydration, median of 3.

benchmark Hermes V8
hydrate: issues filtered open −13.1% −17.4%
hydrate: issues only −6.6% −14.7%
hydrate: issues with creator + comments −4.5% −9.0%
hydrate: issues with creator −3.8% −19.6%
hydrate: issues limit 50 −2.0% −10.4%

Push is flat on Hermes (−1.2% to +3.7%, inside the noise) — it does not run
this path.

Caveat on the third change

The o[b ? 'a' : 'b']()b ? o.a() : o.b() result is Hermes-measured
only
. I could not get a trustworthy V8 number: the computed form timed 1.0 ms
for one branch and 19.9 ms for the other while the ternary sat at ~9.6 ms both
ways, stable across runs and two probe designs. 1.0 ms for 2M allocating calls
is 0.5 ns each, so V8's escape analysis is scalar-replacing the returned object
asymmetrically and the microbenchmark is not measuring the property access.
Treat that edit as Hermes-motivated and V8-unknown. It is subsumed by the
end-to-end numbers above, which do show a V8 win overall.

Verification

  • zql 1448 · zqlite 199 · zero-cache 5078 · zero-client 659 tests pass.
  • check-types, lint, check-format clean.
  • zero-client has one pre-existing failure (logged-out client uses a private storage sentinel for idb naming) that fails identically on main.

Scope

Deliberately limited to memory-source.ts's fetch path — that is where the
profile pointed and where the measurement confirms the win.
generateWithOverlay, mergeSortedStreams and the operator-level generators
are untouched; the repo has ~120 yield* sites and sweeping them would be a
separate change needing its own measurement.

Chained generators are the most expensive way to move a row on Hermes. A
four-deep pipeline over 5000 rows measured 31.9 ms against 16.2 ms for the
same chain written as manual iterators and 1.2 ms for a plain loop, and a CPU
profile of `hydrate: issues with creator` put 5.4% of the benchmark in
generator resume plumbing alone, before counting what hides inside the
generator bodies themselves.

Three changes, all confined to the fetch path:

- `generateRows` delegated with `yield*` to something that is already an
  `IterableIterator`. That whole generator layer bought nothing and cost a
  resume per row; it now returns the BTree's iterator directly. Single-use
  semantics are unchanged -- an `IterableIterator` returns itself from
  `[Symbol.iterator]()`, exactly as a generator does.

- `#fetch` is no longer a generator. Its setup contains no yields, so the
  three exits become plain returns. The hot path -- no overlay, no start, no
  filters, which is what a plain scan and every join child-lookup take -- is
  now `ConstrainedRowIterator` rather than a generator loop.

- `generateRows` picked its method with `data[reverse ? a : b]()`. The
  computed member forces a dynamic lookup Hermes cannot inline-cache; the
  ternary-of-calls form measured 18% faster on the same object.

`#fetch` keeps its laziness. A generator body does not run until the first
`next()`, and this one reads `#overlay` and `conn.lastPushedEpoch`, so a
caller may fetch and only iterate after a push. `LazyStream` preserves that
timing exactly, and propagates `return()` so early termination still closes
the underlying scan -- a leaked SQLite cursor makes later writes on the same
connection fail.

Android emulator, median of 3 runs (rn-bench --repeat 3):

  hydrate: issues filtered open            87.95 -> 76.46 ms  -13.1%
  hydrate: issues only                     77.74 -> 72.64 ms   -6.6%
  hydrate: issues with creator + comments 888.98 -> 849.31 ms  -4.5%
  hydrate: issues with creator            405.42 -> 389.99 ms  -3.8%
  hydrate: issues limit 50                 11.72 -> 11.48 ms   -2.0%

Push is flat, as expected -- it does not run this path. Baseline spreads were
0.3-3.5% except 'add comment' at 6.9%.
@vercel

vercel Bot commented Sep 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
replicache-docs Ready Ready Preview Sep 8, 2026 2:37pm UTC
zbugs Ready Ready Preview Sep 8, 2026 2:37pm UTC

Request Review

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