Skip to content

perf: encode ProRes MOV exports across all cores (4x faster) - #2528

Merged
richiemcilroy merged 5 commits into
CapSoftware:mainfrom
Aqua-123:perf/prores-frame-threading
Oct 19, 2026
Merged

richiemcilroy merged 5 commits into
CapSoftware:mainfrom
Aqua-123:perf/prores-frame-threading

Conversation

@Aqua-123

@Aqua-123 Aqua-123 commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Summary

MOV (ProRes 4444) export from the GPUI editor ran on a single CPU core. This PR makes it use all cores. On an Apple M5, 1080p30 export is 4.1× faster and 4K30 export is 3.7× faster. The encoded bitstream is unchanged.

MOV export (synthetic testsrc2, M5, release) before after speedup
1080p30, 10 s clip, median of 5 runs 13.2 fps 54.3 fps 4.1×
4K30, 5 s clip, median of 3 runs 3.2 fps 11.8 fps 3.7×

Root cause

ProResEncoderBuilder called set_threading(Config::count(n)). In ffmpeg-next, Config::count keeps kind: Type::None, so thread_type is written as 0. For prores_ks, libavcodec treats thread_type == 0 as "no threading" and resets thread_count to 1. A profile of the old build showed one thread spending about 95% of its samples in libavcodec and swscale while the other cores were idle.

The other Config::count call sites (libx264, libx265, AAC, Opus) are not affected. libx264/libx265 manage their own threads from thread_count (FF_CODEC_CAP_AUTO_THREADS), and the audio encoders don't thread.

Change

  • prores.rs turns on frame threading for prores_ks. Slice threading was measured too. It gave no speedup.
  • Frame threading keeps a reference to every sent frame until a worker encodes it. The old code reused one converted_frame_pool frame, so the next swscale call would overwrite a frame that was still being encoded. The pool now reuses a converted frame only when av_frame_is_writable says the encoder has released it, and allocates a new one otherwise. Without this check the output was corrupted. That was caught by comparing packet hashes and is covered by the new test.
  • Frame threading only applies when frames go through the converter. The pass-through path, where the caller owns the frame, keeps the old single-threaded behaviour.

Output is unchanged

  • Bitstream. The per-packet MD5s (ffmpeg -i out.mov -map 0:v -c copy -f framemd5 -) of a 90-frame 1080p export match exactly before and after.
  • New test. mux::mov::tests::every_queued_frame_is_encoded_with_its_own_pixels encodes 24 differently shaded frames through MOVFile and checks that each one decodes to its own shade. It fails 3/3 when the pool reuses frames without the writability check, and passes with this change.

Trade-off: peak memory

Frame threading keeps roughly threads + 2 frames in flight, so peak RSS rises with core count and resolution. On the 10-core M5, 1080p went from 247 MB to 704 MB and 4K from 747 MB to 2.37 GB. A 4K thread sweep showed fps scaling all the way to 10 threads at about 180 MB per thread, so lowering the thread count trades speed for memory roughly linearly. The PR keeps available_parallelism(), which is what the code already asked for. Happy to add a cap if you'd prefer a lower memory ceiling.

Benchmark

crates/export/examples/mov-export-benchmark.rs builds a synthetic studio project and times full MovExportSettings::export runs. MOV_BENCH_KEEP=<path> keeps the last output for hash comparison.

cargo run --release -p cap-export --example mov-export-benchmark -- --runs 5 --duration 10
cargo run --release -p cap-export --example mov-export-benchmark -- --width 3840 --height 2160 --runs 3 --duration 5

Checks

  • cargo test --release -p cap-enc-ffmpeg -p cap-export: 152 and 40 tests pass.
  • cargo clippy --release -p cap-enc-ffmpeg -p cap-export --all-targets --no-deps -- -D warnings: clean.
  • cargo fmt --all -- --check: clean.

🤖 Generated with Claude Code

RetriggerView in GreptileConfidence Score: 5/5

The PR appears safe to merge, with no new blocking issues found.

Summary

The PR enables parallel ProRes encoding while protecting frames that workers still use. The follow-up limits threads by physical RAM and output size.

  • Converted ProRes MOV exports now use several CPU cores.

Reviews (5) · Last reviewed commit: "perf(enc-ffmpeg): size ProRes frame thre..." · Reviewed by Greptile

Config::count leaves thread_type at 0, so prores_ks never threaded and MOV
export ran on one core. Use frame threading, and convert each frame into a
pooled buffer the encoder no longer references, since frame threads hold
the sent frame until a worker encodes it.

1080p30: 13.2 -> 54.3 fps. 4K30: 3.2 -> 11.8 fps. Encoded packets are
byte-identical to the single-threaded encoder.
@vercel

vercel Bot commented Oct 13, 2026

Copy link
Copy Markdown
Contributor

@Aqua-123 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 on lines +145 to +147
let mut mov = MOVFile::init(path.clone(), |output| {
ProResEncoder::builder(info).build(output)
})

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.

P2 Test can miss threaded reuse

If the host reports only one CPU, this test uses one encoder thread and never exercises the delayed frame reuse that this PR fixes. It can pass even if unsafe reuse returns. Let the test request at least two encoder threads so it checks that failure on every host.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/enc-ffmpeg/src/mux/mov.rs
Line: 145-147

Comment:
**Test can miss threaded reuse**

If the host reports only one CPU, this test uses one encoder thread and never exercises the delayed frame reuse that this PR fixes. It can pass even if unsafe reuse returns. Let the test request at least two encoder threads so it checks that failure on every host.

---

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

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

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.

Valid in theory, but every CI runner and supported Mac has ≥2 cores, and on those the test fails 3/3 against the unsafe reuse. Forcing a thread count would mean adding an encoder option only for the test, so leaving it as is.

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.

That’s fair. Given the supported environments and CI runners all have at least two cores, and the regression reproduces reliably there, the test exercises the threaded reuse path without adding a test-only encoder option. I’ll withdraw this finding.

Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.

Comment on lines +106 to +109
if let Ok(keep) = std::env::var("MOV_BENCH_KEEP") {
fs::copy(&path, keep).ok();
}
fs::remove_file(&path).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.

P2 Failed copies lose output

MOV_BENCH_KEEP silently ignores a failed copy, then deletes the exported file. A missing destination directory or unwritable path leaves no retained output and no error, which blocks the requested hash comparison. Report the copy failure instead of continuing as if the file was kept.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/export/examples/mov-export-benchmark.rs
Line: 106-109

Comment:
**Failed copies lose output**

`MOV_BENCH_KEEP` silently ignores a failed copy, then deletes the exported file. A missing destination directory or unwritable path leaves no retained output and no error, which blocks the requested hash comparison. Report the copy failure instead of continuing as if the file was kept.

---

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

Comment on lines +22 to +23
#[arg(long, default_value = "5")]
runs: usize,

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.

P2 Zero runs crashes

--runs 0 is accepted, but leaves fps_samples empty and panics when printing the median. Reject zero runs during argument parsing, before generating the test video.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/export/examples/mov-export-benchmark.rs
Line: 22-23

Comment:
**Zero runs crashes**

`--runs 0` is accepted, but leaves `fps_samples` empty and panics when printing the median. Reject zero runs during argument parsing, before generating the test video.

---

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

@richiemcilroy

Copy link
Copy Markdown
Member

Thanks for the PR. Greptile currently has this at 4/5, and we only merge PRs once Greptile scores the current head 5/5. Please address the remaining Greptile feedback, then comment @greptileai to request a re-review, and repeat until it reaches 5/5.

@Aqua-123

Copy link
Copy Markdown
Contributor Author

@greptileai Addressed the review on 975ac5b: --runs 0 is now rejected at argument parsing, and a failed MOV_BENCH_KEEP copy now panics with the reason instead of silently deleting the output. The single-CPU test finding was withdrawn in its thread. Please re-review the current head.

@Aqua-123

Copy link
Copy Markdown
Contributor Author

@richiemcilroy 5/5 on greptile now

@richiemcilroy

Copy link
Copy Markdown
Member

Measured this on an M4 Max (16 cores) with real Studio recordings, main vs this branch, cap export --format mov --fps 60:

4K ProRes, 1243 frames Wall Peak footprint
main 377 s 1.18 GB
this branch (16 frame threads) 51.6 s 3.50 GB
capped at 8 threads 52.4 s 2.44 GB
capped at 4 threads 83.9 s 1.78 GB

Every variant decodes to the same frames as main (framemd5 identical, 1080p and 4K). The 1080p export goes 102.6 s to 12.8 s.

Past 8 threads the export no longer gets faster, but each extra thread holds about 130 MB at 4K, so I pushed e9bfb4d capping frame threads at 8. That keeps the full speedup and takes 1 GB off the 4K peak.

@richiemcilroy

Copy link
Copy Markdown
Member

@greptileai review

1 similar comment
@richiemcilroy

Copy link
Copy Markdown
Member

@greptileai review

@richiemcilroy

Copy link
Copy Markdown
Member

Pushed a follow-up: the frame thread count now also respects RAM (threads may use at most 1/16 of physical memory, at about 16 bytes per output pixel each). An 8 GB Mac exporting 4K gets 4 threads instead of 8; 16 GB and up is unchanged at 8. Unknown memory falls back to 1 thread (main's behaviour). Tests cover the hostile sizes too (0x0, 8K on 1 GB, u32::MAX).

@greptileai review

@richiemcilroy richiemcilroy left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Verified: frame-identical output vs main, CI and engine/real-capture suites green, Greptile 5/5. Thanks!

@richiemcilroy
richiemcilroy merged commit a1a8a1b into CapSoftware:main Oct 19, 2026
46 of 48 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