Хабрахабр

[Из песочницы] Экономия газа в смарт-контрактах Ethereum

В Ethereum для выполнения каждой транзакции требуется определённое количество газа — специальной сущности. Существуют разные пути для снижения затрат. Часть из них уже реализована. Хочу начать с обсуждения вопроса оптимизации стоимости создания смарт-контракта.

Накладные расходы для уникальных контрактов

Как видите, можно заметно снизить расход газа, сокращая издержки. Прежде чем займёмся деталями давайте обсудим вопрос оптимизации программ.

Как работают оптимизаторы?

Давайте рассмотрим следующую простую C-программу.

Начальная версия программы для оптимизации

Программе потребуется некоторое время на выполнение, если скомпилировать её без оптимизации. Если же запустить оптимизированную версию программы, то она выполнится моментально. Причина в том, что компилятор обнаружит, что переменная x в функции main() нигде не используется в последующем коде, поэтому вызов функции calculate() можно вообще не выполнять. Вот результат оптимизации:

Оптимизированная версия программы

Давайте немного изменим возвращаемое значение в исходной функции main() следующим образом:

Для данной программы невозможна автоматическая оптимизация

Теперь компилятор будет бессилен помочь нам с оптимизацией. Остаётся лишь ручная оптимизация.

Ручная оптимизация

Сразу отмечу, что ручная оптимизация — это творческий процесс, поскольку есть множество мест и возможностей для улучшений в реальных программах. С другой стороны, мы можем использовать профайлер для поиска узких мест в программе. А когда проблема будет локализована, то можно воспользоваться одним из многих подходов к оптимизации, более эффективными алгоритмами и так далее.

Давайте внимательно посмотрим на функцию calculate() из предыдущего примера. На каждой итерации внутреннего цикла переменная r меняется с 0 на 1 и обратно. Начальное значение 0, поэтому нам достаточно лишь знать, будет ли чётным количество итераций или нет. Если хотя бы один из параметров a или b чётный, то будет чётное количество итераций, поэтому возвращаемое значение будет 0. Таким образом получаем следующую оптимизированную версию функции calculate():

Вручную оптимизированная функция calculate()

Опасные оптимизации

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

Одна из проблем связана с различным окружением и параметрами. Например, программа была оптимизирована для запуска на кластере, а мы используем её на отдельном компьютере. Или программу оптимизировали по скорости и все входные файлы полностью загружаются в оперативную память. Скорее всего, что будут проблемы при нехватке памяти для слишком больших файлов.

Иногда оптимизации могут привести к проблеме с безопасностью. (Тут можно вспомнить и про Spectre с Meltdown.) Во многих программах используется стандартная функция memset() для очистки переменных с конфиденциальной информацией, например, ключами и паролями. Но компиляторы часто просто удаляют эти вызовы, поскольку обновлённые значения переменных не используются в дальнейшем. До недавнего времени функция очистки в проекте OpenSSL выглядела следующим образом:

Функция очистки из проекта OpenSSL (файл crypto/mem_clr.c)

Конечно, проблема с функцией memset() является исключением из правил. Оптимизаторы генерируют корректный код, и обстоятельства использования могут привести к ошибкам. Но источником некорректного кода являются люди.

Ручные оптимизации очень опасны. Ранее показал оптимизированную версию функции calculate(), но это была некорректная оптимизация. Началось всё с истинного утверждения:

Если хотя бы один из параметров a или b чётный, то будет чётное количество итераций, поэтому возвращаемое значение будет 0.

Это импликация, поэтому при ложном условии следствие может быть любым. Возможна ли ситуация, когда и a, и b нечётные, но количество итераций будет чётным?

Ответ "да". Если значение или a, или b будет отрицательным, то вообще не будет ни одной итерации. Поэтому корректная ручная оптимизация приведёт к следующему коду:

Корректно оптимизированная функция calculate()

Виртуальная машина Ethereum

Виртуальная машина Ethereum (Ethereum Virtual Machine, EVM) — это основное "железо" платформы Ethereum. В упрощённом виде её архитектура представлена на следующей схеме:

Упрощённая архитектура EVM architecture

Можно выделить три типа памяти: балансы счетов (balances of accounts), код контрактов (code) и хранилища контрактов (storage). У каждого счёта (личного кошелька или контракта) есть свой собственный баланс в валюте Ethereum (ETH). Для каждого смарт-контракта хранится его код (исполняемая программа для EVM), а также собственная память для хранения переменных. Код контракта не меняется после создания.

Блокчейн Ethereum состоит из множества блоков в определённом порядке. Каждый блок — это набор транзакций и квитанций их выполнения (receipts). Состояние EVM (его память) полностью определяется всем набором предыдущих транзакций. Чтобы получить состояние EVM в момент N надо взять состояние EVM в момент N-1, после чего выполнить все транзакции из блока N. Поэтому, зная все транзакции блокчейна, мы можем определить состояние EVM на любой момент в прошлом. Процесс проиллюстрирован на следующей схеме:

Состояние EVM и блокчейн

Отмечу, что если зафиксировать состояние EVM в момент M, то есть состояния EVM, которые будут недостижимы в будущем вне зависимости от выполняемых транзакций после блока M.

