Penulisan array yang lebih efisien ke memori persisten kontrak pintar dalam Solidity

Baru-baru ini, saya harus bekerja sedikit dengan blockchain Ethereum . Gagasan yang saya kerjakan diperlukan untuk menyimpan sejumlah besar bilangan bulat langsung di blockchain sehingga kontrak pintar memiliki akses mudah ke mereka. Sebagian besar pelajaran tentang mengembangkan kontrak cerdas memberi tahu kami, "jangan menyimpan banyak data di blockchain, itu mahal!" Tetapi berapa "banyak", dan berapa harga terlalu tinggi untuk penggunaan praktis? Saya harus mengetahuinya, karena kami tidak bisa membuat data kami tidak terkendali, seluruh gagasan itu runtuh.

Saya baru mulai bekerja dengan Solidity dan EVM, jadi artikel ini tidak mengklaim sebagai kebenaran pamungkas, tetapi saya tidak dapat menemukan materi lain tentang topik ini baik dalam bahasa Rusia maupun bahasa Inggris (walaupun sangat buruk bahwa saya tidak menemukan artikel ini sebelumnya) ), jadi saya harap ini bisa bermanfaat bagi seseorang. Ya, atau sebagai upaya terakhir, mungkin berguna bagi saya jika kawan yang berpengalaman memberi tahu saya bagaimana dan di mana tepatnya saya salah.

Pertama-tama, saya memutuskan untuk mencari tahu apakah kita bisa melakukannya? Mari kita ambil tipe kontrak standar dan tersebar luas - token ERC20 . Setidaknya, kontrak semacam itu menyimpan di blockchain korespondensi dari alamat orang-orang yang membeli token dengan saldo mereka. Pada kenyataannya, hanya saldo yang disimpan, yang masing-masing membutuhkan 32 byte (pada kenyataannya, tidak masuk akal untuk menyimpan di sini karena fitur Solidity dan EVM). Token yang kurang lebih sukses dapat dengan mudah memiliki puluhan ribu pemilik, dan dengan demikian kita mendapatkan bahwa menyimpan sekitar 320.000 byte di blockchain dapat diterima dengan baik. Dan kami tidak membutuhkan lebih banyak!

Pendekatan naif


Baiklah, mari kita coba untuk menyimpan data kita. Sebagian besar dari mereka adalah bilangan bulat 8-bit yang tidak ditandatangani, jadi kami akan mentransfer array mereka ke kontrak, dan mencoba menulisnya ke memori hanya baca:

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

Gufi! Fungsi ini memakan gas, seolah-olah tidak dengan sendirinya. Upaya untuk menghemat 100 nilai biaya kami gas 814033, gas 8100 per byte!

Buang napas dan mundur selangkah ke teori. Berapa biaya minimum (dalam gas) untuk menyimpan data pada blockchain Ethereum? Harus diingat bahwa data disimpan dalam blok 32 byte. EVM dapat membaca atau menulis hanya seluruh blok sekaligus, jadi idealnya, data yang akan ditulis harus dikemas seefisien mungkin sehingga satu perintah tulis menghemat lebih cepat. Karena perintah perekaman yang sama - SSTORE - saja harganya 20.000 gas (jika kita menulis ke sel memori yang belum pernah kita tulis sebelumnya). Jadi minimum teoritis kami, mengabaikan semua pengeluaran lain, adalah sekitar 625 gas per byte. Jauh dari 8100 yang kita dapatkan dalam contoh di atas! Sekarang adalah waktunya untuk menggali lebih dalam dan mencari tahu siapa yang memakan gas kita, dan bagaimana cara menghentikannya.

Impuls pertama kami adalah melihat kode yang dihasilkan oleh kompiler Solidity dari baris tunggal kami (m_test = data), karena tidak ada lagi yang bisa dilihat. Ini adalah dorongan yang baik dan benar yang akan membiasakan kita dengan fakta yang menakutkan - kompiler di tempat ini menghasilkan beberapa kengerian kuno yang tidak akan Anda pahami pada pandangan pertama! Melihat daftar dengan cepat, kita melihat di sana tidak hanya SSTORE (yang diharapkan), tetapi juga SLOAD (memuat dari memori read-only) dan bahkan EXP (exponentiation)! Secara keseluruhan, ini terlihat seperti cara yang sangat mahal untuk merekam data. Dan yang terburuk, menjadi sangat jelas bahwa SSTORE dipanggil terlalu sering. Apa yang sedang terjadi di sini?

