Skip to content

runtime: keep timers in a binary min-heap - #6035

Merged
dgryski merged 4 commits into
tinygo-org:devfrom
0magnet:timers-min-heap
Nov 3, 2026
Merged

dgryski merged 4 commits into
tinygo-org:devfrom
0magnet:timers-min-heap

Conversation

@0pcom

@0pcom 0pcom commented Oct 25, 2026

Copy link
Copy Markdown
Contributor

Fixes #5956.

The timer queue was a sorted list, so adding or removing a timer cost O(n), which shows up with many timers. It is now a binary min-heap. Ties keep FIFO order by sequence number, and a program without timers still compiles no heap code. The second commit is @dgryski's from the issue. It folds sift-up and sift-down into one function to make the heap smaller, and adds heap tests that check pop order, ties, removal, and randomized operations against a reference model.

From the size tables in the issue (-opt=z): no change for programs without timers or with one Sleep. With 100 timers reset and stopped, +1913 bytes of wasip1 code, +274 bytes on linux/amd64, +496 bytes on pico.

TestTimerHeap* passes on linux/amd64 and wasip1, and so does TestBuild/Host/timers.go.

@dgryski dgryski added this to the 0.45 milestone Oct 26, 2026
@dgryski

dgryski commented Oct 28, 2026

Copy link
Copy Markdown
Member

Based on #6058 , can we remove the calls to whenTicks and just compare the when fields directly?

@0pcom

0pcom commented Oct 28, 2026

Copy link
Copy Markdown
Contributor Author

Done. The heap now compares timer.when directly, the same field #6058 uses, and the cached copy in timerNode is gone, so whenTicks is only used where the scheduler compares against now in ticks. Equal deadlines still keep insertion order through seq. I also rebased onto dev. TestTimerHeap* passes on linux/amd64 and wasip1, along with TestBuild/Host/timers.go, TestBuild/WebAssembly/timers.go and TestTimerStopResetRace. I can't reproduce the binary size table with my system LLVM, so the new numbers are local deltas added to dev's golden and CI will confirm them.

@dgryski

dgryski commented Nov 2, 2026

Copy link
Copy Markdown
Member

This will need to be rebased against and get the pico size timings once #6059 is merged.

@dgryski

dgryski commented Nov 3, 2026

Copy link
Copy Markdown
Member

Please rebase and fix the conflict in binary-size.txt.

@0pcom

0pcom commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

Merged dev in and took dev's binary-size.txt. The timer heap no longer changes any size there, and CI is green so far.

@dgryski

dgryski commented Nov 3, 2026

Copy link
Copy Markdown
Member

The latest dev is actually tracking binary sizes for packages with timers now. Last update and then we can merge. 🤞 The code size increase is acceptable for now, and if we need to shrink it on smaller systems later, we can revert to a sorted slice on those teeeny-tiny boards via build tags for the timerHeap implementation and keep the same API.

0pcom and others added 3 commits November 3, 2026 11:02
A program with thousands of goroutines and many net deadlines or tickers
spent about a quarter of its CPU in removeTimer, with more in
timerQueueAdd and whenTicks.

The timer queue was a sorted linked list. Adding a timer walked the list
to find its place, and stopping or resetting one walked it to find the
node, so every operation was O(n) in the number of pending timers.

Keep the queue in a slice backed min-heap ordered by deadline and then
by an insertion sequence number, so equal deadlines still fire in FIFO
order. Each timer points to its queued node and each node knows its heap
index, which makes stop and reset O(log n). The synctest bubble uses the
same heap, and its alternating placement of equal deadlines is kept by
giving front insertions a negative sequence number. The global heap is
allocated by the first timer, so programs without timers still drop the
timer code from the scheduler.
Replace up and down with one fix(i, tn), hold the node in a local and write it once, and inline the comparison and index update, which asyncify otherwise instruments. Adds heap tests: pop order, equal deadline FIFO, remove, and randomized operations against a reference model.
The heap kept its own copy of the deadline in timerNode.when. A timer's
deadline only changes while none of its nodes is queued, so the heap can
compare timer.when directly, like the list does after tinygo-org#6058. This drops
the copy and makes whenTicks the same as on dev again. Equal deadlines
still keep insertion order through seq.
@dgryski

dgryski commented Nov 3, 2026

Copy link
Copy Markdown
Member

CI stalling on https://github.com/tinygo-org/tinygo/actions/runs/41683974625/job/126108167383#step:14:15 .. I cancelled and restarted and it hung again. I'll restart it later today and hopefully we can get this merged.

Thanks for all your work on these PRs. Hopefully it won't take too long to work through the back log since I think most of them have been reviewed and approved in their draft state.

@0pcom

0pcom commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

I don't think it's hung. The ASSERT=1 test step prints nothing until it finishes, and on dev today assert-test-linux took 35 to 41 minutes (runs 41687213221 and 41679512096). The run you cancelled had been in that step for about 11 minutes. Before the rebase, the same job passed on this branch in 27 minutes. If it is still silent past about 45 minutes, then it's a real hang and I'll dig in.

@dgryski

dgryski commented Nov 3, 2026

Copy link
Copy Markdown
Member

Ok, I'll restart it and see what happens. Sorry for being too impatient then :D

@dgryski
dgryski merged commit 07296d0 into tinygo-org:dev Nov 3, 2026
54 of 56 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.

runtime: the timer queue is a sorted list, so many timers cost O(n) per operation

2 participants