Газовый гольф за счет оптимизации хранения

Эта статья представляет собой краткое изложение доклада, представленного на SmartCon # 1.

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

Что такое хранилище в Solidity?

Хранилище - это любые данные, которые сохраняются между транзакциями. Он часто хранится в течение длительных периодов времени, и к нему можно будет получить доступ в будущих транзакциях. На других языках мы могли бы назвать эти «переменные экземпляра» или «переменные класса».

Однако есть загвоздка с переменными хранилища. Фактически, это одна из самых дорогих вещей, с которой можно поиграть в контракте.

Почему дорогое хранилище?

Чтобы объяснить, почему это так в децентрализованном мире, давайте сначала представим централизованный.

На централизованном сервере хранилище хранится в одном месте, обычно в базе данных. Это предсказуемо, так что чем больше пользователей будет использовать это централизованное приложение, мы можем предсказать объем хранилища, который потребуется базе данных. Его легко масштабировать, так как есть очень мало мест для масштабирования.

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

Из-за этого EVM накладывает два намеренно дорогих кода операции:

  • SSTORE (также известный как «Хранить эти данные в этом слоте хранения»)
  • SLOAD (также известный как «Загрузить данные из этого слота в память»)

Что мы можем с этим поделать?

Вот 5 советов, которые помогут оптимизировать использование хранилища вашим контрактом.

1. Не храните, если вам не нужно

Ниже представлена ​​фиктивная функция передачи, которая выполняет некоторые проверки, изменяет некоторые значения баланса, выполняет перенос, а затем записывает запись о передаче в s_transfers. На этом последнем шаге мы должны сосредоточиться.

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

В этом случае мы должны вместо этого создать событие.

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

ГАЗ Раньше: ~ 61000

ГАЗ после: ~ 36,000

Экономия: ~ 25 000 (~ 41%)

2. По возможности используйте константы

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

В приведенном выше примере адрес LINK никогда не изменится после развертывания контракта, но при каждом чтении он по-прежнему получает SLOAD.

Первый вариант - установить его как immutable:

Неизменяемые переменные устанавливаются при конструировании и никогда не могут быть изменены после завершения конструирования и развертывания.

Второй - установить его как constant:

При работе с большим количеством переменных хранилища всегда спрашивайте себя: «Это может быть immutable или constant

Читайте сбережения:

ГАЗ Раньше: ~ 2,500

ГАЗ после: ~ 450

Экономия: ~ 2050 (~ 82%)

3. Сделайте это очевидным

Ниже приведены 5 переменных, 2 из которых неизменяемы, 3 из которых являются переменными хранилища. Для любого читателя или рецензента этого контракта очень трудно заметить разницу. Если они просматривают функцию на сотни строк ниже этих определений, сложно понять, когда эта функция касается дорогостоящего хранилища или дешевых неизменяемых переменных.

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

Таким образом, легко увидеть, сколько раз какая-либо функция обращается к хранилищу или меняет его.

4. Не читайте и не пишите слишком часто.

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

Следите за функциями, которые:

  • Сенсорная память более двух раз
  • Используйте переменные хранилища в циклах
  • Есть много условных операторов, которые проверяют переменные хранилища

Стоит отметить, что на самом деле это было еще дороже до берлинского хард-форка в начале 2021 года, в результате которого был принят EIP-2929. Это ввело понятие холодного и теплого хранения. Если вы коснетесь слота в первый раз в транзакции, вы переключите его с холодного на теплый до конца этой транзакции. Теплые прикосновения дешевле холодных, но все же дороже, чем вычисления в памяти.

ГАЗ Раньше: ~ 27,600

ГАЗ после: ~ 27,100

Экономия: ~ 500 (~ 2%)

2% может показаться небольшим, но для функции с высокой пропускной способностью, которая принимает сотни или тысячи транзакций, эта экономия со временем становится значительной.

5. Упакуйте свои конструкции

Структуры представляют записи с более чем одной точкой данных. Возьмем этот пример книжной записи в библиотеке. У него есть идентификатор, заголовок, тема и автор. У него могут быть другие точки данных, такие как издание, обложка / мягкая обложка и т. Д. Вот как это может выглядеть в Solidity:

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

Хранилище

  • Слоты для хранения 32 байта каждый
  • Каждый слот получает SLOAD или SSTORE

Типы значений

Разные типы значений используют разный объем дискового пространства:

  • uint256: 32 байта
  • int256: 32 байта
  • bytes32: 32 байта
  • address: 20 байт
  • контракты: 20 байт
  • bool: 1 байт

Полезный факт о целочисленных и байтовых типах значений заключается в том, что их размер может быть определен. uint256 занимает 32 байта, а uint128 - всего 16 байтов. Это чрезвычайно полезно при эффективной упаковке структур.

В приведенном выше примере показана структура, занимающая 2 слота памяти. Первый слот занимает wallet, тип address, что означает, что занято 20 из доступных 32 байтов. EVM пытается втиснуть следующую переменную в оставшиеся 12 байтов, но не может, потому что это uint256, поэтому назначает ее своему собственному слоту.

Всего 2 слота. Но в первом слоте есть запасные 12 байтов, которые не используются.

Можно ли изменить размер number, чтобы он втиснулся в оставшиеся 12 байтов слота 1? Что ж, если мы уверены, что number никогда не превысит определенное значение (и у нас есть соответствующие проверки на протяжении всего контракта для этого), тогда да, мы можем!

При изменении number на uint96 эта структура теперь использует только 1 слот памяти, поэтому для чтения или хранения используется только один SLOAD или SSTORE вместо двух.

Для справки: max (uint96) = 2⁹⁶-1 = 79,228,162,514,264,337,593,543,950,335

Давайте посложнее:

Зная то, что мы знаем, вы могли бы подумать, что EVM будет достаточно умен, чтобы втиснуть все точки данных в этой структуре всего в два слота, потому что в этой структуре всего 64 байта типов значений. Но, как мы видим из комментариев справа, это не так. Это потому, что порядок действительно имеет значение. В этой структуре используются 3 слота вместо 2.

Решение? Изменение порядка:

Итак, 2 слота вместо 3!

Резюме

  1. Не храните, если вам не нужно
  2. Используйте константы и неизменяемые
  3. Сделайте очевидным, что вы касаетесь хранилища
  4. Не читайте и не пишите слишком часто
  5. Упакуйте свои конструкции