Skip to content

bitunpack extracts one bit at a time; the win scales with bit width, up to ~14% where unpacking dominates #501

Description

@ChronicallyJD

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions