«Les programmeurs passent énormément de temps à s'inquiéter de la vitesse de leurs programmes, et les tentatives pour atteindre l'efficacité ont souvent un impact négatif dramatique sur la capacité de les déboguer et de les soutenir. Il faut oublier les petites optimisations, disons, dans 97% des cas. L'optimisation prématurée est la racine de tout mal! Mais il ne faut pas perdre de vue ces 3% où c'est vraiment important! »
Donald Knut.

Lorsque nous réalisons des audits de contrats intelligents, nous nous demandons parfois si leur développement concerne ces 97% où il n'est pas nécessaire de penser à l'optimisation ou si nous traitons uniquement ces 3% de cas où cela est important. À notre avis, plus probablement le second. Contrairement à d'autres applications, les contrats intelligents ne sont pas mis à jour, ils ne peuvent pas être optimisés «à la volée» (à condition que leur algorithme ne soit pas défini, mais il s'agit d'un sujet distinct). Le deuxième argument en faveur de l'optimisation
précoce des contrats est que, contrairement à la plupart des systèmes où la sous-optimalité ne se manifeste que par l'échelle, liée aux spécificités du fer et de l'environnement, elle est mesurée par un grand nombre de paramètres, un contrat intelligent a essentiellement la seule mesure de performance - la consommation de gaz.
Par conséquent, il est techniquement plus facile d'évaluer l'efficacité d'un contrat, mais les développeurs continuent souvent de s'appuyer sur leur intuition et de faire la même «optimisation prématurée» aveugle dont le professeur Knut a parlé. Nous vérifierons le degré d'intuitivité de la solution par rapport à la réalité par l'exemple du choix de la profondeur de bits d'une variable. Dans cet exemple, comme dans la plupart des cas pratiques, nous ne réaliserons pas d'économies, et même vice versa, notre contrat s'avérera plus cher en termes de gaz consommé.
Quel genre de gaz?
Ethereum est comme un ordinateur mondial, dont le «processeur» est la machine virtuelle EVM, le «code de programme» est une séquence de commandes et de données enregistrées dans un contrat intelligent, et les appels sont des transactions du monde extérieur. Les transactions sont regroupées dans des structures connexes - des blocs qui se produisent toutes les quelques secondes. Et puisque la taille du bloc est par définition limitée et que le protocole de traitement est déterministe (nécessite un traitement uniforme de toutes les transactions dans le bloc par tous les nœuds du réseau), pour satisfaire une demande potentiellement illimitée avec une ressource limitée de nœuds et se protéger contre le DoS, le système doit fournir un algorithme équitable pour choisir la demande à servir, et dont ignorer, en tant que mécanisme dans de nombreuses chaînes de blocs publiques, il existe un principe simple - l'expéditeur peut choisir le montant de la rémunération du mineur pour effectuer sa trans ktsii et mineur dont les besoins Choisit comprennent un bloc, et dont pas, le choix le plus rentable pour eux-mêmes.
Par exemple, dans Bitcoin, où le bloc est limité à un mégaoctet, le mineur choisit d'inclure la transaction dans le bloc ou non en fonction de sa longueur et de la commission proposée (en choisissant celles avec le rapport satoshis par octet maximum).
Pour le protocole Ethereum plus complexe, cette approche n'est pas appropriée, car un seul octet peut représenter à la fois l'absence d'une opération (par exemple, le code STOP) et l'opération d'écriture lente et coûteuse sur le stockage (SSTORE). Par conséquent, pour chaque op-code à l'antenne, son propre prix est fourni, en fonction de sa consommation de ressources.
Barème des frais selon la spécification du protocole
Tableau des débits de gaz pour différents types d'opérations. D'après la spécification du protocole
Ethereum Yellow Paper .
Contrairement à Bitcoin, l'expéditeur de la transaction Ethereum ne fixe pas la commission en crypto-monnaie, mais la quantité maximale de gaz qu'il est prêt à dépenser -
startGas et le prix par unité de gaz -
gasPrice . Lorsque la machine virtuelle exécute le code, la quantité de gaz pour chaque opération suivante est soustraite de startGas jusqu'à ce que la sortie du code soit atteinte ou que le gaz s'épuise. Apparemment, c'est pourquoi un nom si étrange est utilisé pour cette unité de travail - la transaction est remplie de gaz comme une voiture, et elle atteindra le point de destination ou non selon qu'il y a suffisamment de volume rempli dans le réservoir. Une fois l'exécution du code terminée, la quantité d'air reçue en multipliant le gaz réellement consommé par le prix fixé par l'expéditeur (
wei per gas) est débitée de l'expéditeur de la transaction. Dans le réseau mondial, cela se produit au moment de «miner» le bloc, qui comprend la transaction correspondante, et dans l'environnement Remix, la transaction est «minée» instantanément, gratuitement et sans aucune condition.
Notre outil - Remix IDE
Pour le «profilage» de la consommation de gaz, nous utiliserons l'environnement en ligne pour développer les contrats Ethereum du
Remix IDE . Cet IDE contient un éditeur de code de coloration syntaxique, un visualiseur d'artefacts, un rendu d'interface de contrat, un débogueur visuel de machine virtuelle, des compilateurs JS de toutes les versions possibles et de nombreux autres outils importants. Je recommande fortement de commencer l'étude de l'éther avec lui. Un avantage supplémentaire est qu'il ne nécessite pas d'installation - il suffit de l'ouvrir dans un navigateur depuis le
site officiel .
Sélection du type variable
La spécification du langage Solidity offre au développeur jusqu'à trente-deux bits de types entiers uint - de 8 à 256 bits. Imaginez que vous développez un contrat intelligent conçu pour stocker l'âge d'une personne en années. Quelle profondeur de bits choisissez-vous?
Il serait tout à fait naturel de choisir le type minimum suffisant pour une tâche spécifique - uint8 conviendrait mathématiquement ici. Il serait logique de supposer que plus l'objet que nous stockons sur la blockchain est petit et moins nous dépensons de mémoire pour l'exécution, moins nous avons de frais généraux, moins nous payons. Mais dans la plupart des cas, cette hypothèse sera incorrecte.
Pour l'expérience, nous prenons le contrat le plus simple de ce que propose la
documentation officielle de
Solidity et le collectons en deux versions - en utilisant le type variable uint256 et le type 32 fois plus petit - 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; } }
Mesurer les «économies»
Ainsi, les contrats sont créés, chargés dans Remix, déployés et les appels aux méthodes .set () sont exécutés par des transactions. Que voyons-nous?
L'enregistrement d'un type long est plus cher qu'un type court - 20464 contre 20205 unités à gaz! Comment? Pourquoi? Voyons ça!

