Skip to content

Switch threads in resume by pointing at the coroutine's state - #52

Merged
folbricht merged 1 commit into
folbricht:mainfrom
ebarkie:coroutine-switch
Oct 21, 2026
Merged

folbricht merged 1 commit into
folbricht:mainfrom
ebarkie:coroutine-switch

Conversation

@ebarkie

@ebarkie ebarkie commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

A resume copied the running thread's state out and the coroutine's in, and the yield copied
them back, with the thread state's six pointer words taking a write barrier at every copy:
five copies of 26 words per round trip, and the resume values and results copied once more
each on the way. A generator ran 1.4 times slower than C Lua, and 5 times slower while the
collector was marking.

Performance changes

  • The executor points at the running thread's state instead of copying it. It holds only
    the thread's counters and frame list, which the loop reads without a hop, and points at the
    rest, the stack and the side lists, in the thread itself. A switch copies the counters and
    the frame list, 13 words with two pointers, and sets the pointer. The thread waiting in
    resume keeps its state where tracebacks and the live walk read it, so the waiting copy
    goes. The main thread's coroutine is made with the executor, holding its first stack.
  • Yield values and results are copied once. resume appends what the coroutine passes
    back, its results or the values it yielded, to the caller's result buffer straight from the
    coroutine's stack, where yield values stay until the next resume, instead of copying them
    into a buffer of its own and out again.
  • The restore after the body is no longer deferred. A panic poisons the executor, which no
    one runs again, so nothing needs restoring on that path.
  • Closed coroutines leave their stacks for reuse. One closed after dying in error hands
    its stack to the next coroutine, as one that returned does; before, every failed wrapped
    body allocated a fresh 16 KB stack.
  • What was tried and rejected. Pointing the executor at the whole thread state made every
    Lua call take the hop for its frame and counters, 5 to 8 percent on fib and binarytrees.
    Caching the pointer in the loop spilled across it. With the counters and frames kept in the
    executor, the loop is unchanged and the gate's counts are identical.

Times on an Apple M-series machine against upstream main at 80f93c7, three rounds each, best
of five per run:

Script upstream main this PR C Lua 5.4.9
Generator, 1M yield/resume round trips 83 ms 54 ms 60 ms
coroutine (suite) 8.3 ms 5.4 ms 6.0 ms
200k short coroutines returning two values 37 ms 27 ms 33 ms
fib, closures, binarytrees, vararg, sort, oop, pairs unchanged unchanged
Go-to-Lua call, host call (boundary benchmarks) unchanged unchanged

A note on fib: one build of this branch ran fib 10 percent slower while another, with the
same loop code and a small unrelated function added before it, ran it at upstream's time, as
did seven other such builds. The loop's machine code is identical, 2837 instructions either
way; the difference is code placement, which docs/benchmarks.md already notes moves single
rows by about 15 percent between builds.

Bug fixes

Most of these are pre-existing in the lines this change rebuilt; each has a test that fails
on the version before its fix.

  • A resume that could not hold its values left the coroutine running forever. The check
    failed on the coroutine's thread, named its yield site or no position at all, and left the
    status at running, so the coroutine could be neither resumed nor closed. Values the
    coroutine's stack cannot hold are now refused before the switch, with the resumer's
    position, in the terms pushFrame asks for (frameRoom), and the coroutine stays suspended.
  • Closers of a coroutine were placed by the resumer's call. The resume or close that runs
    a coroutine's closers has its arguments on another thread's stack, and the record of a call
    that had already returned could hold a position on yet another; placing a closer above
    either grew the coroutine's stack to that depth, which failed a close from deep in the main
    thread under an allocation limit. A thread records where its own Go calls begin
    (depthBase, as nnyBase does for yields), and a returned call's record is cleared, so it
    places nothing.
  • A host function that recovers a panic itself left the executor on the coroutine. When a
    host function swallows a panic that unwound the VM through it, the entry completes with the
    counters and the thread as the panic left them, and the pooled executor's next entry ran on
    the coroutine's stack. Such an entry now poisons the executor, so it is never used again.
  • A coroutine whose closer failed stayed running rather than dead.
  • A coroutine closed before it ran kept its body alive, with everything its upvalues
    reach. The body goes with the close.
  • The live walk double counted the main stack, once as the root and once through the main
    thread, and counted another executor's whole stack against an entry that held that
    executor's main thread value. It counts the running thread's stack once, and another
    executor's main thread as a thread alone.
  • A failed wrapped coroutine's error value stayed in a pooled call record past the
    result slice's length, where reset did not reach it. The record now holds it within length,
    so reset lets it go.

