Skip to content

Commit 8510ae0

Browse files
authored
[common] Widen hilbert index bytes beyond 8 dimensions (#9777)
1 parent d3716d1 commit 8510ae0

2 files changed

Lines changed: 116 additions & 1 deletion

File tree

‎paimon-common/src/main/java/org/apache/paimon/sort/hilbert/HilbertIndexer.java‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,14 @@ public static byte[] hilbertCurvePosBytes(Long[] points) {
317317
long[] data = Arrays.stream(points).mapToLong(Long::longValue).toArray();
318318
HilbertCurve hilbertCurve = HilbertCurve.bits(BITS_NUM).dimensions(points.length);
319319
BigInteger index = hilbertCurve.index(data);
320-
return ConvertBinaryUtil.paddingToNByte(index.toByteArray(), BITS_NUM);
320+
// an N-dimensional 63-bit index needs up to 63*N/8 + 1 bytes: the extra byte covers
321+
// BigInteger's sign byte when the top bit is set. paddingToNByte drops trailing bytes
322+
// when the array is longer than the width, so a narrower width silently discards
323+
// low-order bits — at 8 dimensions that also inverts the order, because the sign byte
324+
// makes an upper-half index sort below a smaller lower-half one. Hilbert keys are
325+
// transient sort keys in every consumer, so widening them breaks nothing.
326+
int paddingBytes = BITS_NUM * data.length / 8 + 1;
327+
return ConvertBinaryUtil.paddingToNByte(index.toByteArray(), paddingBytes);
321328
}
322329

323330
/** Process function interface. */

‎paimon-common/src/test/java/org/apache/paimon/sort/hilbert/HilbertIndexerTest.java‎

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,13 @@
2323
import org.apache.paimon.types.DataTypes;
2424
import org.apache.paimon.types.RowType;
2525

26+
import org.davidmoten.hilbert.HilbertCurve;
2627
import org.junit.jupiter.api.Test;
2728

29+
import java.math.BigInteger;
30+
import java.util.ArrayList;
2831
import java.util.Arrays;
32+
import java.util.List;
2933

3034
import static org.assertj.core.api.Assertions.assertThat;
3135

@@ -60,6 +64,110 @@ public void testBooleanValuesDistinctFromNull() {
6064
assertThat(falseIndex).isNotEqualTo(trueIndex);
6165
}
6266

67+
@Test
68+
public void testHighDimensionIndexKeepsAllBits() {
69+
// 9 dimensions: the 63*9-bit index needs 71 bytes; distinct points that differ
70+
// only in the low-order bits must stay distinct instead of being truncated away
71+
Long[][] points = {
72+
{0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L},
73+
{0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 1L},
74+
};
75+
byte[] first = HilbertIndexer.hilbertCurvePosBytes(points[0]);
76+
byte[] second = HilbertIndexer.hilbertCurvePosBytes(points[1]);
77+
assertThat(first).hasSize(71);
78+
assertThat(second).hasSize(71);
79+
assertThat(first).isNotEqualTo(second);
80+
81+
// 16 dimensions: the top bit being set adds BigInteger's sign byte, so the width
82+
// must cover it or the low byte is truncated away
83+
Long[] highBits =
84+
new Long[] {
85+
Long.MAX_VALUE, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L
86+
};
87+
Long[] highBitsVariant = highBits.clone();
88+
highBitsVariant[15] = 1L;
89+
byte[] highFirst = HilbertIndexer.hilbertCurvePosBytes(highBits);
90+
byte[] highSecond = HilbertIndexer.hilbertCurvePosBytes(highBitsVariant);
91+
assertThat(highFirst).hasSize(127);
92+
assertThat(highSecond).hasSize(127);
93+
assertThat(highFirst).isNotEqualTo(highSecond);
94+
95+
// the width is 63*N/8 + 1 for every N, so a 2-dimension key is 16 bytes and an
96+
// 8-dimension one is 64 — the extra byte over the 63-byte magnitude is what makes
97+
// room for BigInteger's sign byte
98+
assertThat(HilbertIndexer.hilbertCurvePosBytes(new Long[] {0L, 0L})).hasSize(16);
99+
assertThat(HilbertIndexer.hilbertCurvePosBytes(new Long[] {0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L}))
100+
.hasSize(64);
101+
}
102+
103+
/**
104+
* At 8 dimensions the index fills 63 bytes, so the top half of the space carries BigInteger's
105+
* sign byte and spills to 64. Truncating that back to 63 does not merely lose resolution: the
106+
* leading zero makes a large index sort below a smaller one.
107+
*/
108+
@Test
109+
public void testEightDimensionKeysOrderLikeTheirIndex() {
110+
List<Long[]> points = new ArrayList<>();
111+
for (long i = 0; i < 24; i++) {
112+
// spread the points over the whole space so some land in the top half
113+
long v = Long.MAX_VALUE / 23 * i;
114+
points.add(new Long[] {v, v / 3, i, Long.MAX_VALUE - v, v / 7, i * 31, v / 11, i});
115+
}
116+
117+
for (Long[] left : points) {
118+
for (Long[] right : points) {
119+
int indexOrder = index(left).compareTo(index(right));
120+
int keyOrder =
121+
compareUnsigned(
122+
HilbertIndexer.hilbertCurvePosBytes(left),
123+
HilbertIndexer.hilbertCurvePosBytes(right));
124+
assertThat(Integer.signum(keyOrder))
125+
.as(
126+
"key order must follow index order for %s vs %s",
127+
Arrays.toString(left), Arrays.toString(right))
128+
.isEqualTo(Integer.signum(indexOrder));
129+
}
130+
}
131+
}
132+
133+
/**
134+
* Sizes and inequalities are proxies for the property the width exists to guarantee: the key
135+
* carries the whole index. State it directly, at the two dimension counts where the index needs
136+
* its last byte the most.
137+
*/
138+
@Test
139+
public void testKeyCarriesTheWholeIndex() {
140+
for (int dimensions : new int[] {8, 9}) {
141+
Long[] topOfSpace = new Long[dimensions];
142+
Arrays.fill(topOfSpace, Long.MAX_VALUE);
143+
Long[] oneLowBitOff = topOfSpace.clone();
144+
oneLowBitOff[dimensions - 1] = Long.MAX_VALUE - 1;
145+
146+
for (Long[] point : new Long[][] {topOfSpace, oneLowBitOff}) {
147+
byte[] key = HilbertIndexer.hilbertCurvePosBytes(point);
148+
assertThat(new BigInteger(1, key))
149+
.as("key must round-trip the index at %s dimensions", dimensions)
150+
.isEqualTo(index(point));
151+
}
152+
}
153+
}
154+
155+
private static BigInteger index(Long[] points) {
156+
long[] data = Arrays.stream(points).mapToLong(Long::longValue).toArray();
157+
return HilbertCurve.bits(63).dimensions(points.length).index(data);
158+
}
159+
160+
private static int compareUnsigned(byte[] left, byte[] right) {
161+
assertThat(left).hasSameSizeAs(right);
162+
for (int i = 0; i < left.length; i++) {
163+
int cmp = Integer.compare(left[i] & 0xFF, right[i] & 0xFF);
164+
if (cmp != 0) {
165+
return cmp;
166+
}
167+
}
168+
return 0;
169+
}
170+
63171
private static GenericRow booleanRow(Boolean value) {
64172
GenericRow row = new GenericRow(2);
65173
row.setField(0, value);

0 commit comments

Comments
 (0)