Skip to content

Cache row scan plans across CollectRows and AppendRows collections - #2661

Open
asuffield wants to merge 1 commit into
jackc:masterfrom
asuffield:planned-row-scanners
Open

asuffield wants to merge 1 commit into
jackc:masterfrom
asuffield:planned-row-scanners

Conversation

@asuffield

@asuffield asuffield commented Sep 24, 2026 •

Copy link
Copy Markdown

Summary

Per the discussion on this PR, this replaces the six RowTo*Plan closure factory functions with automatic collection-scoped plan caching inside AppendRows / CollectRows plus a single generic RowPlan helper for custom RowToFunc implementations:

func RowPlan[P any](row CollectableRow, init func(row CollectableRow) (*P, error)) (*P, error)

Existing callers using pgx.RowToStructByName, pgx.RowToStructByNameLax, pgx.RowToStructByPos, pgx.RowToAddrOfStruct*, pgx.RowTo, and pgx.RowToAddrOf with CollectRows or AppendRows get the speedup and allocation reductions automatically without any call-site changes.


Design

  1. Pooled collectRows Wrapper in AppendRows:

    • AppendRows wraps rows in an unexported collectRows struct obtained from a sync.Pool, retaining a reusable targets []any slice and a cached plan slot (planType, planVal) (with a lazy overflow planMap when a single callback uses multiple distinct plan types per row).
    • Pooling collectRows and its targets slice avoids wrapper heap allocations and reduces even 1-row struct scan allocations compared to master.
    • Scoping plan state to the AppendRows call eliminates cross-query closure reuse hazards and interface-comparability panics on custom value-type Rows implementations.
  2. Pre-Bound Scratch Struct (structScanPlan[T, Mode], Zero unsafe):

    • On the first row of a collection, newStructScanPlan allocates a single structScanPlan[T, Mode] containing a scratch dst T and binds targets[i] to &p.dst's fields once using reflect.Value.Field (for top-level fields) or reflect.Value.FieldByIndex (for embedded structs).
    • On subsequent rows (p.dirty == true), p.dst is zeroed in-place before row.Scan(p.targets...) (resetting any slice, map, or pointer fields to nil so pgtype never aliases prior rows) and returned by value.
    • This eliminates all per-row reflect calls (reflect.Value.Field, Addr(), (*rtype).ptrTo, Interface()), all per-row joinFieldNames + sync.Map lookups, and—for value-returning scanners—the per-row heap escape of var value T.
    • RowToAddrOfStruct* delegates to RowToStruct*, returning &value so each row receives a distinct *T while sharing the same reflection-free scan path.
  3. Extensible RowPlan Helper for External RowToFunc Implementations:

    • Third-party or custom RowToFunc[T] implementations can call pgx.RowPlan(row, init) to cache per-query column index lookups or scan state for the duration of a CollectRows / AppendRows call without introducing stateful closures.
    • When called outside CollectRows / AppendRows (such as in CollectOneRow or a manual Rows.Next loop), RowPlan falls back to invoking init(row) directly.

Benchmarks vs master (Apple M3 Pro, Go 1.26, PostgreSQL 18)

1. Pure Scanner Overhead (BenchmarkCollectRowsOverhead, Fake Rows, 12-Field Struct)

Benchmark master (Time · Bytes · Allocs) This PR (Time · Bytes · Allocs) Time Diff Memory Diff Allocs Diff
ByName/1 265.8 ns · 304 B · 3 271.8 ns · 144 B · 2 +6.0 ns -52.6% (-160 B) -1 alloc
ByPos/1 173.7 ns · 240 B · 2 181.5 ns · 80 B · 1 +7.8 ns -66.7% (-160 B) -1 alloc
Scalar/1 20.4 ns · 20 B · 2 32.0 ns · 48 B · 1 +11.6 ns +28 B -1 alloc
ByName/10 2,542 ns · 3,040 B · 30 463.9 ns · 144 B · 2 -81.8% (5.5× faster) -95.3% -28 allocs
ByPos/10 1,692 ns · 2,400 B · 20 463.8 ns · 80 B · 1 -72.6% (3.6× faster) -96.7% -19 allocs
Scalar/10 148.3 ns · 200 B · 20 113.6 ns · 48 B · 1 -23.4% -76.0% -19 allocs
ByName/20 5,153 ns · 6,080 B · 60 683.0 ns · 144 B · 2 -86.7% (7.5× faster) -97.6% -58 allocs
ByPos/20 3,368 ns · 4,800 B · 40 768.9 ns · 80 B · 1 -77.2% (4.4× faster) -98.3% -39 allocs
Scalar/20 291.2 ns · 400 B · 40 191.3 ns · 48 B · 1 -34.3% -88.0% -39 allocs
ByName/100 25.8 µs · 30,400 B · 300 2.19 µs · 144 B · 2 -91.5% (11.8× faster) -99.5% -298 allocs
ByPos/100 17.2 µs · 24,000 B · 200 3.19 µs · 80 B · 1 -81.4% (5.4× faster) -99.7% -199 allocs
Scalar/100 1,406 ns · 2,000 B · 200 842.0 ns · 48 B · 1 -40.1% -97.6% -199 allocs
ByName/1k 253.6 µs · 304,000 B · 3,000 19.2 µs · 144 B · 2 -92.4% (13.2× faster) -99.95% -2,998 allocs
ByPos/1k 170.4 µs · 240,000 B · 2,000 30.3 µs · 80 B · 1 -82.2% (5.6× faster) -99.97% -1,999 allocs
Scalar/1k 13.8 µs · 20,000 B · 2,000 8.01 µs · 48 B · 1 -42.1% -99.8% -1,999 allocs
ByName/1M 255.2 ms · 304.0 MB · 3,000,000 19.3 ms · 173 B · 2 -92.4% (13.2× faster) -99.9999% (-304 MB) -2,999,998 allocs
ByPos/1M 167.8 ms · 240.0 MB · 2,000,000 31.1 ms · 127 B · 1 -81.5% (5.4× faster) -99.9999% (-240 MB) -1,999,999 allocs
Scalar/1M 14.2 ms · 20.0 MB · 2,000,000 8.01 ms · 59 B · 1 -43.6% -99.9997% (-20 MB) -1,999,999 allocs

2. Existing BenchmarkSelectRowsSimple* Suite (PostgreSQL 18)

Benchmark master (Time · Bytes · Allocs) This PR (Time · Bytes · Allocs) Time Diff Memory Diff Allocs Diff
CollectRowsByPos/1_rows 41.3 µs · 1,249 B · 22 41.3 µs · 1,138 B · 21 0.0% -8.9% -1 alloc
CollectRowsByPos/10_rows 49.7 µs · 9,021 B · 116 50.8 µs · 6,320 B · 97 ~noise -29.9% -19 allocs
CollectRowsByPos/100_rows 120.7 µs · 78.7 KB · 1,019 115.4 µs · 50.1 KB · 820 -4.4% -36.4% -199 allocs
CollectRowsByPos/1000_rows 603.2 µs · 875.4 KB · 10,026 538.5 µs · 587.7 KB · 8,027 -10.7% -32.9% -1,999 allocs
AppendRowsByPos/1_rows 40.4 µs · 1,104 B · 21 42.3 µs · 993 B · 20 ~noise -10.1% -1 alloc
AppendRowsByPos/10_rows 51.8 µs · 4,554 B · 111 50.4 µs · 1,850 B · 92 -2.8% -59.4% -19 allocs
AppendRowsByPos/100_rows 124.4 µs · 39.1 KB · 1,011 111.3 µs · 10.5 KB · 812 -10.5% -73.2% -199 allocs
AppendRowsByPos/1000_rows 571.6 µs · 385.0 KB · 10,013 512.6 µs · 97.0 KB · 8,014 -10.3% -74.8% -1,999 allocs
CollectRowsByName/1_rows 41.5 µs · 1,329 B · 23 42.6 µs · 1,217 B · 22 ~noise -8.4% -1 alloc
CollectRowsByName/10_rows 52.4 µs · 9,822 B · 126 51.6 µs · 6,400 B · 98 -1.5% -34.8% -28 allocs
CollectRowsByName/100_rows 136.8 µs · 86.7 KB · 1,119 114.8 µs · 50.2 KB · 821 -16.1% -42.1% -298 allocs
CollectRowsByName/1000_rows 688.0 µs · 955.4 KB · 11,026 542.2 µs · 587.7 KB · 8,028 -21.2% -38.5% -2,998 allocs
AppendRowsByName/1_rows 42.2 µs · 1,104 B · 21 42.2 µs · 993 B · 20 0.0% -10.1% -1 alloc
AppendRowsByName/10_rows 50.1 µs · 4,555 B · 111 48.9 µs · 1,850 B · 92 -2.3% -59.4% -19 allocs
AppendRowsByName/100_rows 125.2 µs · 39.1 KB · 1,011 111.6 µs · 10.5 KB · 812 -10.9% -73.2% -199 allocs
AppendRowsByName/1000_rows 616.2 µs · 385.0 KB · 10,013 554.3 µs · 97.0 KB · 8,014 -10.0% -74.8% -1,999 allocs

3. End-to-End PostgreSQL 18 (BenchmarkAppendRowsToStruct, 12-Column Struct)

Benchmark master (Time · Memory · Allocs) This PR (Time · Memory · Allocs) Time Diff Memory Diff Allocs Diff
ByName/1 38.4 µs · 1,152 B · 16 38.5 µs · 993 B · 15 ~noise (+0.4%) -13.8% -1 alloc
ByNameLax/1 38.1 µs · 1,152 B · 16 38.4 µs · 993 B · 15 ~noise (+0.8%) -13.8% -1 alloc
ByPos/1 37.9 µs · 1,088 B · 15 38.1 µs · 929 B · 14 ~noise (+0.5%) -14.6% -1 alloc
AddrOfByName/1 38.0 µs · 1,152 B · 16 38.5 µs · 1,153 B · 16 ~noise (+1.3%) 0% 0
AddrOfByNameLax/1 38.0 µs · 1,152 B · 16 38.4 µs · 1,153 B · 16 ~noise (+1.1%) 0% 0
AddrOfByPos/1 37.8 µs · 1,088 B · 15 38.7 µs · 1,089 B · 15 ~noise (+2.4%) 0% 0
ByName/10 50.3 µs · 5,235 B · 115 45.4 µs · 1,329 B · 87 -9.7% -74.6% -28 allocs
ByNameLax/10 48.6 µs · 5,235 B · 115 45.6 µs · 1,329 B · 87 -6.2% -74.6% -28 allocs
ByPos/10 48.9 µs · 4,595 B · 105 45.4 µs · 1,265 B · 86 -7.1% -72.5% -19 allocs
AddrOfByName/10 48.7 µs · 5,235 B · 115 45.8 µs · 2,931 B · 97 -6.0% -44.0% -18 allocs
AddrOfByNameLax/10 48.6 µs · 5,235 B · 115 45.7 µs · 2,931 B · 97 -6.0% -44.0% -18 allocs
AddrOfByPos/10 47.3 µs · 4,595 B · 105 46.2 µs · 2,867 B · 96 -2.2% -37.6% -9 allocs
ByName/20 56.1 µs · 9,774 B · 225 48.2 µs · 1,706 B · 167 -14.1% -82.5% -58 allocs
ByNameLax/20 58.3 µs · 9,774 B · 225 51.9 µs · 1,706 B · 167 -10.9% -82.5% -58 allocs
ByPos/20 54.0 µs · 8,493 B · 205 48.8 µs · 1,642 B · 166 -9.7% -80.7% -39 allocs
AddrOfByName/20 58.7 µs · 9,774 B · 225 49.0 µs · 4,910 B · 187 -16.5% -49.8% -38 allocs
AddrOfByNameLax/20 58.0 µs · 9,774 B · 225 49.4 µs · 4,910 B · 187 -14.8% -49.8% -38 allocs
AddrOfByPos/20 56.1 µs · 8,493 B · 205 50.5 µs · 4,846 B · 186 -10.0% -42.9% -19 allocs
ByName/100 135.0 µs · 46.1 KB · 1,105 94.5 µs · 4.7 KB · 807 -30.0% -89.8% -298 allocs
ByNameLax/100 130.6 µs · 46.1 KB · 1,105 96.0 µs · 4.7 KB · 807 -26.5% -89.8% -298 allocs
ByPos/100 120.5 µs · 39.7 KB · 1,005 89.8 µs · 4.6 KB · 806 -25.5% -88.3% -199 allocs
AddrOfByName/100 128.9 µs · 46.1 KB · 1,105 96.2 µs · 20.7 KB · 907 -25.3% -55.0% -198 allocs
AddrOfByNameLax/100 129.6 µs · 46.1 KB · 1,105 91.2 µs · 20.7 KB · 907 -29.6% -55.0% -198 allocs
AddrOfByPos/100 119.9 µs · 39.7 KB · 1,005 89.1 µs · 20.6 KB · 906 -25.7% -47.9% -99 allocs
ByName/1k 629.7 µs · 454.3 KB · 11,007 427.6 µs · 38.4 KB · 8,009 -32.1% -91.6% -2,998 allocs
ByNameLax/1k 636.5 µs · 454.3 KB · 11,007 421.5 µs · 38.4 KB · 8,009 -33.8% -91.6% -2,998 allocs
ByPos/1k 540.7 µs · 390.3 KB · 10,007 439.7 µs · 38.3 KB · 8,008 -18.7% -90.2% -1,999 allocs
AddrOfByName/1k 616.3 µs · 454.3 KB · 11,007 453.6 µs · 198.5 KB · 9,009 -26.4% -56.3% -1,998 allocs
AddrOfByNameLax/1k 615.9 µs · 454.3 KB · 11,007 458.7 µs · 198.5 KB · 9,009 -25.5% -56.3% -1,998 allocs
AddrOfByPos/1k 522.2 µs · 390.3 KB · 10,007 445.0 µs · 198.5 KB · 9,008 -14.8% -49.1% -999 allocs
ByName/1M 554.9 ms · 453.3 MB · 11.0M 451.0 ms · 37.3 MB · 8.0M -18.7% -91.8% (-416 MB) -3.0M allocs
ByNameLax/1M 548.4 ms · 453.3 MB · 11.0M 439.3 ms · 37.3 MB · 8.0M -19.9% -91.8% (-416 MB) -3.0M allocs
ByPos/1M 465.3 ms · 389.3 MB · 10.0M 450.5 ms · 37.3 MB · 8.0M -3.2% -90.4% (-352 MB) -2.0M allocs
AddrOfByName/1M 555.7 ms · 453.3 MB · 11.0M 457.5 ms · 197.3 MB · 9.0M -17.7% -56.5% (-256 MB) -2.0M allocs
AddrOfByNameLax/1M 554.6 ms · 453.3 MB · 11.0M 438.5 ms · 197.3 MB · 9.0M -20.9% -56.5% (-256 MB) -2.0M allocs
AddrOfByPos/1M 470.1 ms · 389.3 MB · 10.0M 449.0 ms · 197.3 MB · 9.0M -4.5% -49.3% (-192 MB) -1.0M allocs

