Skip to content

Commit 620afcf

Browse files
committed
feat(threadpool): blocking parallelFor with work stealing
Removes the need for a dedicated JXL thread pool
1 parent e589f72 commit 620afcf

4 files changed

Lines changed: 76 additions & 23 deletions

File tree

include/tev/Task.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
#include <tev/Common.h>
2222

23+
#include <chrono>
2324
#include <coroutine>
2425
#include <future>
2526
#include <ranges>
@@ -200,6 +201,10 @@ template <typename T> class Task {
200201
return await_resume();
201202
}
202203

204+
bool done() const noexcept { return mState->latch.value() <= (mState->continuation ? 0 : 1); }
205+
206+
std::future_status wait_for(const std::chrono::microseconds& duration) const noexcept { return mFuture.wait_for(duration); }
207+
203208
bool await_suspend(std::coroutine_handle<> coroutine) noexcept {
204209
if (!mHandle) {
205210
tlog::error("Cannot co_await/get() a task multiple times.");

include/tev/ThreadPool.h

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,26 @@ class ThreadPool {
112112
void shutdownThreads(size_t num);
113113
void shutdown();
114114

115+
bool tryRunOneTask();
116+
117+
// Block the calling thread on `task`, but keep the pool productive by running queued tasks inline instead of sleeping. Useful for
118+
// sychronous APIs that need to wait for a task to finish, but don't want to block the threadpool. See `parallelForSync`.
119+
template <typename T> T blockAndDrain(Task<T>&& task) {
120+
while (!task.done()) {
121+
if (!tryRunOneTask()) {
122+
// If we've got no tasks to run, back off a bit to avoid busy waiting. No big harm done if tasks are queued for up to 1ms
123+
// without being dealt with. If more tasks arrive than can be processed, the threadpool will remain busy once the 1ms are
124+
// over. Waiting on the blocked task itself allows early wakeups if the task finishes while we're waiting, so we're not
125+
// actually incurring 1ms latency on that front.
126+
if (task.wait_for(std::chrono::milliseconds(1)) == std::future_status::ready) {
127+
break;
128+
}
129+
}
130+
}
131+
132+
return task.get(); // already complete; no real blocking
133+
}
134+
115135
size_t numTasksInSystem() const { return mNumTasksInSystem; }
116136

117137
void waitUntilFinished();
@@ -190,6 +210,15 @@ class ThreadPool {
190210
);
191211
}
192212

213+
template <std::integral Int, std::invocable<Int, Int> F>
214+
void parallelForSync(Int start, Int end, size_t approxCost, F body, int priority) {
215+
blockAndDrain(parallelFor(start, end, approxCost, body, priority));
216+
}
217+
218+
template <std::integral Int, std::invocable<Int> F> void parallelForSync(Int start, Int end, size_t approxCost, F body, int priority) {
219+
blockAndDrain(parallelFor(start, end, approxCost, body, priority));
220+
}
221+
193222
size_t numThreads() const { return mNumThreads; }
194223

195224
private:

src/ThreadPool.cpp

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,30 @@ void ThreadPool::startThreads(size_t num) {
9292
}
9393
}
9494

95+
bool ThreadPool::tryRunOneTask() {
96+
if (!mTaskQueueSemaphore.tryWait()) {
97+
return false;
98+
}
99+
100+
QueuedTask task;
101+
{
102+
const std::scoped_lock lock{mTaskQueueMutex};
103+
TEV_ASSERT(!mTaskQueue.empty(), "Task queue empty after successful tryWait.");
104+
105+
if (mTaskQueue.top().stopToken) {
106+
// Hand off stop tokens to actual thread pool threads, not draining callers
107+
mTaskQueueSemaphore.signal();
108+
return false;
109+
}
110+
111+
task = mTaskQueue.pop();
112+
}
113+
114+
task.fun();
115+
--mNumTasksInSystem;
116+
return true;
117+
}
118+
95119
void ThreadPool::shutdownThreads(size_t num) {
96120
mNumThreads -= num;
97121
for (size_t i = 0; i < num; ++i) {

src/imageio/JxlImageLoader.cpp

Lines changed: 18 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@
3333
#include <jxl/thread_parallel_runner.h>
3434
#include <jxl/thread_parallel_runner_cxx.h>
3535

36-
#include <istream>
3736
#include <limits>
3837
#include <span>
3938
#include <vector>
@@ -172,13 +171,12 @@ Task<vector<ImageData>> JxlImageLoader::load(
172171
// co-routine. I.e. we need to synchronously wait for the work to finish, which could deadlock the global threadpool. Other mitigation
173172
// strategies involve temporarily creating and removing extra threads from the global threadpool (which tev previously implemented),
174173
// but this approach here scales better to huge numbers of images (n-cores extra threads instead of n-images extra threads).
175-
static auto jxlPool = ThreadPool();
176174

177175
const auto* runnerDataPtr = static_cast<RunnerData*>(runnerOpaque);
178176

179177
const uint32_t range = endRange - startRange;
180178
const uint32_t numTasks = std::min(
181-
jxlPool.nTasks(
179+
ThreadPool::global().nTasks(
182180
0u,
183181
range,
184182
numeric_limits<uint32_t>::max() // Max parallelism up to range tasks & hardware concurrency
@@ -191,26 +189,23 @@ Task<vector<ImageData>> JxlImageLoader::load(
191189
return initResult;
192190
}
193191

194-
jxlPool
195-
.parallelFor(
196-
0u,
197-
numTasks,
198-
numeric_limits<uint32_t>::max(), // Maximum parallelism up to numTasks threads
199-
[&](uint32_t i) {
200-
const uint32_t taskStart = startRange + (range * i / numTasks);
201-
const uint32_t taskEnd = startRange + (range * (i + 1) / numTasks);
202-
TEV_ASSERT(taskStart != taskEnd, "Should not produce tasks with empty range.");
203-
204-
for (uint32_t j = taskStart; j < taskEnd; ++j) {
205-
func(jpegxlOpaque, j, (uint32_t)i);
206-
}
207-
},
208-
runnerDataPtr->priority
209-
)
210-
// The synchronous parallel for loop is janky, because it doesn't follow the coroutine paradigm. But it is the only way to
211-
// get the thread pool to cooperate with the JXL API that expects a non-coroutine function here. We will offload the
212-
// JxlImageLoader::load() function into a wholly separate thread to avoid blocking the thread pool as a consequence.
213-
.get();
192+
// Since the JXL API's contract doesn't let us async/await, call the synchronous variant of parallelFor, which will block this
193+
// thread by running other thread pool tasks until the parallel for is done.
194+
ThreadPool::global().parallelForSync(
195+
0u,
196+
numTasks,
197+
numeric_limits<uint32_t>::max(), // Maximum parallelism up to numTasks threads
198+
[&](uint32_t i) {
199+
const uint32_t taskStart = startRange + (range * i / numTasks);
200+
const uint32_t taskEnd = startRange + (range * (i + 1) / numTasks);
201+
TEV_ASSERT(taskStart != taskEnd, "Should not produce tasks with empty range.");
202+
203+
for (uint32_t j = taskStart; j < taskEnd; ++j) {
204+
func(jpegxlOpaque, j, (uint32_t)i);
205+
}
206+
},
207+
runnerDataPtr->priority
208+
);
214209

215210
return 0;
216211
};

0 commit comments

Comments
 (0)