Cadence Compilation

Background

Since its inception, Cadence language has come a long way, getting richer in features, safety, and usability day by day. The reference implementation of Cadence has been using an interpreter to execute programs, making it easier to improve and evolve the language faster. The existing interpreter executes code by traversing the abstract syntax tree (AST) produced by the parser, with the use of the type information produced by the type checker.

The Cadence team is embarking on an exciting new chapter in the language implementation - compiling Cadence programs into a lower-level representation. This will unlock path to improved transaction execution performance and greater flexibility in ensuring the language remains backward compatible.

Improved Performance

By design, AST interpreters are not the most efficient for implementing a programming language runtime, in terms of both memory usage and run-time performance. Several factors affect this lower performance, such as the overhead of traversing the AST and finding the information needed to execute the program, deeply-nested recursive function calls, large memory footprint due to tree nodes, etc. While un-optimized compiled program outperforms interpreter in all the areas mentioned above, applying bytecode optimizers to the compiled program can further enhance its efficiency.

Backward Compatibility

A major goal of the Cadence 1.0 release was to provide backward compatibility for deployed contracts. Over time, as new changes are introduced, maintaining backward compatibility may require identifying the version of Cadence used to deploy each contract and using the corresponding runtime for execution. However, because contracts often interact with one another, this creates a need for different Cadence runtimes to communicate, significantly increasing the complexity.

In contrast, compiling all contracts and transactions into a unified bytecode instruction set enables execution with a single Cadence runtime, greatly simplifying backward compatibility.

Considerations

Compiling a higher-level language like Cadence into a lower-level representation can be approached in various ways. Selecting the right method requires careful consideration of several key factors.

Security

One of the design goals of the Cadence language is security and safety, to ensure that no program can gain unauthorized access to the resources of the network, or negatively affect it’s operation. A secure Cadence runtime must include safety features such as: runtime resource invalidations, memory and computation limits, ability to recover from unintended crashes, etc.

Interoperability with the host

Another aspect to consider when choosing an approach is the interoperability with the host environment, which is the Blockchain. Flow blockchain’s core protocol layer is implemented in the Go language, and the Cadence runtime frequently communicates with this layer for operations like accessing the storage, performing cryptographic operations, etc. Overhead of this communication must be minimal, and should not undo the performance improvements gained from the compilation.

IO-bound vs CPU-bound operations

Related to the previous point on interoperability: A typical workload of contracts and transactions mostly consist of IO-bound operations, such as reading an account storage, working with Capabilities, and storage references, etc. They hardly perform any CPU-heavy operations such as complex mathematical computation, or tree traversal/searching algorithms. Thus, it is essential for the runtime to be able to perform IO-bound operations optimally, even if the CPU-bound operations are not the most optimal.

Approaches

Compiling to Native

Executing a program compiled to native machine code is the most performant option. It is possible to make use of existing compiler toolchains like LLVM to reduce the amount of work on writing the compiler backend. For example, a Cadence program can be compiled to the LLVM intermediate representation (LLVM IR) and then be compiled to native either statically (using llc) or dynamically interpret the IR with just-in-time compilation (using lli).

However, while native code is faster to execute, compiling a higher level language to native takes a long time. For example, compilation time for LLVM IR is significantly high. This can be a problem as/if the compilation for transactions happens on-chain (more details in the next section)

One of the downsides of this approach is the lack of control over the execution environment. Having a user code compiled directly to native machine code as-is wouldn’t include the safety features we discussed in the previous section, and would require third-party tools to integrate such safety and security features into the runtime.

Another drawback is the interoperability overhead. The preliminary analyses showed that the overhead of using an existing library like cgo or an inter-process communication (IPC) layer (e.g.: pipes, sockets, etc.) to communicate between Cadence runtime and the host environment is unacceptably large.

Targeting an existing VM/runtime

Another approach for compiling Cadence is to reuse an existing well-known byte-code format and an existing battle-tested execution environment. For example, useing general-purpose Instruction Set Architecture (ISA) such as JVM bytecode and JVM, or WeAssembly (WASM) instructions together with an existing WASM runtime such as Wasmer, Wasmtime, WasmEdge, etc. Furthermore, a bytecode set and runtime specifically designed for blockchains, such as MoveVM (move-on-aptos, move-sui), could also be used.

Among these, WASM and MoveVM have been promising candidates in terms of suitability for Cadence’s use cases. However, the downside, similar to compiling to native is that both of these runtimes do not have Cadence-specific runtime-semantics, and would require extending the runtime to integrate those. Preliminary experiments revealed that extending a WASM runtime introduces a performance overhead, negating the gains achieved through the compilation.

Additionally, the interoperability between the runtime and the host environment poses the same challenges as compiling to native code, as they operate in separate environments, resulting in significant communication overhead.

Compiling to a custom Instruction Set Architecture (ISA)

The third approach is to compile Cadence programs to a custom byte-code/instruction set and developing a dedicated virtual machine (VM) to execute them.

The biggest advantage of this approach is that it’ll make it possible to design the instruction set to match Cadence runtime semantics specifically. It also provides complete control over the execution environment, making it easier to integrate safety and security mechanisms, such as the runtime type checks and static analyses of the bytecode.

Additionally, implementing the VM in the same language as the host environment ensures seamless integration while minimizing potential overhead.

There are two downsides of this approach. First, implementing a fully optimized VM from the ground up is a complex and resource-intensive task compared to leveraging an existing runtime. Second, even with a well-designed VM, executing a higher-level bytecode instruction set will inherently be less efficient than running natively compiled code.

On-chain vs Off-chain Compilation

A key priority in transitioning from an interpreter to a compiler is ensuring minimal disruption for developers building in Cadence. To achieve this, contract and transaction compilation will happen seamlessly within the Cadence execution runtime, meaning dApp developers will experience no changes in how transactions are submitted or contracts are deployed. Moreover, Flow team’s belief is that all smart contract source code should be available on-chain.

While offloading compilation outside of execution nodes (ENs) could reduce their workload and potentially increase transaction throughput (TPS), it introduces significant challenges. Off-chain compilation would require a robust verification mechanism to ensure the correctness and security of the bytecode before execution. Developing a verifier capable of enforcing all runtime semantics solely through bytecode analysis would be highly complex and computationally demanding, ultimately adding overhead. Given these constraints, off-chain compilation for Cadence transactions is not currently being pursued.

What’s Next?

So far, the Cadence team has been evaluating the various approaches discussed above and has been implementing different prototypes. The next step would be to finalize the most suitable approach and then convert the prototype to a production-ready implementation.

If this is something interesting to you, and have ideas, opinions, or expertise related to what we are doing, please do reach out to us and help us make Cadence better and faster!

4 Likes

For On-chain Compilation, if I understand correctly, it is:
Transaction scripts will be compiled in ANs(or other non-ENs), and executed in ENs with its compiled bytecode?

1 Like

Yes, correct. Transactions will be compiled and executed (the compiled bytecode) in ENs

1 Like

Just to clarify, transactions and scripts will continue to be sent to the network and processed across the nodes in the network in source form, just like they are now. Only individual nodes will compile, as an “implementation detail”. As far as I can see, Access Nodes will not need to compile, only Execution and Verification Nodes.

1 Like

For the closer future, compiled cadence will primarily benefit the Execution Node. Access Nodes would continue to interpret the cadence code. Though, as an outlook, I thought it might be interesting to share some scenarios where Access Nodes could benefit from compilation as well.

I think Access Nodes could compile frequently-used scripts and contracts (heuristic selection which to compile and which not). Nevertheless, working with complied cadence would more complicated for Access Nodes compared to Execution Nodes because

  • Access Nodes serve data from a very large span of blocks and larger span means generally more changes
  • Execution Nodes proceed block by block so they can detect when a transaction updates a smart contract, invalidate the cached complied representation of that smart contract and cache the newly compiled one instead. An Access Node does not necessarily inspect what happened during the execution of every block, so they would need a more sophisticated mechanism to detect if a smart contract changed.

Nevertheless, also for developers running their own Access Nodes it could be beneficial in the mid- to long-term to use compiled scripts. For example, if I need to execute the same script over and over again for my dap, I could benefit from the smaller resource footprint of a compiled version … and I would know when I change my script on my Access Node. Also for other common scripts like “balance of account X” an Access Node could benefit from a compiled representation.

The central point is that in my opinion, the protocol should maintain the human-readable source code while compilation is only a node-internal optimization for executing the code faster.

1 Like

I am curious why Verification Nodes would need to compile? I think their latency is much less critical compared to Execution Nodes. So I think for the time being, only Execution Nodes could compile, while verification Nodes could continue to interpret Cadence? That way, we would have a very good check that compilation does not change the output of a transaction.

Given the human-readable Cadence, I don’t think that Verification Nodes could rely on the Execution Node’s compiled byte-code … because an Execution Node could be byzantine and compile incorrectly. So the verifiers would need to recompile and could only use their compiled code for once chunk, because they verify a chunk without knowing what happened in the other chunks (smart contract could be updated).

1 Like

Off-chain compilation would require a robust verification mechanism to ensure the correctness and security of the bytecode before execution.

why do we need verification here? ( btw I agree that transaction should be plaintext on protocol ) but couldn’t understand the need for verification part. To be clear, technically VM bytecode can be unsafe?

another question I have is; what is the approx. performance gain for VM against Interpreter ? Considering current linear flow of checking / interpreting.

2 Likes

Right, this has been the consensus so far.

2 Likes

Compilation is an implementation detail, any node kind and concrete node can choose how it wants to execute the Cadence code. None of the node kinds need to compile. When and where compilation will be used will depend on several factors.

Maybe there was a misunderstanding, but as I replied above Cadence Compilation - #4 by bastian, compilation is an implementation detail and e.g. bytecode will not be visible to users, shared across nodes, etc.

3 Likes

why do we need verification here? ( btw I agree that transaction should be plaintext on protocol ) but couldn’t understand the need for the verification part. To be clear, technically VM bytecode can be unsafe?

It’s not about the bytecode being unsafe, but more about whether the EN can trust a bytecode sent from a third party. Regardless of whether it is executing a compiled bytecode or executing via an AST interpreter, the runtime always relies on certain checks done by the parser and type-checker/semantic analyzer to execute a program correctly. If the type-checking/compilation happens on the same node, then the interpreter/VM can trust that the AST/bytecode it executes has been generated by the Cadence type-checker/compiler, for that provided source code, and assume it is correct. But if the source code and/or the bytecode/AST was sent outside of the EN, then there’s no guarantee that the bytecode/AST is correct or corresponds to the same source code.

So if the bytecode/AST is being sent externally, it needs to be verified.

4 Likes

I think it’s clear now in my head :crossed_fingers:. Thanks Bastian and Supun for the detailed answers and clarifications.

2 Likes

Sorry I missed this second question. It is difficult to say an exact number at this stage (still very early stages). Because while we would get an improvement on the execution/runtime by switching to the VM from the interpreter, we are also adding a new layer/step for compilation which wasn’t there before. So this new step would also add some overhead and might cancel-out some of gains we get from the VM. But from the preliminary tests, we are expecting the performance to be alteast similar to the existing interpreter. (This is mostly applicable for the trasnactions. Contract codes would be pre-compiled and cached)

However, in the long term, once parallel checking/compilation is implemented (not sure if the FVM already supports this), we should see a significant improvement in the performance (compared to having parallel checking with the interpreter)

2 Likes

In addition, we’ll also be able to cache e.g. compilation result for e.g. contracts (which change infrequently), avoiding the compilation overhead.

2 Likes