Skip to content

JavaScriptEventLoop: Avoid O(N^2) job queue insert for bursts of jobs - #823

Merged
krodak merged 1 commit into
swiftwasm:mainfrom
PassiveLogic:kr/job-queue-insert
Oct 29, 2026
Merged

krodak merged 1 commit into
swiftwasm:mainfrom
PassiveLogic:kr/job-queue-insert

Conversation

@krodak

@krodak krodak commented Oct 28, 2026 •

Copy link
Copy Markdown
Member

Overview

insertJobQueue keeps 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). A Task {} loop, TaskGroup.addTask in a loop, or many continuations resumed from one callback all produce such bursts. On main, 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 in SchedulerPrivate[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 of JobQueue.swift asks 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.

QueueState does not change, nothing is allocated, and headJob is still the first job, so runAllJobs and the yieldInterval drain 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-RELEASE with the swift-6.4.0-RELEASE_wasm SDK, 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).

Workload N main (ms) This PR (ms)
Task {} in a loop, one priority 10,000 1,049 8.3
100,000 > 60,000 271
withTaskGroup, N addTask {} 10,000 340 7.9
100,000 > 60,000 297
N tasks awaiting continuations, resumed in one loop 10,000 2,116 10.7
100,000 > 60,000 315
Task(priority:) in a loop, cycling high, medium, low 10,000 483 8.6
100,000 > 60,000 287
One task, await Task.yield() 1,000,000 335 337

On main, a Task {} 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: with yieldInterval set, the order holds across drains, including jobs added to a run whose first job already ran.
  • The new tests also pass on main.
  • make unittest, and a build of JavaScriptEventLoop for Embedded Swift (JAVASCRIPTKIT_EXPERIMENTAL_EMBEDDED_WASM=true with the wasm-embedded SDK).

@krodak krodak self-assigned this Oct 28, 2026
@krodak
krodak force-pushed the kr/job-queue-insert branch from bba9e94 to c332a3b Compare October 28, 2026 11:15

@kateinoigakukun kateinoigakukun left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

@krodak
krodak merged commit 5c95e3c into swiftwasm:main Oct 29, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants