lenatriestounderstand

Lab · runnable experiments

From Python Call to GPU Kernel: The C++ Underneath ML Libraries

Created Sep 19, 2026 Updated Sep 19, 2026

Read the parent note

A small nearest-neighbour library is written here from scratch, in C++ and CUDA, and then used to measure the things a Python call to such a library hides: where the numbers live, who frees them, what a copy costs, how the layout of memory and the knowledge of the compiler change the speed, what it takes to turn source files into something import can load, and what happens between the moment search() returns and the moment the GPU is actually done.

Part 1 writes the library — tinyknn, about a thousand lines with comments — builds it, and checks its answers against NumPy. Part 2 runs the same search in pure Python, NumPy, the library on the CPU and the library on the GPU. Parts 3–12 take one layer at a time: Python objects, memory, ownership, copies, layout, the compiler, templates, dispatch, the build, the binding. Part 13 is the GPU: streams, asynchrony, pinned memory. Part 14 rebuilds the CPU search one change at a time, and Part 15 follows a single call through every layer with a stopwatch at each boundary.

Every C++ experiment is a small standalone program, compiled and run from the notebook; each prints one line of JSON, which is what the tables and charts below read.

Setup

Run on Kaggle with Settings → Accelerator → GPU T4 x2 (the lab uses one card) and Internet on — pybind11 is installed from PyPI. The C++ compiler (g++) and the CUDA compiler (nvcc) come with the Kaggle image.

import os, sys, gc, re, json, time, glob, random, shutil, platform, datetime, sysconfig
import subprocess, traceback, contextlib, threading
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor
import numpy as np, pandas as pd
import matplotlib.pyplot as plt

subprocess.run([sys.executable, "-m", "pip", "install", "-q", "pybind11"], check=True)
import pybind11

INK, LINE = "#2a2a2a", "#d9d3c7"
BLUE, EMBER, FOREST, GRAY, PLUM = "#3b6ea5", "#c4521e", "#2e7d5b", "#9a9384", "#7a4f8c"
plt.rcParams.update({"font.size": 9, "axes.edgecolor": LINE, "axes.spines.top": False,
                     "axes.spines.right": False, "figure.dpi": 120})

RESULTS = {"meta": {}, "errors": {}}


@contextlib.contextmanager
def experiment(name):
    """One experiment per cell. A failure is printed and recorded, and the run moves on."""
    t0 = time.time()
    try:
        yield
    except Exception:
        RESULTS["errors"][name] = traceback.format_exc()
        print(f"!! {name} failed:\n{traceback.format_exc()}")
    finally:
        print(f"[{name}: {time.time() - t0:.1f} s]")


def sh(cmd, check=True):
    """Run a shell command; return the finished process (stdout and stderr as text)."""
    p = subprocess.run(cmd, shell=True, capture_output=True, text=True)
    if check and p.returncode != 0:
        raise RuntimeError(f"command failed ({p.returncode}): {cmd}\n{p.stderr[-4000:]}")
    return p


def build(src, out, flags="-O3 -march=native", std="c++20", extra=""):
    """Compile one benchmark program with g++; return the compile time in seconds."""
    t0 = time.perf_counter()
    sh(f"g++ -std={std} {flags} -pthread -Itinyknn/include {extra} {src} -o {out}")
    return time.perf_counter() - t0


def run_json(cmd):
    """Run a benchmark program and parse the JSON line it prints."""
    lines = [l for l in sh(cmd).stdout.splitlines() if l.startswith("{")]
    return json.loads(lines[-1])


def best_time(fn, reps=5, warmup=1):
    """Fastest of `reps` runs of fn(), in seconds."""
    for _ in range(warmup):
        fn()
    best = float("inf")
    for _ in range(reps):
        t0 = time.perf_counter()
        fn()
        best = min(best, time.perf_counter() - t0)
    return best


def jsonable(o):
    if isinstance(o, (np.floating, np.integer)):
        return o.item()
    if isinstance(o, np.ndarray):
        return o.tolist()
    return str(o)


for d in ["tinyknn/include/tinyknn", "tinyknn/src", "tinyknn/python", "bench", "build"]:
    Path(d).mkdir(parents=True, exist_ok=True)

CUDA_HOME = next((p for p in [os.environ.get("CUDA_HOME"), "/usr/local/cuda"]
                  if p and Path(p, "bin", "nvcc").exists()), None)
NVCC = f"{CUDA_HOME}/bin/nvcc" if CUDA_HOME else None
CPU = sh("lscpu | grep 'Model name' | head -1", check=False).stdout.split(":")[-1].strip()
GPU = sh("nvidia-smi --query-gpu=name --format=csv,noheader | head -1", check=False).stdout.strip()
RESULTS["meta"].update(
    cpu=CPU, cores=os.cpu_count(), gpu=GPU or None, python=platform.python_version(),
    numpy=np.__version__, gcc=sh("g++ --version | head -1").stdout.strip(),
    nvcc=sh(f"{NVCC} --version | tail -1", check=False).stdout.strip() if NVCC else None,
)
print(json.dumps(RESULTS["meta"], indent=1))
{
 "cpu": "Intel(R) Xeon(R) CPU @ 2.00GHz",
 "cores": 4,
 "gpu": "Tesla T4",
 "python": "3.12.13",
 "numpy": "2.0.2",
 "gcc": "g++ (Ubuntu 11.4.0-1ubuntu1~22.04.3) 11.4.0",
 "nvcc": "Build cuda_12.8.r12.8/compiler.35583870_0"
}

Part 1 — The library

tinyknn does one thing: for each query vector, find the k rows of a database closest to it, by brute force. It is organised the way larger numerical libraries are. A few headers hold the vocabulary — a buffer that owns memory, a view that only looks at it, metrics as types, a top-k structure — and the search itself is a template over the metric. Two source files compile it: cpu.cpp with g++, cuda.cu with nvcc. They link into one shared library, libtinyknn.so, whose public surface is api.hpp. The Python module _tinyknn is a separate shared library that links against it and contains no numerical code at all.

tinyknn/
  include/tinyknn/  buffer.hpp  view.hpp  distance.hpp  topk.hpp  search_cpu.hpp  api.hpp
  src/              cpu.cpp  cuda.cu              →  libtinyknn.so
  python/           bindings.cpp                  →  _tinyknn.cpython-*.so

The owning buffer. Copying it is a compile error; a copy has to be asked for by name.

%%writefile tinyknn/include/tinyknn/buffer.hpp
// tinyknn/buffer.hpp — the one type in the library that owns host memory.
#pragma once
#include <cstddef>
#include <cstring>
#include <new>
#include <utility>

namespace tinyknn {

// n elements of T, starting on a 64-byte boundary (one cache line).
// The constructor allocates, the destructor frees: whoever holds the buffer owns the bytes,
// and the bytes live exactly as long as the buffer does (RAII).
// Copying is disabled on purpose. Moving hands the bytes over; clone() is the only way to
// pay for a second copy, and it has to be written out.
template <class T>
class host_buffer {
 public:
  host_buffer() = default;
  explicit host_buffer(std::size_t n)
      : data_(static_cast<T*>(::operator new(n * sizeof(T), std::align_val_t{64}))), size_(n) {}
  ~host_buffer() { release(); }

  host_buffer(const host_buffer&) = delete;
  host_buffer& operator=(const host_buffer&) = delete;

  host_buffer(host_buffer&& other) noexcept
      : data_(std::exchange(other.data_, nullptr)), size_(std::exchange(other.size_, 0)) {}
  host_buffer& operator=(host_buffer&& other) noexcept {
    if (this != &other) {
      release();
      data_ = std::exchange(other.data_, nullptr);
      size_ = std::exchange(other.size_, 0);
    }
    return *this;
  }

  host_buffer clone() const {
    host_buffer out(size_);
    std::memcpy(out.data_, data_, size_ * sizeof(T));
    return out;
  }

  T* data() noexcept { return data_; }
  const T* data() const noexcept { return data_; }
  std::size_t size() const noexcept { return size_; }

 private:
  void release() noexcept {
    if (data_) ::operator delete(data_, std::align_val_t{64});
  }
  T* data_ = nullptr;
  std::size_t size_ = 0;
};

}  // namespace tinyknn
Writing tinyknn/include/tinyknn/buffer.hpp

The view: a pointer, a shape, a stride, and where the memory is. It never frees anything. A view of float converts to a view of const float; the reverse conversion does not exist.

%%writefile tinyknn/include/tinyknn/view.hpp
// tinyknn/view.hpp — non-owning views. A view answers "how do I read these bytes",
// never "who frees them". It is three numbers and a pointer, cheap to pass by value.
#pragma once
#include <cstddef>
#include <type_traits>

#if defined(__CUDACC__)
#define TINYKNN_HD __host__ __device__
#else
#define TINYKNN_HD
#endif

namespace tinyknn {

// Where the bytes are. A view carries it so that a CPU function cannot be handed GPU memory
// by mistake: the check is one comparison, made before any work starts.
enum class memory { host, device };

// rows × cols elements of T. Row i starts `stride` elements after row i-1, so a view can
// describe a slice of a bigger matrix without copying it. T = const float gives a read-only view.
template <class T>
struct matrix_view {
  T* data = nullptr;
  std::size_t rows = 0, cols = 0, stride = 0;
  memory where = memory::host;

  TINYKNN_HD T* row(std::size_t i) const { return data + i * stride; }
  TINYKNN_HD T& operator()(std::size_t i, std::size_t j) const { return data[i * stride + j]; }
  bool contiguous() const { return stride == cols; }

  // Mutable → read-only converts implicitly; read-only → mutable does not exist.
  template <class U = T, std::enable_if_t<!std::is_const_v<U>, int> = 0>
  operator matrix_view<const U>() const { return {data, rows, cols, stride, where}; }

  // Rows [begin, end) of the same memory — a smaller view, no copy.
  matrix_view slice_rows(std::size_t begin, std::size_t end) const {
    return {data + begin * stride, end - begin, cols, stride, where};
  }
};

template <class T>
matrix_view<T> make_view(T* data, std::size_t rows, std::size_t cols,
                         memory where = memory::host) {
  return {data, rows, cols, cols, where};
}

}  // namespace tinyknn
Writing tinyknn/include/tinyknn/view.hpp

Metrics are types. The dimension-sized inner loop is written with eight independent partial sums — Part 8 shows why — and a second version handles four queries at once, so that each database row is loaded once and used four times.

%%writefile tinyknn/include/tinyknn/distance.hpp
// tinyknn/distance.hpp — metrics as types. Which metric to use is decided when the code is
// compiled, so the inner loop contains the arithmetic of one metric and nothing else.
#pragma once
#include <cstddef>
#include "view.hpp"
#if defined(__cpp_concepts)
#include <concepts>
#endif

namespace tinyknn {

// Every metric is "smaller is closer", so one top-k works for all of them.
struct L2 {
  static constexpr const char* name = "l2";
  static constexpr bool needs_norms = true;
  template <class T>
  TINYKNN_HD static float term(T a, T b) {
    float d = float(a) - float(b);
    return d * d;
  }
  // The same distance rewritten around a dot product: |q - x|² = |q|² + |x|² - 2·q·x.
  // This is what lets a matrix multiplication do most of the work.
  TINYKNN_HD static float from_dot(float q_norm, float x_norm, float dot) {
    return q_norm + x_norm - 2.0f * dot;
  }
};

struct InnerProduct {
  static constexpr const char* name = "ip";
  static constexpr bool needs_norms = false;
  template <class T>
  TINYKNN_HD static float term(T a, T b) { return -float(a) * float(b); }
  TINYKNN_HD static float from_dot(float, float, float dot) { return -dot; }
};

#if defined(__cpp_concepts)
// What the search code needs from a metric, stated once. A type that doesn't fit fails here,
// with this name in the error, instead of deep inside the loop that tried to use it.
template <class M>
concept Metric = requires(float a, float b) {
  { M::template term<float>(a, b) } -> std::convertible_to<float>;
  { M::from_dot(a, a, b) } -> std::convertible_to<float>;
  { M::needs_norms } -> std::convertible_to<bool>;
};
#define TINYKNN_METRIC Metric
#else
#define TINYKNN_METRIC class
#endif

// Distance between two d-dimensional points, summed in LANES independent partial sums.
// Floating-point addition is not associative, so a compiler may not reorder one running sum
// into vector lanes on its own. Writing the lanes out states the order explicitly — the result
// is the same on every run, and the loop is free to become SIMD.
template <class M, int LANES = 8, class T>
TINYKNN_HD inline float distance(const T* a, const T* b, std::size_t d) {
  float acc[LANES] = {};
  std::size_t i = 0;
  for (; i + LANES <= d; i += LANES)
    for (int l = 0; l < LANES; ++l) acc[l] += M::term(a[i + l], b[i + l]);
  for (; i < d; ++i) acc[0] += M::term(a[i], b[i]);
  float s = 0.0f;
  for (int l = 0; l < LANES; ++l) s += acc[l];
  return s;
}

// Four queries against one database row. Every element of x is loaded once and used four
// times, and the 4 × LANES partial sums stay in registers. DIM > 0 fixes the dimension at
// compile time: the loop bound becomes a constant the compiler can unroll around.
// Same summation order as distance<M, LANES>, so both give bit-identical results.
template <class M, std::size_t DIM = 0, int LANES = 8, class T>
inline void distance_x4(const T* q, std::size_t q_stride, const T* x, std::size_t d_runtime,
                        float* out) {
  const std::size_t d = DIM ? DIM : d_runtime;
  float acc[4][LANES] = {};
  std::size_t i = 0;
  for (; i + LANES <= d; i += LANES)
    for (int b = 0; b < 4; ++b)
      for (int l = 0; l < LANES; ++l) acc[b][l] += M::term(q[b * q_stride + i + l], x[i + l]);
  if constexpr (DIM == 0 || DIM % LANES != 0)
    for (; i < d; ++i)
      for (int b = 0; b < 4; ++b) acc[b][0] += M::term(q[b * q_stride + i], x[i]);
  for (int b = 0; b < 4; ++b) {
    float s = 0.0f;
    for (int l = 0; l < LANES; ++l) s += acc[b][l];
    out[b] = s;
  }
}

}  // namespace tinyknn
Writing tinyknn/include/tinyknn/distance.hpp
%%writefile tinyknn/include/tinyknn/topk.hpp
// tinyknn/topk.hpp — keep the k smallest of a stream of (distance, id) pairs.
#pragma once
#include <algorithm>
#include <cstdint>
#include <limits>
#include <utility>
#include <vector>

namespace tinyknn {

// A max-heap of the k best so far: the root is the worst of the kept candidates, so a new
// candidate is compared with one number and usually rejected. With N ≫ k almost every
// candidate is, and the heap is touched O(k log N) times in total.
class topk {
 public:
  explicit topk(std::size_t k) : k_(k) { heap_.reserve(k); }

  float worst() const {
    return heap_.size() < k_ ? std::numeric_limits<float>::infinity() : heap_.front().first;
  }
  void push(float dist, std::int64_t id) {
    if (heap_.size() < k_) {
      heap_.emplace_back(dist, id);
      std::push_heap(heap_.begin(), heap_.end());
    } else if (dist < heap_.front().first) {
      std::pop_heap(heap_.begin(), heap_.end());
      heap_.back() = {dist, id};
      std::push_heap(heap_.begin(), heap_.end());
    }
  }
  // Write the kept pairs, closest first. Unfilled slots (N < k) get id -1.
  void write(std::int64_t* ids, float* dists) {
    std::sort_heap(heap_.begin(), heap_.end());
    for (std::size_t i = 0; i < k_; ++i) {
      bool have = i < heap_.size();
      ids[i] = have ? heap_[i].second : -1;
      dists[i] = have ? heap_[i].first : std::numeric_limits<float>::infinity();
    }
    heap_.clear();
  }

 private:
  std::size_t k_;
  std::vector<std::pair<float, std::int64_t>> heap_;
};

}  // namespace tinyknn
Writing tinyknn/include/tinyknn/topk.hpp

The CPU search. It allocates nothing for the data it reads or writes: the caller hands it views.

%%writefile tinyknn/include/tinyknn/search_cpu.hpp
// tinyknn/search_cpu.hpp — brute-force k-nearest-neighbour search on the CPU.
// A template: it is compiled once for every (metric, element type, dimension) it is used with.
#pragma once
#include <algorithm>
#include <cstdint>
#include <stdexcept>
#include <thread>
#include <vector>
#include "distance.hpp"
#include "topk.hpp"
#include "view.hpp"

namespace tinyknn {
namespace detail {

// Queries [q0, q1) against the whole database. The database is walked in blocks of
// `db_block` rows; every block is used by all of these queries while it is still in cache,
// so each row comes from main memory once per thread instead of once per four queries.
// db_block = 0 turns that off: each group of four queries walks the whole database itself.
template <class M, std::size_t DIM, class T>
void scan(matrix_view<const T> queries, matrix_view<const T> db, std::size_t q0, std::size_t q1,
          std::size_t db_block, std::vector<topk>& best) {
  const std::size_t dim = db.cols, block = db_block ? db_block : db.rows;
  for (std::size_t r0 = 0; r0 < db.rows; r0 += block) {
    const std::size_t r1 = std::min(db.rows, r0 + block);
    std::size_t q = q0;
    for (; q + 4 <= q1; q += 4)
      for (std::size_t n = r0; n < r1; ++n) {
        float d[4];
        distance_x4<M, DIM>(queries.row(q), queries.stride, db.row(n), dim, d);
        for (int b = 0; b < 4; ++b) best[q - q0 + b].push(d[b], std::int64_t(n));
      }
    for (; q < q1; ++q)
      for (std::size_t n = r0; n < r1; ++n)
        best[q - q0].push(distance<M>(queries.row(q), db.row(n), dim), std::int64_t(n));
  }
}

// The dimension is a run-time number; the kernels are faster when it is a compile-time one.
// This switch turns the common sizes into template arguments — one instantiation each —
// and sends everything else to the general version.
template <class M, class T, class... Args>
void scan_any_dim(std::size_t dim, bool fixed_dim, Args&&... args) {
  if (fixed_dim) switch (dim) {
      case 64: return scan<M, 64, T>(args...);
      case 128: return scan<M, 128, T>(args...);
      case 256: return scan<M, 256, T>(args...);
      case 384: return scan<M, 384, T>(args...);
      case 768: return scan<M, 768, T>(args...);
    }
  scan<M, 0, T>(args...);
}

}  // namespace detail

// queries: Q × D, db: N × D (read-only).  ids, dists: Q × k (written).
// Nothing is allocated for the data and nothing is returned: the caller decides where every
// byte lives, and the signature says which bytes the function may change.
template <TINYKNN_METRIC M, class T>
void search_cpu(matrix_view<const T> queries, matrix_view<const T> db,
                matrix_view<std::int64_t> ids, matrix_view<float> dists,
                int threads = 1, std::size_t db_block = 512, bool fixed_dim = true) {
  if (queries.where != memory::host || db.where != memory::host ||
      ids.where != memory::host || dists.where != memory::host)
    throw std::invalid_argument("search_cpu: every view must point to host memory");
  if (queries.cols != db.cols) throw std::invalid_argument("search_cpu: dimension mismatch");
  if (ids.rows != queries.rows || dists.rows != queries.rows || ids.cols != dists.cols)
    throw std::invalid_argument("search_cpu: output shape must be Q × k");
  const std::size_t k = ids.cols, Q = queries.rows;
  if (k == 0 || Q == 0) return;

  // Each worker owns a contiguous range of queries and one top-k per query. Workers share the
  // database read-only and write disjoint rows of the output: nothing to lock.
  auto work = [&](std::size_t q0, std::size_t q1) {
    std::vector<topk> best(q1 - q0, topk(k));
    detail::scan_any_dim<M, T>(db.cols, fixed_dim, queries, db, q0, q1, db_block, best);
    for (std::size_t q = q0; q < q1; ++q) best[q - q0].write(ids.row(q), dists.row(q));
  };

  threads = std::max(1, std::min<int>(threads, int(Q)));
  if (threads == 1) return work(0, Q);
  std::vector<std::thread> pool;
  const std::size_t per = (Q + threads - 1) / threads;
  for (int t = 0; t < threads; ++t) {
    const std::size_t q0 = t * per, q1 = std::min(Q, q0 + per);
    if (q0 < q1) pool.emplace_back(work, q0, q1);
  }
  for (auto& th : pool) th.join();
}

}  // namespace tinyknn
Writing tinyknn/include/tinyknn/search_cpu.hpp

What the shared library exports. No templates — they cannot cross a library boundary — and no CUDA types: the stream is an opaque pointer, and the GPU index hides everything behind one pointer to an implementation.

%%writefile tinyknn/include/tinyknn/api.hpp
// tinyknn/api.hpp — what libtinyknn.so exports. Templates cannot cross a shared-library
// boundary (they are recipes, not machine code), so the public surface is a handful of
// ordinary functions and one class, each compiled once inside the library.
#pragma once
#include <cstdint>
#include <memory>
#include "view.hpp"

struct CUstream_st;  // cudaStream_t without including CUDA headers in every user of the library

namespace tinyknn {

enum class metric { l2, inner_product };

// CPU search; every view points to host memory.
void search(metric m, matrix_view<const float> queries, matrix_view<const float> db,
            matrix_view<std::int64_t> ids, matrix_view<float> dists,
            int threads = 1, std::size_t db_block = 512, bool fixed_dim = true);

bool has_cuda();

// How the GPU computes the Q × N distance matrix before the top-k pass.
enum class gpu_algo {
  naive,  // one thread per (query, row) pair, walking the row itself
  gemm,   // norms + one matrix multiplication (cuBLAS), then norms added back in the top-k pass
};

// How the GPU picks the k smallest of each row of distances.
enum class topk_algo {
  runtime_k,       // one block per query; k is a run-time number, so each thread's list of
                   // candidates is indexed at run time and lives in slow per-thread memory
  compile_time_k,  // one block per query; k is a template argument (1, 4, 8, 10 or 16), so the
                   // list is fully unrolled into registers
  split_rows,      // as compile_time_k, but a row is split across many blocks, and a second
                   // pass merges their candidates — so a handful of queries still fills the GPU
};

// Low level: every pointer is device memory, nothing is allocated, and the call returns as soon
// as the work is queued on `stream`, typically long before the GPU has done it.
// scratch must hold queries.rows × (db.rows + 2048) floats; db_norms / query_norms may be null
// for metrics that don't need them (query_norms is scratch space this call fills).
void search_async(metric m, gpu_algo algo, topk_algo select,
                  matrix_view<const float> queries, matrix_view<const float> db,
                  const float* db_norms, float* query_norms, float* scratch,
                  matrix_view<std::int64_t> ids, matrix_view<float> dists,
                  CUstream_st* stream);

// Stage times of one gpu_index::search call, in milliseconds.
struct trace {
  double stage_in = 0;   // host: caller's queries → pinned staging buffer
  double queue = 0;      // host: issuing the copies and kernels (they run later)
  double h2d = 0;        // staging → GPU
  double distances = 0;  // Q × N distances
  double topk = 0;       // k smallest per query
  double d2h = 0;        // results → pinned staging
  double stage_out = 0;  // pinned staging → caller's arrays
  double total = 0;      // whole call, host clock
};

// High level: owns a copy of the database on the GPU, plus everything a search needs.
// The implementation (CUDA types, cuBLAS handle, buffers) is hidden behind a pointer, so this
// header — and the binary layout of the class — doesn't change when the internals do.
class gpu_index {
 public:
  gpu_index(matrix_view<const float> db, metric m);  // copies db to the GPU once
  ~gpu_index();
  gpu_index(gpu_index&&) noexcept;
  gpu_index& operator=(gpu_index&&) noexcept;

