perf: unpack bit-packed values a word at a time, not a bit at a time (#501) - #514
Conversation
…501) bitunpack ran its inner loop once per BIT -- a byte load, two shifts, a mask, a test and an or for every bit of every value. It was the largest single frame in a CPU profile of both scan shapes: 21.0% of a filtered aggregate and 19.2% of a row-returning projection, against 4-7% for the decode machinery around it. A value of `width` bits at bit offset `shift` spans at most 64 + 7 bits, so one unaligned 64-bit load plus at most one further byte covers it. Both are little-endian reads, which this format already requires (spec 3). The high-byte shift-in is guarded by `shift + width > 64`, which cannot hold when shift is 0 because width is at most 64 -- so `64 - shift` is never a shift by 64. The fast path runs only where its nine-byte window lies inside the encoded body; the tail keeps the per-bit assembly rather than over-reading. The over-read would be at most seven bytes and would usually land inside the same allocation, which is the kind of defect that passes every test and then fails under a sanitizer. Proven byte-identical against a kept reference, in the shape this file already uses for bitpack: ref_bitunpack is the pre-#501 per-bit loop verbatim, and the encoding selftest now compares the two value by value at every width from 1 to 64, at every sub-byte start offset the existing value counts produce. That is the only place widths above ~32, the nine-byte field span and the width == 64 mask are reached at all, since the encoder never selects packing there. The check has teeth, proven by breaking the subtlest part of the rewrite rather than by deleting the check: with the high-byte carry removed, so that only values spanning past 64 bits are wrong, FAIL bitunpack width=59 n=7 value 5: 34962621760722854 vs reference 179077809836578726 which is exactly the case the guard exists for. Refs #501 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FeNm2Gw6h16Z123We3F1vJ
A per-value 'does the nine-byte window fit' test cost more than the entire per-bit loop it replaced when width was 1: measured 1.7% slower on a monotonic bigint, where delta encoding packs to a single bit and the old inner loop ran one iteration. The count of values the wide load can serve is arithmetic on nbytes and width, so it is computed once and the loop splits into a fast body with no bounds test and a per-bit tail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FeNm2Gw6h16Z123We3F1vJ
ChronicallyJD
left a comment
There was a problem hiding this comment.
Read the arithmetic rather than the description, and checked the one thing that
would make the oracle vacuous.
The bounds are conservative in the right direction. The necessary condition is
floor(i*width/8) <= nbytes - 9; yours is i*width <= 8*(nbytes-9), which is
strictly stronger, so values near the boundary fall to the tail rather than off
the end. nbytes >= 9 guards the unsigned subtraction. That is the right way to
be wrong about a bounds test.
The high-byte shift is sound and the guard is exact. shift + width > 64
cannot hold with shift == 0 because width <= 64, so 64 - shift lies in
[57,63] and is never a shift by 64. One extra byte is also sufficient rather than
merely convenient: the residual is shift + width - 64, maximised at 7 when
shift == 7 and width == 64, and a byte supplies 8.
width == 64 special-casing the mask avoids the 1 << 64 UB, and
COLUMNAR_DECODE_INTERRUPT survives in both loops.
The thing I checked because the oracle could have been vacuous
An exhaustive comparison at every width proves nothing about the fast path if the
fixture never reaches it — nFast > 0 needs n * width >= 72, so a small
counts[] would have tested widths 1..9 through the tail only, which is the
reference implementation compared against itself.
counts[] = {1, 2, 3, 7, 8, 9, 17, 64, 129} is sufficient: every width from 1 to
64 has at least one count reaching the fast path, and the interesting case is
width 1 with n=129, where nFast is 65 — values 0..64 take the wide load and
65..128 take the tail, in the same call. Both paths, one comparison, every width.
Worth stating in the PR because "at every width from 1 to 64" reads as complete
coverage and is only complete given those counts; a later trim of that array would
silently narrow it to the tail at small widths.
One note, not a blocker: the two paths disagree on a big-endian host
The tail assembles values by indexing bits, so it is endian-independent. The fast
path is memcpy into a uint64 and shift, which is little-endian by
construction. Your comment cites spec 3, and that is the right answer — the format
already stores host-endian integers and already assumes little-endian, so this
introduces no portability break that was not there.
What is new is the shape of the break. Before, bitunpack was one of the few
pieces that would have produced correct values on a big-endian machine. Now the
same call would return wrong values for indices below nFast and correct ones
above it — an array correct at one end and wrong at the other, which is a nastier
thing to diagnose than uniformly wrong. Worth one line at the tail loop saying it
is endian-independent and the fast path is not, so nobody later "simplifies" the
tail into the same word trick believing that restores consistency.
And on the framing
Carrying "no measurable difference on ClickBench" into the body beside the 13.6%
is the right call and it is my error you are guarding against — I filed this issue
claiming ~20% of every scan from two shapes of one fixture. The profile showing
the share tracking width (absent from the top five at ~1 bit, 17.22% at width
10) is a better argument than any single percentage, because it is a prediction
the mechanism makes rather than a number a fixture happened to produce.
Approving.
… list (#501) Raised in review of #514. Coverage of the unpacker's two paths is a property of the counts array, not of the loop: nFast is zero until the encoded body reaches nine bytes, which needs n * width >= 65. A shorter list would exercise low widths through the TAIL only -- and the tail is the per-bit assembly, which is exactly what ref_bitunpack does, so the oracle would be comparing the reference against itself and passing. 'Every width from 1 to 64' would still read as complete coverage. The fixed list is sufficient today (verified: every width reaches the fast path, and every width exercises both paths in one call -- width 1 with n=129 splits 65 fast and 64 tail). But that is a fact about nine constants, and trimming them would narrow the oracle silently. So one count per width is now derived from the width rather than listed, and checked, so it cannot rot either. Proven by breaking the derivation rather than deleting the check: FAIL width=1: derived n=1 yields 1 bytes, which never reaches the wide load Also notes at the tail that it is deliberately bit-indexed and endian- independent where the fast path is not. Spec 3 already requires a little-endian host so this is not a second contract, but an array correct above nFast and wrong below it is far nastier to diagnose than one uniformly wrong, and that is what 'simplifying' the tail into the same word trick would produce. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FeNm2Gw6h16Z123We3F1vJ
|
Both review points taken, The oracle could have been vacuous, and now cannot be trimmed into vacuityYour catch, verified independently before acting on it. The current list is sufficient. I checked all 64 widths rather than spot-checking: But that is a fact about nine constants. So one count per width is now derived from the width instead of listed, and checked, so trimming the array cannot narrow the oracle and the derivation cannot rot either. Proven by breaking the derivation rather than deleting the check: The tail's endian-independence is now statedYour reading is right and the shape of the risk is the part worth writing down: spec 3 already requires a little-endian host, so this is not a second contract — but an array correct above Re-verified
One small correction to the arithmetic in your note, for the record rather than because it changes anything: the threshold is |
On failure pgc_summary tails 40 lines of the server log. That is the right thing
to show when one statement failed and the wrong thing after a crash: a crashing
backend takes the postmaster through "terminating any other active server
processes" and recovery for every subsequent check, so the cause is at the TOP of
the log and the last 40 lines are its aftermath. pgc_teardown then removes the
workdir, so there is nowhere left to look.
Measured, running a deliberate heap overrun through this harness under the
pg18_san build:
server log: 8,777 lines
AddressSanitizer reports: 67
first report at line: 12
what the 40-line tail showed: lines 8738-8777, all crash recovery
The suite reported 123 failures and not one word about why. The diagnosis existed
for about a quarter of a second, 8,765 lines above the only window anyone saw.
So the failure path now greps the whole log for the events that mean "this was
not a failed assertion" -- AddressSanitizer, UndefinedBehaviorSanitizer, runtime
error:, terminated by signal, PANIC: -- and prints the first five with line
numbers, above the existing tail. The tail is unchanged; this is added context,
not a replacement, because for the ordinary single-statement failure the tail is
still the useful view.
## Tests
harness_selftest.sh stands the scenario up without needing a sanitizer build: a
fatal-looking line via RAISE LOG, 60 filler lines to push it past the tail window,
then a real failure.
Two premise checks first, because this check has two ways to pass for the wrong
reason -- if the sub-suite did not fail, the summary never runs; if the filler did
not bury the marker, the existing tail would have shown it and the new code would
be untested:
PASS premise: the sub-suite failed, so its summary ran
PASS premise: the 40-line tail is filler, not the marker
Red before the change, and again with lib.sh reverted under the new test:
FAIL a failing suite names the first fatal event in its log: got [no] want [yes]
The assertion is scoped to the new section and asks whether the marker is there
rather than how many times: PostgreSQL emits a STATEMENT: line beside the message,
so it legitimately appears twice, and an exact count would be asserting a detail
of PostgreSQL's logging.
Found while running the sanitizer build over today's decode-path changes (#511,
#514), where a clean result could not be distinguished from a broken instrument.
No defect was found in either.
test/run_san.sh runs a SUBSET of the suites, and a suite outside it is not
sanitized -- silently, because nothing reports the omission.
encode_invariants was outside it. That is the only suite that drives
pgcolumnar_debug_encoding_selftest, which exercises bitunpack at every width
1..64 across counts 1,2,3,7,8,9,17,64,129 plus a derived count per width.
It matters because it is the only fixture that crosses bitunpack's fast/tail
boundary in both directions. nFast is zero until the encoded body reaches nine
bytes (n * width >= 65), so small counts run the tail and larger ones the wide
load. Measured with a probe build that recorded whether the tail loop executed,
counting backends that reached it:
encode_invariants 21
differential 0
differential's chunks are large enough that nFast == n throughout. So the
sanitizer pass covered one of bitunpack's two paths, over exactly the code #514
rewrote, and "clean under ASAN" described the half that ran.
Adding encode_invariants to the default subset closes that. The suite is already
clean there: 11 pass / 0 fail with no sanitizer reports under the ASAN+UBSAN
build.
## Tests
The check asks whether every suite driving the selftest is in the subset, rather
than naming encode_invariants, so moving the selftest elsewhere cannot quietly
narrow it. Two premises first, because it has two ways to pass vacuously -- an
empty subset parse, or no drivers found at all:
PASS premise: run_san.sh's default subset was found and is non-empty
PASS premise: at least one suite drives the C-level encoding selftest
Red before the change, and again with run_san.sh alone reverted:
FAIL the sanitizer subset runs every suite that drives the encoding selftest: got [encode_invariants] want []
The sweep skips harness_selftest.sh itself: it names the function in the pattern
it searches with, so a blind sweep matches the searcher as well as the searched.
That is the same self-match that makes `pgrep -f <pattern>` find its own command
line, and the first run of this check reported [encode_invariants harness_selftest].
Refs #520, #514.
bitunpackran its inner loop once per bit — a byte load, two shifts, a mask, a test and an or for every bit of every value. This reads one unaligned 64-bit word instead, and is byte-identical.What the win actually is, which is not what the issue first claimed
The saving scales with bit width, because the per-bit loop runs
widthiterations per value and the word path runs one. @ChronicallyJD has since amended #501's title accordingly.A/B on the bench, same cluster, same data, only the
.sodiffering, postmaster restarted between arms, medians of 3, answers identical throughout:sum(a)— bigint, delta-packed to ~1 bitcount(*) WHERE b = 7— int, width 10On ClickBench there is no measurable difference at all. The queries I tried first (equality on
ClientTimeZone,GROUP BY CounterID,sum(ResolutionWidth)) either prune to zero rows or are not dominated by unpacking. This is a primitive-level win that shows up where unpacking dominates; it should not be quoted as a workload number.The mechanism, confirmed by profile rather than asserted
cpu-clock, 999 Hz, 5K samples per shape, distinct capture files, on the same fixture:That is a sharper claim than any single percentage: the share tracks width, which is what the mechanism predicts and what the A/B shows.
Byte-identical, proven the way this file already proves
bitpackref_bitunpackis the pre-#501 per-bit loop kept verbatim as the oracle, beside the existingref_bitpack. The encoding selftest now compares the two value by value at every width from 1 to 64, at every sub-byte start offset the existing value counts produce — the only place widths above ~32, the nine-byte field span and thewidth == 64mask are reached at all, since the encoder never selects packing there.The check has teeth, proven by breaking the subtlest part rather than by deleting the check. With the high-byte carry removed, so that only values spanning past 64 bits go wrong:
A coarser break would have failed at width 1 and told me nothing about the case the guard exists for.
Second commit: the bounds test had to come out of the loop
The first version was 1.7% slower at width 1 — a per-value "does the nine-byte window fit" test cost more than the entire per-bit loop it replaced, when that loop ran one iteration. How many values the wide load can serve is arithmetic on
nbytesandwidth, so it is computed once and the loop splits into a fast body with no bounds test and a per-bit tail. That removed the regression and kept the width-10 win.The tail is deliberate: the fast path runs only where its nine-byte window lies inside the encoded body. An over-read would be at most seven bytes and would usually land in the same allocation — the kind of defect that passes every test and then fails under a sanitizer.
Gate
Five majors, all green, at this head:
encode_invariants=PASSandnative_encoding=PASSon all five, and neither is in any skip list — a green major and "the suite that checks this ran" are different claims.Refs #501