Skip to content

perf(recording): optimize the Windows D3D11 frame-conversion path - #2999

Open
Addin wants to merge 18 commits into
CapSoftware:mainfrom
Addin:perf/d3d11-converter-frame-path
Open

Addin wants to merge 18 commits into
CapSoftware:mainfrom
Addin:perf/d3d11-converter-frame-path

Conversation

@Addin

@Addin Addin commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Optimizes the Windows D3D11 frame-conversion hot path and adds the instrumentation used to verify it.

  • Reuse cached video-processor input/output views and staging textures instead of recreating them per frame.
  • Share one D3D11 converter/device across pool workers on Windows, with a staging ring sized for four workers.
  • Poll Map with D3D11_MAP_FLAG_DO_NOT_WAIT and release the shared context between retries so other workers can submit GPU work.
  • Keep one pair of staging textures for standalone converters and grow lazily up to the four-pair cap.
  • Add benchmark controls (BENCH_INPUT, BENCH_WORKERS, BENCH_SINGLE_ONLY, BENCH_POOL_ONLY, BENCH_D3D11_ONLY) and report the selected adapter.
  • Request D3D11_CREATE_DEVICE_VIDEO_SUPPORT on capture devices.
  • Instrument the Media Foundation encoder loop and the Windows M4S readback/encode stages, and report readback misses against the frames that actually attempted a readback.

Performance comparison

GTX 950M / i7-6700HQ, YUYV422 -> NV12, release build. Before is 8a71a02c; after is e8333db6, the last converter commit before the readback Flush correctness fix. Medians of three four-worker runs and two single-thread runs.

Workload Before After Improvement Speedup
Single-thread, 720p 2.23 ms 2.13 ms 4.2% 1.04x
Single-thread, 1080p 4.29 ms 3.88 ms 9.6% 1.11x
4-worker shared converter, 720p 2.30 ms 1.11 ms 51.8% 2.07x
4-worker shared converter, 1080p 4.48 ms 2.05 ms 54.3% 2.19x
4-worker from_config pool, 720p 1.07 ms 1.09 ms ~2% (noise) 0.98x
4-worker from_config pool, 1080p 1.99 ms 2.04 ms ~2% (noise) 0.98x

from_config is the production camera pool. At maximum submitted throughput it is within run-to-run noise of the old four-device configuration while using one D3D11 device instead of four; at the 30 fps camera rate the pool does not contend, so the single-thread latency and contention rows are the relevant gains. Without the readback polling changes, sharing the converter would have been about 2x slower.

Additional after-only measurements:

  • Real camera 640x480 @ 30 fps: conversion 1.65 ms avg / 2.08 ms P99, encode 0.84 ms, 29.6 fps, zero drops
  • Screen readback > 5 ms: 1/251 frames; encoder > 5 ms: 2.4%; no drops
  • DO_NOT_WAIT polling at maximum throughput uses 148% of one core (18.5% of the 8-thread machine); at the 30 fps production rate the workers idle between frames, so no backoff was added

The MF hardware encoder still falls back on this GPU: NVIDIA MFT returns 0x8000FFFF and Intel QSV returns E_INVALIDARG. The FFmpeg fallback remains.

