COM-464 Deep Sleep - #2
Conversation
Busy wait behavious can now be configured in the task options. Constructors now use rvalues.
| } | ||
| } | ||
| else if(dif < 0) | ||
| else if(dif < 0 && (!is_strong || m_enqueue_pos.load(std::memory_order_relaxed) - m_buffer_mask - 1 == m_dequeue_pos.load(std::memory_order_relaxed))) |
There was a problem hiding this comment.
You still have two independent loads and one of the atomic variables may change independently from the second one between the two loads. It's not very clear what you mean here by the "strong" notation?
| } | ||
| } | ||
| else if(dif < 0) | ||
| else if(dif < 0 && (!is_strong || m_dequeue_pos.load(std::memory_order_relaxed) == m_enqueue_pos.load(std::memory_order_relaxed))) |
There was a problem hiding this comment.
The same here this may still fail even if the queue is not empty. Imagine that as a result of reordering the m_enqueu_pos is being loaded first then your thread is being suspended, then when it wakes up m_enqueu_pos is already different. So you will be comparing your m_dequeue_pos with old m_enqueue_pos.
| */ | ||
| template <typename U> | ||
| bool push(U&& data); | ||
| bool pushStrong(U&& data); |
There was a problem hiding this comment.
I dichotomized the push/pop interface such that it is similar to atomic::compare_exchange_weak/atomic::compare_exchange_strong - the weak versions are faster, but may fail spuriously.
| * The bag supports multiple consumers, and a single producer per slot. | ||
| */ | ||
| template <template<typename> class Queue> | ||
| class SlottedBag |
There was a problem hiding this comment.
This is a really niche structure I made up for keeping track of idle threads in the context of the lockfree interaction between workers and calls to post(). See the brief above.
The class templates its queue to match the design pattern in worker.hpp and thread_pool.hpp.
| @@ -131,7 +135,30 @@ template <typename Task, template<typename> class Queue> | |||
| template <typename Handler> | |||
| inline bool ThreadPoolImpl<Task, Queue>::tryPost(Handler&& handler) | |||
| /** | ||
| * @brief The BusyWaitOptions class provides worker busy wait behaviour options. | ||
| */ | ||
| class BusyWaitOptions |
There was a problem hiding this comment.
You can configure the busy wait pattern assigned to workers. By default, workers will run 3 busy wait loops, with exponentially increasing backoff starting from 1ms (i.e. 1ms, 2ms, 4ms). This pattern was experimentally determined to be efficient on my machine, and is subject to change.
|
|
||
| template <typename Task, template<typename> class Queue> | ||
| inline void Worker<Task, Queue>::threadFunc(size_t id, WorkerVector* workers) | ||
| inline void Worker<Task, Queue>::threadFunc(size_t id, WorkerVector* workers, SlottedBag<Queue>* idle_workers, std::atomic<size_t>* num_busy_waiters) |
doxxx
left a comment
There was a problem hiding this comment.
That looks great, Sever! I've looked through the changes and nothing jumps out at me with my less than stellar C++ knowledge.
| inline bool MPMCBoundedQueue<T>::push(U&& data, bool is_strong) | ||
| { | ||
| Cell* cell; | ||
| size_t pos = m_enqueue_pos.load(std::memory_order_relaxed); |
There was a problem hiding this comment.
Strongly suggest having acquire memory ordering here. Because of reordering and predictive optimization some further code may execute before the load finishes, which is not what you desire here.
| if(m_enqueue_pos.compare_exchange_weak( | ||
| pos, pos + 1, std::memory_order_relaxed)) | ||
| { | ||
| if(m_enqueue_pos.compare_exchange_weak(pos, pos + 1, std::memory_order_relaxed)) |
There was a problem hiding this comment.
Use acquire_release semantics here for the same reason. Because of reordering there might be some writes to a wrong cell, even if intuitively it seems it can't be.
load a then do b based on value of a may become do b based on some predicted value of a then check if a is different undo b
If you use acquire_release semantics it is guaranteed not to happen
| } | ||
| } | ||
| else if(dif < 0) | ||
| else if(dif < 0 && (!is_strong || m_dequeue_pos.load(std::memory_order_relaxed) == m_enqueue_pos.load(std::memory_order_relaxed))) |
There was a problem hiding this comment.
As we spoke this effectively introduces spin locking. Your for loop will end up indefinitely looping if one of the producer threads gets suspended during the process of pushing. And this may have an adverse effect by consuming cpu resources and reducing the chances of the producer thread to wake up.
This type of behavior is not what you want to be hardwired in the design of your low level LF data structure.
|
|
||
| cell->sequence.store( | ||
| pos + m_buffer_mask + 1, std::memory_order_release); | ||
| cell->sequence.store(pos + m_buffer_mask + 1, std::memory_order_release); |
There was a problem hiding this comment.
using release semantics is correct here
| inline bool MPMCBoundedQueue<T>::pop(T& data, bool is_strong) | ||
| { | ||
| Cell* cell; | ||
| size_t pos = m_dequeue_pos.load(std::memory_order_relaxed); |
| if(m_dequeue_pos.compare_exchange_weak( | ||
| pos, pos + 1, std::memory_order_relaxed)) | ||
| { | ||
| if(m_dequeue_pos.compare_exchange_weak(pos, pos + 1, std::memory_order_relaxed)) |
| @@ -169,7 +208,33 @@ inline MPMCBoundedQueue<T>& MPMCBoundedQueue<T>::operator=(MPMCBoundedQueue&& rh | |||
|
|
|||
There was a problem hiding this comment.
You don't need to use std::move with scalar types. m_buffer_mask is a scalar type you can simply copy it.
| @@ -169,7 +208,33 @@ inline MPMCBoundedQueue<T>& MPMCBoundedQueue<T>::operator=(MPMCBoundedQueue&& rh | |||
|
|
|||
There was a problem hiding this comment.
Also in general it's a good practice to set rhs to some sensible state after moving from it. This may help catching bugs when you buy mistake try to use the object which was already moved:
A a;
A a2 = std::move(a);
do something with a
If you handle the move properly and throw a proper exception when trying to use a moved object you can catch such bugs in place of use
| @@ -76,19 +76,40 @@ class MPMCBoundedQueue | |||
| MPMCBoundedQueue& operator=(MPMCBoundedQueue&& rhs) noexcept; | |||
There was a problem hiding this comment.
This class obviously should not have a copy constructor and copy assignment operator. It's a good practice to explicitly delete them.
There was a problem hiding this comment.
Couldn't this still be useful if ulterior synchronization is used to ensure rhs has entered a defined state?
| } | ||
|
|
||
| template <template<typename> class Queue> | ||
| inline bool SlottedBag<Queue>::tryEmptyAny(size_t& id) |
There was a problem hiding this comment.
There are two alternatives interfaces you can use for this function to avoid passing id by reference:
- use std::optional if the compiler is C++17 compatible
- return a std::tuple<bool, size_t>
There was a problem hiding this comment.
We're still using C++14, but I'll switch the signature over to the tuple form.
| * @brief setIterationFunction Set the function to be called upon each sleep iteration. | ||
| * @param function The iteration function to be called. | ||
| */ | ||
| void setIterationFunction(IterationFunction&& function); |
There was a problem hiding this comment.
I'm not super convinced about the rvalue references everywhere here, will revisit.
|
Please see #3 for updates. |
|
Closing - please defer to #3. |


This PR serves to add deep sleeping on worker slow paths in the thread pool. It solves inkooboo#20.
Essentially, this thread pool previously busy waited when no tasks were present, and this ate up CPU when the process was idle. Threads now enter an 'idle' state in which they block on a condition variable when asleep. This results in the threads consuming 0 CPU when idle, with a performance cost that renders it up to 3% slower than before. Tests are detailed below.
Note that this PR also solves inkooboo#18, and it is built on top of a fix for inkooboo#19.
This PR is backed by a set of torture tests that use our Synaptive Task Library. I'll be making a PR soon that commits those.
Test Details
The following new tests were performed upon the Synaptive Task Library using this thead pool as the underlying scheduler:
Active,Busy Wait, andIdlestates.There were several previous tests in Common that utilized the thread pool which I'm not mentioning here (e.g. TaskBehaviourTest, TaskInliningTest, SynaptiveImagePerformanceTest, Async Primitives, etc...). They all function correctly.
Test Results
The following are the numerical results from the test cases mentioned above.
Mandelbrot Performance Benchmark
Sub-test: IndividualTasks - submits a new task to the thread pool for each pixel calculation in a 2500 X 2500 mandelbrot set image. Values are test duration in milliseconds. Overall,
Sub-test: TaskChains - submits the mandelbrot calculation tasks in a chained fashion to the thread pool in order to leverage task inlining. 64 task chains containing all calculations are submitted to the thread pool. Values are test duration in milliseconds.
Note that in this case, the edits actually seem faster than the original thread pool implementation. This may have to do with the design of the busy waiting sequence.
Task Throughput Test
I ran the throughput test for 10 seconds while keeping the number of tasks in the thread pool fixed at each of 1, 5, 10, 50, 100, 150, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 2000, 3000, 4000 and 5000. The number of tasks in the thread pool are fixed by spawning N tasks, and having each task post another task to the pool before completion. I then measured how long it took to process one individual task in each case. The following are the averages of all these values. Values are in average processing time per task in nanoseconds.
IdlingTest
The CPU usage of the thread pool tends to 0 regardless of the number of threads spun up; they're all asleep 😴.