Skip to content

Fetch by row number re-reads and re-decodes the whole row group, making UPDATE and DELETE quadratic within a group #143

Description

@jdatcmd

Found while measuring whether the ColumnarDeleteVectorBufferedDeleted nested
scan (left unfixed by the July audit) is worth fixing. It is not the problem. The
problem is larger and sits in the fetch-by-row-number path.

Measurements

PostgreSQL 17 non-assert, UPDATE t SET v = v + 1 over the whole table,
EXPLAIN ANALYZE execution time.

Cost grows with the square of the row count, not linearly:

rows with unique index without unique index
5,000 878 ms 333 ms
10,000 4,452 ms 1,623 ms
20,000 19,211 ms 6,901 ms

Doubling the rows multiplies the time by about 4.3 to 5. It happens with and
without a unique index, so this is not the uniqueness check.

More row groups makes it faster, which is the opposite of a per-row-group
cost. 20,000 rows held constant, unique index present:

row groups update
1 15,743 ms
2 6,775 ms
10 1,403 ms

So the term is quadratic in the rows within a row group. Splitting the same
rows across ten groups divides the work by roughly ten.

The column cache does not help:

rows cache off cache on
20,000 15,846 ms 14,693 ms
10,000 4,454 ms 4,957 ms

pgcolumnar.enable_column_cache caches decompressed streams, so this says
decompression is not where the time goes.

Mechanism

ColumnarReadRowByNumber (src/columnar_reader.c:1271) fetches one row by
reading its entire row group each time:

  • groupBuffer = palloc(rg->byteLength) and ColumnarReadLogicalData pull the
    whole group's bytes,
  • columnar_native_decode_chunk decodes each column's chunk in full,
  • and then a for (i = 0; i < rowInGrp; i++) walk steps to the row's position
    inside the decoded chunk.

Fetching N rows from one group therefore costs N times the group's size, plus a
walk that is linear in the row's offset. Nothing is retained between calls, so
two consecutive fetches of adjacent rows in the same group repeat all of it.

Every caller of the fetch path pays this: index scans that return many rows from
one group, bitmap scans, UPDATE and DELETE reached by index, and the
uniqueness check.

It matters at the defaults. stripe_row_limit is 150,000, so one group can hold
150,000 rows; extrapolating the measured curve from 20,000 rows in one group puts
a full-group update in the tens of minutes.

Direction, not yet a design

Two independent pieces, either of which helps on its own:

  1. Reuse the decoded group across consecutive fetches. The bytes at a given
    fileOffset are immutable once written, so a decoded group can be keyed by
    (storageId, fileOffset, byteLength) without an invalidation problem.
    Visibility is checked per fetch against the delete vector and row mask, which
    is separate from the decoded values, so caching values does not affect what a
    snapshot sees. Bounding memory is the real design question, since a decoded
    group of 150,000 rows across many columns is not small.
  2. Remove the linear walk to the row position. Fixed-width columns can index
    directly; variable-width ones need an offset the decoder could produce.

I have not implemented either. The path is on the read side and adjacent to
correctness, so it wants a written plan and a review before code, not an
overnight patch.

Not the cause

ColumnarDeleteVectorBufferedDeleted, which the audit record lists as left
unfixed, is called only under the uniqueness check, and the quadratic behaviour
is present without a unique index at similar magnitude. Its nested scan is real
but is not what makes these numbers. Worth keeping on the list, not worth fixing
first.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions