Skip to content

rh264: decoder performance series (~1.2x, -18.7% instructions) - #19307

Merged
LibretroAdmin merged 9 commits into
libretro:masterfrom
LibretroAdmin:audit-h264
Jul 30, 2026
Merged

rh264: decoder performance series (~1.2x, -18.7% instructions)#19307
LibretroAdmin merged 9 commits into
libretro:masterfrom
LibretroAdmin:audit-h264

Conversation

@LibretroAdmin

@LibretroAdmin LibretroAdmin commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Description

Nine commits continuing the rh264 audit, all performance work — no behaviour change. Every commit is bit-exact against reference decoders and passed a full sanitizer sweep before landing; the per-commit messages carry the detailed rationale and measurements.

Measured effect of the whole series

End-to-end A/B of the branch base against its tip, same binary flags, same machine, same session, best of five runs. 300-frame x264-encoded streams, single thread, x86_64, -O2 -march=native:

Stream Before After
CAVLC I/P 480p 265 fps 321 fps 1.21×
CABAC I/P 480p 199 fps 237 fps 1.19×
CABAC+B main 480p 201 fps 243 fps 1.21×
High profile 480p 197 fps 236 fps 1.20×
CABAC+B main 720p 111 fps 131 fps 1.18×
CRF 8, coefficient-dense 176 fps 195 fps 1.11×

Instruction count (callgrind, deterministic, 60-frame CABAC+B stream, -O2): −18.7%.

Two commits are wall-clock-neutral on this desktop and were landed for their instruction and memory-traffic reductions, which should matter more on the narrow in-order cores and bandwidth-limited handhelds RetroArch also ships to. Those commits say so explicitly rather than claiming a speedup.

What the commits do

Entropy decoding

  • 38fdb3f — branchless CABAC engine. The LPS/MPS decision is the decoded bin, so as a branch it mispredicts at the bin entropy; it becomes an all-ones mask selecting range, offset, bin and state. pStateIdx/valMPS fuse into one byte with a per-instance 2×128 transition table built from the spec tables.
  • af96580 — significance/last context derivation settled once per block instead of a four-deep test chain per coefficient. Honest result: −0.6%, wall clock in the noise; the compiler was already hoisting most of it.

Prediction

  • 111af54 — motion compensation runs directly off the reference plane when the interpolation window is in-bounds, instead of gathering a (bw+5)×(bh+5) patch per call whose only purpose is baking in the edge clamp. Border blocks still take the patch.
  • d8d66e3 — weighted bi-prediction sets up its weight/bias/shift vectors once per block rather than once per row.

Reconstruction

  • 22d5887 — 4×4 dequantisation and the inverse transform vectorised (SSE2/NEON). SSE2 lacks a 32-bit low multiply before SSE4.1, so the even/odd 32×32→64 products are combined.
  • 53afaff — twelve separate per-sample add-and-clip loops (intra/inter, luma/chroma, CAVLC/CABAC, 4×4/8×8) collapse into one row-at-a-time kernel where saturating narrowing performs the clip for free. Largest single win in the series at −6.0% instructions.

Deblocking and output

  • 88607af — P/B chroma deblocking vectorised across the whole edge, with per-lane strength and tc0 vectors so the four differing 4-sample segments are handled in one pass.
  • f4bb40f — chroma vertical edges skip the store-side transpose, since only p0/q0 are written.
  • 961ecb7 — frame pictures swap plane buffers into the output slot instead of copying a full frame per displayed picture. Field pairs keep the copy.

Validation

Every commit was verified bit-exact against libavcodec on six streams (CAVLC I/P, CABAC I/P, CABAC+B main, high profile, 720p B-frame, CRF 8) and against openh264 on left/top/bottom-cropped vectors, in both the SIMD build and a forced-scalar build so the fallback paths are covered too.

Per commit: ASan + UBSan across all streams plus 350–700 mutated/truncated/spliced inputs, LeakSanitizer in strict mode including the decode-error paths, and TSan with four concurrent decoder instances on both entropy modes. C89 verified with C89_BUILD=1.

