bitunpack extracts values one bit at a time. It is the largest single frame in a CPU profile of both scan shapes, and a word-at-a-time rewrite is byte-identical and dramatically cheaper in isolation.
Found by profiling with bench/run_profile.sh (#499).
The measurement
Self time, cpu-clock with DWARF unwinding, 8M rows, PG 18.4, serial:
| shape |
bitunpack |
PgColumnarDecodeChunk |
| filtered aggregate over a skippable range |
21.0% |
4.2% |
| row-returning projection |
19.2% |
7.4% |
The decode machinery around it is 4 to 7%. The time is in the inner unpacking loop.
Why
src/columnar_encoding.c:168. The inner loop runs once per bit, and each iteration does a byte load, two shifts, a mask, a test and an or:
for (i = 0; i < n; i++)
{
uint64 v = 0;
int b;
COLUMNAR_DECODE_INTERRUPT(i);
for (b = 0; b < width; b++)
{
if ((in[(bitpos + b) >> 3] >> ((bitpos + b) & 7)) & 1)
v |= (uint64) 1 << b;
}
out[i] = v;
bitpos += width;
}
So cost scales with n * width. A 32-bit-wide column pays 32 dependent operations per value to reconstruct something a single shifted load could produce.
The alternative, measured
One unaligned 64-bit load, one shift, one mask, per value:
uint64 w;
memcpy(&w, in + (bitpos >> 3), 8);
out[i] = (w >> (bitpos & 7)) & mask;
1,000,000 values per run, mean of five, gcc -O2. Every value compared against the current implementation on random input before timing, because a faster wrong answer is not a result:
| width |
current |
word-at-a-time |
speedup |
identical |
| 1 |
3.7 ms |
0.6 ms |
6.2x |
yes |
| 7 |
26.2 ms |
0.5 ms |
49.6x |
yes |
| 8 |
31.3 ms |
0.6 ms |
48.7x |
yes |
| 13 |
51.2 ms |
0.6 ms |
90.2x |
yes |
| 17 |
66.2 ms |
0.6 ms |
106.2x |
yes |
| 32 |
120.5 ms |
0.7 ms |
177.1x |
yes |
| 57 |
210.0 ms |
0.6 ms |
335.3x |
yes |
| 64 |
235.7 ms |
236.1 ms |
1.00x |
yes |
The current implementation scales linearly with width, as the loop shape predicts. The replacement is flat, because the work no longer depends on width.
What this is worth at query level, which is much less
The microbenchmark speedups are not query speedups, and it would be easy to read them that way. bitunpack is about 20% of these queries, so Amdahl caps the whole thing:
bitunpack speedup |
overall query |
| 10x |
1.22x |
| 50x |
1.24x |
| 335x |
1.25x |
The ceiling is 1.25x even if bitunpack became free, and essentially all of it is reached by 10x. That is worth having on the hottest read path, and it is not the 300x the table above might suggest. It also means an elaborate SIMD kernel buys nothing over the simple version: the curve is flat past 10x.
Three things that need care
Endianness. The packing is LSB-first, so memcpy into a uint64 and shifting is correct only on a little-endian host. A big-endian build needs a byteswap after the load. This is the one that would produce silently wrong data rather than a crash, on a platform nobody here tests.
Bounds. A 64-bit load at bitpos >> 3 touches up to 8 bytes, so any value whose start byte is within 8 of the encoded body must keep using the existing per-bit path. The version measured above does exactly that, which is also why it is byte-identical: the tail is literally the old code.
width > 57. With a bit offset of up to 7, a single 8-byte load covers at most 57 bits. Wider values need a second load, which I did not implement; width 64 falls through to the safe path, hence the 1.00x row. Whether wide columns are common enough to justify the second word is a question for whoever takes this.
COLUMNAR_DECODE_INTERRUPT(i) must survive the rewrite. It is per-value and the stride makes it nearly free, but a decode of one vector is otherwise uninterruptible for chunk_group_row_limit values, which is user-settable and unbounded.
Not proposing to take this
Filing it with the numbers rather than a patch, since the endianness question is a portability decision rather than a coding one. Happy to implement if you want it, including the big-endian path.
bitunpackextracts values one bit at a time. It is the largest single frame in a CPU profile of both scan shapes, and a word-at-a-time rewrite is byte-identical and dramatically cheaper in isolation.Found by profiling with
bench/run_profile.sh(#499).The measurement
Self time,
cpu-clockwith DWARF unwinding, 8M rows, PG 18.4, serial:bitunpackPgColumnarDecodeChunkThe decode machinery around it is 4 to 7%. The time is in the inner unpacking loop.
Why
src/columnar_encoding.c:168. The inner loop runs once per bit, and each iteration does a byte load, two shifts, a mask, a test and an or:So cost scales with
n * width. A 32-bit-wide column pays 32 dependent operations per value to reconstruct something a single shifted load could produce.The alternative, measured
One unaligned 64-bit load, one shift, one mask, per value:
1,000,000 values per run, mean of five,
gcc -O2. Every value compared against the current implementation on random input before timing, because a faster wrong answer is not a result:The current implementation scales linearly with width, as the loop shape predicts. The replacement is flat, because the work no longer depends on width.
What this is worth at query level, which is much less
The microbenchmark speedups are not query speedups, and it would be easy to read them that way.
bitunpackis about 20% of these queries, so Amdahl caps the whole thing:bitunpackspeedupThe ceiling is 1.25x even if
bitunpackbecame free, and essentially all of it is reached by 10x. That is worth having on the hottest read path, and it is not the 300x the table above might suggest. It also means an elaborate SIMD kernel buys nothing over the simple version: the curve is flat past 10x.Three things that need care
Endianness. The packing is LSB-first, so
memcpyinto auint64and shifting is correct only on a little-endian host. A big-endian build needs a byteswap after the load. This is the one that would produce silently wrong data rather than a crash, on a platform nobody here tests.Bounds. A 64-bit load at
bitpos >> 3touches up to 8 bytes, so any value whose start byte is within 8 of the encoded body must keep using the existing per-bit path. The version measured above does exactly that, which is also why it is byte-identical: the tail is literally the old code.width > 57. With a bit offset of up to 7, a single 8-byte load covers at most 57 bits. Wider values need a second load, which I did not implement; width 64 falls through to the safe path, hence the 1.00x row. Whether wide columns are common enough to justify the second word is a question for whoever takes this.COLUMNAR_DECODE_INTERRUPT(i)must survive the rewrite. It is per-value and the stride makes it nearly free, but a decode of one vector is otherwise uninterruptible forchunk_group_row_limitvalues, which is user-settable and unbounded.Not proposing to take this
Filing it with the numbers rather than a patch, since the endianness question is a portability decision rather than a coding one. Happy to implement if you want it, including the big-endian path.