Skip to content

perf: reciprocal-space mapping locality, grouping and per-call scheduling - #76

Merged
tifuchs merged 33 commits into
masterfrom
perf/subdivision-corner-passing
Aug 12, 2026
Merged

perf: reciprocal-space mapping locality, grouping and per-call scheduling#76
tifuchs merged 33 commits into
masterfrom
perf/subdivision-corner-passing

Conversation

@tifuchs

@tifuchs tifuchs commented Aug 12, 2026

Copy link
Copy Markdown
Owner

perf: reciprocal-space mapping locality, grouping and per-call scheduling

Summary

This branch is a multi-phase performance pass on reciprocal-space mapping (orgui/reconstruction_job.py, orgui/datautils/xrayutils/reconstruction.py, the C++ kernel), driven throughout by measurement against a real 3651-frame Pilatus 6M rotation-scan job rather than synthetic benchmarks. No mapping convention, voxel selection, or default behaviour changes; every record-shape and value change below is verified bit-for-bit or pinned against an independent reference test.

Design records and measurements are under doc/design/ (not published, not in the Sphinx toctree — see doc/AGENTS.md for how these are meant to be read and maintained going forward).

What changed, and why

  • Pixel subdivision now passes corner coordinates down instead of caching them (89f969c), pinned against an independent reference test (415dc1f) before anything downstream touched it.
  • Frame-group mapping: adjacent frames are now mapped through one native call as a work block shaped as a brick in (row, column, frame) rather than a run of per-image flattened blocks. On a rotation scan, adjacent frames land as close together in reciprocal space as adjacent pixels do, so a brick merges contributions a per-image block structurally could not see — 0.60x the emitted records at group size 8 on the reference job. This also changes how sums associate (inside the kernel instead of in the checkpoint accumulator), so it is marked as a phys change even though voxel selection and per-voxel sample counts are unchanged.
  • A scheduler for grouped calls runs a few concurrent native calls with the thread budget split between them and moves whole-frame correction into the prefetch pool, so one call's GIL-held Python work overlaps the next call's native work. 0.87x wall time at group size 4.
  • Group size is now measured per job, not configured. Grouping only pays while consecutive frames land within about a voxel of each other, so the job's own geometry is probed at three points in the scan to decide whether it does, then sized to the largest the memory budget allows without giving up concurrency. Non-monotonic scans (e.g. interlaced) fall back to one frame per call. 0.88x wall time on the reference job.
  • Fixed a detector-band-height bug: band height was still derived from the worst-case adaptive leaf count instead of the record ceiling the memory prechecks actually use, over-estimating per-pixel cost by up to four orders of magnitude at very_high/maximum accuracy — one row per band on a Pilatus 6M at maximum, 2527 native calls per image instead of six. balanced throughput is unchanged; this only mattered at the two highest accuracy settings.
  • Detector tiles are read in place instead of copied out of the corrected image before mapping — this was copying ~105 MB/image on a Pilatus 6M purely to hand the native call a shape it didn't need. 0.84x wall time at Center only accuracy, bit-for-bit identical output.
  • Image correction fused into one native pass: solid-angle/polarization, exposure/monitor normalization, and the non-finite mask now happen in a single native call instead of eight or nine full-image NumPy passes. Arithmetic, ordering, and uncertainty propagation are unchanged and bit-for-bit identical; 0.92x wall time at Center only, biggest where mapping itself is cheap (memory-traffic saving, not compute).
  • Measured which accuracy setting to actually use, and documented it: Balanced is sufficient for a full rotation scan against each voxel's own counting error; High/Very high/Maximum shift intensities by ≤0.02 sigma (indistinguishable from Balanced) for 4x/15x/100x the runtime. Center only is lossy and misses ~4% of reachable voxels. No default changed.
  • Investigated and closed out a per-frame serial-fraction hypothesis (doc/design/reciprocal_space_mapping_serial_fraction.md): the premise — that the pipeline is GIL-serialization bound — measured false. The machine is memory-bandwidth bound, not core bound, at low adaptive depth. Two of the three candidate fixes under that hypothesis are the tile-copy and image-correction changes above; the third (a pure scheduling change, no traffic reduction) was implemented, measured as a regression, and reverted. That negative result is recorded rather than discarded.

Net effect

Compounding the changes that survived measurement (grouping, scheduling, tile read-in-place, fused correction) gets to roughly 0.77x wall time at depth 0 on the reference job relative to branch start, with unchanged voxel selection and bit-for-bit-identical results wherever the change isn't explicitly a phys reassociation (frame grouping).

Validation

  • pytest orgui/datautils/xrayutils/test/test_reconstruction*.py (includes new test_reconstruction_group.py, test_reconstruction_pipeline.py, test_reconstruction_subdivision.py, and expanded test_reconstruction_job.py/test_reconstruction.py)
  • Independent-reference regression test for the subdivision corner-passing change
  • Six benchmarks/benchmark_reconstruction_*.py scripts added to reproduce every measurement above against the real job, not synthetic data
  • CHANGELOG.md / doc/source/release_notes.rst updated with full, measurement-backed entries for each user-visible change

Compatibility notes

  • Reciprocal-space reconstruction has not shipped in a release yet, so no previously published result is affected by the record-association change from frame grouping.
  • A job resumed against a scratch directory written by an earlier development build of this feature will contain checkpoint parts written under both the old and new association order — flagged in the changelog, not silently handled.

tifuchs added 30 commits August 11, 2026 00:41
Golden digests fail across compilers, so the adaptive rule is
reimplemented in Python and compared record for record at depths 0-4.
The dyadic corner cache switched off above depth 3, so the very_high and
maximum presets recomputed every corner, at 74 CPU-hours for one real
job. Output is bit-identical and 1.65x to 2.98x faster.

Profile counter coordinate_evaluations changes meaning.
Adjacent frames sit as close in reciprocal space as adjacent pixels, so
a (row, column, frame) brick measures 42% fewer records. Conditional on
per-frame displacement staying near one voxel; not implemented.
Both memory prechecks sized a pixel at its worst-case leaf count,
claiming 32.7 GB for a Pilatus-6M frame at depth 3. That rejected any
useful detector tile and collapsed image_workers to one.

Changes how many frames run concurrently, not any record value.
What the record-based bound unblocked, and why it resolved the
Python/kernel drift by removing the exposure dependence from both sides.
Consecutive frames land as close in reciprocal space as adjacent pixels,
so a (row, column, frame) brick emits 0.575x the records. Not
bit-for-bit, though every voxel and contributor count is unchanged.

accumulate() is untouched and remains the per-image path.
Measured record and time ratios against the geometric prediction, plus
the correction that a single-frame group is not bit-for-bit.
Compares group bricks against the same frames mapped one at a time,
reporting records before the cross-block merge and wall time.
The pipeline now calls accumulate_group, making a work block a brick in
(row, column, frame) rather than a run of one image. Records reaching
the checkpoint router fall to 0.598x at eight frames per group.

BREAKING CHANGE: reconstructed values move in the last bits for every
job, including those mapping one frame at a time, since contributions
now merge inside the kernel rather than in the checkpoint accumulator.
Which voxels are reached and how many samples reach each are unchanged.
Checkpoints stay resumable, but a job resumed across this change holds
parts from both association orders.
Times the whole mapping phase rather than one kernel call, which is what
grouping has to actually improve. It reports records entering the
checkpoint layer, not rows on disk, where the saving never shows.
The record prediction held at 0.598x; the throughput one did not, since
image_workers collapses when a worker is charged for its resident
frames. The scheduler must come before per-job group sizing.
Grouping was not worth turning on while the per-frame scheduler charged
a worker for every resident frame. Moving correction into a prepare pool
takes mapping to 0.866x the per-frame pipeline at four frames per call.

A different number of concurrent calls changes how sums associate.
The knobs the grouped scheduler turned out to need measuring against;
the final progress line is now reported alongside the timing.
Mapping is 0.866x the per-frame pipeline at four frames per group.
Corrects the thread-saturation claim, and records that this machine
varies 30% run to run, so arms must be interleaved.
frames_per_group now defaults to measuring the job, picking four frames
per call at 0.88x mapping time. Chosen on throughput, since density
alone picks the slowest configuration.

Turns grouping on by default, so contributions associate differently.
--group 1 is now how the per-frame arm is requested explicitly.
The measured choice reproduces step 5's hand sweep: four frames per call
at 0.88x the per-frame pipeline. Band height is still taken as given.
Tile planning was the third site still sizing from worst-case leaves,
collapsing a band to one row at depth 5: 2527 native calls per image
instead of six. Throughput is a wash, so this is for correctness.
Swept at four frames per call the surface is flat, so the pair needs no
joint search -- the default already sits in the region that matters.
Two design documents were referenced by the tracked locality document
while untracked themselves, so a fresh clone got dangling references.
All three now agree with what has landed.
--depth replaced max_depth alone, leaving work_block_pixels at whatever
the job's own accuracy implied. The preset halves with depth to hold the
native working set fixed, so a depth-0 arm ran an eight-times-too-small
block -- and frame-group brick dimensions are cut from that same count,
which biased the one comparison this benchmark exists to make.
Record counts converging with depth says nothing about whether an
intensity moves. This compares weighted_intensity / weight across
depths against each voxel's own propagated error bar, and reports the
exposure at which a difference would become visible -- which is the
answer independent of how long the probe could afford to run.

Unlike the other reconstruction benchmarks it compares numbers rather
than times, so it needs no interleaving, repeats or a quiet machine.
Frame grouping's record saving transfers above depth 0 and even
improves there, but it stops converting into time: depth 1 and depth 2
are nulls on both grids, while the depth-0 control reproduces phase 3
at 0.872x with every pair favouring grouping. Above depth 0 each pixel
and frame still walks its own subdivision tree, so record density no
longer governs kernel time and no brick shape can recover it.
Pixel-outer iteration and re-derived brick dimensions are retired
unbuilt.

Records the prior question with it, which turned out to be the larger
result: depth 3 differs from depth 5 by 0.013-0.024 of a voxel's own
error bar, so balanced accuracy is enough for a full job and the three
settings above it are not distinguishable from it by the data.

Also records that the reference job on disk no longer has the grid
every phase 1-3 number was taken against, which invalidated a whole
sweep before it was noticed.
Frame grouping pays only where the kernel dominates and record density
dominates the kernel, and nothing above balanced accuracy changes an
intensity measurably.

The traps are the ones that cost time this round. The reference job's
own grid has changed since these numbers were taken. The page cache,
not thermal state, dominates depth-0 noise, because the window is
11.7 GB against 31 GB of RAM. Foreign load must be summed per process:
taking it as machine-busy minus the benchmark charges the benchmark's
own I/O kernel time to something else, reporting 7.2 cores of
interference where per-process accounting showed 2.9. And a run that
maps nothing looks like a fast run, so a checkpoint fingerprint has to
be compared against its partner's.
The footprint accuracy settings were described by what they do, not by
what they are worth. Each one's effect on a reconstructed intensity is
now given against that voxel's own counting error, together with the
exposure needed to see it.

Balanced is sufficient for a full rotation scan, which is why it is the
default. High, Very high and Maximum shift an intensity by 0.02 sigma
or less and are not distinguishable from Balanced by the data, at
roughly 4x, 15x and 100x the runtime. Center only is genuinely lossy
and also misses about 4% of the voxels the other settings reach.

No behaviour or default changes.
Roughly one run in twenty mapped every frame, routed nothing, wrote a
checkpoint claiming its full frame count with zero rows, and exited
successfully in a fifth of the usual time. Nothing downstream could tell
that from a fast but legitimately empty run, and on resume the empty
part counts as done.

This warns rather than raises, because a grid can genuinely cover a
region a given slice of the scan never reaches. It reports through the
progress channel as well as warnings, since a bare warning is invisible
in the GUI, where an empty reconstruction is most expensive to discover
late. Both schedulers are covered: the grouped one returns early, so a
check placed only at the end of the function would miss every grouped
job, which is the default whenever a scan's geometry qualifies.

The root cause is not yet known, and this does not address it. The
static correction factor, the exposure angle bounds and the grid are all
bit-identical across fresh job loads; both schedulers re-raise recorded
exceptions; and the native memory precheck throws rather than returning
empty.
Four candidates measured and ruled out rather than reasoned about, so
that the next person does not spend the time again: the static
correction factor, the exposure angle bounds and the grid, a swallowed
exception, and the native memory precheck. Thirty consecutive runs of
one window returned the identical record count, so it is not a plain
per-run race either.

What remains is a raw frame reading back non-finite for a whole run,
which the finite-value check then masks away entirely. Unproven.
No tag contains the reciprocal-space reconstruction feature, so the
association change has no published behaviour to break and no stored
result a user could have produced under the old order. Recorded as
decided rather than left pending.

The changelog had described this as changing reconstructed values for
every job, which says nothing to a reader for whom the entire feature is
new. What survives is the part that is still true: a job resumed against
a scratch directory written by an earlier development build will hold
checkpoint parts from both association orders.
Phase 4 measured something it was not looking for: at depth 0 the
pipeline runs at ~110 ms/frame using 7-8 of 24 cores, bound by GIL-held
loading and correction rather than by mapping. That is the largest
remaining win in the mapping phase, and it takes over findings open item
1 as a plan of its own.

Three mechanisms in order, each with a predicted band and each measured
on its own: drop the per-tile stack and copy, fuse and GIL-release the
correction, then extend the prepare pool to the per-frame path. The
first step is none of them but a re-measurement -- the existing stage
table predates grouping and was taken in a kernel-dominated regime, so
it cannot apportion 110 ms/frame today.

Corrects the headroom claim in passing. Cold reads cap the pipeline at
51.5 ms/frame, so the prize is 2.1x; an earlier draft reasoned from idle
cores alone and said 3x.

Also records what phase 3 step 2 actually shipped: the per-brick centre
ray precompute is implemented and was never marked done, while the
whole-detector centre-ray cache it suggested is not, and is estimated at
2-4% for 149 MB.
The stage benchmark splits the mapping phase by stage and reports wall
against on-core time for each, from QueryThreadCycleTime rather than
time.thread_time, which quantises to the 15.6 ms scheduler tick. It also
probes GIL contention directly.

The A/B driver runs two arms alternating, flipping their order each
repeat, and reports paired ratios, per-run foreign CPU load and each
run's checkpoint fingerprint. An arm is a set of files copied into the
checkout, so a native change measures against its own baseline binary.
Detector tiles partition the detector, so gathering each tile into its
own (frames, rows, columns) buffer copied every corrected frame exactly
once per group -- ~105 MB a frame, in Python, on the compute worker --
purely to give accumulate_group a shape it did not need.

accumulate_group_tile takes the whole frames plus the tile rectangle and
reads it through the frame's own row stride. Both entry points build the
same GroupPixels view and run the identical brick loop, so this is
bit-for-bit: measured 0.840x end to end at depth 0 over six interleaved
pairs, with identical voxels, contributors and totals.
The per-pixel static factor, each scalar factor in turn and the
finiteness check that masks non-finite pixels were eight or nine
full-detector NumPy passes over ~50 MB each, taking and dropping the GIL
between them. They are now one native pass, GIL released throughout.

Bit-for-bit, not merely equivalent: correction is entirely element-wise,
so there is nothing to reassociate. The caller passes the squared factor
it already computed rather than letting the kernel recompute it, since
x ** 2 is pow in Python and a multiplication in C++, and the propagated
branch keeps its order -- scale the variance, use the intensity from
before the factor, then scale the intensity. A test pins both branches.

Measured 0.923x end to end at depth 0, against an independently measured
ceiling of 0.928x for removing the arithmetic entirely. The saving is
memory traffic, not GIL: the pool that runs correction is idle 87% of
the time either way.
The premise was wrong. A GIL probe measures 23% contention at depth 0
and the prepare pool is 13% occupied, so correction is already
overlapped and nothing is serialised behind it. What holds the pipeline
at 6.4 of 24 cores is memory: the same correction costs 2.3x the cycles
in-pipeline that it costs alone on one thread.

So work removed anywhere pays, including off the critical path, and
moving work between threads pays only where it was queued. A (0.840x)
and B (0.923x) are both traffic reductions and both landed; C was
scheduling only, measured 1.035x on the arm it was written for, and is
reverted. Two of four predicted bands were missed and are recorded as
such.

Also records three things the sweeps caught: a zero-record run on an
unchanged arm at 46.4 ms/frame rather than the documented fifth-of-normal
speed, a run that mapped identical voxels and contributors while
carrying 22% more total intensity, and the pipeline hang caught alive
with every checkpoint written and 58 threads at zero CPU.

Includes the uncommitted zero-record investigation write-up that was
already in the working tree.
@tifuchs
tifuchs merged commit a6b2f1d into master Aug 12, 2026
4 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.

1 participant