Skip to content

_box_reduce's uint32 accumulator wraps every signed integer frame - #65

Open
CSSFrancis wants to merge 2 commits into
mainfrom
fix/box-reduce-signed-int-accumulator
Open

_box_reduce's uint32 accumulator wraps every signed integer frame#65
CSSFrancis wants to merge 2 commits into
mainfrom
fix/box-reduce-signed-int-accumulator

Conversation

@CSSFrancis

Copy link
Copy Markdown
Owner

Closes #64.

Why

_box_reduce chose its accumulator with one line:

out = (blk.sum(axis=(1, 3), dtype=np.uint32 if is_int else np.float64)
       .astype(np.float32) / (sy * sx))

#64 is about the np.float64 half. The np.uint32 half turned out to be worse:
it is a silent correctness bug on every signed integer frame, and it has been
there as long as the tiled path has.

A 2048² int16 image of -100 — a difference map, a background-subtracted
frame, an ordinary signed detector readout — displays as +1073741696:

a = np.full((2048, 2048), -100, np.int16)
ax.imshow(a)          # overview reads 1073741696.0, no warning

The ragged path does not even get that far. On a non-divisible grid the same
frame raises out of acc[:sh, :sw] += sub:

numpy._core._exceptions._UFuncOutputCastingError: Cannot cast ufunc 'add' output
from dtype('int64') to dtype('uint32') with casting rule 'same_kind'

And the width is wrong independently of the sign — uint32 overflows anything
past 16 bits, so an int64 frame of 3e9 reads back as 4.7e7.

test_tile_backend.py only ever exercised uint16 and float32. That is the
whole reason this survived.

What

_acc_dtype(dtype, n) picks the narrowest accumulator that cannot wrap over an
n-pixel block: same signedness, widened by the block size, falling back to
float64 for absurdly large blocks. bool keeps the cheap uint32 path.

input accumulator (64-px block)
uint8, uint16, bool uint32 (unchanged)
int8, int16 int32
uint32 / int64 uint64 / float64
float16, float32 float32
float64 float64

The float half — the issue's premise is right, its mechanism is not

#64 attributes the cost to "~537 MB of accumulator traffic". That is not what
happens: numpy does not materialise a float64 copy of the region for
sum(dtype=np.float64), it buffers the cast in chunks. The accumulator is the
1024² output grid, 8 MB. So the proposed fix on its own underdelivers — measured
on an 8192² float32 frame:

variant time
sum(axis=(1,3), dtype=float64) — before 64.6 ms
sum(axis=(1,3), dtype=float32) — as proposed in #64 58.5 ms
two-stage rows-first, float64 44.1 ms
two-stage rows-first, float32 15.7 ms

The dominant cost is the reduction order. sum(axis=(1, 3)) collapses a
strided axis and a contiguous one in a single pass, which defeats numpy's fast
inner loops. Collapsing whole rows first and then the contiguous column blocks
walks memory in order on both passes:

rows = region.reshape(gh, sy, w).sum(axis=1, dtype=acc)
out  = rows.reshape(gh, gw, sx).sum(axis=2, dtype=acc).astype(np.float32) / (sy * sx)

The two changes compound rather than add (1.1x and 1.5x alone, 4.1x together) —
once the access pattern is contiguous the cast is the bottleneck, and vice versa.

Measured

dtype    frame -> overview         before     after
float32  8192² -> 1024²             64.6ms     15.7ms    4.13x
float32  4096² -> 1024²             28.1ms      8.9ms    3.16x
float64  8192² -> 1024²             57.0ms     26.3ms    2.17x
uint16   8192² -> 1024²             66.3ms     41.6ms    1.59x
uint8    8192² -> 1024²             65.7ms     40.1ms    1.64x

Apple silicon, numpy 2.4.4. @TheDrOnos measured ~170 ms for the same call on
two other machines, so the absolute numbers travel less well than the ratios.

The trade this makes

float32 accumulation costs 2.4e-7 relative error against an output quantised to
1/255 ≈ 4e-3, so the overview cannot express the difference — the issue's core
argument, and it holds.

It does bound what a float32 frame may contain: n terms overflow to inf past
float32.max / n, ~5e36 for the default 64-pixel block. A frame carrying values
that large cannot survive the 8-bit quantisation downstream either, and float64
input — where that dynamic range actually turns up — keeps its own width. The
bound is written down in _acc_dtype's docstring rather than left to be
rediscovered.

Tests

A TestSampleDtypes class covering every integer width and both grid paths
(divisible and ragged), plus TestAccumulatorDtype for the choice itself.
16 of them fail on the previous implementation; all 59 pass now, and the
full suite is green (2090 passed, 58 skipped).

_box_reduce picked its accumulator with `np.uint32 if is_int else np.float64`.
Both halves of that are wrong, and the integer half is a correctness bug.

Any SIGNED integer frame wraps. A 2048² int16 image of -100 comes back from the
divisible path as +1073741696 — imshow displays it, no warning, no clue:

    a = np.full((2048, 2048), -100, np.int16)
    ax.imshow(a)          # overview reads 1073741696.0

The ragged path does not even get that far; `acc[:sh, :sw] += sub` raises
_UFuncOutputCastingError ("cannot cast ufunc 'add' output from int64 to uint32")
for the same frame on a non-divisible grid. Anything wider than 16 bits
overflows regardless of sign — an int64 frame of 3e9 reads back 4.7e7. The
tests only ever covered uint16 and float32, which is why none of this showed.

