Deep EVM #7: Газоэффективные циклы и условия в Yul
Engineering Team
Почему циклы в Yul эффективнее
Когда Solidity компилирует цикл for, он добавляет множество проверок безопасности: верификацию границ массива, проверку переполнения счётчика, безопасный доступ к элементам. Каждая из этих проверок стоит газа. В Yul вы контролируете каждый аспект цикла и можете убрать ненужные проверки, сохраняя только необходимые.
Анатомия цикла в Yul
Цикл for в Yul имеет три блока:
assembly {
// for { инициализация } условие { пост-итерация } { тело }
for { let i := 0 } lt(i, 10) { i := add(i, 1) } {
// тело цикла
}
}
На уровне байткода это компилируется в:
// Инициализация:
PUSH1 0 // i = 0
// Условие (начало итерации):
JUMPDEST // метка начала цикла
DUP1 // копируем i
PUSH1 10 // предел
LT // i < 10?
ISZERO // инвертируем
PUSH2 end // адрес конца
JUMPI // если i >= 10, выходим
// Тело цикла:
... // ваш код
// Пост-итерация:
PUSH1 1
ADD // i = i + 1
PUSH2 start // адрес начала
JUMP // обратно к условию
// Конец:
JUMPDEST
POP // убираем i со стека
Газ одной итерации
Стоимость «инфраструктуры» одной итерации цикла:
- JUMPDEST: 1 газ
- DUP1: 3 газа
- PUSH: 3 газа
- LT: 3 газа
- ISZERO: 3 газа
- PUSH: 3 газа
- JUMPI: 10 газа
- ADD (инкремент): 3 газа
- PUSH: 3 газа
- JUMP: 8 газа
- Итого: ~40 газа за итерацию (только инфраструктура, без тела)
Сравнение: Solidity vs Yul
Суммирование массива
// Solidity — ~800 газа за итерацию
function sumSolidity(uint256[] calldata arr) external pure returns (uint256 total) {
for (uint256 i = 0; i < arr.length; i++) {
total += arr[i];
}
}
// Yul — ~200 газа за итерацию
function sumYul(uint256[] calldata arr) external pure returns (uint256 total) {
assembly {
let length := arr.length
let offset := arr.offset
for { let i := 0 } lt(i, length) { i := add(i, 1) } {
total := add(total, calldataload(add(offset, mul(i, 32))))
}
}
}
Разница объясняется тем, что Solidity добавляет:
- Проверку переполнения
i++(~100 газа) - Проверку границ массива
arr[i](~200 газа) - Проверку переполнения
total += arr[i](~100 газа) - Безопасный доступ к calldata через ABI-декодирование (~200 газа)
Оптимизация: декремент вместо инкремента
Обратный цикл (от length к 0) чуть эффективнее, потому что ISZERO дешевле LT:
assembly {
let length := arr.length
let offset := arr.offset
// Обратный цикл
for { let i := length } i { i := sub(i, 1) } {
let idx := sub(i, 1)
total := add(total, calldataload(add(offset, mul(idx, 32))))
}
}
// Экономия: ~2 газа за итерацию (ISZERO vs LT)
Условные конструкции
if в Yul
Yul не имеет else. Для условий с двумя ветвями используйте switch:
assembly {
// if — только одна ветвь
if iszero(value) {
revert(0, 0)
}
// switch — несколько ветвей
switch selector
case 0xa9059cbb { // transfer
// обработка transfer
}
case 0x70a08231 { // balanceOf
// обработка balanceOf
}
case 0x095ea7b3 { // approve
// обработка approve
}
default {
revert(0, 0)
}
}
Тернарный оператор через Yul
Yul не имеет тернарного оператора, но его можно эмулировать:
assembly {
// result = condition ? valueIfTrue : valueIfFalse
// Способ 1: через mul + sub (branchless)
let result := add(mul(condition, valueIfTrue), mul(sub(1, condition), valueIfFalse))
// Способ 2: через xor (если значения — 0/1)
let result2 := xor(valueIfFalse, mul(xor(valueIfTrue, valueIfFalse), condition))
}
«Безветвевой» (branchless) код часто эффективнее, потому что избегает JUMPI (10 газа) и связанных с ним опкодов.
Реальные паттерны из DeFi
Паттерн 1: Поиск в отсортированном массиве (бинарный поиск)
assembly {
function binarySearch(arrSlot, length, target) -> index, found {
let low := 0
let high := length
for {} lt(low, high) {} {
let mid := shr(1, add(low, high)) // (low + high) / 2
let midVal := sload(add(arrSlot, mid))
switch gt(target, midVal)
case 1 {
low := add(mid, 1)
}
default {
high := mid
}
}
if lt(low, length) {
let val := sload(add(arrSlot, low))
if eq(val, target) {
index := low
found := 1
}
}
}
}
Бинарный поиск в хранилище: O(log n) SLOAD вместо O(n). Для массива из 1000 элементов: ~10 SLOAD (21000 газа при холодном доступе) вместо ~500 SLOAD (1 050 000 газа).
Паттерн 2: Пакетная обработка трансферов
assembly {
// Calldata: selector(4) + offset(32) + length(32) + [to(32) + amount(32)] * n
function batchTransfer(token) {
let length := calldataload(36)
let dataStart := 68 // 4 + 32 + 32
for { let i := 0 } lt(i, length) { i := add(i, 1) } {
let offset := add(dataStart, mul(i, 64))
let to := calldataload(offset)
let amount := calldataload(add(offset, 32))
// Маскируем адрес
to := and(to, 0xffffffffffffffffffffffffffffffffffffffff)
// Формируем calldata для transfer(address,uint256)
mstore(0x00, 0xa9059cbb00000000000000000000000000000000000000000000000000000000)
mstore(0x04, to)
mstore(0x24, amount)
let success := call(gas(), token, 0, 0x00, 0x44, 0x00, 0x20)
if iszero(success) {
revert(0, 0)
}
}
}
}
Этот паттерн позволяет выполнить N трансферов в одной транзакции, экономя 21000 газа (внутренний газ транзакции) за каждый дополнительный трансфер.
Паттерн 3: Эффективный маппинг-итератор
Маппинги в Solidity не итерируемы. Но если вы поддерживаете параллельный массив ключей, можно эффективно итерировать:
assembly {
// Предполагаем: slot 0 = length, slot keccak256(0) + i = keys[i]
// Маппинг balances: slot 1, balances[key] = keccak256(key . 1)
function sumAllBalances() -> total {
let length := sload(0)
let keysBase := keccak256(0x00, 0x20) // Нужно записать 0 в 0x00 перед keccak
// Записываем базовый слот массива ключей
mstore(0x00, 0)
let keysSlot := keccak256(0x00, 0x20)
for { let i := 0 } lt(i, length) { i := add(i, 1) } {
// Загружаем ключ
let key := sload(add(keysSlot, i))
// Вычисляем слот баланса
mstore(0x00, key)
mstore(0x20, 1) // слот маппинга
let balanceSlot := keccak256(0x00, 0x40)
// Суммируем
total := add(total, sload(balanceSlot))
}
}
}
Паттерн 4: Развёрнутый цикл (Loop Unrolling)
Для фиксированного числа итераций развёрнутый цикл убирает накладные расходы JUMP/JUMPI:
assembly {
// Вместо цикла на 4 итерации (~160 газа инфраструктуры)
// Развёрнутый код (0 газа инфраструктуры)
let a := calldataload(0x04)
let b := calldataload(0x24)
let c := calldataload(0x44)
let d := calldataload(0x64)
let total := add(add(a, b), add(c, d))
}
Это экономит ~40 газа за «убранную» итерацию. Для 4 итераций: 160 газа экономии. Для MEV-ботов, обрабатывающих фиксированное число пулов, это реальная оптимизация.
Обработка динамических данных
Обработка bytes и string
assembly {
// Копируем bytes из calldata в память
let dataOffset := calldataload(4) // смещение до данных
let dataLength := calldataload(add(4, dataOffset)) // длина данных
let dataStart := add(add(4, dataOffset), 32) // начало данных
let ptr := mload(0x40)
calldatacopy(ptr, dataStart, dataLength)
mstore(0x40, add(ptr, dataLength))
// Обработка побайтно
for { let i := 0 } lt(i, dataLength) { i := add(i, 1) } {
let b := byte(0, mload(add(ptr, i)))
// ... обработка байта ...
}
}
Обработка массива адресов
assembly {
// Calldata: selector(4) + offset(32) + length(32) + address[](length * 32)
let length := calldataload(36)
let mask := 0xffffffffffffffffffffffffffffffffffffffff
for { let i := 0 } lt(i, length) { i := add(i, 1) } {
let addr := and(calldataload(add(68, mul(i, 32))), mask)
// Проверяем: адрес не нулевой
if iszero(addr) {
// Записываем код ошибки и откатываем
mstore(0x00, 0x08c379a000000000000000000000000000000000000000000000000000000000)
mstore(0x04, 0x20)
mstore(0x24, 14) // длина строки
mstore(0x44, "Zero address") // текст ошибки
revert(0x00, 0x64)
}
}
}
Сравнительная таблица стоимости
| Операция | Solidity | Yul | Экономия |
|---|---|---|---|
| Цикл (за итерацию) | ~800 газа | ~200 газа | 75% |
| Сумма массива (100 элементов) | ~80000 газа | ~20000 газа | 75% |
| ABI-декодирование | ~500 газа | ~50 газа | 90% |
| Проверка переполнения | ~100 газа | 0 газа | 100% |
| Чтение маппинга | ~2500 газа | ~2200 газа | 12% |
Основная экономия приходится на устранение проверок безопасности. Это значит, что вы должны быть абсолютно уверены в корректности своего кода.
Заключение
Циклы и условия в Yul — это фундаментальные строительные блоки газоэффективного кода. Ключевые принципы: минимизируйте число JUMP/JUMPI, используйте безветвевой код где возможно, развёртывайте циклы с фиксированным числом итераций, и всегда помните о стоимости каждого опкода.
В финальной статье серии мы применим все изученные концепции для написания полноценного свопа токенов на чистом Yul — от чтения calldata до взаимодействия с Uniswap V2 пулом.