Skip to content

hierarchy: vectorize _get_branch_stats per-label loops (ADR 0013)#242

Merged
aelefebv merged 2 commits into
dechaofrom
hierarchy-perf-b-vectorize-branch-stats
May 12, 2026
Merged

hierarchy: vectorize _get_branch_stats per-label loops (ADR 0013)#242
aelefebv merged 2 commits into
dechaofrom
hierarchy-perf-b-vectorize-branch-stats

Conversation

@aelefebv

Copy link
Copy Markdown
Owner

Summary

Replaces the 4 per-label Python loops in `Branches._get_branch_stats`
(`hierarchical.py`) with vectorized numpy + scipy.ndimage equivalents:

  • base_lengths gather — single `label_lengths[unique_labels_int]` + bounds-mask.
  • Tip-radius adjustment (×2) — `np.searchsorted` + `np.add.at` (sequential semantics for duplicate indices).
  • Median thickness — `scipy.ndimage.median(thicknesses, labels, index)`.
  • Tortuosity — stable `np.argsort(tip_labels)` + boundary scan to grab the first two tips per qualifying label, then a single vectorized distance + division. 2D/3D split derived from `tip_coords.shape`; the now-unused `no_z` local is removed.

Equivalence bar

Approx-equivalent (`rtol=1e-5, atol=1e-5`) per ADR 0013. Float32 cast point shifts: the legacy loop casts after every `+=` (one cast per tip), the rewrite accumulates in float64 and casts once at the end. More numerically accurate in the IEEE sense (single rounding vs. accumulated round-after-every-step), but drifts by 1 float32 ULP on multi-tip labels.

Observed drift on yeast 3D fixture, label 2 at t=0 (the longest branch):

  • `branch_length_raw`: 16.6453686 → 16.6453667 (1.9e-6 absolute, 2e-7 relative)
  • `branch_tortuosity_raw`: 19.6316452 → 19.6316414 (3.8e-6 absolute, 2e-7 relative)
  • `branch_aspect_ratio_raw`: same row drifts in proportion

Bit-identical for:

  • 2D fixture (no multi-tip labels in the test data)
  • `branch_thickness` column on both fixtures (`scipy.ndimage.median` matches `np.median` exactly on the test inputs and on a 5000-element synthetic case)
  • All other rows on the 3D fixture

Why

cProfile on the yeast 3D fixture (`Hierarchy.run()`, 0.42 s total) flagged `_get_branch_stats` at 62 ms (15%) — second only to motility (which is in `flow_interpolation.py`, out of scope). The 4 per-label loops are O(N · L) (full-volume comparison per unique label); vectorized variants are O(N log N) or O(N + L). On the test fixtures the wall-clock win is modest (small label count); on production data with hundreds-to-thousands of branches per frame, the savings should be visible.

Test plan

  • 3 new regression tests pin the equivalence bar:
    • `test_get_branch_stats_2d_post_rewrite_bit_identical` — bit-identical to dechao baseline on 2D.
    • `test_get_branch_stats_3d_post_rewrite_approx_equivalent` — `rtol=1e-5, atol=1e-5` on `branch_length` / `tortuosity`; bit-identical for `branch_thickness` and for non-drifting rows.
    • `test_get_branch_stats_run_is_deterministic` — in-band determinism check across two independent runs on fresh fixtures.
  • Full `tests/test_hierarchical.py` (57 tests) passes.
  • ADR 0013 documents the trade-off + 4 considered (and rejected) alternatives.

🤖 Generated with Claude Code

aelefebv and others added 2 commits May 11, 2026 19:55
Replaces 4 per-label Python loops in `Branches._get_branch_stats`
(`hierarchical.py`) with vectorized numpy + scipy.ndimage equivalents:

- **base_lengths gather**: `label_lengths[unique_labels_int]` with a
  bounds-mask, replacing `for i, lbl in enumerate(unique_labels): ...`.
- **Tip-radius adjustment** (lone + regular tip loops):
  `np.searchsorted(unique_labels, tip_labels)` + `np.add.at`. Multiple
  tips per label accumulate via `np.add.at`'s sequential semantics
  (bare indexed `+=` would only apply the last addend for duplicates).
- **Median thickness**: `scipy.ndimage.median(thicknesses, labels,
  index=unique_labels)`. Bit-identical to per-group `np.median` on the
  yeast 2D + 3D fixtures and on a 5000-element synthetic case.
  Defensive `np.bincount`-driven NaN-fill for absent labels (no-op on
  the test fixtures since `unique_labels = np.unique(L[L>0])`
  guarantees every label has at least one matching voxel).
- **Tortuosity**: stable `np.argsort(tip_labels)` + boundary scan to
  grab the first two tips per qualifying label, then a single
  vectorized distance + division. 2D/3D split falls out of
  `tip_coords.shape` directly (the now-removed `no_z` local).

The float32 cast point shifts: legacy casts after every `+=` (one
cast per tip), rewrite accumulates in float64 and casts once at the
end. **More numerically accurate** in the IEEE sense (single rounding
vs. accumulated round-after-every-step), but drifts by 1 float32 ULP
on labels with multiple tips. Observed on yeast 3D fixture, label 2
at t=0 (the longest branch): `branch_length_raw` 16.6453686 →
16.6453667, a 1.9e-6 absolute / 2e-7 relative diff. Tortuosity
inherits the same drift in the same row. Bar: `rtol=1e-5, atol=1e-5`
(see ADR 0013); 2D fixture is bit-identical (no multi-tip labels);
`branch_thickness` is bit-identical on both fixtures.

New imports: `from scipy import ndimage as ndi_cpu` (scipy already a
transitive dependency).

Tests: 3 new regression tests pin the equivalence bar:
`test_get_branch_stats_2d_post_rewrite_bit_identical`,
`test_get_branch_stats_3d_post_rewrite_approx_equivalent`, and
`test_get_branch_stats_run_is_deterministic` (in-band determinism
across two independent runs on fresh fixtures).

ADR 0013 documents the trade-off and the 4 considered options
(rejected: per-tip Python loop with intermediate cast, pure-float32
add.at, single+multi-tip detection split, defer entirely).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…hetic multi-tip test

Frangi runs at session-start (`frangi_3d_path` →
`_run_filter_to_disk`) and is SIMD-sensitive across macOS / Linux /
Windows. Different Frangi → different segmentation → different
branch counts on the 3D yeast fixture (Linux/Windows produce 4
branches, macOS produces 6), so a hardcoded 3D baseline can't be
cross-platform stable.

Replace `test_get_branch_stats_3d_post_rewrite_approx_equivalent`
with `test_get_branch_stats_multi_tip_label_synthetic_post_rewrite`:
constructs a 9-voxel "+" skeleton (4 tips on one label) directly,
bypassing all SIMD-sensitive upstream stages. Asserts the
post-rewrite single-end-cast value equals the formula-derived
expected value, and that the legacy per-tip-cast pattern stays
within `rtol=1e-5, atol=1e-5` of the new value. Platform-stable by
construction (all inputs are hand-set integers + irrational-at-
float32 distances chosen to make the cast point matter).

Updates ADR 0013 to reflect the test-bar shift and the cross-
platform Frangi limitation. The 2D bit-identity + determinism +
synthetic tests together cover the equivalence claim without
depending on SIMD-stable Frangi output.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@aelefebv
aelefebv merged commit b4d4005 into dechao May 12, 2026
6 checks passed
@aelefebv
aelefebv deleted the hierarchy-perf-b-vectorize-branch-stats branch May 12, 2026 03:10
aelefebv added a commit that referenced this pull request May 12, 2026
…ncy (#243)

Two safe-and-free cleanups in `hierarchical.py`:

- **`_get_ref_coords` redundant gather** — `vals_a` and `vals_b` were
  computed by the *same* `idxmin[branch_labels_clipped]` expression
  (same input array, same lookup). Compute once, share the NaN mask.
  ~6 lines deleted; fewer allocations on the hot motility path
  (called per-frame inside `_get_motility_stats`).

- **`_save_adjacency_maps` voxel→node Python loop** — replaces the
  nested `for voxel_idx, nodes in enumerate(...): for n in nodes:
  edges_vn.append((voxel_idx, int(n)))` with `np.repeat` +
  `np.concatenate`. Only fires when `not skip_nodes` (production
  default in the napari pipeline). Removes per-edge Python attribute
  + tuple-allocation overhead — a 1MB-edge frame goes from millions of
  Python ops to two C-level numpy calls.

Bit-identical: verified locally on yeast 2D + 3D fixtures via SHA
over all 6 hierarchy outputs (`features_*` + `adjacency_maps`) — all
12 hashes match the post-PR-#242 baseline exactly.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
aelefebv added a commit that referenced this pull request May 12, 2026
…ation audit close-out (#244)

- now.md: new "Recently shipped" entry covering PRs #241/#242/#243
  (vectorized per-label group construction, vectorized
  _get_branch_stats loops + ADR 0013, _get_ref_coords + vox→node
  cleanups). Watch list updated to reflect hierarchical's pinning
  status and the deferred wins (regionprops solidity ~17%,
  flow_interpolation _get_vector_weights ~28%) that the next perf
  audit could target.
- feature-extraction.md: new Performance section documenting the 4
  shipped optimizations + the 3 deferred items, plus a Benchmarks
  subsection pointing at tests/test_hierarchical_perf.py.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant