Skip to content

perf(client-generator-ts,client-generator-js): read the search queue by index instead of shift() - #30199

Open
lpbonomi wants to merge 1 commit into
prisma:v7from
lpbonomi:generic-args-info-linear
Open

lpbonomi wants to merge 1 commit into
prisma:v7from
lpbonomi:generic-args-info-linear

Conversation

@lpbonomi

@lpbonomi lpbonomi commented Sep 2, 2026 •

Copy link
Copy Markdown

Problem

prisma generate spends almost all of its time in GenericArgsInfo.typeNeedsGenericModelArg on schemas with a few hundred models. A CPU profile (node --cpu-prof) of prisma generate --generator client on a 316-model schema, Prisma 7.9.1, on Machine A below: 241 s of 272 s in that function; the same schema takes 165 s there without the profiler and 139 s on 7.10.0. #29308 reports the same shape (530 models, 235 s on 7.3.0).

The function runs a breadth-first search over the input types reachable from a type and consumes its queue with Array.prototype.shift(). The search from a create input visits most of the nested create inputs of the schema, so the queue grows to more than a hundred thousand items, and at that size V8's shift() is linear in the array length: the search becomes quadratic in its own queue. The traversal itself is already linear in the number of types and references; the negative result caches every visited type, and only a few dozen types of a real DMMF are positive because the *WhereInput family carries meta.source.

Change

Read the queue through an index instead of shift(). Same order, same visits, same cache behaviour; one line in each of the two generator packages, which carry the same file.

