Otimização de contratos inteligentes. Como os tipos de solidez afetam os custos de transação

“Os programadores passam muito tempo se preocupando com a velocidade de seus programas, e as tentativas de obter eficiência geralmente têm um impacto negativo dramático na capacidade de depurar e dar suporte a eles. É necessário esquecer pequenas otimizações, por exemplo, em 97% dos casos. Otimização prematura é a raiz de todo mal! Mas não devemos perder de vista esses 3% onde é realmente importante! ”
Donald Knut.
Gás Ethereum

Ao conduzir auditorias de contratos inteligentes, às vezes nos perguntamos se o desenvolvimento deles está relacionado aos 97% em que não há necessidade de pensar em otimização ou estamos lidando apenas com os 3% dos casos em que isso é importante. Na nossa opinião, provavelmente o segundo. Diferentemente de outros aplicativos, os contratos inteligentes não são atualizados, eles não podem ser otimizados "on the go" (desde que o algoritmo deles não seja estabelecido, mas esse é um tópico separado). O segundo argumento a favor da otimização antecipada de contratos é que, ao contrário da maioria dos sistemas em que a subotimidade se manifesta apenas em escala, relacionada às especificidades de ferro e ambiente, é medida por um grande número de métricas, um contrato inteligente tem essencialmente a única métrica de desempenho - consumo de gás.

Portanto, é tecnicamente mais fácil avaliar a eficácia de um contrato, mas os desenvolvedores geralmente continuam confiando em sua intuição e fazem a mesma "otimização prematura" cega da qual o professor Knut falou. Verificaremos quão intuitiva a solução corresponde à realidade pelo exemplo da escolha da profundidade de bits de uma variável. Neste exemplo, como na maioria dos casos práticos, não obteremos economia e, vice-versa, nosso contrato se tornará mais caro em termos de gás consumido.

Que tipo de gás?


O Ethereum é como um computador global, cujo "processador" é a máquina virtual EVM, "código do programa" é uma sequência de comandos e dados gravados em um contrato inteligente, e chamadas são transações do mundo exterior. As transações são empacotadas em estruturas relacionadas - blocos que ocorrem uma vez a cada poucos segundos. E como o tamanho do bloco é limitado por definição e o protocolo de processamento é determinístico (requer processamento uniforme de todas as transações no bloco por todos os nós da rede), para satisfazer a demanda potencialmente ilimitada com um recurso limitado de nós e proteger contra o DoS, o sistema deve fornecer um algoritmo adequado para escolher a solicitação a ser atendida, e cujo ignorar, como mecanismo em muitas cadeias públicas de blockchain, existe um princípio simples - o remetente pode escolher a quantia de remuneração para o minerador por executar sua transação ktsii e escolhe mineiro cujas necessidades incluem um bloco, e não cuja, escolhendo o mais rentável para si próprios.

Por exemplo, no Bitcoin, onde o bloco é limitado a um megabyte, o minerador escolhe incluir a transação no bloco ou não com base no seu comprimento e na comissão proposta (escolhendo aqueles com a taxa máxima de satoshis por byte).

Para o protocolo Ethereum mais complexo, essa abordagem não é adequada, porque um único byte pode representar a ausência de uma operação (por exemplo, o código STOP) e a operação cara e lenta de gravação no armazenamento (SSTORE). Portanto, para cada código operacional no ar, seu próprio preço é fornecido, dependendo do consumo de recursos.

Tabela de taxas da especificação do protocolo
Tabela de taxas do Ethereum
Tabela de fluxo de gás para diferentes tipos de operações. Na especificação do protocolo Ethereum Yellow Paper .