New SIMD kernels were additionally differential-tested against their scalar references in isolation before integration — 200k random multiplies, 500k random transform blocks, 400k random residual blocks. That caught three lane-ordering and saturation faults that every conformance stream would have passed, including one where adding in 16-bit lanes wrapped on out-of-range residuals from corrupt input and would have made the SIMD and scalar builds disagree.

Notes for review

The riskiest commit is 111af54: aliasing onto the reference plane means the SIMD tails read a few bytes past the filter margin, so the direct path requires the window to sit strictly above the last row and bottom-edge blocks fall back to the gathered patch. Worth a close look, and it is the commit the corrupt-vector fuzz corpus was aimed at.

961ecb7 changes buffer ownership rather than arithmetic — the working planes and the output slot exchange buffers. The argument for safety is that the DPB already holds the reference copy, every macroblock of the next picture rewrites the working planes, and the displayed slot is excluded from selection while borrowed.

Related Issues

None filed; these are follow-ups from the decoder audit rather than reported bugs.

Related Pull Requests

Continues #19304 (correctness and first-round performance work) on the same branch. Self-contained in libretro-common/formats/h264/rh264.c.

libretroadmin added 9 commits July 30, 2026 06:24
The P/B deblock walked chroma edges one sample at a time through the
scalar rh264_filter_chroma_edge - the vectorised edge filter existed
(the all-intra path uses it) but takes one strength and tc for the
whole run, and the P/B derivation gives every 4-sample luma segment
its own, so that path was left scalar.  On a CABAC+B stream the
per-sample walk was 4% of decoded instructions.

New per-segment kernels take the strength class and tc0 as per-lane
vectors: both filter branches are computed vector-wide and blended
per lane, inactive lanes keeping their samples, so one pass covers
the whole chroma edge of a macroblock regardless of how the four
segments differ.  Lane k of n belongs to segment k*4/n, which also
covers 4:2:2's doubled rows on vertical edges.  The two P/B call
sites hoist the per-plane alpha/beta/tc derivation out of the
segment loop and issue one call per edge per plane; SSE2 and NEON
kernels with the same scalar fallback shape as the existing edge
filters (verified bit-exact in a forced-scalar build as well).

Instruction count (callgrind, 60-frame CABAC+B stream, -O2): -2.7%.
Decode fps, 300 frames, single thread, x86_64 -O2 -march=native,
best of four, same-session A/B:
  CAVLC I/P 480p:  345 -> 362  (1.05x)
  CABAC I/P 480p:  225 -> 238  (1.06x)
  CABAC+B 720p:    132 -> 134
Output verified bit-exact against ffmpeg on CAVLC I/P, CABAC I/P,
CABAC+B main, high-profile and 720p B-frame streams, and against
openh264 on the left/top/bottom-cropped vectors, in both SIMD and
forced-scalar builds.
Sanitizer sweep: ASan+UBSan clean over the seven streams and 500
mutated/truncated inputs with plane emission active; LeakSanitizer
strict clean including decode-error paths; TSan clean with four
concurrent decoder instances on CAVLC and CABAC+B streams.
The LPS/MPS decision in rh264_cabac_decode is the decoded bin itself,
so as a conditional branch it mispredicts at exactly the bin entropy;
bypass bins (signs, exp-golomb suffixes) are near-uniform and pay the
full misprediction rate.  The engine also kept pStateIdx and valMPS in
separate arrays with a separate valMPS-flip branch at state 0, and
refilled its bit buffer one byte at a time.

The decision is now taken as an all-ones mask - both outcomes run the
same straight-line code, with offset, range, bin and the state update
selected by the mask.  pStateIdx and valMPS fuse into one byte
(pStateIdx * 2 | valMPS) and both transitions come from a 2x128 table
built per instance at context init from rh264_transIdxMPS/LPS, the
valMPS flip at state 0 folded into the LPS row (9.3.3.2.1.1), so the
spec tables stay the single source of truth.  The renormalisation
refill tops the buffer up past 16 bits so consecutive
renormalisations share one refill; bytes past the end still read as
zero.

Decode fps, 300 frames, single thread, x86_64 -O2 -march=native,
best of four, same-session A/B:
  CABAC I/P 480p:  230 -> 236  (1.03x)
  CABAC+B 480p:    233 -> 239  (1.03x)
  CABAC+B 720p:    131 -> 135  (1.03x)
  CAVLC I/P:       unchanged
Output verified bit-exact against ffmpeg on CAVLC I/P, CABAC I/P,
CABAC+B main, high-profile and 720p B-frame streams, and against
openh264 on the left/top-cropped vectors.
Sanitizer sweep: ASan+UBSan clean over all five streams and 400
mutated/truncated inputs; LeakSanitizer strict clean; TSan clean with
four concurrent decoder instances on CAVLC and CABAC+B streams.
Every luma MC call gathered a (bw+5)x(bh+5) patch and every chroma
call a (bw+1)x(bh+1) one - a memcpy per call whose only purpose is
baking the picture-edge clamp into a dense buffer, paid even though
the overwhelming majority of interpolation windows lie entirely
inside the picture.  Chroma even routed full-pel copies through it.

The filters index the source through a pointer and a stride, so the
in-bounds case now aliases them straight onto the reference plane and
the patch is gathered only at the borders.  The direct path requires
the window to sit strictly above the last row: the vector tails read
a few bytes past the 6-tap/bilinear margin, and the spare row keeps
those reads inside the plane allocation (bottom-edge blocks take the
patch, as edge blocks always did).

Decode fps, 300 frames, single thread, x86_64 -O2 -march=native,
best of four, same-session A/B:
  CABAC+B 480p:  229 -> 255  (1.11x)
  CABAC+B 720p:  132 -> 143  (1.08x)
  CAVLC I/P 480p: 370 -> 385 (1.04x)
  CABAC I/P 480p: 235 -> 242 (1.03x)
Output verified bit-exact against ffmpeg on CAVLC I/P, CABAC I/P,
CABAC+B main, high-profile and 720p B-frame streams, and against
openh264 on the left/top/bottom-cropped vectors.
Sanitizer sweep: ASan+UBSan clean over the streams and 400 mutated
inputs (corrupt vectors exercise the border fallback); LeakSanitizer
strict clean; TSan clean with four concurrent decoder instances on
CAVLC and CABAC+B streams.
Every displayed picture was plane-copied from the working frame into
its output slot - a full frame of memcpy per picture whose content
already exists, since the reference copy (when the picture is one)
sits in the DPB by the time the output queue takes it.

Frame pictures now swap their plane buffers with the slot instead:
the slot was allocated from the same SPS so geometry and strides are
identical, every macroblock of the next picture rewrites the working
planes, and the slot being displayed is already excluded from
selection, so its buffer is never handed back while borrowed.  The
derived plane views are re-pointed on both sides.  Field pairs keep
the copy - their two pictures fill the working planes incrementally,
which a swapped-in stale buffer would break.

Instruction count (callgrind, 60-frame CABAC+B stream, -O2): -1.5%,
the output-queue share of memcpy traffic eliminated (3.9% -> 2.4%
of decoded instructions).  Wall-clock is within noise on a desktop
whose memory system hides a frame of copy per picture; the traffic
reduction is aimed at the bandwidth-constrained targets this
frontend actually ships to.  Output verified bit-exact against
ffmpeg on CAVLC I/P, CABAC I/P, CABAC+B main, high-profile and 720p
B-frame streams (reorder-heavy paths exercise the swap), and against
openh264 on the left/top/bottom-cropped vectors.
Sanitizer sweep: ASan+UBSan clean over the streams and 350 mutated
inputs with plane emission active; LeakSanitizer strict clean; TSan
clean with four concurrent decoder instances on CAVLC and CABAC+B
streams.
The per-segment chroma filter transposed all four loaded columns back
to rows to store them, but a chroma edge filter only ever writes p0
and q0 - p1 and q1 are read-only taps, so half the stored columns
were unchanged and the second 8x8 transpose existed to put them back
where they already were.

The two filtered columns are now interleaved into per-row byte pairs
(one punpcklbw, or vst2 on NEON) and written two bytes a row, which
drops the store-side transpose entirely.  The load side still
transposes: p1 and q1 are needed as columns to filter.

Instruction count (callgrind, 60-frame CABAC+B stream, -O2): -0.3%.
Output verified bit-exact against ffmpeg on CAVLC I/P, CABAC I/P,
CABAC+B main, high-profile and 720p B-frame streams, and against
openh264 on the left/top-cropped vectors.
Sanitizer sweep: ASan+UBSan clean over all five streams and 350
mutated/truncated inputs including a crop corpus with plane emission
active; LeakSanitizer strict clean; TSan clean with four concurrent
decoder instances on CAVLC and CABAC+B streams.
rh264_dequant4x4 rebuilt the per-coefficient scale from two table
lookups and a multiply on every one of sixteen coefficients, and
re-tested both the has_dc_sep and the per>=4 conditions per
coefficient, though all three are invariant across a block.
rh264_itransform4x4 was a scalar butterfly.  Together they were 8.8%
of decoded instructions on a CABAC+B stream.

The scale vector is now built once per block, the shift decision taken
once, and the body is a 4-lane multiply-and-shift; has_dc_sep restores
blk[0] after the pass rather than branching per lane.  The transform
transposes into lanes and runs the same butterfly twice - rows, then
columns - leaving the vectors holding rows to store, so two transposes
serve both passes.  SSE2 and NEON, with the original scalar code as
the fallback (verified bit-exact in a forced-scalar build).  SSE2 has
no 32-bit low multiply before SSE4.1, so the even and odd 32x32->64
products are combined instead.

Instruction count (callgrind, 60-frame CABAC+B stream, -O2): -4.3%;
dequantisation 6.3% -> 3.1% and the transform 2.5% -> 1.6% of decoded
instructions.  Wall clock gains about 2% on a coefficient-dense
(CRF 8) stream and is inside run-to-run variance elsewhere on a wide
out-of-order desktop core, which absorbed the scalar dependency
chains; the instruction reduction is aimed at the narrow in-order
cores this frontend also ships to.
Output verified bit-exact against ffmpeg on CAVLC I/P, CABAC I/P,
CABAC+B main, high-profile and 720p B-frame streams, and against
openh264 on the left/top/bottom-cropped vectors, in both SIMD and
forced-scalar builds.  The two new intrinsic helpers were
differentially tested against scalar references in isolation (200k
random multiplies, 500k random transform blocks) after each showed a
lane-ordering fault in first-pass integration.
Sanitizer sweep: ASan+UBSan clean over six streams and 450 mutated
inputs including a coefficient-dense corpus; LeakSanitizer strict
clean; TSan clean with four concurrent decoder instances on CAVLC and
CABAC+B streams.
Twelve places - intra and inter, luma and chroma, CAVLC and CABAC, 4x4
and 8x8 - each ended with their own nested per-sample loop adding the
rounded residual to the prediction and clipping, a byte load and store
per sample.  They were 6% of decoded instructions between them and
identical apart from the plane stride and the block size.

They now share rh264_add_residual, which works a row at a time:
unpack the predicted bytes into lanes, add, and narrow with saturation,
which performs the clip for free.  The sum is formed in 32-bit lanes
and saturated 32->16 then 16->8 rather than added in 16-bit lanes;
the shorter form wraps on the out-of-range residuals a corrupt stream
can produce, which would make the vector and scalar paths disagree.
SSE2 and NEON, with the original loop as the scalar fallback.

Also drops a dead clear of the per-block coefficient array in the
CABAC P residual path: it is only read under nz, and the scan scatter
writes all sixteen entries from an array rh264_cabac_residual has
already zero-filled.

Instruction count (callgrind, 60-frame CABAC+B stream, -O2): -6.0%.
Decode fps, 300 frames, single thread, x86_64 -O2 -march=native,
best of four, same-session A/B:
  CAVLC I/P 480p:  440 -> 474  (1.08x)
  CABAC+B 480p:    286 -> 315  (1.10x)
  CABAC I/P 480p:  279 -> 294  (1.05x)
  CRF 8 (dense):   260 -> 279  (1.07x)
  CABAC+B 720p:    179 -> 183  (1.02x)
Output verified bit-exact against ffmpeg on CAVLC I/P, CABAC I/P,
CABAC+B main, high-profile, 720p B-frame and CRF 8 streams, and
against openh264 on the left/top/bottom-cropped vectors, in both SIMD
and forced-scalar builds.  The kernel was differentially tested
against the scalar reference in isolation over 400k random blocks
spanning both block sizes, unaligned strides and residuals well past
the clipping range, which is what caught the 16-bit wrap.
Sanitizer sweep: ASan+UBSan clean over eight streams and 550 mutated
inputs; LeakSanitizer strict clean; TSan clean with four concurrent
decoder instances on CAVLC and CABAC+B streams.
The significance and last-flag context derivation ran a four-deep
chain of tests per coefficient - category, field flag, 4:2:2 flag -
though all of those are fixed for the whole block and only the
coefficient index varies.  It is now settled once before the scan: a
ctxIdxInc map pointer and a context base, with identity and chroma-DC
maps added alongside the existing 8x8 ones so every category reduces
to a table load.

This is a small win, not a large one: -0.6% of decoded instructions
(callgrind, 60-frame CABAC+B stream), with wall clock inside
run-to-run variance on a desktop core - the compiler was already
hoisting most of the invariant tests, and what remains of
rh264_cabac_decode's cost is the arithmetic engine's serial
dependency rather than context selection.  It is committed for the
instruction reduction and because the scan loop is now three lines
instead of twenty.
Output verified bit-exact against ffmpeg on CAVLC I/P, CABAC I/P,
CABAC+B main, high-profile, 720p B-frame and CRF 8 streams, and
against openh264 on the left/top-cropped vectors, in both SIMD and
forced-scalar builds.
Sanitizer sweep: ASan+UBSan clean over seven streams and 550 mutated
inputs weighted to CABAC and 4:2:2/high-profile paths; LeakSanitizer
strict clean; TSan clean with four concurrent decoder instances on
CAVLC and CABAC+B streams.
Bi-predicted blocks were averaged a row at a time by a helper that
built its weight, bias, shift and offset vectors on entry, so a 16-row
block paid sixteen identical setups and the four call sites each drove
it from their own row loop.  The weights are constant across a block.

The helper becomes a block-level one that sets up once and walks the
rows itself, taking source and destination strides; the four call
sites lose their loops.  The per-row helper is folded in rather than
kept as a fallback, so the SSE2, NEON and plain-C paths all hoist
alike and no dead per-row version is left behind.

Instruction count (callgrind, 60-frame CABAC+B stream, -O2): -2.5%.
Decode fps, 300 frames, single thread, x86_64 -O2 -march=native,
best of five, same-session A/B: 720p CABAC+B 172 -> 176 (1.02x),
480p CABAC+B unchanged.
Output verified bit-exact against ffmpeg on CAVLC I/P, CABAC I/P,
CABAC+B main, high-profile, 720p B-frame and CRF 8 streams, and
against openh264 on the left/top/bottom-cropped vectors, in both SIMD
and forced-scalar builds.
Sanitizer sweep: ASan+UBSan clean over seven streams and 580 mutated
inputs weighted to the B-frame paths this touches; LeakSanitizer
strict clean; TSan clean with four concurrent decoder instances on
CAVLC and CABAC+B streams.
@LibretroAdmin LibretroAdmin changed the title rh264: vectorise P/B chroma deblocking across the whole edge rh264: decoder performance series (~1.2x, -18.7% instructions) Jul 30, 2026
@LibretroAdmin
LibretroAdmin merged commit 526bd4c into libretro:master Jul 30, 2026
52 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