Correctness

  • 316-model schema (192 enums, 37k input types, 3,899 FieldRef sites): generated client byte-identical for all 326 files (diff -r against the released generator's output).
  • packages/internals/src/__tests__/__fixtures__/odoo.prisma (168 models) and synthetic schemas at 100, 200, 300 and 500 models: byte-identical at every size.
  • The existing GenericsArgsInfo.test.ts tests and both packages' suites pass unchanged (see below).

Performance

Generator time reported by the CLI ("Generated Prisma Client in ..."), measured by applying this same one-line change to the released prisma@7.9.1 bundle and running it against the unmodified bundle on the same schema and machine. The synthetic schemas come from the script below (N models, each with K relations to random other models, seeded); odoo.prisma is packages/internals/src/__tests__/__fixtures__/odoo.prisma.

Machine A — AWS r7i VM: Intel Xeon Platinum 8488C, 8 vCPU, 61 GB, Ubuntu 24.04 (glibc 2.39), Node 24.18.

schema models relations/model stock patched speedup peak RSS stock / patched
private schema 316 165 s 6.1 s 27× 2.06 / 2.02 GB
odoo.prisma fixture 168 73.1 s 3.6 s 20× 1606 / 1509 MB
synthetic 100 4 2.1 s 0.9 s 2.3× 558 / 550 MB
synthetic 200 4 8.0 s 2.0 s 4.0× 749 / 900 MB
synthetic 300 4 18.4 s 3.1 s 5.9× 1054 / 1028 MB
synthetic 500 4 55.0 s 5.6 s 9.8× 1668 / 1643 MB
synthetic 300 8 113.7 s 6.3 s 18.0× 1808 / 2225 MB

Machine B — MacBook Pro 16-inch 2023, Apple M3 Pro (6 performance + 6 efficiency cores), 36 GB, macOS 26.4.1.

Node 24.18 (V8 13.6), median of 3 runs:

schema models relations/model stock patched speedup peak RSS stock / patched
odoo.prisma fixture 168 19.5 s 1.97 s 9.9× 1778 / 1748 MB
synthetic 100 4 0.53 s 0.47 s 1.1× 581 / 582 MB
synthetic 200 4 1.35 s 1.00 s 1.4× 902 / 853 MB
synthetic 300 4 2.53 s 1.74 s 1.5× 1208 / 1209 MB
synthetic 500 4 14.5 s 2.85 s 5.1× 1846 / 1837 MB
synthetic 300 8 10.0 s 3.05 s 3.3× 1997 / 2038 MB

Node 22.22, single run:

schema models relations/model stock patched speedup
odoo.prisma fixture 168 34.3 s 2.00 s 17.1×
synthetic 500 4 13.7 s 2.85 s 4.8×
synthetic 300 8 42.6 s 3.21 s 13.3×

Machine dependence. The stock cost is the per-element cost of shift() once the queue passes the 128 KB large-object threshold, which V8 no longer left-trims: a memmove with an 8-byte overlap plus per-slot bookkeeping. That constant depends on the V8 version and on the CPU and libc. On Machine B, Node 24 (V8 13.6) is 2–4× faster than Node 22 on the same shapes; on Machine A, Node 22, 24 and 26 are within 15% of each other because its CPU/libc pair handles the 8-byte-overlap memmove at 2.9 GB/s against 51 GB/s for any other offset (rep movsb small-overlap path; with GLIBC_TUNABLES=glibc.cpu.x86_rep_movsb_threshold=1000000000 its 316-model generation takes 87 s instead of 165 s). A 4-vCPU Blacksmith CI runner (Node 26.8.1) generates the same private schema in 7.9 s stock. The change removes the shift() cost on every machine and version; the patched times are the rest of the generator, flat at 2–6 s across all of the above.

Peak RSS is within a few percent in every case but the densest synthetic schema on Machine A, where the default V8 heap grows 23% larger (the consumed part of the queue stays referenced until the search returns). That is GC slack, not retained data: with --max-old-space-size=1400 the same run finishes in the same time at 1837 MB, the stock figure, and it still completes under a 1000 MB cap.

Reproduction: synthetic schema generator
// node gen-schema.mjs <models> [relationsPerModel=4] [seed=1] > schema.prisma
const N = Number(process.argv[2] ?? 100);
const K = Number(process.argv[3] ?? 4);
let seed = Number(process.argv[4] ?? 1);
const rand = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648);
const lines = [];
lines.push(`generator client {\n  provider = "prisma-client"\n  output   = "./out"\n}\n`);
lines.push(`datasource db {\n  provider = "postgresql"\n}\n`);
lines.push(`enum Kind {\n  ALPHA\n  BETA\n  GAMMA\n}\n`);
const back = Array.from({ length: N }, () => []);
const fwd = Array.from({ length: N }, () => []);
for (let i = 0; i < N; i++) {
  const targets = new Set();
  while (targets.size < Math.min(K, N - 1)) { const j = Math.floor(rand() * N); if (j !== i) targets.add(j); }
  for (const j of targets) {
    const name = `r${i}_${j}`;
    fwd[i].push(`  to${j} M${j}? @relation("${name}", fields: [to${j}Id], references: [id])\n  to${j}Id Int?`);
    back[j].push(`  from${i} M${i}[] @relation("${name}")`);
  }
}
for (let i = 0; i < N; i++) {
  lines.push(`model M${i} {\n  id        Int      @id @default(autoincrement())\n  name      String\n  count     Int      @default(0)\n  score     Float?\n  flag      Boolean  @default(false)\n  kind      Kind     @default(ALPHA)\n  meta      Json?\n  createdAt DateTime @default(now())\n${fwd[i].join("\n")}\n${back[i].join("\n")}\n}\n`);
}
process.stdout.write(lines.join("\n"));

With a prisma.config.ts next to it (datasource: { url: "postgresql://" }), run prisma generate with the released CLI and with a CLI that has this change, then diff -r the two out directories and compare the "Generated Prisma Client in ..." lines. The odoo.prisma fixture in this repository reproduces the effect without a synthetic schema.

Package suites

pnpm exec vitest run after turbo run build --filter=@prisma/client:

  • client-generator-js: 4 files, 20 tests, all pass (includes the end-to-end generation snapshot tests).
  • client-generator-ts: 4 files, 49 of 50 pass. The one failure, workerd - issue prisma#28073, fails identically on the unmodified v7 source in this environment: it needs query_compiler_fast_bg.sqlite.wasm from a built packages/cli.
  • GenericsArgsInfo.test.ts: 9 tests in each package, unchanged.

Related: #29308.

