Skip to content

Interrupt - #125

Open
Wavesonics wants to merge 1 commit into
CharlieTap:mainfrom
Wavesonics:interrupt
Open

Wavesonics wants to merge 1 commit into
CharlieTap:mainfrom
Wavesonics:interrupt

Conversation

@Wavesonics

Copy link
Copy Markdown
Contributor

Follow-up to #124, split out as requested in review. Built on that branch, so until it merges this PR also shows its commit; only the top commit (runtime: interrupt running calls in interruptible stores) is new here.

Adds a way to stop a running call from another thread, independent of fuel metering.

API

  • StoreConfig(interruptible = true) opts a store in.
  • interrupt(store) can be called from any thread. The running call traps with InvocationError.Interrupted at its next function entry or loop iteration. It returns an error for a store that isn't interruptible.

Implementation

  • Uses the same check sites as fuel. emitFuelCheck() becomes emitCheckpoint(), which picks the dispatcher at compile time: fuel only, interrupt only, or a combined fuel-and-interrupt check, so a store with both settings pays for one dispatch per site. A store with neither emits nothing.
  • An interrupt made while no call runs is dropped: the flag is cleared when an outermost call starts. Calls made back into the store from a host function never clear it, so an interrupt raised during a nested call also stops its caller.
  • A long-running host function is only stopped once it returns to Wasm code.

Cost
CoreMark on the JVM, 5 alternating runs per configuration, median score:

Store Score vs plain
plain 993.5
fuel 947.9 -4.6%
interrupt 953.9 -4.0%
fuel+interrupt 926.9 -6.7%

The runs are noisy (the ranges overlap), so treat these as approximate. ./gradlew :benchmark:coremark --args=<plain|fuel|interrupt|fuel+interrupt> reproduces them.

Tests
Fixture-based tests (interrupt.wat) cover:

  • interrupting a store that isn't interruptible;
  • stopping a running call;
  • the next call running normally;
  • dropping an interrupt made while idle;
  • how many checks are compiled;
  • fuel and interrupt together;
  • an interrupt raised in a nested host callback stopping its caller.

A JVM test also interrupts a running loop from another thread.

data object InterruptCheck : AdminInstruction

/** A [FuelCheck] and an [InterruptCheck] in one dispatch, for stores that are both metered and interruptible. */
data object FuelAndInterruptCheck : AdminInstruction

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Good use of super instructions, this is the way to go

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks! Glad that fit.

vstack.setFrameSlot(index, value.toLongFromBoxed())
}
initializeLocals(vstack, callStrategy, ROOT_FP)
val interrupt = store.interrupt

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

I would move all of this into FunctionInvoker, so it covers invocation of a HostFunction also, its the same thing just a higher entry point

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good call, moved it into FunctionInvoker so directly invoked host functions are covered too. Added a test for a host function that interrupts and then calls back into the store.

import kotlin.test.Test
import kotlin.test.assertEquals

class InterruptThreadTest {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Could we use the host callback to signal that execution has started, then send a single interrupt and wait for completion with a timeout? That avoids the startup race and verifies that one interrupt is sufficient.

The current test can hang indefinitely if interruption breaks, a timed wait and daemon worker let it fail without keeping the test JVM alive. We can retain the assertion that the next invocation succeeds.

Roughly:

   val started = CountDownLatch(1)
        val (store, instance) = InterruptTest.instantiate(
            StoreConfig(interruptible = true),
        ) { _, _ ->
            started.countDown()
        }

        val invocation = FutureTask {
            invoke(store, instance, "callback_then_spin")
        }

        thread(isDaemon = true, name = "interrupt-test") {
            invocation.run()
        }

        assertTrue(
            started.await(5, TimeUnit.SECONDS),
            "Wasm execution did not reach the host callback",
        )

        assertEquals(ChasmResult.Success(Unit), interrupt(store))

        assertEquals(
            ChasmResult.Error(
                ChasmError.ExecutionError(InvocationError.Interrupted.toString()),
            ),
            invocation.get(5, TimeUnit.SECONDS),
        )

        assertEquals(
            ChasmResult.Success(listOf(NumberValue.I32(0))),
            invoke(store, instance, "count", listOf(NumberValue.I32(3))),
        )

note I'm using kotlin.concurrent.thread ^^^

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done, thanks for the sketch. I went with it almost as written. The one addition is that the loop now polls a host import, so if the interrupt ever fails the test can release the worker instead of leaving it spinning.

An embedder had no way to stop a call from outside it. Fuel bounds
how long a call runs, but a watchdog thread or a host-side cancel
cannot end a call early without it.

StoreConfig(interruptible = true) compiles a check at each function
entry and loop branch target, the same sites fuel uses. interrupt()
sets a volatile flag from any thread, and the running call traps with
Interrupted at its next check. A store that is also metered emits one
combined check per site rather than two, which reports an interrupt
ahead of running out of fuel. A store with neither setting emits none.

FunctionInvoker tracks the calls running on the store, so host
functions invoked directly are covered as well as Wasm functions. The
flag is cleared only when an outermost call starts, and before that
call is published as running. interrupt() returns whether a call was
running to receive it, so an interrupt made between calls is reported
as dropped rather than lost silently. Calls made back into the store
from a host function never clear it, so an interrupt raised during one
also stops its caller.

CoreMark on the JVM (Ryzen 9 8945HS), 5 alternating single runs per
store configuration, median score with range:

  plain           993.5  (955.9 to 1033.5)
  fuel            947.9  (922.4 to 972.7)   -4.6%
  interrupt       953.9  (943.7 to 981.8)   -4.0%
  fuel+interrupt  926.9  (919.2 to 962.4)   -6.7%

The ranges overlap, so treat these as approximate. The coremark task
takes the store configuration as an argument to reproduce them.
@Wavesonics

Copy link
Copy Markdown
Contributor Author

Heads up on one change beyond the review: interrupt() now returns ChasmResult<Boolean, …>, with true if a call was running to receive it. Before, an interrupt landing between calls was dropped silently, even though it reported success. The flag is also now cleared before a call is marked as running, which closes a race at call start. Happy to revert to Unit if you'd prefer to keep the API as it was.

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