Beberapa hal. Ternyata menyimpan bilangan bulat 8-bit hampir merupakan hal terburuk yang dapat Anda lakukan dengan EVM / Solidity (artikel, tautan yang saya kutip di awal, berbicara tentang ini). Kami kehilangan produktivitas (yang berarti kami membayar lebih banyak gas) di setiap belokan. Pertama, ketika kita melewatkan array nilai 8-bit ke input fungsi kita, masing-masing dari mereka mengembang menjadi 256 bit. Artinya, hanya dengan ukuran data transaksi kita sudah kehilangan 32 kali! Bagus Namun, pembaca yang penuh perhatian akan melihat bahwa biaya untuk byte yang disimpan masih hanya 13 kali lebih tinggi dari minimum teoritis, dan bukan 32, yang berarti bahwa ketika kontrak disimpan secara permanen ke memori, semuanya tidak terlalu buruk. Begini: ketika menyimpan, ia mengemas data, dan dalam memori permanen kontrak, nomor 8-bit kami akan disimpan dengan cara yang paling efisien, 32 buah di setiap blok memori. Ini menimbulkan pertanyaan, tetapi bagaimana konversi nomor yang dibongkar β€œ256-bit” yang datang kepada kami di input fungsi menjadi bentuk yang dikemas? Jawabannya adalah "cara paling bodoh yang bisa saya bayangkan."

Jika kita menuliskan semua yang terjadi dalam bentuk yang disederhanakan, maka baris kode kita yang kesepian berubah menjadi siklus yang menakutkan:

 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); } 

Cara kode ini terlihat hampir tidak terpengaruh dengan mengaktifkan atau menonaktifkan optimasi (setidaknya dalam kompiler Solidity versi 0.4.24), dan seperti yang Anda lihat, kode ini memanggil SSTORE (sebagai bagian dari set_storage_data_at_offset) 32 kali lebih sering daripada yang diperlukan (sekali untuk setiap nomor 8-bit, dan tidak sekali untuk 32 nomor tersebut). Apa yang menyelamatkan kita dari kegagalan total adalah bahwa merekam ulang dalam sel yang sama harganya bukan 20.000, tetapi 5.000 gas. Jadi setiap 32 byte biayanya 20.000 + 5.000 * 31 = 125.000 gas, atau sekitar 4.000 gas per byte. Sisa nilai yang kami lihat di atas berasal dari membaca memori (juga bukan operasi yang murah) dan perhitungan lain yang tersembunyi dalam kode di atas dalam fungsi (dan ada banyak dari mereka).

Yah, kita tidak bisa melakukan apa-apa dengan kompiler, jadi kita akan mencari tombol . Tinggal menyimpulkan bahwa tidak perlu mentransfer dan menyimpan dalam array kontrak nomor 8-bit dengan cara ini.

Solusi sederhana untuk nomor 8-bit


Dan apa yang perlu? Jadi:

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

Kami beroperasi di semua bidang tipe byte. Dengan pendekatan ini, menghemat 100 nilai akan menelan biaya 1.29914 gas - hanya 1.300 gas per byte, 6 kali lebih baik daripada menggunakan uint8 []! Biaya untuk hal ini adalah ketidaknyamanan - elemen-elemen dari array tipe byte adalah tipe byte1, yang tidak secara otomatis dikonversi ke tipe integer biasa, jadi Anda harus meletakkan konversi tipe eksplisit di tempat yang tepat. Tidak terlalu bagus, tetapi keuntungannya 6 kali lipat dari biaya rekaman, saya pikir itu sepadan! Dan, ya, kita akan kehilangan sedikit ketika bekerja dengan data ini nanti, saat membaca, dibandingkan dengan menyimpan setiap angka sebagai 256-bit, tetapi di sini skalanya mulai menjadi masalah: keuntungan dari menyimpan seribu atau dua angka 8-bit dalam bentuk yang dikemas dapat , tergantung pada tugasnya, lebih besar daripada kerugiannya saat membacanya nanti.

