perf: cache field parsers and prebuilt rows per query shape - #3753
Conversation
0167057 to
9c67c03
Compare
Real workloads repeat a small set of result-set shapes, so addFields rebuilding the parser array and prebuilt empty row on every query is measurable overhead at high query rates. Cache both per shape, keyed on the types object, invalidated by a version counter that setTypeParser bumps. Only versioned types objects (TypeOverrides backed by the global pg-types module, or the module itself) use the cache; user-supplied types objects keep the uncached path. ~2.6x faster result processing for a cached 20-column single-row shape (7.3 -> 2.9 us per query in a addFields+parseRow microbenchmark).
9c67c03 to
2b4e8d7
Compare
|
Thanks for the PR and sorry for the delay! Been out of town for a while dealing w/ some stuff... I need to think about this one a bit more - it definitely makes sense in a lot of scenarios. Would it be possible to run the benchmark suite that @nigrosimone wrote with these changes & see how it impacts? I have a few very minor concerns like the seemingly reasonable but also seemingly arbitrary 1000 cache limit on number of queries. Overall on the surface it makes total sense. I'll sleep on it a bit and see if there are any weird end-cases throughout the years that could crop back up pop into my mind. FWIW A long time ago I used to build a constructor object with eval for each query which sped up subsequent rows but leaked some memory and had some hard to track down problems and the perf gains were minor. I always wary introducing something that might end up w/ more allocations in large production apps so...I'll take some caution here. |
|
@Iaotle Brian talk about this benchmark https://github.com/nigrosimone/postgres-benchmarks you can fork and patch pg with your pr. |
What
Result.addFieldscurrently callsgetTypeParserper column and rebuilds the prebuilt empty row object on every query. Real workloads repeat a small set of result-set shapes, so this work is almost always identical to what the previous query already computed. In a production Node service we profiled (~700–800 queries/s through pg + an ORM),addFields+getTypeParserself-time added up to ~9.6% of the main thread's on-CPU time — pure per-query overhead, since most queries return a single row.This PR caches the parser array and prebuilt row per query shape:
WeakMap), then on a shape signature ofname/dataTypeID/formatper field (NUL-separated — Postgres identifiers cannot contain NUL).TypeOverrides.setTypeParserbumps, soclient.setTypeParser(...)between queries applies to later queries exactly as before (unit-tested).TypeOverridesinstances backed by the global pg-types module (the standardClientpath — client.js injects itsTypeOverridesinto everyResult), and the global module itself. User-suppliedtypesobjects (per-querytypes, orTypeOverrideswrapping customuserTypes) keep the existing uncached path, since theirgetTypeParserbehavior can change without notice.Microbenchmark (
new Result+addFields+ oneparseRow, 20 columns, Node 26): 7.3µs → 2.9µs per query, ~2.6× faster result processing on repeated shapes.Global setTypeParser coverage
Registrations on the global module —
pg.types.setTypeParser(...),defaults.parseInt8, or a directrequire('pg-types')— all mutate the same module object, so itssetTypeParseris wrapped once (in result.js, at first pg load) to bump the same version counter. TheparseInt8integration test, which toggles the global parser between same-shape queries, passes. The one remaining bypass is code that saved a reference to the unwrappedsetTypeParserbefore pg was first required and calls it after queries have run; a version counter inside pg-types itself would close even that, and I'm happy to send that one-liner to brianc/node-pg-types as a follow-up if you want it.Tests
packages/pg/test/unit/client/field-metadata-cache-tests.js: shape reuse, distinct shapes, name/oid/format keying,setTypeParserinvalidation, per-instance isolation, custom-types bypass (both flavors), and a client-level test thatclient.setTypeParserbetween two same-shape queries applies to the second.pg.types.setTypeParserbetween two same-shape queries applies to the second.getaddrinfo ENOTFOUND localhost— fail identically on unmodified master).rowMode: 'array', named prepared statements, and livesetTypeParserinvalidation in both row modes.