fix(pf-encode): arbitrate NVENC split-encode vs sub-frame readback (Phase 8)

Verified against nvEncodeAPI.h's own splitEncodeMode doc (user-prompted
— the audit's 'exclusive for HEVC' one-liner deserved checking):
- H.264: split 'is not applicable' — hard-DISABLE the mode so the
  written config, CeilingKey, the split diagnostic log and the
  rejection-retry stay truthful (the retry used to re-open a
  byte-identical session after an H.264 'split rejection'). The libav
  path's operator arm gains the codec gate its auto arm always had.
- HEVC: split 'not supported if … subframe mode' — when WE force split
  (TWO/THREE/AUTO_FORCED, the 4K120 throughput lever), sub-frame yields
  with a logged escape (PUNKTFUNK_SPLIT_ENCODE=0 chooses sub-frame).
  ⚠ Keyed on FORCED modes only, never != DISABLE: AUTO(0) is the
  resolver's fallthrough for every sub-950Mpix session, and the wider
  key would have disarmed the Phase-3 chunked-poll feature fleet-wide
  (critic catch). Under AUTO the driver arbitrates — the shipped state.
- AV1: untouched — per-tile sub-frame + split are legal together.

The arbitration is a pure nvenc_core fn called by each backend BEFORE
the ladder, the ceiling key and the chunked-poll latch — all three see
the post-arbitration truth. A drop inside build_init_params would have
left poll_chunk busy-polling its whole budget every AU (numSlices stays
0 without reportSliceOffsets; both loop exits dead — critic catch).
Linux latches subframe_forced beside subframe_on at query_caps (no env
re-reads after open); Windows records the arbitrated state so
reconfigure presents exactly the params the open had (also closes the
pre-existing mid-session env-flip hazard there).

Truth-table tests in nvenc_core; PUNKTFUNK_NVENC_SUBFRAME documented
(it never was); PUNKTFUNK_SPLIT_ENCODE row updated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-26 10:08:44 +02:00
co-authored by Claude Opus 5
parent 25765c53ec
commit 1cefd37603
5 changed files with 222 additions and 17 deletions
+18 -2
View File
@@ -62,8 +62,8 @@
use super::nvenc_core::{
apply_low_latency_config, build_init_params, cached_ceiling, codec_guid, plan_range_recovery,
resolve_slices, resolve_split_mode, resolve_subframe, store_ceiling, CeilingKey,
LowLatencyConfig, NvStatusExt, RangePlan,
resolve_slices, resolve_split_mode, resolve_split_subframe, resolve_subframe, store_ceiling,
subframe_env_forced, CeilingKey, LowLatencyConfig, NvStatusExt, RangePlan,
};
use super::nvenc_status;
use super::{AuChunk, ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps};
@@ -629,6 +629,10 @@ pub struct NvencCudaEncoder {
/// [`subframe_cap`](Self::subframe_cap)); consumed by every `build_init_params` call so the
/// open and the in-place reconfigure present identical init params.
subframe_on: bool,
/// Whether `PUNKTFUNK_NVENC_SUBFRAME=1` was EXPLICITLY forced — latched with `subframe_on`
/// (same invariant: open and reconfigure must present identical init params, so no env
/// re-reads after `query_caps`). Only consumed by [`resolve_split_subframe`]'s log severity.
subframe_forced: bool,
/// Sub-frame chunked poll armed for the live session (§7 LN1 Phase 1): multi-slice +
/// sub-frame readback configured AND sync retrieve at init. See [`Encoder::poll_chunk`].
subframe_chunks: bool,
@@ -721,6 +725,7 @@ impl NvencCudaEncoder {
slices: 1,
subframe_cap: false,
subframe_on: false,
subframe_forced: false,
subframe_chunks: false,
chunk: None,
})
@@ -921,6 +926,7 @@ impl NvencCudaEncoder {
// escapes.
self.slices = resolve_slices(self.codec, 4);
self.subframe_on = resolve_subframe(self.subframe_cap);
self.subframe_forced = subframe_env_forced();
tracing::info!(
rfi = self.rfi_supported,
custom_vbv = self.custom_vbv,
@@ -1136,6 +1142,16 @@ impl NvencCudaEncoder {
// [`resolve_split_mode`] for the precedence (env override / 10-bit / pixel rate).
let pixel_rate = self.width as u64 * self.height as u64 * self.fps.max(1) as u64;
let split_mode: u32 = resolve_split_mode(self.bit_depth, pixel_rate);
// Split × sub-frame arbitration (Phase 8) BEFORE the ladder, the ceiling key and the
// chunked-poll latch — all three must see the post-arbitration truth (a drop inside
// build_init_params would leave poll_chunk busy-polling its whole budget per AU).
let (split_mode, subframe_on) = resolve_split_subframe(
self.codec,
split_mode,
self.subframe_on,
self.subframe_forced,
);
self.subframe_on = subframe_on;
const CLAMP_TOL_BPS: u64 = 20_000_000;
// Ceiling cache (process lifetime, `nvenc_core`): a prior clamp search already found