Tests

New tests cover yield values read after the coroutine's stack grew, through nested resumes,
from inside pcall and after an error; a resume at the stack limit; a close from deep in the
main thread under an allocation limit; closers placed with a stale record; a panic recovered
by a host function and one recovered at a nested entry; stack reuse after an error; the main
thread's record after an entry; and the live walk's counting of the main thread, both this
executor's and another's. vet, tests, race, checkptr, stack stress, asmgate and the
generated-loop check pass locally; fuzzing was not run. TestDifferential against a C Lua
built on macOS shows only pre-existing differences that main shows too.

A resume copied the running thread's state out and the coroutine's in, and
the yield copied them back, with the thread state's six pointer words taking
a write barrier at every copy: five copies of 26 words per round trip, and
the resume values and results copied once more each on the way. A generator
ran 1.4 times slower than C Lua, and 5 times slower while the collector was
marking.

The executor now holds only the running thread's counters and frame list,
which the loop reads without a hop, and points at the rest of its state, the
stack and the side lists, in the thread itself. A switch copies the counters
and the frame list, 13 words with two pointers, and sets the pointer; the
thread waiting in resume keeps its state where tracebacks and the live walk
read it, so the waiting copy goes. The running thread's copy is zeroed, so
that no stale closure outlives the switch in a pooled executor, and the live
walk counts the main thread's stack once, as the root, and another
executor's main thread as a thread alone. The main thread's coroutine is
made with the executor, holding the executor's first stack.

resume appends what the coroutine passes back, its results or the values it
yielded, to the caller's result buffer straight from the coroutine's stack,
where yield values stay until the next resume, instead of copying them into
a buffer of its own and out again; the value a wrapped coroutine failed with
goes with that buffer too. The restore after the body is no longer deferred:
a panic poisons the executor, which no one runs again. A host function that
recovers a panic which unwound the VM through it ends its entry with the
counters and the thread as the panic left them; such an entry now poisons
the executor too, where it ran the next entry on a coroutine's stack.

Values that the coroutine's stack cannot hold are refused before the switch,
with the resumer's position, in the terms pushFrame asks for, where the
resume that failed on the coroutine's thread named its yield site, or no
position, and left it running forever, so that it could be neither resumed
nor closed. The closers of a coroutine are placed by its own frames: the
arguments of the resume or close that runs them lie on another thread's
stack, and a call that returned leaves a record that places nothing, where
placing closers above either grew a stack to the other's depth. A coroutine
whose closer failed stays dead rather than running, one closed after dying
in error leaves its stack to the next coroutine, as one that returned does,
and one closed before it ran lets its body go.

A generator of a million values goes from 83 ms to 54 ms, C Lua's 60; the
suite's coroutine script from 8.3 ms to 5.4 ms, C Lua's 6.0; short coroutines
that return values from 37 ms to 27 ms, C Lua's 33. Pointing the executor at
the whole thread state made every Lua call take the hop for its frame and
counters, 5 to 8 percent on fib and binarytrees; with those kept in the
executor, the call-heavy scripts are unchanged.
@folbricht
folbricht merged commit c2f0481 into folbricht:main Oct 21, 2026
33 of 34 checks passed
@ebarkie
ebarkie deleted the coroutine-switch branch October 21, 2026 22:26
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