|
| 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. |
0 commit comments