ADR-001: Cython Optimization Plan — Full Compilation Pipeline¶
Status: Proposed Date: 2026-07-04 Author: Code analysis agent
Context¶
The openptv2 tracking pipeline is written in Cython Pure Python mode (@cython.ccall, @cython.cfunc, @cython.cclass). The hot path — trackcorr_loop_fast in track_kernels.py — is already compiled to C and runs 2.6× faster than pure Python. The remaining Python object barriers prevent reaching full native speed and block parallelism.
Current Performance¶
| Configuration | 208 unit tests | Speedup vs pure Python |
|---|---|---|
Pure Python (editable -e) |
49.07s | 1.0× |
| Compiled C | 19.11s | 2.6× |
| Compiled + minor opts | 18.67s | 2.6× |
Problem: Python Objects in the Hot Path¶
The per-particle inner loop in trackcorr_loop_fast accesses calibration data through 6 separate Python tuples (cal_t, md_t, mo_t, mnr_t, mnz_t, mrw_t — each a tuple[ndarray]). Target coordinates arrive as object-typed parameters (targ_x_1: object). Every access like cal_t[j] or targ_x_1[j][_ix] generates two Python PyObject_GetItem calls instead of a single C-level memoryview index.
Decision¶
Adopt a four-phase optimization plan to eliminate all Python objects from the tracking hot path and enable multi-core parallelization.
Phase 1: Flatten Calibration → Single 2D Memoryview¶
Goal: Replace 6 per-camera tuples with a single (num_cams, 31) flat array + 5 typed 1D vectors.
Current Data Flow¶
track.py: track_kernels.py:
───────────────────── ────────────────────────
cal_t = tuple(ndarray,) trackcorr_loop_fast(
md_t = tuple(ndarray,) cal_t: tuple, ← 6x Py tuple
mo_t = tuple(ndarray,) md_t: tuple,
mnr_t = tuple(int,) mo_t: tuple, ← each access
mnz_t = tuple(int,) mnr_t: tuple, ← PyObject_GetItem
mrw_t = tuple(float,) mnz_t: tuple,
mrw_t: tuple,
)
for j in range(num_cams):
_point_to_pixel_out(
cal_t[j], ← 6 Py getitems
md_t[j], ← per camera
... per particle
)
Target Layout¶
cal_arr: cython.double[:, ::1] # shape (num_cams, 31)
md_arr: list[cython.double[:]] # mmlut data (variable length per cam — can't flatten)
mo_arr: cython.double[:, ::1] # shape (num_cams, 3) — mmlut origins
mnr_arr: cython.int[:] # shape (num_cams,)
mnz_arr: cython.int[:]
mrw_arr: cython.double[:]
Changes Required¶
| File | Change |
|---|---|
track_kernels.py L55 pack_cal_array() |
Unchanged — already returns 31-element ndarray |
track.py _pack_cams_fast_tuples() |
Return (N,31) array + 5 typed arrays instead of 6 tuples |
track_kernels.py trackcorr_loop_fast() |
Accept cal_arr: double[:, ::1] + typed vectors, replace cal_t[j] → cal_arr[j] |
track_kernels.py _sorted_candidates_fast_out() |
Same signature change |
track_kernels.py assess_new_position_fast() |
Same signature change |
track_kernels.py _point_to_pixel_out() |
Already takes cal: double[:] — callers pass cal_arr[j] (1D slice, zero-copy) |
Estimated speedup: 1.05-1.1× (eliminates ~1M PyObject_GetItem calls per test run) Risk: Low — mechanical substitution, no algorithm change. Prerequisite for: Phase 4 (nogil — can't parallelize with Python tuples).
Phase 2: Flatten Target/Frame Data → 3D Memoryview¶
Goal: Replace targ_x: tuple[ndarray] / targ_y: tuple[ndarray] with targ_xy: double[:, :, ::1] — one 3D memoryview with shape (num_cams, max_targets, 2).
Current Pattern¶
# trackcorr_loop_fast
targ_x_1: object, # ← Python tuple of 1D arrays
targ_y_1: object,
# Hot loop (L2760-2761):
cpx[j] = targ_x_1[j][_ix] # 2x PyObject_GetItem
cpy[j] = targ_y_1[j][_ix] # 2x PyObject_GetItem
Target Pattern¶
# trackcorr_loop_fast
targ_xy_1: cython.double[:, :, ::1], # shape (nc, max_targets, 2)
# Hot loop:
cpx[j] = targ_xy_1[j, _ix, 0] # 1x C memoryview access
cpy[j] = targ_xy_1[j, _ix, 1] # 1x C memoryview access
targ_tnr Write-Back Problem¶
targ_tnr arrays are written inside trackcorr_loop_fast (particle addition, L3071, 3243, 3681). Converting to a flat 2D int[:, ::1] requires syncing modified data back to the original per-camera arrays at function exit:
# At function entry:
_ttnr_mv: cython.int[:, ::1] = np.asarray(list(targ_tnr), dtype=np.int32)
# At function exit:
for ci in range(num_cams):
targ_tnr[ci][:] = _ttnr_mv[ci] # sync back
This sync-back is a small O(nc) cost and only needed when particle addition occurred.
Estimated speedup: 1.15-1.25× Risk: Medium — write-back logic must be correct; 2D copy changes memory layout which may affect numerical results (observed in test_cavity 1373→1374). Prerequisite for: Phase 4.
Phase 3: Replace Target Objects → Memoryview Rows¶
Goal: Eliminate list[Target] Python objects. Replace with flat memoryview arrays.
Current Pattern¶
# sortgrid.py returns:
pix = [Target(pnr=i, x=..., y=..., n=..., nx=..., ny=..., sumg=..., tnr=...), ...]
# Accessed in nearest_neighbour_pix (L27-50):
for p in pix: # Python iteration
if ymin < p.y < ymax and ...: # Python attribute access
d = sqrt((x - p.x) ** 2 + (y - p.y) ** 2)
Target Pattern¶
# Replace list[Target] with two flat arrays:
pix_xy: cython.double[:, ::1] # shape (N, 2): x, y coordinates
pix_meta: cython.int[:, ::1] # shape (N, 6): pnr, n, nx, ny, sumg, tnr
# Iteration:
for i in range(n):
py = pix_xy[i, 1]
if ymin < py < ymax and ...:
dx = x - pix_xy[i, 0]
dy = y - pix_xy[i, 1]
d = c_sqrt(dx*dx + dy*dy)
Files touched:
| File | Change |
|---|---|
| tracking_frame_buf.py | TargetArray(list) → return arrays from read_targets() |
| sortgrid.py sortgrid() | Return (pix_xy, pix_meta) instead of list[Target] |
| track_kernels.py candsearch_in_pix_fast | Already takes typed memoryviews — interface unchanged |
| track_kernels.py _nearest_neighbour_arr | Already takes arrays — just remove Target construction |
Estimated speedup: 1.1-1.2×
Risk: Medium — changes I/O layer, many callers of read_targets().
Phase 4: prange + nogil Parallelization¶
Goal: Release the GIL and parallelize the per-particle loop across CPU cores.
The Payoff Phase¶
Phases 1-3 are prerequisites: nogil requires ZERO Python objects in the parallel section. Once all data flows through typed memoryviews:
@cython.cfunc
@cython.nogil
@cython.boundscheck(False)
@cython.wraparound(False)
def _track_particle(
h: cython.int,
path_x_0: cython.double[:, ::1],
path_x_1: cython.double[:, ::1],
cal_arr: cython.double[:, ::1],
# ... ALL typed memoryviews, NO Python objects ...
) -> cython.int:
"""Process one particle — nogil safe, no Python interaction."""
...
@cython.ccall
def trackcorr_loop_fast(...):
# Allocate output buffers (GIL required)
result_arr = np.zeros((orig_parts_1, 2), dtype=np.int32)
result_mv: cython.int[:, ::1] = result_arr
# Parallel section (nogil released)
for h in prange(orig_parts_1, nogil=True):
result_mv[h, 0] = _track_particle(h, ...)
What nogil Forbids¶
| Forbidden | Allowed |
|---|---|
np.asarray(), np.empty(), np.zeros() |
Pre-allocated cython.double[:, :] views |
list, tuple, dict operations |
cython.int[:] typed arrays |
| Python function calls | @cython.cfunc calls |
print(), raise |
Return error codes |
Attribute access (obj.field) |
Memoryview index (arr[i, j]) |
Lock-Free Design¶
The per-particle loop writes to a pre-allocated output array with each particle writing to its own row (result_mv[h, :]). No two particles share a row → no locking needed.
Expected Scaling¶
| Cores | Speedup (ideal) | Speedup (realistic, Amdahl) |
|---|---|---|
| 2 | 2.0× | 1.7× |
| 4 | 4.0× | 2.7× |
| 8 | 8.0× | 3.5× |
| 16 | 16.0× | 4.0× |
Estimated speedup: 2-4× on typical multi-core workstation. Risk: High — must verify no shared state between particles; floating-point non-determinism across threads.
Roadmap Summary¶
| Phase | Description | Est. Speedup | Cumulative | Risk | Lines Changed |
|---|---|---|---|---|---|
| 0 | ✅ Compiled C (already done) | 2.6× | 2.6× | — | — |
| 1 | Flatten calibration → 2D memoryview | 1.05-1.1× | 2.7-2.9× | Low | ~50 |
| 2 | Flatten target data → 3D memoryview | 1.15-1.25× | 3.1-3.6× | Medium | ~80 |
| 3 | Replace Target objects → array rows | 1.1-1.2× | 3.5-4.3× | Medium | ~100 |
| 4 | prange + nogil parallelization |
2-4× | 7-17× | High | ~200 |
| Total | vs pure Python baseline | 7-17× | ~430 |
Testing Strategy¶
Each phase must be tested independently:
- Phase 1: Run all 242 tests, verify
test_cavitynlinks within ±2% - Phase 2: Same, plus verify
targ_tnrwrite-back works correctly - Phase 3: Same, plus verify all I/O paths (read_targets, sortgrid)
- Phase 4: Verify both correctness AND multi-core determinism (run 10×, check identical results)
Alternatives Considered¶
Keep tuples, use Cython tuple type hint¶
Cython's tuple type hint helps marginally — it avoids the Python type-dispatch overhead but still does PyObject_GetItem for each index. Not enough for nogil.
Move tracking to a C library¶
Would be fastest but requires a complete rewrite in C, losing the Python ecosystem. The Cython route lets us keep readability while getting 90% of C speed.
Numba JIT compilation¶
Numba works on individual functions, not across the call chain that trackcorr_loop_fast creates. The existing Cython compilation already covers more ground.
Consequences¶
- Positive: 7-17× speedup on the tracking pipeline; full CPU utilization on multi-core systems
- Positive: Cleaner code — flat arrays are easier to reason about than heterogenous tuples
- Negative: More memory per flat array (copy overhead for
targ_xy, ~1-2MB for 20000 targets × 4 cameras) - Negative: Write-back logic adds complexity for mutable arrays
- Negative: Phase 4 introduces non-deterministic floating-point results across runs