Skip to content

perf: trim per-query allocation in the statement build path#296

Merged
zantvoort merged 1 commit into
mainfrom
perf/query-build-allocation
Jul 20, 2026
Merged

perf: trim per-query allocation in the statement build path#296
zantvoort merged 1 commit into
mainfrom
perf/query-build-allocation

Conversation

@zantvoort

@zantvoort zantvoort commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

Part of #294 (the issue stays open for the remaining tail).

What

Statement building dominated Storm's per-operation allocation on short operations: ~8 KB per by-id build against a 1.9 KB hand-written JDBC floor, even with the compiled SQL cached. JFR allocation profiling itemized the producers; six changes cut the build phase in half and reduce total allocation on the short workloads by 34–40%.

change what it removes
Query creation uses the dialect of the template that generated the SQL full ServiceLoader provider resolution (sort + stream + lambdas) per query — and a latent mismatch: the per-call lookup used global provider order while the template's dialect was matched by database product name
SqlParser: leading-whitespace fast path + marker-char guards two full-SQL regex passes per call for templates that start with a newline; matcher allocations for comment/identifier/literal passes that cannot match
Parameter validation: single-pass named check, BitSet positional check stream + groupingBy machinery even for purely positional statements; a boxed TreeSet per bind (boxed path kept as cold fallback for non-positive positions, identical error messages)
Unsafe-statement check computed once per processor hasWhereClause full-SQL regex on every bind of UPDATE/DELETE statements, despite being a pure function of the immutable compiled SQL
AbstractMetamodel caches its hash code an Objects.hash varargs array per compilation-key lookup (String.hashCode pattern)
Query builder provider resolved once, mirroring getORMReflection() provider list sort + stream on every select()

Measured effect

Precise per-thread allocation counters (warmed hot loop, by-id read, PostgreSQL):

phase before after
builder chain 1,623 B/op 763 B/op
build (SQL generation) 8,016 B/op 4,031 B/op
full operation 10,908 B/op 7,135 B/op

JMH allocation profiler, per operation:

workload before after
singleRowById 10,512 B 6,944 B (−34%)
updateById 21,060 B 12,548 B (−40%)
multiStatement 20,523 B 12,486 B (−39%)
dynamic 26,800 B 23,299 B (−13%)

The excess allocation correlated with the small latency deltas on the point workloads; the wall-clock effect will show on the pinned-runner benchmark.

Notes

  • No API changes; all six changes are internal. Every cached value is a pure function of immutable state (compiled SQL, metamodel identity, provider order after startup), following caching patterns already present in the codebase.
  • Remaining Trim per-query allocation in the statement build path #294 tail, deliberately left out: per-call BindingSession/SqlImpl/parameter-list carriers and the template machinery of dynamically assembled projections (the reason dynamic moved least).

Testing

All suites green against their real databases: storm-foundation 320 · storm-core 2307 · H2 158 · SQLite 131 · PostgreSQL 178 · MySQL 175 · MariaDB 158, plus a full reactor test-compile.

Statement building dominated per-operation allocation on short operations:
about 8 KB per by-id build against a 1.9 KB hand-written JDBC floor, even
with the compiled SQL cached. Six changes cut the build phase in half and
reduce total allocation on the short workloads by 34-40%:

- Query creation uses the dialect of the template that generated the SQL
  instead of repeating the provider lookup on every query, which could even
  select a different dialect than the statement was generated with.
- SqlParser strips leading whitespace before the keyword fast path (templates
  regularly start with a newline, which forced the comment-removal fallback
  on every call) and skips comment, quoted-identifier, and string-literal
  regex passes entirely when no marker character is present.
- Named parameter validation runs in a single pass without stream and
  grouping machinery; positional validation uses a bit set instead of a
  boxed sorted set, with the boxed path kept as a cold fallback.
- The unsafe-statement check, a pure function of the immutable compiled SQL,
  is computed once per processor instead of re-scanning on every bind.
- AbstractMetamodel caches its hash code: metamodels are immutable and are
  hashed on every compilation-key lookup.
- The query builder provider is resolved once and reused, mirroring
  getORMReflection(), instead of sorting the provider list on every select.

Refs #294 (remaining tail: per-call binding-session and parameter carriers,
and the template machinery of dynamically assembled projections).
@zantvoort
zantvoort force-pushed the perf/query-build-allocation branch from 33f0003 to 3ec1f3b Compare July 20, 2026 18:45
@zantvoort
zantvoort merged commit 8465d4b into main Jul 20, 2026
7 checks passed
@zantvoort
zantvoort deleted the perf/query-build-allocation branch July 20, 2026 18:47
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