runtime: meter fuel calls in metered stores - #124
Conversation
| * Instantiating a module runs its start function on the same fuel. Call it only while none of the | ||
| * store's calls are running. Has no effect on an unmetered store. | ||
| */ | ||
| fun setFuel(store: Store, fuel: Long) { |
There was a problem hiding this comment.
I think maybe something like addFuel would be more flexible, as you could top it up rather than replacing the remaining each time. With some sensible checks like:
fun addFuel(
store: Store,
amount: Long,
): ChasmResult<Unit, ChasmError.ExecutionError> {
val fuel = store.store.fuel
if (amount < 0L || !fuel.metered) {
return ChasmResult.Error(
ChasmError.ExecutionError(
"Fuel must be non-negative and metering must be enabled",
),
)
}
fuel.remaining += amount.coerceAtMost(Long.MAX_VALUE - fuel.remaining)
return ChasmResult.Success(Unit)
}I don't if its of any use but a resetFuel might be a good complementary function?
| * function entry or loop iteration. An interrupt made while no call runs has no effect. False for an | ||
| * unmetered store, whose code has no checks to stop at. | ||
| */ | ||
| fun interrupt(store: Store): Boolean { |
There was a problem hiding this comment.
I would pull this (and the other interrupt related parts) out and make them a separate PR which is agnostic of metering
| @Suppress("UNUSED_PARAMETER") instruction: AdminInstruction.FuelCheck, | ||
| fuel: Fuel, | ||
| ): DispatchableInstruction = DispatchableInstruction { _, _, nextIp -> | ||
| if (--fuel.remaining < 0L) { |
There was a problem hiding this comment.
I would decrement post check:
if (fuel.remaining <= 0L) {
throw InvocationException(InvocationError.FuelExhausted)
}
fuel.remaining--This preserves zero after exhaustion
| */ | ||
| class Fuel(val metered: Boolean = false) { | ||
| /** Unlimited until set. Read and written only by the thread running the store's calls. */ | ||
| var remaining: Long = Long.MAX_VALUE |
There was a problem hiding this comment.
This can go to zero once we addFuel
| if (kind == BlockKind.Loop) { | ||
| state.bind(branchTarget) | ||
| // At the loop's branch target, so every iteration spends fuel. | ||
| state.compiler.fuel?.let { fuel -> state.emit(AdminInstruction.FuelCheck) { FuelCheckDispatcher(it, fuel) } } |
There was a problem hiding this comment.
It would be nice to have this as its own function, like:
internal fun FunctionCompilationContext.emitFuelCheck() {
val fuel = compiler.fuel ?: return
emit(AdminInstruction.FuelCheck) {
FuelCheckDispatcher(it, fuel)
}
}| * spin: `(loop (br 0))`. count (i32) -> i32: counts its argument down to zero in a loop. recurse: | ||
| * calls itself. | ||
| */ | ||
| internal val FUEL_MODULE = intArrayOf( |
There was a problem hiding this comment.
Typically we commit both a .wasm and a .wat fixture rather than embedding the binary directly. That lets me review the source. You could combine these modules into one fixture and call different exports through testRunner, as in [TypeSystemCorrectnessTest](https://github.com/CharlieTap/chasm/blob/main/chasm/src/commonTest/kotlin/io/github/charlietap/chasm/integration/TypeSystemCorrectnessTest.kt#L15-L41).
If testRunner isn’t flexible enough for the fuel setup, you can load and instantiate the fixture directly—[PrepareFunctionTest](https://github.com/CharlieTap/chasm/blob/main/chasm/src/commonTest/kotlin/io/github/charlietap/chasm/integration/PrepareFunctionTest.kt) shows that approach.
| import kotlin.test.Test | ||
| import kotlin.test.assertEquals | ||
|
|
||
| class FuelTest { |
There was a problem hiding this comment.
Given we move to addFuel we could do with a few extra cases:
- A fresh metered store has zero fuel and traps on invocation; an exactly sufficient allowance succeeds and leaves zero.
addFuelpreserves the existing balance, adding zero changes nothing, and additions reaching or exceedingLong.MAX_VALUEsaturate correctly.- Negative additions and additions to unmetered stores return an error without changing the balance.
- After exhaustion, adding fuel allows a finite function to complete successfully. The current refill test runs another infinite loop, so it doesn’t quite establish this.
- A looping start function exhausts fuel during instantiation.
| } | ||
|
|
||
| /** The fuel a metered store has left, 0 after it ran out; call it only while none of its calls run. */ | ||
| fun remainingFuel(store: Store): Long = store.store.fuel.remaining |
There was a problem hiding this comment.
Similarly this should probably have a check if fuel.metered is set just to steer correct use of the api.
|
thanks for all the feedback @CharlieTap I'll address it tonight! |
CharlieTap
left a comment
There was a problem hiding this comment.
Hey 👋🏼
Thank you for this! I like the approach as It think it's rigorous enough without getting into really granular cost estimation. Just a few comments to steer the API and get the implementation a little more consistent with the existing source
An embedder running untrusted modules had no way to stop one that loops forever: chasm has no instruction budget. The workaround is rewriting each module to decrement a global at every function entry and loop header, which adds eight instructions to every iteration. A store created with StoreConfig(meterFuel = true) compiles one FuelCheck instruction at each function entry and at each loop's branch target, taking a unit from the store's Fuel. A metered store starts with no fuel; addFuel tops it up, saturating at Long.MAX_VALUE, resetFuel empties it, and remainingFuel reads it. Each returns an error for an unmetered store, and addFuel rejects negative amounts. Running out traps with FuelExhausted and leaves the balance at zero, including during a module's start function. Unmetered stores compile no checks, so existing embedders are unaffected. On a grammar-checking plugin (Harper, compiled to Wasm) checking 40 paragraphs on the JVM, warmed up: 0.83 to 0.88 s unmetered, 1.12 to 1.16 s with the rewriting workaround, and 0.94 to 0.97 s with native fuel.
|
I think I've addressed all of your feedback. A few notes:
|
CharlieTap
left a comment
There was a problem hiding this comment.
Thank you for this it's much appreciated! I'm planning a release next weekend so lets see if we can get the interrupt parts over the line, if you need something in the interim I can publish a snapshot for you
chasm has no instruction budget and no way to interrupt a running call. For apps embedding chasm in order to run 3rd party code like plugins that is often a feature they want.
I've implemented an instruction budge by rewriting each module when I load it, to decrement a global counter at every function entry and loop header, which adds eight instructions to every iteration. This costs me about 26% performance degradation.
Integrating a solution directly into Chasm is much more efficient:
A store created with
store(meterFuel = true)now compiles oneFuelCheckinstruction at each function entry and at each loop's branch target, taking from the store's Fuel.setFuel,remainingFuel, andinterruptdrive it: running out traps withFuelExhausted, and an interrupt from another thread traps the running call withInterruptedat its next check. An interrupt made while no call runs is dropped.Unmetered stores compile no checks, so existing embedders are unaffected.
On a grammar-checking plugin (Harper, compiled to Wasm) checking 40 paragraphs on the JVM:
Fixes #123 & #122, possibly fixes #86