  std::size_t size() const;
  std::size_t dim() const;

  // Synchronous: returns when the results are in ids/dists (host memory).
  // With t != nullptr it stops after every stage to time it, which makes the call slower.
  void search(matrix_view<const float> queries, matrix_view<std::int64_t> ids,
              matrix_view<float> dists, gpu_algo algo,
              topk_algo select = topk_algo::split_rows, trace* t = nullptr);

  // Asynchronous, for at most chunk() queries: queue the whole search and return.
  // The results land in pinned host memory owned by the index (result_ids / result_dists),
  // and are only valid after wait().
  void enqueue(matrix_view<const float> queries, std::size_t k, gpu_algo algo,
               topk_algo select = topk_algo::split_rows);
  void wait();
  const std::int64_t* result_ids() const;
  const float* result_dists() const;
  std::size_t chunk() const;

 private:
  struct impl;
  std::unique_ptr<impl> p_;
};

}  // namespace tinyknn
Writing tinyknn/include/tinyknn/api.hpp
%%writefile tinyknn/src/cpu.cpp
// src/cpu.cpp — the CPU half of libtinyknn.so. The metric is a run-time value in the public
// API and a compile-time type inside: the switch below is the one place where one becomes
// the other, once per call, outside every loop.
#include "tinyknn/api.hpp"
#include "tinyknn/search_cpu.hpp"

namespace tinyknn {

void search(metric m, matrix_view<const float> queries, matrix_view<const float> db,
            matrix_view<std::int64_t> ids, matrix_view<float> dists,
            int threads, std::size_t db_block, bool fixed_dim) {
  switch (m) {
    case metric::l2:
      return search_cpu<L2>(queries, db, ids, dists, threads, db_block, fixed_dim);
    case metric::inner_product:
      return search_cpu<InnerProduct>(queries, db, ids, dists, threads, db_block, fixed_dim);
  }
}

#ifndef TINYKNN_WITH_CUDA
bool has_cuda() { return false; }
#endif

}  // namespace tinyknn
Writing tinyknn/src/cpu.cpp

The GPU half: an owning device buffer and a pinned host buffer with the same move-only contract, a naive distance kernel, the matrix-multiplication path through cuBLAS, a top-k kernel, and the index that owns the database on the card.

%%writefile tinyknn/src/cuda.cu
// src/cuda.cu — the GPU half of libtinyknn.so. Compiled by nvcc; nothing outside this file
// sees a CUDA type except the opaque stream pointer in api.hpp.
#include <cublas_v2.h>
#include <cuda_runtime.h>

#include <chrono>
#include <cmath>
#include <cstring>
#include <stdexcept>
#include <algorithm>
#include <string>
#include <type_traits>
#include <utility>

#include "tinyknn/api.hpp"
#include "tinyknn/distance.hpp"

namespace tinyknn {
namespace {

void check(cudaError_t e, const char* what) {
  if (e != cudaSuccess) throw std::runtime_error(std::string(what) + ": " + cudaGetErrorString(e));
}
void check(cublasStatus_t s, const char* what) {
  if (s != CUBLAS_STATUS_SUCCESS)
    throw std::runtime_error(std::string(what) + ": cuBLAS status " + std::to_string(int(s)));
}
#define CK(x) check((x), #x)

// GPU memory with the same contract as host_buffer: allocated in the constructor, freed in
// the destructor, moved but never copied.
template <class T>
class device_buffer {
 public:
  device_buffer() = default;
  explicit device_buffer(std::size_t n) : n_(n) {
    if (n) CK(cudaMalloc(reinterpret_cast<void**>(&p_), n * sizeof(T)));
  }
  ~device_buffer() {
    if (p_) cudaFree(p_);
  }
  device_buffer(const device_buffer&) = delete;
  device_buffer& operator=(const device_buffer&) = delete;
  device_buffer(device_buffer&& o) noexcept
      : p_(std::exchange(o.p_, nullptr)), n_(std::exchange(o.n_, 0)) {}
  device_buffer& operator=(device_buffer&& o) noexcept {
    if (this != &o) {
      if (p_) cudaFree(p_);
      p_ = std::exchange(o.p_, nullptr);
      n_ = std::exchange(o.n_, 0);
    }
    return *this;
  }
  T* data() const { return p_; }
  std::size_t size() const { return n_; }

 private:
  T* p_ = nullptr;
  std::size_t n_ = 0;
};

// Page-locked ("pinned") host memory. The GPU's copy engine can read and write it directly,
// which is what makes an asynchronous copy possible at all.
template <class T>
class pinned_buffer {
 public:
  pinned_buffer() = default;
  explicit pinned_buffer(std::size_t n) : n_(n) {
    if (n) CK(cudaMallocHost(reinterpret_cast<void**>(&p_), n * sizeof(T)));
  }
  ~pinned_buffer() {
    if (p_) cudaFreeHost(p_);
  }
  pinned_buffer(const pinned_buffer&) = delete;
  pinned_buffer& operator=(const pinned_buffer&) = delete;
  pinned_buffer(pinned_buffer&& o) noexcept
      : p_(std::exchange(o.p_, nullptr)), n_(std::exchange(o.n_, 0)) {}
  pinned_buffer& operator=(pinned_buffer&& o) noexcept {
    if (this != &o) {
      if (p_) cudaFreeHost(p_);
      p_ = std::exchange(o.p_, nullptr);
      n_ = std::exchange(o.n_, 0);
    }
    return *this;
  }
  T* data() const { return p_; }

 private:
  T* p_ = nullptr;
  std::size_t n_ = 0;
};

constexpr int KMAX = 16;          // largest k the top-k kernel supports
constexpr int TOPK_THREADS = 256;  // one block of this many threads per query

__global__ void row_norms(const float* x, std::size_t rows, std::size_t dim, float* out) {
  std::size_t r = blockIdx.x * std::size_t(blockDim.x) + threadIdx.x;
  if (r >= rows) return;
  const float* p = x + r * dim;
  float s = 0.0f;
  for (std::size_t i = 0; i < dim; ++i) s += p[i] * p[i];
  out[r] = s;
}

// One thread per (query, database row): the same distance<M> the CPU uses, run 25.6 million
// times in parallel. Neighbouring threads read rows `dim` floats apart, so their loads don't
// combine into wide memory transactions — the price of the simplest possible mapping.
template <class M>
__global__ void distances_naive(const float* q, const float* db, std::size_t n, std::size_t dim,
                                float* out) {
  std::size_t row = blockIdx.x * std::size_t(blockDim.x) + threadIdx.x;
  std::size_t qi = blockIdx.y;
  if (row >= n) return;
  out[qi * n + row] = distance<M>(q + qi * dim, db + row * dim, dim);
}

// One block per query. Each thread keeps its own sorted k best while striding over the row,
// then the 256 lists are merged pairwise in shared memory: 8 rounds of O(k) work.
template <class M, bool FROM_DOT>
__global__ void topk_rows(const float* scores, std::size_t n, int k, const float* q_norms,
                          const float* x_norms, std::int64_t* ids, float* dists) {
  __shared__ float sd[TOPK_THREADS * KMAX];
  __shared__ int si[TOPK_THREADS * KMAX];
  const int t = threadIdx.x;
  const std::size_t q = blockIdx.x;
  const float* row = scores + q * n;

  float bd[KMAX];
  int bi[KMAX];
  for (int j = 0; j < KMAX; ++j) {
    bd[j] = INFINITY;
    bi[j] = -1;
  }
  const float qn = (FROM_DOT && q_norms) ? q_norms[q] : 0.0f;
  for (std::size_t i = t; i < n; i += blockDim.x) {
    float v = row[i];
    if (FROM_DOT) v = M::from_dot(qn, x_norms ? x_norms[i] : 0.0f, v);
    if (v < bd[k - 1]) {
      int j = k - 1;
      while (j > 0 && bd[j - 1] > v) {
        bd[j] = bd[j - 1];
        bi[j] = bi[j - 1];
        --j;
      }
      bd[j] = v;
      bi[j] = int(i);
    }
  }
  for (int j = 0; j < k; ++j) {
    sd[t * KMAX + j] = bd[j];
    si[t * KMAX + j] = bi[j];
  }
  for (int s = 1; s < blockDim.x; s *= 2) {
    __syncthreads();
    if (t % (2 * s) == 0) {
      const float* ad = sd + t * KMAX;
      const int* ai = si + t * KMAX;
      const float* od = sd + (t + s) * KMAX;
      const int* oi = si + (t + s) * KMAX;
      float md[KMAX];
      int mi[KMAX];
      int a = 0, b = 0;
      for (int j = 0; j < k; ++j) {
        if (ad[a] <= od[b]) {
          md[j] = ad[a];
          mi[j] = ai[a++];
        } else {
          md[j] = od[b];
          mi[j] = oi[b++];
        }
      }
      for (int j = 0; j < k; ++j) {
        sd[t * KMAX + j] = md[j];
        si[t * KMAX + j] = mi[j];
      }
    }
  }
  __syncthreads();
  if (t < k) {
    ids[q * k + t] = si[t];
    dists[q * k + t] = sd[t];
  }
}

// Insert (v, i) into a sorted list of K held by one thread. With K a compile-time constant the
// loop unrolls completely, every index is a constant, and the list stays in registers.
template <int K>
__device__ __forceinline__ void insert(float (&d)[K], int (&id)[K], float v, int i) {
  if (!(v < d[K - 1])) return;
#pragma unroll
  for (int j = 0; j < K; ++j)
    if (v < d[j]) {
      const float tv = d[j];
      const int ti = id[j];
      d[j] = v;
      id[j] = i;
      v = tv;
      i = ti;
    }
}

// Merge the lists of all threads in a block, pairwise through shared memory.
// Afterwards thread 0 holds the block's K best, in its registers.
template <int K>
__device__ void block_select(float (&d)[K], int (&id)[K], float* sd, int* si) {
  const int t = threadIdx.x;
#pragma unroll
  for (int j = 0; j < K; ++j) {
    sd[t * K + j] = d[j];
    si[t * K + j] = id[j];
  }
  for (int s = 1; s < blockDim.x; s *= 2) {
    __syncthreads();
    if (t % (2 * s) == 0) {
#pragma unroll
      for (int j = 0; j < K; ++j) insert<K>(d, id, sd[(t + s) * K + j], si[(t + s) * K + j]);
#pragma unroll
      for (int j = 0; j < K; ++j) {
        sd[t * K + j] = d[j];
        si[t * K + j] = id[j];
      }
    }
  }
}

// Top-K of a slice of each row. Grid: (slices, queries). With one slice the block writes the
// final answer; with several, each block writes K candidates for topk_merge.
template <class M, bool FROM_DOT, int K>
__global__ void topk_fixed(const float* scores, std::size_t n, std::size_t slice,
                           const float* q_norms, const float* x_norms, std::int64_t* ids,
                           float* dists, float* cand_d, int* cand_i) {
  __shared__ float sd[TOPK_THREADS * K];
  __shared__ int si[TOPK_THREADS * K];
  const std::size_t q = blockIdx.y, s = blockIdx.x, S = gridDim.x;
  const std::size_t begin = s * slice, end = begin + slice < n ? begin + slice : n;
  const float* row = scores + q * n;
  float d[K];
  int id[K];
#pragma unroll
  for (int j = 0; j < K; ++j) {
    d[j] = INFINITY;
    id[j] = -1;
  }
  const float qn = (FROM_DOT && q_norms) ? q_norms[q] : 0.0f;
  for (std::size_t i = begin + threadIdx.x; i < end; i += blockDim.x) {
    float v = row[i];
    if (FROM_DOT) v = M::from_dot(qn, x_norms ? x_norms[i] : 0.0f, v);
    insert<K>(d, id, v, int(i));
  }
  block_select<K>(d, id, sd, si);
  if (threadIdx.x == 0) {
#pragma unroll
    for (int j = 0; j < K; ++j) {
      if (S == 1) {
        ids[q * K + j] = id[j];
        dists[q * K + j] = d[j];
      } else {
        cand_d[(q * S + s) * K + j] = d[j];
        cand_i[(q * S + s) * K + j] = id[j];
      }
    }
  }
}

// Second pass of split_rows: the K best of each query's S × K candidates.
template <int K>
__global__ void topk_merge(const float* cand_d, const int* cand_i, int S, std::int64_t* ids,
                           float* dists) {
  __shared__ float sd[TOPK_THREADS * K];
  __shared__ int si[TOPK_THREADS * K];
  const std::size_t q = blockIdx.x;
  float d[K];
  int id[K];
#pragma unroll
  for (int j = 0; j < K; ++j) {
    d[j] = INFINITY;
    id[j] = -1;
  }
  for (int c = threadIdx.x; c < S * K; c += blockDim.x)
    insert<K>(d, id, cand_d[q * S * K + c], cand_i[q * S * K + c]);
  block_select<K>(d, id, sd, si);
  if (threadIdx.x == 0)
#pragma unroll
    for (int j = 0; j < K; ++j) {
      ids[q * K + j] = id[j];
      dists[q * K + j] = d[j];
    }
}

int sm_count() {
  static const int n = [] {
    int dev = 0, v = 0;
    CK(cudaGetDevice(&dev));
    CK(cudaDeviceGetAttribute(&v, cudaDevAttrMultiProcessorCount, dev));
    return v;
  }();
  return n;
}

cublasHandle_t blas() {
  thread_local cublasHandle_t h = [] {
    cublasHandle_t x;
    CK(cublasCreate(&x));
    return x;
  }();
  return h;
}

void validate_device(matrix_view<const float> queries, matrix_view<const float> db,
                     matrix_view<std::int64_t> ids, matrix_view<float> dists) {
  if (queries.where != memory::device || db.where != memory::device ||
      ids.where != memory::device || dists.where != memory::device)
    throw std::invalid_argument("search_async: every view must point to device memory");
  if (!queries.contiguous() || !db.contiguous() || !ids.contiguous() || !dists.contiguous())
    throw std::invalid_argument("search_async: device views must be contiguous");
  if (queries.cols != db.cols) throw std::invalid_argument("search_async: dimension mismatch");
  if (ids.rows != queries.rows || dists.rows != queries.rows || ids.cols != dists.cols)
    throw std::invalid_argument("search_async: output shape must be Q × k");
  if (ids.cols < 1 || ids.cols > KMAX)
    throw std::invalid_argument("search_async: k must be between 1 and " + std::to_string(KMAX));
}

template <class M>
void launch_distances(gpu_algo algo, matrix_view<const float> q, matrix_view<const float> db,
                      float* query_norms, float* scratch, cudaStream_t s) {
  const std::size_t Q = q.rows, N = db.rows, D = db.cols;
  if (algo == gpu_algo::naive) {
    dim3 grid(unsigned((N + 255) / 256), unsigned(Q));
    distances_naive<M><<<grid, 256, 0, s>>>(q.data, db.data, N, D, scratch);
  } else {
    if (M::needs_norms) row_norms<<<unsigned((Q + 255) / 256), 256, 0, s>>>(q.data, Q, D, query_norms);
    // Row-major Q×D and N×D are column-major D×Q and D×N to cuBLAS. Asking for dbᵀ·q gives an
    // N×Q column-major result, which is exactly the Q×N row-major matrix of dot products.
    const float one = 1.0f, zero = 0.0f;
    CK(cublasSetStream(blas(), s));
    CK(cublasSgemm(blas(), CUBLAS_OP_T, CUBLAS_OP_N, int(N), int(Q), int(D), &one, db.data,
                   int(D), q.data, int(D), &zero, scratch, int(N)));
  }
  CK(cudaGetLastError());
}

template <class M, bool FROM_DOT>
void launch_select(topk_algo select, std::size_t Q, std::size_t N, int k, const float* scores,
                   const float* q_norms, const float* x_norms, std::int64_t* ids, float* dists,
                   float* cand, cudaStream_t s) {
  // k is a run-time number; the fast kernels need it as a type. Same move as the dimension
  // switch on the CPU: a few common values are compiled, anything else takes the general path.
  auto fixed = [&](auto k_const) {
    constexpr int K = decltype(k_const)::value;
    unsigned S = 1;
    if (select == topk_algo::split_rows) {
      // Enough blocks for about four per SM, but no slice shorter than 1,024 values.
      const std::size_t want = (4 * std::size_t(sm_count()) + Q - 1) / Q;
      S = unsigned(std::max<std::size_t>(1, std::min<std::size_t>({want, 64, N / 1024})));
    }
    const std::size_t slice = (N + S - 1) / S;
    float* cand_d = cand;
    int* cand_i = reinterpret_cast<int*>(cand + Q * S * K);
    topk_fixed<M, FROM_DOT, K><<<dim3(S, unsigned(Q)), TOPK_THREADS, 0, s>>>(
        scores, N, slice, q_norms, x_norms, ids, dists, cand_d, cand_i);
    if (S > 1) topk_merge<K><<<unsigned(Q), TOPK_THREADS, 0, s>>>(cand_d, cand_i, int(S), ids, dists);
  };
  if (select != topk_algo::runtime_k) switch (k) {
      case 1: return fixed(std::integral_constant<int, 1>{});
      case 4: return fixed(std::integral_constant<int, 4>{});
      case 8: return fixed(std::integral_constant<int, 8>{});
      case 10: return fixed(std::integral_constant<int, 10>{});
      case 16: return fixed(std::integral_constant<int, 16>{});
    }
  topk_rows<M, FROM_DOT><<<unsigned(Q), TOPK_THREADS, 0, s>>>(scores, N, k, q_norms, x_norms, ids, dists);
}

// scratch holds Q × N distances, then room for the split_rows candidates (≤ Q × 64 × 16 pairs).
template <class M>
void launch_topk(gpu_algo algo, topk_algo select, std::size_t Q, std::size_t N,
                 const float* db_norms, const float* query_norms, float* scratch,
                 matrix_view<std::int64_t> ids, matrix_view<float> dists, cudaStream_t s) {
  const int k = int(ids.cols);
  float* cand = scratch + Q * N;
  if (algo == gpu_algo::naive)
    launch_select<M, false>(select, Q, N, k, scratch, nullptr, nullptr, ids.data, dists.data, cand, s);
  else
    launch_select<M, true>(select, Q, N, k, scratch, M::needs_norms ? query_norms : nullptr,
                           M::needs_norms ? db_norms : nullptr, ids.data, dists.data, cand, s);
  CK(cudaGetLastError());
}

// The run-time metric becomes a type here, once per call — the same move as in cpu.cpp.
template <class F>
void with_metric(metric m, F&& f) {
  if (m == metric::l2) f(L2{});
  else f(InnerProduct{});
}

double ms_since(std::chrono::steady_clock::time_point t0) {
  return std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0).count();
}

}  // namespace

bool has_cuda() {
  int n = 0;
  return cudaGetDeviceCount(&n) == cudaSuccess && n > 0;
}

void search_async(metric m, gpu_algo algo, topk_algo select, matrix_view<const float> queries,
                  matrix_view<const float> db, const float* db_norms, float* query_norms,
                  float* scratch, matrix_view<std::int64_t> ids, matrix_view<float> dists,
                  CUstream_st* stream) {
  validate_device(queries, db, ids, dists);
  with_metric(m, [&](auto metric_tag) {
    using M = decltype(metric_tag);
    if (algo == gpu_algo::gemm && M::needs_norms && (!db_norms || !query_norms))
      throw std::invalid_argument("search_async: this metric needs norms for the gemm path");
    launch_distances<M>(algo, queries, db, query_norms, scratch, stream);
    launch_topk<M>(algo, select, queries.rows, db.rows, db_norms, query_norms, scratch, ids, dists, stream);
  });
}

struct gpu_index::impl {
  metric m;
  std::size_t n, dim, chunk = 256, k_alloc = 0;
  device_buffer<float> db, db_norms, q, q_norms, scratch;
  device_buffer<std::int64_t> ids;
  device_buffer<float> dists;
  pinned_buffer<float> q_stage;
  pinned_buffer<std::int64_t> ids_stage;
  pinned_buffer<float> dists_stage;
  cudaStream_t stream = nullptr;
  cudaEvent_t ev[5] = {};
  std::size_t pending_rows = 0, pending_k = 0;

  impl(matrix_view<const float> host_db, metric metric_)
      : m(metric_), n(host_db.rows), dim(host_db.cols) {
    if (host_db.where != memory::host) throw std::invalid_argument("gpu_index: db must be host memory");
    db = device_buffer<float>(n * dim);
    CK(cudaMemcpy2D(db.data(), dim * sizeof(float), host_db.data, host_db.stride * sizeof(float),
                    dim * sizeof(float), n, cudaMemcpyHostToDevice));
    db_norms = device_buffer<float>(n);
    row_norms<<<unsigned((n + 255) / 256), 256>>>(db.data(), n, dim, db_norms.data());
    CK(cudaGetLastError());
    q = device_buffer<float>(chunk * dim);
    q_norms = device_buffer<float>(chunk);
    scratch = device_buffer<float>(chunk * (n + 2048));
    q_stage = pinned_buffer<float>(chunk * dim);
    CK(cudaDeviceSynchronize());
    CK(cudaStreamCreate(&stream));
    for (auto& e : ev) CK(cudaEventCreate(&e));
  }
  ~impl() {
    if (stream) cudaStreamSynchronize(stream);
    for (auto& e : ev)
      if (e) cudaEventDestroy(e);
    if (stream) cudaStreamDestroy(stream);
  }

  void ensure_k(std::size_t k) {
    if (k < 1 || k > std::size_t(KMAX))
      throw std::invalid_argument("gpu_index: k must be between 1 and " + std::to_string(KMAX));
    if (k <= k_alloc) return;
    ids = device_buffer<std::int64_t>(chunk * k);
    dists = device_buffer<float>(chunk * k);
    ids_stage = pinned_buffer<std::int64_t>(chunk * k);
    dists_stage = pinned_buffer<float>(chunk * k);
    k_alloc = k;
  }

