Skip to content

Speed up IndexFlatPanorama search with query blocking - #5447

Open
mulugetam wants to merge 3 commits into
facebookresearch:mainfrom
mulugetam:panorama-fp16-storage
Open

Speed up IndexFlatPanorama search with query blocking#5447
mulugetam wants to merge 3 commits into
facebookresearch:mainfrom
mulugetam:panorama-fp16-storage

Conversation

@mulugetam

@mulugetam mulugetam commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

The Panorama flat-search dot-product kernel is memory-bandwidth bound: every query repeatedly streams the same level-major database layout from memory. This change improves cache reuse by processing queries in blocks (configured via panorama_query_block_size). The search loop is reordered so that the database-batch loop wraps a level-outer/query-inner traversal, allowing each level block to be loaded once and reused across all queries in the block while it remains in cache.

This optimization delivers approximately 2.2× speedup for n_levels=8 and up to 2.8× for n_levels=4.

The change is purely a loop-order transformation. Each query maintains its own active set, pruning threshold, and traversal state, producing bit-identical results to the original query-at-a-time implementation.

Results

SIFT1M, n_levels=8, single core, mean of 3 runs

+---------------+----------+-------+-------------+-----------+--------------+
| Config        | ms/query |   QPS |   baseline | recall@10 | dims scanned |
+---------------+----------+-------+-------------+-----------+--------------+
| baseline (=1) |    6.572 | 152.2 |       1.00x |  0.986800 |       13.57% |
| block=16      |    3.125 | 320.0 |       2.10x |  0.986800 |       13.57% |
| block=32      |    2.946 | 339.4 |       2.23x |  0.986800 |       13.57% |
| block=64      |    2.930 | 341.3 |       2.24x |  0.986800 |       13.57% |
+---------------+----------+-------+-------------+-----------+--------------+

The optimization consistently achieves 2.2–2.3× speedups for n_levels=8 and up to 2.8× for n_levels=4. It also maintains or improves multithreaded performance by reducing shared-cache contention. Recall and the percentage of dimensions scanned remain unchanged.

Implementation Notes

  • Controlled by a global panorama_query_block_size (FAISS_API extern size_t), following the same tuning pattern as distance_compute_blas_query_bs. The default is 32. Setting it to 0 or 1 restores the original implementation. Configure it before issuing searches; changing it while searches are in flight is not thread-safe.
  • The database-batch loop encloses the reordered level/query traversal so that each query's heap and pruning threshold are updated after every batch, preserving threshold evolution and ensuring bit-identical results.
  • A persistent SingleResultHandler is maintained for each query slot, since begin() reinitializes the heap and must be called once per query, not once per batch.
  • Applies only to top-k search. Range search continues to use the original path (qbs > 1 && !use_radius) because RangeSearchPartialResult requires per-query contiguous appends, which are incompatible with the interleaved execution schedule.
  • Memory overhead is minimal, requiring only additional per-thread scratch space with no per-vector or database growth.

Verification

A dedicated test (test_query_blocking_bit_identical in tests/test_flat_panorama.py) verifies bit-identical distances and IDs across a broad configuration sweep and all supported SIMD implementations. End-to-end SIFT1M recall is unchanged.

Reproducing the Results

OPENBLAS_NUM_THREADS=1 taskset -c 2 \
python benchs/bench_flat_l2_panorama.py \
    --dataset sift1m --nq 1000 --query-block-size 0,16,32,64

@meta-cla meta-cla Bot added the CLA Signed label Jul 18, 2026
@mulugetam

Copy link
Copy Markdown
Contributor Author

@mnorris11, could you take a look at this when you get a chance? Thanks!

@mnorris11

Copy link
Copy Markdown
Contributor

@mnorris11, could you take a look at this when you get a chance? Thanks!

We are a bit behind in reviewing things. @AlSchlo @aknayar , how does this look?

@mulugetam

mulugetam commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

Would appreciate your review. @AlSchlo @aknayar

It looks like IndexIVFFlatPanorama could also benefit from a similar optimization, although it's a bit trickier to implement. I'll address it in a follow-up PR (if it gives a performance boost).

@aknayar aknayar left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mnorris11 Did a high-level pass—this definitely seems like a viable optimization. I would make sure it is thoroughly benchmarked for different configurations (e.g., 8-64 queries + 8-32 threads, for which I noticed some regressions locally). Also I'm not sure how we'd extend it for IVFFlatPanorama (given that we can no longer trivially exploit the property of each query accessing the exact same base vectors) but perhaps there's a clean solution I'm missing. @mulugetam Thank you for the PR :)

Comment thread faiss/IndexFlat.cpp Outdated
size_t n_batches = (index.ntotal + bs - 1) / bs;

size_t n_blocks = (size_t(n) + qbs - 1) / qbs;
[[maybe_unused]] int nt = std::min(int(n_blocks), omp_get_max_threads());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't fully understand this. Personally I would try to keep the same number of threads and assign each one the same n/nt slice as before (rounded to qbs queries).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Fixed by treating qbs as a maximum: the effective block size shrinks to ceil(n / max_threads), so the thread count matches the unblocked path and blocking degrades gracefully to the original schedule when queries are scarce.

