Skip to content

perf: linear-time toon_agg via C transition + set-based rows_to_toon - #11

Merged
xzilla merged 2 commits into
masterfrom
perf/linear-agg
Oct 1, 2026
Merged

xzilla merged 2 commits into
masterfrom
perf/linear-agg

Conversation

@xzilla

@xzilla xzilla commented Jul 13, 2026 •

Copy link
Copy Markdown
Owner

Fixes #4.

The quadratic cost had two independent causes (the issue initially blamed only the finalizer; measurement showed the transition function was the bigger half):

  1. Transition boundary: PostgreSQL flattens/re-expands SQL & plpgsql transition state every call, so ANY custom array-accumulating transition is O(n²) regardless of body (measured: minimal state := state || x plpgsql = 17 s @ 40k rows; SQL wrapper around array_append = 15 s). Only a C transition stays linear (pg_catalog.array_append: 92 ms @ 160k).
  2. Finalizer: per-row string concat loop, quadratic on its own (8 s of 39 s @ 40k).

Design: the default toon_agg(anycompatible) collects rows with pg_catalog.array_append (STYPE anycompatiblearray) and encodes once at finalization via a new public function rows_to_toon(anyarray, delim DEFAULT ',') — one set-based pass over json_array_elements(array_to_json(rows)). (It never subscripts the array in a loop: flat varlena arrays have O(i) element access, so rows[i] over all i is itself O(n²) — measured.) It's LANGUAGE sql because plpgsql can't accept record[], and STABLE because array_to_json is GUC-dependent.

The 2-arg toon_agg(q, delim) can't use array_append (no third argument), so it keeps a plpgsql transition (text[] state, linear finalizer) and stays quadratic at the boundary; README documents rows_to_toon(array_agg(q), '|') as the linear escape hatch and adds a Performance section. The toon_agg_state composite type is gone.

Numbers (2-column rows): 40k: 39.2 s → 0.9 s. 200k: >10 min → 2–6 s.

Semantics change (deliberate, tested): NULL records (unmatched LEFT JOIN rows) are now skipped, with [N] counting only encoded rows. Previously a NULL record NULL-poisoned the entire 2-arg result. Non-record elements raise a clear error.

Verification: output byte-identical to the old implementation for comma/pipe/tab across 5k mixed rows (quoting, embedded newlines, NULLs, float NaN, text 'NaN') plus empty/single-row cases; independently fuzzed across 30+ record shapes (unicode, 22-col, domains, composites, arrays-as-columns, DISTINCT/FILTER/window/GROUP BY) by an adversarial review pass. Suite: 77/77.

Merge order: conflicts with #8 (volatility — function list in its test) and #9 (delimiter validation — sfunc guard). Suggest merging the small ones first; I'll rebase this on top (re-adding the delim guard to the rewritten sfunc and the new names to the volatility test).

🤖 Generated with Claude Code

https://claude.ai/code/session_01FEYjgkWDYiRMYwwRS985g5

toon_agg was quadratic in row count from two independent causes:

1. The transition function. PostgreSQL flattens and re-expands SQL and
   plpgsql transition state on every call, so ANY custom transition that
   accumulates an array is O(n²) regardless of its body (measured: a
   minimal 'state := state || x' plpgsql transition takes 17 s for 40k
   rows; a SQL wrapper around array_append, 15 s). Only a C transition
   function stays linear (pg_catalog.array_append: 92 ms for 160k).

2. The finalizer built the result with a per-row string concatenation
   loop, quadratic on its own (8 s of the 39 s total at 40k rows).

The default-delimiter toon_agg(anycompatible) now collects rows with
pg_catalog.array_append (STYPE anycompatiblearray) and encodes once at
finalization through a new public function:

    rows_to_toon(rows anyarray, delim text DEFAULT ',')

which serializes the array once via array_to_json and encodes all rows
in one set-based pass. It deliberately never subscripts the array in a
loop: flat varlena arrays have O(i) element access, so rows[i] over all
i is itself O(n²) — measured and avoided. It is LANGUAGE sql because
plpgsql cannot accept record[] from anonymous subquery rows, and STABLE
(not IMMUTABLE) because array_to_json's rendering is GUC-dependent.

NULL records (e.g. unmatched LEFT JOIN rows) are skipped in both
aggregate variants and in rows_to_toon, with [N] counting only encoded
rows. The previous implementation NULL-poisoned the whole result (2-arg)
or returned NULL for everything (mixed cases); tests pin the new
semantics. Non-record elements raise a clear error.

The two-argument toon_agg(anyelement, text) cannot use array_append (no
way to carry the delimiter), so it keeps a plpgsql transition (text[]
state, linear finalizer) and remains quadratic at the transition
boundary; README documents rows_to_toon(array_agg(q), delim) as the
linear escape hatch for large sets. The toon_agg_state composite type
is gone.

Measured, 2-column rows:
  40k rows:   39.2 s  ->  0.9 s
  200k rows:  >10 min ->  2-6 s

Output verified byte-identical against the previous implementation for
comma/pipe/tab across 5k mixed rows (quoted values, embedded newlines,
NULLs, float NaN, text 'NaN'), plus empty-set/single-row cases, and
independently fuzzed across 30+ record shapes by an adversarial review
pass. Suite grows to 77 assertions.

Fixes #4

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEYjgkWDYiRMYwwRS985g5
Resolve conflicts with #8 (volatility), #9 (delimiter validation) and
#10 (NaN docs), which landed on master first:

- toon_agg_sfunc: keep this branch's text[] state, mark it STABLE per
  #8, and run #9's delimiter guard before the NULL-record skip so an
  invalid delimiter raises even when the row is NULL.
- Volatility test: replace toon_agg_sfunc_default with the functions
  this branch adds (rows_to_toon, toon_agg_rows_ffunc, toon_delim_ok,
  toon_rows_ok).
- AGENTS.md: list the new aggregate helpers in place of the removed
  toon_agg_state / sfunc_default entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JAaWhjV4FjNThbcAAWksS
@xzilla
xzilla merged commit e1acdee into master Oct 1, 2026
5 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.

toon_agg_ffunc builds output with an O(n^2) string-concatenation loop

2 participants