Gas ⛽ Metering for Wasm Programs
In this post we consider the following methods of gas metering for Wasm contracts:
-
By preparatory instrumentation of the program module.
-
By the Wasm executor engine itself.
Wasm Module Instrumentation
Instrumentation means a process of injecting auxiliary instructions into the module blob. This is generally done once and results in a new instrumented module which allows host to measure gas spent during its execution.
There are several ways of doing such an instrumentation. Here we consider two of them:
-
Injection of gas host function calls.
-
Injection of gas_left mutable global which is shared with the host.
Metering via Host Function Calls
How it works
Instrumentation:
- Injects the
gas
host function import into the module. - Goes through all instructions in every program block and sums up gas charges implied for them.
- Injects an invocation of the
gas
host function with a constant value of the gas amount to be charged for all the instructions of the block.
Metering:
- Upon starting a contract call, host tracks
gas_left
during the call execution. - Executed contract calls the host function at the beginning of each block.
- Host function charges the gas amount passed in by subtracting it from the
gas_left
. - In case
gas_left
falls to zero, host interrupts contract execution and returnsOutOfGas
error to the caller.
Example
Let’s take a simple example of a smart contract which returns the caller account balance:
By the way, this program is a valid Polkadot smart contract which you can deploy to any parachain having runtime equipped with pallet_contracts. For that you need to convert it to binary Wasm, which can be done e.g. with wat2wasm
utility from the WebAssembly Binary Toolkit.
When contract code gets uploaded to the pallet (usually via upload_code
extrinsic), it undergoes a number of checks as well as gas metering instrumentation.
pallet_contracts
worked before switching to executor’s built-in gas metering (see substrate#14084 PR for details). Newer versions use engine-sided gas metering which does not require instrumentation. More on this in the next section.Now let’s take a look at how this works in practice. The described operations on the contract module are being done by the wasm-instrument crate. To see particular injections in a human-readable format we convert the .wat
source code to the Wasm binary and then back to text format via wat2wasm <file.wat> | wasm2wat
and then compare it with the resulting module converted to the same format by wasm2wat
.
Here is how our module looks before instrumentation:
Control flow graph (drawn with octopus tool) of this Wasm program is:
Now here is how this program’s code looks after instrumented with gas metering:
And control flow graph after instrumentation:
Injected gas metering instructions are marked with yellow. The amount of gas being charged in each block (i64.const instructions in the marked chunks) is calculated by summing up the cost of all instructions of the block. The costs of all instructions are defined in the cost schedule, which has them calculated via benchmarks. But this is another topic which deserves a dedicated post.
Cons of This Metering Method
Calling a host function is a computationally expensive operation, as it requires execution context switching between guest and host. Doing this in each basic block of a guest program adds up performance costs.
Metering via Mutable Global
How it works
Instrumentation:
-
Injects a mutable global variable
gas_left
into the module.The variable is exported from the module having initialized value of
0
. -
Injects a local
gas
function definition into the module.The function code is:
It tries to decrease the
gas_left
global value by the passed in gas amount, raising an error in case there is not enough gas left. -
Goes through all instructions in every program block and sums up gas charges implied for them.
-
Injects an invocation of the local
gas
function with a constant value of the gas amount to be charged for all the instructions of the block.
Metering:
-
Upon staring a contract call, the host initializes the
gas_left
global (shared with the guest) with the gas amount left for the contract execution. -
Executed contract calls its injected local
gas
function at the beginning of each basic block.The function decreases
gas_left
global variable by the gas amount needed for the block execution, failing the execution if not enough gas left. In order to signal the host the exact reason of the failure, the guest sets thegas_left
value to the special sentinel value which equals-1i64
and casts tou64::MAX
if host (reasonably) uses unsigned integer for representing gas amounts. -
If all gas is exhausted (which can happen both in host and guest execution contexts), the host interrupts contract execution and returns
OutOfGas
error to the caller.
Example
Let’s instrument the same balance contract with the mutable global -based gas metering facilities.
We get the following code as the result:
This code has the following control flow graph:
Injected gas metering code is marked with yellow. As we see, it has the injected local gas
function (on the right) as well as its invocation injected into every block.
Method Validity
Whether this method is beneficial to use in comparison to the host function method, should be determined by benchmarking for every particular execution engine as well as specific gas metering host function implementation. For some engines, the costs of calling an imported function and calling a local function are the same, and for them using this method would not necessarily bring performance wins.
Engine-resided Metering of Gas
It is good to have gas metering implemented in the execution engine itself, for the following reasons:
- It is likely to occur more optimal performance-wise.
- No code instrumentation required, which makes embedder logic simpler and saves storage and computational resources for the host.
- No benchmarks required on the embedder side.
Now let’s consider how this approach works in general and see some details on the particular example of Substrate’s pallet_contracts use case.
How it works
Basic Flow
While specific implementations might vary a bit in some details, overall algorithm is mostly the same:
-
Fuel metering feature is usually optional and is set to off by default.
It’s needed to be opted-in in the engine config when spinning an instance.
-
Current gas level is being stored into the state.
Engine initializes it to
0
. Upon spinning an execution instance, embedder sets the initial value of the gas left.This value is usually set by the user who calls the contract as
gas_max
value, meaning the upper limit of the gas (essentially funds) the user is ready to pay for the execution. -
Engine charges the gas during execution.
Cost schedule is either hardcoded into the engine or provided from the outside (different in different engines). Units of gas measurement are usually normalized so that the minimal (base) fuel cost is
1
.Different instructions cost different amounts of gas. Some of them could even cost
0
as they could only be present in Wasm program together with other instructions (examples areloop
,block
,drop
andnop
, see here for details). -
During the execution embedder can check either
gas_consumed
amount orgas_left
amount (depending on particular engine’s implementation). -
During execution embedder can arbitrary charge additional fuel for the operations on its side, e.g. for a host function execution.
For that a special
charge_gas
function provided by the engine. -
When fuel in store is exhausted, engine traps Wasm execution with
OutOfGas
error.While this seems to be an obvious outcome, engines supporting asynchronous execution allow pit stops for refueling.
Particular Implementations
Without getting too deep into the details, in a nutshell here’s how three popular Wasm engines deal with gas metering:
-
wasmtime delegates gas charges to its code generator Cranelift, which processes it during translation of each operator into its IR instructions.
Costs schedule is as simple as it gets and is hardcoded here.
-
wasmi is an interpreter, thus it seems reasonable that it charges gas for each executed instruction during a program run.
For that it adds
ConsumeFuel
instruction into its IR of the program. Costs schedule is defined in the engine config currently without a method to customize it (though with the design taken this could be done as a one-liner change). -
wasmer basically injects a mutable global and updates it during proccessing each operator of the Wasm code, adding a bunch of instructions to check if it is exhasted.
This looks quite similar to the technique we’ve discussed in the previous section. Albeit wasmer appears to inject Wasm instructions of the checker code on every operation, which seems a bit wasteful approach resource-wise. It has no internal costs schedule and expects it to be provided by the embedder as a specific cost_function, which also reminds the out-of-engine metering facilities described above.
APIs
These engines expose the following interfaces which we list here with the mapping to the basic steps of gas metering flow:
Now let’s move on to some use case examples of those gas metering APIs.
Examples
- wasmtime: an example of using the metering API through Go bindings.
- wasmer: see metering example from the project repository.
- wasmi: on this example I’d like to dwell in a bit more detail since work on it is what inspired me to write this post.
Instantiation
Upon a contract call invocation pallet instantiates a fresh wasmi instance. Its engine is configured to account for the fuel consumption in the Eager
mode:
let mut config = WasmiConfig::default();
config
.wasm_multi_value(false)
.wasm_mutable_global(false)
.wasm_sign_extension(false)
.wasm_bulk_memory(false)
.wasm_reference_types(false)
.wasm_tail_call(false)
.wasm_extended_const(false)
.wasm_saturating_float_to_int(false)
.floats(matches!(determinism, Determinism::Relaxed))
.consume_fuel(true)
.fuel_consumption_mode(FuelConsumptionMode::Eager);
Fuel Syncs
We have now two separate fuel meters, each having its own units of measurement:
- Gas Meter built into the pallet, measures 2-dimensional Weight1 burn during runtime host function execution.
- Fuel Meter built into the wasmi, measures 1-dimensional Fuel burn in engine for Wasm instructions execution.
UoMs for fuel consumption in wasmi are normalized, so that basic operations like i64.const
cost 1 Fuel. For conversion between the two UoMs, the ref_time
component of i64.const
instruction Weight is used. This is why we keep its benchmark and its weight in the Schedule
We do fuel consumption level synchronizations at the times of execution context switching, namely:
- Right before starting Wasm module execution in the engine.
- At very beginning and very end of each host function (which is generated with the proc macro).
- Right after completed module execution in the engine.
Storage Migration
Contract code instrumented with metering instructions had to be stored on-chain for every contract. This added up the costs for contract authors in the form of additional storage deposit reserved when uploading contract, as well as cost of performing the instrumentation, which burnt additional gas as well.
When the changes described got deployed on-chain via a runtime upgrade, a corresponding migration has been run which
- Freed up >50% of storage occupied with contract codes.
- Unlocked >40% of funds locked for the storage deposits of the code stored.
Code Became Simpler
Before switching to engine-sided fuel metering, we also had to re-instrument contract code every time the cost schedule was changed. First, it took additional gas burnt for the instrumentation itself. Second, this approach required a bunch of caching logic to guarantee the contract was always run with the latest schedule. All those inefficiencies were wiped out with the change.
More to that, we don’t need to benchmark all Wasm instructions in the pallet anymore, except the single one which serves as a conversion factor base between UoMs of two meters, which looks like this:
Conclusion: Which One Is Better?
Module instrumentation-based fuel metering allows host to be Wasm executor agnostic, which could be preferable at early stages of a project development when it is not yet decided which particular execution engine would be best to use in long term.
On the other hand, it brings additional costs for performing this instrumentation upon each smart contract uploaded to the runtime. Also, it bulks the contract code significantly, while storage is a precious resource especially in blockchain-specific environments. These two points make this type of metering more expensive for the end user, as she pays more for the gas and storage used for the contract.
Last but not the least, it makes the host program implementation being based on certain assumptions, and it requires her to write and maintain a set of benchmarks for every Wasm instruction. In practice this means artificially reducing the scope of Wasm features being used in smart contracts.
Well-designed Wasm engine provides a clear and simple API both for validating the module and for its execution, including fuel metering functional. Therefore, even using specific engine does not necessary make the host being tightly coupled with it. That is why we consider using engine-resided gas metering as a more beneficial approach long-term.
-
See substrate#10918 for more details on Weight V2 (aka 2-dimensional weight); ↩︎