EVM часть 3: оптимизируем контракт с одним полем

В предыдущей статье мы добавили в наш пустой контракт одно поле.
Изучили байткод, который получился в результате добавления поля. Выяснили, что в байткоде смарт-контракта присутствует селектор методов. И что для публичных полей Soldity создает одноименные методы.

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

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

Предыдущая статья. Следующая статья.

Полный список статей по теме тут.

Заходите в наш телеграмм канал — Blockchain Witnesses! Делитесь опытом или задавайте вопросы, если что-то непонятно.

Оптимизация метода

Из всех участков кода, самый большой оказался участок метода чтения поля. Поэтому им мы и займемся. А остальные части контракта оставим как есть. Для начала этого хватит.

Перед оптимизацией запишем, сколько стоит залить наш смарт-контракт и какого он размера.

  • Залить  смарт-контракт стоит — 112,828
  • Размер смарт-контракта — 204 байта

Приступим к оптимизации. Мы могли бы посмотреть на код метода и выкинуть лишние блоки кода. Но мы поступим по другому. Давайте подумаем, что реально должен делать метод и напишем свой код. Итак, метод должен:

  1. Прочитать значение поля из storage в стэк
  2. Записать значение из стэка в память
  3. Вернуть значение из памяти при выходе

Довольно просто, не так ли. Приступим

Прочитать значение из поля из storage в стэк

Мы помним из предыдущей статьи, куда в storage конструктор записывает наше значение:

Наше значение конструктор записывает в storage по адресу 0x0. Вот оттуда мы его и прочитаем. Для этого нам потребуется команда чтения из storage — SLOAD — Storage load. Эта инструкция делает следующее:

  1. Забирает из стэка адрес
  2. Читает из storage по адресу из стэка 32 байта
  3. Помещает прочитанные данные в стэк

Поэтому для чтения нашего поля необходимо поместить в стэк адрес 0x0. Сделаем это командой PUSH0, а потом вызовем SLOAD. В результате получим простой код:

Записать прочитанное значение из стэка в память

Мы помним, что куда попало в память писать мы не можем. Нам нужно писать именно в свободную память. А указатель на свободную память у нас хранится по адресу 0x40. Поэтому алгоритм действий  у нас будет такой:

  1. Читаем указатель на свободную память в стэке
  2. Записываем в память значения поля из стэка по адресу из (1)

Прочитать указатель свободной памяти из адреса 0x40 в стэк мы можем командой MLOAD. Memory load.

Записать в память значение мы можем командой MSTORE. Memory store. Эта инструкция забирает из стэка два значения — адрес и само значение, которое нужно записать.

Давайте напишем код:

Поясним. Перед вызовом MSTORE мы ничего не помещаем в стэк, потому что до выполнения этого кода в стэке  уже лежит значение поля. А адрес куда поместить значение будет помещен в стэк командой MLOAD.

Код рабочий и делает то, что нам нужен. Но написан немного не по правилам.

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

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

После выполнения этого кода на стэке у нас будет лежать указать на наши данные в памяти.

И теперь при необходимости можно увеличить указатель на свободную память на 0x20.

Вернуть значение из памяти при выходе

Выход у нас осуществляется инструкцией RETURN. Эта инструкция ожидает в стэке: адрес откуда из памяти брать данные для возврата и размер данных.

В стэке у нас уже лежит указатель на данные в памяти. Осталось положить размер. Размер данных 32 байта. Или 0x20 в HEX.

Поэтому код получится такой:

Полный код функции получения значения поля

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

Итак, код нашего метода:

Как проверить работоспособность кода нашего метода

Зайдите на сервис evm playground и вставьте туда этот код

Впереди 4 строчки — это инициализация storage и memory: в storage мы кладем 0x0309, а в memory инициализируем указатель пустой памяти. Это необходимо для корректной работы нашего метода. Ведь мы не выполняем весь смарт-контракт, поэтому и требуется небольшая инициализация.
Теперь можете пройтись по шагам и убедиться, что наш код работает. В поле return value мы увидим 0x309.


Осталось собрать байткод нашего смарт-контракт вместе.

Сборка байткода контракта

Мы оптимизировали код нашего метода возврата поля a. Теперь давайте соберем байткод всего контракта. Напомню из чего состоит байткод смарт-контракта:
Чтобы исправить байткод нам потребуется:

  1. Взять оригинальный байткод
  2. Заменить код методов на наш оптимизированный
  3. Исправить размер кода при возврате из конструктора
  4. Исправить размер кода при копировании в конструкторе

В результате получим (без метаданных)

А байткод станет таким:

0x60806040526103095f553480156013575f80fd5b50606c8060
1f5f395ff3fe6080604052348015600e575f80fd5b50600436
106026575f3560e01c80630dbe671f14602a575b5f80fd5B5F5
460405180919052602090F3a264697066735822122052b6
89f2eb123eb4fe4ead878d0b4a6224c0554c640c36015c4e5a
cc61d7c9d364736f6c63430008190033

  • Жирным выделены изменившееся данные
  • Сиреневым метаданные

Осталось протестировать!

Тестирование

Для тестирования будем использовать фреймворк hardhat. Предполагается, что читатель знаком с ним или способен установить по документации. Если вкратце, то:

  1. Создаем папку проекта и переходим в нее
  2. Устанавливаем hardhat: npm install —save-dev hardhat
  3. Инициализируем проект: npx hardhat init
    Выбираем typescript project
  4. В папке contracts создаем файл с нашим контрактом  BlockwitEmptyContract.sol:
  5. В папке test создаем файлик с нашими тестами BlockwitEmptyContract.ts:

    Это тест оригинального смарт-контракта.
  6. В папке test создадим файлик с тестами не оптимизированного байткода BlockwitEmptyContractNotOptimized.sol:
  7. В папке test создаем файлик с нашими тестами BlockwitEmptyContractOptimized.ts:

    В переменной bytecode я записал наш оптимизированный байткод с комментариями.
  8. Запускаем: npx hardhat test и должны получить успех. А один из тестов выведет байткод оптимизированного смарт-контракта

Готовы репозиторий можно склонировать отсюда — https://github.com/BlockWit/blockwit-empty-contract-field-optimization.

Резюме

В этой статье мы оптимизировали наш контракт.

Давайте посмотрим сколько мы сэкономили:

До оптимизации После оптимизации
Размер смарт-контракта 204 141
Стоимость по газу 112 828 99 004

Мы сократили затраты на смарт-контракт  на 13,000 газа! Можно сказать, что наша оптимизация успешна.

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

Предыдущая статья. Следующая статья.

Полный список статей по теме тут.

Заходите в наш телеграмм канал — Blockchain Witnesses! Делитесь опытом или задавайте вопросы, если что-то непонятно.

Добавить комментарий