Comment thread faiss/impl/Panorama.h Outdated
const uint8_t* storage_base = codes_base + batch_offset;

// Per-query init of the active set (identical to the scalar path).
std::vector<uint8_t> first_level_full(block_size);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: This might not change anything but should we hoist this allocation out into the caller?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. first_level_full is now caller-allocated alongside the other per-thread scratch and passed in as a pointer.

@mulugetam
mulugetam force-pushed the panorama-fp16-storage branch from 5d1c5ed to 8f88464 Compare July 23, 2026 18:05
@mulugetam

Copy link
Copy Markdown
Contributor Author

Thanks for the review @aknayar @mnorris11

Below is summary of the new result and the gains I see on my machine.

Raw result: https://gist.github.com/mulugetam/bda9537802ed63f891cbe3b6370deb86#file-pr5447-stdout

The Pano baseline QPS = 0 block size

| nq   | threads | Flat QPS | Pano baseline QPS | Pano block=32 QPS | Pano block=64 QPS |
|-----:|--------:|---------:|------------------:|------------------:|------------------:|
|    8 |       1 |     26.2 |             160.9 |     291.9 (1.81x) |     292.7 (1.82x) |
|    8 |       8 |    181.6 |            1076.8 |    1105.9 (1.03x) |    1109.5 (1.03x) |
|    8 |      16 |    910.6 |            1095.6 |    1113.3 (1.02x) |    1118.4 (1.02x) |
|    8 |      32 |   1462.6 |            1085.8 |    1111.7 (1.02x) |    1115.0 (1.03x) |
|   16 |       1 |     26.8 |             157.8 |     317.2 (2.01x) |     317.2 (2.01x) |
|   16 |       8 |    170.0 |            1042.9 |    1540.1 (1.48x) |    1497.6 (1.44x) |
|   16 |      16 |    348.1 |            1700.0 |    1743.9 (1.03x) |    1735.6 (1.02x) |
|   16 |      32 |   2677.8 |            1466.5 |    1504.6 (1.03x) |    1493.6 (1.02x) |
|   32 |       1 |     27.4 |             168.9 |     337.3 (2.00x) |     337.2 (2.00x) |
|   32 |       8 |    180.6 |            1102.9 |    2065.8 (1.87x) |    1972.8 (1.79x) |
|   32 |      16 |    352.1 |            1756.5 |    2724.4 (1.55x) |    2691.0 (1.53x) |
|   32 |      32 |    508.9 |            2888.6 |    3101.9 (1.07x) |    3089.5 (1.07x) |
|   64 |       1 |     27.7 |             170.0 |     354.6 (2.09x) |     353.7 (2.08x) |
|   64 |       8 |    180.6 |            1140.3 |    2519.8 (2.21x) |    2444.8 (2.14x) |
|   64 |      16 |    367.8 |            1808.0 |    3764.2 (2.08x) |    3671.3 (2.03x) |
|   64 |      32 |    559.7 |            2924.9 |    4819.8 (1.65x) |    4823.5 (1.65x) |
| 1000 |       1 |    476.7 |             173.0 |     336.4 (1.94x) |     351.3 (2.03x) |
| 1000 |       8 |   1955.2 |            1149.5 |    2876.6 (2.50x) |    2973.1 (2.59x) |
| 1000 |      16 |   2265.9 |            1826.3 |    5643.1 (3.09x) |    6058.8 (3.32x) |
| 1000 |      32 |   2481.7 |            2970.7 |    9785.6 (3.29x) |    9432.1 (3.18x) |

And below is the result comparing the existing implementation with this PR for query-block-size = 0.

Raw result: https://gist.github.com/mulugetam/bda9537802ed63f891cbe3b6370deb86#file-current-stdout

| nq   | threads | Pano baseline, main QPS | Pano baseline, branch QPS | branch / main |
|-----:|--------:|------------------------:|--------------------------:|--------------:|
|    8 |       1 |                   156.5 |                     160.9 |         1.03x |
|    8 |       8 |                  1065.9 |                    1076.8 |         1.01x |
|    8 |      16 |                  1107.7 |                    1095.6 |         0.99x |
|    8 |      32 |                  1110.6 |                    1085.8 |         0.98x |
|   16 |       1 |                   156.0 |                     157.8 |         1.01x |
|   16 |       8 |                  1032.3 |                    1042.9 |         1.01x |
|   16 |      16 |                  1706.0 |                    1700.0 |         1.00x |
|   16 |      32 |                  1469.3 |                    1466.5 |         1.00x |
|   32 |       1 |                   161.4 |                     168.9 |         1.05x |
|   32 |       8 |                  1050.2 |                    1102.9 |         1.05x |
|   32 |      16 |                  1771.8 |                    1756.5 |         0.99x |
|   32 |      32 |                  2942.0 |                    2888.6 |         0.98x |
|   64 |       1 |                   165.8 |                     170.0 |         1.03x |
|   64 |       8 |                  1090.8 |                    1140.3 |         1.05x |
|   64 |      16 |                  1829.1 |                    1808.0 |         0.99x |
|   64 |      32 |                  2862.6 |                    2924.9 |         1.02x |
| 1000 |       1 |                   165.0 |                     173.0 |         1.05x |
| 1000 |       8 |                  1089.0 |                    1149.5 |         1.06x |
| 1000 |      16 |                  1793.9 |                    1826.3 |         1.02x |
| 1000 |      32 |                  2930.8 |                    2970.7 |         1.01x |

