Delphi is the successor to the Carthage protocol. Delphi’s main difference from Carthage is that gas costs are lower, so that smart contracts can compute more before hitting the Delphi/Carthage per-operation gas limit of 1,040,000 gas units (gu).
In this post we quantify the difference that Delphi’s lower gas costs will make:
- We start with a description and justification of the Michelson gas model; and then
- we showcase the expected gains for some smart contracts chosen to illustrate the Delphi model’s advantages.
Measuring the gas gains for real-world smart contracts will be the topic of another post.
1. An overview of the Michelson gas model
To recap: gas limits guarantee that block validators only need a fraction of the time interval between blocks to validate a new block.
The exact fraction depends on the speed of the node’s hardware. Tezos tries to be inclusive, so we want even slow hardware to be able to sync the chain in real time.
As we wrote in our last gas post:
overestimating gas costs prevents developers from writing more interesting contracts, while underestimating these costs leads to possible attacks. It is important to get this right.
In fact, there are two ways to allow more computation in a block:
- improve the performance of block validation and decrease the gas costs accordingly, or
- refine the gas model when it overestimates gas costs.
For the Delphi proposal, we did both.
1.1 Execution steps of a smart contract call
A Tezos smart contract is a type of Tezos account consisting of:
- the smart contract balance,
- the smart contract code, which is executable code in the Michelson programming language, and
- the smart contract storage, used to save the contract’s state between calls.
A transfer calls a smart contract by invoking it along with a parameter. The Tezos protocol then executes1 the smart contract code, passing it two inputs:
- the parameter, and
- the contract’s current storage.
Outputs after execution are:
- updated storage, and
- a (possibly empty) list of operations to be added to the queue of pending operations.2
The Tezos protocol uses three distinct formats to represent Michelson scripts and values, listed here in increasingly higher levels of abstraction:
- Raw byte sequences: is how data is stored on disk, or in operation payloads (in particular, parameters are stored as raw byte sequences).
- Micheline: the Micheline format is protocol-independent (think: XML or Json for Michelson code) and portable across protocol versions. Micheline data has a tree-like structure so it is easier to manipulate than byte sequences; but it is still untyped.
- The Michelson internal representation: this format is specific to each version of the Tezos protocol. Michelson expressions written in this format are guaranteed to be well-typed. It is the only format known to the Michelson interpreter.
When a Tezos smart contract is called — either due to some initial call to it, or as a consequence of working through a queue of accumulated operations e.g. from step 8 below — the Tezos protocol performs the following steps:
- Read the contract’s script and storage from disk, as raw byte sequences (the parameter is already in memory).
- Deserialise (convert) the script, storage, and parameter to Micheline.
- Convert Micheline to the internal representation. The protocol code calls this step parsing; the command-line client calls it type-checking (since type errors might be thrown).
- Interpret (execute) the smart contract using as inputs its storage and the parameter. At the end of this step, we obtain the updated storage, and a list of new operations.
- Unparse updated storage — convert from the internal representation to Micheline.
- Serialise this Micheline expression — convert it to a byte sequence.
- Write the byte sequence to disk.
- Queue the list of new operations to apply.
- Loop to 1, until the operation queue is empty.
1.2 Limiting execution, with gas
Each of the execution steps above could take arbitrary time, so to guarantee that nodes can check the validity of operations in reasonable time, the protocol imposes a hard limit on computation and considers as invalid any operations that exceed it. This limit cannot just be a timeout: then fast nodes would have different valid operations than slower ones — and the network would not reach consensus.
Instead, we rely on gas; a hardware-independent abstraction of the time needed to validate operations that is built in to the Tezos protocol.
Here are the usual reasons we consume gas (relative weights depend on the smart contract being executed):
- reading from disk,
- deserialisation,
- parsing,
- interpretation, including a fixed per-operation gas cost,
- unparsing,
- serialisation, and
- writing to disk.
Gas prevents overlong operations, so the chain’s security depends on good estimates of how long each task above will take:
- An underestimate is a security risk because it makes it cheap to launch a denial-of-service attack on a node, using a smart contract.
- An overestimate will unduly restrict the complexity of the smart contracts that can be run, which may annoy users but is not a security risk.
Thus, when we launched the Betanet, our gas constraints were deliberately overestimated, intended to be decreased later by protocol amendments.
1.3 Estimating gas costs (and getting it right)
But how far can we safely decrease gas costs in practice? To quantify this, we performed two kinds of benchmarks:
- Macro benchmarks fill a block with operations designed to stress some particular source of gas consumption (e.g. computation, or disk accesses).
- Micro benchmarks measure the time it takes to execute functions of the protocol on random inputs (of various sizes). This helps build a predictive model of the time taken by the function, so that the gas cost can precisely reflect it.
In the previous Athens and Babylon amendments we adjusted the gas model:
For the Athens proposals, we did macro benchmarks to refine the relative costs of disk accesses and computations. These benchmarks showed that disk accesses were too expensive compared to the other costs and we divided the relative cost of disk accesses by 2 to reflect this.
For the Babylon proposal, we micro-benchmarked the interpretation time of most instructions on random data of various sizes and interpolated cost models to all the benchmarked instructions. This led to a significant decrease of the interpretation costs of most instructions.
For the current Delphi proposal:
- We developed a generator of well-typed Michelson code of arbitrary size and used it to micro-benchmark serialisation and parsing functions.
- We optimised the parsing functions.
- We optimised the Michelson interpreter.
- We updated the Babylon instruction-per-instruction benchmarks to reflect the new optimisations of the interpreter and reduced the interpretation costs accordingly.
- We benchmarked disk accesses on modern hardware and revised the corresponding gas model.
The modifications of the gas model brought by the Delphi proposal are documented in the following merge requests:
Now, we will measure the changes in gas consumption for contracts that are intensive in one of the components and not the others.
2. Theoretical limits
The following Michelson scripts are not realistic; they are designed to measure the gas gains for the Delphi proposal:
2.1 Gas costs for disk access (read / write)
To avoid deserialisation and parsing costs, we read and write lots of data of type bytes
.
Before reaching the gas limit per operation, we hit another limit defined by the protocol: the storage increase limit.
A single transaction may increase the contract storage by no more than 60kB (1kB = 1000 bytes) — we can build larger byte sequences dynamically; see later. To write more than 60kB in a single transaction we must also remove data in the same transaction.
Here is our Michelson script for a smart contract to stress the disk limits:
# Script io.tz
parameter (or (unit %add32K) (or (nat %write) (unit %read)));
storage (pair (nat %counter) (big_map %tank nat bytes));
code
{
UNPAPAIR;
IF_LEFT
{
# %add32K(_ : unit): add 32 KiB to the storage
DROP;
PUSH nat 1; ADD;
PUSH bytes 0x00;
# 15 DUP; CONCAT
DUP; CONCAT;
DUP; CONCAT;
DUP; CONCAT;
DUP; CONCAT;
DUP; CONCAT;
DUP; CONCAT;
DUP; CONCAT;
DUP; CONCAT;
DUP; CONCAT;
DUP; CONCAT;
DUP; CONCAT;
DUP; CONCAT;
DUP; CONCAT;
DUP; CONCAT;
DUP; CONCAT;
SOME; SWAP; DUP; DUG 3; UPDATE; SWAP;
PAIR;
}
{
IF_LEFT
{
# %write(n : nat): store 2^n bytes in the big_map
# First clear the big_map to decrease the storage size diff
DUG 2;
RENAME @counter;
SWAP;
PUSH @index nat 0;
PUSH bool True;
LOOP
{
DUP; DIP { NONE bytes; SWAP; UPDATE };
PUSH nat 1; ADD @index;
DUP @index; DUP @counter 4; CMPGE
};
DROP; SWAP; DROP; SWAP;
# do n DUP; CONCAT
DIP { PUSH bytes 0x00 };
INT;
DUP;
GT;
LOOP
{
PUSH nat 1; SWAP; SUB;
DIP {DUP; CONCAT};
DUP; GT
};
DROP;
SOME;
PUSH nat 0;
UPDATE
}
{
# read(_ : unit): read the bytes that have been stored by %write
DROP 2;
DUP;
PUSH nat 0;
GET; DROP
};
PUSH nat 0; PAIR
};
NIL operation;
PAIR
}
The script io.tz
above declares a storage of type pair (nat %counter) (big_map %tank nat bytes)
. The tank
big map serves as a data tank which we will fill in several transactions, and then empty all at once to get a lot of writing rights. tank
is expected to contain at index i
32KiB (1KiB = 1024 bytes) of zeros if i
is smaller than the counter counter
, and nothing otherwise. This script features three entrypoints:
add32K
inserts data into the tank.
It adds 32KiB of data (note that 64KiB would exceed the storage size diff limit) in the first empty slot in the big map, and increments the counter to maintain the invariant.write n
empties the tank.
It putsNone
in all big map entries from \(0\) to the value of the counter, resets the counter to \(0\), and puts a byte sequence of size \(2^n\) at the first position in the big map.read
accesses the first position in the big map and does nothing with it.
This entrypoint returns the storage unchanged.
We use the script io.tz
as follows:
- We originate it on a default storage
Pair 0 {}
; and - we repeatedly call the
add32K
entrypoint to insert enough data in the tank; and - once the storage is big enough (experimentally, 64 calls to
add32K
are enough), we callwrite
with the largest parameter that the gas limit allows; and finally - we call
read
.
For the read
entrypoint, thanks to the --trace-stack
option of the client, we can compute the gas consumption of the GET
instruction alone, so the gas consumption caused by reading the disk is well-isolated from other gas consumptions.
- In Carthage, the largest
n
we can pass thewrite
entrypoint isn=15
, which hence writes 32KiB (\(2^{15}\)B) and consumes 535741 gu (thus slightly more than half the per-operation gas limit of 1,040,000 gu). Reading back these 32KiB costs approximately a third of the gas limit (the gas consumption of theGET
instruction is 344481 gu). - In Delphi, we can reach
n=21
which writes 2MiB (\(2^{21}\)B) and also consumes exactly 555330.160 gu (again slightly more than half the gas limit, which is the same as Carthage’s); theGET
instruction of theread
entrypoint then consumes almost as much gas (520045.437 gu).
In summary, comparing the gas costs in Carthage and Delphi:
- Writing large pieces of data costs about 62 times less (a 98.4% saving).
- Reading data costs about 42 times less (a 97.6% saving).
Quantifying the real-world impact of these cost reductions on practical Delphi usage will be the topic of the future blog post — but we can note here that this is clearly a significant reduction.
2.2 Gas costs for parsing code and data
Parsing costs are easy to decorrelate from the other sources of gas consumption thanks to the tezos-client typecheck data
and tezos-client typecheck script
commands that report exactly the amount of gas consumed at parsing. The Delphi gas model for parsing costs typically 40 times less than Carthage’s one.
To assess typechecking costs, we construct a synthetic script big_script.tz
, which contains 4000 DUP
and 4000 DROP
.
We then typecheck this script via the tezos-client typecheck script
command.
This costs 248168 gas units under Carthage but only 6003.655 gas units under Delphi; so in this example the Dephi gas model costs 41.37 times less.
2.3 Gas costs for contract interpretation
Gas costs in the Michelson interpreter include
- costs for instruction interpretation as discussed — and also
- overhead costs in the Michelson interpreter due to the gas accounting system itself.
In Delphi we have optimised both. To evaluate the gains due to the Michelson interpreter optimisations in Delphi, we consider the scripts of two contracts:
2.3.1 Gas costs for constructing a large data structure
Let’s first consider a contract that builds a huge list and does nothing with it:
# Script big_list.tz
parameter nat;
storage unit;
code
{
CAR;
INT;
NIL unit;
UNIT;
CONS;
SWAP;
DUP;
GT;
LOOP
{
PUSH nat 1; SWAP; SUB;
SWAP;
DUP; ITER {CONS};
SWAP; DUP; GT
};
DROP 2;
UNIT; NIL operation; PAIR
}
When the contract big_list.tz
receives n
as parameter, it:
- Builds a
list unit
that initially contains a single element. - Iterates
n
times in a loop that concatenates the list with itself (DUP; ITER{CONS}
). - Empties the stack (
DROP 2
).
So this contract pays gas for building a list of length 2^n
, and only for that.
- In Carthage we can set at most
n=20
and this consumes 996143 gas units. - In Delphi, the same contract with
n=20
consumes 230406 gas units.
Thus, the cost in Delphi is 4.32 times less.
2.3.2 Gas costs for arithmetic
Arithmetic in Michelson is implemented using the Zarith arbitrary-precision library, a well-optimised OCaml wrapper of the GMP C library. Neither Zarith nor GMP has been modified in the development of Delphi and the cost models for arithmetic functions have been quite precise since Babylon, so there’s little hope for further optimisation in Delphi, for smart contracts that are mostly arithmetic computation.
To measure this, we wrote a simple loop-based factorial in Michelson:
# Script arith.tz
parameter nat;
storage unit;
code
{
CAR;
PUSH @acc nat 1;
PUSH @i nat 2;
DUP;
DUP 4;
CMPGE;
LOOP
{
DUP;
DIP {MUL @acc};
PUSH nat 1; ADD @i;
DUP;
DUP 4;
CMPGE;
};
DROP 3; UNIT;
NIL operation; PAIR
}
As in the big_list.tz
example, the result of the computation is not stored, to isolate the cost of the computation from the cost of storing the result.
The biggest parameter that can be sent to this contract within the gas limit in Carthage is exactly 6400. With this parameter,
- 1039686 gas units are consumed in Carthage and
- 389054 gas units are consumed in Delphi.
Thus, the factorial script costs 2.67 times less in Delphi.
2.4 Gas costs for inter-contract calls, in two parts
Part 1 of 2: Recursion
In Carthage, applying an operation has a minimal cost of 10 kgu (1 kgu = 1000 gu). In Delphi, the minimal cost is 1 kgu, thus ten times smaller. As we shall see below when we look at the self_recursion.tz
script, this minimal cost is no longer a dominating cost for contracts that perform a lot of calls.
A simple way to test how many contract calls can be achieved in Carthage and in Delphi is to write a recursive contract that calls itself n
times and then stops: it takes a number n
as parameter and stops if n
is 0
, and calls itself on n-1
otherwise.
Here is the corresponding Michelson script:
# Script self_recusion.tz
parameter int;
storage unit;
code
{
UNPAIR; DUP; EQ;
IF
{ DROP; NIL operation; PAIR }
{
PUSH int 1; SWAP; SUB;
SELF; PUSH mutez 0; DIG 2; TRANSFER_TOKENS;
NIL operation; SWAP; CONS; PAIR
}
}
-
In Carthage, we measured that each recursive call of
self_recursion.z
consumes 13178 gas units, so the largestn
we can send to this contract within the protocol-set per-operation gas limit (1,040,000 gu) isn=77
. -
In Delphi, each recursive call of this contract only consumes 2871.559 gas, so the largest
n
we can send to this contract within the per-operation gas limit (identical to Carthage’s) isn=361
.
We see that in Carthage the gas costs of self_recursion.tz
are dominated by the 10 kgu cost of operation application, which represents about 3/4th of its total gas consumption. In Delphi, it only represents about 1/3rd of the total.
Part 2 of 2: A chain of calls
Calling a contract (recursively or otherwise) incurs a non-trivial gas cost, corresponding to loading, deserialising, and type-checking the contract as well as checking that the parameter to the call has the right type (cf. discussion above).
Since typical inter-contract interactions are not recursive, we concentrate here on non-recursive calls.
To test non-recursive contract calls, we build a chain of smart contracts. Each contract calls the next until we reach the last contract in the chain, which then originates a new contract to grow the chain. More precisely:
- The contracts in the chain have two possible states called
forwarding
andfinal
. - In
forwarding
state, the contract- stores an address,
- casts it to
contract unit
(the type of contracts expecting no parameter), and - calls it.
- In
final
state, the contract- originates a copy of itself with an initial storage in
final
state, - stores the address of the newly-originated contract, and
- switches to
forwarding
mode.
- originates a copy of itself with an initial storage in
Unfortunately, there is no instruction in Michelson to originate a copy of oneself; the only way to originate a contract from Michelson is with the CREATE_CONTACT
instruction, which expects as static argument the full script of the contract to originate. Therefore, to originate a copy of the current contract, we cannot use CREATE_CONTRACT
directly in the contract script: there is no finite script satisfying the equation script = { ...; CREATE_CONTRACT script ...; ... }
. We need to untie this recursive3 equation and solve it at run-time, by either placing part of the script in the storage (using a lambda) or in the script of another contract.
We choose the former option (to put it in the storage) and leave the motivated reader to implement a solution based on the latter as an exercise.
The lambda that the contract stores needs to return both the origination operation and the address of the newly created contract, so its return type is pair address operation
. As input, it needs some data to initialise the storage of the freshly-originated contract, which is basically the lambda itself in serialised form.
The trick to solve the recursive equation mentioned above is to have the lambda deserialise its own code using the UNPACK
instruction — so the input type of the lambda is bytes
and its code starts with UNPACK (lambda bytes (pair address operation))
— followed by code to originate the contract using the CREATE_CONTRACT
instruction.
The complete script of the contract used by each link in the call chain is:
# Script link_contract.tz
parameter unit;
storage (or (lambda %final bytes (pair address operation)) (address %forward));
code
{ NIL operation;
SWAP;
CDR;
IF_LEFT
{
# in %final mode, call the stored lambda on itself
DUP ;
PACK;
EXEC;
UNPAIR;
DIP{CONS}
}
{
# in %forward mode, call the stored address
DUP;
CONTRACT unit;
ASSERT_SOME;
PUSH mutez 0;
UNIT;
TRANSFER_TOKENS;
SWAP;
DIP{CONS}
};
# in both cases, we end in %forward mode
RIGHT (lambda bytes (pair address operation));
SWAP;
PAIR
}
And its initial storage is:
Left { UNPACK (lambda bytes (pair address operation));
ASSERT_SOME;
LEFT address;
PUSH mutez 0;
NONE key_hash;
CREATE_CONTRACT {<<script_of_the_link_contract>>};
SWAP;
PAIR}
- In Carthage, the cost of each transfer in this chain of calls is 27817 gu. We can build a chain of 36 transfers and one origination before reaching the per-operation limit of 1,040,000 gu.
- In Delphi, the cost for each transfer is about 4779 gu. We can build a chain of 217 transfers and one origination before reaching the limit of 1,040,000 gu.
Thus, Delphi lets us grow the chain by a factor of almost 6.
Conclusion
The Delphi protocol reduces gas costs and allows more complex smart contracts to be deployed and executed. The gas gains vary depending on the source of gas consumption. We measured this using scripts tailored to reach the gas limit in different ways. The results in short are:
Gas source | Carthage consumed gas | Delphi consumed gas | Ratio |
---|---|---|---|
Writing to disk | 535741 gu / 32 KiB | 555330.160 gu / 2MiB | 61.7 |
Reading from disk | 344481 gu / 32 KiB | 520045.437 gu / 2MiB | 42.4 |
Parsing | 248168 gu | 6003.655 gu | 41.4 |
Computation | 996143 gu | 230406.664 gu | 4.32 |
Arithmetic | 1039686 gu | 389054.329 gu | 2.67 |
Recursion | 13178 gu | 2871.559 gu | 4.59 |
Inter-contract call | 27817 gu | 4779.106 gu | 5.82 |
So to sum up:
- Delphi enjoys a significant reduction in parsing costs: thanks to the refinement of the gas model for parsing, most contracts should see their parsing gas reduce by a factor of about 40, compared to Carthage, making it more practical to deploy and operate on larger contracts.
- Similarly the cost of large disk accesses (I/Os) is reduced by a factor of about 60 for the largest disk writes.
- Together, our improvements make inter-contract calls cheaper by a factor of 6 in our tests.
- Improvements in contract interpretation costs exist though they are less striking because the cost model was quite precise already in Babylon (the version preceding Carthage). Yet, even here we can see quite significant improvements thanks to Delphi’s optimisations in the gas costs of the Michelson interpreter’s gas accounting system.
In a future blog post, we will measure the gas gains observed on a collection of real-world contracts that are either already popular on mainnet or about to be launched.
-
“Executes” here means “interprets”, in the sense of “an interpreter”. ↩
-
Returning a (non-empty) list of operations is sometimes also referred to as emitting or applying them. ↩
-
We distinguish between a recursion when a lambda calls itself, and a recursive contract call when a contract calls itself (with all the costs this entails). Here, we’re dealing just with the first kind of recursion. ↩