Skip to content

Commit 23643b0

Browse files
test(thread-pool): document and guard constructor behavior
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 8935730 commit 23643b0

4 files changed

Lines changed: 346 additions & 1 deletion

File tree

THREAD_POOL_BEHAVIOR.md

Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
# ThreadPool Constructor and Configuration Behavior
2+
3+
## Why this changed
4+
5+
`ThreadPool` previously exposed both of these constructors:
6+
7+
```cpp
8+
ThreadPool(uint32_t size, bool binding);
9+
ThreadPool(bool binding);
10+
```
11+
12+
Calls such as `ThreadPool(1)` selected the exact `bool` overload instead of
13+
converting `1` to `uint32_t`. As a result, a caller intending to create one
14+
thread actually created `hardware_concurrency()` threads with CPU binding
15+
enabled. Any positive one-argument thread count had the same problem.
16+
17+
The ambiguous overload was removed. Thread count and CPU binding must now be
18+
specified together:
19+
20+
```cpp
21+
ThreadPool(uint32_t size, bool binding);
22+
```
23+
24+
## Direct C++ API
25+
26+
| Construction | Thread count | CPU binding |
27+
|---|---:|---|
28+
| `ThreadPool()` | `max(std::thread::hardware_concurrency(), 1)` | Disabled |
29+
| `ThreadPool(n, false)` | `n` | Disabled |
30+
| `ThreadPool(n, true)` | `n` | Enabled |
31+
32+
The default constructor has not changed: it uses hardware concurrency, never
33+
zero, with binding disabled.
34+
35+
The following one-argument forms are no longer supported:
36+
37+
```cpp
38+
ThreadPool(n);
39+
ThreadPool(true);
40+
ThreadPool(false);
41+
```
42+
43+
Use explicit replacements:
44+
45+
```cpp
46+
// Old: ThreadPool(false)
47+
ThreadPool();
48+
49+
// Old: ThreadPool(true)
50+
ThreadPool(std::max(std::thread::hardware_concurrency(), 1u), true);
51+
52+
// Intended meaning of old ThreadPool(n)
53+
ThreadPool(n, false); // or true when binding is required
54+
```
55+
56+
This is a C++ source compatibility change. It intentionally turns ambiguous
57+
calls into compile errors instead of silently choosing the wrong behavior.
58+
59+
CPU binding is implemented on Linux excluding Android. On unsupported
60+
platforms, the binding flag does not change thread affinity.
61+
62+
## DB global query and optimize pools
63+
64+
The DB pools do not use the direct `ThreadPool()` default. Their counts are
65+
initialized independently from `CgroupUtil::getCpuLimit()`:
66+
67+
| Configuration | Default count | Default binding |
68+
|---|---:|---|
69+
| Query pool | Cgroup CPU limit | Disabled |
70+
| Optimize pool | Cgroup CPU limit | Disabled |
71+
72+
Therefore:
73+
74+
- Omitting a DB thread count does **not** mean one thread.
75+
- On an unrestricted host, the cgroup CPU limit will commonly equal available
76+
CPU capacity.
77+
- In a CPU-limited container, the DB defaults may differ from
78+
`std::thread::hardware_concurrency()`.
79+
- Query and optimize counts are independent. Setting `query_threads` does not
80+
implicitly change `optimize_threads`.
81+
82+
Before this fix, the DB passed its configured count as a single constructor
83+
argument. Every valid positive count was converted to `true`, so the actual
84+
runtime behavior was hardware-concurrency threads with binding enabled. This
85+
meant configured values such as `optimize_threads=1` were ignored.
86+
87+
After this fix, the configured count and binding are both forwarded
88+
explicitly, so the effective behavior matches the configuration.
89+
90+
## Python API
91+
92+
`zvec.init()` exposes the four independent options:
93+
94+
```python
95+
zvec.init(
96+
query_threads=None,
97+
query_thread_binding=None,
98+
optimize_threads=None,
99+
optimize_thread_binding=None,
100+
)
101+
```
102+
103+
Behavior:
104+
105+
| Input | Effective behavior |
106+
|---|---|
107+
| Count is `None` | Use the corresponding cgroup-derived DB default |
108+
| Count is a positive integer | Use exactly that many threads |
109+
| Binding is `None` | Use the DB default (`False`) |
110+
| Binding is `False` | Do not bind worker threads |
111+
| Binding is `True` | Bind worker threads where supported |
112+
113+
Examples:
114+
115+
```python
116+
# Both pools use their cgroup-derived counts without binding.
117+
zvec.init()
118+
119+
# Optimize uses exactly one unbound worker.
120+
zvec.init(optimize_threads=1)
121+
122+
# Query uses four bound workers; optimize keeps its independent defaults.
123+
zvec.init(query_threads=4, query_thread_binding=True)
124+
```
125+
126+
## C API
127+
128+
The C configuration API now includes independent setters and getters:
129+
130+
```c
131+
zvec_config_data_set_query_thread_binding(config, binding);
132+
zvec_config_data_get_query_thread_binding(config);
133+
zvec_config_data_set_optimize_thread_binding(config, binding);
134+
zvec_config_data_get_optimize_thread_binding(config);
135+
```
136+
137+
If these setters are not called, binding remains disabled.
138+
139+
## Internal call-site migration
140+
141+
Internal call sites were migrated according to their previous intended
142+
behavior:
143+
144+
- Benchmark tools with no requested count still use hardware concurrency
145+
without binding.
146+
- Recall tools with no requested count still use hardware concurrency with
147+
binding.
148+
- Explicit benchmark and recall thread counts are now honored.
149+
- `Index::Merge` uses `write_concurrency` without binding.
150+
- DB query and optimize pools use their configured count and binding.
151+
- Existing call sites already passing both count and binding are unchanged.
152+
153+
The recall resize path previously used the arguments in reverse order:
154+
155+
```cpp
156+
ThreadPool(true, threads);
157+
```
158+
159+
That constructed one bound worker. It now correctly uses:
160+
161+
```cpp
162+
ThreadPool(threads, true);
163+
```
164+
165+
## Performance validation
166+
167+
The fix was validated with an end-to-end Python DB API benchmark using the
168+
SIFT dataset:
169+
170+
| Parameter | Value |
171+
|---|---|
172+
| Dataset | SIFT, 1,000,000 FP32 vectors, 128 dimensions |
173+
| Index | Vamana |
174+
| Search list size | 500 |
175+
| Alpha | 1.5 |
176+
| Quantizer | Uniform INT8 |
177+
| Optimize threads | 1 per process |
178+
| Build flow | `create_and_open -> insert -> optimize` |
179+
180+
Two codebases were compared on the same machine with separately built wheels
181+
and virtual environments:
182+
183+
- Before the fix: commit `a90ec5b1d658d`, wheel `0.5.2.dev30`.
184+
- After the fix: the ThreadPool fix and migrated call sites, wheel
185+
`0.5.2.dev32`.
186+
187+
### Observed runtime behavior
188+
189+
The test machine exposes 64 logical CPUs. The benchmark called:
190+
191+
```python
192+
zvec.init(optimize_threads=1)
193+
```
194+
195+
It did not override the query thread count or either binding option.
196+
197+
| Pool / behavior | Before the fix | After the fix |
198+
|---|---|---|
199+
| Query pool | 64 workers, bound to CPUs 0-63 | 64 workers, unbound |
200+
| Optimize pool | 64 workers, bound to CPUs 0-63 | 1 worker, unbound |
201+
| Total pool workers | 128 | 65 |
202+
| Observed process threads | Approximately 214 | Approximately 150 |
203+
| Workers doing Vamana build work | Effectively about 1 per process | 1 per process |
204+
| CPU used by each builder in the four-process phase | Approximately 25% of one CPU before competing builders completed | Approximately 92-100% of one CPU |
205+
206+
The total process thread count is higher than the two global pools because it
207+
also includes Python, storage-engine, I/O, and other background threads. Those
208+
additional threads were present in both versions.
209+
210+
Before the fix, both positive configuration values were passed through the
211+
one-argument constructor:
212+
213+
```cpp
214+
ThreadPool(configured_count);
215+
```
216+
217+
Both `64` and `1` converted to the Boolean value `true`. Consequently, both
218+
pools created 64 workers and enabled binding. `optimize_threads=1` therefore
219+
did not create one optimize worker.
220+
221+
The Vamana build remained effectively single-threaded in this test: despite
222+
the 64 optimize workers, only about one worker per process was doing sustained
223+
CPU work. The other optimize workers and the entire query pool were mostly
224+
idle. This explains why the single-process result did not become faster in the
225+
old version.
226+
227+
The excessive idle workers add thread stacks, scheduler bookkeeping, and some
228+
context-switch overhead, but they are not sufficient to explain the observed
229+
four-times slowdown. The dominant problem was CPU affinity:
230+
231+
1. Every process created the same 64-worker optimize pool.
232+
2. Worker `i` in every process was bound to CPU `i`.
233+
3. The effectively active worker selected by each identical pool tended to
234+
have the same worker index.
235+
4. Active build work from multiple processes therefore competed on the same
236+
CPU instead of being spread across the 64 available CPUs.
237+
238+
This behavior matches the timing curve. The degree-24 build, which overlapped
239+
with all four processes for almost its entire lifetime, took 4.28 times its
240+
single-process time before the fix. As shorter jobs completed, the remaining
241+
processes received a larger share of the contended CPU, so the longest
242+
degree-64 job showed a smaller but still substantial 2.45-times slowdown.
243+
244+
After the fix, each process created one unbound optimize worker. The operating
245+
system could schedule the four runnable workers on different CPUs, and live
246+
sampling showed each builder consuming close to one full CPU. The query pool
247+
still contributed 64 mostly idle workers because the benchmark did not set
248+
`query_threads`; this accounts for much of the approximately 150-thread process
249+
total, but did not prevent scaling because those threads were unbound and idle.
250+
251+
### Single-process results
252+
253+
| Degree | Before | After | Change |
254+
|---:|---:|---:|---:|
255+
| 24 | 790.2 s | 777.8 s | -1.6% |
256+
| 64 | 2865.3 s | 2847.0 s | -0.6% |
257+
258+
Single-process performance was effectively unchanged. This indicates that the
259+
fix does not alter Vamana's underlying build cost or introduce a measurable
260+
single-process regression.
261+
262+
### Four-process results
263+
264+
Four independent processes simultaneously built indexes with degrees 24, 32,
265+
48, and 64. Every process requested `optimize_threads=1`.
266+
267+
| Degree | Before | After | Speedup | Time reduction |
268+
|---:|---:|---:|---:|---:|
269+
| 24 | 3378.6 s | 841.6 s | 4.01x | 75.1% |
270+
| 32 | 4282.3 s | 1137.5 s | 3.76x | 73.4% |
271+
| 48 | 5955.2 s | 1946.4 s | 3.06x | 67.3% |
272+
| 64 | 7009.0 s | 2975.1 s | 2.36x | 57.6% |
273+
274+
The four-process group wall-clock time was determined by the degree-64 build:
275+
276+
| Metric | Before | After | Improvement |
277+
|---|---:|---:|---:|
278+
| Group wall time | 7009.2 s (116m 49s) | 2975.4 s (49m 35s) | 57.5% lower |
279+
| Equivalent speedup | — | — | 2.36x |
280+
281+
### Parallel overhead relative to a single process
282+
283+
| Degree | Before fix | After fix |
284+
|---:|---:|---:|
285+
| 24 | +327.6% | +8.2% |
286+
| 64 | +144.6% | +4.5% |
287+
288+
Before the fix, the degree-24 build took 4.28 times as long when sharing the
289+
machine with three other builders. After the fix it took only 1.08 times as
290+
long. The degree-64 ratio similarly improved from 2.45 times to 1.05 times.
291+
292+
All indexes produced before and after the fix reached an index completeness of
293+
1.0. The results confirm that the change specifically removes multi-process
294+
thread contention while preserving single-process performance and index
295+
correctness.
296+
297+
## Compatibility summary
298+
299+
Behavior is unchanged for:
300+
301+
- `ThreadPool()`;
302+
- `ThreadPool(n, false)`;
303+
- `ThreadPool(n, true)`;
304+
- internal call sites whose old behavior was already explicit and correct.
305+
306+
Behavior intentionally changes for:
307+
308+
- one-argument thread counts that were previously interpreted as booleans;
309+
- DB thread-count configuration that was previously ignored;
310+
- `Index::Merge` and recall resize paths affected by ambiguous or reversed
311+
arguments;
312+
- DB binding defaults, which now explicitly default to disabled instead of
313+
being accidentally enabled by overload resolution.

