«Программисты тратят огромное количество времени беспокоясь о скорости работы своих программ, и попытки достичь эффективности зачастую оказывают резко негативное влияние на возможность их отладки и поддержки. Необходимо забыть о маленьких оптимизациях, скажем, в 97% случаев. Преждевременная оптимизация это корень всех зол! Но мы не должны упускать из виду те 3%, где это действительно важно!».
Дональд Кнут.
Проводя аудиты смарт-контрактов, мы иногда задаём себе вопрос относится ли их разработка к тем 97%, где нет необходимости думать об оптимизации или мы имеем дело как раз с теми 3% случаев, где она важна. На наш взгляд, скорее второе. В отличие от других приложений, смарт-контракты не обновляемы, их невозможно оптимизировать «на ходу» (при условии, если в их алгоритм это не заложено, но это отдельная тема). Второй довод в пользу ранней оптимизации контрактов — то, что, в отличие от большинства систем, где неоптимальность проявляется только в масштабе, связана со спецификой железа и окружения, измеряется колоссальным количеством метрик, смарт-контракт обладает по сути единственной метрикой производительности — потребление газа.
Поэтому эффективность контракта оценить технически проще, но разработчики зачастую продолжают полагаться на свою интуицию и делают ту самую, слепую «преждевременную оптимизацию», о которой говорил профессор Кнут. Мы проверим насколько интуитивное решение соответствует реальности на примере выбора разрядности переменной. В данном примере, как и в большинстве практических случаев, мы не добьёмся экономии, и даже наоборот, наш контракт получится более дорогим в плане потребляемого газа.
Что за газ?
Ethereum похож на глобальный компьютер, «процессором» которого является виртуальная машина EVM, «программным кодом» является последовательность команд и данных, записанных в смарт-контракте, а вызовы — это транзакции, поступающие из внешнего мира. Транзакции упаковываются в связанные друг с другом структуры — блоки, возникающие раз в несколько секунд. И так как размер блока по определению ограничен, а протокол обработки детерминирован (требует единообразной обработки всех транзакций в блоке всеми узлами сети), то для удовлетворения потенциально неограниченного спроса ограниченным ресурсом узлов и защиты от DoS" система должна предусматривать справедливый алгоритм выбора чей запрос обслуживать, а чей игнорировать. В качестве такого механизма во многих публичных блокчейнах действует простой принцип — отправитель может выбирать размер вознаграждения майнеру за исполнение своей транзакции, а майнер самостоятельно выбирает чьи запросы включать в блок, а чьи нет, выбирая наиболее выгодные для себя.
Например, в Bitcoin, где блок ограничен одним мегабайтом, майнер выбирает включать транзакцию в блок или нет исходя из её длины и предложенной комиссии (выбирая те, у которых соотношение satoshis per byte максимально).
Для более сложного протокола Ethereum такой подход не годится, ведь один байт может представлять собой как отсутствие операции (например, код STOP), так и дорогостоящую и медленную операцию записи в хранилище (SSTORE). Поэтому для каждого оп-кода в эфире предусмотрена своя цена в зависимости от его ресурсоёмкости.
Таблица расхода газа по разным типам операций. Из спецификации протокола Ethereum Yellow Paper.
В отличие от Bitcoin, отправитель Ethereum-транзакции устанавливает не комиссию в криптовалюте, а максимальное кол-во газа, которое он готов потратить — startGas и цену за единицу газа — gasPrice. При исполнении кода виртуальной машиной из startGas вычитается кол-во газа за каждую следующую операцию, пока либо не будет достигнут выход из кода, либо не закончится газ. Видимо, поэтому и используется такое странное название для этой единицы работы — транзакцию заправляют газом как автомобиль, а доедет он до точки назначения или нет зависит от того, хватит ли заправленного в бак объёма. По завершении исполнения кода с отправителя транзакции списывается объём эфира, полученный умножением фактически израсходованного газа на заданную отправителем цену (wei per gas). В глобальной сети это происходит в момент «майнинга» блока, в который включена соответствующая транзакция, а в среде Remix транзакция «майнится» мгновенно, бесплатно и без каких-либо условий.
Наш инструмент — Remix IDE
Для «профайлинга» расхода газа мы будем использовать онлайн-среду разработки Ethereum контрактов Remix IDE. Этот IDE содержит редактор кода с подсветкой синтаксиса, просмотрщик артефактов, рендер интерфейсов контрактов, визуальный отладчик виртуальной машины, JS-компиляторы всех возможных версий и множество других важных инструментов. Очень рекомендую начинать изучение эфира именно с него. Дополнительный плюс, что он не требует установки — достаточно открыть его в браузере с официального сайта.
Выбор типа переменной
Спецификация языка Solidity предлагает разработчику аж тридцать две разрядности целочисленных типов uint — от 8 до 256 бит. Представьте, что вы разрабатываете смарт-контракт, который предназначен для хранения возраста человека в годах. Какую разрядность uint выберите вы?
Вполне естественным было бы выбрать минимально достаточный тип для конкретной задачи — математически тут подошёл бы uint8. Логичным было бы предположить, что чем меньший по размеру объект мы храним в блокчейне и чем меньше мы расходуем памяти при исполнении, меньше имеем накладных расходов, тем меньше платим. Но в большинстве случаев такое предположение окажется неверным.
Для эксперимента возьмём самый простой контракт из того, что предлагает официальная документация Solidity и соберём его в двух вариантах — с использованием типа переменной uint256 и в 32 раза меньшего типа — uint8.
pragma solidity ^0.4.0;
contract SimpleStorage {
//uint is alias for uint256
uint storedData;
function set(uint x) public {
storedData = x;
}
function get() public view returns (uint) {
return storedData;
}
}
pragma solidity ^0.4.0;
contract SimpleStorage {
uint8 storedData;
function set(uint8 x) public {
storedData = x;
}
function get() public view returns (uint) {
return storedData;
}
}
Измеряем «экономию»
Итак, контракты созданы, загружены в Remix, задеплоены и транзакциями выполнены вызовы методов .set(). Что же мы видим? Запись длинного типа стоит дороже чем короткого — 20464 против 20205 gas units! Как? Почему? Давайте разбираться!
Хранение uint8 против uint256
Запись в постоянное хранилище это одна из самых дорогостоящих операций в протоколе по вполне понятным причинам: во-первых запись состояния увеличивает размер дискового пространства, необходимого полному узлу. Размер этого хранилища постоянно увеличивается, и чем больше состояний хранится у узлов, тем медленнее происходит синхронизация, выше требования к инфраструктуре (размеру раздела, количеству iops). В моменты пиковых нагрузок именно медленные дисковые IO операции определяют производительность всей сети.
Было бы логичным ожидать, что хранение uint8 должно стоить в десятки раз дешевле чем uint256. Однако, в отладчике вы можете видеть, что оба значения располагаются абсолютно одинаково в storage slot в виде 256-битного value.
И в данном конкретном случае применение uint8 не даёт никакого преимущества по стоимости записи в хранилище.
Обработка uint8 против uint256
Может быть, мы получим преимущества при работе с uint8 если не при хранении, то хотя бы при манипуляции с данными в памяти? Ниже сравниваются инструкции одной и той же функции полученные для разных типов переменных.
Вы можете видеть, что операции с uint8 имеют даже большее количество инструкций, чем uint256. Это объясняется тем, что машина приводят 8-битное значение к нативному 256-битному слову, и в результате код обрастает дополнительными инструкциями, которые оплачивает отправитель. Не только запись, но и исполнение кода с uint8 типом в данном случае оказывается дороже.
Где же может быть оправдано применение коротких типов?
Наша команда давно занимается аудитом смарт-контрактов, и пока ещё не было ни одного практического случая, где применение малого типа в предоставленном на аудит коде приводило бы к экономии. Между тем, в некоторых очень специфических кейсах экономия теоретически возможна. Например, если ваш контракт хранит большое количество малых state variables или структур, то они имеют возможность быть упакованными в меньшее количество слотов хранилища.
Разница будет наиболее очевидна в следующем примере:
1. контракт с 32мя переменными uint256
pragma solidity ^0.4.0;
contract SimpleStorage {
uint storedData1;
uint storedData2;
uint storedData3;
uint storedData4;
uint storedData5;
uint storedData6;
uint storedData7;
uint storedData8;
uint storedData9;
uint storedData10;
uint storedData11;
uint storedData12;
uint storedData13;
uint storedData14;
uint storedData15;
uint storedData16;
uint storedData17;
uint storedData18;
uint storedData19;
uint storedData20;
uint storedData21;
uint storedData22;
uint storedData23;
uint storedData24;
uint storedData25;
uint storedData26;
uint storedData27;
uint storedData28;
uint storedData29;
uint storedData30;
uint storedData31;
uint storedData32;
function set(uint x) public {
storedData1 = x;
storedData2 = x;
storedData3 = x;
storedData4 = x;
storedData5 = x;
storedData6 = x;
storedData7 = x;
storedData8 = x;
storedData9 = x;
storedData10 = x;
storedData11 = x;
storedData12 = x;
storedData13 = x;
storedData14 = x;
storedData15 = x;
storedData16 = x;
storedData17 = x;
storedData18 = x;
storedData19 = x;
storedData20 = x;
storedData21 = x;
storedData22 = x;
storedData23 = x;
storedData24 = x;
storedData25 = x;
storedData26 = x;
storedData27 = x;
storedData28 = x;
storedData29 = x;
storedData30 = x;
storedData31 = x;
storedData32 = x;
}
function get() public view returns (uint) {
return storedData1;
}
}
2. контракт с 32мя переменными uint8
pragma solidity ^0.4.0;
contract SimpleStorage {
uint8 storedData1;
uint8 storedData2;
uint8 storedData3;
uint8 storedData4;
uint8 storedData5;
uint8 storedData6;
uint8 storedData7;
uint8 storedData8;
uint8 storedData9;
uint8 storedData10;
uint8 storedData11;
uint8 storedData12;
uint8 storedData13;
uint8 storedData14;
uint8 storedData15;
uint8 storedData16;
uint8 storedData17;
uint8 storedData18;
uint8 storedData19;
uint8 storedData20;
uint8 storedData21;
uint8 storedData22;
uint8 storedData23;
uint8 storedData24;
uint8 storedData25;
uint8 storedData26;
uint8 storedData27;
uint8 storedData28;
uint8 storedData29;
uint8 storedData30;
uint8 storedData31;
uint8 storedData32;
function set(uint8 x) public {
storedData1 = x;
storedData2 = x;
storedData3 = x;
storedData4 = x;
storedData5 = x;
storedData6 = x;
storedData7 = x;
storedData8 = x;
storedData9 = x;
storedData10 = x;
storedData11 = x;
storedData12 = x;
storedData13 = x;
storedData14 = x;
storedData15 = x;
storedData16 = x;
storedData17 = x;
storedData18 = x;
storedData19 = x;
storedData20 = x;
storedData21 = x;
storedData22 = x;
storedData23 = x;
storedData24 = x;
storedData25 = x;
storedData26 = x;
storedData27 = x;
storedData28 = x;
storedData29 = x;
storedData30 = x;
storedData31 = x;
storedData32 = x;
}
function get() public view returns (uint) {
return storedData1;
}
}
Деплой первого контракта (32 uint256) будет стоить дешевле — всего 89941 gas, но .set() окажется значительно более дорогим т.к. будет оккупировать 256 слотов в хранилище, что обойдётся 640639 gas на каждый вызов. Второй контракт (32 uint8) окажется в два с половиной раза дороже при деплое (221663 gas), но зато каждый вызов метода .set() будет многократно дешевле, т.к. изменяет только одну ячейку сторэджа (185291 gas).
Применять ли такую оптимизацию?
Насколько значителен эффект от оптимизации типов — вопрос спорный. Как видите, даже для такого специально подобранного, синтетического кейса мы не получили многократной разницы. Выбор использовать uint8 или uint256 — это скорее иллюстрация того, что оптимизацию нужно либо применять осмысленно (с пониманием инструментов, профайлингом), либо не задумываться о ней вообще. Вот несколько общих рекомендаций:
- если контракт содержит в хранилище множество небольших чисел или компактных структур, то об оптимизации думать можно;
- если используете «сокращённый» тип — помните про over-/under-flow уязвимости;
- для memory- переменных и аргументов функций, не записываемых в хранилище, всегда лучше использовать нативный тип uint256 (или его алиас uint). Например, нет никакого смысла присваивать итератору списка тип uint8 — только проиграете;
- огромное значение для корректноого упаковывания в слоты хранилища для компилятора имеет порядок следования переменных в контракте.
Ссылки
Закончу советом, не имеющим никаких противопоказаний: экспериментируйте с инструментами разработки, знайте спецификации языка, библиотеки и фрэймворки. Приведу самые полезные, на мой взгляд, ссылки для начала изучения платформы Ethereum:
- Среда разработки контрактов Remix — очень функциональный браузерный IDE;
- Спецификация языка Solidity, по ссылке попадёте конкретно в раздел про State Variables Layout;
- Очень интересный репозиторий контрактов от известной команды OpenZeppelin. Примеры реализации токенов, краудсэйл-контрактов, и самое главное — библиотека SafeMath, та самая, которая помогает безопасно работать с типами;
- Ethereum Yellow Paper, формальная спецификация виртуальной машины Ethereum;
- Ethereum White Paper, спецификация Ethereum платформы, более общий и высокоуровневый документ с большим количеством ссылок;
- Ethereum in 25 minutes, короткое, но тем не менее, ёмкое техническое введение в Ethereum от создателя платформы Виталика Бутерина;
- Etherscan blockchain explorer, окно в реальный эфировский мир, браузер блоков, транзакций, токенов, контрактов на основной сети. На Etherscan вы найдёте эксплореры и для тестовых сетей Rinkeby, Ropsten, Kovan (сети с бесплатным эфиром, построены на разных консенсус-протоколах).
Автор: Kirill Varlamov