_acc_dtype now picks the narrowest accumulator that cannot wrap over the block:
same signedness, widened by the block size, falling back to float64 for the
absurd cases. bool keeps the cheap uint32 path.

The float half was only slow, and less slow than #64 supposed. float64 there
does not materialise a float64 copy of the region — numpy buffers the cast — so
switching to float32 alone is worth ~1.1x, not the ~2x the issue predicts. The
real cost is the reduction ORDER: sum(axis=(1, 3)) collapses a strided axis and
a contiguous one in one pass, which defeats numpy's fast inner loops.
Collapsing whole rows first and then the contiguous column blocks walks memory
in order both times. The two changes compound — fix the access pattern and the
cast becomes the bottleneck, and vice versa:

    8192² float32 -> 1024² overview     64.6ms -> 15.7ms   4.13x
    8192² float64                       57.0ms -> 26.3ms   2.17x
    8192² uint16                        66.3ms -> 41.6ms   1.59x

float32 accumulation costs 2.4e-7 relative error against an output quantised to
1/255, so the overview cannot express the difference. It does bound a float32
frame at ~5e36 before the sum overflows to inf; such a frame cannot survive the
8-bit quantisation downstream either, and float64 input keeps float64.

16 of the new dtype tests fail on the previous implementation.

Assisted-by: Claude Opus 5 (1M context)
Named for the PR since it has one, per the upcoming_changes README — the
orphan +{slug} form is for work batched on a branch with no PR number.
Verified with `towncrier build --draft`.

Assisted-by: Claude Opus 5 (1M context)
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 90.92%. Comparing base (87e7fa3) to head (5d2fe39).

Additional details and impacted files
@@            Coverage Diff             @@
##             main      #65      +/-   ##
==========================================
+ Coverage   90.90%   90.92%   +0.01%     
==========================================
  Files          40       40              
  Lines        4550     4560      +10     
==========================================
+ Hits         4136     4146      +10     
  Misses        414      414              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@TheDrOnos

Copy link
Copy Markdown

Benchmarked on our Windows box to close the loop on the absolute-numbers caveat — as
you expected, the ratios travel better than the milliseconds, but the before column
lands almost exactly on the ~170 ms from #64.

Machine: Windows 10, Xeon E5-1650 v2 workstation (6c/3.5 GHz), numpy 2.5.2, Python 3.12.10.
Two clean venvs, anyplotlib==0.8.0 vs git+…@fix/box-reduce-signed-int-accumulator,
same numpy in both. _box_reduce(frame, 1024, 1024, "mean") called the way
_make_overviewNumpyTileBackend.sample calls it for a full-frame overview.
Fixed-seed arrays, 3 warmups + 15 timed reps; medians below are the mean of two
independent runs taken in opposite order (old-first, then new-first), which agreed
within 5%.

case 0.8.0 PR #65 speedup
float32 8192² → 1024² 166.3 ms 54.6 ms 3.05x
float32 4096² → 1024² 71.5 ms 29.2 ms 2.45x
float64 8192² → 1024² 135.4 ms 90.9 ms 1.49x
uint16 8192² → 1024² 153.2 ms 107.8 ms 1.42x
uint8 8192² → 1024² 155.5 ms 107.8 ms 1.44x

Per-rep spread was tight — the float32 8192² case ran 162–182 ms before and 53–61 ms
after; the widest case was float64 (127–170 ms before). Full ordering, both directions:

                          run1 old   run2 old   run1 new   run2 new
float32 8192² → 1024²      170.1      162.4       54.5       54.8
float32 4096² → 1024²       73.8       69.2       29.0       29.4
float64 8192² → 1024²      140.5      130.4       89.9       91.9
uint16  8192² → 1024²      154.6      151.7      106.0      109.6
uint8   8192² → 1024²      159.4      151.7      109.4      106.3

Signed-integer bug confirmed here too, both grid paths, np.full((n, n), -100, np.int16):

divisible (2048² → 1024²)   0.8.0: 1073741696.0        PR #65: -100.0
ragged    (2001² →  700²)   0.8.0: UFuncTypeError: Cannot cast ufunc 'add' output
                                   from dtype('int64') to dtype('uint32') …
                            PR #65: -100.0

(With numpy 2.5.2 the ragged crash surfaces as UFuncTypeError rather than the
_UFuncOutputCastingError spelling in the PR body — same error, newer exception name.)

On the numbers being different from Apple silicon: our before for float32 8192²
is 166 ms against your 64.6 ms, i.e. this box is ~2.6x slower on the pathological
access pattern, and only ~3.5x slower on the fixed one — which is why our absolute
speedup (3.05x) comes out below your 4.13x. The ordering of the five cases is the
same on both machines, and the float32 8192² before number reproduces the ~170 ms
that #64 reported from two of our machines almost exactly, so the two measurements
are looking at the same thing. One like-for-like caveat: this ran on numpy 2.5.2
against your 2.4.4, so part of the ratio difference could be numpy rather than
silicon.

Output equivalence, same fixed-seed frames: uint8/uint16/float64 overviews are
bit-identical before vs after; the float32 cases differ by ~1.2e-8 relative on the
sum over the whole 1024² grid — three orders of magnitude below the 1/255 the tile
quantisation can express, so the trade in _acc_dtype's docstring holds on our
numbers too.

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.

_box_reduce float64 accumulator dominates large-frame set_data — float32 accumulate for the float path?

3 participants