Skip to content

Commit 8f88464

Browse files
committed
Add query blocking to IndexFlatPanorama search
Process queries in blocks so each DB level-storage block is streamed from memory once and reused across the block while resident in a faster cache level, raising cache-bandwidth efficiency on the bandwidth-bound Panorama dot kernel. The DB-batch loop stays outside the block (preserving each query's threshold evolution) and within a batch the loop runs level-outer / query-inner. Controlled by the global panorama_query_block_size, following the existing FAISS tunable idiom (cf. distance_compute_blas_query_bs). It defaults to an enabled value of 32; 0 or 1 selects the original query-at-a-time path. The value is a maximum: the effective block size shrinks to ceil(n / max_threads) when queries are scarce, so the thread count always matches the unblocked path (min(n, max_threads)) and blocking degrades gracefully to the original schedule instead of trading threads for full blocks. The filter kernel is non-allocating; all per-thread scratch is allocated once by the caller. Pure loop-order transform: each query keeps its own active set, threshold, and pruning decisions, so results are bit-identical to the original path (verified 432/432 equality cases + unchanged SIFT1M recall, and across metrics {L2, IP} x threads {1, 4, 32, 240} x block sizes {2, 7, 32, 64}). Top-k only; range search falls back to the original path since RangeSearchPartialResult requires per-query-contiguous appends. SIFT1M speedups vs the unblocked path: ~1.8-2.1x single-thread at any batch size, 1.5-2.5x multithreaded once each thread holds a full block, up to ~3.3x at nq=1000 with 16-32 threads, and no regression (1.02-1.07x) in the small-batch corner where nq is close to the thread count and the effective block size collapses by design. Also extend bench_flat_l2_panorama so the speedup is measurable directly: --query-block-size, --nq, and --threads accept comma-separated sweeps (the indexes are built once; only nq, the thread count, and the block-size global change between timed runs), and --repeat times each configuration N times reporting the fastest, needed at small nq where single runs are noisy. The summary prints per-(nq, threads) speedups vs plain Flat and vs the Panorama baseline. E.g.: python benchs/bench_flat_l2_panorama.py --dataset sift1m \ --nq 8,16,32,64 --threads 8,16,32 --query-block-size 0,32 \ --repeat 20 Signed-off-by: Mulugeta Mammo <mulugeta.mammo@intel.com>
1 parent 7437cac commit 8f88464

5 files changed

Lines changed: 478 additions & 45 deletions

File tree

benchs/bench_flat_l2_panorama.py

Lines changed: 115 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -18,32 +18,65 @@
1818

1919
parser = argparse.ArgumentParser()
2020
parser.add_argument("--dataset", default="gist1m", choices=["sift1m", "gist1m"])
21+
parser.add_argument(
22+
"--nq",
23+
default="1000",
24+
help="comma-separated query counts to sweep "
25+
"(query blocking needs a real batch to help)",
26+
)
27+
parser.add_argument(
28+
"--threads",
29+
default="1",
30+
help="comma-separated search thread counts to sweep "
31+
"(index build always uses all cores)",
32+
)
33+
parser.add_argument(
34+
"--query-block-size",
35+
default="0",
36+
help="comma-separated block sizes to sweep for the Panorama index; "
37+
"0 or 1 selects the original query-at-a-time path; the first value is "
38+
"the reference for the vs-Pano speedup in the summary",
39+
)
40+
parser.add_argument(
41+
"--repeat",
42+
type=int,
43+
default=1,
44+
help="timed repetitions per configuration, reporting the fastest; "
45+
"raise this for small --nq where a single run is noisy",
46+
)
2147
args = parser.parse_args()
2248

49+
query_block_sizes = [int(v) for v in args.query_block_size.split(",")]
50+
nq_values = [int(v) for v in args.nq.split(",")]
51+
thread_values = [int(v) for v in args.threads.split(",")]
52+
2353
if args.dataset == "sift1m":
2454
ds = DatasetSIFT1M()
2555
else:
2656
ds = DatasetGIST1M()
2757

28-
nq = 10
29-
xq = ds.get_queries()[:nq]
58+
max_nq = max(nq_values)
59+
xq_all = ds.get_queries()[:max_nq]
3060
xb = ds.get_database()
31-
gt = ds.get_groundtruth()[:nq]
61+
gt_all = ds.get_groundtruth()[:max_nq]
3262

3363
xt = ds.get_train()
3464

3565
nb, d = xb.shape
3666
nt, d = xt.shape
3767

3868
k = 10
39-
gt = gt[:, :k]
69+
gt_all = gt_all[:, :k]
4070

4171

42-
def eval_qps(index):
72+
def eval_qps(index, xq, gt):
73+
nq = len(xq)
4374
faiss.cvar.indexPanorama_stats.reset()
44-
t0 = time.time()
45-
_, I = index.search(xq, k=k)
46-
t = time.time() - t0
75+
t = np.inf
76+
for _ in range(args.repeat):
77+
t0 = time.time()
78+
_, I = index.search(xq, k=k)
79+
t = min(t, time.time() - t0)
4780
speed = t * 1000 / nq # ms/query
4881
qps = 1000 / speed
4982

@@ -63,46 +96,83 @@ def build_index(name):
6396
faiss.omp_set_num_threads(mp.cpu_count())
6497
index.train(xt)
6598
index.add(xb)
66-
67-
faiss.omp_set_num_threads(1)
6899
return index
69100

70101

71102
nlevels = 16 if args.dataset == "gist1m" else 8
72103
batch_size = 512
73104

74-
plt.figure(figsize=(8, 6), dpi=80)
75-
76-
names = [
77-
"Flat",
78-
f"PCA{d},FlatL2Panorama{nlevels}_{batch_size}",
79-
]
80-
81-
labels = []
82-
qps_values = []
83-
84-
for name in names:
85-
print(f"======{name}")
86-
index = build_index(name)
87-
recall, qps = eval_qps(index)
88-
labels.append(f"{name}\n(r@{recall:.3f})")
89-
qps_values.append(qps)
90-
91-
x = np.arange(len(names))
92-
plt.bar(x, qps_values, color=["#1f77b4", "#ff7f0e"])
93-
speedup = qps_values[1] / qps_values[0]
94-
ax = plt.gca()
95-
ax.text(
96-
x[1],
97-
qps_values[1] * 1.01,
98-
f"{speedup:.2f}x",
99-
ha="center",
100-
va="bottom",
101-
)
102-
plt.xticks(x, labels, rotation=0)
103-
plt.ylabel("QPS")
104-
dataset_label = args.dataset.upper()
105-
plt.title(f"Flat Indexes on {dataset_label}")
106-
107-
plt.tight_layout()
108-
plt.savefig(f"bench_flat_l2_panorama_{args.dataset}.png", bbox_inches="tight")
105+
pano_name = f"PCA{d},FlatL2Panorama{nlevels}_{batch_size}"
106+
107+
# Both indexes are built once; only nq, the thread count, and the global
108+
# query-block toggle change between runs, so results must match.
109+
print("======building Flat")
110+
flat_index = build_index("Flat")
111+
print(f"======building {pano_name}")
112+
pano_index = build_index(pano_name)
113+
114+
115+
def qbs_tag(qbs):
116+
return "baseline" if qbs <= 1 else f"block={qbs}"
117+
118+
119+
rows = [] # one entry per (nq, threads): flat QPS + per-block-size results
120+
for nq in nq_values:
121+
xq = xq_all[:nq]
122+
gt = gt_all[:nq]
123+
for nthr in thread_values:
124+
faiss.omp_set_num_threads(nthr)
125+
print(f"====== nq={nq} threads={nthr}")
126+
print("---Flat")
127+
flat_recall, flat_qps = eval_qps(flat_index, xq, gt)
128+
per_qbs = []
129+
for qbs in query_block_sizes:
130+
faiss.cvar.panorama_query_block_size = qbs
131+
print(f"---Pano {qbs_tag(qbs)}")
132+
recall, qps = eval_qps(pano_index, xq, gt)
133+
per_qbs.append((qbs, recall, qps))
134+
faiss.cvar.panorama_query_block_size = 0
135+
rows.append((nq, nthr, flat_recall, flat_qps, per_qbs))
136+
137+
# Report speedups: Panorama-vs-Flat, and each block size vs the first swept
138+
# block size at the same nq/threads.
139+
ref_tag = qbs_tag(query_block_sizes[0])
140+
print("\n=== summary ===")
141+
for nq, nthr, _, flat_qps, per_qbs in rows:
142+
ref_qps = per_qbs[0][2]
143+
print(f"nq={nq} threads={nthr}: Flat {flat_qps:.1f} QPS")
144+
for qbs, recall, qps in per_qbs:
145+
vs_flat = qps / flat_qps
146+
vs_ref = qps / ref_qps
147+
print(
148+
f" Pano {qbs_tag(qbs):>10}: {qps:10.1f} QPS "
149+
f"({vs_flat:.2f}x vs Flat, {vs_ref:.2f}x vs Pano {ref_tag})"
150+
)
151+
152+
# The bar chart only makes sense for a single nq/threads combination; sweeps
153+
# rely on the summary table above.
154+
if len(rows) == 1:
155+
nq, nthr, flat_recall, flat_qps, per_qbs = rows[0]
156+
labels = [f"Flat\n(r@{flat_recall:.3f})"]
157+
qps_values = [flat_qps]
158+
for qbs, recall, qps in per_qbs:
159+
labels.append(f"Pano\n{qbs_tag(qbs)}\n(r@{recall:.3f})")
160+
qps_values.append(qps)
161+
162+
plt.figure(figsize=(8, 6), dpi=80)
163+
x = np.arange(len(qps_values))
164+
colors = ["#1f77b4"] + ["#ff7f0e"] * len(per_qbs)
165+
plt.bar(x, qps_values, color=colors)
166+
ax = plt.gca()
167+
# Annotate each Panorama bar with its speedup over plain Flat.
168+
for xi, qps in zip(x[1:], qps_values[1:]):
169+
ax.text(
170+
xi, qps * 1.01, f"{qps / flat_qps:.2f}x", ha="center", va="bottom"
171+
)
172+
plt.xticks(x, labels, rotation=0)
173+
plt.ylabel("QPS")
174+
dataset_label = args.dataset.upper()
175+
plt.title(f"Flat Indexes on {dataset_label}")
176+
177+
plt.tight_layout()
178+
plt.savefig(f"bench_flat_l2_panorama_{args.dataset}.png", bbox_inches="tight")

faiss/IndexFlat.cpp

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323

2424
namespace faiss {
2525

26+
size_t panorama_query_block_size = 32;
27+
2628
IndexFlat::IndexFlat(idx_t d_, MetricType metric)
2729
: IndexFlatCodes(sizeof(float) * d_, d_, metric) {}
2830

@@ -571,6 +573,130 @@ inline auto dispatch_metric_compare(MetricType metric, Fn&& fn) {
571573
return fn.template operator()<C>();
572574
}
573575

576+
/// Query-blocked search core. For each block of up to `qbs` queries, iterates
577+
/// DB batches in order (preserving each query's threshold evolution), and
578+
/// within each batch iterates level-outer / query-inner so the level storage
579+
/// block is streamed from memory once and reused across the block while
580+
/// resident in a faster cache level. Produces results identical to
581+
/// flat_pano_search_core.
582+
template <bool use_radius, typename C, typename BlockHandler>
583+
inline void flat_pano_search_core_blocked(
584+
const IndexFlatPanorama& index,
585+
BlockHandler& handler,
586+
idx_t n,
587+
const float* x,
588+
float radius,
589+
const SearchParameters* params,
590+
size_t qbs) {
591+
using SingleResultHandler = typename BlockHandler::SingleResultHandler;
592+
593+
IDSelector* sel = params ? params->sel : nullptr;
594+
bool use_sel = sel != nullptr;
595+
596+
const size_t d = index.d;
597+
const size_t bs = index.batch_size;
598+
const size_t n_lp1 = index.pano.n_levels + 1;
599+
size_t n_batches = (index.ntotal + bs - 1) / bs;
600+
601+
// qbs is a maximum: when queries are scarce, shrink the effective block
602+
// size so the thread count matches the unblocked path
603+
// (min(n, max_threads)) rather than dropping to one thread per full
604+
// block.
605+
int max_nt = omp_get_max_threads();
606+
size_t qbs_eff = std::min(qbs, (size_t(n) + max_nt - 1) / max_nt);
607+
size_t n_blocks = (size_t(n) + qbs_eff - 1) / qbs_eff;
608+
// Cap threads at n_blocks: every spawned thread allocates the scratch
609+
// buffers below, even if it gets no loop iterations.
610+
[[maybe_unused]] int nt = std::min(int(n_blocks), max_nt);
611+
612+
#pragma omp parallel num_threads(nt)
613+
{
614+
// One persistent SingleResultHandler per query slot so each heap and
615+
// threshold survive across DB batches (begin() re-heapifies, so it must
616+
// be called exactly once per query, not once per batch).
617+
std::vector<SingleResultHandler> ress;
618+
ress.reserve(qbs_eff);
619+
for (size_t qb = 0; qb < qbs_eff; qb++) {
620+
ress.emplace_back(handler);
621+
}
622+
623+
std::vector<float> query_cum_norms(qbs_eff * n_lp1);
624+
std::vector<uint32_t> active_indices(qbs_eff * bs);
625+
std::vector<uint8_t> active_byteset(qbs_eff * bs);
626+
std::vector<uint8_t> first_level_full(qbs_eff);
627+
std::vector<float> exact_distances(qbs_eff * bs);
628+
std::vector<float> dot_buffer(bs);
629+
std::vector<float> thresholds(qbs_eff);
630+
std::vector<size_t> num_active(qbs_eff);
631+
632+
#pragma omp for
633+
for (int64_t blk = 0; blk < int64_t(n_blocks); blk++) {
634+
size_t q0 = size_t(blk) * qbs_eff;
635+
size_t block_size = std::min(qbs_eff, size_t(n) - q0);
636+
637+
PanoramaStats local_stats;
638+
local_stats.reset();
639+
640+
// Per-query: compute cum sums and open the result heap (once).
641+
for (size_t qb = 0; qb < block_size; qb++) {
642+
const float* xi = x + (q0 + qb) * d;
643+
index.pano.compute_query_cum_sums(
644+
xi, query_cum_norms.data() + qb * n_lp1);
645+
ress[qb].begin(q0 + qb);
646+
}
647+
648+
for (size_t batch_no = 0; batch_no < n_batches; batch_no++) {
649+
size_t batch_start = batch_no * bs;
650+
651+
// Snapshot each query's current threshold for this batch.
652+
for (size_t qb = 0; qb < block_size; qb++) {
653+
if constexpr (use_radius) {
654+
thresholds[qb] = radius;
655+
} else {
656+
thresholds[qb] = ress[qb].threshold;
657+
}
658+
}
659+
660+
with_metric_type(index.metric_type, [&]<MetricType M>() {
661+
index.pano.progressive_filter_block<C, M>(
662+
index.codes.data(),
663+
index.cum_sums.data(),
664+
x + q0 * d,
665+
query_cum_norms.data(),
666+
block_size,
667+
batch_no,
668+
index.ntotal,
669+
sel,
670+
nullptr,
671+
use_sel,
672+
active_indices.data(),
673+
active_byteset.data(),
674+
first_level_full.data(),
675+
exact_distances.data(),
676+
dot_buffer.data(),
677+
thresholds.data(),
678+
num_active.data(),
679+
local_stats);
680+
});
681+
682+
// Push survivors into each query's heap (updates threshold).
683+
for (size_t qb = 0; qb < block_size; qb++) {
684+
const uint32_t* ai = active_indices.data() + qb * bs;
685+
const float* ed = exact_distances.data() + qb * bs;
686+
for (size_t j = 0; j < num_active[qb]; j++) {
687+
ress[qb].add_result(ed[ai[j]], batch_start + ai[j]);
688+
}
689+
}
690+
}
691+
692+
for (size_t qb = 0; qb < block_size; qb++) {
693+
ress[qb].end();
694+
}
695+
indexPanorama_stats.add(local_stats);
696+
}
697+
}
698+
}
699+
574700
template <bool use_radius, typename C, typename BlockHandler>
575701
inline void flat_pano_search_core(
576702
const IndexFlatPanorama& index,
@@ -579,6 +705,17 @@ inline void flat_pano_search_core(
579705
const float* x,
580706
float radius,
581707
const SearchParameters* params) {
708+
// Query blocking is implemented for top-k search only. Range search uses a
709+
// RangeSearchPartialResult that finalizes once per handler and requires
710+
// per-query-contiguous appends, which is incompatible with the interleaved
711+
// block schedule; it falls through to the original path.
712+
size_t qbs = panorama_query_block_size;
713+
if (qbs > 1 && !use_radius) {
714+
flat_pano_search_core_blocked<use_radius, C>(
715+
index, handler, n, x, radius, params, qbs);
716+
return;
717+
}
718+
582719
using SingleResultHandler = typename BlockHandler::SingleResultHandler;
583720

584721
IDSelector* sel = params ? params->sel : nullptr;

faiss/IndexFlat.h

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@
1717

1818
namespace faiss {
1919

20+
/// Query-block size for IndexFlatPanorama search (default 32). When > 1,
21+
/// queries are processed in blocks so each DB level-block is loaded from memory
22+
/// once and reused across the block (raising cache-bandwidth efficiency). 0 or
23+
/// 1 selects the original query-at-a-time path. Results are identical for any
24+
/// value; this only trades off cache behavior. Set before searching; changing
25+
/// it concurrently with in-flight searches is not thread-safe.
26+
FAISS_API extern size_t panorama_query_block_size;
27+
2028
/** Index that stores the full vectors and performs exhaustive search */
2129
struct IndexFlat : IndexFlatCodes {
2230
explicit IndexFlat(

0 commit comments

Comments
 (0)