@aknayar

aknayar commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Thanks @mulugetam. One more thing I would recommend is benching this optimization with a batch_size of 1024. The current default of 128 is incorrect, and we have #5441 open to update it (cc: @mnorris11).

@mulugetam

Copy link
Copy Markdown
Contributor Author

One more thing is I would recommend benching this optimization with a batch_size of 1024

Thanks @aknayar. Below is measured on top of #5441, which changes the default batch size to 1024.

| nq   | threads | Flat QPS | Pano baseline (block = 0) | Pano block=32 QPS | Pano block=64 QPS |
|-----:|--------:|---------:|--------------------------:|------------------:|------------------:|
|    8 |       1 |     27.0 |                     182.8 |     297.4 (1.63x) |     298.0 (1.63x) |
|    8 |       8 |    176.1 |                    1247.0 |    1283.1 (1.03x) |    1280.5 (1.03x) |
|    8 |      16 |    932.8 |                    1271.0 |    1290.1 (1.01x) |    1358.5 (1.07x) |
|    8 |      32 |   1467.4 |                    1274.9 |    1294.0 (1.01x) |    1292.9 (1.01x) |
|   16 |       1 |     27.5 |                     173.2 |     317.5 (1.83x) |     320.4 (1.85x) |
|   16 |       8 |    177.4 |                    1173.7 |    1529.8 (1.30x) |    1487.4 (1.27x) |
|   16 |      16 |    329.1 |                    1928.5 |    1990.4 (1.03x) |    1922.5 (1.00x) |
|   16 |      32 |   2662.6 |                    1594.0 |    1625.1 (1.02x) |    1636.6 (1.03x) |
|   32 |       1 |     27.6 |                     188.1 |     336.6 (1.79x) |     337.5 (1.79x) |
|   32 |       8 |    177.6 |                    1412.5 |    2215.6 (1.57x) |    1991.6 (1.41x) |
|   32 |      16 |    350.8 |                    1955.6 |    2836.6 (1.45x) |    2734.3 (1.40x) |
|   32 |      32 |    505.1 |                    3189.7 |    3181.9 (1.00x) |    3211.0 (1.01x) |
|   64 |       1 |     28.0 |                     195.8 |     340.2 (1.74x) |     354.3 (1.81x) |
|   64 |       8 |    175.2 |                    1483.1 |    2393.9 (1.61x) |    2266.5 (1.53x) |
|   64 |      16 |    362.7 |                    2018.2 |    3757.9 (1.86x) |    3699.5 (1.83x) |
|   64 |      32 |    550.9 |                    3339.6 |    5080.1 (1.52x) |    5087.6 (1.52x) |
| 1000 |       1 |    478.0 |                     193.8 |     337.4 (1.74x) |     351.6 (1.81x) |
| 1000 |       8 |   1890.9 |                    1487.1 |    2591.3 (1.74x) |    2742.9 (1.84x) |
| 1000 |      16 |   2264.7 |                    2084.0 |    4848.3 (2.33x) |    5471.2 (2.63x) |
| 1000 |      32 |   2439.5 |                    3409.0 |    8909.0 (2.61x) |   10602.9 (3.11x) |

Raw data: https://gist.github.com/mulugetam/bda9537802ed63f891cbe3b6370deb86#file-pr5447_and_5441-stdout

@meta-codesync

meta-codesync Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

@mnorris11 has imported this pull request. If you are a Meta employee, you can view this in D114380042.

Comment thread faiss/IndexFlat.h Outdated
/// 1 selects the original query-at-a-time path. Results are identical for any
/// value; this only trades off cache behavior. Set before searching; changing
/// it concurrently with in-flight searches is not thread-safe.
FAISS_API extern size_t panorama_query_block_size;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this be moved out of IndexFlat.h/cpp to Panorama.h/cpp?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, @mnorris11! I just did that and rebased it.

Could you also take a look at #5341? We plan to apply it to speed up OpenSearch-KNN, and I think it would be a big benefit.

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>

Move panorama_query_block_size to faiss/impl/Panorama.{h,cpp}

The global belongs with the Panorama implementation, not IndexFlat.
IndexFlat.h already includes Panorama.h so all existing users still
compile without changes.

Signed-off-by: Mulugeta Mammo <mulugeta.mammo@intel.com>
@mulugetam
mulugetam force-pushed the panorama-fp16-storage branch from b73411f to c4ac96d Compare July 31, 2026 18:21
@mulugetam

Copy link
Copy Markdown
Contributor Author

@mnorris11 fixed the merge conflict after #5441 got merged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants