-
Notifications
You must be signed in to change notification settings - Fork 2.7k
Seal: Scratch away the scratch buffer (Part 1) #5513
Description
Background
This issue introduces an incremental redesign of parts of the interface between the contracts pallet and WebAssembly smart contracts.
A similar discussion has already been started some time ago but in the end was not targeted enough and not chunked up into actionable work items. This issue is the beginning of a potential series of issues that chunk up the massive work needed to get the interface into an efficient and nice shape.
Motivation
The current problem with certain parts of the interface is two fold.
- Since we are operating between boundaries of the WebAssembly sandbox and the native host runtime calls between those are generally regarded as expensive. This leads to the design goal of an interface that reduces the number of calls between client (contract) and host (contracts pallet).
- The current interface has explicit notions of a so-called scratch buffer. This is a linear memory buffer on the contracts pallet side and mostly acts as a cache in order to make certain common operations less expensive by memoization. Also it literally is a scratch buffer to store intermediate compute information.
- The problem some people are having with this approach is that the scratch buffer is primarily an implementation detail since a similar interface could be implement with multiple scratch buffer (or registers) as shown by the NEAR protocol interface or arguably without the explicit notion of a scratch buffer as represented in this issue in the following.
- Besides this another bigger interface problem with an explicit scratch buffer is that the interface constraints itself to shared mutable state between different interface methods leading to implicit behavior that might lead to confusion and arcane bugs and behavior.
- While introducing the
ext_hashes we have already taken an approach of avoiding the scratch buffer in their interfaces. So going down this path for other APIs does not introduce another unknown API style.
Example
Current Interface
Let us look at an example of the current API, line out the problems with it and propose another interface that fixes those outlined problems with the same example.
We are going to take a look at a common invocation of the ext_caller API.
This extracts information about the AccountId of the caller of the currently running smart contract execution.
The procedure is as follows:
- Call
ext_callerto make the host side put the information into the scratch buffer. - Query the scratch buffer size (in bytes) via
ext_scratch_size. - Finally read the bytes from the scratch buffer into contract memory via
ext_scratch_read.
In code this looks similar to this:
let mut buffer = <Vec<u8>>::new();
ext_caller();
let req_len = ext_scratch_size();
buffer.resize(req_len, 0x00);
ext_scratch_read(buffer.as_ptr(), req_len);This incorporate 3 mandatory calls between contract and pallet.
Also it has the huge disadvantage of the shared mutable state on the pallet side which is hidden from the contract and would cause trouble if there was a call to another ext_ function somewhere in between for example.
Proposed Interface
This issue proposes to change the API to no longer require 3 calls but instead a single call between contract and pallet. The API for ext_caller looks like the following:
fn ext_caller(output_ptr: u32, output_len: u32, output_req_len: u32) -> u32;Where (output_ptr, output_len) represents a contract-side buffer where the result of ext_caller is to be stored if output_len suffices the requirements and output_req_len is an out-parameter to be set by the pallet that tells the contract side how many bytes have actually been used by output since output_len could in theory be equal to or greater than the actually required bytes.
Returns 0 upon success or 1 if output_len is too small to hold the result.
In this case the contract side needs to handle the error e.g. by increasing the buffer size and do the call again. This second call is obviously "bad" however, given that entities such as ink! or Solang are aware of the issues this case would potentially never really happen since the output buffer would simply be big enough for all use cases. So the API optimizes for the happy path.
Let us look how the call to this new ext_caller API would look like:
// We simply instantiate a big-enough buffer up front.
// This is how ink! already handles all calls because it allows to completely
// drop dynamic memory allocation from the contracts runtime and saves
// tons of bytes per contract.
// For example ink! in reality uses a one-size-fits all 16kB buffer.
let mut output = [0x00_u8; 100];
let mut req_len = 0;
if ext_caller(output.as_ptr(), output.len(), &mut req_len) != 0 {
// Handle very uncommon error: for example trap or try with bigger buffer.
// Since it is so unlikely to happen, ink! would simply bail out (trap).
}Note that in the worst case exactly two calls to ext_caller are issued: If the first call provided an output buffer that was too small the contract still receives the required length of the output buffer via the req_len output-parameter back and can adjust its output buffer size.
Alternative Design
A slight alteration of the proposed design is to merge output_len input parameter and output_req_len output parameter:
fn ext_caller(output_ptr: u32, output_len_ptr: u32) -> u32;This has the advantage of getting rid of a single parameter but has the downside of making the calling process a bit harder and callers have to take extra care not to accidentally mutate state of their internal data structures.
Proposal
This issue proposes to apply the above presented API change not only to ext_caller but to all property-like APIs currently existing in the contracts pallet interface for smart contracts.
This includes but is not limited to:
ext_callerext_addressext_gas_priceext_gas_leftext_balanceext_value_transferredext_nowext_minimum_balanceext_tombstone_depositext_rent_allowanceext_block_number
Future Work
In the future we are trying to apply scratch buffer avoiding interfaces to other APIs in the contracts pallet. We could do this for the majority or even all APIs. However, we have to solve the problem of potentially expensive calls such as ext_call that might explode in costs (gas costs) if called multiple times in case the given output buffer was too small.
The ultimate goal is to completely get rid of the explicit notion of a scratch buffer in the interfaces. However, due to open questions as the one stated above we opted in to make these changes incremental instead of bulk.