Core: Interleave Z-order bits a byte at a time with a lookup table - #17774
Core: Interleave Z-order bits a byte at a time with a lookup table#17774GGraziadei wants to merge 1 commit into
Conversation
interleaveBits moves one bit per loop iteration, and each iteration searches for the next source column, so the cost is 8 * interleavedSize iterations of a loop the hardware cannot pipeline. With four Z-ordered columns and an 8 byte contribution each, that is 256 iterations per row. When every column contributes the same number of bytes, which is what all callers produce, the interleaving is a fixed permutation of the source bits that depends only on the source byte and the column count. Tabulate it: SPREAD[n][b] holds the bits of byte b spread n positions apart, so OR-ing the spread bytes of n columns, each shifted right by its column index, yields the n output bytes for that source offset. Columns of differing lengths, which no caller produces but the method accepts, keep using the existing bit at a time loop. The output is unchanged for every input. The tables cost 16680 bytes of heap once per JVM, and only the 2 KB row for the column count in use is read. Nothing is allocated per row, by either implementation. ZOrderByteUtilsBenchmark also gained warmup iterations, without which it was timing largely uncompiled code. JMH, 10M rows per op, JDK 17, i7-13700H: fourColumns 7.379 ± 0.563 -> 0.787 ± 0.277 s/op fourColumns8ByteOutput 1.828 ± 0.459 -> 0.259 ± 0.038 s/op threeColumns 5.601 ± 0.915 -> 0.685 ± 0.373 s/op twoColumns 3.668 ± 0.530 -> 0.456 ± 0.033 s/op gc.alloc.rate.norm is unchanged to the byte (5848, 5824, 5840, 5832 B/op, all of it the benchmark's own buffer and JMH infrastructure) and gc.count is 0 in every case, before and after. Closes apache#17758
Numerical example with 3 columnsA worked example of the table-based interleaving, with every intermediate value. 1. Input dataThree 1-byte columns, one bit set each, so it is easy to follow where each bit lands:
2. What Z-order must produce (conceptual definition)Interleaving takes one bit at a time, rotating across the columns, from the MSB down to the LSB: Substituting the values (A has only bit7, B only bit6, C only bit5): Grouping into 8-bit bytes: Expected result: 3. The SPREAD tableEvery group of With
SPREAD positions the bits as if every column were column 0 (highest slot of each triple). 4. Combining the columns:
|
| Column | SPREAD | shift | Contribution |
|---|---|---|---|
| 0 (A) | 0x800000 |
>>> 0 |
0x800000 |
| 1 (B) | 0x100000 |
>>> 1 |
0x080000 |
| 2 (C) | 0x020000 |
>>> 2 |
0x008000 |
0x800000 = 1000 0000 0000 0000 0000 0000
| 0x080000 = 0000 1000 0000 0000 0000 0000
| 0x008000 = 0000 0000 1000 0000 0000 0000
-----------------------------------------------
0x888000 = 1000 1000 1000 0000 0000 0000
group = 0x888000. A (col 0) stays at bit 23, B (col 1) drops to bit 19, C (col 2) drops to bit 15 — exactly A B C within their triples, non-overlapping.
5. Extracting the output bytes
group sits in the low 3 bytes of a long. The loop writes MSB-byte first (groupByte from N-1=2 down to 0):
| groupByte | group >>> (8*groupByte) |
byte |
|---|---|---|
| 2 | 0x888000 >>> 16 = 0x88 |
0x88 |
| 1 | 0x888000 >>> 8 → (byte) 0x80 |
0x80 |
| 0 | 0x888000 >>> 0 → (byte) 0x00 |
0x00 |
Output: [0x88, 0x80, 0x00] ✓ — identical to the bit-by-bit result in step 2.
6. Why it is faster
- The old method did
8 × interleavedSizeiterations (192 for 3 output bytes), moving one bit at a time, with a next-column search at every step that the CPU cannot pipeline. - The table version processes one source byte at a time:
Nlookups +NORs per byte offset, then writesNbytes. No per-row allocation; only theSPREAD[N]row in use (~2 KB) is touched.
|
Thank you @GGraziadei! |
|
Hi @RussellSpitzer, happy to receive your review whenever you have bandwidth. The previous interleaving method is the fallback with this PR. |
interleaveBits moves one bit per loop iteration, and each iteration searches for the next source column, so the cost is 8 * interleavedSize iterations of a loop the hardware cannot pipeline. With four Z-ordered columns and an 8 byte contribution each, that is 256 iterations per row.
When every column contributes the same number of bytes, which is what all callers produce, the interleaving is a fixed permutation of the source bits that depends only on the source byte and the column count. Tabulate it: SPREAD[n][b] holds the bits of byte b spread n positions apart, so OR-ing the spread bytes of n columns, each shifted right by its column index, yields the n output bytes for that source offset.
Columns of differing lengths, which no caller produces but the method accepts, keep using the existing bit at a time loop. The output is unchanged for every input.
The tables cost 16680 bytes of heap once per JVM, and only the 2 KB row for the column count in use is read. Nothing is allocated per row, by either implementation.
ZOrderByteUtilsBenchmark also gained warmup iterations, without which it was timing largely uncompiled code.
JMH, 10M rows per op, JDK 17, i7-13700H:
fourColumns 7.379 ± 0.563 -> 0.787 ± 0.277 s/op
fourColumns8ByteOutput 1.828 ± 0.459 -> 0.259 ± 0.038 s/op
threeColumns 5.601 ± 0.915 -> 0.685 ± 0.373 s/op
twoColumns 3.668 ± 0.530 -> 0.456 ± 0.033 s/op
gc.alloc.rate.norm is unchanged to the byte (5848, 5824, 5840, 5832 B/op, all of it the benchmark's own buffer and JMH infrastructure) and gc.count is 0 in every case, before and after.
Closes #17758
Numerical example with 3 columns: #17774 (comment)