Перейти к содержанию
Learning Platform
Глоссарий Troubleshooting
Урок 05.05 · 25 мин
Продвинутый
GasОптимизацияTVMSmart Contracts

Оптимизация газа

Оптимизация газа — это разница между экономически жизнеспособным и убыточным проектом на TON. Каждая операция в TVM имеет свою стоимость, и для высоконагруженных контрактов (DEX, NFT-маркетплейсы) даже небольшая оптимизация транслируется в значительную экономию. Это как оптимизация расхода топлива для логистической компании — каждый процент экономии на тысячах рейсов превращается в серьёзную сумму.

Оптимизация газа на TON имеет свою специфику: в отличие от Ethereum, TON взимает рекуррентную плату за хранение. Это означает, что размер данных контракта напрямую влияет на стоимость его существования. В этом уроке мы разберём ключевые стратегии оптимизации.

WARNING

Оптимизация газа на TON важнее, чем на Ethereum, потому что плата за хранение взимается постоянно. Контракт с неоптимальной структурой данных будет “сжигать” Toncoin каждую секунду.


Модель газа TVM: краткое напоминание

Газ в TON складывается из нескольких компонентов:

КомпонентОписаниеЕдиница
ComputeИсполнение опкодов TVMgas units
StorageХранение данных контрактаbits + cells в секунду
ForwardОтправка сообщенийbits + cells
ActionВыполнение действий (send_message и т.д.)фиксированная цена

Стоимость основных операций

Загрузка uint из slice:    26 gas
Сохранение uint в builder: 26 gas
Загрузка ссылки (ref):     25 gas + стоимость десериализации
Создание ячейки:           500 gas
Поиск в словаре:           ~100-200 gas (зависит от глубины)
Отправка сообщения:        ~10,000 gas + forward fee

Оптимизация упаковки ячеек

Минимизация количества ячеек

Каждая Cell вмещает до 1023 бит данных и до 4 ссылок. Оптимальная упаковка — максимально заполнять каждую ячейку:

// Плохо: каждое поле в отдельной ячейке (через ref)
fun saveDataBad(counter: int, owner: slice, config: cell) {
    setContractData(
        beginCell()
            .storeRef(beginCell().storeUint(counter, 32).endCell())
            .storeRef(beginCell().storeSlice(owner).endCell())
            .storeRef(config)
        .endCell()
    );
    // Результат: 4 ячейки, 3 ссылки
}

// Хорошо: плоская упаковка в одну ячейку
fun saveDataGood(counter: int, owner: slice, config: cell) {
    setContractData(
        beginCell()
            .storeUint(counter, 32)     // 32 бита
            .storeSlice(owner)          // ~267 бит (адрес)
            .storeRef(config)           // 1 ссылка (config может быть большим)
        .endCell()
    );
    // Результат: 2 ячейки, 1 ссылка
}

Выравнивание по битам

Используйте минимально необходимое количество бит для каждого поля:

// Плохо: 256 бит на маленькие числа
fun storeStateBad() {
    beginCell()
        .storeUint(counter, 256)       // counter < 1,000,000
        .storeUint(timestamp, 256)     // timestamp -- 32 бита достаточно
        .storeUint(status, 256)        // status: 0, 1, или 2
    .endCell();
    // Использовано: 768 бит
}

// Хорошо: точные размеры полей
fun storeStateGood() {
    beginCell()
        .storeUint(counter, 32)        // 32 бита достаточно для ~4 млрд
        .storeUint(timestamp, 32)      // Unix timestamp
        .storeUint(status, 2)          // 3 значения = 2 бита
    .endCell();
    // Использовано: 66 бит (экономия 702 бита = 91%)
}
TIP

Каждый бит хранилища стоит денег каждую секунду. Разница между 768 и 66 битами — это 10-кратная разница в расходах на storage fee.

Частые ошибки

  1. Оптимизируют код до того, как он заработал корректно: преждевременная оптимизация создаёт баги, которые сложно отлаживать.
  2. Используют Dictionary (map) для маленьких наборов данных вместо простых переменных: Dictionary имеет overhead на каждую операцию.
  3. Не профилируют газовые расходы через sandbox тесты, оптимизируя «на глаз» вместо измерения реальных затрат.
  4. Забывают, что inline-функции экономят газ на вызов, но увеличивают размер кода контракта, а размер кода тоже стоит газ при деплое и хранении.

