Recientemente, tuve que trabajar un poco con la
cadena de bloques Ethereum . La idea en la que estaba trabajando requería almacenar un número bastante grande de enteros directamente en la cadena de bloques para que el contrato inteligente tuviera un acceso conveniente a ellos. La mayoría de las lecciones sobre el desarrollo de contratos inteligentes nos dicen: "no almacenen muchos datos en la cadena de bloques, ¡es costoso!" Pero, ¿cuánto es "mucho" y cuánto sube demasiado el precio para un uso práctico? Tenía que averiguarlo, porque no podíamos hacer que nuestros datos se desconectaran, toda la idea se derrumbó.
Estoy empezando a trabajar con Solidity y EVM, por lo que este artículo no pretende ser la verdad definitiva, pero no pude encontrar otros materiales sobre este tema ni en ruso ni en inglés (aunque es muy malo que no lo haya encontrado antes) ), así que espero que pueda ser útil para alguien. Bueno, o como último recurso, puede ser útil para mí si camaradas experimentados me dicen cómo y dónde me equivoco exactamente.
Para empezar, decidí averiguar rápidamente si podemos hacerlo. Tomemos el tipo de contrato estándar y generalizado: el token
ERC20 . Al menos, dicho contrato almacena en la cadena de bloques la correspondencia de las direcciones de las personas que compraron tokens a sus saldos. En realidad, solo se almacenan los saldos, cada uno de los cuales ocupa 32 bytes (de hecho, no tiene sentido guardarlo aquí debido a las características de
Solidity y EVM). Un token más o menos exitoso puede tener fácilmente decenas de miles de propietarios, y así obtenemos que almacenar aproximadamente 320,000 bytes en la cadena de bloques es perfectamente aceptable. ¡Y no necesitamos más!
Enfoque ingenuo
Bueno, tratemos de guardar nuestros datos. Una parte importante de ellos son enteros sin signo de 8 bits, por lo que transferiremos su matriz al contrato e intentaremos escribirlos en la memoria de solo lectura:
uint8[] m_test; function test(uint8[] data) public { m_test = data; }
Goofy! Esta función come gas, como si no fuera en sí misma. ¡Un intento de ahorrar 100 valores nos costó 814033 gas, 8100 gas por byte!
Exhala y da un paso atrás hacia la teoría. ¿Cuál es el costo mínimo (en gas) de almacenar datos en la cadena de bloques Ethereum? Debe recordarse que los datos se almacenan en bloques de 32 bytes. EVM puede leer o escribir solo un bloque completo a la vez, por lo que idealmente, los datos a escribir deben empaquetarse de la manera más eficiente posible para que un solo comando de escritura se guarde más inmediatamente. Porque este mismo comando de grabación, SSTORE, solo
cuesta 20,000 gases (si escribimos en una celda de memoria en la que no hemos escrito antes). Entonces, nuestro mínimo teórico, ignorando todos los demás gastos, es de aproximadamente 625 gases por byte. ¡Lejos del 8100 que obtuvimos en el ejemplo anterior! Ahora es el momento de profundizar y descubrir quién está comiendo nuestro gas y cómo detenerlo.
Nuestro primer impulso debería ser mirar el código generado por el compilador Solidity de nuestra línea solitaria (m_test = data), porque no hay nada más que ver. Este es un impulso bueno y correcto que nos familiarizará con un hecho aterrador: ¡el compilador en este lugar generó algunos horrores antiguos que no entenderás a primera vista! Echando un vistazo rápido a la lista, vemos no solo SSTORE (que se espera), sino también SLOAD (carga desde la memoria de solo lectura) e incluso EXP (exponenciación). En general, parece una forma muy costosa de registrar datos. Y lo peor de todo, resulta bastante obvio que SSTORE también se llama, con demasiada frecuencia. ¿Qué está pasando aquí?
Algunas cosas Resulta que almacenar enteros de 8 bits es casi lo peor que puede hacer con EVM / Solidity (el artículo, un enlace al que cité al principio, habla sobre esto). Perdemos productividad (lo que significa que pagamos más gasolina) a cada paso. En primer lugar, cuando pasamos una matriz de valores de 8 bits a la entrada de nuestra función, cada uno de ellos se
expande a 256 bits. Es decir, ¡solo por el tamaño de los datos de la transacción ya perdemos 32 veces! Agradable Sin embargo, un lector atento notará que el costo del byte almacenado es, sin embargo, solo 13 veces mayor que el mínimo teórico, y no 32, lo que significa que si bien se guarda el contrato en la memoria permanente, no todo es tan malo. Aquí está la cosa: al guardar, todavía empaqueta los datos, y en la memoria permanente del contrato nuestros números de 8 bits se almacenarán de la manera más eficiente, 32 piezas en cada bloque de memoria. Esto plantea la pregunta, pero ¿cómo es la conversión de los números desempaquetados de "256 bits" que nos llegaron en la entrada de la función en un formato empaquetado? La respuesta es "la forma más estúpida que puedo imaginar".
Si escribimos todo lo que sucede de forma simplificada, nuestra línea de código solitaria se convierte en un ciclo misterioso:
for(uint i = 0; i < data.length; ++i) {
La apariencia de este código casi no se ve afectada al activar o desactivar la optimización (al menos en el compilador Solidity versión 0.4.24), y como puede ver, llama a SSTORE (como parte de set_storage_data_at_offset) 32 veces más de lo necesario (una vez para cada número de 8 bits, y no una vez para 32 de esos números). Lo que nos salva del fiasco completo es que volver a grabar en la misma celda no cuesta 20,000, sino 5,000 gas. Entonces, cada 32 bytes nos cuesta 20,000 + 5,000 * 31 = 125,000 gas, o aproximadamente 4,000 gas por byte. El resto del valor que vimos anteriormente proviene de la lectura de la memoria (también no es una operación barata) y otros cálculos ocultos en el código anterior en las funciones (y hay muchos de ellos).
Bueno, no podemos hacer nada con el compilador,
por lo que buscaremos un botón . Solo queda concluir que no es necesario transferir y almacenar en los arreglos contractuales de números de 8 bits de esta manera.
Solución simple para números de 8 bits.
¿Y que es necesario? Y entonces:
bytes m_test; function test(bytes data) public { m_test = data; }
Operamos en todos los campos de tipo bytes. Con este enfoque, guardar 100 valores costará 129914 de gas, ¡solo 1300 de gas por byte, 6 veces mejor que usar uint8 []! El costo de esto será un inconveniente: los elementos de una matriz de bytes de tipo son de tipo bytes1, que no se convierte automáticamente a ninguno de los tipos enteros habituales, por lo que deberá colocar la conversión de tipo explícita en los lugares correctos. No es muy agradable, pero la ganancia es 6 veces el costo de grabación, ¡creo que vale la pena! Y sí, perderemos un poco cuando trabajemos con estos datos, al leer, en comparación con el almacenamiento de cada número como 256 bits, pero aquí la escala comienza a importar: la ganancia de guardar mil o dos números de 8 bits en forma empaquetada puede , dependiendo de la tarea, superan las pérdidas al leerlas más tarde.
Antes de llegar a este enfoque, primero intenté escribir una función más eficiente para guardar datos en el macro ensamblador local
JULIA , pero encontré algunos problemas que hicieron que mi solución fuera un poco menos eficiente, y di un consumo de aproximadamente 1530 gases. por byte Sin embargo, todavía es útil para nosotros en este artículo, por lo que el trabajo no se hizo en vano.
Además, noto que cuantos más datos guarde a la vez, menor será el costo por byte, lo que sugiere que parte del costo es fijo. Por ejemplo, si guarda 3000 valores, cuando nos acercamos a los bytes obtenemos 900 de gas por byte.
Solución más general
Bueno, eso, todo está bien, eso termina bien, ¿verdad? Pero nuestros problemas no terminaron aquí, porque a veces queremos escribir no solo números de 8 bits en la memoria del contrato, sino también otros tipos de datos que no coinciden directamente con el tipo de bytes. Es decir, está claro que cualquier cosa puede codificarse en el búfer de bytes, pero obtenerlo desde allí más adelante puede que ya no sea conveniente e incluso costoso debido a gestos innecesarios para convertir la memoria en bruto al tipo deseado. Entonces, la función que guarda la matriz de bytes transmitidos en una matriz del tipo deseado sigue siendo útil para nosotros. Es bastante simple, pero me llevó mucho tiempo encontrar toda la información necesaria y comprender EVM y JULIA para escribirla, y todo esto no se recopiló en un solo lugar. Por lo tanto, creo que será útil si traigo aquí lo que desenterré.
Para comenzar, hablemos sobre cómo Solidity almacena una matriz en la memoria. Las matrices son un concepto que existe solo en el marco de Solidity, EVM no sabe nada sobre ellas, sino que simplemente almacena una matriz virtual de 2 ^ 256 bloques de 32 bytes. Está claro que los bloques vacíos no se almacenan, pero de hecho, tenemos una tabla de bloques no vacíos, cuya clave es un número de 256 bits. Y es precisamente este número el que aceptan los comandos EVM SSTORE y SLOAD como entrada (esto no es del todo obvio en la documentación).
Para almacenar matrices, Solidity hace una
cosa tan
difícil : en primer lugar, la matriz de bloques "principal" está asignada en algún lugar de la memoria constante, en el orden habitual de colocación de los miembros del contrato (o estructuras, pero esta es una canción separada), como si fuera Número normal de 256 bits. Esto asegura que la matriz reciba un bloque completo, independientemente de otras variables almacenadas. Este bloque almacena la longitud de la matriz. Pero dado que no se conoce de antemano y puede cambiar (estamos hablando de matrices dinámicas aquí), los autores de Solidity necesitaban averiguar dónde colocar los datos de la matriz para que no se crucen accidentalmente con los datos de otra matriz. Estrictamente hablando, esta es una tarea insoluble: si crea dos matrices de más de 2 ^ 128 de longitud, entonces se garantiza que se intersecarán donde no las coloque, pero en la práctica nadie debería hacer esto, por lo que se usa este simple truco: tome el hash SHA3 del número del bloque principal de la matriz , y el número resultante se usa como una clave en la tabla de bloques (que, recuerdo, 2 ^ 256). Con esta tecla, se coloca el primer bloque de datos de la matriz y el resto, secuencialmente, si es necesario. La probabilidad de colisión de matrices no gigantes es extremadamente pequeña.
Por lo tanto, en teoría, todo lo que tenemos que hacer es encontrar dónde están los datos de la matriz y copiar el búfer de bytes que nos pasa bloque por bloque. Mientras trabajamos con tipos más pequeños que la mitad del tamaño del bloque, al menos ganaremos ligeramente la solución "ingenua" generada por el compilador.
Solo queda un problema: si todo se hace así, los bytes de nuestra matriz resultarán al revés. Porque EVM es big-endian. La forma más fácil y efectiva, por supuesto, es implementar bytes al enviar, pero por la simplicidad de la API, decidí hacer esto en el código del contrato. Si desea guardar algo más, no dude en descartar esta parte de la función y hacer todo al momento del envío.
Aquí está la función que tengo para convertir una matriz de bytes en una matriz de enteros con signo de 64 bits (sin embargo, se puede adaptar fácilmente a otros tipos):
function assign_int64_storage_from_bytes(int64[] storage to, bytes memory from) internal {
Con los números de 64 bits, ganamos no tanto como con los de 8 bits, en comparación con el código que genera el compilador, pero esta función consume 718466 de gas (7184 de gas por número, 898 de gas por byte) frente a 1003225 para los ingenuos. soluciones (1003 gas por número, 1254 por byte), lo que hace que su uso sea bastante significativo. Y como se mencionó anteriormente, puede ahorrar más eliminando la dirección de byte a la persona que llama.
Vale la pena señalar que el límite de gas por unidad en Ethereum establece un límite para la cantidad de datos que podemos registrar en una transacción. Para empeorar las cosas, agregar datos a una matriz ya llena es una tarea mucho más complicada, excepto cuando el último bloque usado de la matriz se llenó hasta el límite (en cuyo caso puede usar la misma función, pero con una sangría diferente). En este momento, el límite de gas por bloque es de aproximadamente 6 millones, lo que significa que podemos ahorrar más o menos 6Kb de datos a la vez, pero en realidad aún menos, debido a otros gastos.
Próximos cambios
Los próximos cambios en la red Ethereum en octubre, que ocurrirán con la activación de los EIP que pertenecen a
Constantinopla , deberían facilitar y abaratar el almacenamiento de datos:
EIP 1087 sugiere que la tarifa de almacenamiento de datos no se cobrará por cada comando SSTORE, sino por la cantidad de bloques modificados, lo que hará que el enfoque ingenuo utilizado por el compilador sea casi tan rentable como el código escrito manualmente en JULIA (pero no del todo, habrá muchos movimientos corporales adicionales allí, especialmente para valores de 8 bits). La transición planificada a WebAssembly como el lenguaje base de EVM cambiará la imagen aún más, pero esto aún es una perspectiva muy lejana, y tenemos que resolver los problemas ahora.
Esta publicación no pretende ser la mejor solución para el problema, y me alegrará si alguien ofrece una más efectiva: recién comencé a comenzar con Ethereum y podría perder de vista algunas características de EVM que podrían ayudarme. Pero en mis búsquedas en la red, no vi nada sobre este tema, y tal vez los pensamientos y el código anteriores serán útiles para alguien como punto de partida para la optimización.