Skip to content

DictionaryMatcher: exhaustive best-atom match in one CUDA kernel - #55

Merged
mcencini merged 1 commit into
mainfrom
claude/project-thread-2eaqzo
Nov 4, 2026
Merged

mcencini merged 1 commit into
mainfrom
claude/project-thread-2eaqzo

Conversation

@mcencini

@mcencini mcencini commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

What changed

The exhaustive match (no groups, no lattice) now runs on a card as a single kernel (src/blochsim/_match.cu, bound in _gpu as match_best). It applies with top_k=1, real float32 atoms, up to 16 contrasts, and float32 or complex64 signals. Each thread holds four signals in registers, the atoms are staged in shared memory and read as broadcasts, and only the best atom and its score are kept. Any case outside those conditions is matched in tensors as before. Fast group matching (groups=) and the coarse lattice are unchanged.

Why

On a card, matching in tensors writes the voxels-by-atoms score matrix and then reads it back for the absolute value and the reduction, once per chunk of atoms. Group matching saves arithmetic but costs a few launches and a sync per group. In MRI2Qmap's 256³ joint projection (7 contrasts, about 21k atoms, 100 groups), that is about 6,400 small launches per call. They are cheap on an A40, but under WSL on an RTX 4060 laptop the projection was 7x the A40's time, well beyond the compute ratio. Matching every voxel against every atom is about 5 TFLOP, so a kernel that never stores the scores searches every atom faster than group matching prunes them.

Validation

On an A40, with blochsim-cuda13 built for 80/86/89:

  • tests/estimators/test_dictionary.py + test_grouped.py: 36 passed, 1 failed.
    • All 8 test_the_card_kernel_finds_the_tensor_match cases ran and passed. They cover real and complex signals at 3, 7 and 16 contrasts, plus 17 contrasts, which falls back to tensors. In each case the indices are identical to the tensor path's, and the scores and scales agree to 1e-5.
    • The failure, test_without_refinement_the_coarse_lattice_alone_is_searched, fails identically at the parent commit 339d0be, so it predates this change.
  • One MRI2Qmap projection call on a (7, 256³) input took 2.18 s and 0.26 GiB with groups=100, against 0.73 s and 0.07 GiB with this kernel. The share of exact hits against the true atoms was 6.87 % and 6.89 % at 3 % noise.
  • Full MRI2Qmap recon (Deli-CS, R8): projection went from 3.0 to 2.3 s per iteration, and the rest of each call is host work. Accuracy against the reference is unchanged (PD NRMSE 0.1692 vs 0.1696, T1/T2 WM+GM 9.63/14.30 vs 9.63/14.33 %). Map differences are at the run-to-run level (brain median T1 0.008 %).

Documentation impact

CHANGELOG entry under Unreleased/Added. The public API is unchanged: an existing exhaustive DictionaryMatcher simply runs faster on a card.

Checklist

  • The new test covers the change; card tests were run on an A40 (the one failure predates this change).
  • ruff format and ruff check are clean.
  • Public functions and classes carry a numpydoc docstring (no new public API).
  • Comments and docstrings describe the code as it is now.
  • The kernel computes the same score as correlate (|<s, d>|, hypot for complex signals against real atoms), pinned against the tensor path.
  • Documentation updated (CHANGELOG).

🤖 Generated with Claude Code

https://claude.ai/code/session_01MFB6QLjhTxFMFDEzathbEm

Without groups or a lattice, top_k=1, real float32 atoms and up to 16
contrasts, the match of float32 or complex64 signals on a card runs as
one kernel (_match.cu, bound in _gpu as match_best): each thread holds
four signals in registers, atoms are staged in shared memory and read as
broadcasts, and only the best atom and its score are kept. The
voxels-by-atoms score matrix is never stored, and there is one launch
per call instead of a product, an absolute value and a reduction per
chunk of atoms (or, with groups, a few launches and a sync per group).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MFB6QLjhTxFMFDEzathbEm
@mcencini
mcencini merged commit b5433b8 into main Nov 4, 2026
35 checks passed
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