Écriture plus efficace de tableaux dans la mémoire persistante d'un contrat intelligent dans Solidity

Récemment, j'ai dû travailler un peu avec la blockchain Ethereum . L'idée sur laquelle je travaillais exigeait de stocker un assez grand nombre d'entiers directement sur la blockchain afin que le contrat intelligent y ait facilement accès. La plupart des leçons sur le développement de contrats intelligents nous disent: "ne stockez pas beaucoup de données sur la blockchain, c'est cher!" Mais combien coûte «beaucoup» et combien le prix devient-il trop élevé pour une utilisation pratique? J'ai dû découvrir, parce que nous ne pouvions pas rendre nos données hors chaîne, l'idée s'est effondrée.

Je commence à peine à travailler avec Solidity et EVM, donc cet article ne prétend pas être la vérité ultime, mais je n'ai pas pu trouver d'autres documents sur ce sujet ni en russe ni en anglais (bien qu'il soit très dommage que je ne l'ai pas rencontré auparavant) ), donc j'espère que cela peut être utile à quelqu'un. Eh bien, ou en dernier recours, cela peut être utile pour moi si des camarades expérimentés me disent comment et où exactement je me trompe.

Pour commencer, j'ai décidé de comprendre rapidement si nous pouvions le faire? Prenons le type de contrat standard et répandu - le jeton ERC20 . Au moins, un tel contrat stocke dans la blockchain la correspondance des adresses des personnes qui ont acheté les jetons à leurs soldes. En réalité, seuls les soldes sont stockés, chacun prenant 32 octets (en fait, cela n'a aucun sens d'économiser ici en raison des fonctionnalités de Solidity et EVM). Un jeton plus ou moins réussi peut facilement avoir des dizaines de milliers de propriétaires, et nous obtenons donc que le stockage d'environ 320 000 octets dans la blockchain est parfaitement acceptable. Et nous n'en avons pas besoin de plus!

Approche naïve


Eh bien, essayons de sauvegarder nos données. Une partie importante d'entre eux sont des entiers non signés 8 bits, nous allons donc transférer leur tableau dans le contrat et essayer de les écrire dans la mémoire morte:

uint8[] m_test; function test(uint8[] data) public { m_test = data; } 

Dingo! Cette fonction mange du gaz, comme si elle n'était pas en elle-même. Une tentative de sauvegarde de 100 valeurs nous a coûté 814033 gaz, 8100 gaz par octet!

Expirez et reculez vers la théorie. Quel est le coût minimum (en gaz) du stockage des données sur la blockchain Ethereum? Il faut se rappeler que les données sont stockées dans des blocs de 32 octets. EVM ne peut lire ou écrire qu'un bloc entier à la fois, donc idéalement, les données à écrire doivent être empaquetées aussi efficacement que possible afin qu'une seule commande d'écriture économise plus immédiatement. Parce que cette même commande d'enregistrement - SSTORE - coûte à elle seule 20 000 gaz (si nous écrivons dans une cellule mémoire que nous n'avons pas écrite auparavant). Donc, notre minimum théorique, en ignorant toutes les autres dépenses, est d'environ 625 gaz par octet. Loin du 8100 que nous avons obtenu dans l'exemple ci-dessus! Il est maintenant temps de creuser plus profondément et de découvrir qui mange notre gaz et comment l'arrêter.

Notre première impulsion devrait être de regarder le code généré par le compilateur Solidity à partir de notre ligne isolée (m_test = data), car il n'y a plus rien à voir. Ceci est une bonne impulsion correcte qui nous familiarisera avec un fait terrifiant - le compilateur à cet endroit a généré des horreurs anciennes que vous ne comprendrez pas à première vue! En jetant un coup d'œil à la liste, nous y voyons non seulement SSTORE (ce qui est prévu), mais aussi SLOAD (chargement à partir de la mémoire morte) et même EXP (exponentiation)! Dans l'ensemble, cela semble être un moyen très coûteux d'enregistrer des données. Et le pire de tout, il devient tout à fait évident que SSTORE est appelé aussi, trop souvent. Que se passe-t-il ici?

Quelques trucs. Il s'avère que le stockage d'entiers 8 bits est presque la pire chose que vous puissiez faire avec EVM / Solidity (l'article, un lien vers lequel j'ai cité au début, en parle). Nous perdons de la productivité (ce qui signifie que nous payons plus de gaz) à chaque tour. Premièrement, lorsque nous transmettons un tableau de valeurs 8 bits à l'entrée de notre fonction, chacune se développe à 256 bits. Autrement dit, seulement par la taille des données de transaction, nous perdons déjà 32 fois! Nice Cependant, un lecteur attentif remarquera que le coût de l'octet stocké n'est toujours que 13 fois plus élevé que le minimum théorique, et non 32, ce qui signifie que lorsque le contrat est définitivement sauvegardé en mémoire, tout n'est pas si mal. Voici la chose: lors de la sauvegarde, il contient toujours les données et dans la mémoire permanente du contrat, nos numéros 8 bits seront stockés de la manière la plus efficace, 32 pièces dans chaque bloc de mémoire. Cela soulève la question, mais comment est la conversion des nombres décompressés "256 bits" qui nous sont parvenus lors de l'entrée de la fonction dans une forme compactée? La réponse est "la manière la plus stupide que je puisse imaginer".

Si nous écrivons tout ce qui se passe sous une forme simplifiée, notre ligne de code solitaire se transforme en un cycle étrange:

 for(uint i = 0; i < data.length; ++i) { //      ,    256-bit  8-bit uint8 from_value = uint8(data[i]); //  32-     -        ,     uint256 to_value = get_storage_data_at_offset(m_test, i); //        (    2  ) add_byte_to_value(to_value, i % 32, from_value); //  32-      set_storage_data_at_offset(m_test, i, to_value); } 

L'aspect de ce code n'est presque pas affecté par l'activation ou la désactivation de l'optimisation (au moins dans la version 0.4.24 du compilateur Solidity), et comme vous pouvez le voir, il appelle SSTORE (dans le cadre de set_storage_data_at_offset) 32 fois plus souvent que nécessaire (une fois pour chaque numéro de 8 bits, et pas une fois pour 32 de ces numéros). Ce qui nous sauve du fiasco complet, c'est que le réenregistrement dans la même cellule ne coûte pas 20 000, mais 5 000 gaz. Donc, chaque 32 octets nous coûte 20 000 + 5 000 * 31 = 125 000 gaz, soit environ 4 000 gaz par octet. Le reste de la valeur que nous avons vu ci-dessus provient de la lecture de la mémoire (également pas une opération bon marché) et d'autres calculs cachés dans le code ci-dessus dans les fonctions (et il y en a beaucoup).

Eh bien, nous ne pouvons rien faire avec le compilateur, nous allons donc chercher un bouton . Il ne reste plus qu'à conclure qu'il n'est pas nécessaire de transférer et de stocker dans les tableaux de contrats de nombres à 8 bits de cette manière.

Solution simple pour les nombres à 8 bits


Et qu'est-ce qui est nécessaire? Et donc:

 bytes m_test; function test(bytes data) public { m_test = data; } 

Nous opérons dans tous les domaines de type octets. Avec cette approche, enregistrer 100 valeurs coûtera 129914 de gaz - seulement 1300 de gaz par octet, 6 fois mieux que d'utiliser uint8 []! Le coût pour cela sera un inconvénient - les éléments d'un tableau d'octets de type sont de type bytes1, qui ne se convertit pas automatiquement en aucun des types entiers habituels, vous devrez donc mettre la conversion de type explicite aux bons endroits. Pas très sympa, mais le gain est 6 fois plus cher que l'enregistrement, je pense que ça vaut le coup! Et, oui, nous perdrons un peu lorsque nous travaillerons avec ces données plus tard, lors de la lecture, par rapport au stockage de chaque numéro en 256 bits, mais ici l'échelle commence à avoir de l'importance: le gain de la sauvegarde de mille ou deux nombres de 8 bits sous forme compactée , selon la tâche, l'emportent sur les pertes lors de leur lecture ultérieure.

Avant d'arriver à cette approche, j'ai d'abord essayé d'écrire une fonction plus efficace pour enregistrer les données dans l'assembleur de macro local JULIA , mais j'ai rencontré quelques problèmes qui ont rendu ma solution un peu moins efficace et ont donné une consommation d'environ 1530 de gaz par octet. Cependant, il nous est toujours utile dans cet article, donc le travail n'a pas été fait en vain.

De plus, je note que plus vous enregistrez de données à la fois, moins le coût par octet sort, ce qui suggère qu'une partie du coût est fixe. Par exemple, si vous enregistrez 3000 valeurs, alors à l'approche des octets, nous obtenons 900 gaz par octet.

Solution plus générale


Eh bien, ça va bien, ça se termine bien, non? Mais nos problèmes ne se sont pas terminés ici, car parfois nous voulons écrire non seulement des nombres 8 bits dans la mémoire du contrat, mais aussi d'autres types de données qui ne correspondent pas directement au type d'octets. Autrement dit, il est clair que tout peut être encodé dans le tampon d'octets, mais le récupérer plus tard peut ne plus être pratique, et même coûteux en raison de gestes inutiles pour convertir la mémoire brute au type souhaité. La fonction qui enregistre le tableau d'octets transmis dans un tableau du type souhaité nous est donc toujours utile. C'est assez simple, mais il m'a fallu beaucoup de temps pour trouver toutes les informations nécessaires et comprendre EVM et JULIA pour les écrire, et tout cela n'a pas été collecté en un seul endroit. Par conséquent, je pense que ce sera utile si j'apporte ici ce que j'ai déterré.

Pour commencer, parlons de la façon dont Solidity stocke un tableau en mémoire. Les tableaux sont un concept qui n'existe que dans le cadre de Solidity, EVM n'en sait rien, mais stocke simplement un tableau virtuel de 2 ^ 256 blocs de 32 octets. Il est clair que les blocs vides ne sont pas stockés, mais en fait, nous avons une table de blocs non vides, dont la clé est un nombre de 256 bits. Et c'est précisément ce nombre que les commandes EVM SSTORE et SLOAD acceptent en entrée (ce n'est pas tout à fait évident dans la documentation).

Pour stocker des tableaux, Solidity fait une chose si délicate : premièrement, le tableau de blocs «principal» lui est alloué quelque part dans la mémoire constante, dans l'ordre habituel de placement des membres du contrat (ou structures, mais c'est une chanson distincte), comme s'il s'agissait nombre régulier de 256 bits. Cela garantit que le tableau reçoit un bloc complet, indépendamment des autres variables stockées. Ce bloc stocke la longueur du tableau. Mais comme il n'est pas connu à l'avance et peut changer (nous parlons ici de tableaux dynamiques), les auteurs de Solidity ont dû déterminer où placer les données du tableau afin qu'elles ne se croisent pas accidentellement avec les données d'un autre tableau. Strictement parlant, c'est une tâche insoluble: si vous créez deux tableaux de plus de 2 ^ 128 de long, alors ils sont garantis de se croiser là où vous ne les placez pas, mais en pratique, personne ne devrait le faire, donc cette astuce simple est utilisée: prenez le hachage SHA3 du numéro du bloc principal du tableau , et le nombre résultant est utilisé comme clé dans la table des blocs (qui, je le rappelle, 2 ^ 256). Par cette clé, le premier bloc de données du tableau est placé, et le reste - séquentiellement après, si nécessaire. La probabilité de collision de réseaux non géants est extrêmement faible.

