Vor kurzem musste ich ein bisschen mit der
Ethereum-Blockchain arbeiten . Die Idee, an der ich arbeitete, erforderte das Speichern einer ziemlich großen Anzahl von Ganzzahlen direkt in der Blockchain, damit der intelligente Vertrag bequem darauf zugreifen konnte. In den meisten Lektionen zur Entwicklung intelligenter Verträge heißt es: "Speichern Sie nicht viele Daten in der Blockchain, es ist teuer!" Aber wie viel ist „viel“ und wie viel wird der Preis für den praktischen Gebrauch zu hoch? Ich musste herausfinden, dass die gesamte Idee zusammenbrach, da wir unsere Daten nicht außerhalb der Kette machen konnten.
Ich fange gerade erst an, mit Solidity und EVM zu arbeiten, daher behauptet dieser Artikel nicht, die ultimative Wahrheit zu sein, aber ich konnte weder auf Russisch noch auf Englisch andere Materialien zu diesem Thema finden (obwohl es sehr schlimm ist, dass ich
diesen Artikel vorher nicht gefunden habe ), also hoffe ich, dass es jemandem nützlich sein kann. Nun, oder als letztes Mittel kann es für mich nützlich sein, wenn erfahrene Kameraden mir sagen, wie und wo genau ich mich darin irre.
Zunächst habe ich mich entschlossen, schnell herauszufinden, ob wir das schaffen können. Nehmen wir den weit verbreiteten Standardvertragstyp - den
ERC20- Token. Zumindest speichert ein solcher Vertrag in der Blockchain die Entsprechung der Adressen der Personen, die die Token gekauft haben, zu ihrem Guthaben. In der Realität werden nur Salden gespeichert, von denen jede 32 Bytes benötigt (tatsächlich ist es aufgrund der Merkmale von
Solidity und EVM nicht sinnvoll, hier zu speichern). Ein mehr oder weniger erfolgreiches Token kann leicht Zehntausende von Besitzern haben, und daher ist das Speichern von etwa 320.000 Bytes in der Blockchain durchaus akzeptabel. Und mehr brauchen wir nicht!
Naiver Ansatz
Versuchen wir, unsere Daten zu speichern. Ein wesentlicher Teil von ihnen sind 8-Bit-Ganzzahlen ohne Vorzeichen, daher werden wir ihr Array in den Vertrag übertragen und versuchen, sie in den Nur-Lese-Speicher zu schreiben:
uint8[] m_test; function test(uint8[] data) public { m_test = data; }
Goofy! Diese Funktion frisst Gas, als ob es nicht an sich wäre. Der Versuch, 100 Werte zu sparen, kostete uns 814033 Gas, 8100 Gas pro Byte!
Atme aus und mache einen Schritt zurück zur Theorie. Was sind die Mindestkosten (in Gas) für die Speicherung von Daten in der Ethereum-Blockchain? Es ist zu beachten, dass Daten in Blöcken von 32 Bytes gespeichert werden. EVM kann nur einen ganzen Block gleichzeitig lesen oder schreiben. Idealerweise sollten die zu schreibenden Daten so effizient wie möglich gepackt werden, damit ein einzelner Schreibbefehl sofort spart. Denn der gleiche Aufzeichnungsbefehl - SSTORE -
kostet allein
20.000 Gas (wenn wir in eine Speicherzelle schreiben, in die wir vorher noch nicht geschrieben haben). Unser theoretisches Minimum, das alle anderen Ausgaben ignoriert, beträgt ungefähr 625 Gas pro Byte. Weit entfernt von den 8100, die wir im obigen Beispiel bekommen haben! Jetzt ist es an der Zeit, tiefer zu graben und herauszufinden, wer unser Gas isst und wie man es aufhält.
Unser erster Impuls sollte sein, den vom Solidity-Compiler aus unserer einzelnen Zeile generierten Code (m_test = data) zu betrachten, da nichts mehr zu sehen ist. Dies ist ein guter, korrekter Impuls, der uns mit einer schrecklichen Tatsache vertraut machen wird - der Compiler an diesem Ort hat einige uralte Schrecken erzeugt, die Sie auf den ersten Blick nicht verstehen werden! Wenn wir uns die Liste kurz ansehen, sehen wir dort nicht nur SSTORE (was erwartet wird), sondern auch SLOAD (Laden aus dem Nur-Lese-Speicher) und sogar EXP (Exponentiation)! Alles in allem scheint dies eine sehr teure Möglichkeit zu sein, Daten aufzuzeichnen. Und das Schlimmste ist, dass SSTORE zu oft aufgerufen wird. Was ist hier los?
Ein paar Dinge. Es stellt sich heraus, dass das Speichern von 8-Bit-Ganzzahlen fast das Schlimmste ist, was Sie mit EVM / Solidity tun können (der Artikel, auf den ich am Anfang verwiesen habe, spricht darüber). Wir verlieren auf Schritt und Tritt an Produktivität (was bedeutet, dass wir mehr Benzin bezahlen). Erstens, wenn wir ein Array von 8-Bit-Werten an den Eingang unserer Funktion übergeben, wird jeder von ihnen auf 256 Bit erweitert. Das heißt, nur durch die Größe der Transaktionsdaten verlieren wir bereits 32 Mal! Schön Ein aufmerksamer Leser wird jedoch feststellen, dass die Kosten für das gespeicherte Byte dennoch nur 13-mal höher sind als das theoretische Minimum und nicht 32-mal, was bedeutet, dass beim Speichern des Vertrags im permanenten Speicher nicht alles so schlecht ist. Hier ist die Sache: Beim Speichern werden die Daten immer noch gepackt, und im permanenten Speicher des Vertrags werden unsere 8-Bit-Nummern auf die effizienteste Weise gespeichert, 32 Teile in jedem Speicherblock. Dies wirft die Frage auf, aber wie erfolgt die Konvertierung der entpackten 256-Bit-Zahlen, die bei der Eingabe der Funktion zu uns gekommen sind, in eine gepackte Form? Die Antwort lautet "die dümmste Art, die ich mir vorstellen kann."
Wenn wir alles, was passiert, in vereinfachter Form aufschreiben, wird unsere einsame Codezeile zu einem unheimlichen Zyklus:
for(uint i = 0; i < data.length; ++i) {
Das Aussehen dieses Codes wird durch das Ein- und Ausschalten der Optimierung (zumindest in der Solidity-Compiler-Version 0.4.24) kaum beeinflusst. Wie Sie sehen, wird SSTORE (als Teil von set_storage_data_at_offset) 32-mal häufiger als erforderlich aufgerufen (einmal für jede 8-Bit-Nummer und nicht einmal für 32 solcher Nummern). Was uns vor dem kompletten Fiasko bewahrt, ist, dass die Neuaufnahme in derselben Zelle nicht 20.000, sondern 5.000 Gas kostet. Alle 32 Bytes kosten uns also 20.000 + 5.000 * 31 = 125.000 Gas oder ungefähr 4.000 Gas pro Byte. Der Rest des Wertes, den wir oben gesehen haben, stammt aus dem Lesen des Speichers (ebenfalls keine billige Operation) und anderen Berechnungen, die im obigen Code in Funktionen versteckt sind (und es gibt viele davon).
Nun, wir können mit dem Compiler nichts anfangen,
also werden wir nach einer Schaltfläche suchen . Es bleibt nur zu schließen, dass es nicht notwendig ist, auf diese Weise Arrays von 8-Bit-Zahlen zu übertragen und in den Vertragsfeldern zu speichern.
Einfache Lösung für 8-Bit-Zahlen
Und was ist notwendig? Und so:
bytes m_test; function test(bytes data) public { m_test = data; }
Wir arbeiten in allen Bereichen von Typbytes. Mit diesem Ansatz kostet das Speichern von 100 Werten 129914 Gas - nur 1300 Gas pro Byte, sechsmal besser als mit uint8 []! Die Kosten hierfür sind einige Unannehmlichkeiten - die Elemente eines Arrays von Typbytes sind vom Typ Bytes1, die nicht automatisch in einen der üblichen Ganzzahltypen konvertiert werden. Daher müssen Sie die explizite Typkonvertierung an den richtigen Stellen platzieren. Nicht sehr schön, aber der Gewinn ist sechsmal so hoch wie die Kosten für die Aufnahme. Ich denke, es lohnt sich! Und ja, wir werden ein wenig verlieren, wenn wir mit diesen Daten arbeiten, dann beim Lesen, verglichen mit dem Speichern jeder Zahl als 256-Bit, aber hier beginnt die Skalierung eine Rolle zu spielen: Der Gewinn durch das Speichern von tausend oder zwei 8-Bit-Zahlen in gepackter Form kann überwiegen je nach Aufgabe die Verluste, wenn Sie sie später lesen.
Bevor ich zu diesem Ansatz kam, habe ich zunächst versucht, eine effizientere Funktion zum Speichern von Daten im lokalen Makro-Assembler
JULIA zu schreiben, aber ich bin auf einige Probleme
gestoßen , die meine Lösung etwas weniger effizient machten und einen Verbrauch von etwa 1530 Gas ergaben pro Byte. In diesem Artikel ist es jedoch immer noch nützlich, sodass die Arbeit nicht umsonst ausgeführt wurde.
Außerdem stelle ich fest, dass je mehr Daten Sie gleichzeitig speichern, desto weniger Kosten pro Byte anfallen, was darauf hindeutet, dass ein Teil der Kosten feststeht. Wenn Sie beispielsweise 3000 Werte speichern, erhalten wir bei Annäherung an Bytes 900 Gas pro Byte.
Allgemeinere Lösung
Nun, das ist alles gut, das endet gut, oder? Unsere Probleme endeten hier jedoch nicht, da wir manchmal nicht nur 8-Bit-Zahlen in den Vertragsspeicher schreiben möchten, sondern auch andere Datentypen, die nicht direkt mit dem Bytetyp übereinstimmen. Das heißt, es ist klar, dass alles in den Bytepuffer codiert werden kann, aber es später möglicherweise nicht mehr bequem und sogar teuer zu bekommen, da unnötige Gesten zum Konvertieren des Rohspeichers in den gewünschten Typ erforderlich sind. Daher ist die Funktion, mit der das übertragene Byte-Array in einem Array des gewünschten Typs gespeichert wird, für uns weiterhin nützlich. Es ist ganz einfach, aber ich habe lange gebraucht, um alle notwendigen Informationen zu finden und EVM und JULIA zu verstehen, um sie zu schreiben, und all dies wurde nicht an einem Ort gesammelt. Daher denke ich, dass es nützlich sein wird, wenn ich hierher bringe, was ich ausgegraben habe.
Lassen Sie uns zunächst darüber sprechen, wie Solidity ein Array im Speicher speichert. Arrays sind ein Konzept, das nur im Rahmen von Solidity existiert. EVM weiß nichts über sie, sondern speichert einfach ein virtuelles Array von 2 ^ 256 32-Byte-Blöcken. Es ist klar, dass leere Blöcke nicht gespeichert werden, aber tatsächlich haben wir eine Tabelle mit nicht leeren Blöcken, deren Schlüssel eine 256-Bit-Zahl ist. Und genau diese Zahl akzeptieren die Befehle EVM SSTORE und SLOAD (dies ist aus der Dokumentation nicht ganz ersichtlich).
Um Arrays zu speichern, macht Solidity eine so
knifflige Sache : Erstens wird das "Haupt" -Blockarray irgendwo im konstanten Speicher in der üblichen Reihenfolge der Platzierung von Vertragsmitgliedern (oder Strukturen, aber dies ist ein separates Lied) zugewiesen, als ob es wäre reguläre 256-Bit-Nummer. Dies stellt sicher, dass das Array unabhängig von anderen gespeicherten Variablen einen vollständigen Block empfängt. Dieser Block speichert die Länge des Arrays. Da dies jedoch nicht im Voraus bekannt ist und sich möglicherweise ändert (wir sprechen hier von dynamischen Arrays), mussten die Autoren von Solidity herausfinden, wo die Daten des Arrays abgelegt werden müssen, damit sie sich nicht versehentlich mit den Daten eines anderen Arrays überschneiden. Genau genommen ist dies eine unlösbare Aufgabe: Wenn Sie zwei Arrays erstellen, die länger als 2 ^ 128 sind, schneiden sie sich garantiert dort, wo Sie sie nicht platzieren. In der Praxis sollte dies jedoch niemand tun. Daher wird dieser einfache Trick verwendet: Nehmen Sie den SHA3-Hash aus der Nummer des Hauptblocks des Arrays und die resultierende Zahl wird als Schlüssel in der Tabelle der Blöcke verwendet (die, wie ich mich erinnere, 2 ^ 256). Mit diesem Schlüssel wird der erste Block von Array-Daten platziert und der Rest - bei Bedarf nacheinander. Die Wahrscheinlichkeit einer Kollision von Nicht-Riesen-Arrays ist äußerst gering.
Theoretisch müssen wir also nur herausfinden, wo sich die Array-Daten befinden, und den an uns übergebenen Byte-Puffer Block für Block kopieren. Während wir mit Typen arbeiten, die kleiner als die Hälfte der Blockgröße sind, werden wir die vom Compiler generierte „naive“ Lösung zumindest geringfügig gewinnen.
Es gibt nur noch ein Problem: Wenn alles so gemacht wird, werden die Bytes in unserem Array rückwärts angezeigt. Weil EVM Big-Endian ist. Der einfachste und effektivste Weg ist natürlich das Bereitstellen von Bytes beim Senden. Aus Gründen der Einfachheit der API habe ich mich jedoch dazu entschlossen, dies im Vertragscode zu tun. Wenn Sie mehr speichern möchten, können Sie diesen Teil der Funktion verwerfen und zum Zeitpunkt des Sendens alles tun.
Hier ist die Funktion, mit der ich ein Array von Bytes in ein Array von 64-Bit-Ganzzahlen mit Vorzeichen umwandeln kann (es kann jedoch leicht an andere Typen angepasst werden):
function assign_int64_storage_from_bytes(int64[] storage to, bytes memory from) internal {
Mit 64-Bit-Zahlen haben wir im Vergleich zu dem vom Compiler generierten Code nicht so viel gewonnen wie mit 8-Bit-Zahlen. Dennoch verbraucht diese Funktion 718466 Gas (7184 Gas pro Zahl, 898 Gas pro Byte) gegenüber 1003225 für Naive Lösungen (1003 Gas pro Zahl, 1254 pro Byte), was seine Verwendung sehr aussagekräftig macht. Und wie oben erwähnt, können Sie mehr sparen, indem Sie die Byteadresse für den Anrufer entfernen.
Es ist erwähnenswert, dass das Gaslimit pro Einheit in Ethereum ein Limit für die Anzahl der Daten festlegt, die wir in einer Transaktion aufzeichnen können. Um die Sache noch schlimmer zu machen, ist das Anhängen von Daten an ein bereits gefülltes Array eine viel kompliziertere Aufgabe, es sei denn, der zuletzt verwendete Block des Arrays wurde bis zum Limit gefüllt (in diesem Fall können Sie dieselbe Funktion verwenden, jedoch mit einem anderen Einzug). Derzeit liegt die Gasgrenze pro Block bei etwa 6 Millionen, was bedeutet, dass wir mehr oder weniger 6 KB Daten gleichzeitig speichern können, in Wirklichkeit jedoch aufgrund anderer Kosten sogar noch weniger.
Bevorstehende Änderungen
Die bevorstehenden Änderungen im Ethereum-Netzwerk im Oktober, die mit der Aktivierung von EIPs aus
Konstantinopel einhergehen, dürften das Speichern von Daten einfacher und billiger machen.
EIP 1087 schlägt vor, dass die Gebühr für die Datenspeicherung nicht für jeden SSTORE-Befehl, sondern für berechnet wird Die Anzahl der geänderten Blöcke, die den vom Compiler verwendeten naiven Ansatz fast so rentabel machen wie manuell geschriebener Code in JULIA (aber nicht ganz - es wird dort viele zusätzliche Körperbewegungen geben, insbesondere für 8-Bit-Werte). Der geplante Übergang zu WebAssembly als Basissprache von EVM wird das Bild noch mehr verändern, aber dies ist noch eine sehr entfernte Perspektive, und wir müssen die Probleme jetzt lösen.
Dieser Beitrag erhebt nicht den Anspruch, die beste Lösung für das Problem zu sein, und ich bin froh, wenn jemand eine effektivere Lösung anbietet. Ich habe gerade erst mit Ethereum begonnen und könnte einige EVM-Funktionen aus den Augen verlieren, die mir helfen könnten. Bei meinen Suchanfragen im Internet habe ich jedoch nichts zu diesem Thema gesehen, und möglicherweise sind die oben genannten Gedanken und der obige Code für jemanden als Ausgangspunkt für die Optimierung hilfreich.