🤖 Generated with Claude Code

…by index instead of shift()

`GenericArgsInfo.typeNeedsGenericModelArg` runs a breadth-first search over
the input types reachable from a type and consumed its queue with
`Array.prototype.shift()`. On a schema with a few hundred models the search
from a create input visits most of the nested create inputs, and the queue
grows to more than a hundred thousand items; V8's `shift()` is linear in the
array length at that size, so the search is quadratic in its own queue.
A CPU profile of `prisma generate` on a 316-model schema put 241 s of 272 s
in that loop.

Reading the queue through an index keeps the same order and the same visits.
Generator time on the released 7.9.1 CLI with this change applied to its
bundle, generated output byte-identical:

  316-model schema (37k input types)        165 s -> 6 s
  odoo.prisma fixture (168 models)          73 s -> 3.6 s
  synthetic, N models x 4 relations each:
    100 models x 4 relations       2.1 s -> 0.9 s
    200 models x 4 relations       8.0 s -> 2.0 s
    300 models x 4 relations      18.4 s -> 3.1 s
    500 models x 4 relations      55.0 s -> 5.6 s
    300 models x 8 relations     113.7 s -> 6.3 s

Both generator packages carry the same file; both get the same change. The
existing tests and the generation snapshot tests pass unchanged.

Signed-off-by: luisopine <luis@tryopine.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@CLAassistant

CLAassistant commented Sep 2, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@coderabbitai

coderabbitai Bot commented Sep 2, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: ASSERTIVE

Plan: Team

Run ID: 603cd847-a988-448d-aad8-1009fab1c707

📥 Commits

Reviewing files that changed from the base of the PR and between 3dcc5b3 and a7d8ce2.

📒 Files selected for processing (2)
  • packages/client-generator-js/src/GenericsArgsInfo.ts
  • packages/client-generator-ts/src/GenericsArgsInfo.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 9 remain after this review.


📝 Walkthrough

Walkthrough

Both client generators now process the typeNeedsGenericModelArg breadth-first queue with an incrementing index instead of shift(). Traversal order remains unchanged.

Changes

Generic model traversal

Layer / File(s) Summary
Indexed BFS queue traversal
packages/client-generator-js/src/GenericsArgsInfo.ts, packages/client-generator-ts/src/GenericsArgsInfo.ts
The queue uses a monotonically increasing index. The breadth-first traversal order remains unchanged.

Estimated code review effort: 1 (Trivial) | ~2 minutes

Merge Risk: ⚪ Minimal · up to a7d8c

This change replaces queue shifting with indexed reads in the client generators while preserving traversal behavior and generated output; no actionable merge-blocking risk remains after normal checks and review.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 2…
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the performance change and names both affected generator packages. It accurately summarizes the main change from Array.prototype.shift() to indexed queue reads.
Full details: Docstring Coverage

Explanation

No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 2 files.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@SevInf

SevInf commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

That's a a great fix, thank you very much.
I wonder if we should reclaim the memory every once in a while when head pointer moves 2 far away from 0. 100k nested created types seems very excessive even for large schemas, we should probably investigate that separately.

@lpbonomi

lpbonomi commented Sep 8, 2026

Copy link
Copy Markdown
Author

Thank you @SevInf, do you need anything else from my side?

@philidem

Copy link
Copy Markdown

I hit this same function independently while profiling a slow prisma generate and landed on the identical fix before finding this PR, so here is a confirmation from a different machine and a newer runtime, plus some queue instrumentation that I think answers @SevInf's question about the 100k-item queue.

Disclosure: I work with @lpbonomi, so the schema below is a later revision of the "private schema" in the description, not an independent one. What is new here is the hardware/runtime, the instrumentation, and the enqueue experiment.

Setup

  • AMD Ryzen 9 9950X (16 cores / 32 threads, up to 5.76 GHz), 121 GB RAM, ext4
  • Ubuntu 26.04.1, kernel 7.0.0, glibc 2.43
  • Node 26.8.1 (V8 14.6), prisma@7.10.0, prisma-client generator, PostgreSQL
  • Schema: 331 models/views, 211 enums, 1,222 @relation attributes, ~16.6k lines
  • Command: prisma generate --generator client. The machine was moderately loaded (load average ~7 of 32 threads), so treat times as ±10%.

How I found it: node --cpu-prof on the stock CLI showed 13.2 s of 27.3 s total (48%) as self time in the typeNeedsGenericModelArg callback. After the change it no longer appears near the top of the profile.

Timing: stock vs this PR

I applied this PR's one-line change to both copies of the function in the released prisma@7.10.0 bundle and ran it against the unmodified bundle. "client" is the CLI's own "Generated Prisma Client in ..." figure, 3 runs each, interleaved:

variant client (3 runs) wall (3 runs) peak RSS
stock 7.10.0 18.1 / 17.3 / 18.2 s 21.0 / 20.0 / 21.1 s 2.23 GB
this PR 4.8 / 5.3 / 5.3 s 7.8 / 8.1 / 8.1 s 2.78 GB

That is about 3.5× on the client generator, on a current desktop CPU with a recent Node, V8 and glibc. So this is not only a slow-VM or old-V8 problem. In our real workflow the client generator is one of three generators, and this change takes the whole prisma generate step from about 26 s to about 14 s.

Generated output is byte-identical to stock (diff -rq over the whole output directory).

Memory

Peak RSS is about 25% higher with the change on the default heap, which matches what the description reports for the densest synthetic schema. I checked the GC-slack explanation on this schema and it holds: with NODE_OPTIONS=--max-old-space-size=1400 the patched run takes the same time (5.3 s client) at 2.34 GB peak RSS, against 2.24 GB for stock under the same cap. So the extra RSS is heap the collector chose not to give back, not data the queue is retaining.

Why the queue gets so large

I instrumented the function to count, per search, how many items are enqueued and how many distinct types are actually visited (negative searches only, since positive ones return early):

total across all searches largest single search
searches 5,461
items enqueued 814,313 432,532
distinct types visited 32,166 13,761

The largest search enqueues 432,532 items to visit 13,761 types: roughly 31 queue entries per type actually expanded. The cause is that visited is only consulted when an item is dequeued, so every reference to a type that has already been seen still allocates a { type, parent } node and goes on the queue, and is then discarded when it reaches the front. The queue length tracks the number of edges reached, not the number of types. That is where the 100k+ figure comes from, and it is also why shift() hurt so much.

Experiment: de-duplicate at enqueue time

On top of this PR's change I tried tracking what has been enqueued, so a type is pushed at most once per search:

const enqueued = new Set<DMMF.InputType>([topLevelType])
// ...
const inputType = this._dmmf.resolveInputObjectType(inputTypeRef)
if (inputType && !enqueued.has(inputType)) {
  enqueued.add(inputType)
  toVisit.push({ type: inputType, parent: item })
}
variant items enqueued (total) largest single search types visited client time peak RSS
this PR 814,313 432,532 32,166 4.8 / 5.3 / 5.3 s 2.78 GB
this PR + enqueue de-dupe 88,083 21,641 32,166 5.2 / 5.0 / 4.6 s 2.74 GB

Types visited are identical, and the generated output is again byte-identical to stock. I believe it is behaviour-preserving in general, not just on this schema: the queue is FIFO, so the first-enqueued entry for a type is always the one that gets expanded and every later duplicate is dropped by the visited check at dequeue; the cache cannot turn true mid-search without returning immediately; and the fieldRefTypes check happens while expanding the parent, not the child.

Two honest caveats on that experiment. It makes no measurable difference to time once shift() is gone, because the traversal was already linear. And it barely moves peak RSS (about 40 MB here), since as shown above RSS is dominated by GC behaviour rather than by the queue. What it does buy is a 20× smaller queue, which bounds the worst case and makes periodic reclamation of the consumed prefix unnecessary.

So my read is that this PR as it stands captures essentially all of the win and is the right thing to merge on its own. I would not hold it for the de-dupe; I am happy to send that as a small follow-up PR if it is wanted.

This branch has not been deployed

No deployments
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.

4 participants