Found while re-running the benchmark suite for the docs.
Measured
PostgreSQL 17.10 non-assert, 6,000,000 rows, one columnar table, median of 5:
| state |
count(*) |
sum/avg |
min/max |
| no deletes |
0.02 ms |
0.29 ms |
0.30 ms |
| after deleting exactly 1 row |
222.28 ms |
233.89 ms |
317.22 ms |
after pgcolumnar.vacuum |
0.02 ms |
0.30 ms |
0.30 ms |
A single deleted row out of six million costs about 11,000x on count(*), and
vacuuming restores it.
Why
The fallback is correct: a zone map covers every row in its group including
deleted ones, so once anything is deleted the metadata answer is wrong. But the
test is storage-wide. columnar_native_agg_exec asks
ColumnarStorageHasDeleteVector(storageId, snapshot) and, if anything anywhere in
the storage has a delete vector, sends the entire aggregate down
columnar_native_scan_agg.
On the table above that means one deleted row in one group forces a full decode of
all 40 groups, including the 39 that have no deletes at all.
What could be done instead
Fold per row group rather than per storage: for each group, use the zone map when
that group has no delete-vector entry and scan only the groups that do. The
per-group delete-vector lookup is already how the scan path works, and
ColumnarReadDeleteVectorList(storageId, groupNumber, snap) is the existing call.
Cost of the current behaviour scales with how spread out the deletes are, and the
worst case is the common one: a handful of scattered deletes across a large table
takes every group off the fast path.
Not a correctness problem, and there is a workaround (pgcolumnar.vacuum), but
the cliff is steep and undocumented until now. docs/benchmarks.md records the
measurement.
Found while re-running the benchmark suite for the docs.
Measured
PostgreSQL 17.10 non-assert, 6,000,000 rows, one columnar table, median of 5:
pgcolumnar.vacuumA single deleted row out of six million costs about 11,000x on
count(*), andvacuuming restores it.
Why
The fallback is correct: a zone map covers every row in its group including
deleted ones, so once anything is deleted the metadata answer is wrong. But the
test is storage-wide.
columnar_native_agg_execasksColumnarStorageHasDeleteVector(storageId, snapshot)and, if anything anywhere inthe storage has a delete vector, sends the entire aggregate down
columnar_native_scan_agg.On the table above that means one deleted row in one group forces a full decode of
all 40 groups, including the 39 that have no deletes at all.
What could be done instead
Fold per row group rather than per storage: for each group, use the zone map when
that group has no delete-vector entry and scan only the groups that do. The
per-group delete-vector lookup is already how the scan path works, and
ColumnarReadDeleteVectorList(storageId, groupNumber, snap)is the existing call.Cost of the current behaviour scales with how spread out the deletes are, and the
worst case is the common one: a handful of scattered deletes across a large table
takes every group off the fast path.
Not a correctness problem, and there is a workaround (
pgcolumnar.vacuum), butthe cliff is steep and undocumented until now.
docs/benchmarks.mdrecords themeasurement.