Otimização de Gás
💡 Iniciando
Para ajudar os desenvolvedores a deixar seus contratos em perfeitas condições antes da implantação, reconfiguramos nossa ferramenta de snapshot de gás mais usada em um simples pacote npm: o Teste de Snapshot de Gás da Uniswap.
No Uniswap Labs, muitos dos contratos inteligentes que escrevemos se tornaram os contratos inteligentes mais frequentemente chamados na blockchain Ethereum.
Dezenas de milhares de ETH são gastos todos os meses por usuários que interagem com os contratos do Protocolo Uniswap. Múltiplos disso ainda são gastos em muitas bifurcações (forks) do protocolo implantados na Ethereum e em outros ambientes.
Devido ao uso significativo do protocolo e à reutilização na forma de bifurcações, uma alteração na otimização de gás de um código de protocolo que economiza 1% em gás se traduz em milhões de dólares economizados pela comunidade DeFi durante a vigência dos contratos.
Como resultado, é difícil exagerar na importância dessas otimizações. Um esforço considerável é dedicado a esse trabalho, que frequentemente continua até a devida implantação.
Embora grande parte do discurso sobre otimização de gás assuma a forma de técnicas de implementação específicas, que podem ser bastante divertidas de ler e experimentar, achamos que uma coisa mais útil para escrever é o desenvolvimento de um processo em busca da otimização de gás, em vez de uma coleção de otimizações específicas que podem se tornar obsoletas à medida que a Ethereum evolui.
Medição
Se há uma lição a aprender com este post, é que toda otimização começa com a medição. A maior ferramenta em nosso arsenal de otimização de gás é o teste de snapshot emprestado do teste de snapshot do Jest. Para a versão 3, usamos um snippet em combinação com o plug-in mocha-chai-jest-snapshot para registrar os custos de gás em centenas de situações.
O código abaixo, que utilizamos em nosso processo de desenvolvimento, foi implementado em um pacote NPM para facilitar o uso em seu projeto: O Teste de Snapshot de Gás da Uniswap.
import {
TransactionReceipt,
TransactionResponse,
} from "@ethersproject/abstract-provider";
import { expect } from "./expect";
import { Contract, BigNumber, ContractTransaction } from "ethers";
export default async function snapshotGasCost(
x:
| TransactionResponse
| Promise<TransactionResponse>
| ContractTransaction
| Promise<ContractTransaction>
| TransactionReceipt
| Promise<BigNumber>
| BigNumber
| Contract
| Promise<Contract>
): Promise<void> {
const resolved = await x;
if ("deployTransaction" in resolved) {
const receipt = await resolved.deployTransaction.wait();
expect(receipt.gasUsed.toNumber()).toMatchSnapshot();
} else if ("wait" in resolved) {
const waited = await resolved.wait();
expect(waited.gasUsed.toNumber()).toMatchSnapshot();
} else if (BigNumber.isBigNumber(resolved)) {
expect(resolved.toNumber()).toMatchSnapshot();
}
}
Este teste nos permite ver todas as alterações nos contratos inteligentes - e revela a economia exata que o usuário experimentaria em uma variedade de situações. É essencial enviar esses snapshots para o repositório para que as alterações futuras possam ser comparadas com os custos atuais de gás de seus contratos inteligentes.
Agora que os conceitos básicos foram abordados, como você decide onde gastar seu tempo otimizando?
Primeiro, é importante entender quais mudanças são relevantes e quais não são. Uma diferença de 50 gás em uma chamada que custa 100k gás normalmente está abaixo da barra de relevância. No entanto, várias otimizações de 50 gás, chamadas várias vezes por transação, podem adicionar até 1% de economia para uma ação do usuário. O importante aqui é o contexto: se você está economizando 50 gás em uma função que normalmente custa 1000 gás, está economizando 5% nessa função. Você deve separar seu código em limites de função e medir nesses limites. Fazemos isso com as muitas bibliotecas na base de código Uniswap V3.
O contexto também é relevante para onde gastar seu tempo em uma base de código. Por exemplo, sabemos que a maioria dos usuários irá interagir com a Uniswap por meio de chamadas para troca (swap). Portanto, devemos concentrar nossa energia principalmente na função de troca.
Às vezes, a otimização é menos clara. Em muitos casos, o código fica estritamente melhor após uma mudança, enquanto em outros, certos cenários se tornam mais caros, enquanto outros menos, por exemplo, cunhar é mais barato, mas trocar é mais caro. Para entender se deve se comprometer com uma mudança, é importante entender como os usuários irão interagir com um contrato.
Um exemplo extremo disso é a falta de proxies na base de código Uniswap V3. O uso de um proxy pode economizar milhões de gás toda vez que você criar um pool na V3. No entanto, os usuários irão interagir com um determinado pool, em média, mais de mil vezes durante a vigência dos contratos. Assumindo O(1_M_)1 gás e O(1_k_) chamadas por contrato, o proxy deve ter uma sobrecarga inferior a O(1_M_)/O(1_K_)=O(1_K_) gás. A sobrecarga de um proxy, infelizmente, é mais do que isso. Um contrato de proxy significa que um endereço de implementação e um endereço de proxy devem ser chamados para executar uma troca. Chamar um novo endereço como parte de uma troca incorre em um mínimo adicional de O(1_K_) gás . Curiosamente, o parâmetro ‘runs’ do otimizador do Solidity faz algo assim: ele otimiza seu código de forma que o custo do gás seja minimizado se você implantou e executou seus tempos runs
de contrato.
Na Prática: Empacotamento do Armazenamento
Ao otimizar contratos inteligentes, é importante identificar as áreas do código que provavelmente produzirão os retornos mais significativos (em termos de economia de gás). Para obter intuição sobre isso, é importante dar um passo para trás e entender as restrições fundamentais em jogo.
As operações mais caras na Ethereum (e na maioria das outras blockchains L1) normalmente envolvem armazenamento e busca de dados que devem persistir em transações e blocos. A totalidade desses dados é chamada de estado da blockchain. Se ampliarmos o subconjunto desse estado associado a um contrato inteligente específico, nos referimos a ele como armazenamento do contrato.
❗ Disco x Memória
Um rápido comentário: armazenar e recuperar o estado é muito caro porque deve residir no disco, já que é muito grande para caber na memória. Para obter mais informações sobre esses tipos de compensações nas configurações da blockchain, consulte Os limites da escalabilidade da Blockchain.
Portanto, voltando à otimização, está claro que um de nossos principais objetivos deve ser minimizar o uso de armazenamento de nosso contrato, pois isso pode levar a grandes economias para os usuários finais.
Agora que sabemos no que focar, é hora de operacionalizar esse insight. Para fazermos isso, precisaremos dar uma olhada nas partes internas da Máquina Virtual da Ethereum (Ethereum Virtual Machine), ou EVM, para abreviar. A EVM é o mecanismo que processa transações na Ethereum (semelhante a como seu navegador é o mecanismo que processa os sites que você visita). Ela define as regras que regem o que os contratos podem fazer, incluindo como eles usam o armazenamento! Uma dessas regras afirma que, quando os contratos estão sendo gravados ou lidos no armazenamento, eles devem fazê-lo em incrementos de 256 bits. Cada bloco de 256 bits é chamado de palavra.
Claro, é possível armazenar mais de 256 bits de dados por contrato, mas os dados fornecidos abrangerão várias palavras, cada uma das quais custa gás para atualizar. Para ilustrar como acomodar essas limitações, considere o caso em que precisamos rastrear vários dados, que são consideravelmente menores que 256 bits. Por exemplo, um booleano é um sinalizador sim/não simples que pode ser armazenado em um único bit, e podemos querer rastrear vários booleanos mutuamente independentes em nosso contrato. Se conseguirmos empacotar as representações dessas variáveis dentro dos limites de uma única palavra, podemos lê-las e escrevê-las em massa - garantindo que sejamos carregados apenas para usar uma única palavra de armazenamento. Esta é provavelmente a técnica de economia de gás mais importante, que usamos amplamente na Uniswap.
Na V3, sete (!) variáveis diferentes são agrupadas em uma única palavra (também chamada de slot):
struct Slot0 {
// o preço atual
uint160 sqrtPriceX96;
// o tick atual
int24 tick;
// o índice atualizado mais recentemente da matriz de observações
uint16 observationIndex;
// o número máximo atual de observações que estão sendo armazenadas
uint16 observationCardinality;
// o próximo número máximo de observações a serem armazenadas, acionado em observations.write
uint16 observationCardinalityNext;
// a taxa de protocolo atual como uma porcentagem da taxa de swap cobrada na retirada
// representado como um denominador inteiro (1/x)%
uint8 feeProtocol;
// se o pool está bloqueado
bool unlocked;
}
Slot0 public slot0;
Para verificar se é esse o caso, podemos simplesmente somar o número de bits usados por cada variável. por exemplo, uint160 sqrtPriceX96 usa 160 bits, int24 tick usa 24 bits, etc.
160+24+16+16+16+8+1=241
Isso nos diz que, na verdade, temos 15 bits de sobra! Este contrato armazena muito mais variáveis, algumas das quais ocupam espaços inteiros por conta própria, mas selecionando cuidadosamente as variáveis com representações compactas e declarando-as lado a lado, alcançamos nosso objetivo de otimização de gás. A esta altura, deve estar claro que a otimização de gás não é apenas uma questão de truques inteligentes e novas expressões de dados; mas também uma questão de tomada de decisão fundamental feita ao projetar a arquitetura de seus contratos inteligentes.
Para uma análise mais profunda do empacotamento de slots no Solidity, um bom lugar para começar é a Documentação do Solidity.
Artigo publicado por Moody Salem, Noah Zinsmeister e Connor Martin, no site da documentação oficial da Uniswap. Tradução por Paulinho Giovannini.
Oldest comments (0)