facebookexperimental / facebookexperimental/libunifex
Discussion and thoughts: using senders and receivers with GPU async
- Dominant language
- C++
- Stars
- 1.7k
- Forks
- 210
- PR merge metrics
- No merged PRs in 30d
Description
I am trying to wrap vulkan's asynchronous gpu calls (i.e. acquiring swapchain images, submitting rendering work, presenting images to the screen) into unifex stuff. As it turns out, I've bitten a lot more than I can chew, so I'd like to discuss this with people who know what they are doing with unifex, especially considering GPU async being mentioned as a potential P2300 application. Here are my current thoughts. They are probably applicable to any modern low-level GPU API, such as directx12 or metal (I think).
Vulkan has a mechanism for chaining GPU-side async operations: semaphores. An async call (usually) takes two arrays of semaphores: "wait" and "signal". The call does not actually start (even though it is submitted) until all "wait" semaphores are signaled, and after it finishes it signals all of the "signal" semaphores. Semaphores are a purely GPU-side synchronization primitive, therefore it is impossible to await them on the CPU.
How do we eventually join the computation if semaphores cannot be awaited (or polled) from the CPU? The answer to that are fences, which can be signaled from the GPU but polled/waited for only from the CPU. Those usually "terminate" a chain of async GPU computations.
Therefore an entire chain of GPU computations can easily be represented with a sender: when the operation starts, we register our fence with a polling system, and when the computation is done the polling system will notify our operation that the result is ready and we can call `set_value()` on the corresponding receiver.
This brings us to the first problem: how do we represent partial computations with senders? I.e. those that do not end in a call that can singnal fences. The `unifex::sender` concept requires that we use the standard `set_value` channel, but we cannot do that, as the value will never become available CPU-side. And that is OK, since S&R supports other channels. Let's call the new channel `work_started` (motivation: vulkan calls don't have asynchronous errors AFAIK, they either return an error immediately, or start an asynchronous call that **must** complete with success, unless the GPU completely crashes). So the plan for starting such a partial `operation` is as follows: call a vulkan function, in case it returns an error call `set_error`, otherwise call `work_started`.
Question: these types of senders and receivers representing partial computations do not satisfy the usual unifex concepts and have totally different semantics form the usual networking stuff. Is this even an intended usage of S&R? Am I on my own in this land of dragons, or is `unifex` designed to handle customization of algorithms well enough to support this?
As far as I know, there's no general way for a receiver/sender to list async channels that it uses, and therefore no way for generic algorithms to perfectly forward these calls when necessary (e.g. `let_value`'s `_successor_receiver`).
Onto the next problem. Where do we get those semaphores that are supposed to chain GPU operations from? My current plan is as follows: add 2 new CPOs for GPU-receivers, `get_wait_semaphores` and `get_signal_semaphores`. When a GPU-sender gets connected and it's operation is started, it will use these CPOs in order to acquire the relevant semaphores.
Lets consider a "simple" example: an analogue of `let_value`, `let_work_started(predecessor, successor)` (there's no successor factory, as no result value is produced by `work_started`). When a sender returned by this function gets connected to a receiver `R` and the operation `op0` is started, the following shall occur
1. Connect `predecessor` to a new `PredReceiver` and start the `op1`
2. `op1`'s start function queries `PredReceiver` for semaphores. The wait semaphore query will be forwarded to `R`, the signal query will return a semaphore that is created in `op0`.
3. `op1`'s start calls `work_started` on `PredReceiver`.
4. `PredReceiver`'s `work_started` connects `successor` with `SuccReceiver` and starts `op2`.
5. `op2` queries `SuccReceiver` for semaphores. The wait semaphore is provided by `op0`, the signal semaphore query is forwarded to `R`.
6. `op2` calls either `work_started` or `set_value` on `SuccReceiver` (for GPU-only successors and GPU-to-CPU ones respectively), all of which get forwarded to `R`.
Furthermore, `let_work_started` could receive the semaphore for `op0` as an input as an opportunity for resource reuse (creating semaphores is costly and should NOT be done every frame in a real time rendering application).
A similar construction would be needed for fences to terminate a GPU async chain.
All of this is good and dandy, but another problem awaits. There's this one very important async call in vulkan that does not behave as well as others: `vkQueuePresentKHR`. It has "wait" semaphores, but does not have neither "signal" semaphores, nor "signal" fences, so this async call is literally unjoinable. The way synchronization is done for this call is described in the next paragraph, but if we wrap this call into a sender, its' behavior will be very strange. It cannot be chained with any further GPU async calls, but can be connected with something that uses `work_started`, i.e. some kind of a "discard" receiver.
Another problematic and related call is `vkQueueSubmit`. It can signal both semaphores and fences, and that is exactly the way it is usually used: the semaphore signal is used to chain it with vkQueuePresentKHR, the fence signal is the one that's used to synchronize with CPU and start the next frame rendering job (several rendering jobs might be in flight at the same time by the way). In fact, the waiting for `vkQueuePresentKHR` does occur on GPU side, but using a totally different mechanism: the GPU job started by `vkQueueSubmit` internally waits for `vkQueuePresentKHR` via GPU magic.
How would one go about chaining `vkQueueSubmit` with both `vkQueuePresentKHR` and a CPU receiver that'll start the next frame? A potential way to do this would be adding a way to transform a `work_started` signal into a `set_value` signal and doing something like `let_work_started(vkQueueSubmit, when_all(CPUSender, work_started_to_set_value(vkQueuePresentKHR)))`, but that would require `when_all`'s receiver to perfectly forward the new CPOs, which it does not support.
So all in all, using S&R for GPU async seems possible, but very quirky. I would very much like to receive some feedback on my ideas and decide whether continuing to pursuing this idea is worth it or not. At the end of it, a rendering subsystem generally is pretty isolated from other stuff and doing ad-hoc concurrency there wouldn't impact the rest of the system much, especially considering that GPU synchronization is so finicky compared to traditional CPU stuff.
Contributor guide
Assessment
This issue has not been assessed yet.