Skip to content

perf(core): colocalization + contiguity micro-optimizations#94

Merged
afermg merged 1 commit into
mainfrom
perf/core-micro-opts
Jul 6, 2026
Merged

perf(core): colocalization + contiguity micro-optimizations#94
afermg merged 1 commit into
mainfrom
perf/core-micro-opts

Conversation

@timtreis

@timtreis timtreis commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Five small, output-preserving speedups found by an end-to-end perf audit (measured single-threaded, each verified allclose vs baseline).

Colocalization

  • _pearson_pair: OLS slope as cov/var instead of a per-object scipy.linalg.lstsq + design-matrix build — ~1.15–1.21× on get_correlation_pearson (slope matches lstsq to ~1e-6, Pearson r unchanged). Drops the now-unused lstsq import.
  • Costes (linear + bisection): extract fi[non_zero]/si[non_zero] once instead of re-slicing 4×/3× per object — ~1.07–1.36× on get_correlation_costes, bit-identical.

Contiguity (labels are 1..N by contract, cp_measure._sanitize)

  • get_sizeshape: nobjects = int(masks.max()) instead of (numpy.unique(masks) > 0).sum() — ~1.0–1.19× (size-dependent), bit-identical.
  • get_feret: numpy.arange(1, masks.max()+1) instead of numpy.unique(ijv[:, 2]) — ~1.03–1.18×, bit-identical.
  • labels_to_binmasks: arange instead of numpy.unique(masks) — drops a full-image sort, bit-identical under the contract.

Five small, output-preserving speedups (measured end-to-end, single-threaded, verified
against baseline):

colocalization
- _pearson_pair: derive the OLS slope as cov/var instead of a per-object scipy.linalg.lstsq
  + design-matrix build (~1.15-1.21x on get_correlation_pearson; slope matches lstsq to
  ~1e-6, Pearson r unchanged). Drops the now-unused lstsq import.
- Costes (linear + bisection): extract fi[non_zero]/si[non_zero] once instead of re-slicing
  4x/3x per object (~1.07-1.36x on get_correlation_costes; bit-identical).

contiguity (labels are 1..N by contract, see cp_measure._sanitize)
- get_sizeshape: nobjects = int(masks.max()) instead of (numpy.unique(masks) > 0).sum()
  (~1.0-1.19x, size-dependent; bit-identical).
- get_feret: numpy.arange(1, masks.max()+1) instead of numpy.unique(ijv[:, 2]) (~1.03-1.18x;
  bit-identical).
- labels_to_binmasks: numpy.arange(1, masks.max()+1) instead of numpy.unique(masks) (drops a
  full-image sort; bit-identical under the contract).

All 95 non-numba tests pass; ruff + mypy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@timtreis
timtreis requested a review from afermg July 6, 2026 14:16
@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

Benchmark — 73ab0c9 vs main

speedup = main/head (>1 faster, <1 slower) · median per cell · showing functions that moved ≥1.05× either way

feret

size \ objects 16 64 256
256 1.10× 1.11× 1.08×
512 1.12× 1.14× 1.11×
1024 1.13× 1.13× 1.13×

pearson

size \ objects 16 64 256
256 1.66× 1.56× 1.30×
512 1.72× 1.59× 1.53×
1024 1.60× 1.66× 1.57×

@afermg afermg left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It makes sense, this PR leverages the assumptions of labels being contiguous to avoid unnecessary np.uniqu calls, as well as assigning intermediate variables to avoid indexing the same thing multiple times.

coeffs = lstsq(numpy.array((fi, numpy.ones_like(fi))).transpose(), si)[0]
return float(corr), float(coeffs[0])
# OLS slope of si on fi is cov/var — avoids a per-object lstsq + design-matrix build.
slope = numpy.cov(fi, si)[0, 1] / numpy.var(fi, ddof=1)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Crazy that this is faster than the scipy function

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reduced scope 🤷

non_zero = (fi > 0) | (si > 0)
xvar = numpy.var(fi[non_zero], axis=0, ddof=1)
yvar = numpy.var(si[non_zero], axis=0, ddof=1)
fz = fi[non_zero]

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are avoiding indexing twice, makes sense.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

*thrice


labels = masks
nobjects = (numpy.unique(masks) > 0).sum()
nobjects = int(masks.max()) # contiguous 1..N (see cp_measure._sanitize)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool, the contiguous assumption pays off.

@afermg
afermg merged commit e041061 into main Jul 6, 2026
17 checks passed
@afermg
afermg deleted the perf/core-micro-opts branch July 6, 2026 18:48
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.

2 participants