Skip to content

Re-scope #155 after encode_effort, with measurements - #213

Merged
jdatcmd merged 1 commit into
mainfrom
design/155-rescope-after-202
Jul 28, 2026
Merged

Re-scope #155 after encode_effort, with measurements#213
jdatcmd merged 1 commit into
mainfrom
design/155-rescope-after-202

Conversation

@jdatcmd

@jdatcmd jdatcmd commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Design only, no code. Scoping work for #155 as it stands after #202.

Why re-scope

This plan's order was already corrected once, after #160, and correctly: the per-row round trip is not the cost, and one integer column beats heap. Since then encode_effort (#202) shipped and half-answered step 1, so the figures in the document no longer describe the tree.

Re-measured on PG17.10 non-assert, 6,000,000 rows, reported as ratios against a heap insert of the same rows so common-mode load on the box cancels rather than being argued about.

What the knob actually bought, per shape

shape full fast recovered
1 text col, low cardinality ('name-'||(g%1000)) 3.83x 3.01x 21%
1 text col, high entropy (sha256, 76 chars) 6.48x 1.94x 70%
5-col mixed, low-cardinality text 5.59x 5.55x nothing
5-col mixed, high-entropy text 8.83x 4.13x 53%

The knob solved the case it was built for, and only that case. High-entropy text at 1.94x is near parity for a format writing a fraction of the bytes — step 1 delivered where the FSST search was the cost.

It does not touch low-cardinality text, which is the more common shape — names, regions, categories, statuses. fast skips the FSST search, so by elimination the remaining 3.01x is not FSST: it is dictionary, value stream, zone maps, compression. Nothing has attributed that yet, and the 5-column low-cardinality row is 5.59x with the knob doing nothing at all.

The other axis: a per-column tax, independent of text

shape heap columnar ratio
1 col (bigint) 2501 ms 1671 ms 0.67x
2 cols 2318 ms 2636 ms 1.14x
4 cols 2674 ms 6744 ms 2.52x

Heap is nearly flat (2501 → 2674) because row overhead dominates and narrow columns are almost free to add. Columnar pays ~1–2 s per extra column per 6M rows. That is the tax on wide tables and it has nothing to do with encoding search.

One thing worth stating before anyone budgets for it

Column-at-a-time transfer (step 2) applies only to the importers, where a column store is genuinely on both ends. INSERT ... SELECT has no columnar source — rows arrive from a heap scan — so the per-row shape is not removable there. Since import already measures the same as an ordinary insert, step 2's ceiling is the slot round trip alone, not the encoding work underneath it.

Revised order

  1. Ablate low-cardinality text at effort = fast — separate dictionary build, value stream, zone maps, compression. 3.01x with the shipped knob already applied is the number to explain. No design until it is attributed.
  2. The per-column numeric tax — additive, sets the floor for every wide table.
  3. Column-at-a-time transfer, for the importers only, scoped by what 1 and 2 leave.

Method notes

  • Ratios, not millisecond counts, for the reason your Finish the plan-shape sweep #203 started #207 root-cause section gives: this box is not idle and cannot be trusted to be.
  • I first used md5() for the high-entropy column and nearly reported a 1.8x regression against this document's 12,989 ms — until I checked and found the plan's own c1 is 'name-'||(g%1000), a low-cardinality string. Different fixture, not a regression. The high-entropy column is now sha256 (6,000,000 distinct, verified, 76 chars); string length is itself an FSST variable, so the two shapes are reported separately rather than blended.
  • Cleared the box first, which turned up a three-day runaway backend — filed as An INSERT in unique_conc spun at 100% CPU for three days, and survived its postmaster #212.

Not marking this as closing #155: it re-scopes it and hands it two measurable next steps.

The plan's order was corrected once after #160. Shipping encode_effort (#202)
has since half-answered step 1, so the numbers no longer describe the tree.
Re-measured at 6,000,000 rows on PG17.10 non-assert, as ratios against a heap
insert of the same rows so common-mode load on the box cancels.

encode_effort = fast takes high-entropy text from 6.48x to 1.94x, near parity
with heap. It recovers a fifth of low-cardinality text (3.83x to 3.01x) and
nothing at all on the 5-column low-cardinality row. Since fast skips the FSST
search, the remaining low-cardinality cost is by elimination not FSST, and
nothing has attributed it yet. That is now the first thing to measure.

Separately, the per-column numeric cost is additive and independent of text:
one bigint column beats heap at 0.67x, four integer columns cost 2.52x, while
heap stays nearly flat across the same range.

Also records what column-at-a-time transfer can and cannot buy, since it
applies only to the importers: INSERT ... SELECT has no columnar source, so its
ceiling is the slot round trip rather than the encoding work underneath.

The headline is shape-dependent, from 0.67x to 8.83x. "4.9x slower than heap"
was one point on that curve.

Design only; no code change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uKWWwBDt5TWWS5DR2tzDb

@ChronicallyJD ChronicallyJD left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The re-scope is right, and the methodology change is the part I most want to endorse: measuring in ratios rather than millisecond counts is the correct answer to exactly what burned my curve on this issue. A ratio cancels common-mode load on the box, so it survives a runaway core the way a raw millisecond count does not — which, given #212, is not hypothetical. This document is now robust to the failure mode that produced my retraction.

I checked the one load-bearing inference rather than nod at it. The argument that the residual 3.01x on low-cardinality text is not FSST rests on encode_effort = fast changing only the FSST search. It does: encodeEffort is read in exactly one place in the encoder, columnar_write_state.c:860, guarding the FSST substring search and nothing else. So the full→fast delta is purely FSST, and by elimination the remaining 3.01x is the dictionary path / value stream / zone maps / compression, exactly as written. The inference is exact, not approximate.

The redirection is the valuable part. "The knob solved the case it was built for and only that case" is the honest reading of the 6.48x→1.94x vs 3.83x→3.01x split, and low-cardinality text (names, regions, statuses) is the more common shape, so a fifth recovered is the smaller half of the problem. Calling for an ablation, not a design, before touching it is the right sequence.

Two things I can corroborate from my own #155 measurements, for whatever a second data point is worth: the narrow/low-entropy end beating heap matches what I saw (a constant-8-char column came in under heap, same shape as your 0.67x bigint), and high-entropy text being where the cost concentrated matches too. The magnitudes differ because my fixtures and pre-#202 tree differ, which is the point — the curve is shape-dependent and "4.9x" was one sample of it, as you now say.

One small note, no change required: the per-column numeric tax reads as isolated (four integer columns, no encoder search) and additive, which makes it the cleanest of the three to attribute — it has no entropy or dictionary confound at all. If step 1's ablation is where the design energy goes, step 2 may be the one that actually moves the median wide-table load, since it applies regardless of type. Worth keeping its priority under review once the text ablation lands.

Approving. And since the step-1 ablation is now the gate on both this issue and the #212 caching decision you're holding, I'll take it if you want it — separate the dictionary build, value stream, zone-map write and block compression on the low-cardinality text shape at fast, on a clean box, in ratios. Say the word.

@jdatcmd
jdatcmd merged commit 613d309 into main Jul 28, 2026
@jdatcmd
jdatcmd deleted the design/155-rescope-after-202 branch July 28, 2026 20:03
@jdatcmd

jdatcmd commented Jul 28, 2026

Copy link
Copy Markdown
Owner Author

Yes, please take it. Merged at 613d309.

Thank you for checking the encodeEffort inference at the source rather than
accepting it. That single read at columnar_write_state.c:860 is what the whole
elimination argument stands on, and it was the one thing in the document that
would have quietly invalidated the rest if it had been wrong.

Scope

Step 1 as you described it: on the low-cardinality text shape at
encode_effort = fast, separate the dictionary build, the value stream, the
zone-map write and the block compression, in ratios, on a clean box. The number
to attribute is the residual 3.01x.

Two additions worth folding in, both cheap once the harness exists:

  • Run the 5-column mixed, low-cardinality text shape through the same
    breakdown. It recovered nothing from the knob (5.59x to 5.55x) where the
    single-column shape recovered a fifth, and that gap is unexplained. If the
    breakdown looks the same on both, the per-column tax is the difference and
    that is worth knowing before either is designed against.
  • State what each component's share does as the row count grows. Constant-share
    and growing-share have different fixes, and the current data is a single point
    at 6M.

Not in scope, deliberately

The per-column tax stays a separate measurement. You are right that it may move
the median wide-table load more, since it applies regardless of type, and it is
the cleaner of the two to attribute. It is next, not concurrent. I would rather
have one attributed result than two half-attributed ones, and the text ablation
is the one that unblocks the other decisions.

What it gates

Both #155's next design step and the caching half of #212, which is deliberately
unstarted until this lands. Neither should be designed on the assumption that the
catalog scans are the cost merely because they were the most recently found
thing.

Box

It is free now. The full matrix finished green at d5c971d and nothing else is
running. Please still check it before measuring, per the lesson from this week:
uptime, then ps -eo pcpu,pid,comm --sort=-pcpu | head, and the container's
own ps -ef | grep "[p]ostgres". An orphaned backend spun at 98.9% from Jul 25
to Jul 28 and silently contaminated every number taken in between, including the
retraction on this very issue. A host kdeconnectd also burns most of a core
here, which is one more reason the ratios matter.

PG17.10 non-assert at 6,000,000 rows keeps it comparable with the figures in the
merged plan.

jdatcmd pushed a commit that referenced this pull request Jul 28, 2026
)

Alpha gate 2 of 4. Audits docs/limitations.md against the current tree, corrects
two sections that documented behaviour that has since been fixed, and adds the
two missing pieces the pre-alpha review note named.

Corrections (documenting a defect is not fixing it, and the reverse holds too --
a fixed defect must not stay listed as a limitation):

- Constraints on the import path: removed the claim that a deferrable unique
  constraint is checked per row rather than deferred to commit. That was #168,
  fixed by the executor index-maintenance path (#180/#182); import_deferred.sh
  now asserts import_arrow and import_parquet defer correctly.
- Planner statistics: removed the warning that ANALYZE can take a long time on
  wide tables, the SET STATISTICS 0 workaround, and the point-lookup plan
  regression. That was #171, both halves fixed (#173 planner, #175 sampler).

Additions:

- Release status: states what 1.0-dev means, what the extension is appropriate
  for today, and what hardening is gated before a first alpha (#214, #216, #217).
  Linked from README.md and docs/index.md.
- Bulk load and import throughput: states the range by shape from #213 (0.67x to
  8.83x, not a single multiplier), names encode_effort as the knob, and records
  that import has no overhead beyond the write path.

The per-column numeric insert cost is deliberately held until its ablation lands,
per the issue.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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