feat: encode pipelining with "pushification" of outputs#21
Conversation
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
This PR introduces depth‑N (2) Vulkan video encode pipelining by separating encode submission from bitstream readback, using timeline semaphores to preserve DPB ordering while allowing multiple frames to be in flight.
Changes:
- Add timeline semaphore feature enabling and require timeline semaphores for video encode devices.
- Introduce
encoder::pipelineto manage per-frame encode slots (images, buffers, command buffers, fences, query pools) and deferred readback. - Update H.264/H.265/AV1 encoders and the
verify_allexample to drain packets on later calls and viaflush().
Reviewed changes
Copilot reviewed 17 out of 17 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| src/vulkan.rs | Requires and enables timeline semaphore features for pipelined encode synchronization. |
| src/encoder/resources.rs | Adds timeline semaphore creation, query pool helper, and splits submit vs. readback. |
| src/encoder/pipeline.rs | New depth‑N encode pipeline implementation (slots + timeline ordering + drain/flush). |
| src/encoder/mod.rs | Exposes pipeline module internally and documents pipelined packet return behavior. |
| src/encoder/h265/mod.rs | Switches H.265 encoder resource ownership to EncodePipeline and updates teardown. |
| src/encoder/h265/init.rs | Builds EncodePipeline during H.265 init instead of single-frame resources. |
| src/encoder/h265/encode.rs | Records into per-slot command buffers and submits without waiting. |
| src/encoder/h265/api.rs | Drains prior slot packets, advances pipeline, and implements flush(). |
| src/encoder/h264/mod.rs | Switches H.264 encoder resource ownership to EncodePipeline and updates teardown. |
| src/encoder/h264/init.rs | Builds EncodePipeline during H.264 init instead of single-frame resources. |
| src/encoder/h264/encode.rs | Records into per-slot command buffers and submits without waiting. |
| src/encoder/h264/api.rs | Drains prior slot packets, advances pipeline, and implements flush(). |
| src/encoder/av1/mod.rs | Switches AV1 encoder resource ownership to EncodePipeline and updates teardown. |
| src/encoder/av1/init.rs | Builds EncodePipeline during AV1 init instead of single-frame resources. |
| src/encoder/av1/encode.rs | Records into per-slot command buffers and submits without waiting. |
| src/encoder/av1/api.rs | Drains prior slot packets, advances pipeline, and implements flush(). |
| examples/verify_all.rs | Drains remaining packets at end-of-stream via encoder.flush(). |
Comments suppressed due to low confidence (1)
src/encoder/h264/api.rs:1
- This method’s behavior is now pipelined (it may return 0 packets or packets from earlier submitted frames). The high-level
Encoderdocs were updated, but these codec-specific public docs don’t mention the changed semantics or the need to callflush()at end-of-stream. Please update the doc comment here (and similarly in H.265/AV1) to explicitly describe the pipelined return behavior.
use super::H264Encoder;
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
@hgaiser @DatCaptainHorse feel free to have a review 👁️ . Personally I think its much cleaner - reduces alot of bloat. |
There was a problem hiding this comment.
I've ran tests on both 9060 XT and RTX 2060, also verified the PR against problematic case:
A game running with a 1440p 120FPS target on an 9060 XT with heavier settings.
- on
mainbranch the encoder drops ~50 frames per second resulting in frame jitter along other visual issues. (game running at 70-90FPS so it's not able to render fast enought to meet target). - on this pr, the encoder keeps on encoding without dropping frames, no visual jitter or other issues, despite game rendering not meeting 120 FPS target.
Great work! 🎉
|
thanks! if you turn on moonlight perf stats its actl pretty cool to see the host processing latency sitting pretty much at 0 instead of the 3-5 ms i was used to seeing. credit must be given to @porkloin as he came up with the idea. |
Yes, but also think of it like this: waiting is more expensive than working in advance. Without this PR, there's tons of waiting going between CPU and GPU, waiting for each other to finish before either can do more work. Most notably it hits the GPU's ability to encode when loaded with other work, as I've found. That waiting in-fact adds latency; E: There's most likely more optimal approaches here for lower latency, but I'd say getting this PR in helps in being able to measure any future work properly as it's not processor-specific waiting that's holding things up. |
I recently purchased parts to build an improvised LDAT. I'd be curious to see the difference before and after this PR. Do you suspect this PR will reduce latency? |
|
OOO a LDAT would be very useful. If you can try to test it as the PR stands now that would be great. I am currently working on reworking the pipeline so the encoder can actually "notify" when a frame is ready instead of having to check everytime we submit a new frame. That way we should get the best of both worlds as it would still be pipelined. Does this sound right? And it would be super interesting to see the LDAT data before and after as it seems like this is something moonshine-bench might miss (and even Moonlight's own telemetry). |
I'd say yes, most likely in cases where GPU is loaded with enough work (95%+ usage). Depending on how powerful your GPU is, you may need to bump resolution, FPS and game settings to get to that point, for my case 9060 XT is easier to choke 🙂 |
That would sound ideal! I haven't yet received those LDAT parts. I expect I'll receive them within a week 🤷 |
|
One thing I should add is:
|
|
benchmarking gets complicated but here is some benchmarks with my new strategy:
benchmarking this stuff gets kinda weird so looking forward to the LDAT. however essentially what @DatCaptainHorse said is still true. when the gpu gets saturated the loop ends up blocked far longer. for the total latency where the packed is held ~2 frames just halve that time cause i could get that down to 1 frame. so with the new strat, worst case our latency is equivalent to the original, but when saturated it stays rock solid. all this took some sweeping changes including some to Moonshine (use this branch: https://github.com/urwrstkn8mare/moonshine/tree/feat/encode-pipelining-push). It's still a bit rough still cleaning stuff up, testing, etc. i implore u guys to try it out. note the moonlight host processing numbers are back to normal but thats just because my previous implementation tagged the frames timestamp wrongly :). however (not biased at all), i feel anecdotally it is much more responsive - especially combined with hgaiser/moonshine#114 (a combined branch available at https://github.com/urwrstkn8mare/moonshine/tree/dev. |
|
Just marking it as ready as its been running pretty well with my anecdotal testing (so not really going to do anything else on it unless we find something). Would still be cool to see what the LDAT has to say |
Those benchmarks look good! On the one hand I want to merge this already, on the other I want to wait until I ran the LDAT test. Any preference @urwrstkn8mare ? I guess there's no immediate point merging this until your PR for moonshine is ready too. Unless @DatCaptainHorse needs it before then. |
|
Oh yeah the branch is already ready just have to make a PR. Was just waiting to see if anyone had any comments with my implementation. I reckon if you guys have tried it out and have no problems we might as well just roll it out now (as I'm planning more work that doesn't make sense to do now unless I want merge conflict hell). |
|
I might have time to try this out tonight 🤞 |
I'm currently in process of rebasing my PR against this one, as it can make use of these improvements. There's no need to merge this before that though, my PR is still a draft 🙂 |
|
I just tested it with a small benchmark, impressive results! I measured about a 2x speedup when encoding 4k@300Hz (from ~134fps to ~288fps). Crazy :) I was thinking about how to interface with this. What would you think about an asynchronous interface like so: pub struct Encoder { /* ... */ }
impl Encoder {
pub fn encode(&mut self, image: vk::Image) -> Result<EncodeFuture> {
let (tx, rx) = oneshot::channel();
self.submit(image, tx)?;
Ok(EncodeFuture { rx })
}
pub async fn flush(&mut self) -> Vec<Result<EncodedPacket>> { /* ... */ }
}
pub struct EncodeFuture {
rx: tokio::sync::oneshot::Receiver<Result<EncodedPacket>>,
}
impl Future for EncodeFuture {
type Output = Result<EncodedPacket>;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
self.rx.poll(cx)
}
}These can be consumed like so (in moonshine for example) : // Encoder task: submit frames, never block on GPU
tokio::spawn(async move {
loop {
let frame = frame_rx.recv().await?;
// ... import, convert ...
let fut = encoder.encode(frame.image())?;
future_tx.send(fut).await?;
}
});
// Packetizer task: drain in order, await each frame
tokio::spawn(async move {
loop {
let fut = future_rx.recv().await?;
let packet = fut.await?;
packetize_and_send(packet).await?;
}
}); |
|
@hgaiser the only thing with the async setup is the encoder task would still have to be on a seperate thread as it contains blocking functions that would block the entire event loop. the future model does look alot more intuitive so ill try to implement it and just make the packetiser task a green thread and the encoder task a normal thread - hopefully things don't get weird haha. since we are already making some substantial changes that need both moonshine and pixelforge patches i might just try to clean up the code that this PR touches substantially and fold in some work i was doing properly implementing RFI. this way we only have to make such a large change to the pixelforge API now. might take till tomorrow to get it all done and you'd probably like to review some of the bigger changes. |
…erCommon; eliminate redundant hotpath bitstream copy
…ti-reference prediction
|
ok so @hgaiser in advance, sorry for what this PR has become. i got carried away with the changes so if you'd like i can split the PRs into multiple - would be a bit tough though as the changes are quite intertwined. Now this PR (and its Moonshine companion) contains the pipelining, a codec refactor (each codec is now mostly just the differences), moonshine-bench fixes (and matrix mode), RFI implementation, and a backpressure policy changes. The last 2 actually have a drastic change to the WiFi experience - the frequency of packet loss events don't change but each event becomes a minor hitch in the stream (mostly). In regards to the async interface. Due to the blocking nature of submitting the encode, I've left that side to an actual thread rather than a green one. The result is a half-half solution - a mix of async and multi-threaded sync. Its something I initially felt a bit odd about when thinking of it but I honestly don't think its that bad. Having the encode function just return a future does make it more intuitive which is great. I'm admittedly not the most experienced with Rust so doing this has mainly just been an exercise in learning - especially the Rusty way of doing this. So would be glad to receive feedback from everyone on any front. Can also go into more detail as I've only really given an overview here. |
I haven't checked the code yet, but honestly I probably won't mind. I feel kinda distant from this implementation, as if someone else wrote it, which I guess is not far from the truth :). Frankly I'm happy someone looks at the code thoroughly 👍 I might dive deeper into the code at some point, but right now I mostly care about public API and quality / results.
I think so too, I like that approach. If you want it immediately you can always wait for it to arrive.
I will try to find some time to look over the code 🤞 Those LDAT components arrived a few days ago and I finally had the time to write some tools and run some tests. Here are the results:
I created a simple application that turns the screen white when a click event is registered. Locally (without streaming) this gets me about ~10-12msec of latency. The LDAT sends the mouse event, so this includes processing of the mouse event, updating the window and measuring the brightness. I streamed that same application in different configurations.
All of this is over a wired network connection. I guess the graph speaks for itself, but I was surprised that the latency for Counter-Strike was so high. Even at 90Hz when there is no significant congestion I measured a latency of ~80-87msec. I'm not ruling out flaws in my tests, but figured these were nice to share already :). Good news is that there are no downsides to your implementation! |
|
Wow the graph is quite interesting specifically where in CS@300HZ the box plot is wider on the pipeline branch even the box plot gets squashed in all the other variants. Good to know there aren't any regressions so far! |
|
Are there any blockers or issues left on this PR @hgaiser @urwrstkn8mare ? Just poking things here since it looks like things went quiet 🙂 |
|
Just waiting on @hgaiser to review it |
|
Apologies, I've been busy with work. I will check it soon! |
hgaiser
left a comment
There was a problem hiding this comment.
Thanks! I appreciate you took the time to refactor the Encoder API too. The API looks exactly like I had in mind, good stuff 👍
ps. feel free to nudge me if there's other PRs that you would like to see merged soon. I've been busy with work and preparing for vacation :).
|
Thanks @hgaiser ! I think the only PRs I recommend merging is the moonshine companion PR and then the grocery bag PR. They are both useful (but I rebased the grocery bag PR on the moonshine PR so one after the other). I can't really do anything from my end until July 2 but only some merges and stuff should need to be done. |
|
Apologies for missing all the pings here, I've been preparing to move to a different city so I've been super offline, but I'm very excited to see that this got merged 👍 |

This is an up to date combination of #12 and porkloin#1 reimplemented to consolidate logic in a pipelining module.
Benchmarks
Benchmarked with
moonshine-benchfrom/home/samit/Developer/moonshineusing:Patched runs used moonshine's workspace patch to this branch. Upstream runs used an unpatched temporary moonshine checkout resolving pixelforge to hgaiser's pinned upstream rev
199bce28a543ad16508a60f3b3f48f4684bfacb8.Session timings are
min/avg/maxin microseconds.37/71/14391163/1215/143817.1x233/370/20271372/1553/218236/76/14341638/2037/413426.8x337/433/18571959/2391/446235/88/14831345/1478/345416.8x333/446/19071641/1878/387457/124/1458883/985/17047.9x277/408/17511133/1304/252738/84/14481394/1534/375618.3x337/476/19981698/1933/4160