Skip to content

Commit babc8ff

Browse files
kNN optimizations
1 parent 9794779 commit babc8ff

4 files changed

Lines changed: 717 additions & 1 deletion

File tree

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
# build_network Optimization Handoff (resume on x86)
2+
3+
Date: 2026-08-14
4+
Status: implementation complete and validated on macOS/arm64; **x86_64
5+
validation and benchmarking pending**
6+
Repository: `actionet-python`
7+
Branch: `dev-gpu` (both parent and `src/libactionet` submodule)
8+
9+
Parent HEAD at handoff: `9794779`
10+
`libactionet` submodule HEAD at handoff: `880be748c47aad07d81dbe4159ab411f64a7e4de`
11+
12+
The work is currently **uncommitted** in the `src/libactionet` submodule working
13+
tree, plus two new untracked test/benchmark files in the parent repo. Preserve
14+
both. When publishing: commit `libactionet` first, then bump the submodule
15+
pointer and commit the parent test/benchmark files.
16+
17+
## What changed
18+
19+
Two files in the `src/libactionet` submodule (the network construction hot
20+
path used by both `algorithm="k*nn"` and `algorithm="knn"`):
21+
22+
- `src/network/_hnsw_jensen_shannon.hpp` — JSD distance kernel rewrite.
23+
- `src/network/build_network.cpp` — parallel graph finalization, KNN merge fix,
24+
gated phase timers.
25+
26+
New in the parent repo (untracked, both lint-clean):
27+
28+
- `tests/benchmark_build_network.py` — subprocess-isolated old-vs-new benchmark
29+
harness with a `prep` phase (computes and caches `H_stacked`).
30+
- `tests/test_build_network_invariants.py` — 15 correctness/invariant tests.
31+
32+
Nothing in the Python wrapper changed: the `_core.build_network` ABI is
33+
unchanged, so no `wp_network.cpp` / `build.py` edits were needed.
34+
35+
### 1. JSD kernel (`_hnsw_jensen_shannon.hpp`)
36+
37+
- Removed the ~4 MB `fasterlog2` lookup table (`params[LOGLEN+2]`, `LOGLEN =
38+
1e6`). The old kernel did 3 data-dependent gathers into that table plus 3
39+
`floor()` calls and 3 branches per vector element per distance evaluation.
40+
- Now computes `fasterlog2` directly per element via a `memcpy`-based bit
41+
reinterpret (`jsd_fasterlog2`). The `fastapprox` union type-pun blocked
42+
clang's loop vectorizer; `memcpy` does not.
43+
- Branchless `x*log2(x)` (`jsd_xlog2x`) using an `(x>0)?v:0` select, plus a
44+
`#pragma clang loop vectorize(enable)` hint.
45+
- `get_dist_func_param()` now returns `&dim_` instead of a pointer into the LUT.
46+
47+
Numerically this is *slightly more accurate* than the quantized LUT, so it is
48+
**not bit-identical**. Parity is by tolerance (see Validation).
49+
50+
### 2. Finalization + KNN merge (`build_network.cpp`)
51+
52+
- Added `parallel_sort` (OpenMP merge sort: per-thread `std::sort` + pairwise
53+
`std::inplace_merge`; portable, no parallel-STL backend needed). Serial
54+
`std::sort` fallback below 65,536 elements or single thread.
55+
- `symmetrize_to_csr` now takes `threads_use` and sorts by a packed 64-bit
56+
`(lo<<32)|hi` key (one integer compare vs four min/max ops). The old
57+
`a.dst < b.dst` final tie-break was dropped — it only ordered exact-duplicate
58+
directed edges whose weight sum is commutative, so the aggregate is unchanged.
59+
- KNN edge merge converted from `#pragma omp critical` (serialized, copying) to
60+
the lock-free per-thread `AdaptiveScratch` accumulator pattern already used by
61+
`k*nn`.
62+
- Added `PhaseTimer` / `network_timing_enabled()`: gated on
63+
`ACTIONET_NETWORK_TIMING=1`, zero overhead when off, prints per-phase seconds
64+
to stderr (`index_build`, `query`, `merge`, `sort`, `finalize`).
65+
66+
## Build type note (relevant to x86 validation)
67+
68+
- Default `pip install` uses scikit-build-core with `cmake.build-type =
69+
"Release"` (`-O3`), but **no `-march`**. On x86_64, `__SSE__`/`__SSE2__` are
70+
baseline so hnswlib L2/IP get SSE and the JSD loop auto-vectorizes to SSE.
71+
AVX/AVX2/AVX512 are **not** enabled in the default build.
72+
- `install_optimized.sh` adds `-march=native -mtune=native -O3 -ffp-contract=fast
73+
-funroll-loops` and IPO on Linux x86_64. This is what unlocks AVX2/AVX512 for
74+
both the JSD loop and hnswlib's hand-written L2/IP SIMD (`space_l2.h`,
75+
`space_ip.h` dispatch on runtime CPUID: `AVXCapable()` / `AVX512Capable()`).
76+
- The top-level `CMakeLists.txt` was intentionally **not** changed, to preserve
77+
manylinux portability. Native tuning stays opt-in via `install_optimized.sh`.
78+
79+
## Results so far (macOS/arm64, Apple Silicon, JSD, real cached `H_stacked`)
80+
81+
Baseline = submodule HEAD before these edits; current = with edits. Speedup =
82+
baseline_median / current_median.
83+
84+
| Dataset | Mode | 1 thr | 4 thr | 8 thr |
85+
| --- | --- | --- | --- | --- |
86+
| 100k | knn (k=100) | 2.52x | 2.14x | 1.95x |
87+
| 100k | k\*nn | (see note) | 2.09x | 1.94x |
88+
| 6790 | knn | 3.41x | — | 3.32x |
89+
| 6790 | k\*nn | 3.27x | — | 3.31x |
90+
91+
Note: the first 100k k\*nn single-thread run reported 1.39x but was on a
92+
contended machine; the clean 4/8-thread runs show ~1.9-2.1x.
93+
94+
Phase profiling (`ACTIONET_NETWORK_TIMING=1`) at knn 100k / 8 threads: index
95+
build ~50% and query ~49% of time (both JSD-bound), finalize ~1.6%. So the
96+
distance kernel is the dominant lever; `parallel_sort` matters mainly for
97+
`k*nn`'s ~47M-edge sort and at multi-million-cell scale.
98+
99+
## Validation status
100+
101+
- 15/15 `tests/test_build_network_invariants.py` pass; 38/38 across
102+
network+diffusion tests.
103+
- JSD parity vs baseline (6790, k\*nn and knn): edge Jaccard >= 0.9996, weight
104+
correlation 1.000000, max relative weight diff 7.9e-4.
105+
- L2/IP topology variation is HNSW multithread nondeterminism, not a regression:
106+
baseline-vs-baseline `knn ip` Jaccard was 0.888, *lower* than current-vs-
107+
baseline 0.954.
108+
109+
## How to resume on x86_64
110+
111+
### 0. Confirm state
112+
113+
```bash
114+
cd actionet-python
115+
git rev-parse --short HEAD # expect the parent commit you land on
116+
cd src/libactionet && git status --short # expect the two modified network files
117+
grep -c "jsd_fasterlog2\|parallel_sort" \
118+
src/network/_hnsw_jensen_shannon.hpp src/network/build_network.cpp
119+
```
120+
121+
If the submodule changes are absent (fresh clone), re-apply them from this
122+
branch / a saved patch before proceeding.
123+
124+
### 1. Build two ways and compare
125+
126+
The benchmark compares a saved "baseline" `_core.so` (pre-edit) against the
127+
"current" one. Recreate the baseline by stashing the two files, building, saving
128+
the `.so`, then restoring:
129+
130+
```bash
131+
# Portable (default) build first:
132+
pip install -e . # or the repo's editable install path
133+
134+
# Baseline _core.so (pre-edit):
135+
cd src/libactionet
136+
git stash push src/network/_hnsw_jensen_shannon.hpp src/network/build_network.cpp
137+
cd ../../build/<wheel_tag> && ninja _core
138+
cp _core*.so ../../tests/_bench_cache/_core_baseline.so
139+
cd ../../src/libactionet && git stash pop
140+
cd ../../build/<wheel_tag> && ninja _core # current, then reinstall the .so
141+
```
142+
143+
**x86-specific:** also build an optimized variant to measure AVX2/AVX512 gains:
144+
145+
```bash
146+
./install_optimized.sh # -march=native, unlocks AVX
147+
```
148+
149+
Rebuild both baseline and current under the optimized flags for an apples-to-
150+
apples optimized comparison (SIMD affects baseline L2/IP and the current JSD
151+
loop differently).
152+
153+
### 2. Prep embeddings (backed mode for large data)
154+
155+
```bash
156+
python tests/benchmark_build_network.py prep \
157+
--dataset data/actionet_benchmark/scale_subset_100k.h5ad \
158+
--out tests/_bench_cache/h_100k.npy --backed --chunk 4096
159+
```
160+
161+
Repeat for 25k/50k/150k/200k and, since 300k runs on the dev machine,
162+
`data/adata_agg_Scn4b_OX_fil.h5ad` (300,157 cells) to get a real large-scale
163+
`knn` point. Prep runs the full ACTION pipeline once and caches the small
164+
`H_stacked` array; benchmarks then reuse it.
165+
166+
### 3. Benchmark
167+
168+
```bash
169+
python tests/benchmark_build_network.py bench \
170+
--embedding tests/_bench_cache/h_100k.npy \
171+
--algorithms "knn,k*nn" --metric jsd --k 100 \
172+
--threads 1,4,8,16,32 --trials 3 \
173+
--baseline-core tests/_bench_cache/_core_baseline.so \
174+
--out tests/_bench_cache/results_100k.jsonl
175+
```
176+
177+
Each build runs in its own subprocess (the `_core` extension always exports
178+
`PyInit__core`, so baseline and current cannot coexist in one process).
179+
180+
### 4. Phase profiling when a number looks off
181+
182+
```bash
183+
ACTIONET_NETWORK_TIMING=1 python -c "
184+
import numpy as np; from actionet import _core
185+
H=np.load('tests/_bench_cache/h_100k.npy').astype('float32')
186+
_core.build_network(H,'knn','jsd',1.0,8,16.,200.,200.,True,100)"
187+
```
188+
189+
## x86 validation goals (what to actually check)
190+
191+
1. Reproduce the arm64 speedups on x86 default (`-O3`, SSE-only) build. Expect
192+
the JSD win to hold; confirm no regression on L2/IP.
193+
2. Measure the *additional* gain from `install_optimized.sh` (AVX2/AVX512). The
194+
JSD loop should vectorize wider; quantify vs portable build.
195+
3. Run a real large tier (>=300k) in backed mode for `knn` (the priority path)
196+
and record wall time + peak RSS. Compare against the projections in
197+
`tests/ACTIONet Scaling Benchmark Run 001 Report.md`.
198+
4. Re-run the JSD parity check on x86 (numerics can differ slightly across ISA):
199+
edge Jaccard should stay >= ~0.999, weight correlation ~1.0.
200+
5. Thread scaling to high core counts (32/44): confirm the KNN merge and
201+
`parallel_sort` changes hold up where the old `omp critical` and serial sort
202+
would bite.
203+
204+
## Reproducing the correctness / parity check
205+
206+
A quick worker used during development (compares graph edge sets + weights
207+
between two `_core` builds); recreate or adapt as needed:
208+
209+
```bash
210+
# writes G to npz for a given core (CUR = installed, or a path to a baseline .so)
211+
python /tmp/corr_worker.py CUR tests/_bench_cache/h_6790.npy knn jsd /tmp/cur.npz
212+
python /tmp/corr_worker.py tests/_bench_cache/_core_baseline.so \
213+
tests/_bench_cache/h_6790.npy knn jsd /tmp/base.npz
214+
# then compare edge Jaccard + weight correlation
215+
```
216+
217+
Or just rely on `tests/test_build_network_invariants.py` for regression safety.
218+
219+
## Housekeeping
220+
221+
- `tests/_bench_cache/` is git-ignored (contains the baseline `.so`, cached
222+
`.npy` embeddings, `*.work.h5ad`, and result JSONL). It will not be committed;
223+
recreate it on the x86 machine via `prep`.
224+
- The `PhaseTimer` instrumentation is intentionally retained (gated, zero
225+
overhead). Keep it unless there's a reason to remove.
226+
227+
## Open follow-ups (not done)
228+
229+
- Stage D (deferred): revisit `k*nn`'s `5*sqrt(N)` neighbor heuristic and `ef`
230+
floor as a benchmarked default change (subject to the DECISIONS backward-compat
231+
bar). `k*nn` remains algorithmically O(N^1.5); the report already recommends
232+
`knn` for large data.
233+
- Backed streaming into HNSW: `build_network` still materializes the embedding
234+
in memory. A backed row-streaming reader is a larger, separate effort.
235+
- GPU/cuVS: explicitly out of scope for this pass (CPU + disk I/O priority).

0 commit comments

Comments
 (0)