python/zvec/zvec.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,8 @@ def init(
8686
Zvec's internal setting, currently ``False``.
8787
optimize_threads (Optional[int], optional):
8888
Threads for background tasks (e.g., compaction, indexing).
89-
If ``None``, defaults to same as ``query_threads`` or CPU count.
89+
If ``None`` (default), inferred independently from available CPU
90+
cores (via cgroup).
9091
optimize_thread_binding (Optional[bool], optional):
9192
Whether to bind optimize worker threads to CPU cores. Defaults to
9293
Zvec's internal setting, currently ``False``.

tests/ailego/parallel/thread_pool_test.cc

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,33 @@
1515
#include <chrono>
1616
#include <iostream>
1717
#include <memory>
18+
#include <type_traits>
1819
#include <gtest/gtest.h>
1920
#include <zvec/ailego/parallel/thread_pool.h>
2021

2122
using namespace zvec::ailego;
2223

24+
static_assert(!std::is_constructible<ThreadPool, uint32_t>::value,
25+
"ThreadPool thread count must not be ambiguous with binding");
26+
static_assert(!std::is_constructible<ThreadPool, bool>::value,
27+
"ThreadPool binding must require an explicit thread count");
28+
static_assert(std::is_constructible<ThreadPool, uint32_t, bool>::value,
29+
"ThreadPool must accept an explicit count and binding");
30+
31+
TEST(ThreadPool, ConstructorBehavior) {
32+
const auto hardware_concurrency =
33+
std::max(std::thread::hardware_concurrency(), 1u);
34+
35+
ThreadPool default_pool;
36+
EXPECT_EQ(hardware_concurrency, default_pool.count());
37+
38+
ThreadPool single_unbound_pool(1, false);
39+
EXPECT_EQ(1u, single_unbound_pool.count());
40+
41+
ThreadPool double_bound_pool(2, true);
42+
EXPECT_EQ(2u, double_bound_pool.count());
43+
}
44+
2345
struct A {
2446
A(void) : pool(std::make_shared<ThreadPool>()) {}
2547

tests/db/common/config_test.cc

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,15 @@ class ConfigTest : public ::testing::Test {
2929
}
3030
};
3131

32+
TEST_F(ConfigTest, ThreadConfigDataDefaults) {
33+
GlobalConfig::ConfigData config;
34+
35+
ASSERT_GT(config.query_thread_count, 0u);
36+
ASSERT_EQ(config.query_thread_count, config.optimize_thread_count);
37+
ASSERT_FALSE(config.query_thread_binding);
38+
ASSERT_FALSE(config.optimize_thread_binding);
39+
}
40+
3241
TEST_F(ConfigTest, InitializeWithDefaultConfig) {
3342
GlobalConfig::ConfigData config;
3443

0 commit comments

Comments
 (0)