- Create thread corresponding each execution flow, execute serially on stream per thread, coordinate with either
- Carefully setup CUDA events and streams such that the correct execution flow will follow.
The 2. seems more appealing to untrained eyes (you don't have to deal with threads!) but in practice, often error-prune. One of the major issue, is that the
cudaStreamWaitEvent pair doesn't capture all synchronization needs. Comparing this to Grand Central Dispatch provided primitives:
dispatch_group_notify, the under-specified part is where the
cudaEventEnter happens. This often leads to a surprising fact that when you
cudaStreamWaitEvent on a event not yet recorded on another stream (with
cudaEventRecord), the current stream will treat as if this event is already happened and won't wait at all.
This is OK if your execution flows is static, thus, all the kernels need to be executed on which stream, are fully specified upfront. Requires some careful arrangement? Yes, but it is doable. However, it all breaks down if some coordinations need to happen after some kernel computations are done. For example, based on the newly computed losses, to determine whether decrease learn rate or not. Generally-speaking, for any computation graph that supports control structure, these coordinations are necessary.
The obvious way to solve this, is to go route 1. However, that imposes other problems, especially given pthread's handling of spawn / join is something much left to be desired.
For a few brave souls wanting to go route 2. to solve this, how?
After CUDA 5.x, a new method
cudaStreamAddCallback is provided. This method itself carries some major flaws (before Kepler,
cudaStreamAddCallback could cause unintended kernel launch serializations; the callback itself happens on the driver thread; and you cannot call any CUDA API inside that callback). But if we can gloss over some of these fundamental flaws and imagine, here is how I could make use of it with the imaginary
At the point I need to branch to determine whether to decrease learn rate, before
cudaStreamAddCallback, I call
cudaEventEnter to say that a event need to happen before certain stream to continue. Inside the callback, I get the loss from GPU, makes the decision, and call
cudaEventLeave on the right event to continue the stream I want to branch into.
In real world, the above just cannot happen. We miss
cudaEventLeave primitives, and you cannot do any CUDA API call inside such callback. More over, the code will be complicated with these callbacks anyway (these are old-fashioned callbacks, not even lambda functions or dispatch blocks!).
What if, I can write code as if it is all synchronous, but under the hood, it all happens on one thread, so I don't have to worry about thread spawn / join when just scheduling work from CPU?
In the past a few days, I've been experimenting how to make coroutines work along
cudaStreamAddCallback, and it seems all working! To make this actually useful in NNC probably will take more time, but I just cannot wait to share this first :P
First, we need to have a functional coroutine implementation. There are a lot stackful C coroutine implementations online and my implementation borrowed heavily from these sources. This particular coroutine implementation just uses
Setup basic data structures:
Setup a main run loop that can schedule coroutines:
Now, create a new task:
Usual utilities for coroutine (ability to yield, launch a new coroutine, and wait for existing coroutine to finish):
With above utilities, you can already experiment with coroutines:
Unsurprisingly, you should be able to see print outs in order of:
coroutine f first executed, it launches coroutine g. When g gives up control (
taskyield), coroutine f continues to execute until finish. After that, scheduler resumes coroutine g, and it finishes as well.
You can also try to
taskwait(task, gtask) in coroutine f, to see that f will finish only after coroutine g is scheduled again until finish.
So far, we have a functional coroutine implementation in C. Some of these code doesn't seem to make sense, for example, why we need a mutex and a condition variable? Because a secret function that enables us to wait on a stream is not included above:
taskcudawait will put the current coroutine on-hold until the said stream finishes. Afterwards, you can do branch, and knowing comfortably kernels in the stream above are all done. The condition variable and the mutex is necessary because the callback happens on the driver thread.
You can see the full code that demonstrated the usage here: https://gist.github.com/liuliu/7366373d0824a915a26ff295c468b6e4
It seems above utilities would cover all my usages (the
taskresume are important to me because I don't want too much hard to control async-y when launch sub-coroutines). Will report back if some of these doesn't hold and I failed to implement fully-asynchronous, control structure supported computation graph with these cute little coroutines.