Batch the Gorilla bit writer: -13% more, now faster than TimescaleDB on load (#155) - #286
Conversation
Profiling a text-heavy bulk load shows the load is CPU-bound on encoding, and the single largest cost (~22%) is ColumnarFsstBuildChunkTable -- the FSST symbol-table build. For a low-cardinality column the dictionary compresses far below the threshold at which FSST is even attempted per vector, so the table is built and then never used: the commandprompt#276 cost-margin gates keep/drop but the expensive build runs before that decision, unconditionally per text chunk. Add ColumnarFsstDictWins: a cheap distinct-count over the corpus (bounded open-addressing set, early-exit once the count exceeds DICT_MAX_DISTINCT). At or below the cap the dictionary is viable for every 1024-row vector and wins, so the FSST build is skipped; above it the column is a genuine FSST candidate and the table is built as before. The probe reads the same corpus the keep/drop decision uses and only skips when the dictionary wins for every vector, so stored bytes are identical -- this trades wasted build CPU for nothing on disk. Our own heuristic (FNV-1a bucketing + distinct count); FSST itself is the public scheme (VLDB 2020). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UX1jrWiQsJJA1t4pkmkb4T
encode_dict found each row's dictionary code by scanning every prior distinct value with memcmp -- O(n * distinct) per vector. After the FSST-build skip routed low-cardinality text to the dictionary, that linear search became ~10% of a text load's CPU (the memcmp hotspot in profiling). Replace it with an open-addressing hash of value -> distinct index (our own FNV-1a + probe), making the lookup O(1) average. First-seen assignment order is unchanged, so the dictionary and bit-packed codes are byte-identical; this is a pure speedup. Stacks on the FSST-build skip (commandprompt#155). Our own code; no external source referenced. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UX1jrWiQsJJA1t4pkmkb4T
…ndprompt#155) bitpack() wrote each field one bit per loop iteration -- up to 64 branch-and-OR steps per value. Since every integer path (FOR, delta, delta-of-delta, dictionary codes, FSST offsets) funnels through it, this was the largest remaining self-cost in the bulk-load profile (~19%). Mask each value to `width` bits, shift it into position, and emit the <=9 bytes it spans directly. Each output byte is extracted with an explicit shift, so the on-disk layout stays LSB-first and identical on any host endianness -- the encoded bytes are unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UX1jrWiQsJJA1t4pkmkb4T
…pt#155) encode_gorilla was the last large pgColumnar self-cost in the bulk-load profile (~10%). Two bit-at-a-time loops drove it: - bw_put() appended one bit per iteration. Merge bits into a 64-bit accumulator and flush whole bytes at once. Same LSB-first stream, so the encoded bytes are unchanged. - clz_in()/ctz_in() scanned for the leading/trailing zero bit one step at a time. Use pg_leftmost/rightmost_one_pos64() (portable hardware bit-scan) instead. Same counts. No change to the on-disk format; output is byte-identical. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UX1jrWiQsJJA1t4pkmkb4T
|
Reviewed all four as a stack (identical SHAs, #286 contains everything). Local Splitting the review, because three of these are a different kind of change from #284, #285, #286: rewrites that preserve output by constructionThese re-express the same algorithm and emit the same bytes, so the risk is
One nit, in the spirit of the thread we just had about comments outclaiming code: #283 is the one that changes a decision, and its claim is an argumentThe other three cannot change stored bytes. This one can, and the PR says it does
The chain is: distinct <= The gate that actually decides whether FSST is attempted is if (w == -1 && fsstTable != NULL && bestLen > rawLen - rawLen / 4)so FSST is reached only when the best encoding so far saved less than 25%. I could not demonstrate it. I swept distinct counts 200 to 3000 against short What would settle it is the missing step in the argument, or a test. Either: Two smaller notes on the same function:
What none of the four hasNo test pins any of this, and byte-identity is the crux of the whole stack. It is PerformanceI have not independently reproduced the numbers, and I am not treating them as Correctness and portability I am satisfied with. #283's byte-identity claim is the |
|
Merged via #290, which carried your four commits unchanged plus the tests. GitHub Landing them together with the test was deliberate. #283 is the only one of the Two things carried forward, neither blocking, both left for you:
One finding from the matrix worth more than this PR: Performance is still unmeasured from my side. That is next. |
The refresh in #320 rebuilt the open list from issue STATE. The follow-up commit on this branch fixed the #155 entry but repeated the same mistake on the entry it wrote to replace it. An audit of every entry against its issue thread, its pull requests and main found that all four were wrong, in three different ways. #289 was a copy of the issue body and gave no sign that work is in flight. The decompression half already merged (#307, 3.8 percent on q4 and 3.2 on q5) and the aggregation half is open as #321. The "about 4x behind TimescaleDB" line reads as the size of the prize for that work, but #321 measures 1.20x and 1.38x, and by its own account the larger lever is dictionary-coded grouping. The widest gap, q6 at 5.3x behind and 3.1x slower than heap, is the only shape where columnar loses to heap and nothing in flight touches it. #300 was framed as core COPY's per-field parse. #300's own profile refuted that before the entry was written: parse is about 21 percent, encode about 53 percent, so bypassing the parser cannot make columnar beat heap. The measured top lever is parallelism over the existing encoder with COPY unchanged, prototyped at 7.39x. IMPORT_THROUGHPUT_PLAN.md was cited as the reference and is the wrong pointer: it predates the #283 to #286 work and puts COPY under "Not in scope". reltuples is removed. It was fixed on 2026-07-28 by #189 and is now exact on every measured shape, and the cause the entry gave was explicitly disproven: it was a block-offset mismatch, not blocks holding no row-group data. The line was written about nine hours before the fix and survived two refreshes. #310 is no longer listed as work. Both causes are merged and it was re-measured at 100M, 273,212 buffers to 8,917. It stays open for a confirmation reading on the real dataset. #291 was open and absent from the list; added, with the note that its documentation half landed in #298. Also fixed, all verified: the "Deferred, not yet built" paragraph listed two things that have been on main since 2026-07-23; a cross-reference to "item 0" that #320's renumbering left dangling; six Done rows naming the extension schema as columnar rather than pgcolumnar, which a reader copying them would find does not exist; and a closed-since line with the wrong date and three omissions. Refs #289, #300, #291, #310. No issue is closed by this commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011miCFRSatixeNRw3w5yNq8
The refresh in #320 rebuilt the open list from issue STATE and not from issue CONTENT. #155 was still open, so its original text was carried forward verbatim, including the "about 4.9x slower than heap" figure. That number was the problem statement from before the work, not the result: four encoder levers (#283 to #286, merged via #290) had already taken the 100M-row load from 783 s to 383 s and the gap to heap from about 4.9x to about 2.2x, with a byte-identical on-disk image. So the roadmap named finished work as the next thing to take, and quoted a measured figure that had been superseded. #155 itself was left open only because the resolution comment said "closing as resolved" without closing it; it is closed now, after verifying the four commits are on main. The entry becomes #300, which is the real remaining work: core COPY's per-field parse, paid identically by heap and TimescaleDB, and not reachable by another encoder change. Refs #155, #300. No issue is closed by this commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011miCFRSatixeNRw3w5yNq8
The refresh in #320 rebuilt the open list from issue STATE. The follow-up commit on this branch fixed the #155 entry but repeated the same mistake on the entry it wrote to replace it. An audit of every entry against its issue thread, its pull requests and main found that all four were wrong, in three different ways. #289 was a copy of the issue body and gave no sign that work is in flight. The decompression half already merged (#307, 3.8 percent on q4 and 3.2 on q5) and the aggregation half is open as #321. The "about 4x behind TimescaleDB" line reads as the size of the prize for that work, but #321 measures 1.20x and 1.38x, and by its own account the larger lever is dictionary-coded grouping. The widest gap, q6 at 5.3x behind and 3.1x slower than heap, is the only shape where columnar loses to heap and nothing in flight touches it. #300 was framed as core COPY's per-field parse. #300's own profile refuted that before the entry was written: parse is about 21 percent, encode about 53 percent, so bypassing the parser cannot make columnar beat heap. The measured top lever is parallelism over the existing encoder with COPY unchanged, prototyped at 7.39x. IMPORT_THROUGHPUT_PLAN.md was cited as the reference and is the wrong pointer: it predates the #283 to #286 work and puts COPY under "Not in scope". reltuples is removed. It was fixed on 2026-07-28 by #189 and is now exact on every measured shape, and the cause the entry gave was explicitly disproven: it was a block-offset mismatch, not blocks holding no row-group data. The line was written about nine hours before the fix and survived two refreshes. #310 is no longer listed as work. Both causes are merged and it was re-measured at 100M, 273,212 buffers to 8,917. It stays open for a confirmation reading on the real dataset. #291 was open and absent from the list; added, with the note that its documentation half landed in #298. Also fixed, all verified: the "Deferred, not yet built" paragraph listed two things that have been on main since 2026-07-23; a cross-reference to "item 0" that #320's renumbering left dangling; six Done rows naming the extension schema as columnar rather than pgcolumnar, which a reader copying them would find does not exist; and a closed-since line with the wrong date and three omissions. Refs #289, #300, #291, #310. No issue is closed by this commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011miCFRSatixeNRw3w5yNq8
Batch the Gorilla bit writer and use a hardware bit-scan (#155)
Fourth and final lever on the #155 bulk-load cost. After #283/#284/#285 the profile's last large pgColumnar self-cost was
encode_gorillaat ~10% (float4/float8 XOR encoding). Two bit-at-a-time loops drove it — the same pattern the other levers removed elsewhere:bw_put()appended the field one bit per iteration. Now it merges bits into a 64-bit accumulator and flushes whole bytes at once (≤ 7 carried + ≤ 57 taken per chunk keeps it within 64 bits). Same LSB-first stream.clz_in()/ctz_in()scanned for the leading/trailing set bit one step at a time. Now they callpg_leftmost_one_pos64()/pg_rightmost_one_pos64()(PostgreSQL's portable hardware bit-scan,port/pg_bitutils.h). Same counts.Byte-identical
The encoded stream is unchanged by construction —
bw_putwrites the identical LSB-first bit sequence, just merged into bytes; the zero-counts are identical. Only the writer and its helpers changed; the reader (decode_gorilla/br_get) is untouched. Corroborated by an identical on-disk image over 100M rows and green roundtrip suites (a changed writer + unchanged reader passing roundtrip proves the new bytes decode identically).Cleanroom
Standard bit-accumulator buffering + hardware bit-scan; implemented from the idea. The Gorilla scheme itself is Facebook's (Pelkonen et al., Gorilla, VLDB 2015) and was already present — this PR only speeds up the existing writer.
Measured (PG18, 100M-row TSBS-cpu; shared_buffers=1GB, work_mem=32MB)
TimescaleDB compressed columnstore loads the same data in ~414 s — so pgColumnar is now at ~0.93× TS: faster than TimescaleDB on bulk load, from ~1.9× (twice as slow) at baseline. On-disk size is byte-identical across all five builds.
Correctness gate
gate_subset.shon PostgreSQL 18.4 and 19beta2 (assert builds) — all suites green:Review order
Stacked on #285. Full chain: #283 → #284 → #285 → this. The diff here is Gorilla-only; please review or squash-merge them in sequence.
🤖 Generated with Claude Code