Skip to content

perf: build the struct mapping plan once per query, not once per row - #12

Merged
stephenafamo merged 3 commits into
stephenafamo:mainfrom
sandonemaki:scan-plan-once
Jul 19, 2026
Merged

perf: build the struct mapping plan once per query, not once per row#12
stephenafamo merged 3 commits into
stephenafamo:mainfrom
sandonemaki:scan-plan-once

Conversation

@sandonemaki

Copy link
Copy Markdown
Contributor

Follow-up to #8 (schedule scans by column index) and #9 (reuse per-row scan buffers).

Summary

The Mapper contract already states the rule this PR enforces:

Any expensive operation, like reflection should be done outside the returned function.

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:

re-derived per row where cost for 1,000 rows × 45 columns
each destination's field type, via reflect.Type.FieldByIndex allOptions before 45,000 FieldByIndex walks, all returning the same types
the validator's column-name slice (mapping.cols() allocates) allOptions after 1,000 slices
the destinations slice allOptions before 1,000 slices
nested-pointer init checks, once per column both paths duplicate IsZero walks on a freshly zeroed struct
the beforeafter link boxing both paths 1,000 interface allocations

This PR resolves the plan once, when the mapper is built, in three commits:

  1. bench: add allOptions (TypeConverter / RowValidator) and nested-pointer
    variants of the existing wide-struct benchmarks, so each following commit can be
    measured on its own.
  2. plan: hoist the field types, the validator's column names and the
    de-duplicated init paths out of the per-row functions; index the mapping by name
    in filterColumns (linear scan per column → one map lookup).
  3. buffers: reuse the allOptions destinations slice and the beforeafter
    link across rows. This is the only commit that relies on the sequential
    before/scan/after contract, so it can be dropped or reverted independently
    if you're not comfortable with it.

Behavior (no functional change)

  • Nested-pointer initialization is unchanged — and slightly safer. The row
    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 IsZero checks
    produced. 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.
  • Buffer reuse relies only on documented sequencing. scanOneRow calls
    before/scan/after strictly in sequence for every row (All, Each, One
    and Cursor all go through it), and the factory runs once per query — the same
    reasoning behind the links slice in Mod and the scan-destination buffers in
    Row (perf: reuse per-row scan buffers in Row #9). Mod passes the inner link through untouched, so mapper mods are
    unaffected.
  • One observable edge, disclosed: the RowValidator now receives the same
    column-name slice on every row instead of a fresh copy. A validator that mutates
    its cols argument would observe its own mutations on later rows.
  • filterColumns keeps the first mapping entry per name, matching the previous
    linear 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):

                              sec/op          B/op        allocs/op
ScanWide45Validator          -31.99%        -29.72%        -5.98%
ScanWide45ConverterValidator -27.15%        -29.72%        -5.98%
ScanWide15NestedConverter    -39.32%        -42.38%        -9.46%
ScanWide45Converter          -26%*          -20.34%        -4.07%
ScanWide5/15/45 (regular)     ~ (no change) -0.6..-6.6%   -32.9..-33.0%
geomean                      -20.17%        -18.88%       -18.07%

* 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/RowValidator user pays, including bob's non-generated
Preload fallback — gets 25–40% faster.

Verification

  • go test ./... passes after each commit.
  • Downstream check with bob: go.mod replaced to this branch, go test ./orm/... .
    and the full PostgreSQL codegen suite (go test ./gen/bobgen-psql/..., generated
    code exercised against a real PostgreSQL via testcontainers) pass.
  • Benchmarks above; the alloc deltas match the accounting exactly (e.g. the regular
    path loses precisely 1 allocation per row — the link boxing; the validator path
    loses the per-row cols() slice).

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.
@stephenafamo
stephenafamo merged commit 38ed4df into stephenafamo:main Jul 19, 2026
3 checks passed
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.

2 participants