Optimizing Storage Costs

Bit-packing is a simple concept: use as few bits as possible to store a piece of data. When done well, it can significantly reduce the size of the data you need to store. This is especially important in smart contracts, where storage is expensive.

When writing Cairo smart contracts, it is important to optimize storage usage to reduce gas costs. Indeed, most of the cost associated with a transaction is related to storage updates; and each storage slot costs gas to write to. This means that by packing multiple values into fewer slots, you can decrease the gas cost incurred by the users of your smart contract.

Integer Structure and Bitwise Operators

An integer is coded on a certain number of bits, depending on its size (For example, a u8 integer is coded on 8 bits).

a u8 integer in bits
Representation of a u8 integer in bits

Intuitively, several integers can be combined into a single integer if the size of this single integer is greater than or equal to the sum of the sizes of the integers (For example, two u8 and one u16 in one u32).

But, to do that, we need some bitwise operators:

  • multiplying or dividing an integer by a power of 2 shifts the integer value to the left or to the right respectively
shift operators
Shifting to the left or to the right an integer value
  • applying a mask (AND operator) on an integer value isolates some bits of this integer
applying a mask
Isolate bits with a mask
  • adding (OR operator) two integers will combine both values into a single one.
combining two values
Combining two integers

With these bitwise operators, let's see how to combine two u8 integers into a single u16 integer (called packing) and reversely (called unpacking) in the following example:

packing and unpacking integer values
Packing and unpacking integer values

Bit-packing in Cairo

The storage of a Starknet smart contract is a map with 2251 slots, where each slot is a felt252 which is initialized to 0.

As we saw earlier, to reduce gas costs due to storage updates, we have to use as few bits as possible, so we have to organize stored variables by packing them.

For example, consider the following Sizes struct with 3 fields of different types: one u8, one u32 and one u64. The total size is 8 + 32 + 64 = 104 bits. This is less than a slot size (i.e 251 bits) so we can pack them together to be stored into a single slot.

Note that, as it also fits in a u128, it's a good practice to use the smallest type to pack all your variables, so here a u128 should be used.

struct Sizes {
    tiny: u8,
    small: u32,
    medium: u64,
}

To pack these 3 variables into a u128 we have to successively shift them to the left, and finally sum them.

Sizes packing
Sizes packing

To unpack these 3 variables from a u128 we have to successively shift them to the right and use a mask to isolate them.

Sizes unpacking
Sizes unpacking

The StorePacking Trait

Cairo provides the StorePacking trait to enable packing struct fields into fewer storage slots. StorePacking<T, PackedT> is a generic trait taking the type you want to pack (T) and the destination type (PackedT) as parameters. It provides two functions to implement: pack and unpack.

Here is the implementation of the example of the previous chapter:

use core::starknet::storage_access::StorePacking;

#[derive(Drop, Serde)]
struct Sizes {
    tiny: u8,
    small: u32,
    medium: u64,
}

const TWO_POW_8: u128 = 0x100;
const TWO_POW_40: u128 = 0x10000000000;

const MASK_8: u128 = 0xff;
const MASK_32: u128 = 0xffffffff;

impl SizesStorePacking of StorePacking<Sizes, u128> {
    fn pack(value: Sizes) -> u128 {
        value.tiny.into() + (value.small.into() * TWO_POW_8) + (value.medium.into() * TWO_POW_40)
    }

    fn unpack(value: u128) -> Sizes {
        let tiny = value & MASK_8;
        let small = (value / TWO_POW_8) & MASK_32;
        let medium = (value / TWO_POW_40);

        Sizes {
            tiny: tiny.try_into().unwrap(),
            small: small.try_into().unwrap(),
            medium: medium.try_into().unwrap(),
        }
    }
}

#[starknet::contract]
mod SizeFactory {
    use super::Sizes;
    use super::SizesStorePacking; //don't forget to import it!
    use core::starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};

    #[storage]
    struct Storage {
        remaining_sizes: Sizes
    }

    #[abi(embed_v0)]
    fn update_sizes(ref self: ContractState, sizes: Sizes) {
        // This will automatically pack the
        // struct into a single u128
        self.remaining_sizes.write(sizes);
    }


    #[abi(embed_v0)]
    fn get_sizes(ref self: ContractState) -> Sizes {
        // this will automatically unpack the
        // packed-representation into the Sizes struct
        self.remaining_sizes.read()
    }
}
Optimizing storage by implementing the `StorePacking` trait.

In this code snippet, you see that:

  • TWO_POW_8 and TWO_POW_40 are used to shift left in the pack function and shift right in the unpackfunction,
  • MASK_8 and MASK_32 are used to isolate a variable in the unpack function,
  • all the variables from the storage are converted to u128 to be able to use bitwise operators.

这种技术可用于任何一组适合打包存储类型位大小的字段。例如,如果一个结构体有多个字段,其位大小加起来为 256 位,那么可以将它们打包成一个 u256 变量。如果字段的位数加起来是 512 位,则可以将它们打包到一个 u512 变量中,依此类推。你可以定义自己的结构和逻辑来打包和解包它们。

The rest of the work is done magically by the compiler - if a type implements the StorePacking trait, then the compiler will know it can use the StoreUsingPacking implementation of the Store trait in order to pack before writing and unpack after reading from storage. One important detail, however, is that the type that StorePacking::pack spits out also has to implement Store for StoreUsingPacking to work. Most of the time, we will want to pack into a felt252 or u256 - but if you want to pack into a type of your own, make sure that this one implements the Store trait.