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.

Ketika menulis kontrak pintar Cairo, penting untuk mengoptimalkan penggunaan penyimpanan guna mengurangi biaya gas. Memang, sebagian besar biaya yang terkait dengan suatu transaksi berhubungan dengan pembaruan penyimpanan; dan setiap slot penyimpanan memerlukan biaya gas untuk ditulis. Ini berarti dengan memadatkan beberapa nilai ke dalam slot yang lebih sedikit, Anda dapat mengurangi biaya gas yang ditanggung oleh pengguna kontrak pintar Anda.

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.

Teknik ini dapat digunakan untuk setiap kelompok bidang yang cocok dalam ukuran bit dari tipe penyimpanan yang dipadatkan. Sebagai contoh, jika Anda memiliki struktur dengan beberapa bidang yang ukuran bit-nya ditambahkan menjadi 256 bit, Anda dapat memadatkannya ke dalam satu variabel u256. Jika ukuran bit-nya ditambahkan menjadi 512 bit, Anda dapat memadatkannya ke dalam satu variabel u512, dan seterusnya. Anda dapat mendefinisikan struktur dan logika sendiri untuk memadatkan dan membuka padatan tersebut.

Sisa pekerjaan tersebut dilakukan secara ajaib oleh kompiler - jika suatu tipe mengimplementasikan trait StorePacking, maka kompiler akan tahu bahwa dapat menggunakan implementasi StoreUsingPacking dari trait Store untuk mengemas sebelum menulis dan membongkar setelah membaca dari penyimpanan. Namun, satu detail penting adalah bahwa tipe yang dihasilkan oleh StorePacking::pack juga harus mengimplementasikan Store agar StoreUsingPacking dapat berfungsi. Sebagian besar waktu, kita akan menginginkan pengemasan ke felt252 atau u256 - tetapi jika Anda ingin mengemas ke tipe milik Anda sendiri, pastikan bahwa tipe tersebut mengimplementasikan trait Store.