Skip to content
This repository was archived by the owner on Nov 15, 2023. It is now read-only.
This repository was archived by the owner on Nov 15, 2023. It is now read-only.

Improve the throughput of wasm contracts on parachains #9354

@athei

Description

@athei

Improve the throughput of wasm contracts on parachains

We want to improve the throughput that can be achieved when pallet_contracts is run on a parachain. It is a cross team long term effort and this is the meta issue to coordinate said effort.

Problem statement

More complex ink! generated smart contracts are big in Wasm code size right now which leads to parachain transaction throughput to be limited by bandwidth rather than computational complexity. Read on to learn why that is.

Parachain Validation

During parachain development it came to light that the transaction throughput will be bottle necked by validator bandwidth rather than computation power (block weight limit). In order to understand why this is we need to look into how parachain validation works:

All blocks submitted for validation by the parachain collators are validated by a shared pool of relay chain validators that are not specific to any parachain. Therefore those do not hold any state (storage, database) of any parachain connected to the relay chain. In order to allow them to validate a block without any state (of the parachain) the collators are required to send the values of all storage items accessed in the block that is submitted for validation together with a merkle proof (witness). We call the entity that contains the said information (among other things) the PoV (Proof of Value). Validators are effectively functioning like light clients with regard to the parachains.

This means that every storage item accessed in a block must be distributed as part of the PoV to the assigned validators via network every time the item accessed for the first time in a block. The parchain team currently targets a size of 2-4 MiB for the PoV.

One can easily see that transaction which access a large amount of storage (in bytes) are expensive to execute in this scheme because they make us reach the PoV size limit much quicker than the block weight limit.

Smart Contracts

With the above knowledge we can now understand why this is especially bad for smart contracts: The contract itself (the wasm blob) is a storage item and needs to included in the PoV (and therefore send via network) whenever it is called so that validators are able to execute the contract. In a standalone chain the binary only needs to be send via network when it is uploaded which is a much rarer occurrence.

We can do some napkin math to determine that this in fact hinders our throughput: The ink! ERC20 example compiles to a 32 KiB wasm binary. Using our 4 MiB PoV limit we can only fit 128 transactions in a block which is a transaction speed of 21 tps assuming a 6 second block time. This limit applies no matter how cheap the called message inside the contract is to execute because the whole contract needs to be pulled from storage and hence included in the PoV.

The ERC20 contract is considered a rather small to medium sized contract. However, in our example we assume 128 unique contracts being called. That is the worst case. The size limit on Ethereum for EVM contracts is 24 KiB. So wasm contracts are at a clear disadvantage right now in terms of size. Using pallet_evm will most likely yield a higher throughput (than pallet_contracts) just because the contracts are smaller. The Open Zeppelin ERC20 contract compiles down to 3 KiB EVM byte code. This is not a fair comparison, though: The ink! version does more things since ink! tries to play well in a world with storage fees but it shows how much bigger ink! contracts are right now.

What about solang?

Solang can compile solidity to wasm contracts executable by pallet_contracts. It is therefore an attractive tool to measure how much of the additional size is because of wasm and how much because of Rust (ink!). We compiled the Open Zeppelin ERC20 contract with solc and solang and compressed it with zstd. This is the result:

solidity/ERC20.raw : 51.77%   (3214 =>   1664 bytes ) 
solang/ERC20.wasm  : 37.09%   (9666 =>   3585 bytes )

You can see than the increase is less dramatic but still 3x uncompressed. Part of it is because of the lower entropy (simpler instructions) of wasm code which can be observed looking at the compressibility. The rest is that solang just produces more code. It is not because of of the wasm module structure itself (types, import declaration) which contributes less than 1 % in this specific case. The reasons are unclear and could maybe attributed to solang being less optimized than solc.

Update

I ran an experiment with the solang compiler. I set the address type to 4 bytes (instead of 32) in the solang compiler source. I also removed all wide integer types from the solidity ERC20 source.

With these changes the size of the wasm compiled ERC20 contract was reduced from 3x (as mentioned above) to 1.3x relative to the EVM one (after compression). I think we can pin the size explosion to the wide integers and not to wasm itself.

Given that no arithmetic is happening on the address type that overhead is coming from merely passing this large value around (as value on the stack). So every access to this type (it is accessed a lot) generates 4 stack accesses (4x32bit). Treating the address as a value type is what we should learn from this.

The repository where this experimentation happens can be found here. It is planned to gather statistics over multiple solidity contracts with the changes regarding large data types applied. However, most contracts won't compile with the address type changed in solang. This is on halt until the issue in solang is resolved.

Proposed Solutions

Clearly there is some work to be done to increase the transaction throughput. When designing pallet_contracts several years ago we thought that the faster execution of wasm contracts gave us an edge over EVM contracts but it turned out that the more important metric is contract size when a contract is executed on a parachain. We need shift our optimization focus. Once we overcome the size issue the faster execution (using JIT) will enable much richer contracts than possible on EVM.

In the following we list all the proposed solutions which could help to increase our throughput. In addition we link all the issues that work towards accomplishing those solutions:

Reduce ink! contract size

This one is quite simple to understand: ink!, the de facto standard language to produce binaries for pallet_contracts, produces rather big artifacts. Reasons for this include but are not limited to:

  • ink! being a proc. macro instead of a fully fledged compiler cannot afford certain optimizations
    • This might improve with future Rust versions empowering proc. macro utilities
  • Certain core ink! data structures have been designed for execution speed and not for Wasm file size
  • Unlike Ethereum ink! has been designed for a smart contract ecosystem that features storage rent. This requires ink! to provide clean-up utilities for storage that is no longer in use and requires data structures to track their associated storage cells.

Other possible reasons are rooted in Rust (rather than ink!):

  • Monomorphization
  • Dependency creep
    • This includes large parts of the core library being pulled in (by accident)

We need to work hard to get this size down. This will be an incremental long term process but there are probably some low hanging fruits which will dramatically decrease size in the short term. The ink! team will analyze the currently produced contracts and bring down their sizes whether those are caused by the ink! or Rust itself (for example by preventing monomorphization with trait objects).

Progress

For an overview of what the ink! team is working have a look at their size reduction project.

Code Merkleization

Code Merkleization will be important in order to enable bigger contracts to be economically viable.

Huffman Coding

Huffman coding of instructions can yield some substantial size savings when compared to applying zstd over the whole PoV because we can tailor a fixed encoding table specifically for our use case. We should look into what can be gained there.

@0x7CFE

Teams involved

ink!

Led by @Robbepop and responsible for optimizing ink! codegen (generating Rust) with regards to code size. The ink! team is also in charge of cargo contract which plays a vital role in enforcing the correct compiler flags for optimal codegen.

Compiler

Led by @testhound. These are our experts for low level codegen (generating wasm). All things LLVM and JIT.

Contracts Pallet

Led by @athei. Sits in the middle and connects the different teams. Also implements functionality in pallet_contracts that enables further size saving in ink!.

Parachains

Led @rphmeier. Advises the other teams on how to optimize best for the parachain protocol.

Metadata

Metadata

Assignees

Labels

J1-metaA specific issue for grouping tasks or bugs of a specific category.

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions