perf: lazy SQL fingerprint + prototype aggregate reducers (−19.5 µs/op) - #30181
Draft
StevenMcClankerton wants to merge 3 commits into
Draft
perf: lazy SQL fingerprint + prototype aggregate reducers (−19.5 µs/op)#30181StevenMcClankerton wants to merge 3 commits into
StevenMcClankerton wants to merge 3 commits into
Conversation
Contributor
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: trueComment |
`recordTelemetry` ran on every execution in both query lanes and did real work unconditionally: three regex passes over the statement, a `toLowerCase()`, and a SHA-256 — then froze the result. Nothing consumed it unless the caller went on to call `runtime.telemetry()`, which most never do. The event now carries the raw SQL and derives the fingerprint on first read, memoised for subsequent reads and cleared when the next execution starts. The observable contract is unchanged: same fields, same fingerprint value, still frozen, still grouping statements that differ only in their literals. Measured on `db.orm`, 5 interleaved reps, paired within one build: list 284.6 -> 270.7 us/op (-13.9, won 5/5), point 211.8 -> 198.6 (-13.2, 5/5). The win lands below both lanes, so `db.sql` collects it too. Five tests added first, pinning the fingerprint contract — stability across repeated reads, literal-insensitivity, and structural separation — so the refactor could not quietly change it. Signed-off-by: Steven McClankerton <tatarintsev@prisma.io>
…ototype
The collection constructor installed one include-scalar reducer per contributed
aggregate operation with `Object.defineProperty(this, ...)`. Chaining is
immutable and every clone runs the constructor, so a four-link chain
(`select().orderBy().limit().offset()`) paid eight `defineProperty` calls per
link — 32 per request on a contract declaring the Postgres target's eight
operations.
ORM composition now derives one subclass per (base class, aggregate registry)
pair and installs the reducers on its prototype. `#createSelf` clones through
`this.constructor`, which is that subclass, so every link inherits them at no
cost. A four-link chain now performs zero `defineProperty` calls.
Reducers use dynamic `this` rather than a captured instance, so one prototype
serves every collection built from the registry. The factory lives in a class
static-initialisation block assigning a module-level binding: it needs the
private `#includeScalarReducer`, and a static member would have put an
`@internal` symbol into the published `.d.mts`. The emitted declaration file
diffs byte-identical against a pristine build.
Behaviour is preserved on both paths that mattered:
- Direct `new Collection(...)` — which the integration suite uses heavily —
still takes the per-instance path, gated on a symbol the derived prototype
carries.
- A custom collection class registered through `orm({ collections })` keeps its
own member over a same-named operation. The old check walked the prototype
chain from the instance; the new one tests the base prototype, which resolves
identically for a prototype method, and an instance field still shadows the
reducer as it did before.
- `reservedCollectionMemberNames()` scans `CollectionImpl.prototype`, which is
never mutated — reducers only ever land on a derived prototype.
- Two ORMs with different registries do not leak operations into one another.
Measured on `db.orm`, 5 interleaved reps, paired within one build:
list 284.6 -> 277.4 us/op (-7.2, won 5/5), point 211.8 -> 206.8 (-5.0, 5/5).
Eight tests added first, covering the prototype placement, chained clones,
registry isolation, the direct-construction fallback, the custom-member
collision, and the unchanged reserved set.
Signed-off-by: Steven McClankerton <tatarintsev@prisma.io>
…ring
`getColumnToFieldMap` and `getFieldToColumnMap` were already memoised per
contract, but every call built a `${namespaceId}\0${modelName}` template string
to index the inner map. The column map is consulted once per returned row, so
the allocation scaled with result size.
Both now use a nested `WeakMap<contract> -> Map<ns> -> Map<model>`, so a lookup
allocates nothing. The three colder callers (polymorphism, complete
column-to-field, model relations) keep the string key.
Measured on `db.orm`, 5 interleaved reps, paired within one build:
list 284.6 -> 276.2 us/op (-8.4, won 4/5), point 211.8 -> 210.6 (-1.2, 4/5).
The seven-to-one split between the ten-row list query and the single-row point
lookup is what identifies this as a per-row cost.
Also adds six characterisation tests for `mapStorageRowToModelFields`, which
sits on the same per-row path. They were written for an allocation-free rewrite
of that function that measured as a regression and was reverted (list +2.5
us/op winning 2/5, point +8.6 us/op winning 0/5). The tests are kept because
they pin behaviour that had none — inherited enumerable properties are ignored,
null and undefined column values survive, the result does not alias the row.
Signed-off-by: Steven McClankerton <tatarintsev@prisma.io>
@prisma/orm-extension-arktype-json
@prisma/orm-extension-middleware-cache
@prisma/orm-extension-paradedb
@prisma/orm-extension-pgvector
@prisma/orm-extension-postgis
@prisma/orm-extension-supabase
@prisma/orm-family-mongo
@prisma/orm-family-sql
@prisma/orm-framework
@prisma/orm-mongo
@prisma/orm-postgres
@prisma/orm-sqlite
@prisma/orm-target-mongo
@prisma/orm-target-postgres
@prisma/orm-target-sqlite
@prisma/orm-toolchain
commit: |
Contributor
size-limit report 📦
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Linked issue
n/a — no ticket. This came in as a profiling brief with a measured baseline, not through issue triage.
Skill update
n/a — internal only. No user-facing surface changes: the emitted SQL is byte-identical, and the published
.d.mtsdiffs byte-identical against a pristine build.At a glance
The call site does not change. Its cost does.
Client CPU µs per operation, median of 5 interleaved reps, both arms compiled into one build. The two raw
pgrows are the control: nothing here can touch the driver, so a framework change that moved them would mean machine drift rather than a win.The benchmark that produced these is deliberately not in this PR — see How the numbers were produced.
Reviewer notes
collection.tsuses a class static-initialisation block assigning a module-level binding. That looks odd on the diff and is deliberate: the reducer factory needs the private#includeScalarReducer, and a plainstaticmember put an@internalsymbol into the published.d.mts. The static block keeps the private access and emits nothing.operation in thisinside the constructor; the new one isoperation in Base.prototype. These resolve identically for a subclass prototype method, and an instance field still shadows the inherited reducer exactly as it shadowed the own-property one.aggregate-reducer-installation.test.tspins both cases.pnpm test:packagesfails 28 tests on this branch and the identical 28 on a pristinemain: every one is a Mongo suite whosemongodb-memory-serverbinary cannot be downloaded in this environment, plus three tarball packaging tests. Two further files (module-identity,migration-cli.exit-scheme) failed only under full parallel load and pass in isolation on both trees.Decision
Three changes ship, each measured on its own against the same build:
sql-runtimefingerprints SQL lazily.recordTelemetryran three regex passes, atoLowerCase()and a SHA-256 over the full statement on every execution in both lanes, whether or not anyone readruntime.telemetry(). It now stores the raw SQL and derives the fingerprint on first read.sql-orm-clientinstalls aggregate reducers on a per-registry prototype. They were installed per instance withObject.defineProperty, and since every chain link clones through the constructor, a four-link chain paid 32 of those calls per request. It now pays zero.sql-orm-clientkeys its two hottest metadata caches by nesting rather than by string. The column map is consulted once per returned row, and each lookup was building a template-string key.A fourth was killed on the evidence and is not in this PR: rewriting
mapStorageRowToModelFieldsto avoid two per-row allocations profiled at 3.8% of CPU and measured slower — the point lookup lost all five reps at +8.6 µs. Reverted; its tests kept.The PR carries only the three production changes and their tests. The benchmark harness is not included.
How it fits together
publicnamespace with snake_case columns and the eight aggregate operations the Postgres target declares, seeded to 191k rows. Nine of ten baseline rows land within 10% of the brief's table; the prepared point lookup sits at +15.4%, so every claim below is made against the locally measured baseline rather than the brief's.--cpu-profcame back 58%(idle)— the round-trip. Dropping the first 30% of the timeline and excluding idle samples puts#installAggregateReducersat 4.2% of client CPU, and fingerprinting (computeSqlFingerprint+Hash+ the whitespace regex) at roughly 4.5%.log_statement='all'— each statement appears in the Postgres log exactly twice, once per arm.Behavior changes & evidence
Telemetry fingerprints are computed on read, not on execution.
RuntimeTelemetryEventis unchanged — same fields, same value, still frozen, still grouping statements that differ only in their literals. Lands below both lanes, sodb.sqlcollects it too:db.sql-prepared moves −10.9 µs on the list shape. Seepackages/2-sql/5-runtime/src/sql-runtime.ts; evidence inpackages/2-sql/5-runtime/test/sql-runtime.test.ts(5 tests written before the change, pinning read-stability, literal-insensitivity and structural separation).A chained ORM collection carries its reducers on a shared prototype.
db.orm.public.Customer.where(…).limit(…)still answerscount,avgand the rest; they are no longer own properties. Custom collection classes keep their own members over a same-named operation, directnew Collection(...)still takes the per-instance path, and two ORMs with different registries stay isolated. Seepackages/3-extensions/sql-orm-client/src/collection.tsandpackages/3-extensions/sql-orm-client/src/orm.ts; evidence inpackages/3-extensions/sql-orm-client/test/aggregate-reducer-installation.test.ts(8 tests).Model-metadata lookups allocate nothing. The win scales with row count — the ten-row list query gains seven times what the single-row point lookup does, which is what identifies it as per-row. See
packages/3-extensions/sql-orm-client/src/collection-contract.ts; evidence inpackages/3-extensions/sql-orm-client/test/collection-runtime.test.ts.Where the cost went
On the list query the ORM layer — the cost above
db.sql-per-request — was 62.1 µs and is now 50.2 µs: the prototype reducers and the metadata cache took out 11.9 µs, or 19% of it. The remaining 7.6 µs of the ORM's 19.5 µs win came from the lazy fingerprint, which sits below the ORM in shared runtime. On the point lookup the layer went from 29.9 to 20.8 µs.The list query's remaining budget splits as: 96.5 µs below both lanes (lowering, parameter encode, row decode — shared with
db.sql, deliberately untouched), 32.9 µs of per-request plan construction thatdb.prepareavoids, and 50.2 µs of ORM client.Two corrections to the brief
Recorded here because they will otherwise be rediscovered:
orderByaccessor does not mint ~110 closures per call.createModelAccessorreturns a Proxy; field accessors and their trait-gated comparison closures are built lazily in thegettrap, per property actually touched.orderBy(c => c.id.asc())builds exactly one.ModelAccessorScopecarries a mutablealiasCounter, incremented by#allocateAliaswhenever a relation filter needs a table alias. Sharing an accessor across requests would emit__orm_rel_1on one and__orm_rel_2on the next.Separately, and not stated in the brief: the two lanes never emitted byte-identical SQL. The ORM qualifies every column against the table (
SELECT "customers"."id" AS "id"), the builder does not (SELECT "id" AS "id"). The harness therefore runs a rawpgfloor per lane against that lane's exact text; the difference costs nothing measurable (78.5 vs 79.1 µs, inside noise).How the numbers were produced
The benchmark harness is kept out of this PR to keep it to the production change. What it does is worth stating, because the numbers above are only as good as the method:
process.cpuUsage(), poolmax: 1, sequential. TheΔ ops/scolumn comes from those same sequential runs and is not a concurrency number.pgfloor per lane, issuing that lane's byte-identical SQL text — the two controls in the table above.winscolumn is how many of the five reps the new arm won outright.publicnamespace, snake_case columns, the eight aggregate operations the Postgres target declares; seeded bygenerate_seriesto 191k rows (10k customers, 5k products, 1k suppliers, 200 employees, 50k orders, 125k order details).postgres:15-alpine,pg8.22, 3 000 iterations per rep after a 300-iteration warm-up.I can open the harness as a follow-up PR if the team wants it in-tree.
Testing performed
pnpm --filter @internal/sql-orm-client test— 785 passed (71 files), no type errorspnpm --filter @internal/sql-runtime test— 348 passed (38 files)pnpm --filter integration-tests test test/sql-orm-client— 335 passed (42 files, PGlite)pnpm lint:deps— no dependency violations (2012 modules, 3127 dependencies)pnpm build— full workspace buildpnpm test:packages— 15 478 passed; 28 failures reproduced identically on pristinemain(see reviewer notes)log_statement='all'.d.mtsdiffed against a pristine build — byte-identicalFollow-ups
db.prepareis builder-only, so the ORM cannot recover 32.9 µs of plan construction on the list query or 53 µs on the point lookup. Larger than everything in this PR combined, and a feature rather than an optimisation — worth its own ticket.#clone. Cheaper to attempt now that reducers no longer live on the instance.Alternatives considered
freezemeasures 0.8% of CPU, roughly 2 µs. The frozen-node invariant is specified in the architecture docs and relied on across the IR; trading it for 2 µs is a call for the team, not a profiling task, so it was left alone.CollectionImpl.prototypedirectly instead of deriving a subclass. Simpler, and wrong: two contracts in one process would leak each other's operations, andreservedCollectionMemberNames()scans that prototype.staticon the class. It works, and it publishes an@internalsymbol in the.d.mts. The static-initialisation block gets the same private access with no emitted surface.pgfloor drifting ±6 µs between runs. Everything quoted here is paired within one build.Checklist
git commit -s) per the DCO.TML-NNNN: <sentence-case title>form — not applicable, there is no Linear ticket for this work. Titled in the repo's conventional-commit form instead, matchingci:anddocs(skills):in the recent log. Happy to retitle if a ticket is opened.