Sebelum datang ke pendekatan ini, saya pertama kali mencoba untuk menulis fungsi yang lebih efisien untuk menyimpan data di JULIA assembler makro lokal, tetapi saya mengalami beberapa masalah yang membuat solusi saya sedikit kurang efisien, dan memberikan konsumsi sekitar 1530 gas per byte Namun, hal itu tetap berguna bagi kita dalam artikel ini, sehingga pekerjaan yang dilakukan tidak sia-sia.

Selain itu, saya perhatikan bahwa semakin banyak data yang Anda simpan pada suatu waktu, semakin sedikit biaya per byte yang keluar, yang menunjukkan bahwa sebagian dari biaya tetap. Misalnya, jika Anda menyimpan 3000 nilai, maka ketika mendekati byte kami mendapatkan 900 gas per byte.

Solusi yang lebih umum


Nah, itu, semuanya baik-baik saja, itu berakhir dengan baik, bukan? Tetapi masalah kami tidak berakhir di sini, karena kadang-kadang kami ingin menulis tidak hanya angka 8-bit ke memori kontrak, tetapi juga tipe data lain yang tidak cocok langsung dengan tipe byte. Artinya, jelas bahwa apa pun dapat dikodekan ke buffer byte, tetapi mendapatkannya dari sana nanti mungkin tidak lagi nyaman, dan bahkan mahal karena gerakan yang tidak perlu untuk mengubah memori mentah ke tipe yang diinginkan. Jadi fungsi yang menyimpan array byte yang dikirimkan ke array dari tipe yang diinginkan masih berguna bagi kita. Ini cukup sederhana, tetapi butuh waktu lama untuk menemukan semua informasi yang diperlukan dan memahami EVM dan JULIA untuk menulisnya, dan semua ini tidak dikumpulkan di satu tempat. Karena itu, saya pikir ini akan berguna jika saya membawa apa yang saya gali ke sini.

Untuk memulai, mari kita bicara tentang bagaimana Solidity menyimpan array dalam memori. Array adalah konsep yang hanya ada dalam kerangka Solidity, EVM tidak tahu apa-apa tentang mereka, tetapi hanya menyimpan array virtual 2 ^ 256 blok 32-byte. Jelas bahwa blok kosong tidak disimpan, tetapi pada kenyataannya, kami memiliki tabel blok kosong, yang kuncinya adalah angka 256-bit. Dan justru angka inilah yang diterima oleh perintah EVM SSTORE dan SLOAD sebagai input (ini tidak sepenuhnya jelas dari dokumentasi).

Untuk menyimpan array, Solidity melakukan hal yang rumit : pertama, array blok "utama" dialokasikan untuknya di suatu tempat dalam memori konstan, dalam urutan penempatan anggota kontrak (atau struktur, seperti biasa, tetapi ini adalah lagu yang terpisah), seolah-olah itu adalah nomor 256-bit reguler. Ini memastikan bahwa array menerima satu blok penuh, terlepas dari variabel tersimpan lainnya. Blok ini menyimpan panjang array. Tetapi karena tidak diketahui sebelumnya, dan dapat berubah (kita berbicara tentang array dinamis di sini), penulis Solidity perlu mencari tahu di mana harus meletakkan data array sehingga mereka tidak akan secara tidak sengaja berpotongan dengan data array lain. Sebenarnya, ini adalah tugas yang tidak dapat diselesaikan: jika Anda membuat dua array lebih dari 2 ^ 128 panjang, maka mereka dijamin untuk berpotongan di mana Anda tidak menempatkan mereka, tetapi dalam praktiknya tidak ada yang harus melakukan ini, jadi trik sederhana ini digunakan: ambil hash SHA3 dari jumlah blok utama array , dan angka yang dihasilkan digunakan sebagai kunci dalam tabel blok (yang, saya ingat, 2 ^ 256). Dengan kunci ini, blok pertama dari data array ditempatkan, dan sisanya - secara berurutan setelahnya, jika perlu. Probabilitas tabrakan array non-raksasa sangat kecil.

