- PVSM.RU - https://www.pvsm.ru -
«Программисты тратят огромное количество времени беспокоясь о скорости работы своих программ, и попытки достичь эффективности зачастую оказывают резко негативное влияние на возможность их отладки и поддержки. Необходимо забыть о маленьких оптимизациях, скажем, в 97% случаев. Преждевременная оптимизация это корень всех зол! Но мы не должны упускать из виду те 3%, где это действительно важно!».
Дональд Кнут.

Проводя аудиты смарт-контрактов, мы иногда задаём себе вопрос относится ли их разработка к тем 97%, где нет необходимости думать об оптимизации или мы имеем дело как раз с теми 3% случаев, где она важна. На наш взгляд, скорее второе. В отличие от других приложений, смарт-контракты не обновляемы, их невозможно оптимизировать «на ходу» (при условии, если в их алгоритм это не заложено, но это отдельная тема). Второй довод в пользу ранней оптимизации контрактов — то, что, в отличие от большинства систем, где неоптимальность проявляется только в масштабе, связана со спецификой железа и окружения, измеряется колоссальным количеством метрик, смарт-контракт обладает по сути единственной метрикой производительности — потребление газа.
Поэтому эффективность контракта оценить технически проще, но разработчики зачастую продолжают полагаться на свою интуицию и делают ту самую, слепую «преждевременную оптимизацию», о которой говорил профессор Кнут. Мы проверим насколько интуитивное решение соответствует реальности на примере выбора разрядности переменной. В данном примере, как и в большинстве практических случаев, мы не добьёмся экономии, и даже наоборот, наш контракт получится более дорогим в плане потребляемого газа.
Ethereum похож на глобальный компьютер, «процессором» которого является виртуальная машина EVM, «программным кодом» является последовательность команд и данных, записанных в смарт-контракте, а вызовы — это транзакции, поступающие из внешнего мира. Транзакции упаковываются в связанные друг с другом структуры — блоки, возникающие раз в несколько секунд. И так как размер блока по определению ограничен, а протокол обработки детерминирован (требует единообразной обработки всех транзакций в блоке всеми узлами сети), то для удовлетворения потенциально неограниченного спроса ограниченным ресурсом узлов и защиты от DoS" система должна предусматривать справедливый алгоритм выбора чей запрос обслуживать, а чей игнорировать. В качестве такого механизма во многих публичных блокчейнах действует простой принцип — отправитель может выбирать размер вознаграждения майнеру за исполнение своей транзакции, а майнер самостоятельно выбирает чьи запросы включать в блок, а чьи нет, выбирая наиболее выгодные для себя.
Например, в Bitcoin, где блок ограничен одним мегабайтом, майнер выбирает включать транзакцию в блок или нет исходя из её длины и предложенной комиссии (выбирая те, у которых соотношение satoshis per byte максимально).
Для более сложного протокола Ethereum такой подход не годится, ведь один байт может представлять собой как отсутствие операции (например, код STOP), так и дорогостоящую и медленную операцию записи в хранилище (SSTORE). Поэтому для каждого оп-кода в эфире предусмотрена своя цена в зависимости от его ресурсоёмкости.
В отличие от Bitcoin, отправитель Ethereum-транзакции устанавливает не комиссию в криптовалюте, а максимальное кол-во газа, которое он готов потратить — startGas и цену за единицу газа — gasPrice. При исполнении кода виртуальной машиной из startGas вычитается кол-во газа за каждую следующую операцию, пока либо не будет достигнут выход из кода, либо не закончится газ. Видимо, поэтому и используется такое странное название для этой единицы работы — транзакцию заправляют газом как автомобиль, а доедет он до точки назначения или нет зависит от того, хватит ли заправленного в бак объёма. По завершении исполнения кода с отправителя транзакции списывается объём эфира, полученный умножением фактически израсходованного газа на заданную отправителем цену (wei [2] per gas). В глобальной сети это происходит в момент «майнинга» блока, в который включена соответствующая транзакция, а в среде Remix транзакция «майнится» мгновенно, бесплатно и без каких-либо условий.
Для «профайлинга» расхода газа мы будем использовать онлайн-среду разработки Ethereum контрактов Remix IDE [3]. Этот IDE содержит редактор кода с подсветкой синтаксиса, просмотрщик артефактов, рендер интерфейсов контрактов, визуальный отладчик виртуальной машины, JS-компиляторы всех возможных версий и множество других важных инструментов. Очень рекомендую начинать изучение эфира именно с него. Дополнительный плюс, что он не требует установки — достаточно открыть его в браузере с официального сайта [4].
Спецификация языка Solidity [5] предлагает разработчику аж тридцать две разрядности целочисленных типов uint — от 8 до 256 бит. Представьте, что вы разрабатываете смарт-контракт, который предназначен для хранения возраста человека в годах. Какую разрядность uint выберите вы?
Вполне естественным было бы выбрать минимально достаточный тип для конкретной задачи — математически тут подошёл бы uint8. Логичным было бы предположить, что чем меньший по размеру объект мы храним в блокчейне и чем меньше мы расходуем памяти при исполнении, меньше имеем накладных расходов, тем меньше платим. Но в большинстве случаев такое предположение окажется неверным.
Для эксперимента возьмём самый простой контракт из того, что предлагает официальная документация Solidity [6] и соберём его в двух вариантах — с использованием типа переменной 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! Как? Почему? Давайте разбираться!