Проверка знанийKnowledge check
Why do inline functions save gas in TON contracts, and when might inlining actually increase costs?
ОтветAnswer
Inline functions save ~20 gas per call by eliminating the CALL/RET opcodes -- the function body is copied directly into the call site. However, inlining a large function called from many places increases the total code size stored on-chain, which raises the recurring storage fee. Inline is best for small, frequently-called helper functions like load_data() or save_data().

Оптимизация Compute

Inline-функции

Часто вызываемые функции можно пометить как inline, чтобы избежать затрат на вызов:

// Inline-функция: тело подставляется в место вызова
// Экономия: ~20 gas на каждый вызов (CALL/RET опкоды)
@inline
fun loadData(): (int, slice) {
    val ds = getContractData().beginParse();
    return (ds.loadUint(32), ds.loadAddress());
}

Ранний выход

Проверяйте условия как можно раньше, чтобы не тратить газ на ненужные вычисления:

fun onInternalMessage(myBalance: int, msgValue: int, msgFull: cell, msgBody: slice) {
    // Ранний выход: пустое сообщение
    if (msgBody.isEndOfSlice()) { return; }

    val cs = msgFull.beginParse();
    val flags = cs.loadUint(4);

    // Ранний выход: bounced сообщение
    if (flags & 1) { return; }

    // Дорогие операции -- только если прошли все проверки
    val op = msgBody.loadUint(32);
    // ...
}

Избегайте повторных вычислений

// Плохо: get_data() вызывается дважды
fun handleMessage(op: int) {
    if (op == 1) {
        val counter = getContractData().beginParse().loadUint(32);
        setContractData(beginCell().storeUint(counter + 1, 32).endCell());
    }
    if (op == 2) {
        val counter = getContractData().beginParse().loadUint(32);
        // Ещё одно чтение хранилища -- лишние 500+ gas
    }
}

// Хорошо: одно чтение, одна запись
fun handleMessage(op: int) {
    var counter = getContractData().beginParse().loadUint(32);

    if (op == 1) { counter += 1; }
    if (op == 2) { counter -= 1; }

    setContractData(beginCell().storeUint(counter, 32).endCell());
}

Оптимизация хранилища

Компактный layout состояния

Группируйте часто используемые поля в первой ячейке, редкие — в ссылках:

// Оптимальная структура хранилища
fun saveData(
    seqno: int,          // Часто обновляется
    owner: slice,        // Читается при каждом сообщении
    balance: int,        // Часто обновляется
    config: cell,        // Редко меняется
    metadata: cell       // Почти никогда не читается
) {
    setContractData(
        beginCell()
            .storeUint(seqno, 32)      // Горячие данные -- в основной ячейке
            .storeSlice(owner)
            .storeCoins(balance)
            .storeRef(config)          // Холодные данные -- в ссылках
            .storeRef(metadata)
        .endCell()
    );
}

Lazy loading (ленивая загрузка)

Не десериализуйте данные, которые не нужны для текущей операции:

fun onInternalMessage(myBalance: int, msgValue: int, msgFull: cell, msgBody: slice) {
    val op = msgBody.loadUint(32);

    if (op == OP_INCREMENT) {
        // Загружаем ТОЛЬКО counter, не трогая config и metadata
        val ds = getContractData().beginParse();
        val counter = ds.loadUint(32);
        // ds.loadAddress() -- пропускаем owner
        // ds.loadRef() -- НЕ загружаем config (экономия ~25 gas + десериализация)

        // Перезаписываем только counter
        val rawData = getContractData();
        val newData = beginCell()
            .storeUint(counter + 1, 32)
            .storeSlice(ds)  // Копируем остальные данные как есть
            .endCell();
        setContractData(newData);
    }
}

Стратегии словарей

Словари (hashmaps) — самый дорогой элемент хранилища:

// Плохо: хранить словарь в основной ячейке, если он большой
// Каждый доступ к любому полю контракта потребует загрузки всего словаря

// Хорошо: словарь в отдельной ячейке (ref)
fun saveData(counter: int, users: cell) {
    setContractData(
        beginCell()
            .storeUint(counter, 32)
            .storeRef(users)        // Словарь в ref -- загружается только при необходимости
        .endCell()
    );
}

Оптимизация сообщений

Минимальное тело сообщения

// Плохо: избыточные данные в сообщении
fun sendNotification(to: slice, counter: int, timestamp: int, sender: slice) {
    val body = beginCell()
        .storeUint(OP_NOTIFY, 32)
        .storeUint(0, 64)             // query_id
        .storeUint(counter, 256)      // 256 бит -- избыточно
        .storeUint(timestamp, 256)
        .storeSlice(sender)           // ~267 бит -- зачем дублировать?
    .endCell();
    // Forward fee пропорциональна размеру!
}

