Repository navigation
fix(gds): run dense frontier tasks on the waiting thread instead of a new one - #1582
Conversation
… new one A dense frontier or vertex-compute task started a new std::thread to take the waiting thread's scheduler slot, about six per shortest-path query on a 15,000-node graph. Since a slot-holding waiter now works on the task it waits for, the GDS thread runs the task itself, so recursive joins start no threads and a one-thread query leaves the pool asleep. ApiTest.DenseRecursiveJoinWithOneThread runs a variable-length join whose second hop is dense on a database with one worker: inline, on the worker under a timeout, and with two threads requested. It hangs if the waiting thread does not run the task. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
adsharma
left a comment
There was a problem hiding this comment.
Minors from reviews/PR-1582-review.md (Approve — non-blocking). Two consistency notes in body: (1) extension/algo/.../in_mem_gds_utils.cpp:36, extension/llm/.../ai_extract.cpp:203, ai_extract_record.cpp:471 still pass launchNewWorkerThread=true — worth a follow-up issue to apply the #1482 reasoning there; (2) WASM causality is doc-derived, keep #1581 open until a multithreaded-WASM measurement confirms, and the 1–3% random-graph delta is noise-adjacent.
| // true for launchNewWorkerThread. | ||
| TaskScheduler::Get(*context->clientContext) | ||
| ->scheduleTaskAndWaitOrError(task, context, true /* launchNewWorkerThread */); | ||
| // GDS runs inside a task, so this thread holds a scheduler slot and works on the task it |
There was a problem hiding this comment.
Minor — invariant lives only in prose. "GDS always runs inside a task" holds for all in-tree callers traced (processor.cpp:88 → ProcessorTask, recursive_extend.cpp:291-299), but a future direct caller (background index build, DDL, vector-index) would silently fall back to queue-and-wait with no slot. Consider REQUIRES: caller holds a scheduler slot naming or a DASSERT-style guard so the next caller can't miss it.
| sharedState->morselDispatcher.init(maxOffset); | ||
| TaskScheduler::Get(*context->clientContext) | ||
| ->scheduleTaskAndWaitOrError(task, context, true /* launchNewWorkerThread */); | ||
| TaskScheduler::Get(*context->clientContext)->scheduleTaskAndWaitOrError(task, context); |
There was a problem hiding this comment.
Nit — bare default re-invites true. Both dense sites now rely on the default launchNewWorkerThread=false. Consider a trailing /* launchNewWorkerThread=false: waiter runs it (§1482) */ so nobody "optimizes" this back to true.
| ASSERT_EQ(result->getNext()->getValue(0)->getValue<int64_t>(), 3); | ||
| } | ||
|
|
||
| TEST_F(ApiTest, DenseRecursiveJoinWithOneThread) { |
There was a problem hiding this comment.
Minor — guard, not repro + unexplained phase 3. As the body notes this passes on main and only hangs against a hypothetical pure-blocking waiter — worth stating in the test name/comment. Phase 3 (CALL threads=2 on a maxNumThreads=1 DB at :666, then re-run) needs a one-line note on capping semantics; readers will expect 2 threads to mean something. (3000-node hop vs 1000-node dense threshold is the load-bearing detail — good that it's commented.)
|
Follow-ups:
|
This work was produced with the help of language models.
Addresses #1581.
Once a recursive join's frontier passes 1,000 nodes, each frontier and vertex-compute task is scheduled with
launchNewWorkerThread, which starts astd::threadto take the waiting thread's scheduler slot. On a stand-in for the #1581 graph, 15,000 nodes and 104,745 hub-biased edges, that is about six threads perSHORTESTquery.In the multithreaded WASM build each of those threads needs a Web Worker. The scheduler starts one thread per hardware thread and the build pre-creates 8 Workers, so with 8 or more hardware threads none is spare. The recursive join runs on the thread blocked in the synchronous
execute(), and Emscripten starts a new Worker only after its creator returns to the event loop, so the query waits for a thread that cannot start. This follows from the Emscripten runtime and its pthreads documentation; it has not been reproduced in a browser.Fix. Since #1482 a waiting thread that holds a slot works on the task it waits for, which is what the new thread was for. GDS always runs inside a task, so the waiting thread now runs the task and pool workers join it. Recursive joins start no threads.
Native Release builds; each value is the mean of two rounds' medians of 9 runs, 5 on the generated graphs:
main, msNo SF1 plan changes, and all 58 SF1 queries return the same rows up to order among ties and inside
collect().Cost. At one thread, searches on the random graph run 1 to 3% slower. The task's scan states are now allocated in the calling thread's malloc arena instead of a fresh thread's; with one arena (
GLIBC_TUNABLES=glibc.malloc.arena_max=1) both builds run within 0.5%.Tests.
ApiTest.DenseRecursiveJoinWithOneThreadruns a variable-length join with a dense second hop on a one-worker database: on the caller, on the worker under a query timeout, and with two threads requested. It passes onmain, and hangs if the waiting thread does not run the task. Full ctest passes, 3,444 tests.