Ainsi, en théorie, tout ce que nous devons faire est de trouver où se trouvent les données du tableau et de copier le tampon d'octets qui nous est passé bloc par bloc. Bien que nous travaillions avec des types inférieurs à la moitié de la taille des blocs, nous gagnerons au moins légèrement la solution «naïve» générée par le compilateur.

Il n'y a qu'un seul problème - si tout est fait comme ça, alors les octets de notre tableau se retourneront. Parce que EVM est big-endian. Le moyen le plus simple et le plus efficace, bien sûr, est de déployer des octets lors de l'envoi, mais pour la simplicité de l'API, j'ai décidé de le faire dans le code du contrat. Si vous souhaitez en économiser davantage, n'hésitez pas à supprimer cette partie de la fonction et à tout faire au moment de l'envoi.

Voici la fonction que j'ai obtenue pour transformer un tableau d'octets en un tableau d'entiers signés 64 bits (cependant, il peut être facilement adapté à d'autres types):

 function assign_int64_storage_from_bytes(int64[] storage to, bytes memory from) internal { //    .      int64,     8    (sizeof  Solidity  :( ) to.length = from.length / 8; //     ,  SHA3      uint256 addr; bytes32 base; assembly{ // keccak256   ,    ,          mstore(addr, to_slot) base := keccak256(addr, 32) } uint i = 0; for(uint offset = 0; offset < from.length; offset += 32) { //  32-     //     32  -  ,   ,     uint256 tmp; assembly{ tmp := mload(add(from, add(offset,32))) } //   .  ,     ,       . for(uint b = 0; b < 16; ++b) { uint shift = b*8; uint shift2 = (256 - (b+1)*8); uint low = (tmp & (0xFF << shift)) >> shift; uint high = (tmp & (0xFF << shift2)) >> shift2; tmp = tmp & ~( (0xFF << shift) | (0xFF << shift2)); tmp = tmp | (low << shift2) | (high << shift); } //      assembly{ sstore(add(base, i), tmp) } i += 1; } } 

Avec les nombres 64 bits, nous avons gagné moins qu'avec les 8 bits, par rapport au code généré par le compilateur, mais cette fonction consomme néanmoins 718466 gaz (7184 gaz par numéro, 898 gaz par octet) contre 1003225 pour les naïfs (1003 gaz par numéro, 1254 par octet), ce qui rend son utilisation assez significative. Et comme mentionné ci-dessus, vous pouvez économiser davantage en supprimant l'adresse octet de l'appelant.

Il convient de noter que la limite de gaz par unité dans Ethereum fixe une limite à la quantité de données que nous pouvons enregistrer en une seule transaction. Pour aggraver les choses, l'ajout de données à un tableau déjà rempli est une tâche beaucoup plus compliquée, sauf lorsque le dernier bloc utilisé du tableau a été rempli à la limite (auquel cas vous pouvez utiliser la même fonction, mais avec un retrait différent). À l'heure actuelle, la limite de gaz par bloc est d'environ 6 millions, ce qui signifie que nous pouvons plus ou moins économiser 6 Ko de données à la fois, mais en réalité encore moins, en raison d'autres dépenses.

Modifications à venir


Les changements à venir dans le réseau Ethereum en octobre, qui se produiront avec l'activation des EIP appartenant à Constantinople , devraient rendre la sauvegarde des données plus facile et moins coûteuse - EIP 1087 suggère que les frais de stockage des données ne seront pas facturés pour chaque commande SSTORE, mais pour le nombre de blocs modifiés, ce qui rendra l'approche naïve utilisée par le compilateur, presque aussi rentable que le code JULIA écrit manuellement (mais pas tout à fait - beaucoup de mouvements corporels supplémentaires resteront là, en particulier pour les valeurs 8 bits). La transition prévue vers WebAssembly en tant que langue de base d'EVM changera encore plus l'image, mais c'est encore une perspective très éloignée, et nous devons résoudre les problèmes maintenant.

Ce message ne prétend pas être la meilleure solution au problème, et je serais heureux si quelqu'un en propose un plus efficace - je viens de commencer à démarrer avec Ethereum, et je pourrais perdre de vue certaines fonctionnalités EVM qui pourraient m'aider. Mais dans mes recherches sur le net, je n'ai rien vu sur ce problème, et peut-être que les réflexions et le code ci-dessus seront utiles à quelqu'un comme point de départ pour l'optimisation.

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


All Articles