TBB
Deep Dive
Implementation

TBB-Based Inverted Lists: Parallelism Without the Drama

You know how sometimes you're working with millions of vectors and your program is just... sitting there? Like it's taking a coffee break while you're frantically refreshing the terminal? That's the problem we're solving here.

The core insight is embarrassingly simple: FAISS's original inverted lists use OpenMP for parallelism, which works great until you try to compose it with other parallel libraries. Then you get into this weird situation where threads are fighting over locks, waiting for each other like cars at a four-way stop where everyone arrived at the same time.

The TBB Advantage

Intel's Threading Building Blocks (TBB) takes a different approach. Instead of "here are 8 threads, go wild," it says "here are tasks that might be independent, let me figure out the optimal execution." It's like having a really smart scheduler that actually understands dependencies.

Our OnDiskInvertedListsTBB replaces OpenMP primitives with TBB parallel patterns:

// Instead of #pragma omp parallel for
tbb::parallel_for(
tbb::blocked_range<size_t>(0, n, grain_size),
[&](const tbb::blocked_range<size_t>& range) {
// Process range.begin() to range.end()
}
);

Cache-Aware Grain Sizing

The magic is in the grain size calculation. We actually detect your L3 cache size from sysfs and calculate how many elements fit per thread:

size_t OnDiskInvertedListsTBB::calculate_cache_aware_grain_size(
size_t n, size_t element_size) {
size_t l3_size = get_l3_cache_size();
size_t max_concurrency = tbb::this_task_arena::max_concurrency();
// How many elements fit in L3 per thread?
size_t cache_per_thread = l3_size / max_concurrency;
size_t elements_per_cache = cache_per_thread / element_size;
// Use 1/4 of cache per thread to leave room for other data
return elements_per_cache / 4;
}

For a typical 16MB L3 cache with 8 threads, that's 2MB per thread, or about 64K floats. Small enough to stay hot in cache, large enough to amortize scheduling overhead.

Thread-Safe File I/O: The pread/pwrite Pattern

Here's where things get interesting. The original FAISS uses a single file pointer with seek+read. That works fine with a global lock, but kills parallelism. We use POSIX pread/pwrite instead:

// Thread-safe read without moving file pointer
size_t bytes_read = data_stream_->pread(
codes->data(), // buffer
1, // element size
codes_size, // count
offset // absolute position
);

Each thread reads from its own position without coordination. No locks, no seeks, just parallel I/O. The kernel handles the complexity.

This shows up in the get_codes/get_ids methods:

const uint8_t* OnDiskInvertedListsTBB::get_codes(size_t list_no) const {
// Check cache first (with TBB concurrent_hash_map)
if (cache_) {
return tbb_cache->get_or_load_codes(list_no, [this, list_no]() {
// Read lock (allows multiple readers)
tbb::spin_rw_mutex::scoped_lock lock(
list_rw_mutexes_[list_no]->mutex, false);
// pread doesn't need exclusive access
data_stream_->pread(codes.data(), 1, codes_size, offset);
return codes;
});
}
}

Notice the false in the scoped_lock? That's a read lock. Multiple threads can read the same list simultaneously. We only take a write lock in add_entries.

The Pipeline Pattern for Search

TBB Pipeline Architecture

Three-stage pipeline: serial input, parallel processing, serial output

Search is where TBB really shines. We use a three-stage pipeline:

Serial Input Stage
Generate query-list pairs in order
Parallel Processing Stage
Compute distances for each query
Serial Output Stage
Write results in order

The pipeline maintains query order (important for reproducibility) while parallelizing the expensive distance computations. And because it's a pipeline, there's natural prefetching—while stage 2 processes query N, stage 1 is already preparing query N+1.

Memory Optimization: Thread-Local Pools

Here's a subtle performance issue: allocating vectors for search results. If every query allocates a std::vector, you spend half your time in malloc. Solution? Thread-local memory pools:

struct CandidatePool {
static constexpr size_t MAX_POOL_SIZE = 1024 * 1024;
std::vector<std::pair<float, idx_t>> pool;
size_t allocated = 0;
void reset() { allocated = 0; }
std::pair<float, idx_t>* allocate(size_t n) {
if (allocated + n > pool.size()) {
pool.resize(allocated + n);
}
auto* ptr = pool.data() + allocated;
allocated += n;
return ptr;
}
};
mutable tbb::enumerable_thread_specific<CandidatePool> search_pools_;

Each thread gets its own pool, allocated once and reused. The pool just bumps a pointer—essentially zero-cost allocation. Reset between queries and you have a trivial bump allocator.

Batch I/O for Throughput

When you're searching 1000 queries that all touch the same 100 lists, reading each list 10 times is wasteful. The BatchReadManager deduplicates reads:

// Phase 1: Collect all requests
for (size_t i = 0; i < n; i++) {
batch_read_manager_->add_request(i, assign[i], true, true);
}
// Phase 2: Execute unique reads in parallel
batch_read_manager_->execute_batch_reads(this);
// Phase 3: Process queries using cached data

This is especially effective with nprobe > 1, where queries share probe lists.

Cache-Line Aligned Mutexes

One last detail that matters more than you'd think. We pad our mutexes to prevent false sharing:

struct alignas(64) PaddedRWMutex {
tbb::spin_rw_mutex mutex;
char padding[64 - sizeof(tbb::spin_rw_mutex)];
};

Without this, two threads locking adjacent list mutexes would bounce the same cache line back and forth, even though they're accessing different data. The padding ensures each mutex lives in its own cache line.

Performance Numbers

On a 5GB dataset (5 batches × 1GB each):

Search throughput:175K QPS (regular) → 398K QPS (TBB) = 2.3x speedup
Add latency:Similar to regular (I/O bound, not CPU bound)
Memory overhead:~128 bytes per list for mutexes, plus TBB task overhead
CPU efficiency:95%+ utilization across all cores (vs 60-70% with OpenMP)

The key insight: TBB's work-stealing scheduler keeps cores fed. OpenMP tends to create barriers where threads wait. TBB says "if your task is blocked, steal work from another queue."

When to Use This

TBB-based inverted lists shine when:

1.
High query throughput: Searching millions of queries/second
2.
Composability: Using FAISS within a larger parallel application
3.
NUMA systems: TBB's affinity partitioner respects NUMA topology
4.
Dynamic workloads: When list sizes vary wildly

Don't use if:

1.
Single-threaded search: Overhead isn't worth it
2.
Small datasets: Just use ArrayInvertedLists in memory
3.
No TBB available: Falls back to regular OnDiskInvertedLists

The code is production-ready, battle-tested on datasets up to 1 billion vectors. The only gotcha: you must use IndexIVFTBB (not IndexIVFFlat) to avoid OpenMP/TBB conflicts. The index and inverted lists need to agree on their threading model.

Think of it like this: TBB is async/await for C++. You describe tasks and dependencies, and the runtime figures out optimal execution. It's more work upfront (understanding parallel patterns) but pays dividends in composability and performance.