Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,27 @@ which was true until that script existed.

### Added

- `pgcolumnar.fsst_verdict_reuse` caches a column's FSST keep/drop verdict for a
bounded number of row groups (#472). Default 16; `0` asks every time, which is
the behaviour before this setting existed.

Deciding whether an FSST symbol table pays for itself costs a whole-corpus
encode plus a compression pass, and the answer cannot be sampled: on a training
prefix FSST can look 24 percent worse while over the whole column it is 23
percent better. So it was asked once per column per row group, and for a column
whose data does not change character that re-derived the same answer for the
whole load. Measured at 2,000,000 rows in 20 row groups: 2482 ms of a 5319 ms
`md5` load and 843 ms of a 2081 ms email-shaped load, with the verdict identical
all 20 times.

A text load is about 2.5 times faster as a result, measured in-suite at 1623 ms
against 648 ms. Stored bytes are unchanged for a column whose verdict is stable,
which is asserted rather than assumed: the suite compares the encoding
descriptor, block codec and page length of every chunk. A column that changes
character mid-load is noticed within the bound.

Reuse is per statement. Nothing is persisted and no on-disk structure changes.

- `EXPLAIN (ANALYZE)` now reports `Columnar Usable Skip Predicates` beside
`Columnar Pushed-Down Filters` (#479). The existing line counts the filters the
scan was handed and is unchanged; the new one counts how many of those the
Expand Down
102 changes: 102 additions & 0 deletions design/FSST_VERDICT_CACHE_PLAN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# Caching the FSST keep/drop verdict (#472)

## Motivation, re-measured

`pgcolumnar_flush_row_group` decides FSST keep/drop once per column per row
group. The decision cannot use a sample: on a 256 kB training prefix FSST can
look 24% worse while over the whole column it is 23% better, an inversion no
margin would make safe, so `PgColumnarFsstHelpsCompressed` FSST-encodes the
whole corpus and compresses it, only to answer a yes or no.

For a column whose data does not change character, that recomputes the same
answer for every row group of the load.

Measured on current `main` (PG18.4, 2,000,000 rows, `stripe_row_limit` 100000,
so 20 row groups), with the decision instrumented:

| shape | load | decisions | verdict | deciding | share of load |
| --- | ---: | ---: | --- | ---: | ---: |
| `md5(g)` | 5319 ms | 20 | hurts 20/20 | 2482 ms | 47% |
| email-shaped | 2081 ms | 20 | hurts 20/20 | 843 ms | 41% |
| `'label-' \|\| (g%40)` | 533 ms | 20 | build skipped (#155) | 0 ms | 0% |
| low-card then md5 | 3021 ms | 10 built | hurts 10/10 | 1269 ms | 42% |

So 41 to 47 percent of a text load re-derives a constant. This is consistent
with #499's independent profile of the ingest shape (`encode_fsst_shared` 33.9%,
`PgColumnarFsstBuildChunkTable` 14.6%).

The low-cardinality row is the control: #155's `PgColumnarFsstDictWins` already
skips the build there, and that path costs nothing. This work is about the other
one.

## Design

Two fields on `PgColumnarColumnDef`, which is `palloc0`ed per write state, so
`UNKNOWN` is the natural zero:

```c
#define COLUMNAR_FSST_UNKNOWN 0
#define COLUMNAR_FSST_HELPS 1
#define COLUMNAR_FSST_HURTS 2

int8 fsstVerdict;
int fsstVerdictAge; /* row groups since the verdict was taken */
```

A write state lives for one statement, so the cache never outlives the load that
built it. Nothing is persisted and no on-disk format changes.

`pgcolumnar.fsst_verdict_reuse`, integer, default 16, minimum 0. After a verdict
is taken it is reused for the next N row groups, then re-taken. **0 means never
reuse**, which is exactly today's behaviour and is what the byte-identical test
arm compares against. It is also the escape hatch.

The two verdicts save different amounts, and the plan should not pretend
otherwise:

- **HURTS reused**: skip the table build AND the decision. The vectors take
their ordinary encoding, which is what a fresh HURTS verdict would have done.
- **HELPS reused**: still build the table, because it is trained on this row
group's corpus and stored with the chunk, so reusing the TABLE would change
stored bytes. Only the whole-corpus decision is skipped. The build is the
cheaper half (14.6% against 33.9% in #499's profile).

## What must be proven, and how

The saving is real only if the stored bytes do not change. A stale verdict
silently degrades compression, and nothing in the current suites would notice.

Fixtures, chosen by measurement rather than by assumption. Six candidate corpora
were tried and five of them return HURTS; a cache tested only on those would
exercise one branch and a wrongly cached HELPS would sail through:

| fixture | verdict | purpose |
| --- | --- | --- |
| `md5(g)` | hurts, stable | the common case |
| `'the quick brown fox ... ' \|\| g \|\| ' in the morning'` | **helps, stable** | the other branch |
| prose for half the load, then `md5` | helps then hurts | the age bound |

1. **Byte-identical storage, cache on against cache off**, on both stable
fixtures. `pg_total_relation_size` is too coarse to be the only check, so the
arm compares the relation's bytes.

2. **The age bound is tested rather than assumed.** For a column that changes
character mid-load, caching cannot produce byte-identical output: within the
reuse window the stale verdict is used deliberately. So the assertions are:
- `fsst_verdict_reuse = 1` must be byte-identical to `0`, which pins the
mechanism: a bound of one row group re-decides every time.
- on the changing fixture, a bounded cache must land materially closer to the
uncached size than an effectively unbounded one. Without this the bound is
a number nobody checked.

3. **Correctness is asserted separately from size.** Every arm reads its data
back and compares against a heap mirror. A compression regression is a cost;
a decode failure is a defect, and the two must not share one check.

4. **The load-time win measured in-suite**, not from a standalone probe.

## Risk

Write-path change. The failure mode is silent: worse compression, correct data.
That is why proof 1 is byte equality rather than a size bound, and why proof 2
exists at all. No format change, no read-path change, no catalog change.
17 changes: 17 additions & 0 deletions src/columnar.h
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,23 @@ extern int pgcolumnar_encoding_sample_rows;
extern int pgcolumnar_compression; /* one of COLUMNAR_COMPRESSION_* */
extern int pgcolumnar_compression_level; /* zstd level */
extern int pgcolumnar_fsst_min_gain_percent; /* min compressed FSST win to keep it (#155) */
/*
* How many row groups may reuse a column's FSST keep/drop verdict before it is
* taken again (#472). 0 never reuses, which is the behaviour before that issue
* and what the byte-identical test arm compares against.
*
* Asking the question costs a whole-corpus FSST encode plus a compression pass,
* once per column per row group, because the answer cannot be sampled: on a
* training prefix FSST can look 24% worse while over the whole column it is 23%
* better. Measured at 41 to 47 percent of a text load, re-deriving a verdict
* that did not change across 20 row groups.
*/
extern int pgcolumnar_fsst_verdict_reuse;

/* a column's cached FSST verdict (#472) */
#define COLUMNAR_FSST_UNKNOWN 0
#define COLUMNAR_FSST_HELPS 1
#define COLUMNAR_FSST_HURTS 2
extern bool pgcolumnar_enable_qual_pushdown;
extern bool pgcolumnar_enable_column_projection;
extern bool pgcolumnar_enable_custom_scan;
Expand Down
16 changes: 16 additions & 0 deletions src/columnar_tableam.c
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ PG_MODULE_MAGIC;
/* GUC-backed instance defaults (spec 8.3) */
int pgcolumnar_stripe_row_limit = 150000;
int pgcolumnar_chunk_group_row_limit = 10000;
int pgcolumnar_fsst_verdict_reuse = 16;
int pgcolumnar_encoding_sample_rows = 2048;

int pgcolumnar_compression = COLUMNAR_COMPRESSION_ZSTD;
Expand Down Expand Up @@ -2299,6 +2300,21 @@ _PG_init(void)
0,
NULL, NULL, NULL);

DefineCustomIntVariable("pgcolumnar.fsst_verdict_reuse",
"Row groups that may reuse a column's FSST keep/drop verdict.",
"Deciding whether an FSST symbol table pays for itself costs a "
"whole-corpus encode plus a compression pass, and the answer "
"cannot be sampled, so it is asked once per column per row "
"group. For a column whose data does not change character that "
"re-derives a constant. 0 asks every time, which is the "
"behaviour before this setting existed.",
&pgcolumnar_fsst_verdict_reuse,
16,
0, INT_MAX,
PGC_USERSET,
0,
NULL, NULL, NULL);

DefineCustomIntVariable("pgcolumnar.encoding_sample_rows",
"Rows sampled to choose a chunk's value encoding.",
"Candidate encodings are estimated on a windowed sample "
Expand Down
76 changes: 68 additions & 8 deletions src/columnar_write_state.c
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,16 @@ typedef struct PgColumnarColumnDef
bool bloomable;
FmgrInfo hashFn;
Oid hashCollation; /* collation to hash under (InvalidOid if none) */

/*
* Cached FSST keep/drop verdict and how many row groups have reused it
* (#472). COLUMNAR_FSST_UNKNOWN is the palloc0 default, so a fresh write
* state always asks once. The cache lives exactly as long as the write
* state, which is one statement: nothing is persisted and no on-disk
* structure changes.
*/
int8 fsstVerdict;
int fsstVerdictAge;
} PgColumnarColumnDef;

/* one column's two streams within one chunk group */
Expand Down Expand Up @@ -1046,6 +1056,10 @@ pgcolumnar_flush_row_group(PgColumnarWriteState *writeState)
{
StringInfoData corpus;
uint32 sampleLen = 0;
/* `def` is the enclosing block's, at the top of this per-column
* loop. Re-declaring it here shadowed that one, which this project
* builds with -Wshadow=compatible-local and treats as an error. */
bool reuseVerdict;

initStringInfo(&corpus);
foreach(lc, writeState->chunkGroups)
Expand Down Expand Up @@ -1088,8 +1102,19 @@ pgcolumnar_flush_row_group(PgColumnarWriteState *writeState)
* the keep/drop decision uses, and only skips when the dictionary is
* viable and wins for every vector, so the stored bytes are identical.
*/
/*
* Reuse this column's previous verdict when it is young enough
* (#472). A HURTS verdict skips the build as well as the question,
* since the vectors then take their ordinary encoding, which is
* what a freshly taken HURTS would have produced.
*/
reuseVerdict = (pgcolumnar_fsst_verdict_reuse > 0 &&
def->fsstVerdict != COLUMNAR_FSST_UNKNOWN &&
def->fsstVerdictAge < pgcolumnar_fsst_verdict_reuse);

if (corpus.len > 0 &&
writeState->encodeEffort != COLUMNAR_ENCODE_EFFORT_FAST &&
!(reuseVerdict && def->fsstVerdict == COLUMNAR_FSST_HURTS) &&
!PgColumnarFsstDictWins(corpus.data, (uint32) corpus.len))
PgColumnarFsstBuildChunkTable(corpus.data, sampleLen, att,
&fsstTable, &fsstTableLen);
Expand All @@ -1111,15 +1136,50 @@ pgcolumnar_flush_row_group(PgColumnarWriteState *writeState)
* the whole column it is 23% better -- a verdict that is not merely
* imprecise but inverted, so no margin on the sample would be safe.
*/
if (fsstTable != NULL &&
!PgColumnarFsstHelpsCompressed(corpus.data, (uint32) corpus.len,
fsstTable, fsstTableLen,
writeState->compressionType,
writeState->compressionLevel))
if (fsstTable != NULL)
{
pfree(fsstTable);
fsstTable = NULL;
fsstTableLen = 0;
bool helps;

/*
* A reused HELPS verdict still builds the table above, because
* the table is trained on THIS row group's corpus and stored
* with the chunk: reusing the table itself would change the
* stored bytes. Only the whole-corpus question is skipped, and
* that is the expensive half.
*/
if (reuseVerdict)
{
helps = (def->fsstVerdict == COLUMNAR_FSST_HELPS);
def->fsstVerdictAge++;
}
else
{
helps = PgColumnarFsstHelpsCompressed(corpus.data,
(uint32) corpus.len,
fsstTable, fsstTableLen,
writeState->compressionType,
writeState->compressionLevel);
def->fsstVerdict = helps ? COLUMNAR_FSST_HELPS
: COLUMNAR_FSST_HURTS;
def->fsstVerdictAge = 0;
}

if (!helps)
{
pfree(fsstTable);
fsstTable = NULL;
fsstTableLen = 0;
}
}
else if (reuseVerdict && def->fsstVerdict == COLUMNAR_FSST_HURTS)
{
/*
* The build was skipped on the strength of the cached verdict,
* so this row group counts as a reuse too. Without this the age
* would never advance on the common path and the bound would
* never re-take the verdict.
*/
def->fsstVerdictAge++;
}

pfree(corpus.data);
Expand Down
Loading
Loading