Skip to content

Hardening Allocations & Concurrency - #764

Merged
ashvardanian merged 18 commits into
mainfrom
main-dev
May 24, 2026
Merged

Hardening Allocations & Concurrency#764
ashvardanian merged 18 commits into
mainfrom
main-dev

Conversation

@ashvardanian

Copy link
Copy Markdown
Member

No description provided.

desertfury and others added 18 commits May 23, 2026 18:49
A freshly-`make`d dense index inherited `index_gt`'s `{0, 0}` thread
limits. The wrapper paths `load_from_stream` and `view` then sized
`available_threads_` and `cast_buffer_` from those zero limits, so the
first `search` threw "No available threads to lock".

Reserve `index_limits_t{}` inside `make` itself so the returned index is
immediately usable. The new fourth parameter accepts an explicit
`index_limits_t`; pass `{unreserved}` (a `std::defer_lock`-style tag) to
skip the upfront allocation when the next call will overwrite everything
anyway. Floor `std::thread::hardware_concurrency()` at 1 inside
`index_limits_t`'s default ctor so hosts where the runtime returns 0 don't
land in the same trap. `load_from_stream` and `view` use the new
`index_limits_t::with_thread_defaults()` helper to fill in zero thread
counts inherited from a prior `unreserved` index.

Drop the now-redundant post-`make` reserves in the Rust and C bindings
and the `hc==0` fallback in the Java binding - they are centralized.


Co-authored-by: Mikhail Chichvarin <6496186+desertfury@users.noreply.github.com>
Co-authored-by: Ash Vardanian <1983160+ashvardanian@users.noreply.github.com>
Co-authored-by: Mikhail Chichvarin <desertfury@nebius.com>
`index_dense_gt::change_metric` reassigned `metric_` in place but left
`cast_buffer_` sized by the previous metric's `bytes_per_vector()`. A
follow-up `add` or `search` then wrote
`metric_.bytes_per_vector() * thread_id` past the start of the buffer,
overrunning the slot belonging to the next thread - silent corruption,
ASan-detectable.

Add `try_change_metric` as the fallible variant, paired with a throwing
`change_metric` - mirroring `try_reserve` / `reserve`. The new helper
re-allocates `cast_buffer_` if the new metric needs more bytes per
vector and rebuilds the cast dispatch table when the scalar kind also
changes.

Wire the new contract through every binding that exposes the API:
  * C ABI: `usearch_change_metric` and `usearch_change_metric_kind`
    route through `try_change_metric` and populate `*error` on OOM
    instead of letting a C++ exception escape the boundary.
  * Rust: cxx bridge declarations gain `-> Result<()>` so a thrown
    `std::runtime_error` is caught and surfaced as `cxx::Exception`.
    `Index::change_metric_kind` and the `VectorType::change_metric`
    trait impls propagate the result.
  * Python: pybind11 already translates `std::runtime_error` to a
    Python `RuntimeError`, no wrapper change needed.
`flat_hash_multi_set_gt::equal_iterator_gt::operator++` could spin forever
when every slot in the hash table was either live or tombstoned. The
`do { ... } while (true)` loop only exited on an empty slot or a match,
and tombstones accumulate monotonically without compaction - so a
long-running workload that adds and removes through the table eventually
loses every empty slot, and the next `remove()` call would peg a CPU
core indefinitely.

Replace the unbounded loop with a `for` bounded by `capacity_slots_`.
After a saturated probe the iterator becomes `end()` and the surrounding
`std::distance` / `for` over `equal_range` terminates normally. No new
branches; the probe upper bound moves from an unreachable `while (true)`
into the `for` header.

Closes #753
Relates to #726
`usearch_change_threads_add` and `usearch_change_threads_search` called
`try_reserve(limits)` and discarded its boolean return. On allocation
failure they returned silently, leaving the caller convinced the change
had succeeded while the underlying thread pool was unchanged or - worse -
half-resized. Subsequent `add`/`search` calls would then either run on a
stale pool or fail with a misleading downstream error.

Populate `*error` when `try_reserve` returns false. Behavior of the
success path is unchanged.
`usearch_metadata` and `usearch_metadata_buffer` wrote to `*error` on a
failed read of the index header, then continued executing - reading
`result.head` fields whose contents are unspecified after the failure
and overwriting the caller's `options` struct with whatever happened to
be there. Callers that conscientiously checked `*error` afterwards would
still see corrupted `options`.

