Bit is a fixed-capacity, uncompressed bitset library for C. It provides
individual bitsets, packed collections of bitsets, set operations, population
counts, and OpenMP-enabled container operations. The public interface is based
on David Hanson's Bit_T design and extended with packed Bit_DB_T
containers. While I strive to ensure the README.md is "in sync" with the codebase, this does not
always happen. You should therefore treat the public declarations in include/bit.h as the ultimate
source of truth if something does work according to what REAMDE.md claims (and alert me to fix it!).
- Project Background and Features
- Comparison to Other Libraries
- Design, Bit Counting, and OpenMP
- Branches and their Status
- Build and Test
- GPU Troubleshooting and Validation
- Using the Library
- Public API Reference
- Controlling the OpenMP environment in CPU and GPU
- Benchmarks and Experiments
- Automation Scripts
- Constraints and Current Status
- Dependencies, Inspiration, and Applications
- Roadmap
Bit began as a retype and extension of David Hanson's Bit_T interface from
Chapter 13 of C Interfaces and Implementations (Addison-Wesley,
ISBN 0-201-49841-3). This is a great book, written before the era of vector extensions and GPUs and
adopts the literate programming approach to educate while delivering good quality code. The bitset is formally a vector of bits, and less formally
a contiguously stored buffer of bytes, with each bit in each byte directly addressable, modifiable and ultimately useful
for compute work in microcontrollers or high performance massive databases using Boolean algebra. Hanson published his book just before multi-threading frameworks like pthreads and OpenMP were introduced (the specifications were published in 1997, shortly after Hanson's book was released in 1996) but before multicore processors (those would not become widely available for another decade), and half a decade before DirectX8 opened up GPUs for general purpose compute purposes. Other compute innovations that postdate this book include the introduction of vectorized operations (Single Instruction, Multiple Data aka SIMD) and the formalization of the cache level hierarchies to narrow the memory-CPU gap. The Bit_T thus became the starting point of an exploration on how we can take classic books and algorithms implemented in C and polish them up for the modern era. This repository is about this personal journey (assisted in the latter stages by generative AI).
The project keeps the original emphasis on a small C interface while extending it with setop count operations that are accelerated by vector extensions, packed bitset containers of bitsets (for the mathematically oriented, these are arrays of bitsets/bitvectors) eying applications in analytical vector databases.
When developing Bit I deliberately started with Hanson's small interface because it is easy to
reason about, then kept extending and experimenting with the C preprocessor as a form of meta-programming to allow seamless and portable execution in CPUs and GPUs. The result is still a
small bitset library at heart, but it now has two useful levels of abstraction: an individual Bit_T and a packed Bit_DB_T for bulk, production work.
The production work I had in mind involves dense, fixed-capacity bitsets and workloads where bitwise set operations, population counts (counting the bits equal to one in a byte) predictable memory access patterns for performance in both CPU and GPUs. Specific implementation features that facilitate these use cases are:
- Population counting: Counting the number of one's in a container is the basis of similarity measures (such as the Hamming distance).1 There are numerous ways to do this calculation, some of which are better geared to specific forms of hardware than others. A scalar population count is part of my CPU and nearly all GPUs, but hardware instructions are limited to Arm or AVX512 capable CPUs. If one were to execute massive database searches that are based e.g. on Hamming distances, memory access patterns may favor implementations that are based on SIMD extensions as compilers may not always be able to auto-vectorize.
Bitattempts to squeeze the maximum of performance in a portable manner by 1) bundling libpopcnt that can use CPU-specific population-count implementations (hardware instructions such asVPOPCNTDQin AVX512 platforms or algorithms such as the Harley-Seal) when enabled 2) vectorized population ops (such as bitwise AND/XOR/OR) with counts (setops) through the SIMDe vectorized loads, stores and portable intrinsics for counts in vectors and 3) a portable Wilkes-Wheeler-Gill (WWG) / sideways-addition path. Key applications of bitsets in the era of data intensive applications require performant, high throughput population counting and the performance portability of this operation has heavily weighted on the design of this library. - Fused Set operations with counts: Union, intersection, symmetric difference, and set difference are available for individual bitsets and packed containers. The library also provides fused setop count operations in which the bitwise Boolean algebra is followed by a population count without forming the full intermediate result of the bitwise operation before counting the bits. The fused setop/count operations (or fused logical cardinality operations if you want to be more formal) are accelerated to various degrees by the underlying pop count implementation for a given hardware architecture, so having multiple ways to skin the popcount cat provides an easy way to optimize Bit for your own hardware using the build system.
- External storage: Bitsets and containers can borrow caller-owned buffers when their storage is allocated with the size and padding required by the
public API. This functionality exists to easily interface
Bitwith other languages (e.g. Perl) without un-necessary copying memory across language interfaces. - Packed containers:
Bit_DB_Tstores equally sized bitsets in contiguous storage for all-pairs count operations on CPU or, where configured, GPU offload. Packing effectively transforms the bitsets into the equivalent of Boolean matrices of bits. This has an important theoretical implication, i.e. fused setop_count operations can be viewed as Boolean analogues of conventional matrix multiplication and thus one could port ideas from high Performance BLAS libraries to speed up things quite a bit. Packed storage has important performance implications for the processor: a) Zero Pointer Chasing keeps the prefetcher happy b) Cache friendly implementation through tiling and c) Natural SIMD implementations: vector lanes can massively accelerate both the setop and the cardinality computation (especially if the processor has a vector instruction for the popcount) at the register level. - Multithreaded portable performance through OpenMP: On the CPU side, one can implement a cache hierarchy friendly multi level (3 to 6) tiled Boolean algebra analog of a GEneral Matrix Matrix (GEMM) across many threads/cores. On the GPU side one can sequentially adapt these multi-level tiled algorithms for the same purpose. Because Bit uses flat, dense matrices, one can map the entire container to a GPU device using a single OpenMP #pragma omp target directive and exploring different parallelization strategies and work-sharing constructs. One interesting finding is that different compilers (e.g.
gccand theLLVMbased ones) use different models to map OpenMP compiler directives to the underlying 2 dimensional compute fabric of GPUs, so that one has to utilize slightly different OpenMP implementations to maximize performance for a given compiler in a given compute architecture - Vendor agnostic OpenMP offload: NVIDIA, AMD, and experimental integrated Intel paths are opt-in: you do not need to use them if you are not going to deploy in the GPU. The default configuration of the build system is to not build these offloads, but retain the GPU-facing API in the library; if the latter is built without offload support, then the internal macro implementations ensure that the GPU API uses the CPU code path. Thus we will not break consuming code that uses the GPU API if the library is built without offloading capabilities.
Bit is not a compressed CRoaring or dynamically growing bitmap library, so given the simplicity of the static bitset data structures, one can find numerous similar implementations in software repositories. Daniel Lemire's cbitset and the bitset_t dense bitvector interface in CRoaring are the closest libraries to Bit (though neither provides Bit's packed-container multithreading or GPU offload; CRoaring's Roaring containers do use SIMD, while cbitset relies on popcount builtins and compiler auto-vectorization). These are not the only libraries that one can find in the C / C++ ecosystem that provide similar functionality. The following table contrasts features of Bit versus other alternatives that one may adopt for their own project:
| Library | Internal Structure | Dynamic Growth | Fused Logic + Count | Bit Matrices / Containers | Explicit SIMD | GPU / Hardware | Multithreading |
|---|---|---|---|---|---|---|---|
Bit [1] |
Uncompressed dense array |
No (Fixed at init; borrowed caller buffers via Bit_load / BitDB_load) |
Yes (Bit_{union,inter,diff,minus}_count; BitDB_*_count_{cpu,gpu}) |
Yes (Native contiguous Bit_DB_T; all-pairs |
Yes (libpopcnt and SIMDe paths; WWG fallback) |
Yes (OpenMP target; NVIDIA/AMD, experimental Intel) | Yes (Host OpenMP on container counts) |
CRoaring [2] |
Compressed Roaring (array / bitset / run); 32- and 64-bit |
Yes (roaring_bitmap_add(); dense bitset_set / bitset_resize / bitset_grow) |
Yes (roaring_bitmap_{and,or,xor,andnot}_cardinality; dense count equivalents) |
Partial (or_many, xor_many; no equal-stride packed row matrix) |
Yes (AVX2, AVX-512, NEON; runtime dispatch on x86) | No | No (ops are single-threaded; COW allows concurrent reads) |
BitMagic [3] |
Compressed hierarchical blocks + GAP/RLE; rank-select as an accelerator |
Yes (set(), set_bit(), resize()) |
Yes (count_and, count_or, count_xor, count_sub) |
Yes (bm::aggregator<>, sparse_vector, rsc_sparse_vector, bit-transposed containers) |
Yes (SSE2/SSE4.2, AVX2, NEON, Wasm SIMD; AVX-512 experimental) | No | Partial (task pool and parallel planners; aggregator is not auto-parallel and is not shareable across threads) |
cbitset [4] |
Uncompressed dense array |
Yes (bitset_resize(), bitset_grow(); bitset_set auto-grows) |
Yes (bitset_intersection_count, bitset_union_count, bitset_difference_count, bitset_symmetric_difference_count) |
No | No (popcount builtins / auto-vectorization; no wide-SIMD API) | No | No |
goldsborough/bitset [5] |
Uncompressed vector-backed |
Yes (bitset_push(), bitset_grow(), bitset_reserve(), bitset_setup(); there is no bitset_resize()) |
No (bitset_and / bitset_or / bitset_xor write a destination) |
No | No | No | No |
EWAHBoolArray [6] |
Compressed EWAH (enhanced WAH RLE); also uncompressed BoolArray
|
Yes (set(), addWord(), append(), padWithZeroes(); updates are append-oriented) |
Yes (logicalandcount, logicalorcount, logicalxorcount, logicalandnotcount) |
Partial (fast_logicalor for N-way union; no packed matrix) |
No (scalar POPCNT / builtins) | No | No |
boost::dynamic_bitset [7] |
Uncompressed vector of blocks |
Yes (resize(), push_back(), push_front(), append()) |
No ((a & b).count() materializes; intersects tests existence only) |
No | No |
No (Boost.Compute has a separate device dynamic_bitset) |
No |
std::bitset [8] |
Uncompressed fixed array |
No (compile-time <size_t N>) |
No ((a & b).count() copies then counts) |
No (static arrays of bitsets only) | No | No | No |
sul::dynamic_bitset [9] |
Uncompressed C++17/20 header-only |
Yes (resize(), push_back(), append()) |
No (intersects only; no fused set-op cardinality) |
No |
Partial (optional libpopcnt for count() only) |
No | No |
SDSL-lite [10] |
Uncompressed bit_vector (int_vector<1>) plus compressed/succinct (rrr_vector, sd_vector, …) |
Partial (bit_vector / int_vector resize; compressed forms are generally rebuilt) |
No (rank/select support, not pairwise fused cardinality) | No (succinct indexes, not dense all-pairs rows) | Partial (low-level popcount/SSE in support code) | No | No |
FastBit [11] |
WAH-compressed bitmap indexes over columnar data | No as a general resizable bitset (indexes are built from columns) |
Partial (compressed AND/OR/XOR/MINUS; in-domain count(mask)) |
Partial (many bitmaps as bit-sliced / binned indexes) | No (compressed-word algorithms) | No | No (query-level parallelism, not a bitset thread API) |
SIMD-Bitset [12] |
Uncompressed aligned char*
|
Partial (Resize(int) reallocates; not a production grow API) |
Partial (CountAnd; AND/OR materialize) |
No | Yes (AVX/AVX2; experimental, leaky ownership) | No | No |
libalgebra [13] |
Raw word arrays (does not expose a bitset container) | No |
Yes (STORM_intersect_count, STORM_union_count, STORM_diff_count) |
No (useful as a kernel backend only) | Yes (SSE4.2, AVX2, AVX-512, NEON) | No | No |
opentl/bitset [14] |
New C++ GitLab project (~6 commits at this snapshot) | Not established (too small/new to treat as a production comparator) | Not established | No | Not established | No | No |
- [1] chrisarg/Bit: https://github.com/chrisarg/Bit/
- [2] RoaringBitmap/CRoaring: https://github.com/RoaringBitmap/CRoaring
- [3] tlk00/BitMagic: https://github.com/tlk00/BitMagic
- [4] lemire/cbitset: https://github.com/lemire/cbitset
- [5] goldsborough/bitset: https://github.com/goldsborough/bitset
- [6] lemire/EWAHBoolArray: https://github.com/lemire/EWAHBoolArray
- [7] boost::dynamic_bitset: https://github.com/boostorg/dynamic_bitset
- [8] std::bitset: https://en.cppreference.com/w/cpp/utility/bitset
- [9] pinam45/dynamic_bitset (
sul::dynamic_bitset): https://github.com/pinam45/dynamic_bitset - [10] simongog/sdsl-lite: https://github.com/simongog/sdsl-lite
- [11] berkeleysdm/fastbit: https://github.com/berkeleysdm/fastbit
- [12] Michael-Tieying-Zhang/SIMD-Bitset: https://github.com/Michael-Tieying-Zhang/SIMD-Bitset
- [13] mklarqvist/libalgebra: https://github.com/mklarqvist/libalgebra
- [14] opentl/bitset: https://gitlab.com/opentl/bitset
These libraries fall roughly in four categories that suggest their use niche:
-
General-Purpose Utilities (
std::bitset,boost::dynamic_bitset,goldsborough/bitset,sul::dynamic_bitset) Designed for everyday software engineering. They provide safe, easy-to-use APIs for tracking state flags and application logic. They allow dynamic memory allocation (exceptstd::bitset), but lack fused cardinality operations and native matrix containers. Running batch queries across thousands of bitsets requires manual loops and intermediate memory allocations.sul::dynamic_bitsetis a modern header-only stand-in for Boost that can optionally uselibpopcnt. -
Compression, Indexes, and Succinct Structures (CRoaring, BitMagic, EWAHBoolArray, FastBit, SDSL-lite) Engineered to handle massive amounts of sparse or compressible data. Both CRoaring and BitMagic recognize the memory bottleneck of intermediate allocations and implement fused logical counters. BitMagic explicitly provides robust container abstractions for succinct bit-transposed data (e.g.,
bm::sparse_vector). Crucially, it featuresbm::aggregator<>, a cache-friendly, SIMD-optimized engine designed for fast N-way AND/OR/SUB batch operations across groups of bit-vectors. However, these features are explicitly oriented toward compressed, inverted-index style workloads (like column-store databases and text search). Because these succinct matrices are composed of compressed trees or run-length encoded streams, traversing them requires complex branching logic, making adaptation to multi-core GPU accelerator threads challenging. CRoaring can still be extremely fast on dense data (it falls back to dense bitset containers), but its overall architecture and strength remain in the sparse/compressed regime. EWAH is the classic word-aligned RLE bitmap (Git-style workloads). FastBit is a WAH bitmap index over scientific/columnar data, not a general bitset ADT. SDSL-lite is a succinct rank/select toolkit (bit_vector,rrr_vector,sd_vector, wavelet trees, FM-indexes), not a dense Boolean GEMM engine. -
The HPC Compute Engine: Bit deliberately trades memory compression and dynamic resizing for maximum computational throughput, explicit parallelism, and strong hardware affinity. HPC workloads such as bioinformatics, similarity search against static document collections, and Boolean matrix algebra rarely operate on isolated pairs of bit vectors. Instead, they process hundreds of thousands of vectors at once. Bit treats its containers (Bit_DB_T) as contiguous 2-D dense matrices of packed bitsets, enabling efficient bulk and all-pairs operations. While libraries such as BitMagic excel at N-way aggregations over sparse, compressed indexes using CPU SIMD, Bit is purpose-built for dense, fixed-capacity packed batches. By enforcing fixed sizes and storing matrices in contiguous memory, a single OpenMP offload directive can map the data directly onto NVIDIA, AMD GPUs and integrated Intel GPUs. Streaming multiprocessors can then compute batch similarities with no pointer chasing and minimal branch divergence—delivering throughput that compressed, pointer-heavy formats cannot efficiently achieve on accelerator hardware.if you have massive, dense bitsets and need to throw everything modern silicon has at them (SIMD, Multithreading, and GPU compute), Bit fills a high-performance computing void. The comparison against the
IndexBinaryFlat, a highly optimized algorithm from FAISS for high performance similarity search in binary vectors using population counts of Hamming distance matrices illustrates how the well individual components ofBitcombine to achieve performance that comes close to state of the art libraries. While FAISS optimizes a search problem, Bit optimizes the broader dense Boolean linear-algebra / similarity-computation problem. -
Kernel backends and experimental code (
libalgebra,SIMD-Bitset,opentl/bitset)libalgebradoes not expose a bitset class directly; it is a header-only SIMD kernel for popcount, positional popcount, and fused intersect/union/diff cardinality over raw word arrays and tensors.SIMD-Bitsetis instructional AVX code with incomplete ownership.opentl/bitsetis too new for direct comparison: analysis of its code suggest it provides functionality similar to the originalBit_Tlibrary by David Hanson.
This section provides further background about the design choice of Bit's implementation, some useful trivia and performance notes about population counting and justification about the containerized operations and the use of OpenMP.
Internally the library relies on a considerable amount of macro code to reduce repetition. Within these macros internal _Pragma helpers are used to express CPU worksharing, vector length and processor adaptible SIMD reduction, coordinate the data travel to and from the GPU, without maintaining several nearly identical
kernels. This is one of the places where the C preprocessor is earning its keep and I am forever indebted to the Hanson book that showed me I should embrace the macro style. While the public C API does not expose these macros, it does provide a macro interface to the high level functions that can be used to direct code to the CPU or the GPU. I have found these macros to substantially cut on the amount of boilerplate I had to use to develop the functionality of the library and I encourage their use.
The codebase originally used the name Wilkes-Wheeler-Gill (WWG) for a portable
sideways-addition population-count technique. Historical literature also calls
the technique Gillies-Miller sideways addition.2 This algorithm offered a portable
fallback when a specific target or compiler path did not use a native popcount
instruction. The algorithm is a very performant one and until Bit release 1.0 was the default algorithm when one did not want to include the libpopcnt library. The present release offers as an alternative to libpopcnt an implementation based on SIMDe's simde_mm512_popcnt_epi64, simde_mm256_popcnt_epi64 or simde_mm_popcnt_epi64 , with the choice made at compile time based on compiler flags for the architecture used. Internally SIMDe is using different algorithms to accomodate different vector architectures (including hardware acceleration if available e.g. in Neon and AVX512 processors). If the vector architecture cannot be resolved via the compiler flags, then the Bit does not use vectorized loads and stores and defaults to the WWG algorith. This choice of algorithms was motivated by the history of the library: WWG was the first popcount I used, followed by the quick adoption of libpopcnt and more recently of SIMDe based portable intrinsics. There is emerging evidence, e.g. see my companion repository bench_popcount , that one must consider additional choices that vary by compiler, architecture and possibly surrounding code context. Turning on LTO will also affect performance and considering that one can obtain differences in performance of an order of magnitude or more, it is worth to have more than one options on the table.
It is worth reflecting on my personal path in exploring population count implementations. This stemmed from the nature of the applications I am using Bit for: in these applications a performant population count can make a huge difference in how the entire application (mostly vector database searches) performs. David Hanson's original implementation of the population count relied on a scalar lookup of the upper and lower nibbles of each byte in the bitvector. Scalar hardware population counts would not appear in processor instructions until the late 1990s and early 2000s (for those into conspiracy theories, look up the relevant stories about NSA's request/insistence to include this instruction in processor ISAs), so Hanson used a very standard approach for the time. This vectorized approach still forms the basis of performant AVX2 vectorized popcount operations and is included in:
- sse-popcount, including the Harley-Seal population-count work associated with Lemire, Kurz, and Mula.
- SIMDe for AVX2 paths
For those who want to explore the fascinating history of the population count in the CPU (going all the way to Alan Turing) here are some links:
- Archived 1999 cryptography mailing-list thread
- The Quest for an Accelerated Population Count
- Retrocomputing Stack Exchange – “Are there any articles elucidating the history of the POPCOUNT instruction?”
- You Won’t Believe This One Weird CPU Instruction!
- Revisiting POPCOUNT Operations in CPUs/GPUs
The last paper provides an interesting evaluation of popcounts in both CPU and GPU and provides an independent evaluation that the Harley-Seal which is used by [libpopcnt](https://github.com/kimwalisch/libpopcnt is slightly better than the vectorized look up method in AVX2 systems. The same paper showed that bit tweaking tricks don't really offer a substantial performance gain in the GPU.
I was not aware of this paper when I selected WWG as the default GPU code path unless USE_BUILTIN_POPCOUNT=1 is selected at build time. There is a useful compiler lesson hiding here: during development I found that Clang's (and gcc's) NVIDIA target, the hand-written WWG expression and __builtin_popcountll
produced byte-identical device PTX containing popc.b64. Both compilers recognized the
classic SWAR pattern and canonicalized it to the hardware operation. Since USE_BUILTIN_POPCOUNT need
not change performance, I left it as the default choice for the compiler to mess with.
The non-containerized bitset operations are straightforward to parallelize at
the application level using OpenMP. Therefore one may ask what is the benefit of providing packed containers?
By explicitly defining the storage layout, these containers facilitate optimal scheduling for batched all-pairs operations. Techniques including CPU memory tiling, OpenMP thread scheduling, and dense GPU layouts are employed to ensure that data locality and parallel work distribution are tightly coupled to the hardware, bypassing the inefficiencies of generic loop nests. This distinction is intentional. An application with an array of independent Bit_T objects can write an OpenMP loop to process one large collection of Bit_T against another directly and very cleanly:
#include "bit.h"
#include <assert.h>
#include <stdlib.h>
int main(void) {
const int query_count = 2;
const int reference_count = 3;
Bit_T queries[2];
Bit_T references[3];
int *counts = calloc((size_t)query_count * reference_count, sizeof(*counts));
assert(counts != NULL);
for (int i = 0; i < query_count; ++i) {
queries[i] = Bit_new(128);
Bit_bset(queries[i], 10 + i);
}
for (int j = 0; j < reference_count; ++j) {
references[j] = Bit_new(128);
Bit_bset(references[j], 10 + j);
}
#pragma omp parallel for collapse(2)
for (int i = 0; i < query_count; ++i) {
for (int j = 0; j < reference_count; ++j) {
counts[i * reference_count + j] =
Bit_inter_count(queries[i], references[j]);
}
}
for (int i = 0; i < query_count; ++i) Bit_free(&queries[i]);
for (int j = 0; j < reference_count; ++j) Bit_free(&references[j]);
free(counts);
return 0;
}Bit_DB_T exists for the cases where one would the library to own that bulk
organization. Its contiguous storage lets the implementation tile the two
outer container dimensions and block the inner bit-vector reduction achieving high performance. The
CPU_TILE, BITVECTOR_TILE, outer-row/column shape, unroll, and scratch-buffer
settings that will be discussed in the benchmarking and tuning sections are "knobs" that affect cache use, register pressure, and memory traffic.
These should be thought as tuning controls for a specific CPU architecture rather than universal constants, even though the default choices mostly work sufficiently well.
A user should not need to worry about those after perhaps an initial tuning to the specific machine they want their code to run at.
This repository includes three specific branches with their branch-specific tooling. Historically these branches emerged from the need to test different OpenMP implementations for GPUs, the integrated Intel GPU branch (which to this day requires further work and is considered highly experimental, not fit for production work until features of OpenMP 5.0 are fully integrated in the implementation). The following table lists the three branches, but for all intents and purposes the main is what you need.
| Branch | Purpose | Notes |
|---|---|---|
main |
Baseline library, SIMD, CPU tuning, NUMA experiments, and shared benchmark work | Owns the CPU sweep/tuning scripts and the main-to-branch synchronization helpers. The FAISS benchmark suite is cross-branch shared (see below). |
gpuOpt |
GPU/offload kernel and comparative benchmark work | Owns Makefile_bench.mak, the openmp_bit_nocpu GPU-only kernel testbed, native CUDA/HIP benchmarks, GPU sweep/plot tooling and results, and the gpuOpt-to-branch synchronization helpers. The FAISS benchmark suite is cross-branch shared (see below). |
inteliGPU |
Intel oneAPI CPU build and offload validation | Build with CC=icx GPU=INTEL. Its scripts/ directory retains the shared bug-report helper and the shared FAISS benchmark suite. |
The repository contains a number of scripts that may be used to report bugs when building the library, synchronize common code paths between two branches, profile and fine tune the library for a specific architecture via benchmarking and summarize the results visually and with markdown tables. Finally, there are scripts that compare the performance of an example application built with Bit, a poor person's analogue of the IndexBinaryFlat functionality from FAISS. The profiling/tuning/benchmark scripts follow the same design philosophy: a JSON configuration file that provides the grid over which one sweeps performance metrics, a Perl script that parses the configuration script, executes artefacts build with Bit, parses and logs their output, and a R script that does the visualization.
| Script or group | main |
gpuOpt |
inteliGPU |
Purpose |
|---|---|---|---|---|
generate_bug_report.sh |
Yes | Yes | Yes | Backend for make bug_report; collects build configuration, diagnostics, preprocessed source, and an optional backtrace. |
cpu_param_sweep.pl + benchmark_config_cpu.json |
Yes | No | No | JSON-driven broad CPU build/runtime sweep. |
cpu_profiling_analytics.R |
Yes | No | No | Intended analysis and plotting companion for broad CPU sweep CSV files; see the compatibility note below. |
sweep_cpu_tuning.pl |
Yes | No | No | Focused CPU kernel timing and perf stat profiling; writes its own CSV and Markdown reports. |
run_numa_sweeps.sh |
Yes | No | No | Runs four dual-socket scenarios through sweep_cpu_tuning.pl. |
gpu_param_sweep.pl + plot_performance.R |
No | Yes | No | Compatible GPU sweep and plotting pair for benchmark_GPU_params/. |
Tracked benchmark_GPU_params/ results |
No | Yes | No | Historical GPU sweep CSV/log results kept with their producer and plotter. |
faiss_compare.pl + benchmark_config_faiss.json |
Yes | Yes | Yes | Small JSON-driven FAISS-vs-Bit comparison sweep; harvests per-iteration timings into benchmark_FAISS/. Shared across branches. |
faiss_compare_visualize.R |
Yes | Yes | Yes | R report for the FAISS comparison; boxplots of per-iteration times and a summarized CSV in benchmark_FAISS/. Shared across branches. |
faiss_cpu_benchmark.py |
Yes | Yes | Yes | Native FAISS IndexBinaryFlat CPU baseline; one of the two sweep FAISS arms. Shared across branches. |
faiss_gpu_benchmark.py |
Yes | Yes | Yes | Native FAISS GPU comparison (no CPU baseline); the other sweep FAISS arm. Shared across branches. |
faiss_cpu_gpu_benchmark.py |
No | Yes | No | Fixed-workload FAISS binary-index comparison with a measured CPU baseline and each detected CUDA GPU. NOT used by the sweep; gpuOpt-only. |
push_main_to_gpuOpt.sh, push_main_to_inteliGPU.sh |
Yes | No | No | Copy curated paths from main to the named destination branch. |
push_gpuOpt_to_main.sh, push_gpuOpt_to_inteliGPU.sh |
No | Yes | No | Mirror the same selective-copy workflow with gpuOpt as the source branch. |
The FAISS programs require Python, NumPy, and a FAISS build with GPU support. They print fixed-workload timing summaries and do not feed either R script.
Building and testing requires a Linux environment (though I have only tested Debian flavors such as Ubuntu 22.04, 24.04, WSL Ubuntu flavors and Armbian). As long as the tooling noted below is available, the library (at least the non GPU versions) should build without issues.
- A C compiler supported by the current
Makefile:clang,gcc,amdclang, oricx. Versions that have been tested are:
| GCC | AMDClang | Clang | ICX |
|---|---|---|---|
| 12.4.0 | 18.0.0 | 18.1.8 | 2026.1.1 |
The major compatibility requirement is the use of a compiler that supports an OpenMP version that is at least 201511 or newer (I have tested OpenMP versions up to 202011)
- GNU Make.
- OpenMP support for the selected compiler (this may require installing the relevant libraries).
- CUDA and an OpenMP offload-capable LLVM toolchain for NVIDIA offload.
- ROCm and a compatible LLVM/ROCm stack for AMD offload.
- Intel oneAPI
icxfor the Intel integrated GPU OpenMP offload (icxis a valid and very performant choice if the library is built without offload capabilities). nvccfor the experimental CUDA benchmark andhipccfor the experimental HIP benchmarks in thegpuOptbranch.
To get you started, just clone and build the default CPU configuration without specifying any target:
git clone https://github.com/chrisarg/Bit.git
cd Bit
makeThe test target builds build/test_bit but does not execute it. Build and run it explicitly:
make test
./build/test_bitThis will execute a number of tests to ensure that the library builds and computes correctly. make disclean or make clean wipes out the slate clean.
The standard Makefile builds the library and ordinary benchmarks on main
and the specialized branches. The following table summarizes the various targets that one can build using a range of compilers and GPU offload configuration flags. The rightmost column below is gpuOpt-only: its
GPU-only and native targets require make -f Makefile_bench.mak. Those targets are useful in ongoing work to optimize the OpenMP implementations against native CUDA and HIP builds. The CUDA/HIP targets are all AI assisted1 and they are mess of slopware due to the AI's hallucinating and me failing to control them through rigorous prompting.
Compiler (CC=) |
GPU target (GPU=) |
Standard targets | Standard OpenMP/offload checks | gpuOpt benchmark targets |
|---|---|---|---|---|
gcc or clang |
NONE |
library, test, bench, bench_omp, bug_report |
test_offload builds but detects host fallback; bench_omp is CPU-only |
none |
gcc or clang |
NVIDIA |
library, test, bench, bench_omp, bug_report |
test_offload, bench_omp |
openmp_bit_nocpu, cuda_gpu_bench, gpu_bench_csv |
gcc or clang |
AMD |
library, test, bench, bench_omp, bug_report |
test_offload, bench_omp |
openmp_bit_nocpu, hip_gpu_bench, gpu_bench_csv |
amdclang |
AMD |
library, test, bench, bench_omp, bug_report |
test_offload, bench_omp |
openmp_bit_nocpu, hip_gpu_bench, gpu_bench_csv |
icx |
INTEL |
library, test, bench, bench_omp, bug_report |
test_offload , bench_omp |
|
icx |
NONE |
library, test, bench, bench_omp, bug_report |
test_offload , bench_omp |
Important things to remember:
- The Makefile rejects
CC=amdclangwith a GPU target other thanAMD,CC=icxwith a GPU target other thanINTEL(orNONE). Combinations such asGPU=NONE,NVIDIAwill also be rejected - Native CUDA and HIP benchmarks deliberately compile their device source with
nvccandhipcc, respectively, rather than the value passed throughCCbut still useGPU=NVIDIAorGPU=AMDas build guards. To use these targets you will need to have a functional CUDA/HIP installation and you may want to specify the location in the Makefile_bench.mak makefile if things do not work. - On
gpuOpt,openmp_bit_nocpuis blocked whenGPU=NONE. Its Makefile guard tests whether a non-NONEtarget was selected; validate the experimental Intel path withtest_offloadon the target machine.
These are Make variables, not runtime environment variables and are listed alphabetically:
| Variable | Default | Effect |
|---|---|---|
APPLY_LTO |
1 |
Enables LTO for supported compilers; set to 0 to disable it. |
CC |
clang |
Selects the compiler, one of GCC , CLANG, AMDCLANG, ICX (case insensitive). |
CLANG_RUNTIME_RPATH |
1 |
Embeds the selected Clang OpenMP runtime path; set to 0 only when deliberately testing another runtime. |
GPU |
NONE |
CPU fallback, or NVIDIA, AMD, and experimental INTEL offload. |
GPU_ARCH |
Auto-detected when possible | NVIDIA sm_/compute_ and AMD gfx architecture list. |
LIBPOPCNT |
1 |
Enables bundled libpopcnt integration; set LIBPOPCNT=0 to disable it. |
OPENMP_GPU_IMPL |
Compiler specific | Selects the backend for GPU accelerated containerized setop counts. |
SIMD_DIAGNOSTICS |
0 |
Enables SIMD configuration diagnostics. |
USE_BUILTIN_POPCOUNT |
0 |
Enables GPU Hardware accelerated popcounts; the default is the WWG algorithm, but both gcc and the LLVM compilers recognize the pattern and replace the function with the hardware version. |
There are additional optimization flags for CPU and GPU that are detailed in the benchmark sections. Those are intended for extreme adaptation to a given environment; for the most part you can forget about them as I strived to find reasonable defaults that work in the average case. However you should feel free to experiment with those, and the sweeping scripts will give you a tool to do so semi-automatically
Important GPU Note:1 The major GPU optimization is the use of the algorithm for performing the setop_count operations. The two GPU algorithms packaged with the algorithm in the main branch are controlled via the OPENMP_GPU_IMPL flag. These two choices do not have tuning parameters, but others in the experimental gpuOpt branch do. As noted below if you do not specify OPENMP_GPU_IMPL, an appropriate value is selected for you based on the compiler you use to build the library.
The primary GPU selection is GPU= and the values that are currently supported are NONE (no offload) AMD, NVIDIA or INTEL (those are case insensitive). When building for a particular architecture, one needs optionally to specify the architecture using the GPU_ARCH configuration flag. As a reminder, NVIDIA architectures use sm_ or
compute_ prefixes; AMD architectures use gfx prefixes. The current Makefile has no equivalent GPU_ARCH selector for Intel targets (perhaps I can get an Arc Battlemage for Christmas?).
There is some support for automatic detection of the architecture in use through tools such as nvidia-smi, but this will likely fail if you have more than one architectures in your system, so it is best to specify manually.
Examples of building the library with offload support:
# NVIDIA OpenMP offload. Omit GPU_ARCH only when nvidia-smi can detect a target.
make CC=clang GPU=NVIDIA GPU_ARCH=sm_70
# AMD OpenMP offload.
make CC=clang GPU=AMD GPU_ARCH=gfx90a
# Experimental Intel OpenMP offload.
make CC=icx GPU=INTELWhen building for an offload target, the OPENMP_GPU_IMPL is a compile-time choice. The active main values are:
TEAM_PARALLEL_SIMDTRANSPOSED_TEAM_PARALLEL_SIMD
If you do not specify the parameter, the build system will use TEAM_PARALLEL_SIMD for the gcc and TRANSPOSED_TEAM_PARALLEL_SIMD LLVM compilers since these are the paths that lead to optimal code generation for each compiler after benchmarking. For the mast part you can forget about this choice unless you are into extreme benchmarking. If you are curious to see how these work, run the benchmarks built and run the bench_omp target with both choices for both clang and gcc.
During development of the library, I found that it is too easy for novices like me to build artefacts that do not offload. The hard lesson learned: GPU offload is opt-in, and OpenMP can fall back to the host when an image, plugin, driver, or device is unavailable Therefore I included the target test_offload that can validate offloat configurations. Setting OMP_TARGET_OFFLOAD=MANDATORY prevents an accidental host fallback from being.
build/test_offload <problem_size> [device_id] [benchmark_iterations]
The test first verifies integer, float, and double target calculations against
host results. When benchmark_iterations is positive, it also reports
memory-bound, compute-heavy, and device-resident benchmark modes. The
device-resident mode transfers its working data once and is useful for
separating steady-state device computation from host/device transfer overhead.
Use OMP_TARGET_OFFLOAD=MANDATORY for validation so an unintended host fallback
fails visibly:
# Correctness checks only.
OMP_TARGET_OFFLOAD=MANDATORY ./build/test_offload 100000 0
# Correctness checks plus 100 benchmark iterations.
OMP_TARGET_OFFLOAD=MANDATORY ./build/test_offload 100000 0 100The executable prints the detected target-device count, default device, and whether each probed target region ran on the host or a device. If no devices are reported or a target region runs on the initial device, rebuild for the intended architecture, verify the selected runtime plugin, and rerun with diagnostics.
You can build and run the offload diagnostic with all supported compilers as:
make test_offload CC=clang GPU=NVIDIA GPU_ARCH=sm_70
OMP_TARGET_OFFLOAD=MANDATORY ./build/test_offload 100000 0
make test_offload CC=clang GPU=AMD GPU_ARCH=gfx90a
OMP_TARGET_OFFLOAD=MANDATORY ./build/test_offload 100000 0
make test_offload CC=icx GPU=INTEL
OMP_TARGET_OFFLOAD=MANDATORY ./build/test_offload 100000 0During system updates one can break the GPU offload and we would like to have a run time option to check what is happening under the hood. The current Makefile exports quiet Clang runtime defaults:
LIBOMPTARGET_INFO=0LIBOMPTARGET_DEBUG=0
Override them while diagnosing device discovery, image loading, or launch behavior:
LIBOMPTARGET_INFO=16 LIBOMPTARGET_DEBUG=1 \
make test_offload CC=clang GPU=AMD GPU_ARCH=gfx90a
OMP_TARGET_OFFLOAD=MANDATORY LIBOMPTARGET_INFO=16 \
./build/test_offload 100000 0If you are using GCC to compile, use GOMP_DEBUG=1 instead.
NVIDIA builds accept sm_<target> or compute_<target> values. The Makefile
can derive sm_ values from nvidia-smi when GPU_ARCH is not supplied, but
an explicit target specification is nearly always the better choice (e.g. since the underlying compiler support and the driver support for architectures may not be identical):
make test_offload CC=clang GPU=NVIDIA GPU_ARCH=sm_70
OMP_TARGET_OFFLOAD=MANDATORY ./build/test_offload 100000 0
# Build multiple NVIDIA images when the installed toolchain supports them.
make test_offload CC=clang GPU=NVIDIA GPU_ARCH=sm_70,sm_80On a multi-GPU host, restrict visible devices before running the test. The
selected physical device normally becomes OpenMP logical device 0:
CUDA_VISIBLE_DEVICES=0 OMP_TARGET_OFFLOAD=MANDATORY \
./build/test_offload 1000000 0 1
CUDA_VISIBLE_DEVICES=1 OMP_TARGET_OFFLOAD=MANDATORY \
./build/test_offload 1000000 0 1Some LLVM/libomptarget and driver combinations behave more reliably with one
visible NVIDIA device at a time. If a multi-device run reports zero target
devices or fails during initialization, narrow CUDA_VISIBLE_DEVICES, keep the
program device ID at 0, and rerun the mandatory-offload check.
AMD targets use the gfx<target> spelling. The Makefile can query rocm-smi
when GPU_ARCH is omitted; inspect the system directly before pinning a target manually:
rocminfo | grep -Eo 'gfx[0-9a-f]+' | sort -u
rocm-smi --showproductname
make clean
make test_offload CC=clang GPU=AMD GPU_ARCH=gfx90a
OMP_TARGET_OFFLOAD=MANDATORY ./build/test_offload 4096 0If several AMD devices are visible or the OpenMP runtime selects the wrong one, restrict visibility and rerun the same mandatory-offload test:
ROCR_VISIBLE_DEVICES=0 OMP_TARGET_OFFLOAD=MANDATORY \
./build/test_offload 4096 0
# Alternative ROCr visibility variable used by some installations.
HSA_VISIBLE_DEVICES=0 OMP_TARGET_OFFLOAD=MANDATORY \
./build/test_offload 4096 0If the runtime cannot load an AMD target image, first make GPU_ARCH match the
actual gfx target and the installed LLVM/ROCm stack. The Makefile exposes
ROCM_PATH and ROCM_DEVICE_LIB_PATH for non-default installations; inspect
those paths before changing system libraries.
This historical recipe was used for a Radeon Pro W5500 (gfx1012) with LLVM
18. This was the card I bought for <120 dollars on eBAY to check the AMD paths during the GPU price bloodbath in 2026.
While the card is not supported via ROCM, code that compiles for the nearby gfx1010 target can be used to offload Bit with this card.
Similar workarounds are possible with other AMD cards, but I feel that you should treat the following note as as a record of one working environment that will allow you to repurpose a cheap GPU for real work, rather than a general solution. If you decide to try this with another card, please verify verify your own setup with OMP_TARGET_OFFLOAD=MANDATORY and drop me a note.
# Historical example: compile a nearby supported target, then present that
# target to the runtime for this shell only.
make test_offload CC=clang GPU=AMD GPU_ARCH=gfx1010
export HSA_OVERRIDE_GFX_VERSION=10.1.0
OMP_TARGET_OFFLOAD=MANDATORY ./build/test_offload 4096 0Some LLVM 18 installations also looked for a matching
libomptarget-amdgpu-*.bc filename for the physical target. System-wide aliases
change the compiler installation, so inspect the active toolchain first and
make that change only with an administrator and a rollback plan:
find "$ROCM_DEVICE_LIB_PATH" -maxdepth 1 -name 'libomptarget-amdgpu-*.bc' -printIn any case, prefer a ROCm/LLVM release that supports the actual target. If an alias is used, record it and retest after compiler, runtime, or driver updates.
make bug_report creates a timestamped directory under bug_reports/ with
the build log, configuration, preprocessed source, and, for a failing build,
an attempted backtrace. The target defaults to BUG_TARGET=bench_omp.
make bug_report CC=clang GPU=NVIDIA GPU_ARCH=sm_70 BUG_TARGET=bench_ompReview build.log, config.txt, backtrace.txt, and
src-bit.preprocessed.i in the generated report directory.
The report script records the selected compiler, target list, effective flags, and the failing build output. It also adds compiler-specific reproduction artifacts:
- GCC:
gcc-v.txt,gcc-repro-command.txt, andgcc-save-temps.log. - Clang: compiler crash reproducers when Clang emits them under the configured report directory.
If the target build fails and gdb is available, the script reruns the target
under batch gdb and writes a full backtrace to backtrace.txt. When the build
succeeds, that file explicitly records that no failing process was available.
Temporary preprocessing and intermediate compiler artifacts in build/ are
removed after collection.
Usage is straightforward and follow's Hanson's clean separation of interfaces and implementations.
Just include bit.h (the API) and link against build/libbit.so or build/libbit.a after
building the library to your application and things should work. The documentation of the API below is deliberately kept at a minimum: the header file should be consulted for the precise order of arguments and their types with the examples included below showing the implementation of common use cases.
Bit_T and Bit_DB_T are Abstract Data Types (ADT) and one works with them through their public interface.
A C structure typedef SETOP_COUNT_OPTS is used to control the CPU and GPU OpenMP environment. I exposed the implementation of this structure to assist with the development of interfacing code when offloading to the GPU and when multi-threading in the CPU.
| Public type | Purpose |
|---|---|
Bit_T |
One fixed-capacity mutable bitset. |
Bit_DB_T |
A packed collection of equally sized bitsets. |
SETOP_COUNT_OPTS |
CPU thread count plus GPU device-residency controls for all-pairs container counts. |
| Family | Functions | Contract |
|---|---|---|
| Lifecycle and storage | Bit_new, Bit_load, Bit_free, Bit_extract, Bit_buffer_size |
Create library-owned storage, borrow caller storage, release the wrapper, or copy bytes out. |
| Properties | Bit_length, Bit_count |
Return fixed capacity or population count. |
| Single-bit mutation | Bit_bset, Bit_bclear, Bit_get, Bit_put |
Access one indexed bit; Bit_put returns its previous value. |
| Bulk/range mutation | Bit_aset, Bit_aclear, Bit_set, Bit_clear, Bit_not |
Apply an index array or an inclusive [lo, hi] range. |
| Callback traversal | Bit_map |
Visit indexes from left to right with the current bit value and caller closure. |
| Comparisons | Bit_eq, Bit_leq, Bit_lt |
Compare equal-length bitsets. |
| Allocating set operations | Bit_union, Bit_inter, Bit_diff, Bit_minus |
Return a newly allocated result that must be passed to Bit_free. |
| Count-only set operations | Bit_union_count, Bit_inter_count, Bit_diff_count, Bit_minus_count |
Return the result population count without constructing a bitset. |
Set-operation names follow the implementation and tests and require one left and one right operand:
| Operation | Expression | Example for |
|---|---|---|
union |
||
inter |
||
diff |
||
minus |
For these individual-bitset set operations, one NULL operand is interpreted as
the empty set. Thus Bit_union(set, NULL) and Bit_minus(set, NULL) return a
copy of set, while Bit_inter(set, NULL) returns an empty bitset. Passing
both operands as NULL is invalid. This convention follows those adopted by Hanson in his book, and frankly correspond to how these operations work in Boolean algebra.
The packed container API consists of library functions and a smaller set of macros. The macros are very helpful for meta-programming with the C preprocessor and for extending the API of the library itself.
| Family | Functions | Contract |
|---|---|---|
| Lifecycle and storage | BitDB_new, BitDB_load, BitDB_free |
Create or borrow storage for a fixed number of equal-length bitsets. |
| Properties and counts | BitDB_length, BitDB_nelem, BitDB_count_at, BitDB_count |
Query shape or population counts; BitDB_count allocates an array the caller frees. |
| Element access | BitDB_get_from, BitDB_put_at |
Copy one element out as a new Bit_T, or copy one equal-length Bit_T into the container. |
| Buffer access | BitDB_extract_from, BitDB_replace_at |
Copy one element to or from a caller-provided buffer. |
| Clearing | BitDB_clear_at, BitDB_clear |
Clear one element or the complete packed container. |
| Allocating all-pairs counts | BitDB_{inter,union,diff,minus}_count_{cpu,gpu} |
Allocate and return an int matrix; the caller uses free. |
| Caller-owned all-pairs counts | BitDB_{inter,union,diff,minus}_count_store_{cpu,gpu} |
Write into a caller-provided int matrix. |
| Target convenience macros | BitDB_{inter,union,diff,minus}_count(..., cpu|gpu) |
Select the corresponding direct CPU or GPU function in C source. |
| Target convenience macros | BitDB_{inter,union,diff,minus}_count_store(..., cpu|gpu) |
Select the corresponding direct CPU or GPU function in C source. |
| Build diagnostics | print_Bit_configuration |
Print the compiled tile, buffer, popcount, and OpenMP configuration. |
Container binary operations require two non-NULL containers (also denoted as right and left in the documentation) whose bitsets have
the same length. If the left and right containers hold diff and
minus retain the XOR and left AND-NOT meanings shown above.
The header also defines target-selecting _count _count_store macros using the same order of arguments as the direct functions, except the last:
BitDB_inter_count(left, right, results, options, cpu);
BitDB_inter_count_store(left, right, results, options, cpu);The final token may be cpu or gpu. Direct _store_cpu and _store_gpu functions remain useful for foreign-function interfaces and callers that cannot
use C preprocessor macros.
Bit was written with the explicit intention to facilitate flexible storage ownership: there are functions in the API that own bitsets and containers, and others that use
externally allocated buffers for the countainers.
| Value | Owner and release rule |
|---|---|
Bit_new / BitDB_new result |
Library owns storage. Bit_free / BitDB_free releases it, sets the handle to NULL, and returns NULL. |
Bit_load / BitDB_load result |
Caller owns storage. The matching free routine destroys the wrapper, sets the handle to NULL, and returns the borrowed pointer for the caller to free or reuse. |
BitDB_get_from or an allocating Bit_* operation |
Caller owns the new Bit_T and releases it with Bit_free. |
BitDB_count or non-store container count |
Caller owns the returned int * and releases it with free. |
_store_ container count |
Caller allocates and retains the result buffer. |
Bitsets and containers owned by the library are zero initialized by default. The implementation uses assert for most pointer, index, length, allocation,
and equal-shape checks. Defining NDEBUG during compilations removes those checks. However the library cannot recover from segfault from an an undersized external buffer or invalid index with or without NDEBUG. Since the library cannot determine the allocation size behind a raw
pointer, so callers must size borrowed and extraction buffers correctly as we illustrate in the examples below.
This is a technical note that should not affect normal users, and is probably an overkill for many modern processors. Internally Bit does enforce strict alignment of the memory buffers it owns, but makes no assumptions about borrowed storage (though it will do a runtime check to optimize the execution path of logical operations and counts for such buffers). The rules are the following:
- Bit_T Allocation: When allocating a single bitset via Bit_new, the library uses the standard C calloc function. Because it relies on calloc, it receives the default memory alignment provided by the host system's standard library (typically 8 or 16 bytes), without enforcing any custom strict alignment.
- Bit_DB_T Allocation: When allocating a packed database of bitsets via BitDB_new, the library explicitly enforces stricter alignment using a custom internal allocator. The required alignment depends on the system architecture that the library is build for: 32 bytes for 32-bit architectures and 64 bytes for 64-bit architectures. This allows us to use aligned load/stores which may be faster in some older processors. In any case maintaining the aligned code path is no match for the C preprocessor which provides a unified internal API.
- Borrowed External Storage: When you load an externally allocated buffer using Bit_load or BitDB_load, the library interacts with the borrowed storage in the following ways:
- Minimum Padding Requirements: The library explicitly expects the external buffer size to be padded to the next multiple of 8 bytes (the size of a uint64_t) to prevent out-of-bounds access during scalar operations.
- Dynamic Alignment Dispatch: The library does not strictly force the borrowed storage to match its ideal internal 32-byte or 64-byte alignment. Instead, it checks the external pointer's alignment at runtime during vectorized database set operations.
- Vectorization Fallback: If the external buffer meets the optimal alignment checks (64-byte alignment on 64-bit systems, or 8-byte alignment on 32-bit systems), the CPU executes fast aligned SIMD loads. If the external buffer is unaligned, the library safely falls back to unaligned SIMD instructions to execute the operations.
At some point, I should probably take down the machinery because everyone is telling me that unaligned loads carry no penalty in our time.
This is a straightforward example showing the creation of two bitsets with sufficient storage for 128 bits, setting individual bits, doing a bitwise and for an overlap and computing the cardinality of the result.
#include "bit.h"
#include <stdio.h>
int main(void) {
Bit_T left = Bit_new(128);
Bit_T right = Bit_new(128);
Bit_bset(left, 3);
Bit_bset(left, 64);
Bit_bset(right, 64);
Bit_bset(right, 100);
Bit_T overlap = Bit_inter(left, right);
printf("intersection count: %d\n", Bit_count(overlap));
Bit_free(&overlap);
Bit_free(&right);
Bit_free(&left);
return 0;
}The distinction between diff and minus is easier to see with actual bits.
For 1 and 7,
whereas left set difference keeps only 1:
#include "bit.h"
#include <assert.h>
int main(void) {
Bit_T left = Bit_new(128);
Bit_T right = Bit_new(128);
int left_bits[] = {1, 3, 5};
int right_bits[] = {3, 5, 7};
Bit_aset(left, left_bits, 3);
Bit_aset(right, right_bits, 3);
Bit_T symmetric = Bit_diff(left, right);
Bit_T remainder = Bit_minus(left, right);
assert(Bit_count(symmetric) == 2);
assert(Bit_get(symmetric, 1) && Bit_get(symmetric, 7));
assert(Bit_count(remainder) == 1 && Bit_get(remainder, 1));
Bit_free(&remainder);
Bit_free(&symmetric);
Bit_free(&right);
Bit_free(&left);
return 0;
}The corresponding Bit_*_count functions compute the same population counts without forming the intermediate bitset.
Bit_load and BitDB_load borrow caller-owned storage. The caller must
allocate enough padded storage and later free the pointer returned by the
matching free routine. The function Bit_buffer_size returns the minimum number of bytes needed to store a bitset of a requested size/capacity (in this case 130). For performance the storage used to store a bitset is the closest to the requested size integer multiple of 64.
#include "bit.h"
#include <stdlib.h>
int main(void) {
const int length = 130;
const int bytes = Bit_buffer_size(length);
void *storage = calloc(1, (size_t)bytes);
if (storage == NULL) {
return 1;
}
Bit_T borrowed = Bit_load(length, storage);
Bit_bset(borrowed, 129);
/* Bit_free returns storage for externally loaded bitsets. */
free(Bit_free(&borrowed));
return 0;
}For a bitset allocated with Bit_new, Bit_free(&bitset) frees its internal
storage and returns NULL. BitDB_free follows the same ownership rule for
packed containers. Bit_extract copies a bitset into caller-provided storage
and returns the number of bytes written.
Borrowed container storage is the per-bitset buffer size multiplied by the
number of elements. BitDB_free returns that original pointer rather than
freeing it behind the caller's back. The size of the needed external buffer can similarly be obtained by multiplying the number of bitsets in the container (variable count in the snippet below) and the size in bytes needed to store a library of a given number of bits (this is the value returned by Bit_buffer_size):
#include "bit.h"
#include <stdlib.h>
int main(void) {
const int length = 130;
const int count = 4;
const size_t bytes = (size_t)Bit_buffer_size(length) * count;
void *storage = calloc(1, bytes);
if (storage == NULL) {
return 1;
}
Bit_DB_T borrowed = BitDB_load(length, count, storage);
Bit_T seed = Bit_new(length);
Bit_bset(seed, 129);
BitDB_put_at(borrowed, 0, seed);
Bit_free(&seed);
storage = BitDB_free(&borrowed);
free(storage);
return 0;
}The ADT Bit_DB_T stores equally sized bitsets in a packed container. You can create such a
container with BitDB_new(length, count) and fill it with individual bitsets BitDB_put_at.
The example below creates a container with 2 elements of capacity of 128 bits, then allocates a bitset of the same capacity (seed), sets the 9th bit and puts it at the first index of the container. Then we extract the bitset at the first index of the container and verify that the bit at the 9th position is set.
#include "bit.h"
#include <assert.h>
#include <stdlib.h>
int main(void) {
const int length = 128;
Bit_DB_T database = BitDB_new(length, 2);
Bit_T seed = Bit_new(length);
Bit_bset(seed, 9);
BitDB_put_at(database, 0, seed);
Bit_T copy = BitDB_get_from(database, 0);
assert(Bit_get(copy, 9) == 1);
void *buffer = calloc(1, (size_t)Bit_buffer_size(length));
if (buffer == NULL) {
Bit_free(©);
Bit_free(&seed);
BitDB_free(&database);
return 1;
}
BitDB_extract_from(database, 0, buffer);
BitDB_clear_at(database, 0);
assert(BitDB_count_at(database, 0) == 0);
BitDB_replace_at(database, 1, buffer);
assert(BitDB_count_at(database, 1) == 1);
free(buffer);
Bit_free(©);
Bit_free(&seed);
BitDB_free(&database);
return 0;
}In this example we initialize two containers, fill them with individual bitsets and then perform a population count in the CPU. The assignment SETOP_COUNT_OPTS options = {.num_cpu_threads = 2}; is used to control the number of OpenMP threads we will task for this job.
#include "bit.h"
#include <stdio.h>
#include <stdlib.h>
int main(void) {
Bit_T seed = Bit_new(128);
Bit_bset(seed, 10);
Bit_bset(seed, 65);
Bit_DB_T queries = BitDB_new(128, 2);
Bit_DB_T references = BitDB_new(128, 3);
for (int index = 0; index < BitDB_nelem(queries); ++index) {
BitDB_put_at(queries, index, seed);
}
for (int index = 0; index < BitDB_nelem(references); ++index) {
BitDB_put_at(references, index, seed);
}
SETOP_COUNT_OPTS options = {.num_cpu_threads = 2};
int *counts = BitDB_inter_count_cpu(queries, references, options);
if (counts != NULL) {
printf("first intersection count: %d\n", counts[0]);
}
free(counts);
BitDB_free(&references);
BitDB_free(&queries);
Bit_free(&seed);
return 0;
}Counting the cardinality of containers is an important data intensive application of Bit. The function BitDB_count(container) returns a newly allocated array containing one
population count per stored bitset for the container of interest. The non-store container count functions
(BitDB_inter_count_cpu, BitDB_union_count_gpu, and so on) also return a newly allocated result array. In both cases, the caller is responsible to free the returned array of counts, i.e. the library will not manage the storage for you.
The result array for a binary container operation has
BitDB_nelem(left) * BitDB_nelem(right) elements in row-major order:
result[left_index * BitDB_nelem(right) + right_index]Use _store_ variants when the caller has previously allocated the research buffer :
size_t result_count = (size_t)BitDB_nelem(queries) * BitDB_nelem(references);
int *results = malloc(result_count * sizeof(*results));
if (results != NULL) {
BitDB_inter_count_store_cpu(queries, references, results, options);
free(results);
}These _store_ variants were created to interface with external libraries and dynamically typed languages (e.g. Perl) that use their own custom allocators. The typical C user should probably never have to use them within C.
The macros BitDB_inter_count, BitDB_union_count, BitDB_diff_count, and
BitDB_minus_count select a cpu or gpu function at compile time. Use the
function forms when linking against a shared library from code that cannot see
the macros. However I strongly encourage you to use the macro interface when coding in C.
The SETOP_COUNT_OPTS that provides control options for CPU execution and advanced GPU data-residency decisions for containerized operations. This is structure in C that is declared in the header of the Bit library as:
typedef struct {
int num_cpu_threads;
int device_id;
bool upd_1st_operand;
bool upd_2nd_operand;
bool release_1st_operand;
bool release_2nd_operand;
bool defer_counts_transfer;
bool release_counts;
enum {
TRANSPOSED_TEAM_PARALLEL_SIMD = 0, // transpose + team parallel + SIMD
SHARED_TILE_ILP = 1, // Shared tile + Instruction level parallelism
} algorithm; // reserved; current library dispatch does not read this field
} SETOP_COUNT_OPTS;The meaning of the fields is explained in the table below:
| Field | Current behavior |
|---|---|
num_cpu_threads |
A positive value selects the CPU OpenMP thread count; a nonpositive value uses the OpenMP runtime maximum. |
device_id |
Selects the OpenMP target device for GPU calls; ignored by CPU calls. |
upd_1st_operand, upd_2nd_operand |
Refresh an operand that is already present on the selected device. An absent operand is mapped on first use regardless of the update flag. |
release_1st_operand, release_2nd_operand |
Decreases the reference counter of the corresponding device mapping after the operation. Leave false only when a later call deliberately reuses that mapping. Setting true will not cause the de-allocation of the buffers if their reference counters is not zero. |
defer_counts_transfer |
Defers the transfer of the counts from the GPU to the host e.g. when further processing should be done. |
release_counts |
Decrements the reference counter of the device mapping for counts if true; may lead to de-allocation of the mapping on the device if this was the last reference to this buffer for the entire program. |
algorithm |
Present in the public structure, but not read by the current library dispatch. It will become runtime kernel selector (at some point in the future). |
If one conceptualizes the right sided container as a fixed, reference database of bits, a repeated query (left side container) workflow can keep an unchanged reference container mapped, refresh each modified query container, and release both operand mappings on the final call. That optimization also creates a responsibility: if host data changes while its update flag is false, the device is allowed to keep using the older mapped contents.
The main justification of allowing device resident bitset is that remory allocations and de-allocations in the CPU are very costly, so it pays handsomely in terms of performance if one did not have to move things around unless absolutely necessary. Consider for example the scenario in which one has 3 containers, each of size N that must be matched against a single container of size M. The device has enough memory to fit a single container of size N, another one of size N, and the results of size N * M. In this case,
SETOP_COUNT_OPTS opts_1to2 = {
.device_id = -1,
.upd_1st_operand = true,
.upd_2nd_operand = false,
.release_1st_operand = false,
.release_2nd_operand = false,
.release_counts = false
};instructs the mapper to update the first operand in the GPU when iterating over the first two containers of size N. To process the final container, one can use
SETOP_COUNT_OPTS opts_3 = {
.device_id = -1,
.upd_1st_operand = true,
.upd_2nd_operand = false,
.release_1st_operand = true,
.release_2nd_operand = true,
.release_counts = true
};which will update the first operand in the GPU and release all the buffers on the device upon exit. Since OpenMP manages device memory regions using reference counting, releasing of the regions amounts to decreasing the reference counters for each of the regions. Regions that are no longer referenced will be automatically de-allocated.
Build the CPU benchmark suite:
make bench_omp GPU=NONEFor GPU=NONE, this creates:
build/openmp_bit_nogpubuild/openmp_bit_containerbuild/cpu_param_sweep
build/cpu_param_sweep is the four-argument C benchmark used by the broad CPU
parameter workflow. It is distinct from scripts/cpu_param_sweep.pl, the
JSON-driven Perl coordinator described in Automation Scripts.
make bench additionally builds build/benchmark.
The OpenMP benchmark command lines are defined by their source files:
build/openmp_bit <size> <number-of-bitsets> <number-of-reference-bitsets> <max-threads> [gpu-id]
build/openmp_bit_nogpu <size> <number-of-bitsets> <number-of-reference-bitsets> <max-threads>
build/openmp_bit_container <bits> <left-bitsets> <right-bitsets> <threads> <repetitions>
With a non-NONE GPU target, make bench_omp also builds build/openmp_bit.
The fourth argument of openmp_bit_nogpu is a maximum CPU thread count.
The OpenMP benchmark is a practical all-pairs intersection-count comparison:
it searches query bitsets against a reference collection and reports the
largest observed intersection count. The mixed benchmark establishes serial
baselines, sweeps OpenMP thread counts for the ordinary bitset representation,
and repeats the workload with packed Bit_DB_T containers. With a configured
offload target, it also exercises container operations through the GPU path.
openmp_bit_nogpu keeps the serial, OpenMP, and packed-container CPU portions
while excluding GPU execution. Use it when characterizing CPU tiles, OpenMP
scheduling, affinity, and memory locality without an offload runtime in the
measurement.
The speedup values use the run's first serial measurement as their baseline. They are useful for comparing configurations on the same machine and workload; bitset shape, topology, compiler, OpenMP runtime, memory placement, and GPU strategy all change the result when that context changes.
Makefile_bench.mak is an experimental extension for GPU-only OpenMP kernels
and native CUDA/HIP benchmarks. It exists only on gpuOpt and is not the public
library build interface. Switch branches before using any command in this
section:
git switch gpuOpt
# GPU-only OpenMP benchmark.
make -f Makefile_bench.mak openmp_bit_nocpu \
CC=clang GPU=NVIDIA GPU_ARCH=sm_70
# Native benchmark backends.
make -f Makefile_bench.mak cuda_gpu_bench GPU=NVIDIA GPU_ARCH=sm_70
make -f Makefile_bench.mak hip_gpu_bench GPU=AMD GPU_ARCH=gfx90a
# Run the selected native backend and write CSV/log output.
make -f Makefile_bench.mak gpu_bench_csv GPU=NVIDIA GPU_ARCH=sm_70The GPU-only executable accepts:
build/openmp_bit_nocpu <size> <number-of-bitsets> <number-of-reference-bitsets> <gpu-iterations> [gpu-id]
Its fourth argument is GPU iterations, not a CPU thread count.
OPENMP_GPU_IMPL is also a compile-time choice for the experimental GPU
benchmark, not a public runtime option. The active gpuOpt values are:
TEAM_PARALLEL_SIMDTRANSPOSED_TEAM_PARALLEL_SIMDSHARED_TILE_ILPTRANSPOSED_TILED_GEMM
The experimental SHARED_TILE_ILP and TRANSPOSED_TILED_GEMM variants are
available ONLY to the openmp_bit_nocpu testbed. The FAISS GPU comparator
(openmp_bit_gpu_FAISS_comp) always uses the library's built-in kernel
(TEAM_PARALLEL_SIMD or TRANSPOSED_TEAM_PARALLEL_SIMD, selected by
OPENMP_GPU_IMPL at library build time).
For example:
make -f Makefile_bench.mak openmp_bit_nocpu \
CC=clang GPU=NVIDIA GPU_ARCH=sm_70 \
OPENMP_GPU_IMPL=TRANSPOSED_TILED_GEMMThese kernels and native CUDA/HIP paths are experimental. Build for the target compiler and architecture, confirm agreement with the CPU reference, and then measure the workload you care about.
Two thin, public-API-only C benchmarks mirror the Python FAISS scripts and are
built by the MAIN Makefile (not Makefile_bench.mak). Both consume only the
public bit.h interface and link libbit (which now also carries the private
top-k selection object); there is no GPU_COMPILE_TOPK flag -- top-k placement
is fixed at build time: host for the CPU executable, device for the GPU
executable.
| C executable | Python counterpart | top-k runs on |
|---|---|---|
openmp_bit_cpu_FAISS_comp |
scripts/faiss_cpu_benchmark.py |
host |
openmp_bit_gpu_FAISS_comp |
scripts/faiss_gpu_benchmark.py |
device |
Build them (the GPU target requires GPU != NONE):
make CC=clang GPU=NVIDIA GPU_ARCH=sm_70 \
openmp_bit_cpu_FAISS_comp openmp_bit_gpu_FAISS_compUsage:
build/openmp_bit_cpu_FAISS_comp <size> <num-bitsets> <num-ref-bitsets> <top-k> <iterations> [threads] [--no-verify]
build/openmp_bit_gpu_FAISS_comp <size> <num-bitsets> <num-ref-bitsets> <top-k> <gpu-iterations> [gpu-id] [--no-verify]
The reported per-iteration end-to-end time (... OpenMP Filter Total and the
SEARCH SUMMARY E2E Avg Time (ns)) is a single wall-clock span around the
whole search call -- the count operation plus the top-k selection plus result
handling -- matching the Python scripts' time.perf_counter_ns() bracket around
index.search(). The scalar best-score reduction over the returned top-k scores
is computed outside that span (untimed), exactly as the Python scripts compute
distances.min() after their timed call. The comparators also print the two
component spans separately (GPU/CPU Algorithm Timing = count call only;
Filter Timings = top-k only) for diagnosis, but the sweep harvests the
end-to-end Filter Total line.
Both comparators compute an independent CPU all-pairs reference and cross-check
the device/library results against it (the agreements/disagreements lines).
That reference uses a host OpenMP parallel for and intentionally saturates all
cores during the setup phase. Pass --no-verify to skip the reference
computation and the cross-check: the benchmark then presents like the Python
FAISS GPU script, with the host mostly idle and no independent reference. The
flag is opt-in; the default behavior (verify on) and the sweep
(scripts/faiss_compare.pl) are unchanged.
scripts/faiss_compare.pl runs a small (non-exhaustive) Cartesian comparison
of four builds : native FAISS CPU, native FAISS GPU, and the two Bit OpenMP
comparators. The sweep is parameterized by the schema
scripts/benchmark_config_faiss.json. It harvests PER-ITERATION end-to-end
timings (each cell runs a fixed number of iterations; this is 100 by default, but can be changed into the JSON configuration file) into a long-format CSV.
The default run grid sweeps bitset size x top_k x database size (num_refs in
{10000, 100000, 1000000}).
The OpenMP builds are independent of any FAISS installation; only the two
native FAISS builds need the FAISS conda environment (resolved automatically
via conda run -n faiss_env when the base python cannot import FAISS).
Before the grid runs, faiss_compare.pl performs a single make -B that pins
libbit and the comparators to a known configuration, so each result set is
reproducible and self-describing. This is driven by the build block of
scripts/benchmark_config_faiss.json:
gpuis required. Set it to"NONE"for a CPU-only comparison, or to a target such as"NVIDIA","AMD", or"INTEL"to also build the GPU comparator. The value is passed verbatim tomake GPU=....gpu_archis optional; leave it blank to let the Makefile auto-detect the architecture (nvidia-smi/rocmsmi).- The remaining keys (
cc,cpu_tile,bitvector_tile,buffer_size,outer_row_num,outer_col_num,outer_vec_blk,libpopcnt,apply_lto,use_builtin_popcount) map to the host-build Make variables. Blank or empty values are omitted so the Makefile defaults take over; set a value to pin it (e.g."libpopcnt": "1").
Only the comparators relevant to the target are built:
openmp_bit_cpu_FAISS_comp always, plus openmp_bit_gpu_FAISS_comp when
gpu != "NONE". When gpu == "NONE", the GPU builds (bit_gpu, faiss_gpu)
are dropped from the run even if stale binaries exist. The build aborts the
whole comparison on failure, printing the tail of the build output.
The effective configuration actually passed to make (only the non-omitted
variables) is recorded in benchmark_FAISS/build_config.txt alongside the
results, so the exact build behind any CSV can be reproduced.
For a GPU comparison, set gpu (and optionally pin the popcount path and
compiler); everything left blank uses the Makefile defaults:
"build": {
"gpu": "NVIDIA",
"gpu_arch": "",
"libpopcnt": "1",
"cc": "clang"
}This runs make -B openmp_bit_cpu_FAISS_comp openmp_bit_gpu_FAISS_comp GPU=NVIDIA LIBPOPCNT=1 CC=clang (GPU architecture auto-detected) and enables
the bit_gpu and faiss_gpu builds. A failed build (for example, an
unsupported cc) stops the sweep before any benchmark runs.
# Run from ANY directory -- the script auto-detects the repo root, builds the
# comparators itself (make -B), and writes results under <repo-root>/benchmark_FAISS/.
# Full grid: bitset sizes 1024..65536 x top_k 64..2048 x num_refs
# 10000..1000000, 100 iterations each.
perl scripts/faiss_compare.pl --config scripts/benchmark_config_faiss.json
# Quick smoke run / dry run.
perl scripts/faiss_compare.pl --bitset_bits 1024 --top_k 64 --num_refs 10000,100000 --dry_runThe script is working-directory agnostic: it locates the repository root
from its own path, chdirs there, builds the comparators with
make -C <root> -B ..., and runs each target. Results are always written to
<repo-root>/benchmark_FAISS/ (alongside benchmark_CPU_params/ and
benchmark_GPU_params/), regardless of the directory you invoke it from. GPU
visibility for the GPU builds is derived from build.gpu
(NVIDIA->CUDA_VISIBLE_DEVICES, AMD->ROCR_VISIBLE_DEVICES, INTEL->none);
set system_env.gpu_visible_env in the JSON (e.g. "CUDA_VISIBLE_DEVICES=1")
to override. The host comparator (bit_cpu) runs with no GPU-visibility prefix.
Outputs (all under <repo-root>/benchmark_FAISS/):
faiss_compare_results.csv-- long-format per-iteration timings.faiss_compare_summary.csv-- per-cell mean/median/sd (written by the R step).faiss_compare_report.pdf-- boxplots of the per-iteration distributions.
Regenerate the report with:
Rscript scripts/faiss_compare_visualize.RThe openmp_bit_nocpu strategy selector and the experimental Makefile_bench.mak
targets belong to gpuOpt. The FAISS comparators
(openmp_bit_cpu_FAISS_comp, openmp_bit_gpu_FAISS_comp) are part of the
cross-branch shared FAISS suite and are built by the standard Makefile on all
branches (see FAISS C comparators).
The GPU-only benchmark is a safe place to experiment with container kernels without changing the standard library path. It creates random query and reference bitsets, computes a CPU reference result, performs a warm-up run, and then reports several timing views:
- GPU Algorithm Timing: kernel-focused time for the intersection-count operation.
- GPU Algorithm + PCIe Timings: end-to-end device-path time, including staging and transfer work measured by the benchmark.
- GPU Transpose Timings: layout preparation time for strategies that need a transposed representation.
- CPU Overhead Timings: host-side setup, dispatch, and synchronization work around device execution.
- Per-Iteration Data Movement Breakdown: query upload and result download volume; the resident reference database is reported separately and excluded from the repeated-transfer payload.
- Agreement/Disagreement Counts: comparison with the CPU reference. Treat any disagreement warning as a correctness failure to investigate before interpreting throughput.
- Estimated Throughput: both kernel-focused and total-operation rates, with the latter representing the user-visible combination of staging, transfers, and computation.
Use GPU Algorithm Timing to compare kernel and layout choices. Use the total-operation view when transfers are part of the workload, and check the movement breakdown when a large result matrix makes download time dominant.
The strategy selector changes compile-time layout and parallelization choices.
TEAM_PARALLEL_SIMD is the baseline team/parallel/SIMD approach;
TRANSPOSED_TEAM_PARALLEL_SIMD prepares a column-oriented reference layout;
SHARED_TILE_ILP combines shared tiles with instruction-level parallelism; and
TRANSPOSED_TILED_GEMM is a further experimental tiled formulation.
These names describe implementation choices, not a ranking. Earlier GCC 12/13 and Clang experiments produced different correctness and performance outcomes for the same transposed and shared-tile structures. For each candidate, keep the compiler, architecture, and command with the result; check CPU agreement first, then compare timings. The GPU-only layer keeps that exploration separate from the library's public execution path.
All benchmark producers anchor their output to the repository root, not the directory you invoke them from. You can run any of them from any working directory and the artifacts always land in the same place:
| Producer | Artifacts land in | Mechanism |
|---|---|---|
scripts/faiss_compare.pl |
<repo-root>/benchmark_FAISS/ |
Detects the repo root from the script's own path and chdirs into it. |
scripts/faiss_compare_visualize.R |
reads/writes <repo-root>/benchmark_FAISS/ |
Resolves the root via this.path::this.dir(). |
scripts/cpu_param_sweep.pl |
<repo-root>/benchmark_CPU_params/ (or <repo-root>/<out_dir> if --out_dir is overridden) |
Detects the repo root from the script's own path and chdirs into it; a relative --config is resolved against the original working directory first. |
scripts/cpu_profiling_analytics.R |
reads <repo-root>/benchmark_CPU_params/ |
Searches a short list of candidate locations, preferring the repo root. |
scripts/sweep_cpu_tuning.pl |
<repo-root>/tuning-results/ |
Requires CWD = repo root (aborts otherwise). |
scripts/run_numa_sweeps.sh |
<repo-root>/tuning-results/ (one run tag per experiment) + tuning-results/numa-compare-<timestamp>.md |
Resolves the root from BASH_SOURCE and cds into it; four experiments plus a cross-experiment table. |
The relative out_dir in benchmark_config_cpu.json (default
benchmark_CPU_params) and the benchmark_FAISS output directory are therefore
always interpreted relative to the repository root. To redirect a run elsewhere,
pass an absolute --out_dir to cpu_param_sweep.pl.
The main CPU tools are complementary stages of investigation, not
interchangeable benchmark front ends:
| Stage | Tool and benchmark | Configuration | Measurements and artifacts |
|---|---|---|---|
| Broad discovery | scripts/cpu_param_sweep.pl -> build/cpu_param_sweep |
JSON matrices and command-line overrides | External wall-clock timings for ordinary-bitset and packed-container paths, plus host telemetry, CSV, and raw logs under benchmark_CPU_params/. |
| Profiling for focused tuning | scripts/sweep_cpu_tuning.pl -> build/openmp_bit_container |
Environment-variable matrix | Repeated packed-container timings, CPU affinity, and perf stat profiles under tuning-results/. |
| Dual-socket analysis | scripts/run_numa_sweeps.sh -> sweep_cpu_tuning.pl |
Four comparable topology/memory-policy cases | Socket-local baselines plus first-touch and interleaved dual-socket results in the focused tuner's artifact layout. |
Use the broad sweep to find candidates across compiler, kernel, workload, and placement choices. Use the focused profiler when you need further insights before locking the configuration for a specific architecture, then use the NUMA runner when the question is memory placement on a dual-socket host and how this affects performance (one worker per physical core by default -- see Why SMT is off by default). The stages can be used independently when that is the only question being investigated.
These scripts and their configuration live on main. The benchmark sources
and Makefile targets may also exist on another branch without the coordinating
scripts being present there.
scripts/cpu_param_sweep.pl is the JSON-driven coordinator; it is distinct
from build/cpu_param_sweep, the C benchmark executable that it rebuilds and
invokes. It requires --config and uses
scripts/benchmark_config_cpu.json for build matrices, runtime matrices,
telemetry, commands, and output parsing.
The script is working-directory agnostic: it locates the repository root
from its own path and chdirs there, so it can be invoked from any directory
and always builds/runs against <repo-root> and writes under
<repo-root>/benchmark_CPU_params/. Both of these invocations are equivalent:
git switch main
# From the repository root.
perl scripts/cpu_param_sweep.pl --config scripts/benchmark_config_cpu.json
# Or from the scripts/ directory (a relative --config is resolved against the
# directory you invoke it from).
cd scripts
perl ./cpu_param_sweep.pl --config ./benchmark_config_cpu.jsonThe framework is schema-driven: the JSON file is the source of truth for the
build matrix, run matrix, system environment, telemetry, and output parser.
The Perl engine dynamically enumerates the build/run matrix, gathers telemetry,
interpolates commands, and runs each configured instance. It is generic within
the three supported blocks, build_matrix, run_matrix, and system_env; the
current configuration still supplies the benchmark-specific build_cmd,
run_cmd, and parser contract.
benchmark_config_cpu.jsondefines compiler and Make-variable combinations, workload sizes, threads, NUMA policies, command templates, output locations, telemetry extractors, and CSV columns.cpu_param_sweep.plreads that configuration at runtime, creates its Cartesian build/run space, adds command-line bindings for configuration keys, and executes each workload using the configuredtaskset,numactl, and scheduling settings.
The current configuration requires Perl 5.36, numactl, taskset, and the
selected compiler/toolchain. The active runner imports Algorithm::Loops,
IPC::Run, Log::Log4perl, and JSON::PP; ensure the required modules are
available before starting a sweep.
perl -MAlgorithm::Loops -MIPC::Run -MLog::Log4perl -MJSON::PP -e 1It writes CSV and raw logs beneath benchmark_CPU_params/ by default.
The runner creates options from every key in build_matrix, run_matrix, and
system_env. A value passed on the command line overrides the corresponding
JSON value; comma-separated matrix values become a smaller sweep.
cd scripts
# Override the configured output directory and thread-count matrix.
perl ./cpu_param_sweep.pl \
--config ./benchmark_config_cpu.json \
--out_dir ../custom_results \
--threads 1,2,4,8,16,20The JSON uses underscore-style option names, such as out_dir, because those
are the configuration keys consumed by GetOptions.
taskset (a system_env scalar) and threads (a run_matrix list) accept
machine-portable sentinel values so a single configuration works across hosts
with different core counts. The resolved logical-core count is the value
reported by nproc (which honors cgroup/affinity limits on shared nodes),
falling back to counting processor entries in /proc/cpuinfo.
taskset controls the CPU mask passed to taskset -c:
| Value | Behavior |
|---|---|
explicit cpulist (0-9, 0,2,4, 0-15:2) |
Passed verbatim to taskset -c. |
auto |
Expands to 0-(N-1), pinning to all N usable logical CPUs. |
threads controls the OpenMP thread-count sweep:
| Value | Behavior |
|---|---|
explicit list ([1, 2, 4, 8]) |
Swept as-is. |
["auto"] |
Expands to 1..N (every logical core). |
list containing "maxcores" |
The sentinel is dropped and numeric entries are capped at <= N, preserving order and removing duplicates. On an 8-core machine, [1, 2, 3, 4, "maxcores", 16, 18] becomes [1, 2, 3, 4], and [1, 2, 3, 4, 8, 16, 18, 72, "maxcores"] becomes [1, 2, 3, 4, 8]. |
If every requested thread count exceeds the available cores (an impossible
scenario on the current machine), the runner logs a warning and falls back to
sweeping 1..N rather than aborting.
Both sentinels are also available as command-line overrides:
perl ./cpu_param_sweep.pl --config ./benchmark_config_cpu.json \
--taskset auto --threads auto
perl ./cpu_param_sweep.pl --config ./benchmark_config_cpu.json \
--threads 1,2,4,maxcoresThe resolved core count is recorded per run in the Logical_CPUs CSV column
(emitted by the cpu_count telemetry entry) and is used by
cpu_profiling_analytics.R to facet and annotate the optimization-frontier,
register-pressure, and cache-saturation plots.
The focused tuner honors the same auto keyword through its CORES and
THREADS environment variables (e.g. CORES=auto THREADS=auto ./scripts/sweep_cpu_tuning.pl). Note the scope differs deliberately: the
tuner's auto expands via nproc --all (the full logical-CPU complement,
SMT siblings included, ignoring any affinity already in force) because it
profiles whole-machine behavior, whereas cpu_param_sweep.pl's auto uses
the cgroup/affinity-aware nproc. run_numa_sweeps.sh is unaffected: it
derives per-socket physical-core lists from the discovered topology instead
(see Why SMT is off by default).
Repetition-style matrix keys such as rep_id can be written as a single
object instead of an explicit list. The object expands before grid generation:
| Value | Behavior |
|---|---|
{ "repeat": 10 } |
Expands to 1..10 (run each configuration ten times). |
{ "range": "3-6" } |
Expands to 3,4,5,6. |
explicit list ([1, 2, 3]) or scalar |
Used as-is (backward compatible). |
rep_id is not interpolated into any command; it only multiplies executions
and labels CSV rows for repetition aggregation.
Both the build grid and the run grid are shuffled so execution order is
decorrelated from time (thermal/turbo drift and background load), which makes
the measured timings more statistically reliable. Set seed in system_env
(or pass --seed N) to make the shuffled order reproducible across
invocations; omit it for a fresh random order each run. The active seed is
logged at startup.
"system_env": { "seed": 12345, ... }perl ./cpu_param_sweep.pl --config ./benchmark_config_cpu.json --seed 12345The FAISS benchmark suite is excluded from this scheme; its seeds are hardwired in the comparator source.
Telemetry is described in JSON rather than embedded as benchmark-specific Perl logic. The current configuration reads CPU and operating-system files and runs a compiler-version command. Its extractors include CPU model, SIMD tier, and hardware population-count capability:
"telemetry": {
"hardware": {
"file": "/proc/cpuinfo",
"extractors": {
"Processor": "model name\\s*:\\s*(.+)",
"SIMD": "(?:flags|Features|isa).*?\\b(avx512f|avx2(?!.*\\bavx512f\\b)|avx(?!.*\\bavx2\\b|.*\\bavx512f\\b)|sse4_2(?!.*\\bavx)|sve2|sve(?!.*\\bsve2\\b)|asimd(?!.*\\bsve)|rv64[a-z]*v[a-z]*)\\b",
"vpopcountHW": "(?:flags|Features|isa).*?\\b(avx512_vpopcntdq|avx512_bitalg|zvbb|asimd)\\b"
}
},
"os": {
"file": "/etc/os-release",
"extractors": {
"Operating_System": "PRETTY_NAME=\\\"([^\\\"]+)\\\""
}
},
"toolchain": {
"cmd": "{cc} --version | head -n 1",
"extractors": {
"Compiler_Version": "(.*)"
}
}
}Likewise, output_parser captures the benchmark's standard output using named
regular-expression groups and writes selected values into CSV columns. Its map
normalizes the optional container label before output:
"output_parser": {
"regex": "(?i)Total\\s+time\\s+for\\s+(?<Benchmark_Type>Container\\s+-\\s+)?Multi-threaded\\s+-\\s+OpenMP:\\s+(?<Timing_ns>\\d+)\\s+ns.*?Number\\s+of\\s+threads:\\s+(?<Threads>\\d+)",
"columns": ["Benchmark_Type", "Threads", "Timing_ns"],
"map": {
"Benchmark_Type": {
"Container - ": "Containerized",
"Container -": "Containerized",
"__UNDEF__": "Non-Containerized"
}
}
}The regex and command templates are part of the selected configuration's contract. Update them together when changing benchmark output or command-line behavior.
After the broad parameter sweep identifies candidates, the focused tools on
main answer two different questions: sweep_cpu_tuning.pl collects repeated
timing and performance-counter evidence for a selected packed-container
configuration, while run_numa_sweeps.sh compares that workflow under four
dual-socket memory-placement cases. They currently remain on main:
scripts/sweep_cpu_tuning.plscripts/run_numa_sweeps.sh
Switch to main before using either workflow. sweep_cpu_tuning.pl must run
from the repository root because it verifies that Makefile is present there.
git switch main
make bench_omp GPU=NONE CC=clang
ELEVATE=always CORES=0-9 REPS=5 PERF_REPS=3 \
perl ./scripts/sweep_cpu_tuning.plsweep_cpu_tuning.pl is used to generate insights before CPU tuning of the containerized
intersection-count kernel for a given architecture. It really is a "live-cell imaging" of the library in action as it streams data across memory hierarchies. For each configuration it performs a clean rebuild,
runs build/openmp_bit_container with an explicit CPU affinity, and collects
perf stat profiles. It is intended to compare CPU tiles, K blocks,
outer-product microkernel shapes, unrolling, and the independent libpopcnt
scratch-buffer path without timing unrelated benchmark work.
Use it after cpu_param_sweep.pl narrows the candidate space or whenever
cache, execution, vectorization, TLB, NUMA, power, or scheduling evidence is
needed. Unlike build/cpu_param_sweep, this benchmark measures only the packed
Bit_DB_T intersection-count path; it does not compare ordinary-bitset and
container timings in the same process.
The default sweep evaluates the direct SIMD path (LIBPOPCNT_MODES=0). Include
both modes explicitly when comparing it with the libpopcnt path:
git switch main
LIBPOPCNT_MODES=0,1 ELEVATE=always \
perl ./scripts/sweep_cpu_tuning.plIn addition to environment variables, sweep_cpu_tuning.pl accepts an
optional JSON config via --config. The checked-in
scripts/benchmark_config_tuning.json documents the full schema. Environment
variables override config values (env wins), so the env-only invocations
above and run_numa_sweeps.sh keep working unchanged.
perl ./scripts/sweep_cpu_tuning.pl --config ./scripts/benchmark_config_tuning.jsonConfig keys are grouped into three blocks; each maps to the corresponding environment variable:
| Config key | Env override | Meaning |
|---|---|---|
seed |
SEED |
Integer seed for the configuration-order shuffle. |
sweep.libpopcnt_modes |
LIBPOPCNT_MODES |
Comma-separated 0/1 algorithm modes. |
sweep.cpu_tiles |
CPU_TILES |
Comma-separated CPU_TILE values. |
sweep.k_blocks |
K_BLOCKS |
Comma-separated BITVECTOR_TILE values. |
sweep.shapes |
SHAPES |
Comma-separated ROWSxCOLS shapes. |
sweep.unrolls |
UNROLLS |
Comma-separated OUTER_VEC_BLK values. |
sweep.buffer_sizes |
BUFFER_SIZES |
Comma-separated BUFFER_SIZE values. |
run.cc |
CC |
Compiler. |
run.cores |
CORES |
CPU affinity mask (auto = every logical CPU from nproc --all, SMT siblings included, ignoring any current affinity restriction). |
run.bits |
BITS |
Bitset length. |
run.left / run.right |
LEFT / RIGHT |
Operand counts. |
run.threads |
THREADS |
OpenMP thread count (auto = the nproc --all logical count, same as CORES). |
run.reps / run.perf_reps |
REPS / PERF_REPS |
Benchmark / perf stat repetitions. |
run.elevate / run.priority |
ELEVATE / PRIORITY |
Privilege escalation and scheduling. |
run.max_configs |
MAX_CONFIGS |
Cap on configurations executed (0 = all). |
output.results_dir |
RESULTS_DIR |
Root of tuning results. |
output.arch_tag |
ARCH_TAG |
Architecture label (empty = auto-detect). |
output.run_label |
RUN_LABEL |
Optional run label. |
output.numa_policy / output.numa_cmd |
NUMA_POLICY / NUMA_CMD |
NUMA policy label and command. |
PERF_PROFILES and PERF_EVENTS are deliberately environment-only and have
no config-file keys. The per-architecture PMU event tables they select or
override are hardware-mapping logic, not experiment design, so they stay in the
script; set them via the environment (see the Performance Profiles section
below).
The configuration list is shuffled before execution so run order is
decorrelated from time (thermal/turbo drift). Set seed (or SEED) to make
the shuffled order reproducible; omit it for a fresh order each run. The seed
is recorded in the generated report. When MAX_CONFIGS is set, it now selects
a random subset of the shuffled configurations rather than the first N.
To evaluate both algorithms, every default tuning parameter, and all 15 diagnostic profiles on one socket, run this from the repository root:
LIBPOPCNT_MODES=0,1 \
CORES=0-9 THREADS=10 \
REPS=5 PERF_REPS=3 \
RUN_LABEL=avx512 \
PERF_PROFILES=summary,cache-l1,cache-l2,cache-l3-dram,cache-stalls,buffers-pending,buffers-store,execution-uops,execution-ports,frontend,frequency,vectorization,tlb,uncore-numa,power-rapl \
ELEVATE=always \
./scripts/sweep_cpu_tuning.plThis is a long, sequential measurement. Direct SIMD varies four CPU tiles,
four K blocks, four microkernel shapes, and three unroll factors for 192 build
configurations. The libpopcnt path varies the same tiles, blocks, and shapes
plus four scratch-buffer sizes for another 256. Together, the two modes produce
448 clean builds and perf stat commands.
The repetition controls work at different levels. PERF_REPS=3 becomes
perf stat -r 3, so those 6,720 commands launch 20,160 benchmark processes.
Each process receives REPS=5 and performs one untimed warm-up followed by five
timed intersection calls. The script also runs the benchmark once outside the
profile loop to collect its primary timing output. Compiler speed, workload
size, PMU access, and host load determine the wall-clock duration, so use this
as a machine specific profiler that can help you understand why the library performs (or not) in the given machine.
Start with a small trial after changing machines, compilers, PMU permissions, or event sets:
git switch main
MAX_CONFIGS=2 REPS=1 PERF_REPS=1 PERF_PROFILES=summary ELEVATE=always \
perl ./scripts/sweep_cpu_tuning.plMAX_CONFIGS=2 stops after two build configurations, REPS=1 performs one
timed call per benchmark process, and PERF_REPS=1 runs each profile once. For
this toolchain and permission check, PERF_PROFILES=summary keeps profiling to
the smallest general-purpose event set.
The two host-shape controls are CORES (the taskset CPU mask the benchmark
may use) and THREADS (the OpenMP worker count passed to the benchmark). For
the full tuning picture, sweep both libpopcnt modes and the complete profile
set. The invocations below are copy-paste complete; run the MAX_CONFIGS=2
smoke test above first on any new machine.
| Host | Topology | CORES |
THREADS |
Entry point |
|---|---|---|---|---|
| Dual Xeon E5-2697 v4 | 2 x 18 cores/36 threads (36 cores / 72 logical) | 0-35 |
36 |
run_numa_sweeps.sh (auto-discovery), or the manual dual call below |
| i7-11700 | 8 cores / 16 threads, 1 socket | 0-15 |
16 |
direct tuner call |
| i9-7900X | 10 cores / 20 threads, 1 socket | 0-19 |
20 |
direct tuner call |
Dual-socket Xeon E5-2697 v4 (preferred: the auto-discovering wrapper, which also produces the cross-experiment comparison table). The wrapper runs one worker per physical core by default -- see Why SMT is off by default:
git switch main
bash ./scripts/run_numa_sweeps.sh # add --dry-run to preview firstor the equivalent single manual call (spread threads, interleaved memory):
git switch main
LIBPOPCNT_MODES=0,1 \
CORES=0-35 THREADS=36 \
OMP_PLACES=cores OMP_PROC_BIND=spread \
NUMA_CMD="numactl --interleave=0,1" NUMA_POLICY="interleave=0,1" \
REPS=5 PERF_REPS=3 RUN_LABEL=2socket-e5-2697v4 \
PERF_PROFILES=summary,cache-l1,cache-l2,cache-l3-dram,cache-stalls,buffers-pending,buffers-store,execution-uops,execution-ports,frontend,frequency,vectorization,tlb,uncore-numa,power-rapl \
ELEVATE=always \
./scripts/sweep_cpu_tuning.pli7-11700 (single socket, 8 cores / 16 threads). Use all logical CPUs:
git switch main
LIBPOPCNT_MODES=0,1 \
CORES=0-15 THREADS=16 \
REPS=5 PERF_REPS=3 RUN_LABEL=i7-11700 \
PERF_PROFILES=summary,cache-l1,cache-l2,cache-l3-dram,cache-stalls,buffers-pending,buffers-store,execution-uops,execution-ports,frontend,frequency,vectorization,tlb,uncore-numa,power-rapl \
ELEVATE=always \
./scripts/sweep_cpu_tuning.plTo restrict to physical cores only on that part, use CORES=0-7 THREADS=8
(and OMP_PLACES=cores OMP_PROC_BIND=close) instead.
i9-7900X (single socket, 10 cores / 20 threads):
git switch main
LIBPOPCNT_MODES=0,1 \
CORES=0-19 THREADS=20 \
REPS=5 PERF_REPS=3 RUN_LABEL=i9-7900x \
PERF_PROFILES=summary,cache-l1,cache-l2,cache-l3-dram,cache-stalls,buffers-pending,buffers-store,execution-uops,execution-ports,frontend,frequency,vectorization,tlb,uncore-numa,power-rapl \
ELEVATE=always \
./scripts/sweep_cpu_tuning.plOn these single-socket hosts there is no NUMA placement question, so no
NUMA_CMD is set; the tuner's default OS policy applies. The equivalent
CORES=auto THREADS=auto forms also work, but note that the tuner's auto
expands to the full logical-CPU count from nproc --all (SMT siblings
included, and ignoring any affinity restriction already in force) -- see the
auto note in the variable table below.
If you want every profile (assuming the host supports them), omit
PERF_PROFILES -- the default is the full 15-profile set. The shortest
complete invocations are:
# Minimal single-socket: all cores (auto) + all 15 profiles (default).
git switch main
LIBPOPCNT_MODES=0,1 CORES=auto THREADS=auto ELEVATE=always \
./scripts/sweep_cpu_tuning.pl
# Minimal dual-socket: pin to one socket's physical cores, all 15 profiles.
# This is one direct tuner run -- the same scope as one wrapper experiment.
# (2x18-core Xeon E5-2697 v4: socket 0 = CPUs 0-17, 18 physical cores.)
git switch main
LIBPOPCNT_MODES=0,1 CORES=0-17 THREADS=18 ELEVATE=always \
./scripts/sweep_cpu_tuning.pl
# To run the full four-experiment NUMA comparison (not a single run), use
# the wrapper: bash ./scripts/run_numa_sweeps.shTo attempt only the profiles that actually resolve on the host, add
PERF_PRUNE=1 (see Profile preflight).
The explicit, fine-control forms below are the same runs with every knob made visible -- use them when you want a fixed core mask or a recorded label rather than the auto-discovered values:
# Single-socket, all logical cores, explicit (i9-7900X: 10 cores / 20 threads).
git switch main
LIBPOPCNT_MODES=0,1 \
CORES=0-19 THREADS=20 \
REPS=5 PERF_REPS=3 RUN_LABEL=i9-7900x \
PERF_PROFILES=summary,cache-l1,cache-l2,cache-l3-dram,cache-stalls,buffers-pending,buffers-store,execution-uops,execution-ports,frontend,frequency,vectorization,tlb,uncore-numa,power-rapl \
ELEVATE=always \
./scripts/sweep_cpu_tuning.pl
# Dual-socket, all cores, explicit memory interleave (2x18-core, 36 threads).
git switch main
LIBPOPCNT_MODES=0,1 \
CORES=0-35 THREADS=36 \
OMP_PLACES=cores OMP_PROC_BIND=spread \
NUMA_CMD="numactl --interleave=0,1" NUMA_POLICY="interleave=0,1" \
REPS=5 PERF_REPS=3 RUN_LABEL=2socket-e5-2697v4 \
PERF_PROFILES=summary,cache-l1,cache-l2,cache-l3-dram,cache-stalls,buffers-pending,buffers-store,execution-uops,execution-ports,frontend,frequency,vectorization,tlb,uncore-numa,power-rapl \
ELEVATE=always \
./scripts/sweep_cpu_tuning.pl
# Dual socket, all cores and logical processors, explicit memory interleave (2x18 core, 72 threads)
git switch main
LIBPOPCNT_MODES=0,1 \
CORES=0-71 THREADS=72 \
OMP_PLACES=cores OMP_PROC_BIND=spread \
NUMA_CMD="numactl --interleave=0,1" NUMA_POLICY="interleave=0,1" \
REPS=5 PERF_REPS=3 RUN_LABEL=2socket-e5-2697v4-smt \
PERF_PROFILES=summary,cache-l1,cache-l2,cache-l3-dram,cache-stalls,buffers-pending,buffers-store,execution-uops,execution-ports,frontend,frequency,vectorization,tlb,uncore-numa,power-rapl \
ELEVATE=always \
./scripts/sweep_cpu_tuning.plThe minimal and explicit forms measure the same thing; the explicit ones only
add a fixed CORES/THREADS, a RUN_LABEL, and the spelled-out
PERF_PROFILES list (which equals the default). On dual-socket hosts the
minimal form uses one worker per physical core (matching the wrapper's
default policy); see Why SMT is off by default.
All sweep variables are environment variables. Comma-separated values define a matrix; a single value fixes that dimension.
| Variable | Default | Description |
|---|---|---|
LIBPOPCNT_MODES |
0 |
Algorithms to compare: 0 is direct SIMD; 1 is the libpopcnt scratch-buffer path. |
CPU_TILES |
4,8,16,32 |
Values compiled as CPU_TILE. |
K_BLOCKS |
256,512,768,1024 |
Values compiled as BITVECTOR_TILE. |
SHAPES |
1x1,2x2,2x4,4x2 |
Outer microkernel shapes, written as ROWSxCOLS. |
UNROLLS |
1,2,4 |
OUTER_VEC_BLK values, used for the direct-SIMD path. |
BUFFER_SIZES |
128,512,1024,4096 |
BUFFER_SIZE values, used for the libpopcnt path. |
CC |
clang |
Compiler supplied to make. |
CORES |
0-9 |
CPU list supplied to taskset -c; choose physical cores where possible. |
BITS |
65536 |
Bitset length passed to openmp_bit_container. |
LEFT, RIGHT |
1000, 1000 |
Left and right packed-container sizes. |
THREADS, REPS |
10, 5 |
OpenMP thread count and timed repetitions. |
PERF_REPS |
3 |
Repetitions requested from perf stat for each profile. |
PERF_PROFILES |
profile set | Comma-separated profiles such as summary, cache-l1, cache-l2, cache-l3-dram, cache-stalls, buffers-pending, buffers-store, execution-uops, execution-ports, frontend, frequency, vectorization, tlb, uncore-numa, and power-rapl. Use summary while narrowing the matrix. |
PERF_EVENTS |
unset | Optional replacement event list for the summary profile. |
ELEVATE |
auto |
never, auto, or always; elevation can be needed for performance counters or scheduling priority. |
PRIORITY |
nice |
normal, nice, or real-time round-robin rr; elevated privileges are required where the operating system requires them. |
MAX_CONFIGS |
0 |
Stops after this many configurations; 0 means no limit. |
ARCH_TAG |
detected | Optional safe filename label for reports. |
RUN_LABEL |
unset | Optional label inserted into report and artifact names. |
NUMA_POLICY |
default OS policy |
Descriptive policy text recorded in the Markdown report. |
NUMA_CMD |
unset | Optional numactl command prefixed to the benchmark process. |
RESULTS_DIR |
tuning-results |
Directory for summary-<run-tag>.csv and llm-summary-<run-tag>.md. |
OUT_DIR |
tuning-results/.work/<run-tag> |
Directory for per-configuration build, benchmark, and perf artifacts. |
Each name in PERF_PROFILES is a separate perf stat invocation with its own
event list and *.perf.csv artifact. The names describe diagnostic questions;
the script maps them to PMU events for generic Intel, hybrid Intel P-core, AMD
x86-64, generic AArch64, and Rockchip/Rock64-class systems.
| Profile | What it helps explain |
|---|---|
summary |
Timing rank, instructions per cycle, branch behavior, and general cache-miss rate. |
cache-l1 |
Retired-load L1 hits and misses. |
cache-l2 |
Retired-load L2 behavior after L1. |
cache-l3-dram |
Last-level-cache behavior and local DRAM demand-load misses. |
cache-stalls |
Cycles stalled around L1D, L2, and L3 miss activity. |
buffers-pending |
Fill-buffer saturation and pending-miss occupancy. |
buffers-store |
Store-buffer and store-queue pressure plus outstanding data-read depth. |
execution-uops |
Issued, executed, and retired micro-operations plus backend stalls. |
execution-ports |
Distribution of work across execution ports. |
frontend |
Undelivered micro-operations and low-delivery cycles. |
frequency |
APERF/MPERF behavior, including possible AVX-512 frequency changes. |
vectorization |
Packed SIMD work compared with scalar fallback indicators. |
tlb |
Data and instruction TLB loads and misses. |
uncore-numa |
Cross-socket, interconnect, and memory-controller traffic where supported. |
power-rapl |
Package and RAM energy or power counters where supported. |
The profile name remains stable across machines, but the underlying events do
not. Some maps use cycles,instructions when a detailed event is unavailable;
many Rockchip profiles and some Intel execution-port profiles intentionally
fall back this way. Check the generated event list before comparing unlike
architectures. PERF_EVENTS replaces the event list for summary only.
Profiles are kept separate rather than combined into one enormous event set.
An individual profile can still exceed the available hardware counters, so use
the running/scaling information from perf when interpreting multiplexed
counts. A missing event or permission affects that profile; the benchmark
timing remains available, and the profile CSV/log records what failed.
Profile preflight (PERF_PROBE / PERF_PRUNE). Because the event sets are
architecture-specific, the script can probe the resolved list on the current
host before building anything. With PERF_PROBE=1 (the default) it runs a
trivial perf stat -e <profile events> -- true per profile and prints which of
the 15 resolve (e.g. Profile preflight: 13/15 profiles resolve on this host (unresolvable: power-rapl,uncore-numa)); a profile is unresolvable when that
probe exits non-zero or reports a "not supported" event. Set
PERF_PRUNE=1 to additionally drop the unresolvable profiles from the run so
only the supported ones are attempted (the summary profile is always kept).
On a host where perf is entirely blocked (for example a high
kernel.perf_event_paranoid or a container without CAP_PERFMON), every probe
fails; the preflight says so, and with PERF_PRUNE=1 only summary remains.
Set PERF_PROBE=0 to skip probing entirely. The probe honors the same
privilege elevation (ELEVATE) as the real runs.
The script runs:
build/openmp_bit_container <bits> <left-bitsets> <right-bitsets> <threads> <repetitions>
The executable allocates and initializes packed containers, runs one untimed
BitDB_inter_count_store_cpu warm-up, performs the requested timed repetitions,
and prints each timing plus best, average, Gqword-pairs/s, and a result checksum.
perf wraps the complete process, so its counters include allocation,
initialization, warm-up, timed calls, checksum, and teardown. Use the C
nanosecond timings to rank the intersection kernel and the PMU profiles to
understand the wider process behavior.
Each run produces compact, architecture-labelled outputs such as
tuning-results/summary-<run-tag>.csv and
tuning-results/llm-summary-<run-tag>.md. The accompanying .work/<run-tag>/
directory holds per-configuration build logs, benchmark output, and perf CSV
files. A full matrix can be long-running, so reduce the matrix first and reserve
the complete profile set for selected candidates.
run_numa_sweeps.sh implements a generic dual-socket experiment: establish a
local-memory baseline on each socket, then compare a dual-socket default
first-touch run with explicit memory interleaving. CPU affinity alone does not
choose where pages are allocated. Because the focused benchmark initializes
its shared input containers before OpenMP workers begin, ordinary Linux
first-touch placement can put many pages on one node and make work on the other
socket remote-memory heavy.
The script is generic: it auto-discovers the socket/CPU topology from
lscpu and runs on any dual-socket host, not just one machine. It locates the
repository root itself and may be started from another directory:
git switch main
bash ./scripts/run_numa_sweeps.shIt requires numactl and lscpu. On any dual-socket host it runs these four
comparable sweeps, deriving the CPU lists and worker counts from the discovered
physical cores (one OpenMP thread per physical core):
socket0-local: the first socket's physical cores, allocation bound to its NUMA node.socket1-local: the second socket's physical cores, allocation local to that node.dual-first-touch-spread: all discovered physical cores, spread OpenMP binding, and default Linux first-touch placement.dual-interleave: all discovered physical cores, spread OpenMP binding, and memory interleaved across both NUMA nodes.
The single-socket runs provide local-memory baselines. Comparing the two dual-socket runs helps distinguish an asymmetric first-touch placement effect from the effect of explicit interleaving. Interleaving balances allocation; it does not make every access local.
Preview the four resolved experiments (CPU lists, thread counts, NUMA commands,
and the ARCH_TAG) without building anything:
bash ./scripts/run_numa_sweeps.sh --dry-runOn a host with a single socket the script exits with a clear message pointing
you to sweep_cpu_tuning.pl directly, rather than running a degraded
experiment.
Every value the script discovers can be overridden. Flags win over discovery;
the environment variables documented for sweep_cpu_tuning.pl
(LIBPOPCNT_MODES, REPS, PERF_REPS, PERF_PROFILES, ELEVATE,
MAX_CONFIGS, CC, SEED, RESULTS_DIR, OUT_DIR) pass straight through.
| Flag | Default | Purpose |
|---|---|---|
--socket0-cpus LIST |
first socket's physical cores | CPU list for the socket0-local run. |
--socket1-cpus LIST |
second socket's physical cores | CPU list for the socket1-local run. |
--dual-cpus LIST |
union of the two socket lists | CPU list for the two dual-socket runs. |
--socket0-threads N |
size of the socket0 list | OpenMP workers for socket0-local. |
--socket1-threads N |
size of the socket1 list | OpenMP workers for socket1-local. |
--dual-threads N |
size of the dual list | OpenMP workers for the dual runs. |
--nodes N0,N1 |
first NUMA node seen on each socket | NUMA node IDs; override on sub-NUMA/cluster-on-die hosts where a socket maps to more than one node. |
--arch-tag TAG |
auto-detected | ARCH_TAG passed to the tuner; set it to keep labels comparable with earlier runs. |
--smt |
off | Use every logical CPU (SMT siblings included) instead of one thread per physical core. |
--dry-run |
off | Print the four resolved experiments and exit without building. |
-h, --help |
— | Print the option summary. |
Before building, the script validates that the CPU lists are nonempty and non-overlapping, that the CPUs are online, that the NUMA nodes exist, and that no thread count exceeds its CPU list size, and it reports the first problem it finds.
For example, on the reference dual Xeon E5-2697 v4 (18 physical cores per
socket, 36 logical CPUs total, no SMT), --dry-run resolves to CPU lists
0-17 / 18-35 / 0-35, worker counts 18 / 18 / 36, and NUMA nodes
0,1 -- the historical hand-written mapping. On a 2x18-core host with SMT
enabled (72 logical CPUs), the default physical-core policy resolves to the
same 18/18/36 worker counts, while --smt produces 36/36/72.
Note that the default value of --smt is off (the flag is not passed on the
command line): all four experiments run one OpenMP worker per physical core.
SMT siblings share a physical core's execution ports and L1/L2 cache, so
per-core kernel efficiency -- the quantity the perf profiles exist to measure --
is cleanest with one thread per core; a sibling thread adds contention noise
to exactly the counters being compared. The default also keeps results
comparable with the historical hand-written Xeon baseline (18 workers on 18
physical cores per socket), and it avoids confounding the experiment's
independent variable, which is memory placement (first-touch vs interleave),
not core occupancy. The tuner is a profiling instrument, not a
maximum-throughput benchmark; if the question is aggregate throughput with
every logical CPU busy, pass --smt explicitly. Note the contrast with
invoking sweep_cpu_tuning.pl directly (standalone, without this wrapper):
its auto sentinel expands to nproc --all, SMT siblings included -- the
wrapper's physical-core policy is the deliberate choice for NUMA comparisons.
All four experiments write under <repo-root>/tuning-results/ (see
Artifact Output Locations).
Each experiment produces its own run tag, and the wrapper then writes a
single cross-experiment table comparing the best configuration of each:
| File (per run) | Contents |
|---|---|
tuning-results/summary-<arch>-<label>-<timestamp>.csv |
Raw per-configuration rows (all knobs + every perf counter). |
tuning-results/llm-summary-<arch>-<label>-<timestamp>.md |
Ranked per-experiment table (avg ns, Gqword-pairs/s, IPC, cache/branch miss). |
tuning-results/.work/<arch>-<label>-<timestamp>/ |
Per-configuration build, benchmark, and perf logs. |
tuning-results/numa-compare-<timestamp>.md |
Cross-experiment table: experiment x best config x avg ns x Gqword-pairs/s. |
The numa-compare-*.md table is the headline read for the dual-socket
question: it shows whether the interleaved run beats default first-touch and
how each compares with the socket-local baselines, in one place. There is no
separate R visualization for this schema; the ranked Markdown tables are the
presentation layer.
The runner forwards OMP_PLACES, OMP_PROC_BIND, NUMA_CMD, and the named
NUMA policy to the tuning script. perf access is governed by the host's
permissions and kernel.perf_event_paranoid; use ELEVATE=always only where
permitted by local administration policy.
scripts/gpu_param_sweep.pl sweeps the experimental native benchmark matrix
through Makefile_bench.mak. It supports --backend, --make-args,
--iterations, --out-dir, --summary, --log, and --dry-run.
git switch gpuOpt
CUDA_VISIBLE_DEVICES=0 perl ./scripts/gpu_param_sweep.pl \
--backend=NVIDIA \
--make-args='CC=clang GPU_ARCH=sm_70'
ROCR_VISIBLE_DEVICES=0 perl ./scripts/gpu_param_sweep.pl \
--backend=AMD \
--make-args='CC=clang GPU_ARCH=gfx1010'Choose the GPU with --backend; reserve --make-args for compiler,
architecture, and other Make variables.
By default, the script writes backend/architecture-labelled CSV and raw log
files in benchmark_GPU_params/. The native CUDA/HIP workflow, its result
files, and plot_performance.R belong to gpuOpt and are separate from the CPU
sweep suite. Plot the collected CSV files with:
Rscript ./scripts/plot_performance.RThe script names suggest several pairings, but the file formats decide which ones work end to end:
- GPU sweep and plot (
gpuOpt):gpu_param_sweep.plwrites architecture-labelled CSV files underbenchmark_GPU_params/.plot_performance.Rreads those files using the sameTILE_J,ILP, workload, timing-type, and throughput columns. This is the working Perl-to-R pair. - Broad CPU sweep and analytics:
cpu_param_sweep.plandcpu_profiling_analytics.Rare intended companions: use the Perl script to generate the data and the R script to help you visualize them. - Focused CPU tuning:
sweep_cpu_tuning.plis self-contained. It writessummary-<run-tag>.csv,llm-summary-<run-tag>.md, and per-configuration build/benchmark/perf files undertuning-results/.work/; no R script in this repository currently consumes that schema. - NUMA orchestration:
run_numa_sweeps.shis not an analytics consumer. It invokessweep_cpu_tuning.plfour times with socket-local, first-touch, and interleaved-memory settings, producing comparable tuning reports.
The R helpers use packages such as data.table, ggplot2, this.path, and
bit64; consult each script for its exact package list and output behavior.
Synchronization is available in both directions between main and gpuOpt,
and both source branches can update inteliGPU:
| Run from | Helper | Destination |
|---|---|---|
main |
scripts/push_main_to_gpuOpt.sh |
gpuOpt |
main |
scripts/push_main_to_inteliGPU.sh |
inteliGPU |
gpuOpt |
scripts/push_gpuOpt_to_main.sh |
main |
gpuOpt |
scripts/push_gpuOpt_to_inteliGPU.sh |
inteliGPU |
Preview any synchronization without fetching, switching branches, staging, committing, or pushing:
./scripts/push_main_to_gpuOpt.sh --dry-run
# Use the helper available on the current source branch for another direction.All four helpers require a clean source worktree and both local branches. They
fetch and fast-forward the destination, copy the paths selected by that helper
with git restore, create a commit when content changed, push the destination,
and return to the starting branch. Pass --no-push to commit the sync on the
destination branch WITHOUT pushing to origin (you then review and push
manually). For one invocation, the selected source
paths are authoritative: corresponding destination edits are replaced rather
than merged, while files outside the selected set remain untouched.
The workflows mirror one another, and each helper copies the same explicitly
curated shared library/test/benchmark paths plus the complete include/ tree.
Branch-exclusive CPU and GPU benchmark files stay with their owning branch.
Helpers targeting main or inteliGPU also remove stale gpuOpt-only benchmark
files; the helper targeting gpuOpt never removes them.
README.md is selected by every helper, so a sync also replaces the
destination branch's README with the source branch version.
The separate benchmarking-bits
repository contains comparative C and Perl bitset/bitmap benchmarks. It is a
research companion rather than a dependency of this library.1 This repository reflects the performance of an earlier version of Bit (the first release version).
- Capacity: Bitsets have fixed,
int-sized capacities. An extension to resize them is unlikely to be written in the form of an API function and this functionality resuts with the user. - Validation: Most pointer, index, shape, and allocation checks use
assert. DefiningNDEBUGremoves them; callers must till provide valid indexes, equal-length operands, and correctly sized borrowed buffers. I admit that a memory safe extension would be great, but unlikely to evolve past the use of compiler level sanitizers. - Concurrency: GPU calls are synchronous in the current library path and this is not going to change. CPU concurrency safety is up to the caller, with the most significant challenge presented by applications that want to use nested parallelism or combine multiprocessing with multithreading. More details below
- GPU residency: Update and release flags control device mappings. Internally the library transposes the right bitset container operand and uses a finite state machine to keep track of the orientation. This functionality is not exposed to the caller (it is part of the internal API), but there may be some value to slowly transition those to the public API.
- Current research surfaces: Intel OpenMP offload and native CUDA/HIP benchmarks remain experimental, largely because I don't enough CUDA/HIP myself1 to verify the AI generated code
-
Individual bitsets are mutable buffers, so you are responsible for coordinating concurrent access to share objects.
-
GPU based container functions are synchronous. Device, update, and release options control data residency across calls; they do not provide asynchronous execution or cross-thread synchronization, i.e. the host thread blocks until the device has finished execution.
-
I have used the container API through a very ordinary, even boring fork-join path: one thread enters a call and OpenMP parallelizes the work inside it. Nested tasks, multiple controlling threads sharing operands, and
forkafter OpenMP initialization remain untested here. In particular be very aware of the use ofBitin the context of multi-processing (launching a process that will then use the multi-threading capabilities ofBit). Traditionally this was an unsafe use of OpenMP, until v 5.0 which introduced theomp_pause_resourceandomp_pause_resource_all, which allow an OpenMP runtime to prepare a process before a subsequent fork. Please consult the the OpenMP 5.0 API3 to ensure that you are using this feature correctly e.g. these calls should occur outside an explicit parallel region, with explicit tasks completed before the run-time is paused (this means that one can screw the pooch if one is messing with OpenMP's blocking semantics) .
Bit is particularly useful for dense set and membership workloads such as:
- Bioinformatics and genomic data processing, including k-mer-like encodings.
- Network packet filtering and Bloom-filter-style membership tests.
- High-performance set operations and all-pairs similarity searches (the context of the FAISS like application).
For genuinely sparse domains, a compressed representation such as a roaring bitmap can be a better fit than this uncompressed library. I have not attempted to figure out how big the capacity should be before a compressed respresentation wins out in performance.
- Continue validating CPU, NVIDIA, AMD build paths.
- Evaluate Intel Arc's architectures for offloads
- Investigate offloads to FPGAs
- Extend SIMD-oriented CPU work across more set-operation paths while retaining portable fallbacks.
- Port or evaluate selected experimental
gpuOptCUDA/HIP implementations as an alternative to the OpenMP ones. - Add set-operation metrics such as Jaccard similarity.
- Improve OS-agnostic build, profiling, and reproducibility workflows.
- Investigate Unified Shared Memory where the target runtime supports it (this may be how one gets to use these ubiquitous integrated Intel GPUs!).
BSD 2-Clause License. See LICENSE for details.
Christos Argyropoulos (April 2025 - May 2026)
This session is intended to document the involvement of AI in this project and a roadmap to preserve, collect and characterize the involvement over time. In retrospect, some of the steps (in particular recovery of history from other machines) should have done much earlier than September 2026. The following few sections represent the use of AI in this project, and some post hoc rambling about how best to record the AI contributions vis-a-vis the human inspiration in future work.
GitHub Copilot and Google Gemini assisted with generating and refactoring Makefile content, exploring test ideas for the OpenMP implementations, drafting and refactoring templated C work used for the CUDA and HIP implementations, and maintaining this README as the source evolved since the last quarter of 2025.
As focus shifted towards benchmarking and associated automation in 2026
generative AI assisted with script refactoring, Perl automation boilerplate,
JSON configuration schemas, R authoring, OpenMP macro work inside the gpuOpt branch
The following model-specific roles are author-confirmed and are described as regular contributions, not as a complete per-file provenance record:
- Google Gemini 3.1: Perl automation, R authoring, and OpenMP macro work.
- Claude Sonnet 5 and Claude Sonnet 4.6: Makefile work.
- Kimi K.3: the main model used since the summer of 2026.
The repository does not use watermark analysis to identify authorship or assign source code to a particular AI model.1 While I wish there was such a framework, there is no universal source-code watermark detector, and any verification must use that provider's supported process, which I am not sure how to access. Therefore, I fear your must take my word when attributing parts of this work to AI. Model-level claims in this disclosure thus depend on my memory and frankly honesty to disclose. Here are some personal thoughts on how to prospectively collect and document AI assisted contributions for future work.
For AI assisted work completed on multiple computers, collect first-party records instead of attempting retrospective source attribution:
- On each VS Code installation signed into the same account, enable
chat.sessionSync.enabledandgithub.copilot.chat.localIndex.enabled. - Run
/chronicle reindexon each machine to index retained local sessions and synchronize them where the account configuration permits it. - Preserve unpushed branches and reflogs from each checkout as work chronology,
for example with
git branch -avvandgit reflog show --all. - Retrieve any account-level Copilot history or usage export available to the account owner, then reconcile it with author-maintained records.
- Redact credentials, tokens, personal data, proprietary prompts, and other sensitive material before centralizing transcripts or exports.
These steps can recover only history still retained by the device or provider. They do not recreate deleted sessions and may not expose the model routed for each request.
All core problem framing, architectural decisions (including decoupling execution engines from target schemas), code validation, security reviews, and scientific evaluations were performed directly by the author. The author maintains responsibility for the accuracy, licensing, and integrity of all submitted code and materials.
Footnotes
-
All statements marked with this footnote reflect the state of the codebase, benchmarks, and external ecosystem as of the dates indicated in the surrounding text (September 2026 unless otherwise noted). They are time-stamped observations, not permanent claims. ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
Maurice V. Wilkes, David J. Wheeler, and Stanley Gill describe the Gillies-Miller method for sideways addition in The Preparation of Programs for an Electronic Digital Computer, 2nd ed., pp. 191-193 (1957). Wojciech Mula, Nathan Kurz, and Daniel Lemire later used the name “Wilkes-Wheeler-Gill” in “Faster Population Counts Using AVX2 Instructions,” The Computer Journal 61(1), 2018. ↩
-
OpenMP Architecture Review Board. Openmp application programming interface, version 5.0, 2018. See the resource-pause run-time routines omp_pause_resource and omp_pause_resource_all. https://www. openmp.org/spec-html/5.0/openmpsu153.html. ↩


