Skip to content

Latest commit

 

History

History
238 lines (206 loc) · 9.52 KB

gas_builtin_accounting.md

File metadata and controls

238 lines (206 loc) · 9.52 KB

Gas and Builtins Accounting

This section documents how programs generated by Cairo Native keep track of gas and builtins during execution.

Gas

Introduction

Gas management in a blockchain environment involves accounting for the amount of computation performed during the execution of a transaction. This is used to accurately charge the user at the end of the execution or to revert early if the transaction consumes more gas than provided by the sender.

This documentation assumes prior knowledge about Sierra and about the way gas accounting is performed in Sierra. For those seeking to deepen their understanding, refer to Enitrat’s Medium post about Sierra and greged’s about gas accounting in Sierra.

Gas builtin

The gas builtin is used in Sierra in order to perform gas accounting. It is passed as an input to all function calls and holds the current remaining gas. It is represented in MLIR by a simple u64.

Gas metadata

The process of calculating gas begins at the very outset of the compilation process. During the initial setup of the Sierra program, metadata about the program, including gas information, is extracted. Using gas helper functions from the Cairo compiler, the consumed cost (steps, memory holes, builtins usage) for each statement in the Sierra code is stored in a HashMap.

Withdrawing gas

The action of withdrawing gas can be split in two steps:

  • Calculating Total Gas Cost: Using the previously constructed HashMap, we iterate over the various cost tokens (including steps, built-in usage, and memory holes) for the statement, convert them into a common gas unit, and sum them up to get the total gas cost for the statement.
  • Executing Gas Withdrawal: The previously calculated gas cost is used when the current statement is a withdraw_gas libfunc call.

The withdraw_gas libfunc takes the current leftover gas as input and uses the calculated gas cost for the statement to deduct the appropriate amount from the gas builtin. In the compiled IR, gas withdrawal appears as the total gas being reduced by a predefined constant. Additionally, the libfunc branches based on whether the remaining gas is greater than or equal to the amount being withdrawn.

Example

Let's illustrate this with a simple example using the following Cairo 1 code:

fn run_test() {
    let mut i: u8 = 0;
    let mut val = 0;
    while i < 5 {
        val = val + i;
        i = i + 1;
    }
}

As noted earlier, gas usage is initially computed by the Cairo compiler for each state. A snippet of the resulting HashMap shows the cost for each statement:

...
(
    StatementIdx(26),
    Const,
): 2680,
(
    StatementIdx(26),
    Pedersen,
): 0,
(
    StatementIdx(26),
    Poseidon,
): 0,
...

For statement 26, the cost of the Const token type (a combination of step, memory hole, and range check costs) is 2680, while other costs are 0. Let's see which libfunc is called at statement 26:

...
disable_ap_tracking() -> (); // 25
withdraw_gas([0], [1]) { fallthrough([4], [5]) 84([6], [7]) }; // 26
branch_align() -> (); // 27
const_as_immediate<Const<u8, 5>>() -> ([8]); // 28
...

When the Cairo native compiler reaches statement 26, it combines all costs into gas using the Cairo compiler code. In this example, the total cost is 2680 gas. This value is used in the withdraw_gas libfunc and the compiled corresponding IR to withdraw the gas and determine whether execution should revert or continue. This can be observed in the following MLIR dump:

llvm.func @"test::test::run_test[expr16](f0)"(%arg0: i64 loc(unknown), %arg1: i128 loc(unknown), %arg2: i8 loc(unknown), %arg3: i8 loc(unknown)) -> !llvm.struct<(i64, i128, struct<(i64, array<24 x i8>)>)> attributes {llvm.emit_c_interface} {
  ...
  %12 = llvm.mlir.constant(5 : i8) : i8 loc(#loc1)
  %13 = llvm.mlir.constant(2680 : i128) : i128 loc(#loc1)
  %14 = llvm.mlir.constant(1 : i64) : i64 loc(#loc1)
  ...
  ^bb1(%27: i64 loc(unknown), %28: i128 loc(unknown), %29: i8 loc(unknown), %30: i8 loc(unknown)):  // 2 preds: ^bb0, ^bb6
    %31 = llvm.add %27, %14  : i64 loc(#loc13)
    %32 = llvm.icmp "uge" %28, %13 : i128 loc(#loc13)
    %33 = llvm.intr.usub.sat(%28, %13)  : (i128, i128) -> i128 loc(#loc13)
    llvm.cond_br %32, ^bb2(%29 : i8), ^bb7(%5, %23, %23, %31 : i252, !llvm.ptr, !llvm.ptr, i64) loc(#loc13)
    ...

Here, we see the constant 2680 defined at the begining of the function. In basic block 1, the withdraw_gas operations are performed: by comparing %28 (remaining gas) and %13 (gas cost), the result stored in %32 determines the conditional branching. A saturating subtraction between the remaining gas and the gas cost is then performed, updating the remaining gas in the IR.

Final gas usage

The final gas usage can be easily retrieved from the gas builtin value returned by the function. This is accomplished when parsing the return values from the function call:

...
for type_id in &function_signature.ret_types {
    let type_info = registry.get_type(type_id).unwrap();
    match type_info {
        CoreTypeConcrete::GasBuiltin(_) => {
            remaining_gas = Some(match &mut return_ptr {
                Some(return_ptr) => unsafe { *read_value::<u128>(return_ptr) },
                None => {
                    // If there's no return ptr then the function only returned the gas. We don't
                    // need to bother with the syscall handler builtin.
                    ((ret_registers[1] as u128) << 64) | ret_registers[0] as u128
                }
            });
        }
        ...
    }
    ...
}
...

This code snippet extracts the remaining gas from the return pointer based on the function's signature. If the function only returns the gas value, the absence of a return pointer is handled appropriately, ensuring accurate gas accounting.

Builtins Counter

Introduction

The Cairo Native compiler records the usage of each builtins in order to provide information about the program's builtins consumption. This information is NOT used for the gas calculation, as the gas cost of builtins is already taken into account during the gas accounting process. The builtins counter types can each be found in the types folder. Taking the Pedersen hash as an example, we see that the counters will be represented as i64 integers in MLIR. Counters are then simply incremented by one each time the builtins are called from within the program.

Example

Let us consider the following Cairo program which uses the pedersen builtin:

use core::integer::bitwise;
use core::pedersen::pedersen;

fn run_test() {
    let mut hash = pedersen(1.into(), 2.into());
    hash += 1;
}

We expect Native to increment the pedersen counter by 1 given the above code. Let's first check how this compiles to Sierra:

const_as_immediate<Const<felt252, 1>>() -> ([1]); // 0
const_as_immediate<Const<felt252, 2>>() -> ([2]); // 1
store_temp<felt252>([1]) -> ([1]); // 2
store_temp<felt252>([2]) -> ([2]); // 3
pedersen([0], [1], [2]) -> ([3], [4]); // 4
drop<felt252>([4]) -> (); // 5
store_temp<Pedersen>([3]) -> ([3]); // 6
return([3]); // 7

contracts::run_test@0([0]: Pedersen) -> (Pedersen);

In the compiled Sierra, we can see that the pedersen builtin is passed with the call to the run_test which starts at statement 0. It is then used in the call to the pedersen libfunc. We would expect to see the pedersen counter incremented by 1 in the Native compiler. Below is the compiled MLIR dump for the same program:

...
llvm.func @"test::test::run_test(f0)"(%arg0: i64 loc(unknown)) -> i64 attributes {llvm.emit_c_interface} {
    %0 = llvm.mlir.constant(2 : i256) : i256 loc(#loc1)
    %1 = llvm.mlir.constant(1 : i256) : i256 loc(#loc1)
    %2 = llvm.mlir.constant(1 : i64) : i64 loc(#loc1)
    %3 = llvm.alloca %2 x i256 {alignment = 16 : i64} : (i64) -> !llvm.ptr loc(#loc2)
    %4 = llvm.alloca %2 x i256 {alignment = 16 : i64} : (i64) -> !llvm.ptr loc(#loc2)
    %5 = llvm.alloca %2 x i256 {alignment = 16 : i64} : (i64) -> !llvm.ptr loc(#loc2)
    %6 = llvm.add %arg0, %2  : i64 loc(#loc2)
    %7 = llvm.intr.bswap(%1)  : (i256) -> i256 loc(#loc2)
    %8 = llvm.intr.bswap(%0)  : (i256) -> i256 loc(#loc2)
    llvm.store %7, %3 {alignment = 16 : i64} : i256, !llvm.ptr loc(#loc2)
    llvm.store %8, %4 {alignment = 16 : i64} : i256, !llvm.ptr loc(#loc2)
    llvm.call @cairo_native__libfunc__pedersen(%5, %3, %4) : (!llvm.ptr, !llvm.ptr, !llvm.ptr) -> () loc(#loc2)
    llvm.return %6 : i64 loc(#loc3)
  } loc(#loc1)
  ...

The compiled MLIR function run_test takes a single argument as input, the pedersen counter and returns the incremented counter at the end of the call. The counter is incremented by 1 in the MLIR code, in the statement %6 = llvm.add %arg0, %2 : i64 loc(#loc2), which takes the %arg0 input and adds %2 to it. We can see from statement %2 = llvm.mlir.constant(1 : i64) : i64 loc(#loc1) that %2 holds the constant 1. When this compiled MLIR code is called, the initial value of all builtin counters is set to 0 as can be seen in the invoke_dynamic function.