refactor(pf-encode): WP4 — one split policy, shared with the libav path

The libav NVENC path carried its own inline copy of the split decision and had
already drifted from the direct-SDK selector: it hard-coded a 2-way split
regardless of engine count, and had no depth rule at all. That is the drift the
shared resolver was extracted to prevent, and the copy quietly reintroduced it.

Routing it through `resolve_split_mode` needed the policy to MOVE. `nvenc_core`
is gated on `feature = "nvenc"`, but the libav path is precisely the build where
that feature is OFF (`PUNKTFUNK_NVENC_DIRECT=0`, and the featureless packages --
the packaging gap this project has been bitten by before). So
resolve_split_mode / max_forced_split_mode / clamp_to_engines, plus a new
`forced_split_width`, now live in `codec.rs`, which is always compiled and
already owned SPLIT_FORCE_PIXEL_RATE.

That means the NV_ENC_SPLIT_ENCODE_MODE values had to be hand-written as plain
constants, since the SDK enum does not exist without the feature. They are
therefore pinned: `nvenc_split_constants_match_the_sdk` (feature-gated, the only
place both are visible at once) asserts all five against the real enum, so the
copies cannot rot.

⚠ Only the FORCED outcomes are actionable on the libav side -- libavcodec's
`split_encode_mode` AVOption is its own vocabulary and our DISABLE is the NVENC
enum's 15, which would be meaningless there. DISABLE/AUTO both map to "leave the
option unset", which is exactly today's behaviour (unset = the driver's auto).
`engines = 0` ("not probed") maps to 2-way, preserving what that site always did;
a 3-NVENC part gets the wider split only on the direct-SDK path, which is the one
that actually probes.

⚠⚠ VERIFICATION GAP: .133 went down mid-change (no ping), so the WINDOWS leg is
UNVERIFIED. This matters more than usual -- the Windows backend imported
resolve_split_mode from nvenc_core and that import had to move too, which a grep
caught rather than a compiler. Re-run before trusting it:
  cargo clippy -p pf-encode --features nvenc --all-targets -- -D warnings

Verified .21: clippy -D warnings clean BOTH with and without the nvenc feature
(the featureless build is the whole point of the move) and with
nvenc,vulkan-encode; 65 unit tests incl. the new constant-parity test; 25/25
NVENC on-hardware; punktfunk-host clippy clean. fmt clean.
This commit is contained in:
2026-08-07 08:16:35 +02:00
parent 1062aa780f
commit 01294e3a53
5 changed files with 222 additions and 150 deletions
+27 -15
View File
@@ -476,13 +476,22 @@ impl NvencEncoder {
opts.set("profile", "main10");
}
// Split-frame encode across both NVENC engines (GB203 has 2) when the pixel rate exceeds
// a single engine's HEVC capacity; e.g. 5120x1440@240 = 1.77 Gpix/s needs it, @120
// (0.88 Gpix/s) does not. HEVC/AV1 only (not H.264). AUTO won't engage below ~2112px
// height, so we force `2`; below the threshold we leave it AUTO (split costs ~2% BD-rate).
// Threshold shared with the direct-SDK selector ([`super::SPLIT_FORCE_PIXEL_RATE`] — set
// so 4K120 = 995.3 Mpix/s forces, which `> 1e9` famously missed by 0.47%). Output is
// standard HEVC — transparent to the client. Override with PUNKTFUNK_SPLIT_ENCODE.
// Split-frame encode across the GPU's NVENC engines. WP4: the policy is no longer
// duplicated here — it comes from the SAME [`resolve_split_mode`] the two direct-SDK
// backends use, so the pixel-rate threshold, the codec scoping and the (dropped) 10-bit
// short circuit cannot drift between the libav path and the rest. This copy had already
// diverged: it hard-coded a 2-way split regardless of engine count and carried no depth
// rule at all.
//
// ⚠ Only the FORCED outcomes are actionable here. libavcodec's `split_encode_mode`
// AVOption is its own vocabulary, and our `DISABLE` is the NVENC enum's `15` — passing
// that through would be meaningless to it (or fail the open). `DISABLE`/`AUTO` therefore
// both mean "leave the option unset", which is exactly today's behaviour: unset = the
// driver's own auto.
//
// ⚠ `engines = 0` = "not probed": the libav path has no caps probe of its own, and
// [`max_forced_split_mode`] maps unknown to 2-way, preserving what this site always did.
// A 3-NVENC part gets the wider split only on the direct-SDK path.
let pix_rate = width as u64 * height as u64 * fps as u64;
let split = std::env::var("PUNKTFUNK_SPLIT_ENCODE").ok();
match split.as_deref() {
@@ -497,14 +506,17 @@ impl NvencEncoder {
"PUNKTFUNK_SPLIT_ENCODE ignored — split encoding is not applicable to H.264 \
(nvEncodeAPI.h)"
),
None if matches!(codec, Codec::H265 | Codec::Av1)
&& pix_rate >= super::SPLIT_FORCE_PIXEL_RATE =>
{
opts.set("split_encode_mode", "2");
tracing::info!(
pix_rate,
"NVENC: forcing 2-way split encode (high pixel rate)"
);
None if matches!(codec, Codec::H265 | Codec::Av1) => {
let resolved = super::resolve_split_mode(codec, bit_depth, pix_rate, 0);
if let Some(n) = super::forced_split_width(resolved) {
opts.set("split_encode_mode", &n.to_string());
tracing::info!(
pix_rate,
bit_depth,
split_encode_mode = n,
"NVENC (libav): forcing split encode (shared selector)"
);
}
}
None => {}
}
+5 -5
View File
@@ -68,12 +68,12 @@
use super::nvenc_core::{
apply_low_latency_config, build_init_params, cached_ceiling, cached_split_verdict, codec_guid,
max_forced_split_mode, plan_range_recovery, resolve_slices, resolve_split_mode,
resolve_split_subframe, resolve_subframe, store_ceiling, store_split_verdict,
subframe_env_forced, ArbAction, CeilingKey, LowLatencyConfig, NvStatusExt, RangePlan,
SplitArbiter, SplitKey,
plan_range_recovery, resolve_slices, resolve_split_subframe, resolve_subframe, store_ceiling,
store_split_verdict, subframe_env_forced, ArbAction, CeilingKey, LowLatencyConfig, NvStatusExt,
RangePlan, SplitArbiter, SplitKey,
};
use super::nvenc_status;
use super::{max_forced_split_mode, resolve_split_mode};
use super::{AuChunk, ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps};
use anyhow::{anyhow, bail, Context, Result};
use pf_frame::{CapturedFrame, FramePayload};
@@ -826,7 +826,7 @@ pub struct NvencCudaEncoder {
/// `NV_ENC_CAPS_NUM_ENCODER_ENGINES` — how many NVENC engines this GPU has, probed in
/// [`query_caps`]. `0` = not probed / unreadable. The split-encode ceiling: the driver accepts
/// a split wider than the hardware and silently encodes narrower, so this is the only honest
/// source for how wide we may go (see `nvenc_core::max_forced_split_mode`).
/// source for how wide we may go (see `codec::max_forced_split_mode`).
encoder_engines: u32,
/// Submit stamp for the split arbiter's per-frame cost (sync depth-1 path only).
last_submit_at: Option<std::time::Instant>,