Jadi, secara teori, yang perlu kita lakukan adalah menemukan di mana data array berada dan menyalin buffer byte yang dilewatkan kepada kita blok demi blok. Sementara kita bekerja dengan tipe yang lebih kecil dari setengah ukuran blok, kita setidaknya akan sedikit memenangkan solusi "naif" yang dihasilkan oleh kompiler.

Hanya ada satu masalah yang tersisa - jika semuanya dilakukan seperti itu, maka byte dalam array kami akan berubah mundur. Karena EVM adalah big-endian. Cara termudah dan paling efisien, tentu saja, adalah menggunakan byte saat mengirim, tetapi untuk kesederhanaan, saya memutuskan untuk melakukan ini dalam kode kontrak. Jika Anda ingin menyimpan lebih banyak, jangan buang bagian fungsi ini, dan lakukan semuanya pada saat pengiriman.

Berikut adalah fungsi yang saya harus mengubah array byte menjadi array integer bertanda 64-bit (namun, dapat dengan mudah disesuaikan dengan jenis lain):

 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; } } 

Dengan angka 64-bit, kami menang tidak sebanyak dengan yang 8-bit, dibandingkan dengan kode yang dihasilkan oleh kompiler, tetapi meskipun demikian fungsi ini mengkonsumsi 718466 gas (7184 gas per angka, 898 gas per byte) dibandingkan 1003225 untuk naif solusi (1003 gas per angka, 1254 per byte), yang menjadikan penggunaannya cukup berarti. Dan seperti yang disebutkan di atas, Anda dapat menyimpan lebih banyak dengan menghapus alamat byte ke pemanggil.

Perlu dicatat bahwa batas gas per unit di Ethereum menetapkan batas berapa banyak data yang dapat kita rekam dalam satu transaksi. Lebih buruk lagi, menambahkan data ke array yang sudah diisi adalah tugas yang jauh lebih sulit, kecuali ketika blok array yang terakhir digunakan diisi hingga batas (dalam hal ini Anda dapat menggunakan fungsi yang sama, tetapi dengan indentasi berbeda). Saat ini, batas gas per blok adalah sekitar 6 juta, yang berarti bahwa kita dapat lebih atau kurang menghemat 6Kb data pada suatu waktu, tetapi pada kenyataannya bahkan lebih sedikit, karena pengeluaran lain.

Perubahan yang akan terjadi


Perubahan mendatang dalam jaringan Ethereum pada bulan Oktober, yang akan terjadi dengan aktivasi EIP milik Konstantinopel , akan membuatnya lebih mudah dan lebih murah untuk menyimpan data - EIP 1087 menyarankan bahwa biaya penyimpanan data akan dibebankan bukan untuk setiap perintah SSTORE, tetapi untuk jumlah blok yang diubah, yang akan membuat pendekatan naif yang digunakan oleh kompiler, hampir sama menguntungkannya dengan kode yang ditulis secara manual di JULIA (tetapi tidak cukup - akan ada banyak gerakan tubuh tambahan di sana, terutama untuk nilai 8-bit). Transisi yang direncanakan ke WebAssembly sebagai bahasa dasar EVM akan mengubah gambar lebih banyak, tetapi ini masih merupakan prospek yang sangat jauh, dan kita perlu menyelesaikan masalah sekarang.

Posting ini tidak mengklaim sebagai solusi terbaik untuk masalah ini, dan saya akan senang jika seseorang menawarkan yang lebih efektif - Saya baru mulai memulai dengan Ethereum, dan dapat kehilangan beberapa fitur EVM yang dapat membantu saya. Tetapi dalam pencarian saya di internet, saya tidak melihat apa-apa tentang masalah ini, dan mungkin pemikiran dan kode di atas akan berguna bagi seseorang sebagai titik awal untuk optimasi.

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


All Articles