Added PQ INT8 quantizer in Turbo#554
Conversation
…efactor/turbo_preprocessor
…rocessor # Conflicts: # .gitignore
…o refactor/turbo_pq
There was a problem hiding this comment.
Pull request overview
Note
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
This PR introduces Product Quantization (PQ) int8 support and an FHT-based rotator in Turbo, including ISA-dispatched kernels (scalar/SSE/AVX2/AVX512/NEON) and expanded test coverage for correctness and SIMD consistency.
Changes:
- Added
PqInt8Quantizerwith training, encoding/decoding, ADC/SDC distance kernels, and (de)serialization. - Added
FhtRotatorpreprocessor with ISA-dispatched rotate/unrotate kernels and serialization. - Updated Turbo dispatch (
get_pq_kernels,get_rotator_kernels), build flags for SIMD sources, and added NEON runtime feature detection.
Reviewed changes
Copilot reviewed 47 out of 53 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
src/turbo/quantizer/pq_int8_quantizer/pq_int8_quantizer.{h,cc} |
Implements PQ int8 quantizer, training, distances, and serialization. |
src/turbo/turbo.cc / src/include/zvec/turbo/turbo.h |
Adds kernel dispatch APIs/types for PQ and rotator; refactors arch matching. |
src/turbo/preprocessor/preprocessor.h / src/turbo/preprocessor/fht_rotator/* |
Introduces rotator interface and FHT rotator implementation with (de)serialization. |
src/turbo/distance/**/pq_distance* / src/turbo/distance/**/rotate/fht/* |
Adds scalar + SIMD kernels for PQ ADC/SDC and FHT rotation. |
src/ailego/internal/cpu_features.{h,cc} |
Adds NEON runtime detection to support ISA dispatch on ARM. |
src/turbo/CMakeLists.txt |
Applies per-file SIMD compile flags for SSE/AVX2/AVX512 sources. |
tests/turbo/* |
Adds extensive unit tests for PQ quantizer and FHT rotator / SIMD consistency. |
| Misc test/style files | Formatting tweaks and minor cleanups. |
Comments suppressed due to low confidence (11)
src/turbo/quantizer/pq_int8_quantizer/pq_int8_quantizer.cc:1
- For
Cosine, the implementation returns squared L2 distance between unit-normalized vectors (range ~[0,4]), not cosine distance1 - cos(range [0,2]). The comment says no conversion is needed, but a conversion factor is required if the public metric semantics are cosine distance. A common conversion for unit vectors iscosine_distance = 0.5f * l2_sq(since||a-b||^2 = 2 - 2cos). This impactscalc_distance_dp_query(_batch),distance()(which currently returnsadc_fn_directly), and any SDC path built fromdist_table_whenmetric_name == \"Cosine\". Suggest wrapping ADC/Batch ADC (and SDC if exposed) in lambdas for Cosine to apply the conversion, or explicitly change the quantizer’s metric exposure to squared-L2-on-normalized-vectors so callers/tests aren’t misled.
src/turbo/quantizer/pq_int8_quantizer/pq_int8_quantizer.cc:1 - For
Cosine, the implementation returns squared L2 distance between unit-normalized vectors (range ~[0,4]), not cosine distance1 - cos(range [0,2]). The comment says no conversion is needed, but a conversion factor is required if the public metric semantics are cosine distance. A common conversion for unit vectors iscosine_distance = 0.5f * l2_sq(since||a-b||^2 = 2 - 2cos). This impactscalc_distance_dp_query(_batch),distance()(which currently returnsadc_fn_directly), and any SDC path built fromdist_table_whenmetric_name == \"Cosine\". Suggest wrapping ADC/Batch ADC (and SDC if exposed) in lambdas for Cosine to apply the conversion, or explicitly change the quantizer’s metric exposure to squared-L2-on-normalized-vectors so callers/tests aren’t misled.
src/turbo/quantizer/pq_int8_quantizer/pq_int8_quantizer.cc:1 - Two concrete correctness issues around deserialization/SDC:\n1)
calc_distance_dp_dp()always callssdc_fn_withdist_table_.data(), butdist_table_is never rebuilt afterdeserialize(). That means SDC can read from an empty buffer (UB) or return garbage post-deserialization. If SDC is part of the supported API surface, either recomputedist_table_duringdeserialize()(or lazily on first SDC use) or make SDC explicitly unavailable after deserialize (and return an error / invalid DistanceImpl).\n2)deserialize()only checksmagic, but does not validatehdr.version,hdr.quant_type,hdr.payload_sizevslen, nor does it restore the metric configuration fromhdr.metric. On a fresh factory-created quantizer (withoutinit()),meta_.metric_name()may be unset, which will make Cosine/IP behaviors andextra_meta_size_inconsistent. Suggest: validate header fields, bounds-check payload reads usinghdr.payload_size, and restoremeta_(including metric) from header before selectingfp32_batch_fn_/extra_meta_size_.
src/turbo/quantizer/pq_int8_quantizer/pq_int8_quantizer.cc:1 - Two concrete correctness issues around deserialization/SDC:\n1)
calc_distance_dp_dp()always callssdc_fn_withdist_table_.data(), butdist_table_is never rebuilt afterdeserialize(). That means SDC can read from an empty buffer (UB) or return garbage post-deserialization. If SDC is part of the supported API surface, either recomputedist_table_duringdeserialize()(or lazily on first SDC use) or make SDC explicitly unavailable after deserialize (and return an error / invalid DistanceImpl).\n2)deserialize()only checksmagic, but does not validatehdr.version,hdr.quant_type,hdr.payload_sizevslen, nor does it restore the metric configuration fromhdr.metric. On a fresh factory-created quantizer (withoutinit()),meta_.metric_name()may be unset, which will make Cosine/IP behaviors andextra_meta_size_inconsistent. Suggest: validate header fields, bounds-check payload reads usinghdr.payload_size, and restoremeta_(including metric) from header before selectingfp32_batch_fn_/extra_meta_size_.
src/turbo/quantizer/pq_int8_quantizer/pq_int8_quantizer.cc:1 thread_count_is documented in the header as0 = hardware_concurrency, buttrain()passes it through directly and constructsSingleQueueIndexThreadswiththread_count == 0. IfSingleQueueIndexThreadstreats 0 as invalid (common), training will either run with zero workers (no tasks executed → untrained centroids), or fail/assert internally. Recommend normalizingthread_countat the start oftrain(holder, thread_count)(e.g., map<=0toIndexThreads::hardware_concurrency()or1) and using that normalized value consistently for logging and pool sizing.
src/turbo/quantizer/pq_int8_quantizer/pq_int8_quantizer.cc:1thread_count_is documented in the header as0 = hardware_concurrency, buttrain()passes it through directly and constructsSingleQueueIndexThreadswiththread_count == 0. IfSingleQueueIndexThreadstreats 0 as invalid (common), training will either run with zero workers (no tasks executed → untrained centroids), or fail/assert internally. Recommend normalizingthread_countat the start oftrain(holder, thread_count)(e.g., map<=0toIndexThreads::hardware_concurrency()or1) and using that normalized value consistently for logging and pool sizing.
src/turbo/quantizer/pq_int8_quantizer/pq_int8_quantizer.cc:1- There are two high-cost patterns in hot paths:\n- Training subsampling swaps vectors element-by-element (
O(dim)swaps inside the shuffle loop). This is significantly slower than swapping contiguous blocks (e.g.,std::swap_rangesover[i*dim, (i+1)*dim)or a temporary buffer/memcpy swap).\n-quantize_data()allocatesstd::vector<float>buffers (norm_vec_storage,centered_vec_storage) per call. Encoding is typically a throughput-critical path; repeated heap allocations can dominate runtime. Consider using a reusable scratch buffer strategy (e.g., thread-local buffers, small static buffers whendimis bounded, or passing an external scratch workspace) to avoid per-vector allocations.
src/turbo/quantizer/pq_int8_quantizer/pq_int8_quantizer.cc:1 - There are two high-cost patterns in hot paths:\n- Training subsampling swaps vectors element-by-element (
O(dim)swaps inside the shuffle loop). This is significantly slower than swapping contiguous blocks (e.g.,std::swap_rangesover[i*dim, (i+1)*dim)or a temporary buffer/memcpy swap).\n-quantize_data()allocatesstd::vector<float>buffers (norm_vec_storage,centered_vec_storage) per call. Encoding is typically a throughput-critical path; repeated heap allocations can dominate runtime. Consider using a reusable scratch buffer strategy (e.g., thread-local buffers, small static buffers whendimis bounded, or passing an external scratch workspace) to avoid per-vector allocations.
src/turbo/quantizer/pq_int8_quantizer/pq_int8_quantizer.cc:1 - There are two high-cost patterns in hot paths:\n- Training subsampling swaps vectors element-by-element (
O(dim)swaps inside the shuffle loop). This is significantly slower than swapping contiguous blocks (e.g.,std::swap_rangesover[i*dim, (i+1)*dim)or a temporary buffer/memcpy swap).\n-quantize_data()allocatesstd::vector<float>buffers (norm_vec_storage,centered_vec_storage) per call. Encoding is typically a throughput-critical path; repeated heap allocations can dominate runtime. Consider using a reusable scratch buffer strategy (e.g., thread-local buffers, small static buffers whendimis bounded, or passing an external scratch workspace) to avoid per-vector allocations.
src/turbo/quantizer/pq_int8_quantizer/pq_int8_quantizer.cc:1 - There are two high-cost patterns in hot paths:\n- Training subsampling swaps vectors element-by-element (
O(dim)swaps inside the shuffle loop). This is significantly slower than swapping contiguous blocks (e.g.,std::swap_rangesover[i*dim, (i+1)*dim)or a temporary buffer/memcpy swap).\n-quantize_data()allocatesstd::vector<float>buffers (norm_vec_storage,centered_vec_storage) per call. Encoding is typically a throughput-critical path; repeated heap allocations can dominate runtime. Consider using a reusable scratch buffer strategy (e.g., thread-local buffers, small static buffers whendimis bounded, or passing an external scratch workspace) to avoid per-vector allocations.
src/turbo/quantizer/distance.h:1 DistanceImplappears to be a generic wrapper used beyond PQ. Updating the comment to state it is specifically "the ADC path for PQ" is misleading for other quantizers/metrics that may usefunc()differently. Suggest rewording to describe the generic contract (distance between candidate and stored query representation), and mention PQ/ADC only as an example.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
6a04997 to
bedd5c3
Compare
The basic PQ has been implemented, but its QPS requires further optimization. The recall test results for the current implementation are as follows:
HNSW
Configuration:
M: 50efConstruction: 500efSearch: 400topk: 50L2 Metric
643216480240120The results are generally consistent with previous tests. Under the int8 condition, using one subquantizer per 4 dimensions yields the best performance.
Cosine Metric
Option 1: Normalization + IP Metric
Option 2: Normalization + L2 Metric
192+ Option 2384+ Option 2The performance of both options is nearly identical. Option 2 was ultimately adopted because the L2 metric also supports operations specific to L2, such as
decentralization.Inner Product Metric
Option 1: L2 for k-means + L2 encoding + IP for SDC calculation + IP for ADC calculation
Option 2: L2 for k-means + L2 encoding + L2 for SDC calculation + IP for ADC calculation |<---- faiss approach
Option 3: L2 for k-means + IP encoding + IP for SDC calculation + IP for ADC calculation
Option 4: IP for k-means(sp) + IP encoding + IP for SDC calculation + IP for ADC calculation |<---- ivf porting approach
192+ Option 1384+ Option 1192+ Option 2384+ Option 2Option 1 performs slightly better and is more semantically coherent: L2 is used for encoding, while IP is used for distance calculation.
Decentralization
3232+ decentralization192192+ decentralization384384+ decentralizationDecentralization does not yield significant improvements, but support for it is retained nonetheless.
IVF
Configuration:
centroid_count: 128nprobe: 16topk: 50ScaleFactor: 4Option 1: Build codebooks using residuals, with each cluster assigned an independent codebook.
Option 2: Build codebooks using residuals, with all clusters sharing a single codebook. |<--- faiss approach
L2 Metric
int8+32+ Option 1int8+32+ Option 1int8+240+ Option 1int8+240+ Option 2Cosine Metric
int8+192+ Option 1int8+192+ Option 2int8+384+ Option 1int8+384+ Option 2Inner Product Metric
int8+192+ Option 1int8+192+ Option 2The original IVFPQ+IP implementation uses MIPS, which results in lower recall.
Although Option 2 shows a slight decrease in Recall compared to Option 1, it significantly reduces training time and storage requirements while maintaining comparable Refiner Recall. Therefore, Option 2 was ultimately adopted.