Ao contrário do Bitcoin, o remetente da transação Ethereum não define a comissão em criptomoeda, mas a quantidade máxima de gás que ele está disposto a gastar - startGas e o preço por unidade de gás - gasPrice . Quando a máquina virtual executa o código, a quantidade de gás para cada operação subseqüente é subtraída de startGas até que a saída do código seja alcançada ou o gás acabe. Aparentemente, é por isso que um nome tão estranho é usado para esta unidade de trabalho - a transação é cheia de gasolina como um carro e chegará ao ponto de destino ou não, depende se há volume suficiente no tanque. Após a conclusão da execução do código para enviar um valor da transação é deduzido éster obtido multiplicando o gás efectivamente consumida por uma taxa de remetente predeterminados ( Wei gás por). Na rede global, isso acontece no momento da “mineração” do bloco, que inclui a transação correspondente, e no ambiente Remix, a transação é “minerada” instantaneamente, gratuitamente e sem quaisquer condições.

Nossa ferramenta - Remix IDE


Para o "perfil" do consumo de gás, usaremos o ambiente on-line para desenvolver os contratos Ethereum do Remix IDE . Esse IDE contém editor de código de destaque de sintaxe, visualizador de artefatos, renderização da interface do contrato, depurador visual da máquina virtual, compiladores JS de todas as versões possíveis e muitas outras ferramentas importantes. Eu recomendo iniciar o estudo do éter com ele. Uma vantagem adicional é que ele não requer instalação - basta abri-lo em um navegador no site oficial .

Seleção de tipo variável


A especificação da linguagem Solidity oferece ao desenvolvedor até 32 bits de tipos inteiros uint - de 8 a 256 bits. Imagine que você esteja desenvolvendo um contrato inteligente projetado para armazenar a idade de uma pessoa em anos. Que profundidade de bit você escolhe?

Seria bastante natural escolher o tipo mínimo suficiente para uma tarefa específica - o uint8 caberia matematicamente aqui. Seria lógico supor que quanto menor o objeto que armazenamos na blockchain e menos memória gastamos na execução, menos sobrecarga temos, menos pagamos. Mas na maioria dos casos, essa suposição estará incorreta.

Para o experimento, pegamos o contrato mais simples do que a documentação oficial do Solidity oferece e o coletamos em duas versões - usando o tipo variável uint256 e o ​​tipo 32 vezes menor - uint8.

simpleStorage_uint256.sol
 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 { //uint is alias for uint256 uint storedData; function set(uint x) public { storedData = x; } function get() public view returns (uint) { return storedData; } }


simpleStorage_uint8.sol
 pragma solidity ^0.4.0; contract SimpleStorage { uint8 storedData; function set(uint8 x) public { storedData = x; } function get() public view returns (uint) { return storedData; } } 



Medindo "economias"


Portanto, os contratos são criados, carregados no Remix, implantados e as chamadas para os métodos .set () são executadas pelas transações. O que nós vemos? Gravar um tipo longo é mais caro que um curto - 20464 versus 20205 unidades de gás! Como Porque Vamos descobrir!

Consumo de gás Ethereum no Remix IDE

Loja uint8 vs uint256


Gravar no armazenamento persistente é uma das operações mais caras do protocolo por razões óbvias: primeiro, gravar o estado aumenta o tamanho do espaço em disco requerido pelo nó completo. O tamanho desse armazenamento está aumentando constantemente e, quanto mais estados são armazenados nos nós, mais lenta a sincronização ocorre, maior é o requisito de infraestrutura (tamanho da partição, número de Iops). Nos horários de pico, são as operações de E / S do disco lento que determinam o desempenho de toda a rede.

Seria lógico esperar que o armazenamento do uint8 custasse dezenas de vezes mais barato que o uint256. No entanto, no depurador, você pode ver que os dois valores estão localizados exatamente no mesmo slot de armazenamento que um valor de 256 bits.



E, neste caso específico, o uso do uint8 não oferece nenhuma vantagem no custo de gravação no armazenamento.

Manuseando uint8 vs uint256


Talvez tenhamos benefícios ao trabalhar com o uint8, se não durante o armazenamento, pelo menos ao manipular dados na memória? A seguir, compara as instruções para a mesma função obtida para diferentes tipos de variáveis.



Você pode ver que as operações com o uint8 têm ainda mais instruções que o uint256. Isso ocorre porque a máquina converte o valor de 8 bits em uma palavra nativa de 256 bits e, como resultado, o código é cercado por instruções adicionais pagas pelo remetente. Não apenas escrever, mas também executar código com um tipo uint8, nesse caso, é mais caro.

Onde o uso de tipos curtos pode ser justificado?


Nossa equipe está envolvida na auditoria de contratos inteligentes há muito tempo e, até o momento, não houve um único caso prático em que o uso de um tipo pequeno no código fornecido para a auditoria levasse a economias. Enquanto isso, em alguns casos muito específicos, a economia é teoricamente possível. Por exemplo, se seu contrato armazena um grande número de pequenas variáveis ​​ou estruturas de estado, elas podem ser compactadas em menos slots de armazenamento.

A diferença será mais aparente no exemplo a seguir:

1. contrato com 32 variáveis ​​uint256

simpleStorage_32x_uint256.sol
 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. contrato com 32 variáveis ​​uint8

simpleStorage_32x_uint8.sol
 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; } } 