Return from the failure branch instead of falling through. No other
changes to the success path.
On `index_dense_t::make` failure, `usearch_init` set `*error` and then
continued to `new index_dense_t(std::move(state.index))`, returning a
pointer to a default-constructed (non-functional) index. Callers who
checked `*error` could still receive a non-NULL pointer and invoke
subsequent C-ABI calls on it - all undefined behavior because the
underlying `typed_` pointer is null and no metric is set.

Return `NULL` inside the existing failure branch and switch the heap
allocation to `new (std::nothrow)` so an out-of-memory condition there
also surfaces through `*error` instead of throwing across the C-ABI
boundary. No public signature changes.
`Napi::Error::...ThrowAsJavaScriptException()` only schedules the JS-level
exception - C++ control flow continues. The previous `Remove`
implementation kept iterating after a failed `native_->remove(...)`,
re-reading `result.error` after its inner buffer had already been
`.release()`d and writing past-end values into `results[i]`. The next
turn of the JavaScript event loop would then see both the scheduled
exception and a return value, with undefined behavior in between.

Return `env.Null()` from the failure branch so the loop stops at the
first error, leaving the exception as the sole observable outcome. The
`Count` and `Contains` siblings don't need the same treatment - their
inner calls return primitives rather than a result struct with an error.
`ring_gt::try_push` was written as `return push(value); return true;` -
ill-formed C++ because the inner `push` returns `void`. It compiled only
because `try_push` happens to be uninstantiated in the current build;
the first downstream user of the method would trip on a compile error.

Drop the unreachable `return true;`, call `push(value)` as a statement,
and return `true` after it. Matches the standard "try X / X-or-throw"
pair style used elsewhere in the file.
Python's Global Interpreter Lock was being held across every multi-threaded
C++ entry point - bulk `add`, `search`, brute-force `exact_search`,
`cluster_many_brute_force`, `cluster`, `join`, `compact`, `remove(compact=True)`,
and `merge_paths` - so other Python threads couldn't make progress while
the executor was running its worker pool. Throughput-bound workloads that
interleave a USearch call with any other Python-driven I/O or compute
saw no parallelism at all.

Make `progress_t::operator()` self-acquire the GIL with `gil_scoped_acquire`
(reference-counted, no-op when the GIL is already held) so it stays safe
to invoke from any thread; release the GIL with `gil_scoped_release`
around each long-running C++ call; and acquire it briefly inside worker
lambdas only around `PyErr_CheckSignals()`. The `progress_func_t` typedef
is `std::function`, not `py::function`, so its destructor on GIL-released
threads is benign.
After the previous commit released the GIL around long C++ operations,
the contract is: another Python library running on the same or a
different Python thread can do useful work concurrently, while the
`Index` API itself is still single-threaded from Python's perspective.

Add five focused tests that assert this contract end-to-end. A
background Python thread increments a counter in a tight loop while the
main thread runs a long `add`/`search`; the counter must advance by at
least a conservative floor during the call, otherwise the GIL was held.
Progress callbacks fire from a C++ worker that must reacquire the GIL
to invoke the Python callable, mutate a list, and return a bool;
returning `False` must terminate the operation cleanly as a Python
`RuntimeError` rather than UB.

Tested locally against Python 3.10, 3.13, and free-threaded 3.14t
(`Py_GIL_DISABLED=1`); all five pass on each.
The documented contract is one Python thread per `Index` at a time -
the native `index_dense_t` carries per-worker `cast_buffer_` slots
indexed by an executor-local `thread_idx` the binding picks for each
call, so two Python threads spawning their own executors collide on
those slots. After releasing the GIL around long C++ ops in the
previous commit, accidental concurrent access from two Python threads
went from "silently incorrect" to "segfault". Defensive enforcement is
the right behavior.