Запись в постоянное хранилище это одна из самых дорогостоящих операций в протоколе по вполне понятным причинам: во-первых запись состояния увеличивает размер дискового пространства, необходимого полному узлу. Размер этого хранилища постоянно увеличивается, и чем больше состояний хранится у узлов, тем медленнее происходит синхронизация, выше требования к инфраструктуре (размеру раздела, количеству iops). В моменты пиковых нагрузок именно медленные дисковые IO операции определяют производительность всей сети.
Было бы логичным ожидать, что хранение uint8 должно стоить в десятки раз дешевле чем uint256. Однако, в отладчике вы можете видеть, что оба значения располагаются абсолютно одинаково в storage slot в виде 256-битного value.

И в данном конкретном случае применение uint8 не даёт никакого преимущества по стоимости записи в хранилище.
Может быть, мы получим преимущества при работе с 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 — это скорее иллюстрация того, что оптимизацию нужно либо применять осмысленно (с пониманием инструментов, профайлингом), либо не задумываться о ней вообще. Вот несколько общих рекомендаций:
Закончу советом, не имеющим никаких противопоказаний: экспериментируйте с инструментами разработки, знайте спецификации языка, библиотеки и фрэймворки. Приведу самые полезные, на мой взгляд, ссылки для начала изучения платформы Ethereum:
Автор: Kirill Varlamov
Источник [15]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/programmirovanie/284804
Ссылки в тексте:
[1] Ethereum Yellow Paper: http://paper.gavwood.com
[2] wei: http://ethdocs.org/en/latest/ether.html
[3] Remix IDE: http://remix.ethereum.org
[4] официального сайта: https://remix.ethereum.org
[5] Спецификация языка Solidity: https://solidity.readthedocs.io/en/v0.4.24/types.html
[6] документация Solidity: https://solidity.readthedocs.io/en/v0.4.24/introduction-to-smart-contracts.html
[7] over-/under-flow уязвимости: https://medium.com/loom-network/how-to-secure-your-smart-contracts-6-solidity-vulnerabilities-and-how-to-avoid-them-part-1-c33048d4d17d
[8] порядок следования переменных в контракте: http://solidity.readthedocs.io/en/develop/miscellaneous.html#layout-of-state-variables-in-storage
[9] Очень интересный репозиторий: https://github.com/OpenZeppelin/openzeppelin-solidity
[10] SafeMath: https://github.com/OpenZeppelin/openzeppelin-solidity/tree/master/contracts/math
[11] Ethereum Yellow Paper: http://gavwood.com/paper.pdf
[12] Ethereum White Paper: https://github.com/ethereum/wiki/wiki/White-Paper
[13] Ethereum in 25 minutes: https://www.youtube.com/watch?v=mCzyDLanA7s
[14] Etherscan blockchain explorer: https://etherscan.io
[15] Источник: https://habr.com/post/415791/?utm_campaign=415791
Нажмите здесь для печати.