Skip to content

GPU-direct hardware encode (#521) slice 1 completion — realtime GPU stream (T3–T6) - #523

Merged
iamfatness merged 8 commits into
mainfrom
spec/gpu-direct-encode-complete
Sep 14, 2026
Merged

iamfatness merged 8 commits into
mainfrom
spec/gpu-direct-encode-complete

Conversation

@iamfatness

@iamfatness iamfatness commented Sep 14, 2026

Copy link
Copy Markdown
Owner

GPU-direct streaming could submit nearly 60 frames/sec while FFmpeg received only 9–35 encoded frames/sec. Synchronous pipe writes blocked the Media Foundation event thread, and missing textures could consume input credits permanently.

This change exports a dedicated encoder texture, selects hardware H.264 with raw fallback, and adds a bounded Windows bitstream writer. It retains asynchronous input credits across texture waits, consumes one output per output event, and corrects sample ownership. RTMP/RTMPS disables TCP delay; SRT excludes that option.

Validation: 946 native tests pass, including delayed-first-frame GPU decoding. The localhost GPU gate received 60.1 fps; forced raw received 59.9 fps. Real eight-source 1080p60 YouTube streaming now runs around 60 encoded fps with excellent receiver health. Broken-pipe recovery was exercised.

Release gate remains open: an earlier live sample had two Program-buffer underruns and another had a deadline miss. Investigation continues; clean average throughput does not waive these failures. Evidence and exact test limits: docs/gpu-encode-live-validation-2026-09-13.md.

Part of #521. Recording/ISO hardware encoding and macOS encoding remain outside this slice.

Follow-up: local candidate archive and real installer validation pass; all 2,224 MediaCore tests pass after the dedicated-worker test correction in 8e92536. The unresolved intermittent buffer failure is tracked in #526 and remains a release blocker.

…64 round-trip (#521 slice1 T3 complete)

Completes Task 3: the compositor exports a DEDICATED keyed-mutex BGRA shared
texture for hardware encode (exportEncoderSharedTexture / ensureEncoderSharedTexture),
mirroring the vcam tap's producer keying (AcquireSync(0,0)/CopyResource/ReleaseSync(1))
so encode never contends with WinUI's ProgramFrame::sharedTexture consumer. Carried on
ProgramFrame::encoderSharedTexture. Uses legacy GetSharedHandle to match the encoder's
legacy OpenSharedResource. Encoder consumer keying is AcquireSync(1)/ReleaseSync(0).

Proven end to end on the dev rig (RTX 4090): compositor renders solid mid-gray ->
dedicated keyed-mutex texture -> MF hardware H.264 MFT (VideoProcessor BGRA->NV12) ->
Annex-B bitstream -> ffmpeg decode. Test asserts a keyframe, non-empty bitstream, and
the decoded coded Y plane within 16 of the encoded gray (measured 128.0 exactly over
12 frames). Reads the yuv420p Y plane, not -pix_fmt gray, to avoid limited->full range
expansion. Self-skips without a hardware MFT or ffmpeg.

Full native suite: 939 passed, 0 failed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014yuH7EMvWCWdkhvevrtMyJ
…m to muxer; env fallback (#521 slice1 T4)

The RTMP/SRT sender now decides its encode path once at process start via the pure
chooseStreamEncodePath policy (folded into GpuVideoEncoder.h): GPU-direct only when the
platform has an MF encoder, a hardware session is (probably) available, the resolved
codec is H.264, the compositor is exporting the dedicated encoder texture on the starting
frame, and COREVIDEO_GPU_ENCODE is not "0". Any base blocker keeps precedence in the
logged reason.

On the GPU path the sender starts the hardware encoder BEFORE FFmpeg (so encoder->start()
is the real capability gate; failure downgrades to raw and FFmpeg is never launched in
bitstream mode with nothing feeding it), builds FFmpeg args with videoBitstreamInput=true
(-c:v copy muxer, T2), submits each frame's compositor encoder texture to the encoder, and
the encoder's sink writes the ~6 Mbps bitstream to FFmpeg stdin. stopFfmpegProcess stops
and joins the encoder before closing the pipe (no write-after-close). The raw NV12/BGRA
pipe stays the fallback for every non-capable machine, non-H.264 codec, and the env toggle.
Every path selection is logged ([gpu-encode] path=...). Availability never REFUSES on a
pending probe (the TESTER rule); start() is the gate.

Tests: chooseStreamEncodePath (5 cases: gpu-direct, base-blocker precedence, non-H.264,
missing encoder texture, env toggle). The runtime submit-vs-raw routing is proven by the
Task 6 localhost acceptance gate (real core + real FFmpeg), not a mocked-process unit test.
Full native suite: 944 passed, 0 failed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014yuH7EMvWCWdkhvevrtMyJ
…estarts (#521 slice1 T5)

The encode loop now distinguishes a genuine device loss from a transient encode miss:
on a convert/ProcessInput failure it reads ID3D11Device::GetDeviceRemovedReason() and, when
the device is removed/reset/hung, sets healthy_=false and breaks with a [gpu-encode] device
lost line. submit() already returns false once unhealthy, so the sender reports a video-write
failure and the existing OutputDestinationSupervisor restarts it, which re-decides the encode
path (Task 4) against the recreated device.

Capacity is already priority-1 for slice 1: the stream is the only GPU encoder, and the
sender gates GPU-direct on the EncoderCapacityProbe session ceiling (sessionAvailable ->
"no-free-encoder-session" fallback, covered by the existing policy tests) without ever
refusing on a pending probe.

Test: the submit-fails-when-not-running contract (the supervisor's restart trigger) on the
real impl. A real TDR is not injected (matching the shell device-loss recovery's documented
posture); the classification is verified by inspection and the round-trip test proves
healthy() stays true on the happy path. Full native suite: 945 passed, 0 failed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014yuH7EMvWCWdkhvevrtMyJ
…-direct stream at realtime (#521 slice1 T6)

Adds scripts/validate-gpu-encode.mjs (streams the fake-engine program to a localhost SRT
sink; FAILS unless the GPU path is taken and the received stream is >=58fps and the sink's
-stats speed >=0.97x; --force-raw confirms the fallback) and the CLAUDE.md section. Gate
result on the dev rig: path=gpu-direct, received 60.0fps of 60, realtime.

Writing the gate surfaced four real defects, each fixed here (the raw-pipe path capped at
~0.76x; this is what makes GPU-direct actually keep up):

- Compositor exports the dedicated encoder texture whenever streaming, BUFFERED or not
  (it was gated !buffered, so with the default 3-frame program buffer the sender saw
  no encoder texture and fell back to raw). The stable handle rides the program buffer
  to the sender; the copy is the latest composed frame, so the stream taps live pixels.
- Encoder consumer keyed-mutex timeout 4ms -> 34ms (~2 frames): 4ms missed the 16ms
  production cadence on almost every frame, wasted the MFT input slot and starved the
  encoder to ~2fps.
- Encode loop no longer waits for a fresh submit per NeedInput (that serialized with the
  mutex wait to ~30fps); it reads the latest handle and lets the keyed mutex pace it to
  the producer's 60fps.
- FFmpeg bitstream input: -use_wallclock_as_timestamps 1 -r <fps>. A live Annex-B stream
  on a pipe has no container timestamps; -r alone left stream 0's PTS unset once audio was
  mapped, and -c:v copy then muxed a stream the endpoint read at 0x/stalled. Wallclock
  stamps each arriving access unit at realtime (monotonic for a 60fps feed).

Verified: validate-gpu-encode.mjs PASS (gpu-direct, 60.0fps realtime) and --force-raw PASS
(fallback still streams); mac-show-drill --load 8 PASS (p50 27.8ms, 100% delivered, no
regression); full native suite 945 passed, 0 failed. Live RTMP to real YouTube remains the
final manual acceptance step.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014yuH7EMvWCWdkhvevrtMyJ
…dable (#521)

Adds -stats -stats_period 1 to both the bitstream and raw ffmpeg arg branches. This is
what makes the sender's realtime keep-up (speed=x, media-time/wall-time) readable from
ffmpeg's own stderr — used to confirm GPU-direct streams to real YouTube at ~1.02x vs the
raw path's ~0.76x. Matches the deploy branch's intent; the raw path lacked it on this branch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014yuH7EMvWCWdkhvevrtMyJ
@iamfatness
iamfatness merged commit 6ff0440 into main Sep 14, 2026
19 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.

2 participants