perf: build the struct mapping plan once per query, not once per row - #12
Merged
Merged
Conversation
The wide-struct benchmarks only exercise the regular mapping path. Add variants with a pass-through TypeConverter and an always-true RowValidator (the allOptions path), and a struct that spreads its columns over two embedded pointer structs (exercising the mapping's nested-pointer init paths).
The Mapper contract says "Any expensive operation, like reflection should be done outside the returned function", but the struct mapper re-derived query-constant data inside its per-row functions: - allOptions resolved each destination's field type with reflect.Type.FieldByIndex for every column of every row — 45,000 FieldByIndex walks for a 1,000-row x 45-column result, all returning the same answers. - the RowValidator was handed a freshly allocated column-name slice on every row (mapping.cols() builds a new slice per call). - both paths re-checked the nested-pointer init paths once per column per row, even though the row struct starts freshly zeroed, so the de-duplicated set of paths can be initialized unconditionally. - filterColumns matched columns to fields with a linear scan per column (O(columns x fields); once per query, included for completeness). Resolve the field types, the validator's column names and the de-duplicated init paths once when the mapper is built, and index the mapping by name in filterColumns. Initializing every unique init path before scheduling any field address also means a nested pointer can no longer be re-initialized after a sibling column's address was taken.
scanOneRow calls before/scan/after strictly in sequence for each row, and the mapper factory runs once per query, so per-query closures can carry buffers that are reused across rows — the same reasoning behind the links slice in Mod and the scan-destination buffers in Row. Reuse the allOptions destinations slice (and its boxed interface value) across rows, and carry the regular path's row value in a small reusable holder instead of boxing a new interface value on every row. This removes two allocations per row from the allOptions path and one from the regular path.
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.
Summary
The
Mappercontract already states the rule this PR enforces:The mapper factory runs once per query with the column names; the two functions it
returns run once per row. Everything that only depends on the struct type and the
column list — the "mapping plan" — is constant for the duration of the query, but the
struct mapper currently re-derives parts of it inside the per-row functions:
reflect.Type.FieldByIndexallOptionsbeforeFieldByIndexwalks, all returning the same typesmapping.cols()allocates)allOptionsafterallOptionsbeforeIsZerowalks on a freshly zeroed structbefore→afterlink boxingThis PR resolves the plan once, when the mapper is built, in three commits:
variants of the existing wide-struct benchmarks, so each following commit can be
measured on its own.
de-duplicated init paths out of the per-row functions; index the mapping by name
in
filterColumns(linear scan per column → one map lookup).before→afterlink across rows. This is the only commit that relies on the sequential
before/scan/aftercontract, so it can be dropped or reverted independentlyif you're not comfortable with it.
Behavior (no functional change)
struct starts freshly zeroed, so initializing the de-duplicated set of init paths
unconditionally (ancestors before descendants, preserved by first-seen order over
the per-column lists) produces exactly the state the per-column
IsZerochecksproduced. Doing all of it before scheduling any field address also removes the
possibility of re-initializing a parent after a sibling column's address was taken.
scanOneRowcallsbefore/scan/afterstrictly in sequence for every row (All,Each,Oneand
Cursorall go through it), and the factory runs once per query — the samereasoning behind the
linksslice inModand the scan-destination buffers inRow(perf: reuse per-row scan buffers in Row #9).Modpasses the inner link through untouched, so mapper mods areunaffected.
RowValidatornow receives the samecolumn-name slice on every row instead of a fresh copy. A validator that mutates
its
colsargument would observe its own mutations on later rows.filterColumnskeeps the first mapping entry per name, matching the previouslinear search's
break.Benchmarks
1,000 rows, pinned to one core, base/new binaries interleaved for 6 rounds
(hybrid-core laptop CPU, hence the interleaving):
* p=0.065 at n=6 (the base run was noisy); the allocation numbers are exact.
No benchmark regressed. The regular path's time is unchanged (its fix is
allocation-only unless the struct has nested pointers); the allOptions path — which
is what every
TypeConverter/RowValidatoruser pays, including bob's non-generatedPreloadfallback — gets 25–40% faster.Verification
go test ./...passes after each commit.go.modreplaced to this branch,go test ./orm/... .and the full PostgreSQL codegen suite (
go test ./gen/bobgen-psql/..., generatedcode exercised against a real PostgreSQL via testcontainers) pass.
path loses precisely 1 allocation per row — the link boxing; the validator path
loses the per-row
cols()slice).