// Хорошо: только необходимые данные
fun sendNotification(to: slice, counter: int) {
    val body = beginCell()
        .storeUint(OP_NOTIFY, 32)
        .storeUint(0, 64)             // query_id
        .storeUint(counter, 32)       // 32 бита достаточно
    .endCell();
    // Экономия: ~489 бит = меньший forward fee
}

Эффективная пересылка

// Используйте mode 64 для пересылки входящего значения
sendRawMessage(msg, 64);  // mode 64: перенести входящие монеты (минус газ)

// mode 128: отправить весь баланс контракта
sendRawMessage(msg, 128); // Обычно при уничтожении контракта

Распространённые ловушки газа

1. Неограниченные циклы

// ОПАСНО: цикл по всему словарю
var key = -1;
do {
    (key, val slice, val found) = dict.udictGetNext(256, key);
    if (found) { processEntry(slice); }
} while (found);
// Если словарь содержит 10,000 записей -- контракт выйдет за лимит газа

2. Глубокие обходы ячеек

// ОПАСНО: рекурсивный обход дерева ячеек
fun countCells(c: cell): int {
    val cs = c.beginParse();
    var count = 1;
    repeat (cs.refsLeft()) {
        count += countCells(cs.loadRef());  // O(n) по количеству ячеек
    }
    return count;
}

3. Избыточные словарные операции

// Плохо: три операции словаря вместо одной
val (oldVal, found) = dict.udictGet(256, key);
if (found) {
    dict.udictDelete(256, key);
}
dict.udictSet(256, key, newValue);

// Хорошо: одна операция замены
dict.udictSet(256, key, newValue);  // Замена или вставка

Инструменты для оценки газа

Blueprint тесты

Blueprint и Sandbox выводят потребление газа в тестах:

// В тесте Counter.spec.ts
const result = await counter.sendIncrement(deployer.getSender());
console.log('Gas used:', result.transactions[1].totalFees);
// Пример вывода: Gas used: { coins: 3284000n }

Sandbox gas tracking

// Детальная информация о газе
const tx = result.transactions[1];
console.log('Compute fee:', tx.description.computePhase.gasUsed);
console.log('Storage fee:', tx.description.storagePhase?.storageFeesCollected);
console.log('Forward fee:', tx.description.actionPhase?.totalFwdFees);

Практический пример: до и после

До оптимизации

fun onInternalMessage(myBalance: int, msgValue: int, msgFull: cell, msgBody: slice) {
    val ds = getContractData().beginParse();
    val counter = ds.loadUint(256);         // 256 бит -- избыточно
    val owner = ds.loadAddress();
    val users = ds.loadRef().beginParse();   // Загружаем словарь ВСЕГДА

    val op = msgBody.loadUint(32);

    if (op == 1) {
        setContractData(
            beginCell()
                .storeUint(counter + 1, 256)
                .storeSlice(owner)
                .storeRef(beginCell().storeSlice(users).endCell())
            .endCell()
        );
    }
    // Общий газ: ~3,500 units
}

После оптимизации

fun onInternalMessage(myBalance: int, msgValue: int, msgFull: cell, msgBody: slice) {
    if (msgBody.isEndOfSlice()) { return; }  // Ранний выход

    val op = msgBody.loadUint(32);

    if (op == 1) {
        val ds = getContractData().beginParse();
        val counter = ds.loadUint(32);           // 32 бита достаточно
        // НЕ загружаем owner и users -- они не нужны

        setContractData(
            beginCell()
                .storeUint(counter + 1, 32)
                .storeSlice(ds)                  // Копируем остаток как есть
            .endCell()
        );
    }
    // Общий газ: ~1,800 units (экономия ~49%)
}

Итоги

  • Плата за хранение рекуррентна — каждый лишний бит стоит Toncoin каждую секунду
  • Упаковка ячеек: минимизируйте количество ячеек, используйте точные размеры полей
  • Compute: inline-функции, ранний выход, избегайте повторных вычислений
  • Storage: компактный layout, lazy loading, словари в ссылках
  • Сообщения: минимальное тело, правильные mode-флаги
  • Избегайте: неограниченных циклов, глубоких обходов, избыточных словарных операций
  • Blueprint/Sandbox позволяют измерять потребление газа в тестах

В заключительном уроке мы сведём всё вместе с подробным сравнением FunC и Tolk.

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 6