HEVC died at 720p and 1080p because we sized the DPB from the level's ceiling, not the stream's need #96

Merged
enricobuehler merged 1 commits from fix/hevc-dpb-level-ceiling into main 2026-08-07 16:21:35 +00:00
Owner

What happened

A client streaming HEVC from .21 (RTX 5070 Ti) refused every access unit with stream needs 17 DPB slots, device caps at 16, flushed, waited for an IRAP, got a fresh IDR that needed 17 too, exhausted the decode ladder and reconnected with HEVC excluded. On a build with no software HEVC decoder — there is no permissively licensed one — that is not a slower path, it is losing the codec.

It reproduced on the first try at 1080p, with no preceding AV1 session, so the in-place codec-switch reconfigure hypothesis is out. So is RFI_DPB: it is 5, nowhere near 17.

The trigger

I dumped the bitstream (PUNKTFUNK_DUMP_VIDEO) and read the SPS the host actually emitted. The host is blameless. At every resolution it signals general_level_idc = 153 (L5.1 High, which NVENC autoselects at hevcConfig.level = 0 because a 130 Mbps target does not fit L5.0's 100 Mbps ceiling) and sps_max_dec_pic_buffering_minus1 = 5six pictures, RFI_DPB references plus the current one. That is already the minimum it can honestly declare.

dpb_limit was reading equation A-2 instead. A-2 is a ceiling on what an SPS may signal — 7.4.3.2.1 constrains sps_max_dec_pic_buffering_minus1 to 0..=MaxDpbSize-1 — not a statement of what a stream needs, and it branches on picture size against the level's MaxLumaPs. max(A-2, buffering) then reported 16 where the stream asked for 6, backends added one slot for the picture in flight, and 17 is one more than NVIDIA's maxDpbSlots.

A resolution sweep drew A-2's branch table exactly — and it is the two commonest streaming resolutions that lost the codec:

coded luma samples A-2 branch frames slots before
720p 1280×720 = 921 600 ≤ MaxLumaPs>>2 16 17 82 refusals, HEVC dropped
1080p 1920×1088 = 2 088 960 ≤ MaxLumaPs>>2 16 17 41 refusals, HEVC dropped
1440p 2560×1440 = 3 686 400 ≤ MaxLumaPs>>1 12 13 clean
4K 3840×2176 = 8 355 840 else 6 7 clean, decode 1.9 ms

One host, one level, one six-picture requirement. Only which branch the picture size landed in decided whether HEVC worked. That is also why it hid: 4K is the resolution this was exercised at, and 4K is the one size that falls through to the honest answer.

Which side, and why not the host

The client. The producer is emitting a minimal, conforming, honest stream; there is nothing to fix there. The only host-side lever is the level, and lowering it below what the bitrate needs signals a bitrate the stream exceeds — trading this interop bug for a worse one against every level-enforcing decoder. Negotiating the client's maxDpbSlots would add a channel with no lever behind it, since the host already emits the minimum.

dpb_limit now returns the stream's own sps_max_dec_pic_buffering_minus1 + 1, capped at 16. That is what the number means: it is exactly the bound C.5.2.2's fullness clause bumps against, and A.4.1 bounds the total RPS entries by the same value, so buffering pictures hold buffering - 1 references plus the current one with nothing left over.

The max() that produced the 16 was written to be generous to malformed streams ("storing their pictures beats erroring the AU") but it never did that either: Dpb::needs_bumping already keys on the signalled buffering, not on max_num_pics, so a stream referencing more than it declared was already being bumped below its own declared depth before every store. The widened limit bought no tolerance at all — it only over-allocated hardware surfaces, by ten pictures per session at 1080p, and on NVIDIA took HEVC away entirely.

RFI is untouched and uncapped at its current value; there are nine slots of headroom.

Measured on .21 (RTX 5070 Ti), same host binary both sides

Before → after, all eight legs, native-vulkan throughout:

before after
HEVC 720p 82 refusals, HEVC dropped 0 refusals, decode 0.5 ms
HEVC 1080p 41 refusals, HEVC dropped 0 refusals, decode 1.3 ms
HEVC 1440p clean 0 refusals, decode 1.1 ms
HEVC 4K clean 0 refusals, decode 2.3 ms @ 61 fps
H.264 ×4 clean 0 refusals (no regression)

This is not a new operating point: it moves 720p and 1080p onto the 7-slot / 6-reference pool shape 4K has been running in the field all along.

Gates

  • cargo fmt --all -- --check, cargo clippy --workspace --all-targets -- -D warnings — clean (clippy caught an int_plus_one in the new test; fixed).
  • cargo test -p pf-bitstream -p pf-vkdecode — 285 pass, including the vendored 25 fps / bear / bbb vectors replayed end to end through the tighter DPB.
  • cargo test -p pf-client-core --lib — 164 pass.
  • cargo test -p pf-vkdecode --test gpu_parity -- --ignored on .21 — 8/8, bit-identical to libavcodec. The H.265 leg now reports slots_held=0/6 where it used to hold 16, with identical output hashes.
  • cargo test -p pf-encode --features nvenc on .21 — 6/6 including the new guard.

Regression tests, both ends

  • pf-bitstream: the field SPS synthesized byte for byte on the fields that matter must plan 6 frames / 7 slots; all four resolutions must agree because the stream does; every depth the envelope gate admits must leave room for the picture in flight. The one honest residue is pinned too and deliberately left refusing — A.4 does let a conforming stream declare a full 16-picture DPB, and 17 slots genuinely do not fit 16, so that stream is still refused rather than decoded with too few slots and silently corrupted references.
  • pf-encode: RFI_DPB + 2 <= 16, guarding the producer end so the depth cannot be raised past what clients can decode.

Note for review

H.264 escaped this only by luck — its own level-derived ceiling happened to land at 13 for 1080p L5.0 and 5 for 4K L5.2, and it is the same shape of derivation. It would fail identically if NVENC ever picked a higher level for a smaller picture (720p at L5.0 already computes 16). I left it alone because H.264's correct DPB size genuinely is level-derived absent a VUI bitstream_restriction, unlike HEVC's — but it is worth knowing it is one level choice away.

.21 restored: client settings back to codec: auto / native resolution, captures and logs deleted, build worktree and target dir removed, the other agent's host drop-in untouched.

## What happened A client streaming HEVC from `.21` (RTX 5070 Ti) refused **every** access unit with `stream needs 17 DPB slots, device caps at 16`, flushed, waited for an IRAP, got a fresh IDR that needed 17 too, exhausted the decode ladder and reconnected with HEVC excluded. On a build with no software HEVC decoder — there is no permissively licensed one — that is not a slower path, it is losing the codec. It reproduced on the first try at 1080p, with no preceding AV1 session, so the in-place codec-switch reconfigure hypothesis is out. So is `RFI_DPB`: it is 5, nowhere near 17. ## The trigger I dumped the bitstream (`PUNKTFUNK_DUMP_VIDEO`) and read the SPS the host actually emitted. **The host is blameless.** At every resolution it signals `general_level_idc = 153` (L5.1 High, which NVENC autoselects at `hevcConfig.level = 0` because a 130 Mbps target does not fit L5.0's 100 Mbps ceiling) and `sps_max_dec_pic_buffering_minus1 = 5` — **six pictures**, `RFI_DPB` references plus the current one. That is already the minimum it can honestly declare. `dpb_limit` was reading **equation A-2** instead. A-2 is a *ceiling on what an SPS may signal* — 7.4.3.2.1 constrains `sps_max_dec_pic_buffering_minus1` to `0..=MaxDpbSize-1` — not a statement of what a stream needs, and it branches on picture size against the **level's** `MaxLumaPs`. `max(A-2, buffering)` then reported 16 where the stream asked for 6, backends added one slot for the picture in flight, and 17 is one more than NVIDIA's `maxDpbSlots`. A resolution sweep drew A-2's branch table exactly — and it is the two commonest streaming resolutions that lost the codec: | | coded luma samples | A-2 branch | frames | slots | before | |---|---|---|---|---|---| | 720p | 1280×720 = 921 600 | `≤ MaxLumaPs>>2` | 16 | 17 | **82 refusals, HEVC dropped** | | 1080p | 1920×1088 = 2 088 960 | `≤ MaxLumaPs>>2` | 16 | 17 | **41 refusals, HEVC dropped** | | 1440p | 2560×1440 = 3 686 400 | `≤ MaxLumaPs>>1` | 12 | 13 | clean | | 4K | 3840×2176 = 8 355 840 | `else` | 6 | 7 | clean, decode 1.9 ms | One host, one level, one six-picture requirement. Only which branch the picture size landed in decided whether HEVC worked. That is also why it hid: 4K is the resolution this was exercised at, and 4K is the one size that falls through to the honest answer. ## Which side, and why not the host **The client.** The producer is emitting a minimal, conforming, honest stream; there is nothing to fix there. The only host-side lever is the level, and lowering it below what the bitrate needs signals a bitrate the stream exceeds — trading this interop bug for a worse one against every level-enforcing decoder. Negotiating the client's `maxDpbSlots` would add a channel with no lever behind it, since the host already emits the minimum. `dpb_limit` now returns the stream's own `sps_max_dec_pic_buffering_minus1 + 1`, capped at 16. That is what the number means: it is exactly the bound C.5.2.2's fullness clause bumps against, and A.4.1 bounds the total RPS entries by the same value, so `buffering` pictures hold `buffering - 1` references plus the current one with nothing left over. The `max()` that produced the 16 was written to be generous to malformed streams ("storing their pictures beats erroring the AU") **but it never did that either**: `Dpb::needs_bumping` already keys on the signalled buffering, not on `max_num_pics`, so a stream referencing more than it declared was already being bumped below its own declared depth before every store. The widened limit bought no tolerance at all — it only over-allocated hardware surfaces, by ten pictures per session at 1080p, and on NVIDIA took HEVC away entirely. RFI is untouched and uncapped at its current value; there are nine slots of headroom. ## Measured on `.21` (RTX 5070 Ti), same host binary both sides Before → after, all eight legs, `native-vulkan` throughout: | | before | after | |---|---|---| | HEVC 720p | 82 refusals, **HEVC dropped** | 0 refusals, decode **0.5 ms** | | HEVC 1080p | 41 refusals, **HEVC dropped** | 0 refusals, decode **1.3 ms** | | HEVC 1440p | clean | 0 refusals, decode 1.1 ms | | HEVC 4K | clean | 0 refusals, decode 2.3 ms @ 61 fps | | H.264 ×4 | clean | 0 refusals (no regression) | This is not a new operating point: it moves 720p and 1080p onto the 7-slot / 6-reference pool shape 4K has been running in the field all along. ## Gates - `cargo fmt --all -- --check`, `cargo clippy --workspace --all-targets -- -D warnings` — clean (clippy caught an `int_plus_one` in the new test; fixed). - `cargo test -p pf-bitstream -p pf-vkdecode` — 285 pass, including the vendored 25 fps / bear / bbb vectors replayed end to end through the tighter DPB. - `cargo test -p pf-client-core --lib` — 164 pass. - **`cargo test -p pf-vkdecode --test gpu_parity -- --ignored` on `.21` — 8/8, bit-identical to libavcodec.** The H.265 leg now reports `slots_held=0/6` where it used to hold 16, with identical output hashes. - `cargo test -p pf-encode --features nvenc` on `.21` — 6/6 including the new guard. ## Regression tests, both ends - **`pf-bitstream`**: the field SPS synthesized byte for byte on the fields that matter must plan 6 frames / 7 slots; all four resolutions must agree because the stream does; every depth the envelope gate admits must leave room for the picture in flight. The one honest residue is pinned too and **deliberately left refusing** — A.4 does let a conforming stream declare a full 16-picture DPB, and 17 slots genuinely do not fit 16, so that stream is still refused rather than decoded with too few slots and silently corrupted references. - **`pf-encode`**: `RFI_DPB + 2 <= 16`, guarding the producer end so the depth cannot be raised past what clients can decode. ## Note for review H.264 escaped this only by luck — its own level-derived ceiling happened to land at 13 for 1080p L5.0 and 5 for 4K L5.2, and it is the same shape of derivation. It would fail identically if NVENC ever picked a higher level for a smaller picture (720p at L5.0 already computes 16). I left it alone because H.264's correct DPB size genuinely is level-derived absent a VUI `bitstream_restriction`, unlike HEVC's — but it is worth knowing it is one level choice away. `.21` restored: client settings back to `codec: auto` / native resolution, captures and logs deleted, build worktree and target dir removed, the other agent's host drop-in untouched.
enricobuehler added 1 commit 2026-08-07 15:40:38 +00:00
fix(client/hevc): the DPB we demanded was the level's ceiling, not the stream's need
ci / bun-nix (pull_request) Successful in 34s
ci / web (pull_request) Successful in 1m12s
ci / docs-site (pull_request) Successful in 1m12s
apple / swift (pull_request) Successful in 1m32s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m18s
ci / rust-arm64 (pull_request) Successful in 2m30s
android / android (pull_request) Successful in 3m21s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m21s
ci / rust (pull_request) Successful in 5m42s
0ee690f8bc
A punktfunk client streaming HEVC from .21 (RTX 5070 Ti) refused every access
unit with "stream needs 17 DPB slots, device caps at 16", flushed, waited for an
IRAP, got a fresh IDR that needed 17 too, exhausted the decode ladder and
reconnected with HEVC excluded. On a build with no software HEVC decoder — there
is no permissively licensed one — that is not a slower path, it is losing the
codec.

The host was blameless. Reading the SPS it actually emitted: general_level_idc
153 (L5.1 High, which NVENC autoselects at hevcConfig.level = 0 because a
130 Mbps target does not fit L5.0's 100 Mbps ceiling) and
sps_max_dec_pic_buffering_minus1 = 5 — six pictures, RFI_DPB references plus the
current one. Six, at every resolution. That is already the minimum the encoder
can honestly declare, and the only host-side lever, the level, cannot be lowered
without signalling a bitrate the stream exceeds. There was nothing to fix there.

dpb_limit was reading equation A-2 instead. A-2 is a CEILING on what an SPS may
signal — 7.4.3.2.1 constrains sps_max_dec_pic_buffering_minus1 to
0..=MaxDpbSize-1 — not a statement of what a stream needs, and it branches on
picture size against the LEVEL's MaxLumaPs. At 1080p the coded 1920x1088 =
2 088 960 luma samples fall under MaxLumaPs(L5.1) >> 2 = 2 228 224, taking the
first branch for min(4 * MaxDpbPicBuf, 16) = 16. max(A-2, buffering) then
reported 16 where the stream had asked for 6, the backends added one slot for the
picture in flight, and 17 is one more than NVIDIA's maxDpbSlots.

A resolution sweep on the box drew A-2's branch table exactly, and it is the two
commonest streaming resolutions that lost the codec:

  720p  1280x720  = 921 600     branch 1 -> 16 frames, 17 slots  82 refusals, HEVC dropped
  1080p 1920x1088 = 2 088 960   branch 1 -> 16 frames, 17 slots  41 refusals, HEVC dropped
  1440p 2560x1440 = 3 686 400   branch 2 -> 12 frames, 13 slots  clean
  4K    3840x2176 = 8 355 840   else     ->  6 frames,  7 slots  clean, decode 1.9 ms

One host, one level, one six-picture requirement. Only which branch the picture
size landed in decided whether HEVC worked. That is also why this hid for so
long: 4K was the resolution it was exercised at, and 4K is the one size that
falls through to the honest answer. H.264 escaped for an unrelated reason — its
own level-derived ceiling happened to land at 13 for 1080p L5.0 and 5 for 4K
L5.2 — but it is the same shape of derivation and would fail the same way if
NVENC ever picked a higher level for a smaller picture.

So dpb_limit now returns the stream's own sps_max_dec_pic_buffering_minus1 + 1,
capped at 16. That is not a workaround, it is what the number means: it is
exactly the bound C.5.2.2's fullness clause bumps against, and A.4.1 bounds the
total RPS entries by the same value, so `buffering` pictures hold `buffering - 1`
references plus the current one with nothing left over.

The max() that produced the 16 was written to be generous to malformed streams —
"storing their pictures beats erroring the AU" — but it never did that either.
Dpb::needs_bumping (C.5.2.2) already keys on the signalled buffering, not on
max_num_pics, so a stream referencing more pictures than it declared was ALREADY
being bumped below its own declared depth before every store. The widened limit
bought no tolerance at all; all it ever did was over-allocate hardware surfaces,
by ten pictures per session at 1080p, and on NVIDIA take HEVC away entirely.

The fix moves 720p and 1080p onto the pool shape 4K has been running in the field
all along (7 slots, 6 references), so it is not a new operating point — it is the
one already proven. max_active_references drops from 15 to 6, still above the 5
an RFI_DPB stream can name. The per-AU level gate in pf-vkdecode reads
plan.picture.level_idc directly, so dropping A-2 out of NegotiationInfo costs no
sensitivity to a mid-stream level change.

Two regression tests pin the arithmetic from both ends, because either end
drifting back reproduces this:

  - h265: the field SPS synthesized byte for byte on the fields that matter must
    plan 6 frames / 7 slots, all four resolutions must agree because the stream
    does, and every depth the envelope gate admits must leave room for the picture
    in flight. The one honest residue is pinned too and deliberately left
    refusing: A.4 does let a conforming stream declare a full 16-picture DPB, and
    17 slots genuinely do not fit 16, so that stream is still refused rather than
    decoded with too few slots and silently corrupted references.

  - pf-encode: RFI_DPB + 2 <= 16, guarding the producer end. RFI is a real
    latency win and this does not cap it at today's value — there are nine slots
    of headroom — it just stops it being raised past the point where clients can
    no longer decode us at all.
enricobuehler merged commit a8a4b11f5c into main 2026-08-07 16:21:35 +00:00
enricobuehler deleted branch fix/hevc-dpb-level-ceiling 2026-08-07 16:21:42 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#96