Skip to content

perf(j2k-native): faster CPU classic Tier-1 decoding - #303

Merged
jcwal1516 merged 10 commits into
mainfrom
perf/classic-tier1-mq
Oct 29, 2026
Merged

jcwal1516 merged 10 commits into
mainfrom
perf/classic-tier1-mq

Conversation

@jcwal1516

Copy link
Copy Markdown
Member

Stacked on #302. Speeds up CPU classic (Part 1) code-block decoding with five independent changes (one commit each), plus a commit with the measurements. Decoded samples and encoded codestreams are unchanged.

  • MQ read-ahead. C is 64 bits wide: the half DECODE compares with A sits in bits 48–63, and up to 48 bits of code bytes are read ahead below it, several bytes per refill, instead of one BYTEIN per renormalization. A byte after 0xFF can carry into the bits above it, so it is still read only once no read-ahead bits remain, as in Annex C; every other byte lands on zero bits, so reading it early changes nothing DECODE sees.
  • Candidate rows as bit sets. Each pass forms a column's candidate rows as a set of bits at the rows' window shifts and visits them lowest first, instead of a data-dependent branch per row. When significance propagation makes a row significant it re-derives the rows below it. Decisions, contexts and their order are unchanged.
  • Window shifts. The context helpers take each row's window shift (3 * row) instead of the row, which the loops divided by three and the helpers multiplied back. The classic encoder shares these helpers and already tracks shifts, so it passes them through as well.
  • Next MQ state with one shift. The packed state holds the next MPS state in byte 3 and the next LPS state in byte 4; DECODE shifts by 24 or 32 instead of two XOR-mask selects.
  • Contexts by label without a bounds check. The context array has 256 one-byte slots, so any u8 label from the zero- and sign-coding tables indexes it in bounds. Labels 0–18 are still the only ones used. This adds 237 bytes per decoding context.

Measurements

M4 Pro, interleaved rounds against the parent branch with a second build of the parent as the A/A control; per-round median change, range in brackets. Inputs are a 4096 × 2048 RGB tissue region of large.jp2 encoded classic lossless (5/3) and at 2 bpp (9/7), and the 18000 × 12000 classic large-plt.jp2 / large.jp2. Method and full notes in docs/benchmark-evidence.md.

Cell Before (ms) A/A After
Lossless decode, 1 thread 1026 −0.4% −11.2%
2 bpp decode, 1 thread 260 +0.8% −8.6%
1024² region of large-plt.jp2, 1 thread 117 −1.8% −12.0%
Lossless decode, 12 threads 122 +0.3% [−7.1, +8.5] −11.3% [−25.7, −5.9]
2 bpp decode, 12 threads 37.1 −2.8% [−17.5, +16.3] −4.4% [−31.3, +4.8]
8 concurrent 1024² regions of large.jp2 185 −0.6% [−6.1, +1.1] −9.2% [−20.3, −2.0]
Lossless encode, 12 threads 353 +0.3% [−4.0, +4.1] −3.0% [−14.4, +10.6]

Every cell's output was byte-identical across builds. Without the 256-slot contexts, one-thread decodes are 0.7–1.8 points less fast. Peak heap of the serial region decode is unchanged; multi-thread peaks differ by no more than the A/A builds' own few kilobytes.

Testing

  • cargo test -p j2k-native (960 passed) and cargo test -p j2k (495 passed), including the classic conformance and Tier-1 golden tests; J2K_REQUIRE_METAL_RUNTIME=1 cargo test -p j2k-metal (587 passed), whose hybrid route uses this block decoder.
  • cargo xtask fmt, clippy, clippy-strict, repo-lint, metal-compile, public-support, panic-surface, unsafe-audit pass.
  • Not run here: x86 execution. The changes are scalar Rust with no target-specific code.

CpuBatchDecoder region requests of large images failed with
ImageTooLarge: prepared region plans store only the region, but their
sub-bands and IDWT levels span whole resolution levels, so 1024 px tiles
of an 18000 x 12000 image asked for whole level-0 and level-1 planes.

Preparation keeps that whole-level geometry, which Metal and CUDA
execute, and adds a CPU copy whose bands hold only what each store
reads: native region decode's IDWT windows, with each sub-band grown to
the code-blocks it decodes. The copy is built through fallible
allocation and used only when it at least halves the plan's
coefficients or the whole-level plan holds 16 Mi or more, since the
windowed IDWT is slower per sample than the banded one.

Those tiles now decode with output identical to single-image region
decode; quarter-resolution tiles, which already fit, take 24 ms instead
of 48 ms (HTJ2K) and 310-317 ms instead of 343-346 ms (classic).

Fixes #299
Batch preparation failed with "failed to decode code-block" on reduced
regions that end where a tile begins on the reduced grid. Tile
selection rounds tile bounds outward, so it keeps a boundary tile that
contributes no samples, and the referenced plan builders rejected a tile
whose store copies nothing. They now leave such a tile out, with its
payload records, as single-image decode does.

On the 19 x 13 OpenJPH fixture in 11 x 7 tiles, 4,598 of the 17,290
half-scale regions failed to prepare on the CPU and Metal batches; all
now match single-image decode. The test oracle now reads signed 8-bit
native output one byte per sample.

Fixes #298
Record window_referenced_classic_plan_to_stores and
window_referenced_htj2k_plan_to_stores in the j2k-native hidden inventory,
refresh the reviewed diff report, and update the review's hidden count,
fingerprint and rationale.
…plane tree

The Metal packetizer gave a code-block with no coding passes its own
zero-bitplane count (all bitplanes) in the zero-bitplane tag tree,
while the CPU packetizer seeds it with zero. Decoders never read that
leaf, but it lowers or raises the parent minimum, so headers with an
empty block beside coded ones differed: all-stage Metal lossy encodes
were valid but not byte-identical to the CPU (mosaic qfactor 86: 2
bytes shorter). The Metal tree now seeds such blocks as the CPU does.

(cherry picked from commit 575f6d2e2f3b3743d71ce91fbf60c660d55c2873)
(cherry picked from commit 6341a18dd2c33d30ece345ddb1b8a2b0c946706b)
The classic MQ decoder ran BYTEIN once per code byte, from the
renormalization slow path, so about one symbol in ten of a lossless decode
took that path. C is now 64 bits wide: the half that DECODE compares with A
sits in bits 48-63, and up to 48 bits of code bytes are read ahead below it,
several bytes per refill.

A byte after 0xFF may carry into the bits above it, so it is still read only
once no read-ahead bits remain, as in Annex C. Every other byte lands on zero
bits, so reading it early changes nothing DECODE sees. Output is unchanged.
The packed-column passes tested each of a column's four rows with its own
data-dependent branch. Each pass now forms the column's candidate rows as a
set of bits, one per row at the row's window shift, and visits them lowest
first: significance propagation takes uncoded rows with a significant
neighbor, refinement the rows significant before the pass, and cleanup the
uncoded rows. When significance propagation makes a row significant, it
re-derives the rows below it, which now have a significant neighbor.

Decisions, contexts and their order are unchanged, so output is unchanged.

Add a changelog entry for both classic Tier-1 changes.
The candidate loops find each row as its window shift, the bit index
`3 * row` of its neighborhood in the column word, then divided it by three to
call the zero-coding, sign-coding and significance helpers, which multiplied
it by three again. On AArch64 that put a multiply and three more instructions
between the candidate and its context label. The helpers now take the shift;
only coefficient addresses still need the row. The classic encoder's passes,
which share the helpers and also track each row as its shift, pass it
unchanged too.

Decoded samples and encoded codestreams are unchanged.
The packed decoder state holds the next MPS state in byte 3 and the next LPS
state in byte 4. DECODE extracted both and chose between them with two
XOR-mask selects; it now shifts by 24 or 32 and keeps the old state unless
the probability state transitions.

Output is unchanged.
… check

Context labels come from the zero-coding and sign-coding tables as `u8`, so
indexing the 19 contexts checked every label against 18. The context array
now has 256 slots, which any `u8` label indexes in bounds; labels 0-18 are
still the only ones used.

Output is unchanged.

Add a changelog entry for the classic Tier-1 changes.
Interleaved A/B rounds against the parent of the classic Tier-1 commits,
with a second build of the parent as the A/A control, on an M4 Pro. On a
4096 x 2048 RGB tissue region, one-thread decodes are 11.2% faster lossless
and 8.6% faster at 2 bpp, 12-thread lossless decodes 11.3% faster, and
eight concurrent serial 1024 x 1024 region decodes 9.2% faster, each beyond
the A/A spread; classic lossless encode is not slower. Every cell's output
is byte-identical. The 256-slot contexts add 0.7 to 1.8 points of the
one-thread gain and 237 bytes per decoding context.

Fold the two classic Tier-1 changelog entries into one with these numbers.
@jcwal1516
jcwal1516 force-pushed the fix/metal-zero-bitplane-seed branch from 431cdf3 to a8e8748 Compare October 29, 2026 05:09
@jcwal1516
jcwal1516 force-pushed the perf/classic-tier1-mq branch from 18cfd2e to 8c8a9ca Compare October 29, 2026 05:09
@jcwal1516
jcwal1516 changed the base branch from fix/metal-zero-bitplane-seed to main October 29, 2026 12:26
@jcwal1516
jcwal1516 merged commit fbd4af1 into main Oct 29, 2026
32 of 37 checks passed
@jcwal1516
jcwal1516 deleted the perf/classic-tier1-mq branch October 29, 2026 12:26
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