Skip to content

Commit

Permalink
feat: store packing (cairo-book#343)
Browse files Browse the repository at this point in the history
* feat: store packing

* Apply suggestions from code review

Co-authored-by: glihm <dev@glihm.net>

* fix: build

* add suggestions

* fmt

---------

Co-authored-by: glihm <dev@glihm.net>
  • Loading branch information
2 people authored and Utilitycoder committed Oct 2, 2023
1 parent 81dc4b6 commit 8744e29
Show file tree
Hide file tree
Showing 5 changed files with 131 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
target
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
[package]
name = "listing_99_13_storage_packing"
version = "0.1.0"

# See more keys and their definitions at https://docs.swmansion.com/scarb/docs/reference/manifest

[dependencies]
# foo = { path = "vendor/foo" }
starknet = ">=2.1.0"
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
//ANCHOR:here
use starknet::{StorePacking};
use integer::{u128_safe_divmod, u128_as_non_zero};
use traits::{Into, TryInto};
use option::OptionTrait;

#[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!

#[storage]
struct Storage {
remaining_sizes: Sizes
}

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


#[external(v0)]
fn get_sizes(ref self: ContractState) -> Sizes {
// this will automatically unpack the
// packed-representation into the Sizes struct
self.remaining_sizes.read()
}
}


//ANCHOR_END:here

#[cfg(test)]
mod tests {
use super::{SizesStorePacking, Sizes};
use starknet::StorePacking;
#[test]
#[available_gas(200000)]
fn test_pack_unpack() {
let value = Sizes { tiny: 0x12, small: 0x12345678, medium: 0x1234567890, };

let packed = SizesStorePacking::pack(value);
assert(packed == 0x12345678901234567812, 'wrong packed value');

let unpacked = SizesStorePacking::unpack(packed);
assert(unpacked.tiny == 0x12, 'wrong unpacked tiny');
assert(unpacked.small == 0x12345678, 'wrong unpacked small');
assert(unpacked.medium == 0x1234567890, 'wrong unpacked medium');
}
}
1 change: 1 addition & 0 deletions src/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@
- [Contract Functions](./ch99-01-03-02-contract-functions.md)
- [Contract Events](./ch99-01-03-03-contract-events.md)
- [Reducing boilerplate](./ch99-01-03-04-reducing-boilerplate.md)
- [Optimizing storage costs](./ch99-01-03-05-optimizing-storage.md)
- [ABIs and Cross-contract Interactions](./ch99-02-00-abis-and-cross-contract-interactions.md)
- [ABIs and Interfaces](./ch99-02-01-abis-and-interfaces.md)
- [Contract Dispatchers, Library Dispachers and system calls](./ch99-02-02-contract-dispatcher-library-dispatcher-and-system-calls.md)
Expand Down
35 changes: 35 additions & 0 deletions src/ch99-01-03-05-optimizing-storage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
## Storage Optimization with `StorePacking`

Bit-packing is a simple concept: Use as few bit 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 to 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 to the users of your smart contract.

Cairo provides the `StorePacking` trait to enable packing struct fields into a fewer number of storage slots. For example, consider a `Sizes` struct with 3 fields of different types. The total size is 8 + 32 + 64 = 104 bits. This is less than the 128 bits of a single `u128`. This means we can pack all 3 fields into a single `u128` variable. Since a storage slot can hold up to 251 bits, our packed value will take only one storage slot instead of 3.

```rust
{{#include ../listings/ch99-starknet-smart-contracts/listing_99_13_storage_packing/src/lib.cairo:here}}
```

<span class="caption">Optimizing storage by implementing the `StorePacking` trait</span>

The `pack` function combines all three fields into a single `u128` value by performing bitshift and additions. The `unpack` reverses this process to extract the original fields back into a struct.

If you're not familiar with bit operations, here's an explanation of the operations performed in the example:
The goal is to pack the `tiny`, `small`, and `medium` fields into a single `u128` value.
First, when packing:

- `tiny` is a `u8` so we just convert it directly to a `u128` with `.into()`. This creates a `u128` value with the low 8 bits set to `tiny`'s value.
- `small` is a `u32` so we first shift it left by 8 bits (add 8 bits with the value 0 to the left) to create room for the 8 bites taken by `tiny`. Then we add `tiny` to `small` to combine them into a single `u128` value. The value of `tiny` now takes bits 0-7 and the value of small takes bits 8-39.
- Similarly `medium` is a `u64` so we shift it left by 40 (8 + 32) bits (`TWO_POW_40`) to make space for the previous fields. This takes bits 40-103.

When unpacking:

- First we extract `tiny` by bitwise ANDing (&) with a bitmask of 8 ones (`& MASK_8`). This isolates the lowest 8 bits of the packed value, which is `tiny`'s value.
- For `small`, we right shift by 8 bits (`/ TWO_POW_8`) to align it with the bitmask, then use bitwise AND with the 32 ones bitmask.
- For `medium` we right shift by 40 bits. Since it is the last value packed, we don't need to apply a bitmask as the higher bits are already 0.

This technique can be used for any group of fields that fit within the bit size of the packed storage type. For example, if you have a struct with multiple fields whose bit sizes add up to 256 bits, you can pack them into a single `u256` variable. If the bit sizes add up to 512 bits, you can pack them into a single `u512` variable, and so on. You can define your own structs and logic to pack and unpack them.

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 details, 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.

0 comments on commit 8744e29

Please sign in to comment.