|
| 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