Store uint8 vs uint256
L'écriture sur un stockage persistant est l'une des opérations les plus coûteuses du protocole pour des raisons évidentes: premièrement, l'enregistrement de l'état augmente la taille de l'espace disque requis par le nœud complet. La taille de ce stockage augmente constamment et plus les états sont stockés sur les nœuds, plus la synchronisation est lente, plus les besoins en infrastructure sont importants (taille de la partition, nombre d'iops). Aux heures de pointe, ce sont les opérations d'E / S sur disque lent qui déterminent les performances de l'ensemble du réseau.
Il serait logique de s'attendre à ce que le stockage de uint8 coûte des dizaines de fois moins cher que uint256. Cependant, dans le débogueur, vous pouvez voir que les deux valeurs sont situées exactement de la même manière dans l'emplacement de stockage qu'une valeur de 256 bits.

Et dans ce cas particulier, l'utilisation de uint8 ne donne aucun avantage sur le coût d'écriture sur le stockage.
Gestion de uint8 vs uint256
Peut-être que nous aurons des avantages à travailler avec uint8 sinon pendant le stockage, du moins lors de la manipulation des données en mémoire? Ce qui suit compare les instructions pour la même fonction obtenues pour différents types de variables.

Vous pouvez voir que les opérations avec uint8 ont encore
plus d'instructions que uint256. En effet, la machine convertit la valeur 8 bits en un mot natif 256 bits et, par conséquent, le code est entouré d'instructions supplémentaires payées par l'expéditeur. Non seulement l'écriture, mais aussi l'exécution de code avec un type uint8 dans ce cas est plus coûteuse.
Où peut-on justifier l'utilisation de types courts?
Notre équipe est engagée dans l'audit de contrats intelligents depuis longtemps, et jusqu'à présent, il n'y a pas eu un seul cas pratique où l'utilisation d'un petit type dans le code fourni pour l'audit entraînerait des économies. Pendant ce temps, dans certains cas très spécifiques, des économies sont théoriquement possibles. Par exemple, si votre contrat stocke un grand nombre de petites variables ou structures d'état, elles peuvent être regroupées dans moins d'emplacements de stockage.
La différence sera plus apparente dans l'exemple suivant:
1. contrat avec 32 variables 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. contrat avec 32 variables 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; } }
Le déploiement du premier contrat (32 uint256) coûtera moins cher - seulement 89941 gaz, mais .set () sera beaucoup plus cher car Il occupera 256 emplacements de stockage, ce qui coûtera 640 639 gaz pour chaque appel. Le deuxième contrat (32 uint8) sera deux fois et demie plus cher lors du déploiement (gaz 221663), mais chaque appel à la méthode .set () sera beaucoup moins cher, car ne change qu'une seule cellule de l'étage (185291 gaz).
Faut-il appliquer une telle optimisation?
L'importance de l'effet de l'optimisation de type est un point discutable. Comme vous pouvez le voir, même pour un boîtier synthétique spécialement sélectionné, nous
n'avons pas obtenu de multiples différences. Le choix d'utiliser uint8 ou uint256 est plutôt une illustration du fait que l'optimisation doit être appliquée de manière significative (avec une compréhension des outils, du profilage), ou ne pas y penser du tout. Voici quelques directives générales:
- si le contrat contient de nombreux petits nombres ou des structures compactes dans le référentiel, alors vous pouvez penser à l'optimisation;
- si vous utilisez le type "abrégé" - n'oubliez pas les vulnérabilités de débordement / dépassement ;
- pour les variables de mémoire et les arguments de fonction qui ne sont pas écrits dans le référentiel, il est toujours préférable d'utiliser le type natif uint256 (ou son alias uint). Par exemple, cela n'a aucun sens de définir l'itérateur de liste sur uint8 - il suffit de perdre;
- L' ordre des variables dans le contrat est d'une grande importance pour le conditionnement correct dans les emplacements de stockage pour le compilateur.
Les références
Je vais me retrouver avec des conseils qui n'ont pas de contre-indications: expérimenter avec des outils de développement, connaître les spécifications du langage, de la bibliothèque et des frameworks. Voici les liens les plus utiles, à mon avis, pour commencer à découvrir la plate-forme Ethereum:
- L'environnement de développement de contrats Remix est un IDE basé sur un navigateur très fonctionnel;
- La spécification du langage Solidity , le lien ira spécifiquement à la section sur la disposition des variables d'état;
- Un référentiel de contrats très intéressant de la célèbre équipe OpenZeppelin. Exemples de mise en œuvre de jetons, de contrats de crowdsale et, surtout, de la bibliothèque SafeMath , celle qui permet de travailler en toute sécurité avec les types;
- Ethereum Yellow Paper , spécification formelle de la machine virtuelle Ethereum;
- Ethereum White Paper , la spécification de la plateforme Ethereum, un document plus général et de haut niveau avec un grand nombre de liens;
- Ethereum en 25 minutes , une introduction technique courte mais néanmoins vaste à Ethereum du créateur de la plateforme, Vitalik Buterin;
- Etherscan blockchain explorer , une fenêtre sur le monde réel de l'éther, un navigateur de blocs, de transactions, de jetons, de contrats sur le réseau principal. Sur Etherscan, vous trouverez l'explorateur pour les réseaux de test Rinkeby, Ropsten, Kovan (réseaux avec diffusion gratuite, construits sur différents protocoles de consensus).