  // Queue one chunk: copy in, compute, copy out. Events mark the stage boundaries.
  // Returns the host time of the two parts: {staging memcpy, issuing the GPU work}.
  std::pair<double, double> queue_chunk(matrix_view<const float> rows, std::size_t k, gpu_algo algo,
                                        topk_algo select) {
    const std::size_t R = rows.rows;
    // The staging buffers are reused: the previous chunk's copies must be finished first.
    CK(cudaStreamSynchronize(stream));
    const auto t0 = std::chrono::steady_clock::now();
    for (std::size_t i = 0; i < R; ++i)
      std::memcpy(q_stage.data() + i * dim, rows.row(i), dim * sizeof(float));
    const double staged = ms_since(t0);
    const auto t1 = std::chrono::steady_clock::now();
    CK(cudaEventRecord(ev[0], stream));
    CK(cudaMemcpyAsync(q.data(), q_stage.data(), R * dim * sizeof(float), cudaMemcpyHostToDevice, stream));
    CK(cudaEventRecord(ev[1], stream));
    auto dq = make_view<const float>(q.data(), R, dim, memory::device);
    auto ddb = make_view<const float>(db.data(), n, dim, memory::device);
    auto did = make_view(ids.data(), R, k, memory::device);
    auto ddi = make_view(dists.data(), R, k, memory::device);
    with_metric(m, [&](auto tag) {
      using M = decltype(tag);
      launch_distances<M>(algo, dq, ddb, q_norms.data(), scratch.data(), stream);
      CK(cudaEventRecord(ev[2], stream));
      launch_topk<M>(algo, select, R, n, db_norms.data(), q_norms.data(), scratch.data(), did, ddi, stream);
    });
    CK(cudaEventRecord(ev[3], stream));
    CK(cudaMemcpyAsync(ids_stage.data(), ids.data(), R * k * sizeof(std::int64_t), cudaMemcpyDeviceToHost, stream));
    CK(cudaMemcpyAsync(dists_stage.data(), dists.data(), R * k * sizeof(float), cudaMemcpyDeviceToHost, stream));
    CK(cudaEventRecord(ev[4], stream));
    return {staged, ms_since(t1)};
  }
};

gpu_index::gpu_index(matrix_view<const float> db, metric m) : p_(std::make_unique<impl>(db, m)) {}
gpu_index::~gpu_index() = default;
gpu_index::gpu_index(gpu_index&&) noexcept = default;
gpu_index& gpu_index::operator=(gpu_index&&) noexcept = default;
std::size_t gpu_index::size() const { return p_->n; }
std::size_t gpu_index::dim() const { return p_->dim; }
std::size_t gpu_index::chunk() const { return p_->chunk; }

void gpu_index::search(matrix_view<const float> queries, matrix_view<std::int64_t> ids,
                       matrix_view<float> dists, gpu_algo algo, topk_algo select, trace* t) {
  auto& P = *p_;
  if (queries.where != memory::host || ids.where != memory::host || dists.where != memory::host)
    throw std::invalid_argument("gpu_index::search: queries and outputs are host memory");
  if (queries.cols != P.dim) throw std::invalid_argument("gpu_index::search: dimension mismatch");
  if (ids.rows != queries.rows || dists.rows != queries.rows || ids.cols != dists.cols)
    throw std::invalid_argument("gpu_index::search: output shape must be Q × k");
  const std::size_t k = ids.cols;
  P.ensure_k(k);
  const auto t_call = std::chrono::steady_clock::now();
  if (t) *t = trace{};
  for (std::size_t c0 = 0; c0 < queries.rows; c0 += P.chunk) {
    const std::size_t c1 = std::min(queries.rows, c0 + P.chunk), R = c1 - c0;
    const auto host = P.queue_chunk(queries.slice_rows(c0, c1), k, algo, select);
    if (t) {
      t->stage_in += host.first;
      t->queue += host.second;
    }
    CK(cudaStreamSynchronize(P.stream));
    if (t) {
      float a, b, c, d;
      CK(cudaEventElapsedTime(&a, P.ev[0], P.ev[1]));
      CK(cudaEventElapsedTime(&b, P.ev[1], P.ev[2]));
      CK(cudaEventElapsedTime(&c, P.ev[2], P.ev[3]));
      CK(cudaEventElapsedTime(&d, P.ev[3], P.ev[4]));
      t->h2d += a;
      t->distances += b;
      t->topk += c;
      t->d2h += d;
    }
    auto t1 = std::chrono::steady_clock::now();
    for (std::size_t i = 0; i < R; ++i) {
      std::memcpy(ids.row(c0 + i), P.ids_stage.data() + i * k, k * sizeof(std::int64_t));
      std::memcpy(dists.row(c0 + i), P.dists_stage.data() + i * k, k * sizeof(float));
    }
    if (t) t->stage_out += ms_since(t1);
  }
  if (t) t->total = ms_since(t_call);
}

void gpu_index::enqueue(matrix_view<const float> queries, std::size_t k, gpu_algo algo,
                        topk_algo select) {
  auto& P = *p_;
  if (queries.where != memory::host || queries.cols != P.dim)
    throw std::invalid_argument("gpu_index::enqueue: host queries of the index's dimension");
  if (queries.rows > P.chunk)
    throw std::invalid_argument("gpu_index::enqueue: at most chunk() queries");
  P.ensure_k(k);
  P.queue_chunk(queries, k, algo, select);
  P.pending_rows = queries.rows;
  P.pending_k = k;
}
void gpu_index::wait() { CK(cudaStreamSynchronize(p_->stream)); }
const std::int64_t* gpu_index::result_ids() const { return p_->ids_stage.data(); }
const float* gpu_index::result_dists() const { return p_->dists_stage.data(); }

}  // namespace tinyknn
Writing tinyknn/src/cuda.cu

The Python module. It converts arrays into views, calls the library, and wraps the results.

%%writefile tinyknn/python/bindings.cpp
// python/bindings.cpp — the Python module _tinyknn. Turns NumPy arrays into views, calls the
// library, turns the results back into NumPy arrays. No numerical code lives here.
#include <pybind11/numpy.h>
#include <pybind11/pybind11.h>
#include <pybind11/stl.h>

#include <chrono>
#include <cstdint>
#include <string>

#include "tinyknn/api.hpp"

namespace py = pybind11;
using clk = std::chrono::steady_clock;

// "Give me C-contiguous float32 — and if the array isn't, convert it."
// The conversion happens before the function body runs: a float64 array arrives as a fresh
// float32 copy, silently. `strict` arrays refuse instead (see probe_strict).
using farray = py::array_t<float, py::array::c_style | py::array::forcecast>;
using strict = py::array_t<float, py::array::c_style>;

static tinyknn::matrix_view<const float> view_of(const farray& a, const char* name) {
  if (a.ndim() != 2) throw std::invalid_argument(std::string(name) + " must be 2-D");
  return tinyknn::make_view(a.data(), std::size_t(a.shape(0)), std::size_t(a.shape(1)));
}

static tinyknn::metric metric_of(const std::string& m) {
  if (m == "l2") return tinyknn::metric::l2;
  if (m == "ip") return tinyknn::metric::inner_product;
  throw std::invalid_argument("metric must be 'l2' or 'ip'");
}

static tinyknn::gpu_algo algo_of(const std::string& a) {
  if (a == "naive") return tinyknn::gpu_algo::naive;
  if (a == "gemm") return tinyknn::gpu_algo::gemm;
  throw std::invalid_argument("algo must be 'naive' or 'gemm'");
}

static tinyknn::topk_algo topk_of(const std::string& a) {
  if (a == "runtime_k") return tinyknn::topk_algo::runtime_k;
  if (a == "compile_time_k") return tinyknn::topk_algo::compile_time_k;
  if (a == "split_rows") return tinyknn::topk_algo::split_rows;
  throw std::invalid_argument("topk must be 'runtime_k', 'compile_time_k' or 'split_rows'");
}

static double ms(clk::time_point a, clk::time_point b) {
  return std::chrono::duration<double, std::milli>(b - a).count();
}

PYBIND11_MODULE(_tinyknn, m) {
  m.doc() = "tinyknn: brute-force nearest-neighbour search, CPU and CUDA";

  m.def("has_cuda", &tinyknn::has_cuda);

  m.def(
      "search",
      [](farray queries, farray db, int k, const std::string& metric, int threads,
         std::size_t db_block, bool fixed_dim, bool release_gil) {
        auto qv = view_of(queries, "queries"), dv = view_of(db, "db");
        const auto Q = qv.rows;
        py::array_t<std::int64_t> ids({Q, std::size_t(k)});
        py::array_t<float> dists({Q, std::size_t(k)});
        auto iv = tinyknn::make_view(ids.mutable_data(), Q, std::size_t(k));
        auto sv = tinyknn::make_view(dists.mutable_data(), Q, std::size_t(k));
        auto run = [&] { tinyknn::search(metric_of(metric), qv, dv, iv, sv, threads, db_block, fixed_dim); };
        if (release_gil) {
          // From here to the end of the scope this thread touches no Python object, so other
          // Python threads may run. The arrays stay alive: this frame holds references to them.
          py::gil_scoped_release unlocked;
          run();
        } else {
          run();
        }
        return py::make_tuple(ids, dists);
      },
      py::arg("queries"), py::arg("db"), py::arg("k") = 10, py::arg("metric") = "l2",
      py::arg("threads") = 1, py::arg("db_block") = 512, py::arg("fixed_dim") = true,
      py::arg("release_gil") = true);

  // The address C++ ends up reading from. Equal to arr.ctypes.data → no copy was made.
  m.def("probe", [](farray a) { return reinterpret_cast<std::uintptr_t>(a.data()); });
  m.def("probe_strict", [](strict a) { return reinterpret_cast<std::uintptr_t>(a.data()); },
        py::arg("a").noconvert());

  // The cheapest possible call, and one that does n floats of work: the price of crossing
  // from Python into C++ once, and how fast it is paid back.
  m.def("noop", [] {});
  m.def("sum", [](farray a) {
    const float* p = a.data();
    const auto n = std::size_t(a.size());
    float acc[16] = {};
    std::size_t i = 0;
    for (; i + 16 <= n; i += 16)
      for (int l = 0; l < 16; ++l) acc[l] += p[i + l];
    for (; i < n; ++i) acc[0] += p[i];
    float s = 0;
    for (float v : acc) s += v;
    return s;
  });

#ifdef TINYKNN_WITH_CUDA
  py::class_<tinyknn::gpu_index>(m, "Index")
      .def(py::init([](farray db, const std::string& metric) {
             return tinyknn::gpu_index(view_of(db, "db"), metric_of(metric));
           }),
           py::arg("db"), py::arg("metric") = "l2")
      .def_property_readonly("size", &tinyknn::gpu_index::size)
      .def_property_readonly("dim", &tinyknn::gpu_index::dim)
      .def(
          "search",
          [](tinyknn::gpu_index& self, farray queries, int k, const std::string& algo,
             bool trace, const std::string& topk) -> py::tuple {
            const auto t_enter = clk::now();
            auto qv = view_of(queries, "queries");
            const auto Q = qv.rows;
            py::array_t<std::int64_t> ids({Q, std::size_t(k)});
            py::array_t<float> dists({Q, std::size_t(k)});
            auto iv = tinyknn::make_view(ids.mutable_data(), Q, std::size_t(k));
            auto sv = tinyknn::make_view(dists.mutable_data(), Q, std::size_t(k));
            const auto t_ready = clk::now();
            tinyknn::trace t;
            {
              py::gil_scoped_release unlocked;
              self.search(qv, iv, sv, algo_of(algo), topk_of(topk), trace ? &t : nullptr);
            }
            const auto t_done = clk::now();
            if (!trace) return py::make_tuple(ids, dists);
            py::dict d;
            d["bind_in"] = ms(t_enter, t_ready);  // views + output arrays allocated
            d["stage_in"] = t.stage_in;
            d["queue"] = t.queue;
            d["h2d"] = t.h2d;
            d["distances"] = t.distances;
            d["topk"] = t.topk;
            d["d2h"] = t.d2h;
            d["stage_out"] = t.stage_out;
            d["library_total"] = t.total;
            d["cpp_total"] = ms(t_enter, t_done);
            return py::make_tuple(ids, dists, d);
          },
          py::arg("queries"), py::arg("k") = 10, py::arg("algo") = "gemm",
          py::arg("trace") = false, py::arg("topk") = "split_rows")
      .def(
          "enqueue",
          [](tinyknn::gpu_index& self, farray queries, int k, const std::string& algo,
             const std::string& topk) {
            self.enqueue(view_of(queries, "queries"), std::size_t(k), algo_of(algo), topk_of(topk));
          },
          py::arg("queries"), py::arg("k") = 10, py::arg("algo") = "gemm",
          py::arg("topk") = "split_rows")
      .def("wait", &tinyknn::gpu_index::wait, py::call_guard<py::gil_scoped_release>())
      // A NumPy array that *views* the index's pinned result buffer — no copy. Passing the
      // index as the array's base keeps the index (and so the buffer) alive as long as the
      // array is: ownership stays with C++, lifetime is extended by Python's reference count.
      .def(
          "peek_ids",
          [](py::object self_obj, std::size_t rows, std::size_t k) {
            auto& self = self_obj.cast<tinyknn::gpu_index&>();
            if (rows > self.chunk()) throw std::invalid_argument("rows > chunk");
            return py::array_t<std::int64_t>({rows, k}, self.result_ids(), self_obj);
          },
          py::arg("rows"), py::arg("k"));
#endif
}
Writing tinyknn/python/bindings.cpp

Build

Four commands: two compilers, two links. Each step is timed, and the size of what it produced is recorded.

def find_cuda_lib(name):
    """Directory and file name of lib<name>.so.* — the CUDA toolkit first, then pip packages."""
    roots = [f"{CUDA_HOME}/lib64", f"{CUDA_HOME}/targets/x86_64-linux/lib"] if CUDA_HOME else []
    roots += glob.glob(f"{sysconfig.get_paths()['purelib']}/nvidia/*/lib")
    for r in roots:
        hits = sorted(glob.glob(f"{r}/lib{name}.so*"), key=len)
        if hits:
            return r, Path(hits[0]).name
    return None, None


EXT = sysconfig.get_config_var("EXT_SUFFIX")
PYINC = sh(f"{sys.executable} -m pybind11 --includes").stdout.strip()
HAVE_CUDA = False
with experiment("build"):
    cublas_dir, cublas_file = find_cuda_lib("cublas")
    cudart_dir, cudart_file = find_cuda_lib("cudart")
    HAVE_CUDA = bool(NVCC and cublas_dir and cudart_dir)
    cuda_def = "-DTINYKNN_WITH_CUDA" if HAVE_CUDA else ""
    cublas_inc = " ".join(f"-I{p}" for p in glob.glob(f"{sysconfig.get_paths()['purelib']}/nvidia/cublas/include"))
    steps = [("cpu.cpp → cpu.o",
              f"g++ -std=c++20 -O3 -march=native -fPIC {cuda_def} -Itinyknn/include -c tinyknn/src/cpu.cpp -o build/cpu.o",
              "build/cpu.o")]
    if HAVE_CUDA:
        steps.append(("cuda.cu → cuda.o",
                      f"{NVCC} -std=c++17 -O3 -arch=sm_75 -Xptxas -v -Xcompiler -fPIC -Itinyknn/include {cublas_inc} "
                      f"-c tinyknn/src/cuda.cu -o build/cuda.o", "build/cuda.o"))
        steps.append(("link libtinyknn.so",
                      f"g++ -shared build/cpu.o build/cuda.o -L{cudart_dir} -l:{cudart_file} -L{cublas_dir} "
                      f"-l:{cublas_file} -Wl,-rpath,{cudart_dir} -Wl,-rpath,{cublas_dir} -o build/libtinyknn.so",
                      "build/libtinyknn.so"))
    else:
        steps.append(("link libtinyknn.so", "g++ -shared build/cpu.o -o build/libtinyknn.so", "build/libtinyknn.so"))
    steps.append(("bindings.cpp → _tinyknn module",
                  f"g++ -std=c++20 -O3 -fPIC -shared {cuda_def} {PYINC} -Itinyknn/include tinyknn/python/bindings.cpp "
                  f"-Lbuild -ltinyknn -Wl,-rpath,'$ORIGIN' -o build/_tinyknn{EXT}", f"build/_tinyknn{EXT}"))
    rows, kernels = [], []
    for name, cmd, out in steps:
        t0 = time.perf_counter()
        p = sh(cmd)
        rows.append({"step": name, "seconds": round(time.perf_counter() - t0, 2),
                     "output_bytes": Path(out).stat().st_size})
        if "-Xptxas -v" in cmd:
            # ptxas reports, per compiled kernel, its registers and its stack frame: bytes of
            # per-thread memory that live outside the registers, in slow "local" memory.
            for block in p.stderr.split("Compiling entry function")[1:]:
                mangled = block.split("'")[1]
                stack = re.search(r"(\d+) bytes stack frame", block)
                regs = re.search(r"Used (\d+) registers", block)
                kernels.append({"kernel": sh(f"echo {mangled} | c++filt").stdout.strip(),
                                "registers": int(regs.group(1)) if regs else None,
                                "stack_frame_bytes": int(stack.group(1)) if stack else None})
    RESULTS["build"] = {"cuda": HAVE_CUDA, "steps": rows, "kernels": kernels}
    display(pd.DataFrame(rows))
    if kernels:
        kd = pd.DataFrame(kernels)
        kd["kernel"] = kd.kernel.str.replace(r"tinyknn::\(anonymous namespace\)::", "", regex=True).str.slice(0, 90)
        display(kd)

