Repository navigation
perf(j2k-native): faster CPU classic Tier-1 decoding - #303
Merged
Merged
Conversation
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
force-pushed
the
fix/metal-zero-bitplane-seed
branch
from
October 29, 2026 05:09
431cdf3 to
a8e8748
Compare
jcwal1516
force-pushed
the
perf/classic-tier1-mq
branch
from
October 29, 2026 05:09
18cfd2e to
8c8a9ca
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
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.u8label 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.jp2encoded classic lossless (5/3) and at 2 bpp (9/7), and the 18000 × 12000 classiclarge-plt.jp2/large.jp2. Method and full notes indocs/benchmark-evidence.md.large-plt.jp2, 1 threadlarge.jp2Every 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) andcargo 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 xtaskfmt, clippy, clippy-strict, repo-lint, metal-compile, public-support, panic-surface, unsafe-audit pass.