Files
ersatztv/docs/decisions/records/ffmpeg/qsv-extra-hw-frames-floor.md
T
timothy fba5233caf
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 16s
PR Gates / decisions lifecycle (pull_request) Failing after 23s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m17s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m29s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m5s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 16m5s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 17m6s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
feat(610): split the decision corpus into one YAML-frontmatter file per record
168 records -> docs/decisions/records/<area>/<topic>.md (163 active, 23 dirs) and
docs/decisions/archive/<area>/<topic>.md (5 archived). The filename IS the key,
so one-active-record-per-key becomes a filesystem property rather than a
validator check, and supersession becomes a `git mv`.

WHY: the monolith was a concurrency problem before an aesthetic one. A
3,900-line append target made parallel sessions collide -- PR #605 and PR #614
both hit append-vs-append conflicts during routine rebases, and hand-resolving
those inside the corpus is exactly the operation the rationale-rewrite guard
exists to police.

HOW IT IS VERIFIED: a ~170-file diff cannot be meaningfully read, so correctness
does not rest on reading it. The parser was taught BOTH formats first, so the
body-diff guard parses the old form at the merge-base and the new form at head --
the migration validates itself, no bypass. The proof is a field-level equivalence
harness: 168 records before and after, zero lost, zero gained, zero field
mismatches, zero rationale bodies differing. Reviewers should scrutinise the
harness; it is the actual evidence.

What measuring caught that reading would not have:

- ~500 lines sit OUTSIDE any record -- decisions.md's lifecycle schema and each
  topic file's preamble, mostly the only copy. Source files are kept and
  stripped, never deleted. They also cannot be filed per-area: topic files hold
  several areas and 4 of 23 areas span several files.
- Archive discovery was a non-recursive glob; after the split it found ZERO
  archived records, surfacing as four bogus "supersedes points to unknown key"
  errors rather than an obvious failure.
- ~32 live docs point into the corpus BY DATE, which the split dangles. Each
  stripped file now ends with a generated "Records formerly in this file" index,
  which also rescues the identical breadcrumbs in old issue comments.
- decisions.md's "In this file:" list was 97 same-file anchor bullets that the
  split makes WRONG, not merely stale. Dropped; the generated index replaces
  them with links that resolve.

The equivalence harness now runs against a checked-in FIXTURE, not the live
corpus. The earlier version migrated the real tree, which made it a one-shot:
the moment the migration landed there was nothing left to move and the tests
failed for reasons unrelated to the code. A fixture keeps them testing the
SCRIPT rather than the repo's current state.

Keys preserved verbatim, warts included: `sched` (12) and `scheduling` (1) remain
two directories for one concept. Renaming a key is not a move -- it changes
identity, breaks the equivalence proof, and invalidates MemPalace's per-key
drawers. Taxonomy normalisation is separate work.

refs #610
2026-07-25 19:45:09 +02:00

5.6 KiB

key, title, status, since, supersedes, superseded-by, rule, signals, mechanics
key title status since supersedes superseded-by rule signals mechanics
ffmpeg.qsv-extra-hw-frames-floor 2026-07-21 — QSV hardware-frame headroom is a floor, not an operator preference (#529) active 2026-07-21 none none a QSV upload never emits `extra_hw_frames` below `FFmpegState.MinimumQsvExtraHardwareFrames` (64); a stored `0` or negative value is treated as "no pool configured" rather than honored literally, because with no headroom any unthrottled read exhausts the pool and the transcode writes nothing at all. QSV, extra_hw_frames, hwupload, hardware frame pool, ENOMEM, "Could not open encoder before EOF", readrate as an incidental allocation bound · paths: `FFmpegState`, `QsvPipelineBuilder`, `HardwareUploadFilter`, `ScaleQsvFilter`, `DeinterlaceQsvFilter` · issues: #529, #350, #516, #519 `FFmpegState.MinimumQsvExtraHardwareFrames`; `FFmpegState.QsvExtraHardwareFrames`; `QsvPipelineBuilder` logs once when a configured value is raised
  • extra_hw_frames=0 is not a valid pool size; it is a dead channel waiting for an unthrottled read. FFmpegState.QsvExtraHardwareFrames honored a stored 0 literally, so hwupload=extra_hw_frames=0 reached FFmpeg with no headroom for frames in flight through the filter graph. Measured against the deployed FFmpeg 8.1.2 on one real logged command (software mpeg4 decode → hwuploadvpp_qsvh264_qsv): the graph fails with -12 (Cannot allocate memory), h264_qsv reports "Could not open encoder before EOF", and zero segments are written. It now clamps to MinimumQsvExtraHardwareFrames (64), which is also the value IfNone already used for an unset profile and the seeded profile default.

  • Input throttling was the only thing hiding it, which is why this looked like a #350 regression. Truth table, same command, same binary, only the marked tokens differing:

    readrate extra_hw_frames result
    1.05, no burst 0 14 segments, exit 0
    1.05 + burst 2 / 4 / 8 0 ENOMEM, 0 segments
    no readrate at all 0 ENOMEM, 0 segments
    1.05 + burst 8 64 14 segments, exit 0
    no readrate at all 64 14 segments, exit 0

    So the defect predates #350's burst: any work-ahead start (which takes no -readrate) on a pipeline that uploads to QSV was already failing on a profile with 0. The burst did not introduce it — it removed the throttle on every realtime session, converting an intermittent failure into a near-deterministic one, which is how it finally got noticed.

  • -readrate was doing load-bearing work nobody had written down. Its stated job is live-TV pacing; it was also incidentally bounding how fast decoded frames enter the filter graph. This is why #350's FFmpeg-level benchmark and #516's argument-generation tests were both green and neither could see it: the burst is bounded in seconds of input, which is not a bound on memory or hardware surfaces. Corrects the #350 entry above, which records the burst as bounded and safe and does not mention hardware frame pools.

  • A floor, not a clamp-to-default — and the floor is wider than the evidence. Values above 64 are honored unchanged; values below it are raised. We measured only 0 (fails) and 64 (works), so 1..63 are untested, not known-bad: we raise them rather than trust them, because the failure they risk is a channel that serves nothing at all. That is a deliberate over-reach, and it is not free — extra_hw_frames allocates additional surfaces (64 NV12 1080p surfaces ≈ 190 MiB, ≈760 MiB at 4K), so an operator who deliberately set a small pool on a memory-constrained iGPU silently gets a larger one. QsvPipelineBuilder therefore logs a warning naming both the configured and the applied value, so the override is discoverable rather than silent. If a smaller pool is ever measured safe, lower the floor rather than removing it.

  • Fixed at FFmpegState.QsvExtraHardwareFrames, the single point every QSV upload site reads. HardwareUploadFilter, HardwareUploadQsvFilter and WatermarkHardwareUploadFilter read it directly; ScaleQsvFilter and DeinterlaceQsvFilter take values QsvPipelineBuilder passes down from it. One guard covers them all rather than five call sites that can drift apart. (A sixth formatter, SubtitleScaleQsvFilter, also emits extra_hw_frames but is currently dead code — no construction site exists in the solution — so it is not covered by this guard and would need the same value threading if it is ever revived.)

Accepted residual: the floor is applied at render time only, so a stored 0 keeps displaying as 0 in the SPA and over GET /api/v1/ffmpeg/profiles/{id} while FFmpeg receives 64 — the config no longer literally describes the behavior. Render-time was chosen deliberately: it fixes every existing deployment with no DB edit and no migration, which matters because this bug is already failing transcodes in production. New and updated profiles are normalized on save so stored rows converge on the truth, and the warning log closes the discoverability gap for rows that predate it; a backfill migration for old rows was judged not worth the dual-provider cost. Two consequences to know about: the save-time normalization is unconditional on hardwareAcceleration, so a non-QSV profile's stored value moves too (harmless — only the QSV path ever reads it — but it is a stored-state change on a field the user didn't touch); and a machine client that PUTs 0 gets a 200 and then reads back 64, which is a silent transform of a submitted value that the OpenAPI description does not advertise.