sys.path.insert(0, str(Path("build").resolve()))
import _tinyknn as tk
HAVE_CUDA = HAVE_CUDA and tk.has_cuda()        # built for CUDA, and a GPU is actually there
print("GPU support:", HAVE_CUDA)
step seconds output_bytes
0 cpu.cpp → cpu.o 3.88 77840
1 cuda.cu → cuda.o 10.43 1000088
2 link libtinyknn.so 0.12 1034088
3 bindings.cpp → _tinyknn module 11.27 354136
kernel registers stack_frame_bytes
0 void topk_rows<tinyknn::InnerProduct, true>(fl... 50 128
1 void topk_fixed<tinyknn::InnerProduct, true, 1... 79 0
2 void topk_fixed<tinyknn::InnerProduct, true, 1... 63 0
3 void topk_fixed<tinyknn::InnerProduct, true, 8... 61 0
4 void topk_fixed<tinyknn::InnerProduct, true, 4... 35 0
5 void topk_fixed<tinyknn::InnerProduct, true, 1... 20 0
6 void topk_rows<tinyknn::InnerProduct, false>(f... 50 128
7 void topk_fixed<tinyknn::InnerProduct, false, ... 79 0
8 void topk_fixed<tinyknn::InnerProduct, false, ... 63 0
9 void topk_fixed<tinyknn::InnerProduct, false, ... 61 0
10 void topk_fixed<tinyknn::InnerProduct, false, ... 35 0
11 void topk_fixed<tinyknn::InnerProduct, false, ... 20 0
12 void distances_naive<tinyknn::InnerProduct>(fl... 54 0
13 void topk_rows<tinyknn::L2, true>(float const*... 50 128
14 void topk_fixed<tinyknn::L2, true, 16>(float c... 79 0
15 void topk_fixed<tinyknn::L2, true, 10>(float c... 63 0
16 void topk_fixed<tinyknn::L2, true, 8>(float co... 61 0
17 void topk_fixed<tinyknn::L2, true, 4>(float co... 31 0
18 void topk_fixed<tinyknn::L2, true, 1>(float co... 20 0
19 void topk_rows<tinyknn::L2, false>(float const... 50 128
20 void topk_merge<16>(float const*, int const*, ... 79 0
21 void topk_fixed<tinyknn::L2, false, 16>(float ... 79 0
22 void topk_merge<10>(float const*, int const*, ... 63 0
23 void topk_fixed<tinyknn::L2, false, 10>(float ... 63 0
24 void topk_merge<8>(float const*, int const*, i... 59 0
25 void topk_fixed<tinyknn::L2, false, 8>(float c... 61 0
26 void topk_merge<4>(float const*, int const*, i... 35 0
27 void topk_fixed<tinyknn::L2, false, 4>(float c... 35 0
28 void topk_merge<1>(float const*, int const*, i... 18 0
29 void topk_fixed<tinyknn::L2, false, 1>(float c... 20 0
30 void distances_naive<tinyknn::L2>(float const*... 54 0
31 row_norms(float const*, unsigned long, unsigne... 20 0
[build: 25.9 s]
GPU support: True

Tests

The library is only worth timing if it is right. The reference is NumPy in double precision: exact distances, then the k smallest.

def exact_knn(q, db, k, metric="l2"):
    q, db = q.astype(np.float64), db.astype(np.float64)
    d = ((q * q).sum(1)[:, None] + (db * db).sum(1)[None] - 2 * q @ db.T) if metric == "l2" else -(q @ db.T)
    kk = min(k, db.shape[0])
    i = np.argpartition(d, kk - 1, axis=1)[:, :kk]
    order = np.take_along_axis(d, i, 1).argsort(1)
    return np.take_along_axis(i, order, 1)


def recall(ids, ref):
    k = ref.shape[1]
    return float(np.mean([len(set(a[:k]) & set(b)) / k for a, b in zip(ids, ref)]))


N, D, K = 100_000, 128, 10
rng = np.random.default_rng(0)
DB = rng.standard_normal((N, D), dtype=np.float32)
QUERIES = rng.standard_normal((1000, D), dtype=np.float32)

with experiment("tests"):
    rows = []
    for metric in ["l2", "ip"]:
        for dim in [128, 100]:                                  # 128 is specialised, 100 is not
            q, db = QUERIES[:37, :dim], DB[:20_000, :dim]      # 37: not a multiple of 4
            ref = exact_knn(q, db, K, metric)
            ids, dists = tk.search(q, db, K, metric)
            rows.append({"path": "cpu", "metric": metric, "dim": dim, "recall": recall(ids, ref),
                         "sorted": bool((np.diff(dists, axis=1) >= 0).all())})
            if HAVE_CUDA:
                index = tk.Index(db, metric)
                for algo in ["naive", "gemm"]:
                    for sel in ["runtime_k", "compile_time_k", "split_rows"]:
                        ids, dists = index.search(q, K, algo, topk=sel)
                        rows.append({"path": f"gpu {algo} / {sel}", "metric": metric, "dim": dim,
                                     "recall": recall(ids, ref), "sorted": bool((np.diff(dists, axis=1) >= 0).all())})
                # k = 7 has no compiled kernel: it must fall back to the general one and still be right.
                ids7, _ = index.search(q[:5], 7, "gemm", topk="split_rows")
                rows.append({"path": "gpu gemm / k=7 fallback", "metric": metric, "dim": dim,
                             "recall": recall(ids7, exact_knn(q[:5], db, 7, metric)), "sorted": True})
                del index
    # Edge cases: fewer rows than k, a non-contiguous query array, threads.
    ids, _ = tk.search(QUERIES[:3], DB[:5], K)
    edge_short = bool((ids[:, 5:] == -1).all() and (np.sort(ids[:, :5], 1) == np.arange(5)).all())
    strided = QUERIES[:40:2]
    edge_strided = recall(tk.search(strided, DB[:20_000], K)[0], exact_knn(strided, DB[:20_000], K)) == 1.0
    edge_threads = bool((tk.search(QUERIES[:50], DB[:20_000], K, threads=4)[0] ==
                         tk.search(QUERIES[:50], DB[:20_000], K, threads=1)[0]).all())
    RESULTS["tests"] = {"rows": rows, "fewer_rows_than_k": edge_short, "strided_queries": edge_strided,
                        "threads_identical": edge_threads}
    display(pd.DataFrame(rows))
    print("fewer rows than k:", edge_short, "| strided queries:", edge_strided, "| 1 vs 4 threads identical:", edge_threads)
path metric dim recall sorted
0 cpu l2 128 1.0 True
1 gpu naive / runtime_k l2 128 1.0 True
2 gpu naive / compile_time_k l2 128 1.0 True
3 gpu naive / split_rows l2 128 1.0 True
4 gpu gemm / runtime_k l2 128 1.0 True
5 gpu gemm / compile_time_k l2 128 1.0 True
6 gpu gemm / split_rows l2 128 1.0 True
7 gpu gemm / k=7 fallback l2 128 1.0 True
8 cpu l2 100 1.0 True
9 gpu naive / runtime_k l2 100 1.0 True
10 gpu naive / compile_time_k l2 100 1.0 True
11 gpu naive / split_rows l2 100 1.0 True
12 gpu gemm / runtime_k l2 100 1.0 True
13 gpu gemm / compile_time_k l2 100 1.0 True
14 gpu gemm / split_rows l2 100 1.0 True
15 gpu gemm / k=7 fallback l2 100 1.0 True
16 cpu ip 128 1.0 True
17 gpu naive / runtime_k ip 128 1.0 True
18 gpu naive / compile_time_k ip 128 1.0 True
19 gpu naive / split_rows ip 128 1.0 True
20 gpu gemm / runtime_k ip 128 1.0 True
21 gpu gemm / compile_time_k ip 128 1.0 True
22 gpu gemm / split_rows ip 128 1.0 True
23 gpu gemm / k=7 fallback ip 128 1.0 True
24 cpu ip 100 1.0 True
25 gpu naive / runtime_k ip 100 1.0 True
26 gpu naive / compile_time_k ip 100 1.0 True
27 gpu naive / split_rows ip 100 1.0 True
28 gpu gemm / runtime_k ip 100 1.0 True
29 gpu gemm / compile_time_k ip 100 1.0 True
30 gpu gemm / split_rows ip 100 1.0 True
31 gpu gemm / k=7 fallback ip 100 1.0 True
fewer rows than k: True | strided queries: True | 1 vs 4 threads identical: True
[tests: 0.7 s]

Part 2 — One search, four ways

The same question — the 10 nearest of 100,000 vectors of 128 floats — asked of pure Python, NumPy, the library on the CPU, and the library on the GPU. First for one query, then for a thousand (the broadcast version, at about 50 ms a query, for sixteen). The data is random: the cost of brute force depends on the shapes, not on the values.

def python_search(q, rows, k):
    best = []
    for i, row in enumerate(rows):
        s = 0.0
        for a, b in zip(q, row):
            d = a - b
            s += d * d
        best.append((s, i))
    best.sort()
    return [i for _, i in best[:k]]


DB_NORMS = (DB * DB).sum(1)                       # computed once, as an index would


def numpy_broadcast(q, k=K):                      # one query: every difference materialised
    d = ((DB - q) ** 2).sum(1)
    i = np.argpartition(d, k)[:k]
    return i[np.argsort(d[i])]


def numpy_gemm(qs, k=K):                          # many queries: one matrix multiplication
    d = (qs * qs).sum(1)[:, None] + DB_NORMS[None] - 2.0 * (qs @ DB.T)
    i = np.argpartition(d, k, axis=1)[:, :k]
    return np.take_along_axis(i, np.take_along_axis(d, i, 1).argsort(1), 1)


with experiment("one_search"):
    rows = []
    db_list, q_list = DB.tolist(), QUERIES[0].tolist()
    t = best_time(lambda: python_search(q_list, db_list, K), reps=1, warmup=0)
    rows.append({"impl": "pure Python", "queries": 1, "ms_total": t * 1e3})
    del db_list

    q1 = QUERIES[:1]
    cases = [("NumPy, broadcast", lambda q: np.stack([numpy_broadcast(x) for x in q])),
             ("NumPy, matrix multiply", numpy_gemm),
             ("tinyknn CPU, 1 thread", lambda q: tk.search(q, DB, K, threads=1)),
             (f"tinyknn CPU, {os.cpu_count()} threads", lambda q: tk.search(q, DB, K, threads=os.cpu_count()))]
    if HAVE_CUDA:
        INDEX = tk.Index(DB)
        cases.append(("tinyknn GPU", lambda q: INDEX.search(q, K)))
    for name, fn in cases:
        for nq in ([1, 16] if "broadcast" in name else [1, 1000]):   # broadcast: ~50 ms a query
            q = QUERIES[:nq]
            t = best_time(lambda: fn(q), reps=5)
            rows.append({"impl": name, "queries": nq, "ms_total": t * 1e3})
    df = pd.DataFrame(rows)
    df["ms_per_query"] = df.ms_total / df.queries
    RESULTS["one_search"] = df.to_dict("records")
    display(df.round(3))
impl queries ms_total ms_per_query
0 pure Python 1 1206.564 1206.564
1 NumPy, broadcast 1 21.678 21.678
2 NumPy, broadcast 16 351.577 21.974
3 NumPy, matrix multiply 1 2.425 2.425
4 NumPy, matrix multiply 1000 944.388 0.944
5 tinyknn CPU, 1 thread 1 4.709 4.709
6 tinyknn CPU, 1 thread 1000 2393.377 2.393
7 tinyknn CPU, 4 threads 1 4.837 4.837
8 tinyknn CPU, 4 threads 1000 886.170 0.886
9 tinyknn GPU 1 0.490 0.490
10 tinyknn GPU 1000 17.881 0.018
[one_search: 30.3 s]

Part 3 — What Python pays for every number

A Python float is an object on the heap: a reference count, a pointer to its type, and then the 8 bytes of the number. A list holds pointers to such objects. A NumPy array holds the numbers themselves, back to back.

with experiment("python_objects"):
    import ctypes
    out = {"sizeof_float": sys.getsizeof(1.5), "sizeof_int_small": sys.getsizeof(7),
           "sizeof_empty_list": sys.getsizeof([]), "pointer_bytes": ctypes.sizeof(ctypes.c_void_p)}
    n = 1_000_000
    L = [random.random() for _ in range(n)]
    A64 = np.array(L)
    A32 = A64.astype(np.float32)
    out["list_bytes_1m"] = sys.getsizeof(L) + n * sys.getsizeof(1.5)
    out["numpy64_bytes_1m"] = A64.nbytes
    out["numpy32_bytes_1m"] = A32.nbytes

    # Where the objects are. Sixteen floats created one after another; the first sixteen of a
    # million; the first sixteen after the million are shuffled.
    fresh = [float(i) + 0.5 for i in range(16)]
    out["fresh_addresses"] = [hex(id(v)) for v in fresh]
    first = [id(v) for v in L[:16]]
    random.shuffle(L)
    shuffled = [id(v) for v in L[:16]]
    out["list_addresses"] = [hex(a) for a in first]
    out["list_addresses_after_shuffle"] = [hex(a) for a in shuffled]
    out["numpy_addresses"] = [hex(A64.ctypes.data + 8 * i) for i in range(16)]
    out["numpy_strides"] = list(A64.reshape(1000, 1000).strides)

    # Time per element to add up a million numbers, five ways.
    def loop_list():
        s = 0.0
        for v in L:
            s += v
        return s

    def loop_numpy():
        s = 0.0
        for v in A64:                  # each element becomes a new numpy.float64 object
            s += v
        return s

    per = {
        "Python loop over list": best_time(loop_list, reps=3),
        "sum(list)": best_time(lambda: sum(L), reps=3),
        "Python loop over NumPy array": best_time(loop_numpy, reps=3),
        "np.sum (float64)": best_time(lambda: A64.sum(), reps=10),
        "tinyknn.sum (float32, C++)": best_time(lambda: tk.sum(A32), reps=10),
    }
    out["ns_per_element"] = {k: v / n * 1e9 for k, v in per.items()}
    RESULTS["python_objects"] = out
    print(json.dumps({k: v for k, v in out.items() if "addresses" not in k}, indent=1))
    print("fresh floats:  ", out["fresh_addresses"][:5])
    print("list order:    ", out["list_addresses"][:5])
    print("after shuffle: ", out["list_addresses_after_shuffle"][:5])
    print("numpy:         ", out["numpy_addresses"][:5])
{
 "sizeof_float": 24,
 "sizeof_int_small": 28,
 "sizeof_empty_list": 56,
 "pointer_bytes": 8,
 "list_bytes_1m": 32448728,
 "numpy64_bytes_1m": 8000000,
 "numpy32_bytes_1m": 4000000,
 "numpy_strides": [
  8000,
  8
 ],
 "ns_per_element": {
  "Python loop over list": 82.32054600000538,
  "sum(list)": 36.1302200000182,
  "Python loop over NumPy array": 99.2808819999027,
  "np.sum (float64)": 0.32362499996452243,
  "tinyknn.sum (float32, C++)": 0.15891099997134006
 }
}
fresh floats:   ['0x7f9250741a50', '0x7f92884a6b50', '0x7f92dd4ef830', '0x7f9278759370', '0x7f9278759390']
list order:     ['0x7f92504e30d0', '0x7f9250842290', '0x7f92dd219a30', '0x7f92503e1650', '0x7f9250b3a690']
after shuffle:  ['0x7f9278c39950', '0x7f92a354d330', '0x7f92787509f0', '0x7f929c077730', '0x7f9278bbe5f0']
numpy:          ['0x3e9dc910', '0x3e9dc918', '0x3e9dc920', '0x3e9dc928', '0x3e9dc930']
[python_objects: 1.5 s]

Part 4 — Memory: where things live, what allocation costs, why order matters

One C++ program, seven measurements: the addresses the process gives to code, globals, the stack and the heap; the price of malloc and of the first write to fresh memory; a scratch array on the stack versus the heap; a matrix summed along rows and down columns; reads at growing strides; the latency of one load as the working set outgrows each cache; and rows gathered in order versus at random — the access pattern of an index that jumps around its data.

%%writefile bench/bench.hpp
// bench/bench.hpp — timing and output helpers shared by the small benchmark programs.
// Every program prints one JSON object on stdout; the notebook parses it.
#pragma once
#include <chrono>
#include <cstdio>
#include <string>
#include <vector>

namespace bench {

inline double now() {
  return std::chrono::duration<double>(std::chrono::steady_clock::now().time_since_epoch()).count();
}

// Fastest of `reps` runs, in seconds. The fastest run is the one least disturbed by everything
// else on the machine, which is the quantity these experiments are after.
template <class F>
double best_of(int reps, F&& f) {
  double best = 1e30;
  for (int r = 0; r < reps; ++r) {
    double t0 = now();
    f();
    double t = now() - t0;
    if (t < best) best = t;
  }
  return best;
}

// Keeps a value "used" so the compiler cannot delete the computation that produced it.
template <class T>
inline void keep(T const& v) {
  asm volatile("" : : "r,m"(v) : "memory");
}

// Minimal JSON writer: fields are appended in order, nesting is written by hand.
struct json {
  std::string s = "{";
  bool first = true;
  json& key(const std::string& k) {
    s += (first ? "\"" : ",\"") + k + "\":";
    first = false;
    return *this;
  }
  json& num(const std::string& k, double v) {
    char b[64];
    std::snprintf(b, sizeof b, "%.15g", v);
    key(k).s += b;
    return *this;
  }
  json& str(const std::string& k, const std::string& v) {
    key(k).s += "\"" + v + "\"";
    return *this;
  }
  json& raw(const std::string& k, const std::string& v) {
    key(k).s += v;
    return *this;
  }
  std::string done() { return s + "}"; }
};

inline std::string array(const std::vector<double>& v) {
  std::string s = "[";
  char b[64];
  for (std::size_t i = 0; i < v.size(); ++i) {
    std::snprintf(b, sizeof b, "%s%.9g", i ? "," : "", v[i]);
    s += b;
  }
  return s + "]";
}

}  // namespace bench
Writing bench/bench.hpp
%%writefile bench/memory.cpp
// bench/memory.cpp — where things live, what allocating costs, and how the order of reads
// decides their speed.
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <numeric>
#include <random>
#include <vector>
#include "bench.hpp"

static int a_global = 42;

// Sums with 8 independent partial sums, so the additions don't wait for each other and the
// time measured is the time of reading memory, not the latency of one long chain of adds.
static float sum8(const float* p, std::size_t n) {
  float acc[8] = {};
  std::size_t i = 0;
  for (; i + 8 <= n; i += 8)
    for (int l = 0; l < 8; ++l) acc[l] += p[i + l];
  for (; i < n; ++i) acc[0] += p[i];
  float s = 0;
  for (float v : acc) s += v;
  return s;
}
// The same over every `stride`-th element (n of them).
static float sum8_strided(const float* p, std::size_t n, std::size_t stride) {
  float acc[8] = {};
  std::size_t i = 0;
  for (; i + 8 <= n; i += 8)
    for (int l = 0; l < 8; ++l) acc[l] += p[(i + l) * stride];
  for (; i < n; ++i) acc[0] += p[i * stride];
  float s = 0;
  for (float v : acc) s += v;
  return s;
}
static std::string hex(const void* p) {
  char b[32];
  std::snprintf(b, sizeof b, "0x%llx", (unsigned long long)(std::uintptr_t)p);
  return b;
}

int main() {
  bench::json out;

  // 1. Addresses. One of each kind of storage, printed where the process put it.
  {
    int on_stack = 1;
    float* small = new float[4];
    float* large = static_cast<float*>(std::malloc(64 << 20));
    auto* code = reinterpret_cast<const void*>(&main);
    bench::json a;
    a.str("code", hex(code)).str("global", hex(&a_global)).str("heap_small", hex(small))
        .str("heap_large", hex(large)).str("stack", hex(&on_stack));
    out.raw("addresses", a.done());
    delete[] small;
    std::free(large);
  }

  // 2. What an allocation costs: the call itself, then the first write to each page (the OS
  //    hands out physical memory lazily), then a second write once it's there.
  {
    std::string rows = "[";
    const std::size_t sizes[] = {64, 4096, 1 << 20, 64 << 20, 256 << 20};
    for (std::size_t bytes : sizes) {
      // A small batch, allocated and freed again and again: the steady-state cost of one call.
      const int batch = 16;
      std::vector<void*> ptrs(batch);
      double t_alloc = bench::best_of(200, [&] {
        for (auto& p : ptrs) p = std::malloc(bytes);
        bench::keep(ptrs[batch / 2]);
        for (auto p : ptrs) std::free(p);
      }) / batch;
      double t_first = 1e30, t_second = 1e30;
      for (int r = 0; r < 3; ++r) {
        char* p = static_cast<char*>(std::malloc(bytes));
        double t0 = bench::now();
        std::memset(p, 1, bytes);
        t_first = std::min(t_first, bench::now() - t0);
        t0 = bench::now();
        std::memset(p, 2, bytes);
        t_second = std::min(t_second, bench::now() - t0);
        bench::keep(p[bytes / 2]);
        std::free(p);
      }
      bench::json r;
      r.num("bytes", double(bytes)).num("malloc_free_ns", t_alloc * 1e9)
          .num("first_write_ms", t_first * 1e3).num("second_write_ms", t_second * 1e3);
      rows += (rows.size() > 1 ? "," : "") + r.done();
    }
    out.raw("allocation", rows + "]");
  }

  // 3. Stack vs heap for a small scratch array inside a hot function.
  {
    const int calls = 2000000;
    double t_stack = bench::best_of(5, [&] {
      for (int i = 0; i < calls; ++i) {
        float tmp[64];
        tmp[i & 63] = float(i);
        bench::keep(tmp[(i + 1) & 63]);
      }
    });
    double t_heap = bench::best_of(5, [&] {
      for (int i = 0; i < calls; ++i) {
        float* tmp = new float[64];
        tmp[i & 63] = float(i);
        bench::keep(tmp[(i + 1) & 63]);
        delete[] tmp;
      }
    });
    double t_vector = bench::best_of(5, [&] {
      for (int i = 0; i < calls; ++i) {
        std::vector<float> tmp(64);
        tmp[i & 63] = float(i);
        bench::keep(tmp[(i + 1) & 63]);
      }
    });
    bench::json s;
    s.num("stack_ns", t_stack / calls * 1e9).num("new_delete_ns", t_heap / calls * 1e9)
        .num("vector_ns", t_vector / calls * 1e9);
    out.raw("scratch", s.done());
  }

  // 4. A 4096 × 4096 float matrix (64 MiB) summed along rows and along columns. Same data,
  //    same number of additions; only the order of addresses differs.
  {
    const std::size_t n = 4096;
    std::vector<float> m(n * n, 1.0f);
    double t_rows = bench::best_of(3, [&] {
      float s = 0;
      for (std::size_t i = 0; i < n; ++i) s += sum8(&m[i * n], n);        // along a row
      bench::keep(s);
    });
    double t_cols = bench::best_of(3, [&] {
      float s = 0;
      for (std::size_t j = 0; j < n; ++j) s += sum8_strided(&m[j], n, n);  // down a column
      bench::keep(s);
    });
    bench::json r;
    r.num("n", double(n)).num("rows_ms", t_rows * 1e3).num("cols_ms", t_cols * 1e3);
    out.raw("traversal", r.done());
  }

  // 5. Stride: touch every s-th float of a 256 MiB array. Time per float touched — up to a
  //    stride of 16 floats (one 64-byte cache line) each line is fetched anyway.
  {
    const std::size_t n = std::size_t(64) << 20;  // 64 Mi floats = 256 MiB
    std::vector<float> a(n, 1.0f);
    std::vector<double> strides, ns, total_ms;
    for (std::size_t s : {1, 2, 4, 8, 16, 32, 64, 128, 256, 1024, 4096}) {
      const std::size_t touched = n / s;
      double t = bench::best_of(3, [&] { bench::keep(sum8_strided(a.data(), touched, s)); });
      strides.push_back(double(s));
      ns.push_back(t / touched * 1e9);
      total_ms.push_back(t * 1e3);
    }
    bench::json r;
    r.raw("stride_floats", bench::array(strides)).raw("ns_per_touch", bench::array(ns))
        .raw("total_ms", bench::array(total_ms));
    out.raw("stride", r.done());
  }

  // 6. Latency by working-set size: follow a random cycle of pointers through a buffer.
  //    Every load depends on the one before, so nothing overlaps — this is the cost of one trip.
  {
    std::vector<double> kib, ns;
    std::mt19937_64 rng(1);
    for (std::size_t bytes = 4 << 10; bytes <= (std::size_t(512) << 20); bytes *= 2) {
      const std::size_t slots = bytes / 64;  // one pointer per cache line
      std::vector<std::size_t> order(slots);
      std::iota(order.begin(), order.end(), 0);
      std::shuffle(order.begin() + 1, order.end(), rng);
      std::vector<std::size_t> next(slots * 8);  // 8 × 8 bytes = one line per slot
      for (std::size_t i = 0; i < slots; ++i) next[order[i] * 8] = order[(i + 1) % slots] * 8;
      const std::size_t steps = 4000000;
      double t = bench::best_of(3, [&] {
        std::size_t p = 0;
        for (std::size_t i = 0; i < steps; ++i) p = next[p];
        bench::keep(p);
      });
      kib.push_back(double(bytes >> 10));
      ns.push_back(t / steps * 1e9);
    }
    bench::json r;
    r.raw("kib", bench::array(kib)).raw("ns_per_load", bench::array(ns));
    out.raw("latency", r.done());
  }

  // 7. Gathering rows: the access pattern of an index that visits database rows in an order
  //    decided by the data (a graph index, an inverted list). Rows of d floats, read in order
  //    or in a random order, from a 512 MiB table.
  {
    const std::size_t total = std::size_t(128) << 20;  // floats
    std::vector<float> table(total, 1.0f);
    std::vector<double> dims, seq_ns, rnd_ns;
    std::mt19937_64 rng(2);
    for (std::size_t d : {1, 4, 16, 32, 64, 128, 256, 1024}) {
      const std::size_t rows = total / d, visits = std::min<std::size_t>(rows, 2000000);
      std::vector<std::uint32_t> seq(visits), rnd(visits);
      std::iota(seq.begin(), seq.end(), 0);
      std::uniform_int_distribution<std::uint32_t> pick(0, std::uint32_t(rows - 1));
      for (auto& v : rnd) v = pick(rng);
      auto run = [&](const std::vector<std::uint32_t>& idx) {
        return bench::best_of(3, [&] {
          float acc = 0;
          for (std::uint32_t r : idx) acc += sum8(&table[std::size_t(r) * d], d);
          bench::keep(acc);
        });
      };
      dims.push_back(double(d));
      seq_ns.push_back(run(seq) / visits * 1e9);
      rnd_ns.push_back(run(rnd) / visits * 1e9);
    }
    bench::json r;
    r.raw("dim", bench::array(dims)).raw("sequential_ns_per_row", bench::array(seq_ns))
        .raw("random_ns_per_row", bench::array(rnd_ns));
    out.raw("gather", r.done());
  }

  std::puts(out.done().c_str());
}
Writing bench/memory.cpp
with experiment("memory"):
    build("bench/memory.cpp", "build/memory")
    RESULTS["memory"] = m = run_json("build/memory")
    print(json.dumps(m["addresses"], indent=1))
    display(pd.DataFrame(m["allocation"]))
    print("scratch array:", m["scratch"])
    print("4096² sum by rows vs by columns:", m["traversal"])
    fig, ax = plt.subplots(1, 3, figsize=(11, 3))
    ax[0].plot(m["stride"]["stride_floats"], m["stride"]["total_ms"], "o-", color=BLUE)
    ax[0].set(xscale="log", xlabel="stride (floats)", ylabel="ms to touch 256 MiB / stride", title="stride")
    ax[1].plot(m["latency"]["kib"], m["latency"]["ns_per_load"], "o-", color=EMBER)
    ax[1].set(xscale="log", yscale="log", xlabel="working set (KiB)", ylabel="ns per dependent load", title="latency")
    g = m["gather"]
    ax[2].plot(g["dim"], g["sequential_ns_per_row"], "o-", color=FOREST, label="in order")
    ax[2].plot(g["dim"], g["random_ns_per_row"], "o-", color=PLUM, label="random order")
    ax[2].set(xscale="log", yscale="log", xlabel="row length (floats)", ylabel="ns per row", title="gather")
    ax[2].legend(frameon=False)
    plt.tight_layout(); plt.show()
{
 "code": "0x5583002f7ec0",
 "global": "0x558300301010",
 "heap_small": "0x5583215dbeb0",
 "heap_large": "0x7d73a153c010",
 "stack": "0x7ffe80c638f0"
}
bytes malloc_free_ns first_write_ms second_write_ms
0 64 17.437493 0.000029 0.000029
1 4096 37.124998 0.000057 0.000056
2 1048576 4093.062500 0.039280 0.038546
3 67108864 11079.499998 42.948220 9.449854
4 268435456 12515.125000 178.611538 37.668690
scratch array: {'stack_ns': 0.839095499998166, 'new_delete_ns': 17.8053590000218, 'vector_ns': 30.6738185000199}
4096² sum by rows vs by columns: {'n': 4096, 'rows_ms': 5.45989700003702, 'cols_ms': 30.426489999968}

[memory: 20.7 s]

Part 5 — Ownership: who frees the bytes

A view outlives the memory it points to. std::vector reallocates when it grows past its capacity; a span taken before the growth still points at the old block.

%%writefile bench/dangling.cpp
// bench/dangling.cpp — a view that outlives the memory it points to.
#include <cstdio>
#include <span>
#include <vector>

int main() {
  std::vector<float> embeddings = {0.1f, 0.2f, 0.3f, 0.4f};
  std::span<const float> first_row(embeddings.data(), 2);  // a view: pointer + length, no ownership

  embeddings.push_back(0.5f);  // capacity was 4: a new block is allocated, the old one freed

  std::printf("first_row[0] = %g\n", first_row[0]);  // reads the freed block
}
Writing bench/dangling.cpp
with experiment("dangling"):
    build("bench/dangling.cpp", "build/dangling", flags="-O2")
    plain = sh("build/dangling", check=False)
    build("bench/dangling.cpp", "build/dangling_asan", flags="-O1 -g -fsanitize=address")
    asan = sh("build/dangling_asan", check=False)
    report = [l for l in asan.stderr.splitlines() if l.strip()]
    RESULTS["dangling"] = {"plain_stdout": plain.stdout.strip(), "plain_exit": plain.returncode,
                           "asan_exit": asan.returncode, "asan_report": report[:40]}
    print("without a sanitizer:", plain.stdout.strip(), "| exit code", plain.returncode)
    print("\n".join(report[:14]))
without a sanitizer: first_row[0] = -5.94411e-29 | exit code 0
=================================================================
==257==ERROR: AddressSanitizer: heap-use-after-free on address 0x502000000010 at pc 0x5ba0cb3faab7 bp 0x7ffdfe2db000 sp 0x7ffdfe2daff0
READ of size 4 at 0x502000000010 thread T0
    #0 0x5ba0cb3faab6 in main bench/dangling.cpp:12
    #1 0x78383c121d8f  (/lib/x86_64-linux-gnu/libc.so.6+0x29d8f)
    #2 0x78383c121e3f in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x29e3f)
    #3 0x5ba0cb3fa304 in _start (/kaggle/working/build/dangling_asan+0x1304)
0x502000000010 is located 0 bytes inside of 16-byte region [0x502000000010,0x502000000020)
freed by thread T0 here:
    #0 0x78383c70b24f in operator delete(void*, unsigned long) ../../../../src/libsanitizer/asan/asan_new_delete.cpp:172
    #1 0x5ba0cb3faefe in __gnu_cxx::new_allocator<float>::deallocate(float*, unsigned long) /usr/include/c++/11/ext/new_allocator.h:145
    #2 0x5ba0cb3faefe in std::allocator<float>::deallocate(float*, unsigned long) /usr/include/c++/11/bits/allocator.h:199
    #3 0x5ba0cb3faefe in std::allocator_traits<std::allocator<float> >::deallocate(std::allocator<float>&, float*, unsigned long) /usr/include/c++/11/bits/alloc_traits.h:496
    #4 0x5ba0cb3faefe in std::_Vector_base<float, std::allocator<float> >::_M_deallocate(float*, unsigned long) /usr/include/c++/11/bits/stl_vector.h:354
[dangling: 0.8 s]

Shared ownership has a price: a second pointer for the control block, an allocation for it, and an atomic counter touched by every copy.

%%writefile bench/shared.cpp
// bench/shared.cpp — what unique_ptr and shared_ptr cost, in bytes and in time.
#include <atomic>
#include <cstdlib>
#include <new>
#include <memory>
#include <thread>
#include <vector>
#include "bench.hpp"

static std::size_t g_allocs = 0;
void* operator new(std::size_t n) {
  ++g_allocs;
  if (void* p = std::malloc(n)) return p;
  throw std::bad_alloc();
}
void operator delete(void* p) noexcept { std::free(p); }
void operator delete(void* p, std::size_t) noexcept { std::free(p); }
void* operator new(std::size_t n, std::align_val_t a) {
  ++g_allocs;
  const std::size_t al = std::size_t(a);
  if (void* p = std::aligned_alloc(al, (n + al - 1) / al * al)) return p;
  throw std::bad_alloc();
}
void operator delete(void* p, std::align_val_t) noexcept { std::free(p); }
void operator delete(void* p, std::size_t, std::align_val_t) noexcept { std::free(p); }

// alignas(64): each Buffer, and the reference counts make_shared puts next to it, gets its own
// cache lines — so "separate objects" really are separate, down to the hardware.
struct alignas(64) Buffer { std::vector<float> data; };

__attribute__((noinline)) float read_raw(const Buffer* b) { return b->data[0]; }
__attribute__((noinline)) float read_ref(const std::shared_ptr<Buffer>& b) { return b->data[0]; }
__attribute__((noinline)) float read_copy(std::shared_ptr<Buffer> b) { return b->data[0]; }

int main() {
  bench::json out;
  out.num("sizeof_raw_pointer", sizeof(Buffer*))
      .num("sizeof_unique_ptr", sizeof(std::unique_ptr<Buffer>))
      .num("sizeof_shared_ptr", sizeof(std::shared_ptr<Buffer>));

  auto count = [](auto&& f) { std::size_t a = g_allocs; f(); return double(g_allocs - a); };
  out.num("allocs_unique_ptr", count([] { auto p = std::make_unique<Buffer>(); bench::keep(p.get()); }))
      .num("allocs_make_shared", count([] { auto p = std::make_shared<Buffer>(); bench::keep(p.get()); }))
      .num("allocs_shared_from_new", count([] { std::shared_ptr<Buffer> p(new Buffer); bench::keep(p.get()); }));

  auto shared = std::make_shared<Buffer>();
  shared->data.assign(16, 1.0f);
  const long calls = 20000000;
  double t_raw = bench::best_of(5, [&] { float s = 0; for (long i = 0; i < calls; ++i) s += read_raw(shared.get()); bench::keep(s); });
  double t_ref = bench::best_of(5, [&] { float s = 0; for (long i = 0; i < calls; ++i) s += read_ref(shared); bench::keep(s); });
  double t_copy = bench::best_of(5, [&] { float s = 0; for (long i = 0; i < calls; ++i) s += read_copy(shared); bench::keep(s); });
  out.num("call_raw_ns", t_raw / calls * 1e9).num("call_const_ref_ns", t_ref / calls * 1e9)
      .num("call_by_value_ns", t_copy / calls * 1e9);

  // Four threads passing shared_ptrs by value. When they share one object, every copy is an
  // atomic increment on the same counter — one cache line bouncing between cores.
  const int threads = 4;
  const long per = 5000000;
  auto run = [&](bool same) {
    std::vector<std::shared_ptr<Buffer>> own(threads);
    for (auto& p : own) p = same ? shared : std::make_shared<Buffer>(Buffer{std::vector<float>(16, 1.0f)});
    return bench::best_of(3, [&] {
      std::vector<std::thread> ts;
      for (int t = 0; t < threads; ++t)
        ts.emplace_back([&, t] { float s = 0; for (long i = 0; i < per; ++i) s += read_copy(own[t]); bench::keep(s); });
      for (auto& th : ts) th.join();
    });
  };
  out.num("threads", threads).num("copies_per_thread", double(per))
      .num("contended_ns_per_copy", run(true) / per * 1e9)
      .num("uncontended_ns_per_copy", run(false) / per * 1e9);
  std::puts(out.done().c_str());
}
Writing bench/shared.cpp
with experiment("shared_ptr"):
    build("bench/shared.cpp", "build/shared", flags="-O2")
    RESULTS["shared_ptr"] = run_json("build/shared")
    print(json.dumps(RESULTS["shared_ptr"], indent=1))
{
 "sizeof_raw_pointer": 8,
 "sizeof_unique_ptr": 8,
 "sizeof_shared_ptr": 16,
 "allocs_unique_ptr": 1,
 "allocs_make_shared": 1,
 "allocs_shared_from_new": 2,
 "call_raw_ns": 1.35214690000112,
 "call_const_ref_ns": 1.31955049999988,
 "call_by_value_ns": 3.74241050000137,
 "threads": 4,
 "copies_per_thread": 5000000,
 "contended_ns_per_copy": 160.461419600006,
 "uncontended_ns_per_copy": 30.9432602000015
}
[shared_ptr: 5.0 s]

Ownership across the Python boundary. Index.peek_ids returns a NumPy array that looks directly at the index’s own result buffer — no copy. The array’s base is the index, so Python’s reference count keeps the index, and its buffer, alive for as long as the array exists. DLPack is the same contract between libraries: the consumer gets a pointer and a deleter, and the producer’s memory is freed only when the deleter runs.

with experiment("ownership_python"):
    out = {}
    if HAVE_CUDA:
        idx = tk.Index(DB[:10_000])
        idx.enqueue(QUERIES[:8], K)
        idx.wait()
        view = idx.peek_ids(8, K)
        out["view_base_type"] = type(view.base).__name__
        out["refcount_index_with_view"] = sys.getrefcount(idx) - 1
        expected = view.copy()
        del idx
        gc.collect()
        out["view_still_valid_after_del"] = bool((view == expected).all())
        del view, expected
    try:
        import torch
        a = np.arange(12, dtype=np.float32).reshape(3, 4)
        t = torch.from_dlpack(a)
        out["dlpack_same_address"] = t.data_ptr() == a.ctypes.data
        t[0, 0] = 42.0
        out["dlpack_write_visible_in_numpy"] = bool(a[0, 0] == 42.0)
        del a
        gc.collect()
        out["dlpack_tensor_valid_after_del_numpy"] = float(t.sum()) == float(42 + sum(range(1, 12)))
    except Exception as e:
        out["dlpack_error"] = repr(e)
    RESULTS["ownership_python"] = out
    print(json.dumps(out, indent=1))
{
 "view_base_type": "Index",
 "refcount_index_with_view": 2,
 "view_still_valid_after_del": true,
 "dlpack_same_address": true,
 "dlpack_write_visible_in_numpy": true,
 "dlpack_tensor_valid_after_del_numpy": true
}
[ownership_python: 4.4 s]

Part 6 — Copies

A 1 GiB std::vector<float> handed around in the usual ways. Every heap allocation in the program is counted by replacing operator new.

%%writefile bench/copy.cpp
// bench/copy.cpp — copy, move, reference: what each one costs for a 1 GiB buffer, counted in
// time and in calls to the allocator.
#include <cstdlib>
#include <new>
#include <utility>
#include <vector>
#include "tinyknn/buffer.hpp"
#include "bench.hpp"

// Every heap allocation in this program goes through here and is counted.
static std::size_t g_allocs = 0, g_bytes = 0;
void* operator new(std::size_t n) {
  ++g_allocs;
  g_bytes += n;
  if (void* p = std::malloc(n)) return p;
  throw std::bad_alloc();
}
void operator delete(void* p) noexcept { std::free(p); }
void operator delete(void* p, std::size_t) noexcept { std::free(p); }
void* operator new(std::size_t n, std::align_val_t a) {
  ++g_allocs;
  g_bytes += n;
  if (void* p = std::aligned_alloc(std::size_t(a), (n + std::size_t(a) - 1) / std::size_t(a) * std::size_t(a))) return p;
  throw std::bad_alloc();
}
void operator delete(void* p, std::align_val_t) noexcept { std::free(p); }

using Tensor = std::vector<float>;
static const std::size_t N = std::size_t(256) << 20;  // 256 Mi floats = 1 GiB

// Four ways to hand a tensor to a function that only reads it.
__attribute__((noinline)) float by_value(Tensor t) { return t[t.size() / 2]; }
__attribute__((noinline)) float by_const_ref(const Tensor& t) { return t[t.size() / 2]; }
__attribute__((noinline)) float by_pointer(const float* p, std::size_t n) { return p[n / 2]; }
__attribute__((noinline)) Tensor make_tensor(std::size_t n) {
  Tensor t(n, 1.0f);
  return t;  // no copy: constructed directly in the caller's variable
}

struct Row {
  std::string name;
  double ms;
  std::size_t allocs, bytes;
};

template <class F>
Row measure(const char* name, F&& f) {
  f();  // warm-up: pages touched, allocator settled
  std::size_t a0 = g_allocs, b0 = g_bytes;
  double t0 = bench::now();
  f();
  double t = bench::now() - t0;
  std::size_t allocs = g_allocs - a0, bytes = g_bytes - b0;
  return {name, t * 1e3, allocs, bytes};
}

int main() {
  Tensor src(N, 1.0f);
  std::vector<Row> rows;
  rows.push_back(measure("copy_construct", [&] { Tensor t = src; bench::keep(t[1]); }));
  rows.push_back(measure("move_construct", [&] {
    Tensor t = std::move(src);
    bench::keep(t[1]);
    src = std::move(t);  // hand it back for the next measurement
  }));
  rows.push_back(measure("pass_by_value", [&] { bench::keep(by_value(src)); }));
  rows.push_back(measure("pass_by_const_ref", [&] { bench::keep(by_const_ref(src)); }));
  rows.push_back(measure("pass_pointer_and_size", [&] { bench::keep(by_pointer(src.data(), src.size())); }));
  rows.push_back(measure("return_by_value", [&] { Tensor t = make_tensor(N); bench::keep(t[1]); }));
  rows.push_back(measure("fill_new_tensor_only", [&] { Tensor t(N, 1.0f); bench::keep(t[1]); }));

  // The library's own buffer: copying doesn't compile, so the copy has to be asked for.
  tinyknn::host_buffer<float> hb(N);
  std::fill(hb.data(), hb.data() + N, 1.0f);
  rows.push_back(measure("host_buffer_clone", [&] { auto c = hb.clone(); bench::keep(c.data()[1]); }));
  rows.push_back(measure("host_buffer_move", [&] {
    auto m = std::move(hb);
    bench::keep(m.data()[1]);
    hb = std::move(m);
  }));

  std::string arr = "[";
  for (auto& r : rows) {
    bench::json j;
    j.str("case", r.name).num("ms", r.ms).num("allocs", double(r.allocs)).num("bytes", double(r.bytes));
    arr += (arr.size() > 1 ? "," : "") + j.done();
  }
  bench::json out;
  out.num("tensor_bytes", double(N * sizeof(float))).raw("cases", arr + "]");

  // Growing a vector one push_back at a time: every time capacity runs out, a bigger block is
  // allocated and every element moved into it. Pointers into the old block now dangle.
  {
    std::vector<double> sizes, caps;
    std::size_t reallocs = 0, moved = 0;
    std::vector<float> v;
    const float* last = v.data();
    for (std::size_t i = 0; i < 1000000; ++i) {
      if (v.size() == v.capacity() && v.size()) {
        ++reallocs;
        moved += v.size();
        sizes.push_back(double(v.size()));
        caps.push_back(double(v.capacity() * 2));
      }
      v.push_back(float(i));
      last = v.data();
    }
    bench::keep(last);
    double t_grow = bench::best_of(5, [&] {
      std::vector<float> w;
      for (std::size_t i = 0; i < 1000000; ++i) w.push_back(float(i));
      bench::keep(w.data());
    });
    double t_reserve = bench::best_of(5, [&] {
      std::vector<float> w;
      w.reserve(1000000);
      for (std::size_t i = 0; i < 1000000; ++i) w.push_back(float(i));
      bench::keep(w.data());
    });
    bench::json g;
    g.num("elements", 1e6).num("reallocations", double(reallocs)).num("elements_moved", double(moved))
        .raw("size_at_realloc", bench::array(sizes)).num("grow_ms", t_grow * 1e3)
        .num("reserve_ms", t_reserve * 1e3);
    out.raw("growth", g.done());
  }
  std::puts(out.done().c_str());
}
Writing bench/copy.cpp
with experiment("copies"):
    build("bench/copy.cpp", "build/copy", flags="-O2")
    RESULTS["copies"] = c = run_json("build/copy")
    display(pd.DataFrame(c["cases"]))
    print({k: v for k, v in c["growth"].items() if k != "size_at_realloc"})
case ms allocs bytes
0 copy_construct 916.530793 1 1073741824
1 move_construct 0.000038 0 0
2 pass_by_value 946.112639 1 1073741824
3 pass_by_const_ref 0.000048 0 0
4 pass_pointer_and_size 0.000035 0 0
5 return_by_value 813.079081 1 1073741824
6 fill_new_tensor_only 797.080987 1 1073741824
7 host_buffer_clone 932.714196 1 1073741824
8 host_buffer_move 0.000036 0 0
{'elements': 1000000, 'reallocations': 20, 'elements_moved': 1048575, 'grow_ms': 6.09348199998294, 'reserve_ms': 1.62552699998741}
[copies: 11.6 s]

The same question at the Python boundary. probe() returns the address the C++ code ends up reading. If it equals the array’s own address, the array was passed as it is; if not, the binding made a converted copy first — silently, because the parameter type allows it. probe_strict() has the same body but a parameter type that refuses to convert.

with experiment("binding_copies"):
    base = DB                                                      # 100,000 × 128 float32, C order
    cases = {
        "float32, C order": base,
        "float32 rows 1000:51000 (still contiguous)": base[1000:51000],
        "float32 every 2nd row (strided)": base[::2],
        "float32, Fortran order": np.asfortranarray(base),
        "float64": base.astype(np.float64),
        "float16": base.astype(np.float16),
    }
    rows = []
    for name, arr in cases.items():
        t = best_time(lambda: tk.probe(arr), reps=5)
        try:
            tk.probe_strict(arr)
            strict = "accepted"
        except TypeError:
            strict = "TypeError"
        rows.append({"array": name, "MiB": arr.nbytes / 2**20, "copied": tk.probe(arr) != arr.ctypes.data,
                     "call_ms": t * 1e3, "strict": strict})
    RESULTS["binding_copies"] = rows
    display(pd.DataFrame(rows).round(3))
array MiB copied call_ms strict
0 float32, C order 48.828 False 0.001 accepted
1 float32 rows 1000:51000 (still contiguous) 24.414 False 0.001 accepted
2 float32 every 2nd row (strided) 24.414 True 5.393 TypeError
3 float32, Fortran order 48.828 True 25.236 TypeError
4 float64 97.656 True 19.698 TypeError
5 float16 24.414 True 44.102 TypeError
[binding_copies: 0.9 s]

Part 7 — Layout

The compiler pads structs so that every field sits at an address that is a multiple of its size. Field order changes how much padding there is. Then the same 16 million points stored as an array of structs and as a struct of arrays, read one field, three fields and all four; and four threads incrementing four separate counters that happen to share a cache line.

%%writefile bench/layout.cpp
// bench/layout.cpp — the shape of data: padding, alignment, arrays of structs vs structs of arrays.
#include <cstddef>
#include <cstdint>
#include <random>
#include <string>
#include <thread>
#include <vector>
#include "bench.hpp"

// Four structs, as the compiler lays them out.
struct Point { float x, y, z; std::int32_t label; };
struct Tagged { bool valid; double score; bool seen; std::int32_t id; };      // careless order
struct TaggedPacked { double score; std::int32_t id; bool valid; bool seen; };  // same fields, sorted by size
struct alignas(64) PaddedCounter { std::int64_t value; };                     // one per cache line

#define FIELD(T, f) \
  "{\"name\":\"" #f "\",\"offset\":" + std::to_string(offsetof(T, f)) + ",\"size\":" + std::to_string(sizeof(T::f)) + "}"
#define LAYOUT(T, ...) \
  (std::string("{\"type\":\"" #T "\",\"sizeof\":") + std::to_string(sizeof(T)) + ",\"alignof\":" + \
   std::to_string(alignof(T)) + ",\"fields\":[" + __VA_ARGS__ + "]}")

int main() {
  bench::json out;
  out.raw("layouts",
          "[" + LAYOUT(Point, FIELD(Point, x) + "," + FIELD(Point, y) + "," + FIELD(Point, z) + "," + FIELD(Point, label)) + "," +
              LAYOUT(Tagged, FIELD(Tagged, valid) + "," + FIELD(Tagged, score) + "," + FIELD(Tagged, seen) + "," + FIELD(Tagged, id)) + "," +
              LAYOUT(TaggedPacked, FIELD(TaggedPacked, score) + "," + FIELD(TaggedPacked, id) + "," + FIELD(TaggedPacked, valid) + "," + FIELD(TaggedPacked, seen)) + "," +
              LAYOUT(PaddedCounter, FIELD(PaddedCounter, value)) + "]");

  // AoS vs SoA: 16 Mi points. The same loops over both layouts.
  const std::size_t n = std::size_t(16) << 20;
  std::vector<Point> aos(n);
  std::vector<float> xs(n), ys(n), zs(n);
  std::vector<std::int32_t> labels(n);
  std::mt19937 rng(3);
  std::uniform_real_distribution<float> u(-1, 1);
  for (std::size_t i = 0; i < n; ++i) {
    aos[i] = {u(rng), u(rng), u(rng), std::int32_t(i & 7)};
    xs[i] = aos[i].x;
    ys[i] = aos[i].y;
    zs[i] = aos[i].z;
    labels[i] = aos[i].label;
  }
  const float cx = 0.1f, cy = -0.2f, cz = 0.3f;

  // (a) One field: the mean x. AoS drags y, z and label through the cache for nothing.
  double aos_x = bench::best_of(5, [&] {
    float acc[8] = {};
    for (std::size_t i = 0; i < n; i += 8)
      for (int l = 0; l < 8; ++l) acc[l] += aos[i + l].x;
    bench::keep(acc);
  });
  double soa_x = bench::best_of(5, [&] {
    float acc[8] = {};
    for (std::size_t i = 0; i < n; i += 8)
      for (int l = 0; l < 8; ++l) acc[l] += xs[i + l];
    bench::keep(acc);
  });
  // (b) Three fields: count points within a radius. Now 12 of AoS's 16 bytes are useful.
  double aos_r = bench::best_of(5, [&] {
    std::int64_t hits = 0;
    for (std::size_t i = 0; i < n; ++i) {
      float dx = aos[i].x - cx, dy = aos[i].y - cy, dz = aos[i].z - cz;
      hits += (dx * dx + dy * dy + dz * dz < 0.25f);
    }
    bench::keep(hits);
  });
  double soa_r = bench::best_of(5, [&] {
    std::int64_t hits = 0;
    for (std::size_t i = 0; i < n; ++i) {
      float dx = xs[i] - cx, dy = ys[i] - cy, dz = zs[i] - cz;
      hits += (dx * dx + dy * dy + dz * dz < 0.25f);
    }
    bench::keep(hits);
  });
  // (c) Every field: radius test for points with one label.
  double aos_all = bench::best_of(5, [&] {
    std::int64_t hits = 0;
    for (std::size_t i = 0; i < n; ++i) {
      float dx = aos[i].x - cx, dy = aos[i].y - cy, dz = aos[i].z - cz;
      hits += (aos[i].label == 3) & (dx * dx + dy * dy + dz * dz < 0.25f);
    }
    bench::keep(hits);
  });
  double soa_all = bench::best_of(5, [&] {
    std::int64_t hits = 0;
    for (std::size_t i = 0; i < n; ++i) {
      float dx = xs[i] - cx, dy = ys[i] - cy, dz = zs[i] - cz;
      hits += (labels[i] == 3) & (dx * dx + dy * dy + dz * dz < 0.25f);
    }
    bench::keep(hits);
  });
  bench::json a;
  a.num("points", double(n))
      .num("aos_x_ms", aos_x * 1e3).num("soa_x_ms", soa_x * 1e3)
      .num("aos_xyz_ms", aos_r * 1e3).num("soa_xyz_ms", soa_r * 1e3)
      .num("aos_all_ms", aos_all * 1e3).num("soa_all_ms", soa_all * 1e3)
      .num("aos_bytes_per_point", double(sizeof(Point))).num("soa_x_bytes_per_point", 4.0);
  out.raw("aos_soa", a.done());

  // False sharing: 4 threads each incrementing their own counter, with the counters packed
  // into one cache line or given a line each. No data is shared — only the line is.
  {
    const long iters = 20000000;
    auto run = [&](auto* counters, std::size_t step) {
      return bench::best_of(3, [&] {
        std::vector<std::thread> ts;
        for (int t = 0; t < 4; ++t)
          ts.emplace_back([&, t] {
            auto* c = reinterpret_cast<volatile std::int64_t*>(
                reinterpret_cast<char*>(counters) + t * step);
            for (long i = 0; i < iters; ++i) *c = *c + 1;
          });
        for (auto& th : ts) th.join();
      });
    };
    alignas(64) std::int64_t packed[4] = {};
    PaddedCounter padded[4] = {};
    bench::json f;
    f.num("iters_per_thread", double(iters))
        .num("same_line_ms", run(packed, sizeof(std::int64_t)) * 1e3)
        .num("own_line_ms", run(padded, sizeof(PaddedCounter)) * 1e3);
    out.raw("false_sharing", f.done());
  }

  std::puts(out.done().c_str());
}
Writing bench/layout.cpp
with experiment("layout"):
    build("bench/layout.cpp", "build/layout")
    RESULTS["layout"] = lay = run_json("build/layout")
    for s in lay["layouts"]:
        print(f"{s['type']:14s} sizeof={s['sizeof']:3d} alignof={s['alignof']:3d}  ",
              "  ".join(f"{f['name']}@{f['offset']}" for f in s["fields"]))
    print(json.dumps(lay["aos_soa"], indent=1))
    print(json.dumps(lay["false_sharing"], indent=1))
Point          sizeof= 16 alignof=  4   x@0  y@4  z@8  label@12
Tagged         sizeof= 24 alignof=  8   valid@0  score@8  seen@16  id@20
TaggedPacked   sizeof= 16 alignof=  8   score@0  id@8  valid@12  seen@13
PaddedCounter  sizeof= 64 alignof= 64   value@0
{
 "points": 16777216,
 "aos_x_ms": 23.481214999947,
 "soa_x_ms": 10.2112869999473,
 "aos_xyz_ms": 27.6383590000933,
 "soa_xyz_ms": 18.3622430000696,
 "aos_all_ms": 28.0496929999572,
 "soa_all_ms": 23.3825909999723,
 "aos_bytes_per_point": 16,
 "soa_x_bytes_per_point": 4
}
{
 "iters_per_thread": 20000000,
 "same_line_ms": 186.733010000012,
 "own_line_ms": 40.7138620000751
}
[layout: 4.1 s]

Part 8 — What the compiler does with a loop

One dot product written two ways — a single running sum, and eight partial sums — compiled with five sets of flags. For each build: time per element (the vectors fit in L1, so this is arithmetic), the result over a million elements with its exact bits, and the machine code of the loop.

%%writefile bench/dot.cpp
// bench/dot.cpp — one dot product, written two ways. The notebook compiles this file with
// different flags and compares speed, result and machine code.
#include <cmath>
#include <cstring>
#include <random>
#include <vector>
#include "bench.hpp"

// The obvious loop: one running sum.
extern "C" __attribute__((noinline)) float dot_plain(const float* a, const float* b, std::size_t n) {
  float s = 0.0f;
  for (std::size_t i = 0; i < n; ++i) s += a[i] * b[i];
  return s;
}

// Eight running sums, combined at the end. A different order of additions, written down.
extern "C" __attribute__((noinline)) float dot_lanes(const float* a, const float* b, std::size_t n) {
  float acc[8] = {};
  std::size_t i = 0;
  for (; i + 8 <= n; i += 8)
    for (int l = 0; l < 8; ++l) acc[l] += a[i + l] * b[i + l];
  for (; i < n; ++i) acc[0] += a[i] * b[i];
  float s = 0.0f;
  for (int l = 0; l < 8; ++l) s += acc[l];
  return s;
}

static std::string bits(float f) {
  std::uint32_t u;
  std::memcpy(&u, &f, 4);
  char b[16];
  std::snprintf(b, sizeof b, "0x%08x", u);
  return b;
}

int main() {
  // Speed: 4096 floats (32 KiB for both vectors) stay in L1, so this times the arithmetic.
  const std::size_t n_small = 4096, reps = 20000;
  // Result: a million values, where the order of additions visibly matters.
  const std::size_t n_big = 1 << 20;
  std::vector<float> a(n_big), b(n_big);
  std::mt19937 rng(7);
  std::uniform_real_distribution<float> u(0.0f, 1.0f);
  for (auto& v : a) v = u(rng);
  for (auto& v : b) v = u(rng);

  bench::json out;
  for (auto [name, f] : {std::pair{"plain", &dot_plain}, std::pair{"lanes", &dot_lanes}}) {
    double t = bench::best_of(5, [&] {
      float s = 0;
      for (std::size_t r = 0; r < reps; ++r) s += f(a.data(), b.data(), n_small);
      bench::keep(s);
    });
    float big = f(a.data(), b.data(), n_big);
    bench::json j;
    j.num("ns_per_element", t / double(reps * n_small) * 1e9).num("result", big).str("bits", bits(big));
    out.raw(name, j.done());
  }
  double exact = 0;
  for (std::size_t i = 0; i < n_big; ++i) exact += double(a[i]) * double(b[i]);
  out.num("exact", exact);
  std::puts(out.done().c_str());
}
Writing bench/dot.cpp
def disassemble(binary, symbol):
    text = sh(f"objdump -d --no-show-raw-insn -M intel {binary}").stdout
    m = re.search(rf"<{symbol}>:\n(.*?)\n\n", text, re.S)
    lines = [l.split("\t", 1)[-1].strip() for l in (m.group(1).splitlines() if m else [])]
    return [re.sub(r"\s+", " ", l) for l in lines if l]


def count(asm, prefixes):
    return sum(1 for l in asm if l.split(" ")[0] in prefixes)


with experiment("compiler"):
    flag_sets = ["-O0", "-O2", "-O3", "-O3 -march=native", "-O3 -march=native -ffast-math"]
    rows, asm = [], {}
    for flags in flag_sets:
        exe = "build/dot_" + re.sub(r"[^a-z0-9]+", "_", flags.lower()).strip("_")
        build("bench/dot.cpp", exe, flags=flags)
        r = run_json(exe)
        for fn in ["plain", "lanes"]:
            a = disassemble(exe, f"dot_{fn}")
            asm[f"{flags} | {fn}"] = a
            rows.append({"flags": flags, "loop": fn, "ns_per_element": r[fn]["ns_per_element"],
                         "result": r[fn]["result"], "bits": r[fn]["bits"], "error_vs_exact": r[fn]["result"] - r["exact"],
                         "instructions": len(a),
                         "packed_ops": count(a, {"vmulps", "vaddps", "vfmadd132ps", "vfmadd213ps", "vfmadd231ps", "mulps", "addps"}),
                         "scalar_adds": count(a, {"vaddss", "addss"})})
    # gcc's own account of why the plain loop's sum is not vectorised without -ffast-math
    notes = sh("g++ -std=c++20 -O3 -march=native -fopt-info-vec-all -c bench/dot.cpp -o /dev/null", check=False).stderr
    RESULTS["compiler"] = {"rows": rows, "asm": asm, "exact": r["exact"],
                           "vectorizer_notes": [l for l in notes.splitlines() if "dot.cpp:1" in l][:20]}
    display(pd.DataFrame(rows).round(4))
    print("\n".join(RESULTS["compiler"]["vectorizer_notes"][:8]))
    print("\n-O3 -march=native, plain loop:\n  " + "\n  ".join(asm["-O3 -march=native | plain"][:40]))
flags loop ns_per_element result bits error_vs_exact instructions packed_ops scalar_adds
0 -O0 plain 3.0952 262127.2344 0x487ffbcf -44.9122 31 0 1
1 -O0 lanes 3.0815 262171.3438 0x4880036b -0.8028 89 0 3
2 -O2 plain 1.3520 262127.2344 0x487ffbcf -44.9122 19 0 1
3 -O2 lanes 0.7634 262171.3438 0x4880036b -0.8028 67 0 3
4 -O3 plain 1.3312 262127.2344 0x487ffbcf -44.9122 59 1 7
5 -O3 lanes 0.1703 262171.3438 0x4880036b -0.8028 133 6 19
6 -O3 -march=native plain 1.3510 262127.2344 0x487ffbcf -44.9122 82 2 12
7 -O3 -march=native lanes 0.1624 262171.3438 0x4880036b -0.8028 127 3 20
8 -O3 -march=native -ffast-math plain 0.1634 262171.3438 0x4880036b -0.8028 69 7 1
9 -O3 -march=native -ffast-math lanes 0.1651 262171.3438 0x4880036b -0.8028 105 11 2
bench/dot.cpp:12:29: optimized: loop vectorized using 32 byte vectors
bench/dot.cpp:12:29: optimized: loop vectorized using 16 byte vectors
bench/dot.cpp:10:44: note: vectorized 1 loops in function.
bench/dot.cpp:13:10: note: ***** Analysis failed with vector mode V8SF
bench/dot.cpp:13:10: note: ***** Skipping vector mode V32QI, which would repeat the analysis for V8SF
bench/dot.cpp:17:44: note: vectorized 3 loops in function.
bench/dot.cpp:17:44: note: ***** Analysis succeeded with vector mode V8SF
bench/dot.cpp:17:44: note: SLPing BB part

-O3 -march=native, plain loop:
  endbr64
  mov rax,rdi
  mov rcx,rsi
  test rdx,rdx
  je 2830 <dot_plain+0x130>
  lea rsi,[rdx-0x1]
  cmp rsi,0x6
  jbe 283c <dot_plain+0x13c>
  mov rdi,rdx
  shr rdi,0x3
  shl rdi,0x5
  xor esi,esi
  vxorps xmm0,xmm0,xmm0
  nop WORD PTR [rax+rax*1+0x0]
  vmovups ymm4,YMMWORD PTR [rax+rsi*1]
  vmulps ymm1,ymm4,YMMWORD PTR [rcx+rsi*1]
  add rsi,0x20
  vaddss xmm0,xmm0,xmm1
  vshufps xmm3,xmm1,xmm1,0x55
  vshufps xmm2,xmm1,xmm1,0xff
  vaddss xmm0,xmm0,xmm3
  vunpckhps xmm3,xmm1,xmm1
  vextractf128 xmm1,ymm1,0x1
  vaddss xmm0,xmm0,xmm3
  vaddss xmm0,xmm0,xmm2
  vshufps xmm2,xmm1,xmm1,0x55
  vaddss xmm0,xmm0,xmm1
  vaddss xmm0,xmm0,xmm2
  vunpckhps xmm2,xmm1,xmm1
  vshufps xmm1,xmm1,xmm1,0xff
  vaddss xmm0,xmm0,xmm2
  vaddss xmm0,xmm0,xmm1
  cmp rsi,rdi
  jne 2738 <dot_plain+0x38>
  mov rsi,rdx
  and rsi,0xfffffffffffffff8
  test dl,0x7
  je 2838 <dot_plain+0x138>
  vzeroupper
  mov rdi,rdx
[compiler: 11.2 s]

Part 9 — Templates

Three things a template buys and costs. Specialisation: the search knows common dimensions at compile time (measured in Part 14). Code size and compile time: every (metric, element type) pair is another copy of the search, and each copy contains one kernel per specialised dimension. Errors: handing the search a type that is not a metric fails at the call with C++20 concepts, and deep inside the loop without them.

%%writefile bench/bad_metric.cpp
// bench/bad_metric.cpp — a metric that doesn't fit, handed to the search template.
#include <cstdint>
#include "tinyknn/search_cpu.hpp"

struct Cosine {  // a metric someone started writing: no term(), no from_dot()
  static constexpr const char* name = "cosine";
};

void run(tinyknn::matrix_view<const float> q, tinyknn::matrix_view<const float> db,
         tinyknn::matrix_view<std::int64_t> ids, tinyknn::matrix_view<float> dists) {
  tinyknn::search_cpu<Cosine>(q, db, ids, dists);
}
Writing bench/bad_metric.cpp
with experiment("templates"):
    pairs = [(m, t) for t in ["float", "double", "std::int8_t", "std::uint8_t"]
             for m in ["tinyknn::L2", "tinyknn::InnerProduct"]]
    rows = []
    for n in [1, 2, 4, 8]:
        src = Path(f"build/instantiate_{n}.cpp")
        src.write_text('#include <cstdint>\n#include "tinyknn/search_cpu.hpp"\n' + "".join(
            f"template void tinyknn::search_cpu<{m}, {t}>(tinyknn::matrix_view<const {t}>, "
            f"tinyknn::matrix_view<const {t}>, tinyknn::matrix_view<std::int64_t>, "
            f"tinyknn::matrix_view<float>, int, std::size_t, bool);\n" for m, t in pairs[:n]))
        t0 = time.perf_counter()
        sh(f"g++ -std=c++20 -O3 -march=native -Itinyknn/include -c {src} -o build/instantiate_{n}.o")
        secs = time.perf_counter() - t0
        syms = sh(f"nm -C build/instantiate_{n}.o | grep -c 'search_cpu<'", check=False).stdout.strip()
        rows.append({"instantiated_pairs": n, "compile_s": secs,
                     "object_bytes": Path(f"build/instantiate_{n}.o").stat().st_size,
                     "search_symbols": int(syms or 0)})
    errors = {}
    for std in ["c++20", "c++17"]:
        p = sh(f"g++ -std={std} -Itinyknn/include -c bench/bad_metric.cpp -o /dev/null", check=False)
        lines = p.stderr.splitlines()
        errors[std] = {"lines": len(lines), "first_errors": [l for l in lines if "error" in l][:6],
                       "text": lines[:40]}
    RESULTS["templates"] = {"instantiation": rows, "bad_metric": errors}
    display(pd.DataFrame(rows).round(2))
    for std, e in errors.items():
        print(f"\n{std}: {e['lines']} lines of diagnostics")
        print("\n".join(e["first_errors"][:3]))
instantiated_pairs compile_s object_bytes search_symbols
0 1 2.29 40408 11
1 2 3.50 77312 22
2 4 6.11 147848 44
3 8 6.48 222056 88

c++20: 26 lines of diagnostics
bench/bad_metric.cpp:11:30: error: no matching function for call to ‘search_cpu<Cosine>(tinyknn::matrix_view<const float>&, tinyknn::matrix_view<const float>&, tinyknn::matrix_view<long int>&, tinyknn::matrix_view<float>&)’

c++17: 56 lines of diagnostics
tinyknn/include/tinyknn/distance.hpp:78:59: error: ‘term’ is not a member of ‘Cosine’
tinyknn/include/tinyknn/distance.hpp:59:54: error: ‘term’ is not a member of ‘Cosine’
tinyknn/include/tinyknn/distance.hpp:60:39: error: ‘term’ is not a member of ‘Cosine’
[templates: 19.7 s]

Part 10 — Dispatch: when the choice is made, and how often

The same distances computed with the metric chosen in seven ways, from a template with the dimension fixed at compile time down to a std::function called once per pair. The metric’s name comes from the command line, so the compiler cannot see through the virtual calls.

%%writefile bench/dispatch.cpp
// bench/dispatch.cpp — one query against every row, with the metric chosen in six different
// ways. Same arithmetic in every case; what changes is when the choice is made and how often.
#include <functional>
#include <memory>
#include <random>
#include <string>
#include <vector>
#include "tinyknn/distance.hpp"
#include "bench.hpp"
using namespace tinyknn;

// Run-time polymorphism, per pair: one virtual call for every distance.
struct Metric1 {
  virtual ~Metric1() = default;
  virtual float operator()(const float* a, const float* b, std::size_t d) const = 0;
};
template <class M>
struct Metric1Impl : Metric1 {
  float operator()(const float* a, const float* b, std::size_t d) const override { return distance<M>(a, b, d); }
};

// Run-time polymorphism, per batch: one virtual call, then a compiled loop over `n` rows.
struct MetricN {
  virtual ~MetricN() = default;
  virtual void scan(const float* q, const float* db, std::size_t n, std::size_t d, float* out) const = 0;
};
template <class M>
struct MetricNImpl : MetricN {
  void scan(const float* q, const float* db, std::size_t n, std::size_t d, float* out) const override {
    for (std::size_t i = 0; i < n; ++i) out[i] = distance<M>(q, db + i * d, d);
  }
};

using FnPtr = float (*)(const float*, const float*, std::size_t);

// Compile-time polymorphism: the metric is a template argument.
template <class M>
__attribute__((noinline)) void scan_template(const float* q, const float* db, std::size_t n, std::size_t d, float* out) {
  for (std::size_t i = 0; i < n; ++i) out[i] = distance<M>(q, db + i * d, d);
}

// Compile-time polymorphism and a compile-time dimension: the whole inner loop is known.
template <class M, std::size_t D>
__attribute__((noinline)) void scan_fixed(const float* q, const float* db, std::size_t n, float* out) {
  for (std::size_t i = 0; i < n; ++i) {
    constexpr std::size_t L = D < 8 ? D : 8;  // lanes, as in distance<M>
    const float* x = db + i * D;
    float acc[L] = {};
    for (std::size_t j = 0; j < D; j += L)
      for (std::size_t l = 0; l < L; ++l) acc[l] += M::term(q[j + l], x[j + l]);
    float s = 0;
    for (float v : acc) s += v;
    out[i] = s;
  }
}

// No abstraction at all: L2 written out by hand.
__attribute__((noinline)) void scan_handwritten(const float* q, const float* db, std::size_t n, std::size_t d, float* out) {
  for (std::size_t i = 0; i < n; ++i) {
    const float* x = db + i * d;
    float acc[8] = {};
    std::size_t j = 0;
    for (; j + 8 <= d; j += 8)
      for (int l = 0; l < 8; ++l) {
        float t = q[j + l] - x[j + l];
        acc[l] += t * t;
      }
    for (; j < d; ++j) {
      float t = q[j] - x[j];
      acc[0] += t * t;
    }
    float s = 0;
    for (float v : acc) s += v;
    out[i] = s;
  }
}

int main(int argc, char** argv) {
  // The metric's name arrives at run time, as it would from a config file or from Python,
  // so the compiler cannot know which implementation the pointers below will hold.
  const std::string which = argc > 1 ? argv[1] : "l2";
  std::unique_ptr<Metric1> per_pair;
  std::unique_ptr<MetricN> per_batch;
  FnPtr fn;
  if (which == "l2") {
    per_pair = std::make_unique<Metric1Impl<L2>>();
    per_batch = std::make_unique<MetricNImpl<L2>>();
    fn = &distance<L2, 8, float>;
  } else {
    per_pair = std::make_unique<Metric1Impl<InnerProduct>>();
    per_batch = std::make_unique<MetricNImpl<InnerProduct>>();
    fn = &distance<InnerProduct, 8, float>;
  }
  std::function<float(const float*, const float*, std::size_t)> stdfn = fn;

  bench::json out;
  std::string rows = "[";
  // A 256 KiB table, scanned many times: it stays in cache, so what is timed is the work
  // per distance, not the trip to main memory.
  auto fixed = [&](std::size_t d, const float* q, const float* db, std::size_t n, float* o) {
    switch (d) {
      case 4: return scan_fixed<L2, 4>(q, db, n, o);
      case 16: return scan_fixed<L2, 16>(q, db, n, o);
      case 64: return scan_fixed<L2, 64>(q, db, n, o);
      case 128: return scan_fixed<L2, 128>(q, db, n, o);
      default: return scan_fixed<L2, 512>(q, db, n, o);
    }
  };
  for (std::size_t d : {4, 16, 64, 128, 512}) {
    const std::size_t n = (std::size_t(64) << 10) / d, reps = std::max<std::size_t>(1, (std::size_t(32) << 20) / (n * d));
    std::vector<float> db(n * d), q(d), outv(n);
    std::mt19937 rng(5);
    std::normal_distribution<float> nd;
    for (auto& v : db) v = nd(rng);
    for (auto& v : q) v = nd(rng);
    auto per = [&](auto&& body) {
      return bench::best_of(5, [&] { for (std::size_t r = 0; r < reps; ++r) body(); }) / double(n * reps) * 1e9;
    };
    bench::json r;
    r.num("dim", double(d)).num("rows", double(n))
        .num("template_fixed_dim", per([&] { fixed(d, q.data(), db.data(), n, outv.data()); bench::keep(outv[7]); }))
        .num("handwritten", per([&] { scan_handwritten(q.data(), db.data(), n, d, outv.data()); bench::keep(outv[7]); }))
        .num("template", per([&] { scan_template<L2>(q.data(), db.data(), n, d, outv.data()); bench::keep(outv[7]); }))
        .num("virtual_per_batch", per([&] { per_batch->scan(q.data(), db.data(), n, d, outv.data()); bench::keep(outv[7]); }))
        .num("function_pointer", per([&] { for (std::size_t i = 0; i < n; ++i) outv[i] = fn(q.data(), &db[i * d], d); bench::keep(outv[7]); }))
        .num("virtual_per_pair", per([&] { for (std::size_t i = 0; i < n; ++i) outv[i] = (*per_pair)(q.data(), &db[i * d], d); bench::keep(outv[7]); }))
        .num("std_function", per([&] { for (std::size_t i = 0; i < n; ++i) outv[i] = stdfn(q.data(), &db[i * d], d); bench::keep(outv[7]); }));
    rows += (rows.size() > 1 ? "," : "") + r.done();
  }
  out.raw("by_dim", rows + "]");

  // Granularity: one virtual call per block of b rows (d = 16). b = 1 is "per pair";
  // b = everything is "per batch". The price of the call is paid once per block.
  {
    const std::size_t d = 16, n = 4096, reps = 512;
    std::vector<float> db(n * d), q(d), outv(n);
    std::mt19937 rng(6);
    std::normal_distribution<float> nd;
    for (auto& v : db) v = nd(rng);
    for (auto& v : q) v = nd(rng);
    std::vector<double> blocks, ns;
    for (std::size_t b = 1; b <= 4096; b *= 4) {
      double t = bench::best_of(5, [&] {
        for (std::size_t r = 0; r < reps; ++r)
          for (std::size_t i = 0; i < n; i += b) per_batch->scan(q.data(), &db[i * d], std::min(b, n - i), d, &outv[i]);
        bench::keep(outv[3]);
      });
      blocks.push_back(double(b));
      ns.push_back(t / double(n * reps) * 1e9);
    }
    bench::json g;
    g.num("dim", double(d)).raw("block", bench::array(blocks)).raw("ns_per_distance", bench::array(ns));
    out.raw("granularity", g.done());
  }
  std::puts(out.done().c_str());
}
Writing bench/dispatch.cpp
with experiment("dispatch"):
    build("bench/dispatch.cpp", "build/dispatch")
    RESULTS["dispatch"] = dsp = run_json("build/dispatch l2")
    display(pd.DataFrame(dsp["by_dim"]).set_index("dim").round(2))
    print("virtual call per block of b rows (d=16):",
          dict(zip(dsp["granularity"]["block"], np.round(dsp["granularity"]["ns_per_distance"], 2))))
rows template_fixed_dim handwritten template virtual_per_batch function_pointer virtual_per_pair std_function
dim
4 16384 0.61 6.57 6.42 6.45 21.44 22.13 23.40
16 4096 3.03 4.63 4.55 4.67 11.08 11.46 12.58
64 1024 9.27 10.48 10.44 11.45 14.49 14.97 16.78
128 512 16.34 17.34 19.32 19.75 24.79 25.16 26.34
512 128 72.09 76.54 79.36 72.06 107.96 112.64 129.70
virtual call per block of b rows (d=16): {1: np.float64(9.08), 4: np.float64(5.58), 16: np.float64(5.02), 64: np.float64(4.8), 256: np.float64(4.67), 1024: np.float64(4.51), 4096: np.float64(4.85)}
[dispatch: 7.6 s]

The same law one level up: a call from Python into compiled code costs about the same whatever it does, so it is only worth making when there is enough work behind it. Time per element to sum n float32 numbers, for growing n.

with experiment("call_overhead"):
    def py_noop():
        pass

    calls = 200_000
    t_py = best_time(lambda: [py_noop() for _ in range(calls)], reps=3) / calls
    t_len = best_time(lambda: [len(QUERIES) for _ in range(calls)], reps=3) / calls
    t_nb = best_time(lambda: [tk.noop() for _ in range(calls)], reps=3) / calls
    t_np = best_time(lambda: [np.add(1.0, 2.0) for _ in range(calls)], reps=3) / calls
    big = np.random.default_rng(1).random(1 << 22, dtype=np.float32)
    big_list = big.tolist()
    rows = []
    for n in [1, 4, 16, 64, 256, 1024, 4096, 16384, 65536, 262144, 1 << 20, 1 << 22]:
        a, lst = big[:n], big_list[:n]
        reps = max(3, min(2000, (1 << 22) // n // 4))
        def loop():
            s = 0.0
            for v in lst:
                s += v
            return s
        rows.append({"n": n,
                     "python_loop": best_time(loop, reps=min(reps, 50)) / n * 1e9,
                     "numpy_sum": best_time(lambda: a.sum(), reps=reps) / n * 1e9,
                     "tinyknn_sum": best_time(lambda: tk.sum(a), reps=reps) / n * 1e9})
    RESULTS["call_overhead"] = {"python_function_ns": t_py * 1e9, "builtin_len_ns": t_len * 1e9,
                                "pybind11_noop_ns": t_nb * 1e9, "numpy_scalar_add_ns": t_np * 1e9,
                                "sum_ns_per_element": rows}
    print({k: round(v, 1) for k, v in RESULTS["call_overhead"].items() if k.endswith("_ns")})
    display(pd.DataFrame(rows).round(3))
{'python_function_ns': 47.5, 'builtin_len_ns': 72.4, 'pybind11_noop_ns': 84.0, 'numpy_scalar_add_ns': 1150.1}
n python_loop numpy_sum tinyknn_sum
0 1 183.000 1470.000 557.000
1 4 65.750 373.000 139.500
2 16 29.250 91.875 34.250
3 64 22.594 23.266 8.719
4 256 19.500 6.109 2.223
5 1024 20.257 1.871 0.646
6 4096 18.902 0.635 0.234
7 16384 18.971 0.333 0.121
8 65536 19.135 0.272 0.098
9 262144 19.911 0.311 0.099
10 1048576 20.861 0.256 0.160
11 4194304 20.865 0.309 0.171
[call_overhead: 1.9 s]

Part 11 — From source files to import

What the compiler actually reads: every #include is pasted in before compilation starts, so a one-line file that includes a header is as big as that header and everything it includes. Then what the build produced — which symbols libtinyknn.so exports, what their names look like to the linker, what the Python module needs at load time — and one flag that decides whether two libraries built from the same source can call each other.

with experiment("build_model"):
    headers = ["<cstdio>", "<vector>", "<string>", "<memory>", "<algorithm>", "<thread>", "<iostream>",
               "<span>", "<pybind11/pybind11.h>", "<pybind11/numpy.h>"]
    extra = {"<pybind11/pybind11.h>": PYINC, "<pybind11/numpy.h>": PYINC}
    if CUDA_HOME:
        headers.append("<cuda_runtime.h>")
        extra["<cuda_runtime.h>"] = f"-I{CUDA_HOME}/include"
    try:
        import torch
        from torch.utils.cpp_extension import include_paths
        headers.append("<torch/extension.h>")
        extra["<torch/extension.h>"] = PYINC + " " + " ".join(f"-I{p}" for p in include_paths())
        RESULTS["torch_cxx11_abi"] = bool(torch.compiled_with_cxx11_abi())
    except Exception as e:
        print("torch headers unavailable:", e)
    rows = []
    for h in headers:
        flags = extra.get(h, "")
        pre = sh(f"echo '#include {h}' | g++ -std=c++17 {flags} -E -x c++ - | wc -l", check=False).stdout.strip()
        t0 = time.perf_counter()
        ok = sh(f"echo '#include {h}' | g++ -std=c++17 {flags} -fsyntax-only -x c++ -", check=False).returncode == 0
        rows.append({"header": h, "lines_after_preprocessing": int(pre or 0),
                     "parse_s": time.perf_counter() - t0 if ok else None})
    for tu, flags in [("tinyknn/src/cpu.cpp", "-std=c++20 -Itinyknn/include"),
                      ("tinyknn/python/bindings.cpp", f"-std=c++20 -Itinyknn/include {PYINC}")]:
        pre = sh(f"g++ {flags} -E {tu} | wc -l").stdout.strip()
        src_lines = sum(1 for _ in open(tu))
        rows.append({"header": tu, "lines_after_preprocessing": int(pre), "source_lines": src_lines})
    exported = sh("nm -DC --defined-only build/libtinyknn.so | grep ' T ' | grep 'tinyknn::'", check=False).stdout.splitlines()
    mangled = sh("nm -D --defined-only build/libtinyknn.so | grep ' T ' | grep search", check=False).stdout.splitlines()
    ldd = sh(f"ldd build/_tinyknn{EXT}", check=False).stdout.splitlines()
    abi_src = Path("build/abi.cpp")
    abi_src.write_text("#include <string>\nvoid load(const std::string& path) {}\n")
    abi = {}
    for flag in [0, 1]:
        sh(f"g++ -std=c++17 -D_GLIBCXX_USE_CXX11_ABI={flag} -c {abi_src} -o build/abi_{flag}.o")
        abi[f"_GLIBCXX_USE_CXX11_ABI={flag}"] = sh(f"nm build/abi_{flag}.o | grep ' T '").stdout.split()[-1]
    RESULTS["build_model"] = {"preprocessing": rows, "exported": [l.split(" ", 2)[-1] for l in exported],
                              "mangled_search": [l.split()[-1] for l in mangled], "ldd": [l.strip() for l in ldd],
                              "abi_names": abi, "module_exports": int(sh(f"nm -D --defined-only build/_tinyknn{EXT} | grep -c ' T '", check=False).stdout.strip() or 0)}
    display(pd.DataFrame(rows))
    print("\n".join(RESULTS["build_model"]["exported"]))
    print(RESULTS["build_model"]["mangled_search"])
    print("\n".join(RESULTS["build_model"]["ldd"]))
    print(json.dumps(abi, indent=1), "| torch built with CXX11 ABI:", RESULTS.get("torch_cxx11_abi"))
header lines_after_preprocessing parse_s source_lines
0 <cstdio> 1015 0.018361 NaN
1 <vector> 14404 0.083324 NaN
2 <string> 22705 0.176457 NaN
3 <memory> 24842 0.144427 NaN
4 <algorithm> 34989 0.217819 NaN
5 <thread> 21739 0.146448 NaN
6 <iostream> 32203 0.254960 NaN
7 <span> 11 0.010560 NaN
8 <pybind11/pybind11.h> 111674 2.075060 NaN
9 <pybind11/numpy.h> 116444 2.539978 NaN
10 <cuda_runtime.h> 9859 0.059053 NaN
11 <torch/extension.h> 350309 17.655298 NaN
12 tinyknn/src/cpu.cpp 81809 NaN 24.0
13 tinyknn/python/bindings.cpp 151049 NaN 164.0
tinyknn::search_async(tinyknn::metric, tinyknn::gpu_algo, tinyknn::topk_algo, tinyknn::matrix_view<float const>, tinyknn::matrix_view<float const>, float const*, float*, float*, tinyknn::matrix_view<long>, tinyknn::matrix_view<float>, CUstream_st*)
tinyknn::search(tinyknn::metric, tinyknn::matrix_view<float const>, tinyknn::matrix_view<float const>, tinyknn::matrix_view<long>, tinyknn::matrix_view<float>, int, unsigned long, bool)
tinyknn::has_cuda()
tinyknn::gpu_index::wait()
tinyknn::gpu_index::search(tinyknn::matrix_view<float const>, tinyknn::matrix_view<long>, tinyknn::matrix_view<float>, tinyknn::gpu_algo, tinyknn::topk_algo, tinyknn::trace*)
tinyknn::gpu_index::enqueue(tinyknn::matrix_view<float const>, unsigned long, tinyknn::gpu_algo, tinyknn::topk_algo)
tinyknn::gpu_index::operator=(tinyknn::gpu_index&&)
tinyknn::gpu_index::gpu_index(tinyknn::matrix_view<float const>, tinyknn::metric)
tinyknn::gpu_index::gpu_index(tinyknn::gpu_index&&)
tinyknn::gpu_index::gpu_index(tinyknn::matrix_view<float const>, tinyknn::metric)
tinyknn::gpu_index::gpu_index(tinyknn::gpu_index&&)
tinyknn::gpu_index::~gpu_index()
tinyknn::gpu_index::~gpu_index()
tinyknn::gpu_index::result_ids() const
tinyknn::gpu_index::result_dists() const
tinyknn::gpu_index::dim() const
tinyknn::gpu_index::size() const
tinyknn::gpu_index::chunk() const
['_ZN7tinyknn12search_asyncENS_6metricENS_8gpu_algoENS_9topk_algoENS_11matrix_viewIKfEES5_PS4_PfS7_NS3_IlEENS3_IfEEP11CUstream_st', '_ZN7tinyknn6searchENS_6metricENS_11matrix_viewIKfEES3_NS1_IlEENS1_IfEEimb', '_ZN7tinyknn9gpu_index6searchENS_11matrix_viewIKfEENS1_IlEENS1_IfEENS_8gpu_algoENS_9topk_algoEPNS_5traceE']
linux-vdso.so.1 (0x0000780c7b085000)
libtinyknn.so => /kaggle/working/build/libtinyknn.so (0x0000780c7af3f000)
libstdc++.so.6 => /lib/x86_64-linux-gnu/libstdc++.so.6 (0x0000780c7ad03000)
libgcc_s.so.1 => /lib/x86_64-linux-gnu/libgcc_s.so.1 (0x0000780c7ace3000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x0000780c7aaba000)
/lib64/ld-linux-x86-64.so.2 (0x0000780c7b087000)
libcudart.so.12 => /usr/local/cuda/lib64/libcudart.so.12 (0x0000780c7a800000)
libcublas.so.12 => /usr/local/cuda/lib64/libcublas.so.12 (0x0000780c73600000)
libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x0000780c7a719000)
libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x0000780c7a714000)
libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x0000780c7a70f000)
librt.so.1 => /lib/x86_64-linux-gnu/librt.so.1 (0x0000780c735fb000)
libcublasLt.so.12 => /usr/local/cuda/lib64/libcublasLt.so.12 (0x0000780c41200000)
{
 "_GLIBCXX_USE_CXX11_ABI=0": "_Z4loadRKSs",
 "_GLIBCXX_USE_CXX11_ABI=1": "_Z4loadRKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE"
} | torch built with CXX11 ABI: True
[build_model: 29.4 s]

Part 12 — The binding and the GIL

Only one thread can run Python bytecode at a time. A C++ function called from Python holds that lock too, unless it lets go. tk.search lets go (py::gil_scoped_release) around the search itself, and can be told not to. Several Python threads, each calling a single-threaded search:

with experiment("gil"):
    qs = QUERIES[:16]
    calls = 4
    rows = []
    for release in [False, True]:
        for n_threads in [1, 2, 4]:
            def worker(_):
                for _ in range(calls):
                    tk.search(qs, DB, K, threads=1, release_gil=release)
            t0 = time.perf_counter()
            with ThreadPoolExecutor(n_threads) as ex:
                list(ex.map(worker, range(n_threads)))
            t = time.perf_counter() - t0
            rows.append({"release_gil": release, "python_threads": n_threads,
                         "queries_per_s": n_threads * calls * len(qs) / t})
    RESULTS["gil"] = rows
    display(pd.DataFrame(rows).round(1))
release_gil python_threads queries_per_s
0 False 1 365.8
1 False 2 380.0
2 False 4 373.8
3 True 1 371.9
4 True 2 738.9
5 True 4 1031.5
[gil: 1.8 s]

Part 13 — The GPU: when a call returns, and when the work is done

A kernel that spins for 50 ms keeps the card busy while the host times other calls: a launch, allocation and freeing (plain and stream-ordered), an “async” copy from ordinary and from pinned memory. Then copy bandwidth in both directions for both kinds of host memory.

%%writefile bench/stream.cu
// bench/stream.cu — when does a CUDA call return, and when is its work actually done?
// A kernel that spins for 50 ms keeps the GPU busy while the host times other calls.
#include <cuda_runtime.h>

#include <cstdlib>
#include <cstring>
#include <vector>

#include "bench.hpp"

#define CK(x)                                                                   \
  do {                                                                          \
    cudaError_t e_ = (x);                                                       \
    if (e_ != cudaSuccess) {                                                    \
      std::fprintf(stderr, "%s: %s\n", #x, cudaGetErrorString(e_));             \
      std::exit(1);                                                             \
    }                                                                           \
  } while (0)

__global__ void spin(long long cycles) {
  const long long start = clock64();
  while (clock64() - start < cycles) {
  }
}

int main() {
  CK(cudaFree(nullptr));  // create the context now, not inside the first measurement
  cudaStream_t s;
  CK(cudaStreamCreate(&s));
  // Calibrate: the clock the card actually runs at is not its rated clock, so time a known
  // number of cycles and scale to 50 ms.
  const long long probe = 20000000;
  spin<<<1, 1, 0, s>>>(probe);
  CK(cudaStreamSynchronize(s));
  double t0 = bench::now();
  spin<<<1, 1, 0, s>>>(probe);
  CK(cudaStreamSynchronize(s));
  const double probe_s = bench::now() - t0;
  const double busy_ms = 50;
  const long long cycles = (long long)(probe * (busy_ms / 1e3) / probe_s);

  bench::json out;
  out.num("busy_ms", busy_ms).num("measured_sm_mhz", probe / probe_s / 1e6);

  // Host time spent in `call` while the GPU is busy with a 50 ms kernel on stream s.
  auto while_busy = [&](auto&& call) {
    spin<<<1, 1, 0, s>>>(cycles);
    const double t0 = bench::now();
    call();
    const double t_call = bench::now() - t0;
    CK(cudaStreamSynchronize(s));
    return t_call * 1e3;
  };

  // 1. A kernel launch returns when the launch is queued, not when the kernel is done.
  {
    t0 = bench::now();
    spin<<<1, 1, 0, s>>>(cycles);
    const double t_launch = bench::now() - t0;
    CK(cudaStreamSynchronize(s));
    const double t_done = bench::now() - t0;
    out.num("launch_return_ms", t_launch * 1e3).num("launch_done_ms", t_done * 1e3);
  }

  // 2. Allocation calls while the GPU is busy. cudaFree waits for the whole device to go
  //    idle; the stream-ordered cudaFreeAsync just queues the free behind the work.
  {
    const std::size_t bytes = 64 << 20;
    void* p = nullptr;
    CK(cudaMalloc(&p, bytes));
    out.num("cudaFree_ms", while_busy([&] { CK(cudaFree(p)); }));
    out.num("cudaMalloc_ms", while_busy([&] { CK(cudaMalloc(&p, bytes)); }));
    CK(cudaFree(p));
    CK(cudaMallocAsync(&p, bytes, s));
    CK(cudaStreamSynchronize(s));
    out.num("cudaFreeAsync_ms", while_busy([&] { CK(cudaFreeAsync(p, s)); }));
    out.num("cudaMallocAsync_ms", while_busy([&] { CK(cudaMallocAsync(&p, bytes, s)); }));
    CK(cudaFreeAsync(p, s));
    CK(cudaStreamSynchronize(s));
  }

  // 3. An "async" copy from ordinary (pageable) memory can't start until the GPU is free to
  //    take it, and the call waits. From pinned memory it is queued and returns at once.
  {
    const std::size_t bytes = 64 << 20;
    char* pageable = static_cast<char*>(std::malloc(bytes));
    std::memset(pageable, 1, bytes);
    char* pinned = nullptr;
    CK(cudaMallocHost(&pinned, bytes));
    std::memset(pinned, 1, bytes);
    void* dev = nullptr;
    CK(cudaMalloc(&dev, bytes));
    out.num("memcpyAsync_pageable_return_ms",
            while_busy([&] { CK(cudaMemcpyAsync(dev, pageable, bytes, cudaMemcpyHostToDevice, s)); }));
    out.num("memcpyAsync_pinned_return_ms",
            while_busy([&] { CK(cudaMemcpyAsync(dev, pinned, bytes, cudaMemcpyHostToDevice, s)); }));

    // 4. Bandwidth by size, both directions, pageable vs pinned (synchronous copies).
    std::vector<double> sizes, h2d_pageable, h2d_pinned, d2h_pageable, d2h_pinned;
    for (std::size_t n = 4 << 10; n <= bytes; n *= 4) {
      auto gbps = [&](void* dst, const void* src, cudaMemcpyKind kind) {
        const int reps = n <= (1 << 20) ? 50 : 5;
        double t = bench::best_of(reps, [&] { CK(cudaMemcpy(dst, src, n, kind)); });
        return double(n) / t / 1e9;
      };
      sizes.push_back(double(n));
      h2d_pageable.push_back(gbps(dev, pageable, cudaMemcpyHostToDevice));
      h2d_pinned.push_back(gbps(dev, pinned, cudaMemcpyHostToDevice));
      d2h_pageable.push_back(gbps(pageable, dev, cudaMemcpyDeviceToHost));
      d2h_pinned.push_back(gbps(pinned, dev, cudaMemcpyDeviceToHost));
    }
    bench::json bw;
    bw.raw("bytes", bench::array(sizes)).raw("h2d_pageable_gbps", bench::array(h2d_pageable))
        .raw("h2d_pinned_gbps", bench::array(h2d_pinned)).raw("d2h_pageable_gbps", bench::array(d2h_pageable))
        .raw("d2h_pinned_gbps", bench::array(d2h_pinned));
    out.raw("bandwidth", bw.done());

    // 5. Allocating pinned memory is itself expensive: the OS has to lock the pages.
    char* tmp = nullptr;
    t0 = bench::now();
    CK(cudaMallocHost(&tmp, bytes));
    const double t_pin = bench::now() - t0;
    t0 = bench::now();
    char* tmp2 = static_cast<char*>(std::malloc(bytes));
    std::memset(tmp2, 1, bytes);  // 1, not 0: malloc + memset(0) may be turned into calloc
    bench::keep(tmp2[bytes / 3]);
    const double t_page = bench::now() - t0;
    out.num("pinned_alloc_64MiB_ms", t_pin * 1e3).num("pageable_alloc_touch_64MiB_ms", t_page * 1e3);
    CK(cudaFreeHost(tmp));
    std::free(tmp2);

    CK(cudaFree(dev));
    CK(cudaFreeHost(pinned));
    std::free(pageable);
  }
  CK(cudaStreamDestroy(s));
  std::puts(out.done().c_str());
}
Writing bench/stream.cu
with experiment("stream"):
    if not HAVE_CUDA:
        raise RuntimeError("no CUDA toolchain")
    sh(f"{NVCC} -std=c++17 -O2 -arch=sm_75 bench/stream.cu -o build/stream")
    RESULTS["stream"] = st = run_json("build/stream")
    print(json.dumps({k: v for k, v in st.items() if k != "bandwidth"}, indent=1))
    bw = pd.DataFrame(st["bandwidth"])
    bw["MiB"] = bw.bytes / 2**20
    display(bw.drop(columns="bytes").round(2))
{
 "busy_ms": 50,
 "measured_sm_mhz": 913.847527005181,
 "launch_return_ms": 0.0097089999826494,
 "launch_done_ms": 49.9575389999336,
 "cudaFree_ms": 50.2123869999878,
 "cudaMalloc_ms": 0.176503000034245,
 "cudaFreeAsync_ms": 0.027231999979449,
 "cudaMallocAsync_ms": 0.802032000024155,
 "memcpyAsync_pageable_return_ms": 42.8851359999953,
 "memcpyAsync_pinned_return_ms": 0.00962900003287359,
 "pinned_alloc_64MiB_ms": 33.0523369999582,
 "pageable_alloc_touch_64MiB_ms": 47.9689190000272
}
h2d_pageable_gbps h2d_pinned_gbps d2h_pageable_gbps d2h_pinned_gbps MiB
0 0.84 0.54 0.49 0.68 0.00
1 1.80 1.35 1.40 2.37 0.02
2 2.90 5.36 2.65 6.20 0.06
3 4.41 9.38 4.68 10.25 0.25
4 4.36 11.44 5.50 12.26 1.00
5 4.75 11.94 8.17 12.77 4.00
6 4.68 12.22 8.17 12.92 16.00
7 4.71 12.31 4.85 13.09 64.00
[stream: 3.5 s]

The library’s own asynchronous path. enqueue stages the queries, queues the copy in, both kernels and the copy out on the index’s stream, and returns. The results land in pinned memory the index owns; peek_ids looks at it without copying. Reading before wait() returns whatever the buffer held before — here, the previous batch’s answers.

with experiment("async"):
    if not HAVE_CUDA:
        raise RuntimeError("no CUDA")
    qa, qb = QUERIES[:256], QUERIES[256:512]
    ref_b = exact_knn(qb, DB, K)
    INDEX.enqueue(qa, K)
    INDEX.wait()
    answers_a = INDEX.peek_ids(256, K).copy()
    t0 = time.perf_counter()
    INDEX.enqueue(qb, K)
    t_return = time.perf_counter() - t0
    early = INDEX.peek_ids(256, K).copy()
    INDEX.wait()
    t_done = time.perf_counter() - t0
    late = INDEX.peek_ids(256, K).copy()
    RESULTS["async"] = {"queries": 256, "enqueue_return_ms": t_return * 1e3, "done_ms": t_done * 1e3,
                        "early_read_equals_previous_batch": float((early == answers_a).all(1).mean()),
                        "early_read_recall": recall(early, ref_b), "after_wait_recall": recall(late, ref_b)}
    print(json.dumps(RESULTS["async"], indent=1))
{
 "queries": 256,
 "enqueue_return_ms": 0.0767280000673054,
 "done_ms": 2.130926000063482,
 "early_read_equals_previous_batch": 1.0,
 "early_read_recall": 0.0011718750000000002,
 "after_wait_recall": 1.0
}
[async: 0.5 s]

Two ways to compute the distance matrix on the card — one thread per (query, row) pair, or one matrix multiplication — and three ways to pick the k smallest of each row: one block per query with k known only at run time (the per-thread candidate lists end up in slow local memory, as ptxas reported at build time), the same with k as a template argument (lists in registers), and that with each row split across many blocks, so that even a single query occupies the whole GPU. Against the same computation written in PyTorch.

with experiment("gpu_algos"):
    if not HAVE_CUDA:
        raise RuntimeError("no CUDA")
    rows = []
    for algo, sel in [("naive", "runtime_k"), ("gemm", "runtime_k"), ("gemm", "compile_time_k"),
                      ("gemm", "split_rows")]:
        for nq in [1, 16, 256, 1000]:
            q = QUERIES[:nq]
            traces = []
            for _ in range(7):
                traces.append(INDEX.search(q, K, algo, trace=True, topk=sel)[2])
            tr = sorted(traces, key=lambda d: d["library_total"])[len(traces) // 2]
            rows.append({"impl": f"tinyknn {algo} / {sel}", "queries": nq, "distances_ms": tr["distances"],
                         "topk_ms": tr["topk"], "total_ms": tr["library_total"],
                         "recall": recall(INDEX.search(q, K, algo, topk=sel)[0], exact_knn(q, DB, K))})
    try:
        import torch
        dbt = torch.from_numpy(DB).cuda()
        dbn = (dbt * dbt).sum(1)
        def torch_search(q):
            qt = torch.from_numpy(q).cuda()
            d = (qt * qt).sum(1, keepdim=True) + dbn[None] - 2.0 * qt @ dbt.T
            return torch.topk(d, K, dim=1, largest=False).indices.cpu()
        for nq in [1, 16, 256, 1000]:
            q = QUERIES[:nq]
            t = best_time(lambda: torch_search(q), reps=7, warmup=2)
            rows.append({"impl": "PyTorch (matmul + topk)", "queries": nq, "total_ms": t * 1e3,
                         "recall": recall(torch_search(q).numpy(), exact_knn(q, DB, K))})
        del dbt, dbn
        torch.cuda.empty_cache()
    except Exception as e:
        print("torch comparison skipped:", e)
    RESULTS["gpu_algos"] = rows
    display(pd.DataFrame(rows).round(3))
impl queries distances_ms topk_ms total_ms recall
0 tinyknn naive / runtime_k 1 0.493 0.501 1.022 1.0
1 tinyknn naive / runtime_k 16 7.807 0.612 8.460 1.0
2 tinyknn naive / runtime_k 256 139.754 5.482 145.364 1.0
3 tinyknn naive / runtime_k 1000 551.337 21.077 572.945 1.0
4 tinyknn gemm / runtime_k 1 0.231 0.474 0.735 1.0
5 tinyknn gemm / runtime_k 16 0.342 0.637 1.012 1.0
6 tinyknn gemm / runtime_k 256 1.268 4.751 6.092 1.0
7 tinyknn gemm / runtime_k 1000 7.317 26.455 34.135 1.0
8 tinyknn gemm / compile_time_k 1 0.234 0.152 0.423 1.0
9 tinyknn gemm / compile_time_k 16 0.347 0.223 0.612 1.0
10 tinyknn gemm / compile_time_k 256 1.266 0.807 2.149 1.0
11 tinyknn gemm / compile_time_k 1000 11.292 6.322 17.955 1.0
12 tinyknn gemm / split_rows 1 0.236 0.061 0.328 1.0
13 tinyknn gemm / split_rows 16 0.363 0.147 0.552 1.0
14 tinyknn gemm / split_rows 256 1.608 1.061 2.753 1.0
15 tinyknn gemm / split_rows 1000 11.282 6.323 17.932 1.0
16 PyTorch (matmul + topk) 1 NaN NaN 0.466 1.0
17 PyTorch (matmul + topk) 16 NaN NaN 1.003 1.0
18 PyTorch (matmul + topk) 256 NaN NaN 7.371 1.0
19 PyTorch (matmul + topk) 1000 NaN NaN 26.628 1.0
[gpu_algos: 17.1 s]

Part 14 — The CPU search, one change at a time

The same search rewritten eight times, each rung changing one thing: a vector of vectors and a full sort; one contiguous block; a heap instead of the sort; eight partial sums; four queries per database row; the dimension as a template argument; the database walked in cache-sized blocks; every core. Each rung is checked against the last one.

%%writefile bench/ladder.cpp
// bench/ladder.cpp — the same k-nearest-neighbour search, rewritten one change at a time.
// Every rung returns the same neighbours; only the time changes.
#include <algorithm>
#include <cstdint>
#include <random>
#include <thread>
#include <vector>
#include "tinyknn/search_cpu.hpp"
#include "bench.hpp"
using namespace tinyknn;

const std::size_t N = 100000, D = 128, K = 10;

struct Data {
  std::vector<float> db, q;                // N × D and Q × D, row-major
  std::vector<std::vector<float>> rows;    // the same database, one heap block per row
};

// Rung 1. The textbook version: a vector of vectors, one running sum, every distance stored,
// the whole list sorted, the first k kept.
void naive(const Data& d, std::size_t Q, std::int64_t* ids, double* sel_s) {
  const auto& rows = d.rows;
  double sel = 0;
  for (std::size_t q = 0; q < Q; ++q) {
    std::vector<float> query(d.q.begin() + q * D, d.q.begin() + (q + 1) * D);
    std::vector<std::pair<float, std::int64_t>> all;
    for (std::size_t n = 0; n < N; ++n) {
      float s = 0;
      for (std::size_t j = 0; j < D; ++j) s += (query[j] - rows[n][j]) * (query[j] - rows[n][j]);
      all.push_back({s, std::int64_t(n)});
    }
    double t0 = bench::now();
    std::sort(all.begin(), all.end());
    sel += bench::now() - t0;
    for (std::size_t i = 0; i < K; ++i) ids[q * K + i] = all[i].second;
  }
  *sel_s = sel;
}

// Rung 2. One contiguous block of memory for the database; distances into a reused buffer.
void flat(const Data& d, std::size_t Q, std::int64_t* ids, double* sel_s) {
  std::vector<std::pair<float, std::int64_t>> all(N);
  double sel = 0;
  for (std::size_t q = 0; q < Q; ++q) {
    const float* qq = &d.q[q * D];
    for (std::size_t n = 0; n < N; ++n) {
      const float* x = &d.db[n * D];
      float s = 0;
      for (std::size_t j = 0; j < D; ++j) s += (qq[j] - x[j]) * (qq[j] - x[j]);
      all[n] = {s, std::int64_t(n)};
    }
    double t0 = bench::now();
    std::sort(all.begin(), all.end());
    sel += bench::now() - t0;
    for (std::size_t i = 0; i < K; ++i) ids[q * K + i] = all[i].second;
  }
  *sel_s = sel;
}

// Rung 3. Keep only the k best while scanning (a heap), instead of sorting all N.
void heap(const Data& d, std::size_t Q, std::int64_t* ids, double*) {
  topk best(K);
  std::vector<float> dist(K);
  for (std::size_t q = 0; q < Q; ++q) {
    const float* qq = &d.q[q * D];
    for (std::size_t n = 0; n < N; ++n) {
      const float* x = &d.db[n * D];
      float s = 0;
      for (std::size_t j = 0; j < D; ++j) s += (qq[j] - x[j]) * (qq[j] - x[j]);
      best.push(s, std::int64_t(n));
    }
    best.write(ids + q * K, dist.data());
  }
}

// Rung 4. Eight partial sums per distance: the loop can use SIMD.
void lanes(const Data& d, std::size_t Q, std::int64_t* ids, double*) {
  topk best(K);
  std::vector<float> dist(K);
  for (std::size_t q = 0; q < Q; ++q) {
    for (std::size_t n = 0; n < N; ++n) best.push(distance<L2>(&d.q[q * D], &d.db[n * D], D), std::int64_t(n));
    best.write(ids + q * K, dist.data());
  }
}

// Rungs 5–8 use the library's own search, with its features switched on one by one.
template <std::size_t DIM>
void tile4(const Data& d, std::size_t Q, std::int64_t* ids, double*) {
  std::vector<topk> best(Q, topk(K));
  auto qv = make_view<const float>(d.q.data(), Q, D), dv = make_view<const float>(d.db.data(), N, D);
  detail::scan<L2, DIM, float>(qv, dv, 0, Q, 0, best);
  std::vector<float> dist(K);
  for (std::size_t q = 0; q < Q; ++q) best[q].write(ids + q * K, dist.data());
}
void library(const Data& d, std::size_t Q, std::int64_t* ids, int threads) {
  std::vector<float> dist(Q * K);
  search_cpu<L2>(make_view<const float>(d.q.data(), Q, D), make_view<const float>(d.db.data(), N, D),
                 make_view(ids, Q, K), make_view(dist.data(), Q, K), threads, 512, true);
}

int main() {
  const std::size_t QMAX = 256;
  Data d;
  d.db.resize(N * D);
  d.q.resize(QMAX * D);
  std::mt19937 rng(11);
  std::normal_distribution<float> nd;
  for (auto& v : d.db) v = nd(rng);
  for (auto& v : d.q) v = nd(rng);
  d.rows.assign(N, std::vector<float>(D));
  for (std::size_t n = 0; n < N; ++n) std::copy_n(&d.db[n * D], D, d.rows[n].begin());

  const int hw = int(std::thread::hardware_concurrency());
  std::vector<std::int64_t> ref(QMAX * K);
  library(d, QMAX, ref.data(), hw);

  std::string rows = "[";
  auto rung = [&](const char* name, std::size_t Q, auto&& f) {
    std::vector<std::int64_t> ids(Q * K);
    double sel = 0;
    double t = bench::best_of(Q <= 16 ? 1 : 2, [&] { f(Q, ids.data(), &sel); });
    std::size_t match = 0;
    for (std::size_t q = 0; q < Q; ++q)
      for (std::size_t i = 0; i < K; ++i)
        match += std::count(&ref[q * K], &ref[q * K] + K, ids[q * K + i]);
    bench::json j;
    j.str("rung", name).num("queries", double(Q)).num("ms_per_query", t / Q * 1e3)
        .num("selection_ms_per_query", sel / Q * 1e3).num("recall_vs_final", double(match) / double(Q * K));
    rows += (rows.size() > 1 ? "," : "") + j.done();
  };
  rung("naive", 16, [&](std::size_t Q, std::int64_t* ids, double* s) { naive(d, Q, ids, s); });
  rung("contiguous", 32, [&](std::size_t Q, std::int64_t* ids, double* s) { flat(d, Q, ids, s); });
  rung("heap_topk", 64, [&](std::size_t Q, std::int64_t* ids, double* s) { heap(d, Q, ids, s); });
  rung("simd_lanes", 256, [&](std::size_t Q, std::int64_t* ids, double* s) { lanes(d, Q, ids, s); });
  rung("four_queries_per_row", 256, [&](std::size_t Q, std::int64_t* ids, double* s) { tile4<0>(d, Q, ids, s); });
  rung("dimension_at_compile_time", 256, [&](std::size_t Q, std::int64_t* ids, double* s) { tile4<D>(d, Q, ids, s); });
  rung("database_in_cache_blocks", 256, [&](std::size_t Q, std::int64_t* ids, double*) { library(d, Q, ids, 1); });
  rung("all_cores", 256, [&](std::size_t Q, std::int64_t* ids, double*) { library(d, Q, ids, hw); });

  bench::json out;
  out.num("n", double(N)).num("dim", double(D)).num("k", double(K)).num("threads", hw).raw("rungs", rows + "]");
  std::puts(out.done().c_str());
}
Writing bench/ladder.cpp
with experiment("ladder"):
    build("bench/ladder.cpp", "build/ladder")
    RESULTS["ladder"] = lad = run_json("build/ladder")
    df = pd.DataFrame(lad["rungs"])
    df["speedup_vs_naive"] = df.ms_per_query.iloc[0] / df.ms_per_query
    display(df.round(3))
rung queries ms_per_query selection_ms_per_query recall_vs_final speedup_vs_naive
0 naive 16 25.933 10.078 1 1.000
1 contiguous 32 24.298 10.518 1 1.067
2 heap_topk 64 13.677 0.000 1 1.896
3 simd_lanes 256 4.522 0.000 1 5.735
4 four_queries_per_row 256 2.695 0.000 1 9.621
5 dimension_at_compile_time 256 2.672 0.000 1 9.705
6 database_in_cache_blocks 256 2.517 0.000 1 10.304
7 all_cores 256 1.036 0.000 1 25.029
[ladder: 16.0 s]

Where NumPy’s time goes on the same search. The broadcast version, step by step for one query: the subtraction allocates and fills a 51 MB array, squaring allocates another, then a sum and a partial sort. The same steps into a preallocated buffer. Then the matrix-multiplication version for a batch, step by step.

with experiment("numpy_steps"):
    q = QUERIES[:1]
    buf = np.empty_like(DB)
    steps = {}
    steps["broadcast: DB - q (new 51 MB array)"] = best_time(lambda: DB - q)
    diff = DB - q
    steps["broadcast: ** 2 (another new array)"] = best_time(lambda: diff ** 2)
    sq = diff ** 2
    steps["broadcast: .sum(1)"] = best_time(lambda: sq.sum(1))
    d = sq.sum(1)
    steps["argpartition + sort 10"] = best_time(lambda: (lambda i: i[np.argsort(d[i])])(np.argpartition(d, K)[:K]))
    steps["in place: subtract into buffer"] = best_time(lambda: np.subtract(DB, q, out=buf))
    steps["in place: subtract + square in buffer"] = best_time(
        lambda: np.square(np.subtract(DB, q, out=buf), out=buf))
    qs = QUERIES[:256]
    steps["gemm, 256 queries: qs @ DB.T"] = best_time(lambda: qs @ DB.T, reps=3)
    dots = qs @ DB.T
    steps["gemm, 256 queries: add norms"] = best_time(lambda: (qs * qs).sum(1)[:, None] + DB_NORMS[None] - 2.0 * dots, reps=3)
    dd = (qs * qs).sum(1)[:, None] + DB_NORMS[None] - 2.0 * dots
    steps["gemm, 256 queries: argpartition rows"] = best_time(lambda: np.argpartition(dd, K, axis=1)[:, :K], reps=3)
    RESULTS["numpy_steps"] = {k: v * 1e3 for k, v in steps.items()}
    display(pd.Series(RESULTS["numpy_steps"], name="ms").round(3).to_frame())
ms
broadcast: DB - q (new 51 MB array) 17.447
broadcast: ** 2 (another new array) 14.210
broadcast: .sum(1) 5.921
argpartition + sort 10 0.208
in place: subtract into buffer 12.171
in place: subtract + square in buffer 15.994
gemm, 256 queries: qs @ DB.T 54.539
gemm, 256 queries: add norms 78.191
gemm, 256 queries: argpartition rows 121.764
[numpy_steps: 1.7 s]

Part 15 — One call, end to end

INDEX.search(queries, k=10) timed from Python, with the library’s own clock at every stage inside it: the binding turning arrays into views, the queries copied into pinned memory, queued, copied to the card, distances, top-k, results copied back and out. The difference between Python’s total and the C++ total is the Python side of the call.

with experiment("journey"):
    if not HAVE_CUDA:
        raise RuntimeError("no CUDA")
    rows = []
    for nq in [1, 16, 256, 1000]:
        q = QUERIES[:nq]
        runs = []
        for _ in range(21):
            t0 = time.perf_counter()
            _, _, tr = INDEX.search(q, K, "gemm", trace=True)
            tr["python_total"] = (time.perf_counter() - t0) * 1e3
            runs.append(tr)
        tr = sorted(runs, key=lambda d: d["python_total"])[len(runs) // 2]
        tr["python_side"] = tr["python_total"] - tr["cpp_total"]
        tr["bind_out"] = tr["cpp_total"] - tr["bind_in"] - tr["library_total"]
        tr["queries"] = nq
        rows.append(tr)
    t_cpu1 = best_time(lambda: tk.search(QUERIES[:1], DB, K, threads=os.cpu_count()), reps=11)
    RESULTS["journey"] = {"gpu": rows, "cpu_one_query_ms": t_cpu1 * 1e3}
    cols = ["queries", "python_side", "bind_in", "stage_in", "queue", "h2d", "distances", "topk", "d2h",
            "stage_out", "bind_out", "python_total"]
    display(pd.DataFrame(rows)[cols].round(3))
queries python_side bind_in stage_in queue h2d distances topk d2h stage_out bind_out python_total
0 1 0.006 0.005 0.000 0.032 0.006 0.354 0.104 0.009 0.000 0.000 0.503
1 16 0.007 0.001 0.000 0.031 0.007 0.583 0.241 0.009 0.000 0.001 0.867
2 256 0.010 0.001 0.013 0.043 0.021 2.827 1.663 0.014 0.006 0.001 4.575
3 1000 0.035 0.001 0.073 0.219 0.086 7.563 4.580 0.047 0.025 0.003 12.501
[journey: 0.5 s]

Summary

with experiment("summary"):
    if "one_search" in RESULTS:
        df = pd.DataFrame(RESULTS["one_search"])
        print(df.pivot(index="impl", columns="queries", values="ms_per_query").round(3))
    if "ladder" in RESULTS:
        r = RESULTS["ladder"]["rungs"]
        print(f"\nCPU ladder: {r[0]['ms_per_query']:.2f} → {r[-1]['ms_per_query']:.3f} ms per query "
              f"({r[0]['ms_per_query'] / r[-1]['ms_per_query']:.0f}×)")
    print("\nerrors:", list(RESULTS["errors"]) or "none")
queries                     1       16     1000
impl                                           
NumPy, broadcast          21.678  21.974    NaN
NumPy, matrix multiply     2.425     NaN  0.944
pure Python             1206.564     NaN    NaN
tinyknn CPU, 1 thread      4.709     NaN  2.393
tinyknn CPU, 4 threads     4.837     NaN  0.886
tinyknn GPU                0.490     NaN  0.018

CPU ladder: 25.93 → 1.036 ms per query (25×)

errors: none
[summary: 0.0 s]
RESULTS["meta"]["executed"] = datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")
RESULTS["meta"]["kaggle"] = bool(os.environ.get("KAGGLE_KERNEL_RUN_TYPE"))
out_dir = "/kaggle/working" if os.path.isdir("/kaggle/working") else "."
with open(os.path.join(out_dir, "cpp_results.json"), "w") as f:
    json.dump(RESULTS, f, default=jsonable, indent=1)
print("saved", os.path.join(out_dir, "cpp_results.json"))
saved /kaggle/working/cpp_results.json
Read the parent note