Skip to content

perf(native): inline VLC symbol decoding and pass refill state by value - #19

Merged
jcwal1516 merged 1 commit into
mainfrom
perf/entropy-inline-vlc
Oct 19, 2026
Merged

jcwal1516 merged 1 commit into
mainfrom
perf/entropy-inline-vlc

Conversation

@jcwal1516

Copy link
Copy Markdown
Member

CPU entropy decoding is most of a decode on both routes. On a 2048² RGB q8 image it is about 50 of 55 ms on the Metal route, and for batches of 256×256 tiles it is the preparation stage. Profiling showed that the HP coefficient loop, about 60% of entropy time, called each per-symbol VLC helper out of line. Each call reloaded and stored the bit reader's fields, returned its Result through memory, and re-checked its table indices. Those helpers were 21% of main-thread samples on that image.

  • Inlined symbol decoding. decode_index, decode_abs_level, decode_first_index, and decode_run_cached are forced inline into the LP and HP block loops. The bit-serial run decoder they fall back to near a packet end is now cold and out of line.
  • By-value refill and errors. The out-of-line cache refill takes the packet slice, position, and length and returns the new cache and bit count, and the end-of-packet error constructor takes values. Calling either no longer passes the reader by address. read_bits is forced inline. This mostly helps flexbit-heavy 16-bit images: on its own, the inlined read_bits gained nothing there, and the by-value helpers gave most of the gain.
  • No behavior change. Decoding logic, error variants, and error positions are unchanged. Each #[inline(always)] carries #[expect(clippy::inline_always)] with its measured reason.

Performance. "Before" is main at 8a7ccbe, which includes #12–#18, and each figure is the median of 5 interleaved rounds. Single images, jxr-load-bench, ms. Prepare is the Metal stage (parse, plan, and entropy into staging); CPU is the full CPU-route decode. The M4 and M5 Max columns show speedups:

Image M4 Pro prepare M4 Pro CPU M4 prepare M4 CPU M5 Max prepare M5 Max CPU
gray 8-bit q10 27.3 → 24.7 25.2 → 23.1 1.07x 1.07x 1.08x 1.07x
gray 16-bit q0 38.6 → 34.2 36.1 → 32.0 1.12x 1.11x 1.10x 1.10x
gray 16-bit q20 36.6 → 32.7 34.1 → 30.4 1.10x 1.09x 1.09x 1.08x
4096² gray 16-bit 150.3 → 134.1 138.4 → 123.0 1.11x 1.10x 1.09x 1.09x
RGB q0 82.3 → 73.9 87.0 → 78.3 1.09x 1.09x 1.09x 1.08x
RGB q8 55.2 → 50.1 59.7 → 54.2 1.08x 1.07x 1.08x 1.07x
RGB q30 37.1 → 33.8 41.2 → 37.9 1.08x 1.06x 1.07x 1.06x
RGB q20 overlap 0 47.6 → 43.0 51.4 → 46.8 1.09x 1.08x 1.08x 1.07x
RGB q20 overlap 2 42.4 → 38.6 46.8 → 42.9 1.08x 1.07x 1.08x 1.07x
RGB q20 frequency 47.1 → 44.0 51.4 → 48.1 1.07x 1.06x 1.06x 1.06x
RGB q20 4:2:0 27.3 → 24.8 39.6 → 37.3 1.07x 1.04x 1.06x 1.04x
RGBA q10 74.5 → 67.9 80.5 → 74.0 1.08x 1.07x 1.08x 1.07x
4096² RGB, 4×4 tiles 30.3 → 27.6 45.6 → 43.3 1.07x 1.04x 1.06x 1.04x

Images are 2048² unless noted.

256×256 Boat tiles, jxr-pathology-bench, ms. Prepare is the Metal host route's preparation, and the other columns are route totals:

Batch Machine Prepare CPU host Metal host Metal dense Metal batch API
1 M4 Pro 0.40 → 0.31 0.67 → 0.61 0.66 → 0.58 0.67 → 0.59 0.69 → 0.60
1 M4 0.36 → 0.30 0.61 → 0.56 0.65 → 0.59 0.65 → 0.59 0.66 → 0.62
1 M5 Max 0.34 → 0.29 0.71 → 0.66 0.58 → 0.52 0.58 → 0.52 0.61 → 0.55
8 M4 Pro 0.77 → 0.64 1.45 → 1.26 1.36 → 1.18 1.37 → 1.19 1.85 → 1.77
8 M4 0.96 → 0.81 1.61 → 1.53 2.21 → 1.81 1.97 → 1.80 2.29 → 2.18
8 M5 Max 0.64 → 0.59 1.41 → 1.32 1.09 → 1.01 1.02 → 1.00 1.19 → 1.14
32 M4 Pro 2.44 → 2.02 4.08 → 3.80 4.39 → 3.83 4.53 → 4.09 4.31 → 3.78
32 M4 3.19 → 2.70 5.38 → 4.96 5.83 → 5.29 5.91 → 5.41 5.80 → 5.35
32 M5 Max 1.62 → 1.49 3.22 → 3.07 2.98 → 2.63 2.97 → 2.87 3.58 → 3.18
128 M4 Pro 8.69 → 7.19 15.31 → 12.82 14.76 → 12.59 14.53 → 13.90 12.38 → 10.81
128 M4 11.87 → 10.12 19.42 → 17.67 20.51 → 18.66 19.74 → 17.98 15.61 → 13.55
128 M5 Max 5.10 → 4.55 9.01 → 8.89 9.28 → 8.45 10.36 → 10.00 8.10 → 7.01

Batch preparation is 1.06–1.29x faster on every machine. The M5 Max CPU-host route at 64 tiles (not shown) measured 5.54 → 5.64 ms; its 32- and 128-tile rows are faster.

MpsGraphBatchDecoder::prepare then run_prepared_group on the same tiles with the rgb8_nhwc_reference graph, M4 Pro, ms:

Batch Prepare Prepare + run
1 0.43 → 0.38 0.83 → 0.73
8 0.84 → 0.74 1.53 → 1.42
32 2.49 → 2.07 4.29 → 3.79
128 8.98 → 7.43 14.60 → 12.66

Three variants measured slower or neutral and are not included: decoding each block from a local copy of the reader so its fields stay in registers (neutral), inlining flexbit decoding into the YUV HP loop (0.67–0.86x), and an inline fast path for refills away from the packet end (0.87–0.94x).

Validation on an M4 Pro, on the earlier main (e52d95c) with this change: cargo fmt --all -- --check; cargo clippy --workspace --all-targets --all-features -- -D warnings, and the same with --target x86_64-unknown-linux-gnu; cargo test --workspace --all-features (272 passed); cargo test -p jxr --no-default-features (24 passed); RUSTDOCFLAGS="-D warnings" cargo doc --workspace --all-features --no-deps; fuzz-package formatting and Clippy; cargo test -p jxr-mpsgraph --test metal -- --ignored --test-threads=1 (7 passed); T.834/T.835 conformance Metal 517/517 and CPU 517/517; jxr-diff --backend metal on the 13 large generated images, all identical. On the current main tree (with #12–#18) with this change, the workspace tests (278) and mpsgraph Metal tests (8) also passed, and T.834 Metal and CPU were 517/517 and the 13 large-image diffs identical on the M4 Pro, an M4, and an M5 Max. The owner-only metal-hardware workflow has not been run on this revision.

Entropy decoding is most of a decode on both routes, and the HP
coefficient loop is about 60% of it. That loop called each per-symbol
VLC helper out of line, so every symbol reloaded and stored the bit
reader's fields, returned its Result through memory, and re-checked
its table indices. The helpers were 21% of main-thread samples on a
2048x2048 RGB q8 image. decode_index, decode_abs_level,
decode_first_index, decode_run_cached, and read_bits are now forced
inline, and the bit-serial run decoder they fall back to near a packet
end is cold and out of line. The out-of-line cache refill and the
end-of-packet error constructor take and return plain values instead
of the reader, so calling them no longer forces it to memory. Decoding
logic, error variants, and error positions are unchanged.

Single-image entropy decoding is 1.04-1.13x faster, and batch tile
preparation 1.06-1.29x faster, on an M4 Pro, an M4, and an M5 Max.
@jcwal1516
jcwal1516 merged commit 2101c86 into main Oct 19, 2026
2 checks passed
@jcwal1516
jcwal1516 deleted the perf/entropy-inline-vlc branch October 19, 2026 03:01
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