mirror of
https://github.com/facebookresearch/faiss.git
synced 2026-10-12 02:13:36 +00:00
Summary: Pull Request resolved: https://github.com/facebookresearch/faiss/pull/5520 Two changes to `faiss::hammings()`: it accepts ragged code sizes, and the kernel for wide codes is told which axis to vectorize. Throughout, **ncodes** is number of bytes per vector (so, 32 for a 256-bit vector), a **word** is one 64-bit (8-byte) unit. A code size is **word-multiple** when `ncodes % 8 == 0` and **ragged** otherwise, so a code is `nwords = ncodes / 8` whole words followed by `rem = ncodes % 8` trailing bytes. ## 1. "Ragged" code sizes Ragged here doesn't mean variable vector lengths, it means non-divisible by 8 ncodes. `hammings()` throws unless the code size is word-multiple, even though the other Hamming entry points (`hammings_knn_hc`, `hamming_range_search`) have always accepted ragged sizes. Callers who need them have to reach past `hammings()` into `with_simd_level_a0_spr` and `with_HammingComputer` and hand-roll the sweep; Laser does exactly that in `KnnFaissBinaryIndex::measureAllCentroids` (D115840549) and can drop it after this. Ragged sizes go to `hammings_ragged_dispatch()`, behind its own `hammings_ragged_fixSL<SL>` entry point so the word-multiple kernels keep the code they generate today. ## 2. Vectorization axis for wide codes All-pairs Hamming is a doubly nested loop: over candidates, and over the words of one code. Only one of them gets vectorized, and clang picks the candidate loop. That means each vector lane wants word `k` of a different candidate, and since candidates are `ncodes` bytes apart the load becomes a strided gather: ``` across candidates (today) within one code (this diff) vpgatherqq (%r8,%zmm19,1),%zmm20 vpxorq (%rbx,%r12,1),%zmm4,%zmm5 ``` Vectorizing the other loop instead reads one whole code contiguously, which is a plain vector load. `#pragma clang loop vectorize(disable)` on the candidate loop is what expresses that: it does not disable vectorization, it takes the candidate loop out of the running so the word loop is what gets vectorized. The arithmetic is identical either way. It is applied only when `nwords >= 4`, i.e. once a code is at least a full 256-bit register wide. That is the widest threshold that costs nothing anywhere: | threshold | ncodes 8 | ncodes 16 | ncodes 32 | ncodes 64 | | --- | --- | --- | --- | --- | | not applied | +0.0 / -0.1 | +0.0 / -0.6 | +0.1 / -0.2 | -0.1 / +0.2 | | `nwords >= 1` | **+45.3 / +63.5** | -10.4 / **+12.2** | -12.6 / -22.7 | -40.7 / -63.1 | | `nwords >= 2` | +0.1 / +0.0 | -10.5 / **+11.7** | -12.6 / -23.7 | -40.7 / -63.1 | | `nwords >= 4` | +0.2 / +0.1 | -0.1 / +0.1 | -12.7 / -23.7 | -40.7 / -63.1 | (AVX2 / AVX512, percent against the parent commit.) At one word the candidate loop is already contiguous, so taking it out of the running costs 45-63%. At two words it helps AVX2 at ncodes 16 and hurts AVX512 by the same amount, which would make the behaviour SIMD-level dependent. Four words is where both levels agree. The pragma is clang-only and guarded with `#if defined(__clang__)`, since MSVC warns C4068 on an unknown pragma. Other compilers therefore never see it and keep today's behaviour: correct, just without the gain. ## 3. Why the ragged kernel is templated A ragged code's loads must be `memcpy` because it is not 8-byte aligned. Both `nwords` and `rem` are template parameters, which is worth an order of magnitude: | ncodes | `<NW, REM>` | `<NW>`, rem runtime | neither templated | | --- | --- | --- | --- | | 2 | **0.487** | 8.720 | 8.724 | | 4 | **0.433** | 9.419 | 9.164 | | 10 | **0.868** | 9.344 | 10.160 | | 12 | **0.692** | 10.387 | 11.472 | | 20 | **1.066** | 9.879 | 10.730 | | 33 | **2.646** | 10.757 | 10.769 | Fig 1: ragged table comparing templates NW and REM (AVX2, ns per pair.) `rem` is the critical part: at a constant size `memcpy` folds into a single instruction, whereas at a runtime size it becomes a real `memcpy` call per candidate, costing 10-20x. That is why the dispatch switches on `ncodes % 8` rather than passing the remainder as an argument. `NW` is worth a further ~2x, but only for 1 to 3 whole words, where a compile-time trip count lets the word loop unroll to straight-line popcounts: | ncodes | NW templated | NW runtime | | --- | --- | --- | | 2, 4 (0 words) | 0.489 / 0.433 | 0.489 / 0.413 | | 10 (1 word) | **0.873** | 1.730 | | 12 (1 word) | **0.696** | 1.725 | | 20 (2 words) | **1.065** | 2.086 | | 33 (4 words) | 2.644 | 2.589 | Fig 2: Ragged table comparing just NW templating vs not having it templated So `NW` is templated for 0-3 words and left runtime from 4 up, which is where it stops paying. ## Results AMD Genoa (EPYC, Zen 4), `mode/opt`, interleaved A/B against the parent commit, one code size per process, 250 samples per cell, min ns per (query, candidate) pair. Word-multiple sizes: | ncodes | AVX2 | AVX512 | | --- | --- | --- | | 32 | **-12.6%** (1.440 -> 1.258) | **-23.3%** (1.352 -> 1.037) | | 64 | **-40.6%** (3.421 -> 2.031) | **-63.1%** (5.680 -> 2.094) | | 40 | +0.0% | **-4.0%** (2.954 -> 2.837) | | 48 | +0.1% | **-4.7%** (3.530 -> 3.365) | | 56 | +0.0% | **+4.0%** (3.944 -> 4.101) | | 8, 16, 24, 128, 160 | within +/-0.3% | within +/-0.8% | Fig 3: result table comparing against baseline (aka before this diff) ncodes 32 and 64 are the sizes whose codes are exactly one vector register wide, which is where the gather cost the most. Absolute cost of every code size with this diff, so the ragged ones can be read against the word-multiple ones: | ncodes | kind | AVX2 ns/pair | AVX2 ns/byte | AVX512 ns/pair | AVX512 ns/byte | | --- | --- | --- | --- | --- | --- | | 2 | ragged | 0.487 | 0.243 | 0.476 | 0.238 | | 4 | ragged | 0.434 | 0.108 | 0.441 | 0.110 | | 8 | word-multiple | 0.240 | 0.030 | 0.215 | 0.027 | | 10 | ragged | 0.879 | 0.088 | 0.789 | 0.079 | | 12 | ragged | 0.693 | 0.058 | 0.693 | 0.058 | | 16 | word-multiple | 0.713 | 0.045 | 0.573 | 0.036 | | 20 | ragged | 1.065 | 0.053 | 1.127 | 0.056 | | 24 | word-multiple | 2.082 | 0.087 | 2.081 | 0.087 | | 32 | word-multiple | 1.257 | 0.039 | 1.036 | 0.032 | | 33 | ragged | 2.644 | 0.080 | 3.160 | 0.096 | | 40 | word-multiple | 2.650 | 0.066 | 2.837 | 0.071 | | 48 | word-multiple | 3.110 | 0.065 | 3.361 | 0.070 | | 56 | word-multiple | 3.452 | 0.062 | 4.096 | 0.073 | | 64 | word-multiple | 2.039 | 0.032 | 2.102 | 0.033 | | 128 | word-multiple | 6.033 | 0.047 | 5.348 | 0.042 | | 160 | word-multiple | 8.164 | 0.051 | 5.731 | 0.036 | Fig 4: result table showing the ns/pair and ns/byte performance. ragged has no "before" because it is newly supported with this diff. Read down the ns/byte columns and the sizes with their own kernel (8, 16, 32, 64) sit at 0.027-0.045, the new ragged kernel at 0.053-0.096 once past a single word, and the shared runtime-word-count kernel (24, 40, 48, 56, 128, 160) at 0.036-0.087. That last group is the weakest: ncodes 24 costs 2.082 ns/pair against 1.257 for ncodes 32, and ncodes 56 costs 3.452 against 2.039 for ncodes 64, both more expensive than a larger code. It vectorizes across candidates for the same reason described above, but its word count is not known at compile time so the same fix does not apply. Left alone here. The two ragged sizes below one word (2 and 4) are dominated by fixed per-call work rather than the compare itself, which is why their ns/byte is high. Reviewed By: alibeklfc Differential Revision: D115982541 fbshipit-source-id: 081c600973fbdf30852527fb6beaae4e29cadfa3
375 lines
13 KiB
C++
375 lines
13 KiB
C++
/*
|
|
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
*
|
|
* This source code is licensed under the MIT license found in the
|
|
* LICENSE file in the root directory of this source tree.
|
|
*/
|
|
|
|
#include <gtest/gtest.h>
|
|
|
|
#include <faiss/impl/FaissAssert.h>
|
|
#include <faiss/utils/hamming.h>
|
|
#include <random>
|
|
|
|
using namespace ::testing;
|
|
|
|
template <typename T>
|
|
std::string print_data(
|
|
std::shared_ptr<std::vector<T>> data,
|
|
const size_t divider) {
|
|
std::string ret;
|
|
for (size_t i = 0; i < data->size(); ++i) {
|
|
if (i % divider) {
|
|
ret += " ";
|
|
} else {
|
|
ret += "|";
|
|
}
|
|
ret += std::to_string((*data)[i]);
|
|
}
|
|
ret += "|";
|
|
return ret;
|
|
}
|
|
|
|
std::stringstream get_correct_hamming_example(
|
|
const size_t na, // number of queries
|
|
const size_t nb, // number of candidates
|
|
const size_t k,
|
|
const size_t code_size,
|
|
std::shared_ptr<std::vector<uint8_t>> a,
|
|
std::shared_ptr<std::vector<uint8_t>> b,
|
|
std::shared_ptr<std::vector<faiss::idx_t>> true_ids,
|
|
// regular Hamming (bit-level distances)
|
|
std::shared_ptr<std::vector<int>> true_bit_distances,
|
|
// generalized Hamming (byte-level distances)
|
|
std::shared_ptr<std::vector<int>> true_byte_distances) {
|
|
assert(nb >= k);
|
|
|
|
// Initialization
|
|
std::default_random_engine rng(123);
|
|
std::uniform_int_distribution<int32_t> uniform(0, nb - 1);
|
|
|
|
const size_t nresults = na * k;
|
|
|
|
a->clear();
|
|
a->resize(na * code_size, 1); // query vectors are all 1
|
|
b->clear();
|
|
b->resize(nb * code_size, 2); // database vectors are all 2
|
|
true_ids->clear();
|
|
true_ids->reserve(nresults);
|
|
true_bit_distances->clear();
|
|
true_bit_distances->reserve(nresults);
|
|
true_byte_distances->clear();
|
|
true_byte_distances->reserve(nresults);
|
|
|
|
// define correct ids (must be unique)
|
|
std::set<faiss::idx_t> correct_ids;
|
|
do {
|
|
correct_ids.insert(uniform(rng));
|
|
} while (correct_ids.size() < k);
|
|
|
|
// replace database vector at id with vector more similar to query
|
|
// ordered, so earlier ids must be more similar
|
|
for (size_t nmatches = k; nmatches > 0; --nmatches) {
|
|
// get id and erase it
|
|
const size_t id = *correct_ids.begin();
|
|
*correct_ids.erase(correct_ids.begin());
|
|
|
|
// assemble true id and distance at locations
|
|
true_ids->push_back(id);
|
|
true_bit_distances->push_back(
|
|
(code_size > nmatches ? code_size - nmatches : 0) *
|
|
/* per-code distance between 1 and 2 (0b01 and 0b10) */
|
|
2);
|
|
true_byte_distances->push_back(
|
|
(code_size > nmatches ? code_size - nmatches : 0));
|
|
for (size_t i = 0; i < nmatches; ++i) {
|
|
b->begin()[id * code_size + i] = 1; // query byte value
|
|
}
|
|
}
|
|
|
|
// true_ids, true_bit_distances, true_byte_distances only contain results
|
|
// for the first query.
|
|
// Query vectors are identical (all 1s), so copy the first sets of k
|
|
// distances na-1 times.
|
|
for (size_t i = 1; i < na; ++i) {
|
|
true_ids->insert(
|
|
true_ids->end(), true_ids->begin(), true_ids->begin() + k);
|
|
true_bit_distances->insert(
|
|
true_bit_distances->end(),
|
|
true_bit_distances->begin(),
|
|
true_bit_distances->begin() + k);
|
|
true_byte_distances->insert(
|
|
true_byte_distances->end(),
|
|
true_byte_distances->begin(),
|
|
true_byte_distances->begin() + k);
|
|
}
|
|
|
|
// assemble string for debugging
|
|
std::stringstream ret;
|
|
ret << "na: " << na << std::endl
|
|
<< "nb: " << nb << std::endl
|
|
<< "k: " << k << std::endl
|
|
<< "code_size: " << code_size << std::endl
|
|
<< "a: " << print_data(a, code_size) << std::endl
|
|
<< "b: " << print_data(b, code_size) << std::endl
|
|
<< "true_ids: " << print_data(true_ids, k) << std::endl
|
|
<< "true_bit_distances: " << print_data(true_bit_distances, k)
|
|
<< std::endl
|
|
<< "true_byte_distances: " << print_data(true_byte_distances, k)
|
|
<< std::endl;
|
|
return ret;
|
|
}
|
|
|
|
TEST(TestHamming, test_crosshamming_count_thres) {
|
|
// Initialize randomizer
|
|
std::default_random_engine rng(123);
|
|
std::uniform_int_distribution<int32_t> uniform(0, 255);
|
|
|
|
// Initialize inputs
|
|
const size_t n = 10; // number of codes
|
|
const hamdis_t hamming_threshold = 20;
|
|
|
|
// one for each case - 65 is default
|
|
for (auto ncodes : {8, 16, 32, 64, 65}) {
|
|
// initialize inputs
|
|
const int nbits = ncodes * 8;
|
|
const size_t nwords = nbits / 64;
|
|
// 8 to for later conversion to uint64_t, and 2 for buffer
|
|
std::vector<uint8_t> dbs(nwords * n * 8 * 2);
|
|
for (size_t i = 0; i < dbs.size(); ++i) {
|
|
dbs[i] = uniform(rng);
|
|
}
|
|
|
|
// get true distance
|
|
size_t true_count = 0;
|
|
uint64_t* bs1 = (uint64_t*)dbs.data();
|
|
for (size_t i = 0; i < n; ++i) {
|
|
uint64_t* bs2 = bs1 + 2;
|
|
for (size_t j = i + 1; j < n; ++j) {
|
|
if (faiss::hamming(bs1 + i * nwords, bs2 + j * nwords, nwords) <
|
|
hamming_threshold) {
|
|
++true_count;
|
|
}
|
|
}
|
|
}
|
|
|
|
// run test and check correctness
|
|
size_t count;
|
|
if (ncodes == 65) {
|
|
ASSERT_THROW(
|
|
faiss::crosshamming_count_thres(
|
|
dbs.data(), n, hamming_threshold, ncodes, &count),
|
|
faiss::FaissException);
|
|
continue;
|
|
}
|
|
faiss::crosshamming_count_thres(
|
|
dbs.data(), n, hamming_threshold, ncodes, &count);
|
|
|
|
ASSERT_EQ(count, true_count) << "ncodes = " << ncodes;
|
|
}
|
|
}
|
|
TEST(TestHamming, test_hamming_thres) {
|
|
// Initialize randomizer
|
|
std::default_random_engine rng(123);
|
|
std::uniform_int_distribution<int32_t> uniform(0, 255);
|
|
|
|
// Initialize inputs
|
|
const size_t n1 = 10;
|
|
const size_t n2 = 15;
|
|
const hamdis_t hamming_threshold = 100;
|
|
|
|
// one for each case - 65 is default
|
|
for (auto ncodes : {8, 16, 32, 64, 65}) {
|
|
// initialize inputs
|
|
const int nbits = ncodes * 8;
|
|
const size_t nwords = nbits / 64;
|
|
std::vector<uint8_t> bs1(nwords * n1 * 8);
|
|
std::vector<uint8_t> bs2(nwords * n2 * 8);
|
|
for (size_t i = 0; i < bs1.size(); ++i) {
|
|
bs1[i] = uniform(rng);
|
|
}
|
|
for (size_t i = 0; i < bs2.size(); ++i) {
|
|
bs2[i] = uniform(rng);
|
|
}
|
|
|
|
// get true distance
|
|
size_t true_count = 0;
|
|
std::vector<int64_t> true_idx;
|
|
std::vector<hamdis_t> true_dis;
|
|
|
|
uint64_t* bs1_64 = (uint64_t*)bs1.data();
|
|
uint64_t* bs2_64 = (uint64_t*)bs2.data();
|
|
for (size_t i = 0; i < n1; ++i) {
|
|
for (size_t j = 0; j < n2; ++j) {
|
|
hamdis_t ham_dist = faiss::hamming(
|
|
bs1_64 + i * nwords, bs2_64 + j * nwords, nwords);
|
|
if (ham_dist < hamming_threshold) {
|
|
++true_count;
|
|
true_idx.push_back(i);
|
|
true_idx.push_back(j);
|
|
true_dis.push_back(ham_dist);
|
|
}
|
|
}
|
|
}
|
|
|
|
// run test and check correctness for both
|
|
// match_hamming_thres and hamming_count_thres
|
|
std::vector<int64_t> idx(true_idx.size());
|
|
std::vector<hamdis_t> dis(true_dis.size());
|
|
if (ncodes == 65) {
|
|
ASSERT_THROW(
|
|
faiss::match_hamming_thres(
|
|
bs1.data(),
|
|
bs2.data(),
|
|
n1,
|
|
n2,
|
|
hamming_threshold,
|
|
ncodes,
|
|
idx.data(),
|
|
dis.data()),
|
|
faiss::FaissException);
|
|
ASSERT_THROW(
|
|
faiss::hamming_count_thres(
|
|
bs1.data(),
|
|
bs2.data(),
|
|
n1,
|
|
n2,
|
|
hamming_threshold,
|
|
ncodes,
|
|
nullptr),
|
|
faiss::FaissException);
|
|
continue;
|
|
}
|
|
size_t match_count = faiss::match_hamming_thres(
|
|
bs1.data(),
|
|
bs2.data(),
|
|
n1,
|
|
n2,
|
|
hamming_threshold,
|
|
ncodes,
|
|
idx.data(),
|
|
dis.data());
|
|
size_t count_count;
|
|
faiss::hamming_count_thres(
|
|
bs1.data(),
|
|
bs2.data(),
|
|
n1,
|
|
n2,
|
|
hamming_threshold,
|
|
ncodes,
|
|
&count_count);
|
|
|
|
ASSERT_EQ(match_count, true_count) << "ncodes = " << ncodes;
|
|
ASSERT_EQ(count_count, true_count) << "ncodes = " << ncodes;
|
|
ASSERT_EQ(idx, true_idx) << "ncodes = " << ncodes;
|
|
ASSERT_EQ(dis, true_dis) << "ncodes = " << ncodes;
|
|
}
|
|
}
|
|
|
|
TEST(TestHamming, test_hamming_knn) {
|
|
// Initialize randomizer
|
|
std::default_random_engine rng(123);
|
|
std::uniform_int_distribution<int32_t> uniform(0, 32);
|
|
|
|
// Initialize inputs
|
|
const size_t na = 4;
|
|
const size_t nb = 12; // number of candidates
|
|
const size_t k = 6;
|
|
|
|
auto a = std::make_shared<std::vector<uint8_t>>();
|
|
auto b = std::make_shared<std::vector<uint8_t>>();
|
|
auto true_ids = std::make_shared<std::vector<faiss::idx_t>>();
|
|
auto true_bit_distances = std::make_shared<std::vector<int>>();
|
|
auto true_byte_distances = std::make_shared<std::vector<int>>();
|
|
|
|
// 8, 16, 32 are cases - 24 will hit default case
|
|
// all should be multiples of 8
|
|
for (auto code_size : {8, 16, 24, 32}) {
|
|
// get example
|
|
std::stringstream assert_str = get_correct_hamming_example(
|
|
na,
|
|
nb,
|
|
k,
|
|
code_size,
|
|
a,
|
|
b,
|
|
true_ids,
|
|
true_bit_distances,
|
|
true_byte_distances);
|
|
|
|
// run test on generalized_hammings_knn_hc
|
|
std::vector<faiss::idx_t> ids_gen(na * k);
|
|
std::vector<int> dist_gen(na * k);
|
|
faiss::int_maxheap_array_t res = {
|
|
na, k, ids_gen.data(), dist_gen.data()};
|
|
faiss::generalized_hammings_knn_hc(
|
|
&res, a->data(), b->data(), nb, code_size, true);
|
|
ASSERT_EQ(ids_gen, *true_ids) << assert_str.str();
|
|
ASSERT_EQ(dist_gen, *true_byte_distances) << assert_str.str();
|
|
|
|
// run test on hammings_knn
|
|
std::vector<faiss::idx_t> ids_ham_knn(na * k, 0);
|
|
std::vector<int> dist_ham_knn(na * k, 0);
|
|
res = {na, k, ids_ham_knn.data(), dist_ham_knn.data()};
|
|
faiss::hammings_knn(&res, a->data(), b->data(), nb, code_size, true);
|
|
ASSERT_EQ(ids_ham_knn, *true_ids) << assert_str.str();
|
|
ASSERT_EQ(dist_ham_knn, *true_bit_distances) << assert_str.str();
|
|
}
|
|
|
|
for (auto code_size : {8, 16, 24, 32, 64}) {
|
|
std::stringstream assert_str = get_correct_hamming_example(
|
|
na,
|
|
nb,
|
|
/* k */ nb, // faiss::hammings computes all distances
|
|
code_size,
|
|
a,
|
|
b,
|
|
true_ids,
|
|
true_bit_distances,
|
|
true_byte_distances);
|
|
std::vector<hamdis_t> dist_gen(na * nb);
|
|
faiss::hammings(
|
|
a->data(), b->data(), na, nb, code_size, dist_gen.data());
|
|
EXPECT_EQ(dist_gen, *true_bit_distances) << assert_str.str();
|
|
}
|
|
}
|
|
|
|
TEST(TestHamming, test_hammings_ragged_code_size) {
|
|
std::default_random_engine rng(123);
|
|
std::uniform_int_distribution<int32_t> uniform(0, 255);
|
|
|
|
const size_t na = 3;
|
|
const size_t nb = 7;
|
|
|
|
for (auto code_size : {1, 2, 5, 12, 20, 33}) {
|
|
std::vector<uint8_t> a(na * code_size);
|
|
std::vector<uint8_t> b(nb * code_size);
|
|
for (auto& v : a) {
|
|
v = uniform(rng);
|
|
}
|
|
for (auto& v : b) {
|
|
v = uniform(rng);
|
|
}
|
|
|
|
// Reference: byte-wise popcount of the XOR, independent of faiss.
|
|
std::vector<hamdis_t> expected(na * nb);
|
|
for (size_t i = 0; i < na; ++i) {
|
|
for (size_t j = 0; j < nb; ++j) {
|
|
int d = 0;
|
|
for (int c = 0; c < code_size; ++c) {
|
|
uint8_t x = a[i * code_size + c] ^ b[j * code_size + c];
|
|
while (x) {
|
|
d += x & 1;
|
|
x >>= 1;
|
|
}
|
|
}
|
|
expected[i * nb + j] = d;
|
|
}
|
|
}
|
|
|
|
std::vector<hamdis_t> dis(na * nb);
|
|
faiss::hammings(a.data(), b.data(), na, nb, code_size, dis.data());
|
|
EXPECT_EQ(dis, expected) << "code_size = " << code_size;
|
|
}
|
|
}
|