diff --git a/CHANGELOG.md b/CHANGELOG.md index 00d4eb1..4f7da99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/design/FSST_VERDICT_CACHE_PLAN.md b/design/FSST_VERDICT_CACHE_PLAN.md new file mode 100644 index 0000000..2047ea3 --- /dev/null +++ b/design/FSST_VERDICT_CACHE_PLAN.md @@ -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. diff --git a/src/columnar.h b/src/columnar.h index 4321973..040073a 100644 --- a/src/columnar.h +++ b/src/columnar.h @@ -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; diff --git a/src/columnar_tableam.c b/src/columnar_tableam.c index 3c506bf..70d8cc7 100644 --- a/src/columnar_tableam.c +++ b/src/columnar_tableam.c @@ -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; @@ -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 " diff --git a/src/columnar_write_state.c b/src/columnar_write_state.c index e7700f0..959590b 100644 --- a/src/columnar_write_state.c +++ b/src/columnar_write_state.c @@ -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 */ @@ -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) @@ -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); @@ -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); diff --git a/test/fsst_verdict_cache.sh b/test/fsst_verdict_cache.sh new file mode 100644 index 0000000..59f8db4 --- /dev/null +++ b/test/fsst_verdict_cache.sh @@ -0,0 +1,219 @@ +#!/usr/bin/env bash +# +# pgColumnar: the FSST keep/drop verdict is cached with an age bound (#472). +# +# pgcolumnar_flush_row_group decides FSST keep/drop once per column per row +# group, and the decision cannot use a sample: on a 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 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 main before this change, 2,000,000 rows in 20 row groups: 2482 ms +# of a 5319 ms md5 load and 843 ms of a 2081 ms email load went to deciding, and +# the verdict was the same all 20 times. 41 to 47 percent of a text load +# re-deriving a constant. +# +# THE RISK IS NOT SPEED, IT IS SILENCE. A stale verdict does not corrupt +# anything; it compresses worse, correctly, and nothing would notice. So the +# headline checks here are byte equality of the stored chunks, not load time. +# +# Usage: test/fsst_verdict_cache.sh [PG_CONFIG] +# Written fresh for pgColumnar. + +set -uo pipefail +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +pgc_setup "${1:-/usr/local/pg17/bin/pg_config}" + +ROWS=${PGC_FSST_ROWS:-600000} +NGROUPS=6 # ROWS / stripe_row_limit below. + # NOT `GROUPS`: that is a bash special + # variable holding the caller's group ids, + # so assigning it silently does nothing and + # $((ROWS / NGROUPS)) divides by zero. + +# Two corpora, chosen by measurement rather than by assumption. Six candidates +# were tried and five return HURTS (md5, urls, emails, JSON, log paths); prose +# is the only one that returns HELPS. A suite that tested only the common case +# would exercise one branch, and a wrongly cached HELPS verdict would sail +# through it. +PROSE="'the quick brown fox jumps over the lazy dog number ' || g || ' in the morning'" +MD5="md5(g::text)" + +# Load