A implantação do primeiro contrato (32 uint256) custará menos - apenas 89941 de gás, mas .set () será muito mais caro, pois Ele ocupará 256 slots de armazenamento, o que custará 640.639 de gás para cada chamada. O segundo contrato (32 uint8) será duas vezes e meia mais caro ao implantar (gás 221663), mas cada chamada para o método .set () será muito mais barata, porque altera apenas uma célula do estágio (185291 gás).

Essa otimização deve ser aplicada?


Quão significativo é o efeito da otimização de tipo é um ponto discutível. Como você pode ver, mesmo para um caso sintético especialmente selecionado, não obtivemos várias diferenças. A escolha de usar o uint8 ou o uint256 é mais uma ilustração do fato de que a otimização deve ser aplicada de maneira significativa (com entendimento de ferramentas, criação de perfil) ou não pensar sobre o assunto. Aqui estão algumas diretrizes gerais:

  • se o contrato contiver muitos números pequenos ou estruturas compactas no repositório, você poderá pensar em otimização;
  • se você usar o tipo "abreviado" - lembre -se das vulnerabilidades de excesso / insuficiência de fluxo ;
  • para variáveis ​​de memória e argumentos de função que não são gravados no repositório, é sempre melhor usar o tipo nativo uint256 (ou seu apelido uint). Por exemplo, não faz sentido definir o iterador da lista como uint8 - apenas perca;
  • De grande importância para o empacotamento correto nos slots de armazenamento para o compilador é a ordem das variáveis ​​no contrato .

Referências


Termino com conselhos que não têm contra-indicações: experimente ferramentas de desenvolvimento, conheça as especificações da linguagem, biblioteca e estruturas. Aqui estão os links mais úteis, na minha opinião, para começar a aprender sobre a plataforma Ethereum:

  • contratos de ambiente de desenvolvimento Remix - IDE muito funcional baseada em browser;
  • Na especificação da linguagem Solidity , o link irá especificamente para a seção Layout de Variáveis ​​de Estado;
  • Um repositório de contratos muito interessante da famosa equipe do OpenZeppelin. Exemplos da implementação de tokens, contratos de crowdsale e, o mais importante - a biblioteca SafeMath , que ajuda a trabalhar com segurança com tipos;
  • Ethereum Yellow Paper , especificação formal da máquina virtual Ethereum;
  • Ethereum White Paper , a especificação da plataforma Ethereum, um documento mais geral e de alto nível com um grande número de links;
  • Ethereum em 25 minutos , uma breve introdução técnica ao Ethereum, curta, mas no entanto abrangente, do criador da plataforma, Vitalik Buterin;
  • Etherscan blockchain explorer , uma janela para o mundo real do éter, um navegador de blocos, transações, tokens, contratos na rede principal. No Etherscan, você encontrará o explorador para redes de teste Rinkeby, Ropsten, Kovan (redes com transmissão gratuita, baseadas em diferentes protocolos de consenso).

Source: https://habr.com/ru/post/pt415791/


All Articles