performance improvements to JobSystem

- reduce the number of calls to notify_one() and notify_all().
  notify_one() is not only called when running a new job, and
  notify_all() only when a job finishes.

- don't hold the condition lock while calling notify_*(), as it is not
  strictly needed, and because notify_*() can be very slow, there can
  be a lot of contention on this lock as a result; blocking the whole
  jobsystem thread pool.

- add a new version of run() that takes an opaque thread id that can
  be retrieved from a job's execute function; this is especially
  intended to be used by parallel_for(); it's just a more efficient
  version of run() that avoids a hashmap lookup.


Overall these change yield a significant performance boost:
- running + waiting a job: +200%
- running many jobs: +150%
- running many jobs in parallel: +50%
This commit is contained in:
Mathias Agopian
2024-07-22 11:12:51 -07:00
committed by Mathias Agopian
parent a8ace2891d
commit a60fe41681
7 changed files with 88 additions and 51 deletions
+36 -9
View File
@@ -21,7 +21,7 @@
#include <utils/architecture.h>
#include <utils/compiler.h>
#include <utils/Condition.h>
#include <utils/Log.h>
#include <utils/debug.h>
#include <utils/memalign.h>
#include <utils/Mutex.h>
#include <utils/Slice.h>
@@ -47,12 +47,18 @@ class JobSystem {
static constexpr size_t MAX_JOB_COUNT = 16384;
static_assert(MAX_JOB_COUNT <= 0x7FFE, "MAX_JOB_COUNT must be <= 0x7FFE");
using WorkQueue = WorkStealingDequeue<uint16_t, MAX_JOB_COUNT>;
using Mutex = utils::Mutex;
using Condition = utils::Condition;
public:
class Job;
using ThreadId = uint8_t;
using JobFunc = void(*)(void*, JobSystem&, Job*);
static constexpr ThreadId invalidThreadId = 0xff;
class alignas(CACHELINE_SIZE) Job {
public:
Job() noexcept {} /* = default; */ /* clang bug */ // NOLINT(modernize-use-equals-default,cppcoreguidelines-pro-type-member-init)
@@ -77,7 +83,9 @@ public:
uint16_t parent; // 2 | 2
std::atomic<uint16_t> runningJobCount = { 1 }; // 2 | 2
mutable std::atomic<uint16_t> refCount = { 1 }; // 2 | 2
// 6 | 2 (padding)
mutable ThreadId id = invalidThreadId; // 1 | 1
// 1 | 1 (padding)
// 4 | 0 (padding)
// 64 | 64
};
@@ -244,7 +252,7 @@ public:
}
/*
* Add job to this thread's execution queue. It's reference will drop automatically.
* Add job to this thread's execution queue. Its reference will drop automatically.
* The current thread must be owned by JobSystem's thread pool. See adopt().
*
* The job can't be used after this call.
@@ -255,7 +263,19 @@ public:
run(p);
}
void signal() noexcept;
/*
* Add job to this thread's execution queue. Its reference will drop automatically.
* The current thread must be owned by JobSystem's thread pool. See adopt().
* id must be the current thread id obtained with JobSystem::getThreadId(Job*). This
* API is more efficient than the methods above.
*
* The job can't be used after this call.
*/
void run(Job*& job, ThreadId id) noexcept;
void run(Job*&& job, ThreadId id) noexcept { // allows run(createJob(...));
Job* p = job;
run(p, id);
}
/*
* Add job to this thread's execution queue and keep a reference to it.
@@ -312,6 +332,13 @@ public:
size_t getThreadCount() const { return mThreadCount; }
// returns the current ThreadId, which can be used with run(). This method can only be
// called from a job's function.
static ThreadId getThreadId(Job const* job) noexcept {
assert_invariant(job->id != invalidThreadId);
return job->id;
}
private:
// this is just to avoid using std::default_random_engine, since we're in a public header.
class default_random_engine {
@@ -363,12 +390,12 @@ private:
Job* steal(WorkQueue& workQueue) noexcept;
void wait(std::unique_lock<Mutex>& lock, Job* job = nullptr) noexcept;
void wake(size_t hint) noexcept;
void wakeAll() noexcept;
void wakeOne() noexcept;
// these have thread contention, keep them together
utils::Mutex mWaiterLock;
utils::Condition mWaiterCondition;
Mutex mWaiterLock;
Condition mWaiterCondition;
std::atomic<int32_t> mActiveJobs = { 0 };
utils::Arena<utils::ThreadSafeObjectPoolAllocator<Job>, LockingPolicy::NoLock> mJobPool;
@@ -468,7 +495,7 @@ right_side:
// start the left side before attempting the right side, so we parallelize in case
// of job creation failure -- rare, but still.
js.run(l);
js.run(l, JobSystem::getThreadId(parent));
// don't spawn a job for the right side, just reuse us -- spawning jobs is more
// costly than we'd like.
@@ -524,7 +551,7 @@ JobSystem::Job* parallel_for(JobSystem& js, JobSystem::Job* parent,
}
template <size_t COUNT, size_t MAX_SPLITS = 12>
template<size_t COUNT, size_t MAX_SPLITS = 12>
class CountSplitter {
public:
bool split(size_t splits, size_t count) const noexcept {