Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,21 @@ Wheels target CPython 3.10+ (abi3); to build from source instead, see
[`ordvec-python/`](https://github.com/Fieldnote-Echo/ordvec/tree/main/ordvec-python).
The runtime dependency floor is `numpy>=2.2`.

### Threading / concurrency

`ordvec` supports concurrent read-only/search use. Mutation is exclusive.

Python search, candidate-generation, and scoring methods release the GIL and
read NumPy inputs in place. Callers must not mutate query, corpus, candidate,
or scoring input arrays passed to those methods until the call returns.

The C ABI allows concurrent search and info calls on one loaded handle.
`ordvec_index_free` must not race with any other call on the same handle.

The Go wrapper serializes `Close` against `Search` and `Info`; after `Close`,
`Search` and `Info` return `ErrClosed`. Callers must not mutate query or
candidate slices passed to `Search` until the call returns.

## Documentation

- **Design deep-dive & reproducible benchmark tables:**
Expand Down
12 changes: 8 additions & 4 deletions docs/c-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,10 +167,14 @@ the candidate entry count, including duplicates. `prepare_ns`, `score_ns`,

## Threading

Concurrent searches and info calls on one loaded handle are allowed.
`ordvec_index_free` must not race with any other call on the same handle.
`ordvec_index_free(NULL)` is a no-op. Use after free and double free are
undefined behavior.
Concurrent `ordvec_index_search` and `ordvec_index_info` calls on one loaded
handle are allowed. Search borrows caller-owned query and candidate buffers in
place for the duration of the call; callers must not mutate those buffers until
the call returns.

Mutation is exclusive. `ordvec_index_free` must not race with any other call on
the same handle. `ordvec_index_free(NULL)` is a no-op. Use after free and
double free are undefined behavior.

## V1 Exclusions

Expand Down
9 changes: 9 additions & 0 deletions ordvec-go/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Package ordvec provides a thin cgo wrapper over the ordvec C ABI.
//
// Index values support concurrent Search and Info calls. Close is serialized
// against Search and Info; after Close, both methods return ErrClosed.
//
// Search pins and passes caller-owned query and candidate slices to the C ABI
// without copying them. Callers must not mutate those slices until Search
// returns.
package ordvec
81 changes: 81 additions & 0 deletions ordvec-go/ordvec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ package ordvec
import (
"encoding/binary"
"errors"
"fmt"
"math"
"os"
"path/filepath"
"strings"
"sync"
"testing"
)

Expand Down Expand Up @@ -214,3 +216,82 @@ func TestCloseIsIdempotentAndErrClosed(t *testing.T) {
t.Fatalf("Search after Close should return ErrClosed, got %v", err)
}
}

func TestConcurrentSearchInfoAndClose(t *testing.T) {
idx, err := Load(writeRankQuantFixture(t))
if err != nil {
t.Fatal(err)
}

const workers = 8
const iterations = 64

start := make(chan struct{})
searchReady := make(chan struct{})
infoReady := make(chan struct{})
errCh := make(chan error, workers*iterations)
var searchReadyOnce sync.Once
var infoReadyOnce sync.Once
var wg sync.WaitGroup

run := func(name string, fn func() error, markReady func()) {
defer wg.Done()
defer func() {
if r := recover(); r != nil {
errCh <- fmt.Errorf("%s panic: %v", name, r)
}
}()
<-start
for i := 0; i < iterations; i++ {
err := fn()
if err == nil {
markReady()
}
if err != nil && !errors.Is(err, ErrClosed) {
errCh <- fmt.Errorf("%s returned unexpected error: %w", name, err)
}
}
}

query := query16()
for i := 0; i < workers/2; i++ {
wg.Add(1)
go run(
"Search",
func() error {
_, _, err := idx.Search(query, 2, nil)
return err
},
func() { searchReadyOnce.Do(func() { close(searchReady) }) },
)
wg.Add(1)
go run(
"Info",
func() error {
_, err := idx.Info()
return err
},
func() { infoReadyOnce.Do(func() { close(infoReady) }) },
)
}

close(start)
<-searchReady
<-infoReady
if err := idx.Close(); err != nil {
Comment thread
Fieldnote-Echo marked this conversation as resolved.
t.Errorf("Close returned unexpected error: %v", err)
}
Comment thread
Fieldnote-Echo marked this conversation as resolved.
wg.Wait()
close(errCh)

for err := range errCh {
t.Error(err)
}

if _, err := idx.Info(); !errors.Is(err, ErrClosed) {
t.Fatalf("Info after Close should return ErrClosed, got %v", err)
}
if _, _, err := idx.Search(query16(), 1, nil); !errors.Is(err, ErrClosed) {
t.Fatalf("Search after Close should return ErrClosed, got %v", err)
}
}
17 changes: 10 additions & 7 deletions ordvec-python/python/ordvec/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,16 @@
caller-supplied data, validate or sandbox the path first, exactly as you would
before a bare ``open()``.

Threading: ``search`` / ``search_asymmetric`` / ``add`` and the dense scoring /
candidate generator methods release the GIL during the heavy Rust scan, so other
Python threads run concurrently. The input arrays are *read in place* (not
copied) for that window — do not mutate an array from another thread while a
call that received it is in progress, or the scan races the write and may return
inconsistent results. This is the standard contract for GIL-releasing numeric
extensions (NumPy itself behaves this way).
Threading: the contract is read-concurrent, mutation-exclusive. ``search``,
``search_asymmetric``, ``search_asymmetric_subset``, and the dense scoring /
candidate generator methods release the GIL during the heavy Rust scan, so
other Python threads run concurrently. ``add`` also releases the GIL while
mutating an index, but mutable index operations must be treated as exclusive.
The input arrays are *read in place* (not copied) for that window — do not
mutate an array from another thread while a call that received it is in
progress, including subset candidate arrays, or the scan races the write and
may return inconsistent results. This is the standard contract for
GIL-releasing numeric extensions (NumPy itself behaves this way).
"""

from ._ordvec import (
Expand Down
21 changes: 21 additions & 0 deletions ordvec-python/tests/test_rank_quant.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
"""
from __future__ import annotations

from concurrent.futures import ThreadPoolExecutor

import numpy as np
import pytest

Expand Down Expand Up @@ -113,6 +115,25 @@ def test_search_asymmetric_shape(bits):
assert indices.shape == (3, 10)


def test_search_asymmetric_read_concurrent_results_match_baseline():
corpus = np.ascontiguousarray(unit_vectors(64, 128, seed=101))
queries = np.ascontiguousarray(unit_vectors(5, 128, seed=202))
idx = RankQuant(dim=128, bits=2)
idx.add(corpus)

baseline_scores, baseline_indices = idx.search_asymmetric(queries, k=8)

def run_search():
return idx.search_asymmetric(queries, k=8)

with ThreadPoolExecutor(max_workers=4) as pool:
results = list(pool.map(lambda _: run_search(), range(40)))

for scores, indices in results:
np.testing.assert_array_equal(indices, baseline_indices)
np.testing.assert_allclose(scores, baseline_scores, rtol=0, atol=0)


@pytest.mark.parametrize("bits", [1, 2, 4])
def test_rankquant_eval_search_matches_rankquant_search(bits):
vectors = unit_vectors(45, 128, seed=31 + bits)
Expand Down
Loading