feat: parallel-aware ungrouped vectorized batch fold (#289 phase 5/6) - #343
Conversation
…se 5/6) The ungrouped batch fold (jdatcmd#337) runs serially: one process folds every row group, so it can only reach ~1.4x over core Agg and leaves the ~5x parallel scan (gap 23) on the table. Make the fold parallel-aware so both stack. A new opt-in GUC, pgcolumnar.enable_parallel_vector_agg (default off), adds a second upper path: a parallel-aware partial ColumnarAgg under a core Gather and Finalize Aggregate. Each worker claims distinct row groups through the same shared atomic the base parallel scan uses (ColumnarReadSetParallelCounter, gap 23) and folds them column-at-a-time, emitting one per-worker transition state; the core Finalize combines them -- int8pl for count, float8_combine + float8_avg for avg(float8) -- so the result matches an ordinary parallel aggregate exactly, overflow parity included (float8_combine re-derives and re-checks the Youngs-Cramer Sxx the partial passes through). The partial target is core's own UPPERREL_PARTIAL_GROUP_AGG reltarget, so the partial and final Aggrefs stay structurally related and setrefs matches them. First slice: count(*), count(col), and sum/avg over float4/float8 -- the kinds whose transition state is a plain, non-internal value the fold already holds (columnar_parallel_agg_ok); q6's count(*)+avg(float8) is covered. Everything else keeps the serial node or the ordinary core Agg. When the parallel arm is added it supersedes the serial node (its Gather runs leader-only with no workers), so the serial node -- priced at the cheap Gather cost by jdatcmd#133 -- is added only as the non-parallel fallback, else it would out-cost the genuinely parallel plan. Correctness guards (a parallel path returns wrong answers, not crashes, when wrong): the partial reader errors if it opens without a shared counter (would read every group in every worker and the Finalize would sum the duplicates); writes/deletes flush once in the leader's InitializeDSM before workers launch (a worker cannot see the leader's unflushed in-xact buffers); and the one unsafe fallback -- an absent column found mid-scan after the counter advanced -- errors rather than undercount. A shape that is not batch-foldable from the start (a NULL test, a non-btree filter) still runs correctly on the row path with the counter shared. Float parallel folds are order-nondeterministic, so the oracle is core's own parallel aggregate, not the serial fold. test/parallel_vector_agg.sh asserts the plan (Finalize -> Gather -> parallel partial ColumnarAgg, batch fold yes) is actually chosen, count is exact vs a serial oracle, avg/sum(float) match core parallel Agg within reassociation tolerance, and null/empty/more-workers-than- groups behave. 13/13 on PG18 assert; the full gate and the q6@100M bench to follow. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UX1jrWiQsJJA1t4pkmkb4T
Take the PG_CONFIG arg like the other suites (was pinned to pg18a during development), use the harness check/pgc_summary, and register the suite in run_all_versions.sh so harness_selftest passes (every suite must be listed -- the jdatcmd#337 CI lesson). 14/14 on PG18 assert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UX1jrWiQsJJA1t4pkmkb4T
|
Reviewed. The design is sound and the correctness guards are the right ones -- erroring when the partial reader is opened without a shared counter, and erroring rather than under-counting on a mid-scan absent column, are both the correct direction to fail in. Preserving the Youngs-Cramer But it does not work on PG15, PG16, or PG17, and the suite cannot tell you why. The bug
701 is
It builds on all five, so the five-major preflight build passes. The failure is at runtime, and the gate that would have caught it (the assert matrix) was run on PG18 and PG19 only, which is exactly where it works. The test hides the causeCI reports this, which is not what is wrong:
q -c "SELECT CASE WHEN abs(($vec::float8)-($core::float8)) <= 1e-6*(abs($core::float8)+1) ..."When the measurement returns an error string rather than a number, Worth fixing regardless of the portability issue: a comparison helper should assert that both sides are numeric before differencing them, so a failure names itself. As written, any error in either arm reports as a syntax error, which sends the reader to the wrong file. Suggested fix for the portability issueTwo options, and I do not have a strong preference between them:
Whichever you pick, the assert matrix needs to run on a major where it is expected to work and one where the fallback path is taken, or the same gap reopens. Note on the measurementsThe q6 @ 100M numbers may predate #339, which landed column projection. Before this PR the scan read every column of every row group regardless of the query, so the Not blocking on that; the portability bug is the blocking item. |
construct_array_builtin's supported-type list did not include float8 (type 701) until PG18, so emit_partial errored 'type 701 not supported by construct_array_builtin()' on PG15-17 (caught by PG17 CI on jdatcmd#343; the local gate ran full suites only on 18/19). Use construct_array with explicit float8 type params -- exactly what core's float8_accum uses to build this same _float8 transition array -- which works on every supported major. parallel_vector_agg now 14/14 on pg15a/16a/17a/18a assert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UX1jrWiQsJJA1t4pkmkb4T
|
Verified the fix independently on all five majors. Same fixture and query as before (
Both arms agree to the digit on every major. Full suite: 14/14 on all five (PG15, 16, 17, 18, 19). One thing from the review is still openThe vec="$(q -c "$PAR $UG $PP" -c "SELECT $1 FROM t WHERE k < 700")"
...
q -c "SELECT CASE WHEN abs(($vec::float8)-($core::float8)) <= ..."So any error in either arm still reports as It is not blocking -- the bug it hid is fixed and the suite is green on all five. But the next failure in this suite will be just as opaque as this one was, and the fix is small: assert both sides are numeric before differencing, and fail with the raw value when they are not. Your call whether to fold that in here or leave it. With the portability bug fixed and 14/14 across the matrix, I have no objection to this landing. |
|
Follow-up on the baseline note in my review, now measured rather than predicted. Composed run on current main with this PR overlaid, 8M rows / 12 columns / 588 MB, PG17:
That is 1.93x over core parallel agg, against the 1.49x in the PR body. Your figure was measured on a tree that predated #339, so both arms were reading all twelve columns to answer a two-column query; once projection removes that I/O, the fold's per-worker win is a larger share of what is left. So the number moved in your favour, not against it. Caveat so this is not over-read: mine is 8M on a different box, yours is 100M on the bench. These are not like-for-like, and I am not proposing you substitute my number for yours. The point is only that the 100M figure is measured against a baseline that no longer exists and should be re-run on current main before it lands in a changelog or a README. Full-stack context, same fixture: baseline with neither projection nor fold, serial, is 1951.6 ms at 76,793 buffers. All three levers together bring that to 39.5 ms at 8,325 buffers, and this PR is the last 1.93x of it. Correctness held throughout: count exact against a heap oracle, average agreeing to 12 significant figures with the residual being cross-worker float reassociation -- which is exactly why comparing against core parallel rather than a serial oracle, as your suite does, is the right call. |
The design flagged H2 (a worker is a separate backend and cannot see the leader's unflushed in-transaction delete buffer) as a wrong-answer hazard; the partial node flushes write+delete state in the leader's InitializeDSM before workers launch, but that path had no test. Add two checks to parallel_vector_agg.sh: committed deletes (parallel fold == serial) and the H2 case -- delete rows and run the parallel fold in the SAME transaction, so the deletes are still unflushed when workers launch. Both match the serial oracle (1384000|-40), confirming the leader flush makes in-xact deletes visible to workers. 16/16 on PG18 assert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UX1jrWiQsJJA1t4pkmkb4T
|
Re-verified at The H2 case is the one worth having. A worker is a separate backend, so an unflushed in-transaction delete buffer in the leader is invisible to it, and the failure mode is workers counting deleted rows and quietly diverging from serial -- a wrong answer, not a crash. That is not something reasoning about the code reliably catches, so pinning it with a test that runs the fold inside the same transaction as the DELETE is the right move. No objection to this landing from my side. Summary of what I checked independently: the The only outstanding item from my review is the non-blocking one: |
|
Status update since opening — all green, ready for review. Three follow-up commits:
Gate:
Measured (q6 @ 100M, median of 5, interleaved): parallel fold 1.83 s vs core parallel Agg 2.72 s (1.49×); serial fold 8.8 s; result matches core parallel to ~15 digits, count exact. |
Parallel int sum/avg partials. Verified locally 18/18 on all five majors (PG15-19) on a branch already containing merged main. Uses construct_array with explicit type params matching core's int4_avg_accum from the start, rather than construct_array_builtin which errored at runtime on PG15/16/17 in #343. Correctly excludes the internal-transtype kinds (sum/avg over int8/numeric) from batch eligibility. Test asserts exact equality with serial rather than a tolerance, which is right: integer sums and numeric division have no reassociation.
What
Makes the ungrouped vectorized batch fold (#337) parallel-aware (#289 phase 5/6). The serial fold runs in one process, so it caps at ~1.4× over core Agg and leaves the ~5× parallel scan (gap 23) on the table. This adds a second upper path — a parallel-aware partial
ColumnarAggunder a core Gather + Finalize Aggregate — so the fold's per-worker speedup stacks on the parallel scan.Opt-in behind a new GUC
pgcolumnar.enable_parallel_vector_agg(default off). First slice:count(*),count(col), andsum/avgoverfloat4/float8— the kinds whose transition state is a plain, non-internal value the fold already holds. Everything else keeps the serial node or the ordinary core Agg.Measured — q6 @ 100M (bench, median of 5, interleaved)
SELECT count(*), avg(usage_system) FROM cpu_pgc WHERE usage_user > 90.0Result matches core's own parallel aggregate to ~15 digits;
countexact. (TimescaleDB's parallel path currently errors on the bench with a broken-compressed-chunk read, so only its serial number is shown.)How it works
ColumnarReadSetParallelCounter, gap 23) and folds them column-at-a-time, emitting one per-worker transition-state tuple; a core Finalize combines them —int8plfor count,float8_combine+float8_avgforavg(float8).Sxx, andfloat8_combinere-derives and re-checks it — so a cross-worker overflow raises exactly as core would (a decompose-to-sum/countshortcut would silently under-raise).UPPERREL_PARTIAL_GROUP_AGGreltarget, so the partial and final Aggrefs stay structurally related andsetrefsmatches them. Uses only public planner/executor APIs (mark_partial_aggref,create_agg_path,create_gather_path,fetch_upper_rel,construct_array_builtin, the CustomScan parallel callbacks) — no core nodeAgg internals.Correctness guards
A parallel path returns wrong answers, not crashes, when it is wrong, so:
InitializeDSMbefore workers launch (a worker cannot see the leader's unflushed in-transaction buffers);Tests / gate
test/parallel_vector_agg.sh(registered inrun_all_versions.sh) asserts the planFinalize → Gather → parallel partial ColumnarAgg (batch fold: yes)is actually chosen,countis exact vs a serial oracle,avg/sum(float)match core parallel Agg within reassociation tolerance (a parallel fold is order-nondeterministic — the oracle is core parallel, never the serial fold), and null/empty/more-workers-than-groups/not-batch-foldable behave. 14/14 on PG18 assert.Gate: preflight build on all five majors (15–19), full assert matrix on PG18 and PG19, and ASAN/UBSAN (pg18_san) on the fold read path.
🤖 Generated with Claude Code