Repository navigation
JavaScriptEventLoop: Avoid O(N^2) job queue insert for bursts of jobs - #823
Merged
Merged
Conversation
krodak
force-pushed
the
kr/job-queue-insert
branch
from
October 28, 2026 11:15
bba9e94 to
c332a3b
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Overview
insertJobQueuekeeps the queue sorted by descending priority and walks it from the head to place a new job after every job of the same or higher priority. When the new job has the same priority as the queued ones, it walks the whole queue, so a burst of N jobs queued before the drain costs O(N^2). ATask {}loop,TaskGroup.addTaskin a loop, or many continuations resumed from one callback all produce such bursts. Onmain, a burst of 50,000 jobs takes 10 s to more than 60 s.This PR groups the jobs of one priority into a run. The first job of each run stores the run's last job in
SchedulerPrivate[1], the second scheduler-private word, which the runtime reserves for the executor while a job is enqueued (Task.h: the scheduler-private storage stays "under its control until it dequeues the job"; the runtime's own cooperative executor keeps its deadline inSchedulerPrivate[1],CooperativeGlobalExecutor.inc). An insert steps over whole runs, so it costs O(distinct priorities in the queue), and claim stays O(1). This is the priority queue the comment at the top ofJobQueue.swiftasks for, kept as one linked list. The order does not change: higher priority first, FIFO within a priority.1. Insert. Walk the runs whose priority is not lower than the new job. Append to the run with the same priority, or start a new run before the first lower one.
2. Claim. If the claimed job is not the last of its run, the next job becomes the run head and takes over the last-job pointer.
QueueStatedoes not change, nothing is allocated, andheadJobis still the first job, sorunAllJobsand theyieldIntervaldrain are untouched. Claim finds run boundaries through the last-job pointer and never reads a priority, and insert reads only the priority of run heads, so the links stay consistent even if a queued job's priority changed. Today the runtime never changes it, because escalating an enqueued task does not rewrite the queued job.Benchmarks
A small executable installs the global executor and times each workload from the first spawn to the last completion. Release build,
swift-6.4-RELEASEwith theswift-6.4.0-RELEASE_wasmSDK, Node.js 22.23.2, Apple M2 Max. One fresh Node process per run, builds alternated, median of 5 runs (10 for the last row).Task {}in a loop, one prioritywithTaskGroup, NaddTask {}Task(priority:)in a loop, cycling high, medium, lowawait Task.yield()On
main, aTask {}costs 5.7 µs at N = 1,000, 105 µs at 10,000 and 583 µs at 50,000. With this PR it stays at 1 to 3 µs. The last row keeps one job in the queue, and the two builds are within noise.I'm happy to add these benchmarks to the repo as well, but from a maintenance standpoint they did not seem important to keep.
Alternative
PassiveLogic/JavaScriptKit#28 adds only a tail pointer. It matches this PR for one priority, but a job that outranks the tail still scans from the head, so the mixed-priority loop stays quadratic (37.6 s at N = 100,000).
Testing
testTaskPriorityOrderWithMixedPriorities: bursts of mixed priorities, tasks started from a running task, and 600 tasks over six priorities run in priority order, FIFO within a priority.testTaskPriorityOrderAfterQueueEmpties: the order holds when the queue drains and refills.testPriorityOrderAcrossYields: withyieldIntervalset, the order holds across drains, including jobs added to a run whose first job already ran.main.make unittest, and a build ofJavaScriptEventLoopfor Embedded Swift (JAVASCRIPTKIT_EXPERIMENTAL_EMBEDDED_WASM=truewith thewasm-embeddedSDK).