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!