@jackc

jackc commented Sep 26, 2026

Copy link
Copy Markdown
Owner

There are several issues that a Codex review caught that I've appended below, but at a higher level, I'm not super excited about having a parallel set of that should only be used for expected large result sets. I'd much rather optimize the existing implementations and have a single function users should call even if it meant that we couldn't get quite as great an optimization. Someone who really cares about performance can use Rows.Next() and Rows.Scan() directly for maximum performance.

This optimization seems to slow down the few row case (not sure where the break point is). Perhaps the optimization could be merged into the existing functions, but it only triggers after N rows. But I don't want to slow down the common 1-20 row case. 🤷‍♂️


Codex findings for reference:

Existing struct scanners regress. Rebuilding structScanPlan for every row adds approximately 272 bytes and two allocations per row. Against the parent commit, the 1,000-row benchmarks were roughly 10–14% slower. Keep the existing scanning path or cache the additional planning work.

The map optimization increases allocations. RowToMapPlan allocates a destination slice plus one *any per column, per row. The PR’s 1,000-row benchmark increased from 1.077 MB / 30,478 allocations to 1.246 MB / 40,495 allocations, without a consistent speed improvement. Using row.Values() would avoid those extra destinations.

The closure reuse contract needs to be explicit. Reusing a named plan for SELECT a, b followed by SELECT b, a silently swaps the fields. The documentation should explicitly prohibit reuse across different column layouts and concurrent calls, or the implementation should handle those cases.

@asuffield

Copy link
Copy Markdown
Author

The current API doesn't permit this sort of thing, so to unify them I'd have to change the type definition of RowToFunc[T] which is a fairly substantial API break. I wish generics could solve this, but T can't be inferred in calls of CollectRows so there is no backwards compatible option. If you're okay with that I'll put it together; it really needs to be an interface[T] instead of a func type. The rest is easy to handle, but only makes sense if we're breaking the API anyway.

I don't see the memory allocation increase when I run the benchmark - I see it slightly decrease at all sizes. I'll try to puzzle that out but I might need to know what system configuration you found that with.

@jackc

jackc commented Oct 17, 2026

Copy link
Copy Markdown
Owner

As I mentioned before, I would like to avoid expanding the public interface if possible. I haven't looked into it in depth myself, but I used Astra to investigate alternatives and it claims that a similar level of performance gain can be gained without adding new interfaces. I'd want to investigate that thoroughly before adding new public interfaces. Astra's report is attached below.


PR 2661 proposes six closure factory functions to speed up struct scanning by retaining a scan plan across rows. The optimization is promising, but the additional public API is not necessary: an internal cache owned by a collection can deliver nearly the same measured benefit through the existing functions.

Recommendation: pursue internal caching in CollectRows and AppendRows, preserve RowToFunc[T], and make small-result performance a requirement before accepting the change. The current prototype establishes feasibility, not a production-ready implementation.

Where the cost comes from

The existing scanners already cache struct field mappings globally. Struct tag discovery and name matching are not repeated in full on every row.

The named scanner still constructs a column-name cache key, looks up the mapping, allocates a destination slice, and uses reflection to populate that slice with pointers into a fresh result struct. The positional scanner also looks up its mapping and allocates and populates destinations, but does not construct a column-name key.

The PR retains the mapping and destination slice in a closure. It also separates direct and embedded fields to reduce reflection overhead. It still reflects over each new struct to obtain field addresses; it does not eliminate per-row reflection.

The underlying opportunity is to retain reusable scanning work for the lifetime of a result set. Requiring callers to create and manage closures is one way to own that state, but it is not essential to the optimization.

Proposed internal design

Keep the current call unchanged:

items, err := pgx.CollectRows(rows, pgx.RowToStructByName[Item])

AppendRows can wrap its input in a private implementation of Rows that delegates row operations and holds collection-local scanning state. The existing RowToStruct* functions recognize this private wrapper and reuse its state. Outside that context, they retain their existing scanning path.

This does not require changing RowToFunc[T], adding methods to public interfaces, or comparing callback function pointers. The wrapper can delegate to pgxpool rows without modifying the pool.

A production implementation should separate immutable field metadata, shared through the existing global caches, from mutable destination scratch space owned by the collection. Refresh the destination pointers for every new result struct; never share mutable destinations globally or reuse a result object across returned rows.

The cache must distinguish destination type and matching mode, including positional, named, and named-lax scans. Callbacks that alternate types or modes must select or rebuild the appropriate entry. Collection ownership removes the need to identify query streams by comparing arbitrary row interfaces, or to expose cross-query reuse rules to callers.

Prototype evidence

A temporary prototype added a private collection wrapper and taught the existing named scanner to obtain a cached closure from it. It reused the PR’s plan construction to isolate the effect of moving state ownership behind the existing API. It did not implement the proposed leaner metadata and scratch-space design or all scanning variants.

The following measurements use 1,000 fake rows with twelve integer fields and a preallocated result slice. The baseline is the PR’s unplanned scanner path. Two runs used Go 1.27.0 on Linux amd64 with an Intel Core Ultra 9 285K.

Approach Time per collection Bytes allocated Allocations
Existing scanner path 286–296 µs 264,048 3,001
Public plan function 178–179 µs 48,592 1,006
Existing function with internal cache About 183 µs 48,664 1,009

The internal cache captured nearly all of the closure approach’s benefit in this microbenchmark. A new public API is therefore not required to obtain this class of speedup.

These measurements isolate scanning overhead. Fake rows omit PostgreSQL transport and decoding costs, so the figures do not establish end-to-end database performance or statistical equivalence.

Small results and compatibility

The simple prototype is not ready to ship. For one row, scanning increased from approximately 365 ns on the existing path to 605–717 ns with the internal cache. It inherited the PR’s plan-construction overhead and added wrapper and closure setup.

The next implementation should avoid rebuilding direct and nested field metadata for every collection and investigate retaining the destination work already needed for the first row. Whether to delay caching until a later row should be decided by measurement; the current evidence does not establish the right threshold.

Two scope decisions remain:

  • A collection wrapper changes the concrete object passed to callbacks. The declared callback interface remains unchanged, but callbacks that make additional concrete-type assertions or depend on object identity may observe a difference. This needs compatibility review.

  • Collection-local caching accelerates CollectRows and AppendRows. Manual Next() loops calling RowToStruct* retain the existing path. Attaching state to underlying rows could extend coverage, but would require a separate design for forwarding through wrappers.

The added collection machinery must also be measured with scalar and custom callbacks that do not use struct scanning.

Findings in the current PR

Two focused tests reproduced problems in the PR’s row-identity check at rows.go line 869:

  1. Interface comparison can panic. A valid value-based CollectableRow containing slices is not comparable. On its second invocation, both named and positional plan functions panic when comparing it with the cached row. The corresponding comparison occurs across the plan variants.

  2. Object identity does not guarantee query identity. A custom pointer wrapper reused for a new query bypasses layout validation. Reordering columns from a, b to b, a silently produced {A:20, B:10} rather than the promised layout error.

These reproductions concern custom row implementations; they do not demonstrate that native pgx or pgxpool rows reuse the same object across queries. They illustrate the additional correctness obligations created by the public closure reuse contract.

The findings were reproduced against PR head 329c21bf before the prototype changes. Full PostgreSQL integration tests were not run.

Recommended next step

Develop an internal collection-cache implementation without introducing the six public plan functions. Validate correctness for all existing struct scanner variants, embedded and ignored fields, mismatched columns, alternating callback types and modes, pgxpool rows, custom row implementations, and independence of returned values.

Benchmark 1, 2, 10, 20, and large row counts across narrow and wide structs, alongside scalar and custom callbacks. Include allocation measurements and end-to-end PostgreSQL runs.

Acceptance should require preserving existing semantics, avoiding a material small-result regression, and retaining most of the large-result improvement. The prototype supports this direction; it does not yet prove those acceptance criteria are met.

@asuffield

Copy link
Copy Markdown
Author

Okay, this is a proposal I thought of and rejected already, but I'm happy to put it together if that's what you really want. The reason why I rejected this idea is because it's not extensible at all: the only operators which can use it would be the ones implemented in the pgx package - so this solves for pgx.RowToStructByName but any user-supplied RowToFunc[T] wouldn't be able to take advantage of it and would have to reproduce the thing I did in this PR on their own.

I really didn't expect you to be willing to accept that change, because it only fixes my specific problem - but it does fix my problem, and if you want that I'm okay with it. I'll work up a PR for it.

@jackc

jackc commented Oct 18, 2026

Copy link
Copy Markdown
Owner

Okay, this is a proposal I thought of and rejected already, but I'm happy to put it together if that's what you really want. The reason why I rejected this idea is because it's not extensible at all: the only operators which can use it would be the ones implemented in the pgx package - so this solves for pgx.RowToStructByName but any user-supplied RowToFunc[T] wouldn't be able to take advantage of it and would have to reproduce the thing I did in this PR on their own.

🤷 I don't know. My goal has been avoid expanding the public interface. pgx is already pretty big. This is especially true for the helpers like (Collect|Append)Rows and RowTo*. They are convenient, but if performance ever really matters, using the rows.Next() and rows.Scan loop is always going to be faster.

@asuffield

asuffield commented Oct 18, 2026 •

Copy link
Copy Markdown
Author

If my specific problem is inspiring, I can describe what I'm dealing with: I've got a bunch of places in code written by a bunch of different teams, where RowToStructByName is exactly the right semantics, but is shockingly cpu hungry when run on millions of rows, to the point where this is the thing sticking out in cpu profiles (in applications which do a bunch of other stuff). I don't need to make it as fast as possible, I just want the big chunk of cpu time back, and ideally in a form which you'll be happy to merge so I don't have to ship a local solution.

@jackc

jackc commented Oct 21, 2026

Copy link
Copy Markdown
Owner

Hmm... to be honest I'm not sure what the best approach is.

For the project's sake, I'd like to avoid expanding the interface. Also, for your use case, it requires the caller to choose between two functions that do the same thing. Not ideal for the caller either.

But as you said, the other optimization path can't be used by external implementations. That's unfortunate as well. I think I would still lean toward the other optimization path. But I would look further into if "can't be used by external implementations" is surmountable. My hunch is that it is.

@asuffield
asuffield force-pushed the planned-row-scanners branch from 329c21b to 99b522d Compare October 27, 2026 21:46
@asuffield asuffield changed the title Add closure factory plan functions for struct and map scanning Cache row scan plans across CollectRows and AppendRows collections Oct 27, 2026
@asuffield

Copy link
Copy Markdown
Author

A bunch of experimenting later, I've come up with something that is at least an improvement - I don't know if you'll like this iteration, but I think you'll at least agree it's better.

It's also significantly faster than the previous iteration by carefully eliminating a number of allocs (it's quite touchy about this, especially in the low-row-count scenarios) and getting creative with how the reflection is applied. The change is longer and more involved than I'd really like, but most of the fiddly stuff is necessary to not make the 1-row and 10-row cases worse. With just the two core changes (RowPlan and structScanPlan) the 1k and 1M cases are "solved", the rest is necessary to avoid regressions for small row counts.

@jackc

jackc commented Oct 31, 2026

Copy link
Copy Markdown
Owner

I like the direction this is going, but there are a few correctness issues according to Astra. Not sure if it is resolvable while keeping the performance gains.


Found two correctness regressions in latest head 99b522d:

  • [P1] Scratch-value reuse can corrupt previously collected results — rows.go:759–768. A valid custom sql.Scanner can expose a slice backed by an array inside its receiver. Returning a shallow copy of p.dst preserves that reference; subsequent scans overwrite earlier results. A three-row reproduction returns [3] for every row instead of [1], [2], [3]. Preserve independent destination storage per result.
  • [P2] RowToAddrOf overwrites cached scan destinations — rows.go:650–651. The primary cached plan shares cr.targets, which RowToAddrOf modifies. Alternating RowTo[int32], RowToAddrOf[int32], then RowTo[int32] produces [1, 2, 0] and changes the retained second-row pointer to 3. Give RowToAddrOf separate destination storage.

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