Рассмотрим два набора транзакций: T and U. Назову эти транзакции "равнозначные", если обработка транзакций T в блоке N приведёт EVM к "идентичному" состоянию, что и обработка U вместо T в том же самом блоке N. Ставлю в кавычки, поскольку допускается разница в балансе отправителя и майнера блока N из-за разницы в затратах газа между наборами транзакций T и U.

Все затраты времени и памяти включены в стоимость газа, поэтому основная цель оптимизации — это снижение затрат газа. Одним из подходов к оптимизации является поиск "равнозначных" транзакций с меньшими затратами газа. Речь идёт про удаление избыточного кода, но не изменении алгоритмов и т.п. Аналогично первому примеру с функцией calculate() выше.

Создание смарт-контрактов

Существует два разных вида транзакций. Сейчас собираюсь обсудить только транзакции создания смарт-контрактов. Транзакция создания контракта выполняет два основных действия в EVM: инициализирует хранилище контракта и сохраняет байт-код. Инициализация хранилища является результатом вызова конструктора контракта с параметрами миграции. Все другие методы контракта сохраняются в коде. Этот процесс изображён на следующей схеме:

Развёртывание (создание) смарт-контракта

Вопрос оптимизации кода контракта оставим для будущих статей. Сегодня сосредоточимся на оптимизации кода развёртывания контракта.

Оптимизация кода развёртывания контракта

В предыдущем разделе мы обсудили, что есть очевидная цель оптимизации — минимизация потребления газа. Одновременно с этим необходимо убедиться, что оптимизированная транзакция "равнозначна" исходной.

Я использовал исходный байт-код развёртывания контрактов, доступный из блокчейна Ethereum. Для этой задачи исходного кода контрактов не требовалось. После этого выполнил трассировку выполнения развёртывания контракта и оставил только требуемый код. Процесс оптимизации кода развётывания можно представить следующим образом:

Оптимизация кода развёртывания

Предыдущий пример упрощён, хотя используемый подход может быть применим и для более сложных транзакций.

Нижняя оценка

Выполнение каждой отдельной инструкции в EVM имеет свою стоимость в количестве газа. Хотя есть множество путей для достижения одинакового результата, но мы можем легко получить нижнюю оценку. Для этого достаточно просуммировать следующие числа:

  1. Плата за данные кода развёртывания контракта;
  2. Плата за создание контракта;
  3. Количество различных переменных в хранилище, умноженное на стоимость оператора SSTORE;
  4. Размер байт-кода контракта в словах, умноженный на стоимость записи в память и стоимость инструкции RETURN;
  5. Количество событий, умноженное на соответствующую стоимость.

Данная нижняя оценка может быть использована в качестве основы и цели оптимизации.

Здесь предполагал, что байт-код контракта будет копироваться из данных транзакции развёртывания. Ситуации генерации байт-кода "на лету" являются исключениями.

Статистика и результаты

Я сделал снимок блокчейна Ethereum на блоке №4841148. На этот момент в блокчейне было 119041944 транзакций, из которых только 1022020 транзакций по созданию контракта. Я сравнил входные данные этих транзакций и обнаружил 111806 уникальных кодов развёртывания контрактов.

Подготовка входных данных

Каждый из уникальных кодов развёртывания запустил в Ganache CLI (бывший TestRPC) и получил квитанцию выполнения и байт-код контракта. Одновременно с этим выполнил наивную оптимизацию, а также посчитал нижнюю оценку. Оптимизированный код был протестирован на локальном блокчейне, после чего результаты сравнивались с исходным кодом. Процесс проиллюстрирован на следующей схеме:

Оптимизация и проверки отдельной транзакции

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

Для каждой обработанной транзакции получил три числа: исходные затраты на обработку, затраты для оптимизированной версии и нижнюю оценку. Все значения были посчитаны в текущей стоимости операций. Получились следующие результаты:

Результаты оптимизации

Из таблицы можно увидеть, что даже наивная оптимизация позволяет сэкономить газ. К сожалению, количество не такое уж и большое. С другой стороны, в теории больше 10% газа может быть сэкономлено лишь для 10% контрактов. На практике с наивной оптимизацией мне удалось достичь экономии в 10% лишь для 0.3% контрактов.

Учитывая все эти числа, мы можем вспомнить, что создание контракта — это единовременный процесс, поэтому могу заключить, что оптимизация развёртывания смарт-контрактов не является сейчас важным вопросом для Ethereum. Уверен, что ещё вернусь к этому вопросу в будущем, а сейчас есть ещё много интересных проблем оптимизации, связанных с байт-кодом самих контрактов.

Человеческий фактор

Небольшие ошибки могут перечеркнуть результаты всех предыдущих усилий. Например, 2131132 единиц газа было потрачено для транзакции transaction 0xdfa1..7fbb. Это на 23% больше газа, чем требовалось. Кто-то просто продублировал код развёртывания контракта перед отправкой. В итоге 6Кб данных вообще не использовались.

Что дальше?

Вопрос оптимизации при развёртывании смарт-контрактов появился в качестве побочного результата. Данная тема достаточно проста для понимания, поэтому решил с неё и начать.

Продолжение следует...

Показать больше

Похожие публикации

Кнопка «Наверх»