Перейти к основному содержимому
БлокчейнMar 28, 2026

Deep EVM #7: Газоэффективные циклы и условия в Yul

OS
Open Soft Team

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 добавляет:

  1. Проверку переполнения i++ (~100 газа)
  2. Проверку границ массива arr[i] (~200 газа)
  3. Проверку переполнения total += arr[i] (~100 газа)
  4. Безопасный доступ к 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)
        }
    }
}

Сравнительная таблица стоимости

ОперацияSolidityYulЭкономия
Цикл (за итерацию)~800 газа~200 газа75%
Сумма массива (100 элементов)~80000 газа~20000 газа75%
ABI-декодирование~500 газа~50 газа90%
Проверка переполнения~100 газа0 газа100%
Чтение маппинга~2500 газа~2200 газа12%

Основная экономия приходится на устранение проверок безопасности. Это значит, что вы должны быть абсолютно уверены в корректности своего кода.

Заключение

Циклы и условия в Yul — это фундаментальные строительные блоки газоэффективного кода. Ключевые принципы: минимизируйте число JUMP/JUMPI, используйте безветвевой код где возможно, развёртывайте циклы с фиксированным числом итераций, и всегда помните о стоимости каждого опкода.

В финальной статье серии мы применим все изученные концепции для написания полноценного свопа токенов на чистом Yul — от чтения calldata до взаимодействия с Uniswap V2 пулом.