Оптимизация газа
Оптимизация газа — это разница между экономически жизнеспособным и убыточным проектом на TON. Каждая операция в TVM имеет свою стоимость, и для высоконагруженных контрактов (DEX, NFT-маркетплейсы) даже небольшая оптимизация транслируется в значительную экономию. Это как оптимизация расхода топлива для логистической компании — каждый процент экономии на тысячах рейсов превращается в серьёзную сумму.
Оптимизация газа на TON имеет свою специфику: в отличие от Ethereum, TON взимает рекуррентную плату за хранение. Это означает, что размер данных контракта напрямую влияет на стоимость его существования. В этом уроке мы разберём ключевые стратегии оптимизации.
Оптимизация газа на TON важнее, чем на Ethereum, потому что плата за хранение взимается постоянно. Контракт с неоптимальной структурой данных будет “сжигать” Toncoin каждую секунду.
Модель газа TVM: краткое напоминание
Газ в TON складывается из нескольких компонентов:
| Компонент | Описание | Единица |
|---|---|---|
| Compute | Исполнение опкодов TVM | gas 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%)
}
Каждый бит хранилища стоит денег каждую секунду. Разница между 768 и 66 битами — это 10-кратная разница в расходах на storage fee.
Частые ошибки
- Оптимизируют код до того, как он заработал корректно: преждевременная оптимизация создаёт баги, которые сложно отлаживать.
- Используют Dictionary (map) для маленьких наборов данных вместо простых переменных: Dictionary имеет overhead на каждую операцию.
- Не профилируют газовые расходы через sandbox тесты, оптимизируя «на глаз» вместо измерения реальных затрат.
- Забывают, что inline-функции экономят газ на вызов, но увеличивают размер кода контракта, а размер кода тоже стоит газ при деплое и хранении.
Проверка знанийWhy do inline functions save gas in TON contracts, and when might inlining actually increase costs?
Оптимизация 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.
Проверьте понимание
Закончили урок?
Отметьте его как пройденный, чтобы отслеживать свой прогресс
Войдите чтобы оценить урок