Greptile review fixes

  • frame-converter: flush the immediate context after queuing the output copy and before the DO_NOT_WAIT polling loop, so standalone conversions cannot spin on work that was never submitted.
  • capture_pipeline: build the WARP fallback without D3D11_CREATE_DEVICE_VIDEO_SUPPORT.
  • win_fragmented_m4s: after draining, return the Media Foundation encoder error so a failed encoder cannot be finalized as a successful recording.
  • scap-ffmpeg (base perf(recording): cut Windows readback copies and encode from textures #2940): create the mapped readback AVBufferRef with AV_BUFFER_FLAG_READONLY.

Dependency

Stacked on #2940 (readback/MF groundwork). Until that merges, this diff includes its readback/MF commits. If #2940 lands with a merge commit, GitHub will reduce this PR to the converter/capture commits automatically; if it is squash-merged, this branch will need a rebase onto the new main.

Validation

  • cargo fmt --all -- --check
  • cargo clippy -p cap-frame-converter -p cap-enc-mediafoundation -p cap-recording --all-targets -- -D warnings
  • cargo test -p cap-recording --lib: 686 passed, 0 failed, 4 ignored
  • cap-frame-converter and cap-enc-mediafoundation tests pass
  • Release d3d11-benchmark rerun after the lazy-staging change; numbers above are unchanged from the eager-allocation baseline

RetriggerView in GreptileConfidence Score: 4/5

The texture-input path should preserve output errors before this PR merges.

Findings

  1. P1 Lost samples look successful ▶
Fix with agent prompt
### Issue 1
crates/recording/src/output_pipeline/win_fragmented_m4s.rs:867-878
When `CAP_MF_TEXTURE_INPUT` is enabled, this callback drops failed packet conversions and failed `write_packet` calls but returns `Ok(())`. The worker can then finish successfully even though video samples were lost. `SegmentedPacketMuxer::finish` does not remember earlier write errors, so a later successful trailer cannot catch them.

Save the first output error and return it after draining, or propagate it through `encoder.run`.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Optimizes Windows frame conversion and adds the stacked readback and Media Foundation work.

  • Windows conversion workers reuse D3D11 resources and share a converter.
  • Windows capture frames reach FFmpeg without an extra pixel copy.
  • Windows fragmented recordings can encode capture textures into DASH segments.

Reviews (4) · Last reviewed commit: "fix(recording): report MF encoder failur..." · Reviewed by Greptile

@vercel

vercel Bot commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

@Addin is attempting to deploy a commit to the Cap Software Inc Team on Vercel.

A member of the Team first needs to authorize it.

Comment thread crates/frame-converter/src/d3d11.rs Resolved
Comment thread crates/scap-ffmpeg/src/direct3d.rs Resolved
@Addin
Addin force-pushed the perf/d3d11-converter-frame-path branch from ff0a562 to 29ea30e Compare October 23, 2026 16:18
@Addin

Addin commented Oct 23, 2026

Copy link
Copy Markdown
Contributor Author

@greptileai review

@Addin

Addin commented Oct 23, 2026

Copy link
Copy Markdown
Contributor Author

@greptileai review

@Addin

Addin commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

Hi @richiemcilroy can I have a review on this, or should this be closed?

@richiemcilroy

Copy link
Copy Markdown
Member

Thanks! Can you rebase on main to clear the conflicts? Then we'll take a look.

Addin added 5 commits November 4, 2026 21:25
Read Windows capture back through a staging ring with DO_NOT_WAIT instead of a blocking Map plus a second full-frame copy, and let the fragmented/instant M4S muxer encode from the captured D3D11 texture through Media Foundation with the existing FFmpeg encoder as fallback.

Also removes the unused win_segmented muxers and the never-invoked DiskSpaceCallback plumbing.
- Keep the readback budget authoritative by default; CAP_READBACK_BLOCKING opts into an unbounded Map.
- Negotiate MFT types inside the candidate loop and handle WARP without VIDEO_SUPPORT.
- Validate harness --path values and count failures only for exercised paths.
- Evict idle stale-size staging textures before reporting StagingBusy.
- Size the benchmark copy from each mapped frame and rebuild old_frame on resize.
- Require METransformDrainComplete and return errors on failed or timed-out drains.
- Count drained samples in the encoder health output counters.
- Keep benchmark runs alive across capture idle timeouts.
- Block in Map by default and keep the readback deadline behind CAP_READBACK_BUDGET_MS.
- Gate the Media Foundation texture path to opt-in Instant recordings; Studio and future recordings fall back to FFmpeg.
- Output BT.709 limited-range video from the MF path and tag the DASH muxer with BT.709 metadata.
- Send END_OF_STREAM before DRAIN and treat a slow drain as non-fatal.
- Preserve the existing MFT selection and keyframe behavior for the MP4/camera encoders.
@Addin
Addin force-pushed the perf/d3d11-converter-frame-path branch from 6341fe6 to 6557c8d Compare November 4, 2026 20:33
@Addin

Addin commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto the rebased #2940 branch, which is now on current main (3ca736247), and force-pushed. No conflicts on the converter commits.

Local cargo fmt --all -- --check and Windows checks for cap-recording and cap-frame-converter --all-targets pass. Still stacked on #2940 as described in the PR body.

@greptileai review
@richiemcilroy could you take a look when you have a moment?

Comment on lines +867 to +878
if let Err(error) =
muxer.write_packet(packet, cap_mediafoundation_ffmpeg::MF_TIMEBASE)
{
failed_writes += 1;
if failed_writes == 1 || failed_writes.is_multiple_of(300) {
warn!(
failed_writes,
"Failed to write Media Foundation sample: {error}"
);
}
}
Ok(())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Lost samples look successful

When CAP_MF_TEXTURE_INPUT is enabled, this callback drops failed packet conversions and failed write_packet calls but returns Ok(()). The worker can then finish successfully even though video samples were lost. SegmentedPacketMuxer::finish does not remember earlier write errors, so a later successful trailer cannot catch them.

Save the first output error and return it after draining, or propagate it through encoder.run.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/recording/src/output_pipeline/win_fragmented_m4s.rs
Line: 867-878

Comment:
**Lost samples look successful**

When `CAP_MF_TEXTURE_INPUT` is enabled, this callback drops failed packet conversions and failed `write_packet` calls but returns `Ok(())`. The worker can then finish successfully even though video samples were lost. `SegmentedPacketMuxer::finish` does not remember earlier write errors, so a later successful trailer cannot catch them.

Save the first output error and return it after draining, or propagate it through `encoder.run`.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 615e65a and rebased #2999 onto it (new head 5cb3e12).

The Media Foundation sample callback now propagates packet-conversion, mutex-poison, and write_packet errors through encoder.run instead of warning and returning Ok(()). The existing failure path then disables the texture path, emits MuxerCrashed, finalizes the segments already written, and uses FFmpeg for the next recording, so a lost sample can no longer be reported as a successful encode.

@greptileai review

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed—the fix addresses the finding. Packet-conversion, muxer-lock, and write_packet failures now propagate through H264Encoder::run; its error path disables future texture input, emits MuxerCrashed, drains the video queue, and returns the error. The Media Foundation backend still finalizes the segments already written, so these lost samples are no longer reported as a successful encode. No further finding on this change.

@Addin

Addin commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

The only red check is the macOS Instant crash, cancel and recovery (real app) job. It is a timeout in real_app_recovered_recording_uploads, not an app crash.

From the sandbox artifact:

  • The app finalized a valid recording with 2 M4S segments; no panic or crash was logged.
  • The runner delivered 65 frames over 120s (~0.5 fps), and libx264 logged 2 I-frames, so only 2 segments existed before the 120s auto-stop. The other recordings in the same job also captured at roughly 1 fps (6 frames in 6s).
  • The hang sample shows the main thread idle in NSApplication run.
  • This PR does not touch the macOS capture path, and main passed the same test in the latest run (https://github.com/CapSoftware/Cap/actions/runs/41774708873).

@richiemcilroy could you rerun the failed job? The runner appears to have been delivering ~1 fps instead of the expected 30 fps.

@Addin

Addin commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

One more flaky check: linux / test 3/3 failed in cap-verify::export turbo (it failed, then passed when rerun; the harness still marks it failed, and the summary says no failing case was produced). This PR only changes Windows D3D11/Media Foundation code, so that export flake is unrelated. A rerun of that job should clear it.

Addin added 10 commits November 4, 2026 22:09
Cache the video processor input/output views at converter creation, replace the single staging pair with a reusable ring, and keep CPU upload/readback copies outside the GPU mutex. Share one D3D11 converter across pool workers so camera recording uses one device instead of one per worker.

Collapse contiguous plane copies into a single memcpy and add a benchmark example that compares the D3D11 path with single-threaded swscale.
Allow selecting the input format, worker counts, and single-thread, pool-only, or D3D11-only runs so the benchmark can isolate transfer, conversion, and concurrency costs.
Map the staging output with DO_NOT_WAIT and release the shared GPU mutex between retries so other camera workers can submit their blits and copies while one readback completes. The 4-worker 1080p pool drops from about 3.6 ms/frame to 2.2 ms/frame on the GTX 950M.
Four staging slots match the default camera pool so workers spend less time waiting for an upload/readback pair; deeper rings showed no additional gain.
Media Foundation hardware encoders need a video-capable D3D11 device to attach their DXGI device manager. Without it the texture-native screen encoder falls back to FFmpeg and the full-frame readback path. Keep the display-notch tests below the platform helpers so clippy accepts the module layout.
Time the Media Foundation encoder wait/input/process/output stages, the native camera upload/mux stages, and the screen D3D11 readback itself instead of including pacer waits in the conversion metric. Also make the camera benchmark run on Windows and honor an externally supplied RUST_LOG.
@Addin
Addin force-pushed the perf/d3d11-converter-frame-path branch from 6557c8d to 5cb3e12 Compare November 4, 2026 21:11

This branch has not been deployed

No deployments
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