Add a `unique_ptr<std::mutex>` to `dense_index_py_t` and
`dense_indexes_py_t` (held via `unique_ptr` so the wrapper stays
move-constructible for pybind11's factories). Each binding entry point
that releases the GIL now acquires the per-index mutex first; the
order is GIL-release-then-lock so a Python thread waiting on the mutex
does not hold the GIL - otherwise the current owner's worker thread
would block forever in `gil_scoped_acquire` when invoking the progress
callback. Hoist `try_reserve` into the locked region in every site so
concurrent callers can't race on it either. Heavy ops covered: bulk
add/search, multi-shard search, cluster, join, compact, isolate from
`remove(compact=True)`, multi-shard merge.

Extend `test_gil_release.py` with `test_concurrent_access_serializes_safely`
that spawns six Python threads (adders with disjoint key ranges,
searchers, lock-free getters) on a single index and asserts every key
landed, no thread errors, no crash. Validated under both stock CPython
3.14 and free-threaded 3.14t (`Py_GIL_DISABLED=1`); existing 608-test
suite passes unchanged.
`index_dense_gt::clear()` documents that it zeros `size()` while
keeping `capacity()`. The previous implementation called
`vectors_lookup_.reset()`, freeing the per-slot pointer table entirely,
so a subsequent `add()` would dereference a null entry and segfault
before any caller could re-reserve.

Zero the entries in place instead - the pointers they held were stale
the moment `vectors_tape_allocator_.reset()` ran on the next line, but
the buffer itself stays sized so future `add()` calls land in valid
memory.

Co-authored-by: Mikhail Chichvarin <6496186+desertfury@users.noreply.github.com>
Co-authored-by: Ash Vardanian <1983160+ashvardanian@users.noreply.github.com>
Co-authored-by: Mikhail Chichvarin <desertfury@nebius.com>
In a multi-vector index, `rename(from, from)` would keep
removing and re-inserting entries under the same key, so
the lookup loop could continue finding the freshly inserted
slot forever.

Co-authored-by: Mikhail Chichvarin <6496186+desertfury@users.noreply.github.com>
Co-authored-by: Mikhail Chichvarin <desertfury@nebius.com>
Co-authored-by: Ash Vardanian <1983160+ashvardanian@users.noreply.github.com>
Reject missing metrics before replacing the active metric,
and mirror the same validation in the callback-based C entry
point. The existing `try_change_metric` path still handles
cast-buffer growth failures separately.

Co-authored-by: Mikhail Chichvarin <desertfury@nebius.com>
Co-authored-by: Mikhail Chichvarin <6496186+desertfury@users.noreply.github.com>
Co-authored-by: Ash Vardanian <1983160+ashvardanian@users.noreply.github.com>
The integer-quantizing casts normalize input vectors by their
magnitude. Zero or NaN magnitudes produced NaN scaled
values, and casting those values to `int8_t` or `uint8_t` is UB.

Co-authored-by: Mikhail Chichvarin <desertfury@nebius.com>
Co-authored-by: Ash Vardanian <1983160+ashvardanian@users.noreply.github.com>
Co-authored-by: Mikhail Chichvarin <6496186+desertfury@users.noreply.github.com>
`index_dense_gt::try_reserve` reused the keyed lookup capacity
as the member limit after reserving. The lookup reported physical
hash slots, so later thread-only reserves fed that larger number
back into the hash-table growth path and kept expanding the table.

Co-authored-by: Mikhail Chichvarin <desertfury@nebius.com>
Co-authored-by: Ash Vardanian <1983160+ashvardanian@users.noreply.github.com>
Co-authored-by: Mikhail Chichvarin <6496186+desertfury@users.noreply.github.com>
Add checked size arithmetic helpers and wire them into allocation,
reservation, serialization, and dense metadata paths that previously
relied on unchecked size_t math.

Co-authored-by: Mikhail Chichvarin <desertfury@nebius.com>
Co-authored-by: Ash Vardanian <1983160+ashvardanian@users.noreply.github.com>
Co-authored-by: Mikhail Chichvarin <6496186+desertfury@users.noreply.github.com>
After a failed load resets the underlying graph, the dense wrapper
can remain mutable while its per-thread context buffers are empty.
The next operation could then acquire no worker thread and index
into empty per-thread storage.

Co-authored-by: Mikhail Chichvarin <desertfury@nebius.com>
Co-authored-by: Ash Vardanian <1983160+ashvardanian@users.noreply.github.com>
Co-authored-by: Mikhail Chichvarin <6496186+desertfury@users.noreply.github.com>
@ashvardanian
ashvardanian merged commit bca53db into main May 24, 2026
32 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants