Skip to content

Commit 09174bc

Browse files
authored
feat: support wider hamming hashes (#7767)
## Summary - Support `FixedSizeList<UInt8, N>` hamming hashes where `N` is a positive multiple of 8 bytes. - Reuse the SIMD `u64` pairwise path lane-by-lane for wider hashes. - Add Rust and Python coverage for 16-byte and wider hash clustering, including invalid-width rejection. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Expanded Hamming-distance clustering to support binary hashes of any positive, 8-byte-aligned width (`FixedSizeList<UInt8, N>`), including 16-byte hashes. * Added sequential and parallel pairwise Hamming-distance APIs for multi-lane binary hashes, with support for extracting fixed-list hash values. * **Bug Fixes** * Improved validation and error handling for malformed or inconsistent fixed-list hash inputs and byte widths. * **Tests** * Broadened and parameterized clustering and distance test coverage for 8- and 16-byte hashes, multi-segment behavior, and sequential-vs-parallel consistency. * **Documentation** * Updated API docs and parameter descriptions to reflect the generalized hash column shape. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 5bda9bd commit 09174bc

6 files changed

Lines changed: 584 additions & 69 deletions

File tree

python/python/lance/vector.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -856,7 +856,8 @@ def hamming_clustering_for_sample(
856856
dataset : LanceDataset
857857
The Lance dataset containing the hash column.
858858
column : str
859-
Name of the hash column (must be FixedSizeList<UInt8, 8>)
859+
Name of the hash column (must be FixedSizeList<UInt8, N> where N is a
860+
positive multiple of 8 bytes)
860861
sample_size : int, optional
861862
Number of rows to sample. If None, uses all rows.
862863
hamming_threshold : int, default 10
@@ -898,7 +899,8 @@ def hamming_clustering_for_range(
898899
dataset : LanceDataset
899900
The Lance dataset containing the hash column.
900901
column : str
901-
Name of the hash column (must be FixedSizeList<UInt8, 8>)
902+
Name of the hash column (must be FixedSizeList<UInt8, N> where N is a
903+
positive multiple of 8 bytes)
902904
fragment_id : int
903905
The fragment ID to read from
904906
start_row : int

python/python/tests/test_vector.py

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -155,21 +155,26 @@ def test_binary_vectors_invalid_metric(tmp_path):
155155

156156

157157
def _hash_table(hashes):
158-
"""Build a table with a ``hash`` column of FixedSizeList<UInt8, 8>.
158+
"""Build a table with a ``hash`` column of FixedSizeList<UInt8, N>.
159159
160-
``hashes`` is a list of 8-byte sequences, one per row.
160+
``hashes`` is a list of byte sequences, one per row. The byte width must
161+
be a positive multiple of 8.
161162
"""
163+
byte_width = len(hashes[0])
164+
assert byte_width > 0 and byte_width % 8 == 0
165+
assert all(len(row) == byte_width for row in hashes)
162166
flat = [byte for row in hashes for byte in row]
163167
values = pa.FixedSizeListArray.from_arrays(
164-
pa.array(flat, type=pa.uint8()), list_size=8
168+
pa.array(flat, type=pa.uint8()), list_size=byte_width
165169
)
166170
return pa.Table.from_arrays([values], names=["hash"])
167171

168172

169-
def test_hamming_clustering_for_sample(tmp_path):
170-
hash_a = [0, 0, 0, 0, 0, 0, 0, 0]
171-
hash_b = [255, 0, 0, 0, 0, 0, 0, 0] # 8 bits from hash_a
172-
hash_c = [1, 2, 3, 4, 5, 6, 7, 8] # far from both
173+
@pytest.mark.parametrize("byte_width", [8, 16])
174+
def test_hamming_clustering_for_sample(tmp_path, byte_width):
175+
hash_a = [0] * byte_width
176+
hash_b = [0] * (byte_width - 8) + [255] + [0] * 7 # 8 bits from hash_a
177+
hash_c = list(range(1, byte_width + 1)) # far from both
173178
# Rows 0,1,2 share hash_a; rows 3,4 share hash_b; row 5 is unique.
174179
table = _hash_table([hash_a, hash_a, hash_a, hash_b, hash_b, hash_c])
175180
dataset = lance.write_dataset(table, tmp_path / "hashes")
@@ -189,11 +194,28 @@ def test_hamming_clustering_for_sample(tmp_path):
189194
assert clusters == {0: [1, 2], 3: [4]}
190195

191196

192-
def test_hamming_clustering_multi_segment(tmp_path):
197+
@pytest.mark.parametrize("byte_width", [8, 16])
198+
def test_hamming_clustering_multi_segment(tmp_path, byte_width):
199+
mask = (1 << 64) - 1
200+
201+
def hash_bytes(value):
202+
if byte_width == 8:
203+
lanes = [(value * 0x9E3779B97F4A7C15) & mask]
204+
else:
205+
# Adjacent logical values share the first 64-bit lane and differ in
206+
# later lanes, so threshold-0 clustering must compare every lane.
207+
lanes = [
208+
((value // 2) * 0x9E3779B97F4A7C15) & mask,
209+
((value * 0xD6E8FEB86659FD93) ^ 0xA5A5A5A5A5A5A5A5) & mask,
210+
]
211+
return [
212+
byte for lane_value in lanes for byte in lane_value.to_bytes(8, "little")
213+
]
214+
193215
# 25 distinct hash values, two copies each; the same table is written to
194216
# fragment 0 and appended as fragment 1.
195-
values = [((i // 2) * 0x9E3779B97F4A7C15) & 0xFFFFFFFFFFFFFFFF for i in range(50)]
196-
table = _hash_table([list(value.to_bytes(8, "little")) for value in values])
217+
values = [i // 2 for i in range(50)]
218+
table = _hash_table([hash_bytes(value) for value in values])
197219
dataset = lance.write_dataset(table, tmp_path / "hashes")
198220
dataset.create_index(
199221
"hash", index_type="IVF_FLAT", num_partitions=4, metric="hamming"

python/src/dataset.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3768,7 +3768,8 @@ impl Dataset {
37683768
/// Parameters
37693769
/// ----------
37703770
/// column : str
3771-
/// Name of the hash column (must be FixedSizeList<UInt8, 8>)
3771+
/// Name of the hash column (must be FixedSizeList<UInt8, N> where N is
3772+
/// a positive multiple of 8 bytes)
37723773
/// sample_size : int, optional
37733774
/// Number of rows to sample (if None or >= total rows, uses all rows)
37743775
/// hamming_threshold : int
@@ -3811,7 +3812,8 @@ impl Dataset {
38113812
/// Parameters
38123813
/// ----------
38133814
/// column : str
3814-
/// Name of the hash column (must be FixedSizeList<UInt8, 8>)
3815+
/// Name of the hash column (must be FixedSizeList<UInt8, N> where N is
3816+
/// a positive multiple of 8 bytes)
38153817
/// fragment_id : int
38163818
/// The fragment ID to read from
38173819
/// start_row : int

rust/lance-linalg/src/distance.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,11 @@ pub mod norm_l2;
2828
pub use cosine::*;
2929
pub use dot::*;
3030
pub use hamming::{
31-
Cluster, ClusteringResult, PairwiseResult, UnionFind, cluster_edges, cluster_pairwise_result,
32-
extract_hashes_from_fixed_list, hamming_distance_arrow_batch, hamming_u64,
33-
pairwise_hamming_distance, pairwise_hamming_distance_parallel,
31+
BinaryHashValues, Cluster, ClusteringResult, PairwiseResult, UnionFind, cluster_edges,
32+
cluster_pairwise_result, extract_binary_hashes_from_fixed_list, extract_hashes_from_fixed_list,
33+
hamming_distance_arrow_batch, hamming_u64, pairwise_hamming_distance,
34+
pairwise_hamming_distance_binary, pairwise_hamming_distance_binary_parallel,
35+
pairwise_hamming_distance_parallel,
3436
};
3537
pub use l2::*;
3638
use lance_core::deepsize::DeepSizeOf;

0 commit comments

Comments
 (0)