في الآونة الأخيرة ، اضطررت للعمل قليلاً مع
blockchain Ethereum . تتطلب الفكرة التي كنت أعمل عليها تخزين عدد كبير إلى حد ما من الأعداد الصحيحة مباشرة على blockchain بحيث يكون العقد الذكي سهل الوصول إليها. تخبرنا معظم الدروس حول تطوير العقود الذكية ، "لا تخزن الكثير من البيانات على بلوكشين ، إنها باهظة الثمن!" ولكن كم هو "الكثير" ، وكم يرتفع السعر أكثر من اللازم للاستخدام العملي؟ كان عليّ معرفة ذلك ، لأنه لم نتمكن من جعل بياناتنا خارج السلسلة ، انهارت الفكرة بأكملها.
لقد بدأت للتو في العمل مع Solidity و EVM ، لذلك لا تدعي هذه المقالة أنها الحقيقة المطلقة ، ولكن لم أتمكن من العثور على مواد أخرى حول هذا الموضوع سواء باللغة الروسية أو باللغة الإنجليزية (على الرغم من أنه من السيء جدًا أنني لم أقرأ
هذه المقالة من قبل ) ، لذا آمل أن تكون مفيدة لشخص ما. حسنًا ، أو كملاذ أخير ، قد يكون من المفيد لي إذا أخبرني الرفاق المتمرسون كيف وأين أكون مخطئًا فيه.
بادئ ذي بدء ، قررت أن أعرف بسرعة ما إذا كان بإمكاننا القيام بذلك؟ لنأخذ نوع العقد القياسي الواسع - الرمز المميز
ERC20 . على الأقل ، يخزن هذا العقد في blockchain مراسلات عناوين الأشخاص الذين اشتروا الرموز المميزة لأرصدةهم. في الواقع ، يتم تخزين الأرصدة فقط ، كل منها يأخذ 32 بايت (في الواقع ، لا معنى للحفظ هنا بسبب ميزات
Solid و EVM). يمكن أن يحتوي الرمز المميز الناجح إلى حد ما على عشرات الآلاف من المالكين ، وبالتالي نحصل على أن تخزين حوالي 320،000 بايت في blockchain مقبول تمامًا. ولا نحتاج المزيد!
نهج ساذج
حسنًا ، دعنا نحاول حفظ بياناتنا. جزء كبير منها هو أعداد صحيحة 8 بت غير موقعة ، لذلك سننقل مصفوفة إلى العقد ، ونحاول كتابتها إلى ذاكرة للقراءة فقط:
uint8[] m_test; function test(uint8[] data) public { m_test = data; }
أحمق! هذه الوظيفة تأكل الغاز ، كما لو لم تكن في حد ذاتها. تكلفة إنقاذ 100 قيمة تكلفنا 814033 غازًا ، 8100 غازًا لكل بايت!
زفر وأخذ خطوة للوراء إلى النظرية. ما هو الحد الأدنى للتكلفة (في الغاز) لتخزين البيانات على Ethereum blockchain؟ يجب أن نتذكر أنه يتم تخزين البيانات في كتل من 32 بايت. يمكن لـ EVM قراءة أو كتابة كتلة كاملة فقط في وقت واحد ، لذلك من الأفضل أن يتم تعبئة البيانات المراد كتابتها بأكبر قدر ممكن من الكفاءة بحيث يتم حفظ أمر كتابة واحد على الفور. لأن أمر التسجيل نفسه - SSTORE - وحده
يكلف 20000 غاز (إذا كتبنا إلى خلية ذاكرة لم نكتب إليها من قبل). لذا فإن الحد الأدنى النظري ، الذي يتجاهل جميع النفقات الأخرى ، هو حوالي 625 غازًا لكل بايت. بعيدًا عن 8100 التي حصلنا عليها في المثال أعلاه! حان الوقت للحفر بشكل أعمق ومعرفة من يأكل غازنا ، وكيفية إيقافه.
يجب أن يكون دافعنا الأول هو النظر إلى الكود الذي تم إنشاؤه بواسطة مترجم Solidity من خطنا الوحيد (m_test = data) ، لأنه لا يوجد شيء آخر نراه. هذا دافع جيد وصحيح سيطلعنا على حقيقة مرعبة - المترجم في هذا المكان ولّد بعض الفظائع القديمة التي لن تفهمها من النظرة الأولى! بإلقاء نظرة سريعة على القائمة ، نرى أنه ليس فقط SSTORE (وهو أمر متوقع) ، ولكن أيضًا SLOAD (تحميل من ذاكرة القراءة فقط) وحتى EXP (أسي)! بشكل عام ، تبدو هذه طريقة مكلفة للغاية لتسجيل البيانات. والأسوأ من ذلك كله ، يصبح من الواضح تمامًا أن SSTORE تُسمى أيضًا في كثير من الأحيان. ما الذي يحدث هنا؟
بعض الأشياء. اتضح أن تخزين الأعداد الصحيحة 8 بت هو تقريبًا أسوأ شيء يمكنك القيام به مع EVM / Solidity (المقالة ، الرابط الذي استشهدت به في البداية ، تتحدث عن هذا). نفقد الإنتاجية (مما يعني أننا ندفع المزيد من الغاز) في كل منعطف. أولاً ، عندما نمرر مصفوفة من قيم 8 بت لإدخال وظيفتنا ، يتم
توسيع كل منها إلى 256 بت. أي أننا فقط نخسر 32 مرة من حجم بيانات المعاملات! جميل ومع ذلك ، سيلاحظ القارئ اليقظ أن تكلفة البايت المخزن لا تزال أعلى 13 مرة فقط من الحد الأدنى النظري ، وليس 32 ، مما يعني أنه عندما يتم حفظ العقد بشكل دائم في الذاكرة ، فإن كل شيء ليس سيئًا للغاية. إليك الشيء: عند الحفظ ، فإنه لا يزال يحزم البيانات ، وفي الذاكرة الدائمة للعقد سيتم تخزين أرقام 8 بت الخاصة بنا بالطريقة الأكثر فعالية ، 32 قطعة في كل كتلة ذاكرة. هذا يطرح السؤال ، ولكن كيف يتم تحويل الأرقام "256 بت" غير المعبأة التي جاءت إلينا في إدخال الدالة في شكل معبأ؟ الجواب هو "الطريقة الأكثر غباءاً التي يمكنني تخيلها".
إذا كتبنا كل شيء يحدث في شكل مبسط ، فإن سطر التعليمات البرمجية الوحيد الخاص بنا يتحول إلى دورة غريبة:
for(uint i = 0; i < data.length; ++i) {
الطريقة التي يبدو بها هذا الرمز لا تتأثر تقريبًا من خلال تشغيل التحسين أو إيقاف تشغيله (على الأقل في إصدار برنامج Solidity مترجم 0.4.24) ، وكما ترون ، فإنه يستدعي SSTORE (كجزء من set_storage_data_at_offset) 32 مرة أكثر من اللازم (مرة واحدة لكل رقم 8 بت ، وليس مرة واحدة لـ 32 مثل هذه الأرقام). ما ينقذنا من الفشل التام هو أن إعادة التسجيل في نفس الزنزانة لا يكلف 20000 ، ولكن 5000 غاز. لذا كل 32 بايت يكلفنا 20000 + 5000 * 31 = 125000 غاز ، أو حوالي 4000 غاز لكل بايت. تأتي بقية القيمة التي رأيناها أعلاه من قراءة الذاكرة (أيضًا ليست عملية رخيصة) والحسابات الأخرى المخفية في الرمز أعلاه في الوظائف (وهناك الكثير منها).
حسنًا ، لا يمكننا فعل أي شيء مع المترجم ،
لذلك سنبحث عن زر . يبقى فقط أن نخلص إلى أنه ليس من الضروري نقل وتخزين في صفائف العقد من أرقام 8 بت بهذه الطريقة.
حل بسيط لأرقام 8 بت
وما هو ضروري؟ وهكذا:
bytes m_test; function test(bytes data) public { m_test = data; }
نعمل في جميع المجالات من نوع البايت. مع هذا النهج ، سيكلف توفير 100 قيمة 129914 غازًا - فقط 1300 غاز لكل بايت ، أفضل 6 مرات من استخدام uint8 []! ستكون تكلفة هذا بعض الإزعاج - عناصر صفيف من نوع بايت هي من نوع بايت 1 ، والتي لا يتم تحويلها تلقائيًا إلى أي من الأنواع الصحيحة المعتادة ، لذلك سيكون عليك وضع تحويل النوع الصريح في الأماكن الصحيحة. ليست لطيفة جدًا ، ولكن المكسب هو 6 أضعاف تكلفة التسجيل ، أعتقد أنه يستحق ذلك! ونعم ، سنفقد القليل عند العمل مع هذه البيانات لاحقًا ، عند القراءة ، مقارنةً بتخزين كل رقم على أنه 256 بت ، ولكن هنا يبدأ المقياس في الأهمية: المكسب من حفظ ألف أو رقمين 8 بت في شكل معبأ يمكن بناءً على المهمة ، تفوق الخسائر عند قراءتها لاحقًا.
قبل القدوم إلى هذا النهج ، حاولت أولاً كتابة وظيفة أكثر كفاءة لحفظ البيانات في مجمع الماكرو المحلي
جوليا ، لكنني واجهت بعض المشكلات التي جعلت حلّي أقل كفاءة ، وأعطيت استهلاكًا لحوالي 1530 غازًا لكل بايت. ومع ذلك ، فإنه لا يزال مفيدًا لنا في هذه المقالة ، لذلك تم إنجاز العمل دون جدوى.
بالإضافة إلى ذلك ، ألاحظ أنه كلما زادت البيانات التي تحفظها في كل مرة ، قل تكلفة البايت ، مما يشير إلى أن جزءًا من التكلفة ثابت. على سبيل المثال ، إذا قمت بحفظ 3000 قيمة ، فعند الاقتراب من البايت نحصل على 900 غاز لكل بايت.
حل أكثر عمومية
حسنًا ، هذا كل شيء على ما يرام ، ينتهي بشكل جيد ، أليس كذلك؟ لكن مشاكلنا لم تنتهي هنا ، لأننا في بعض الأحيان لا نريد كتابة أرقام 8 بت فقط في ذاكرة العقد ، ولكن أيضًا أنواع البيانات الأخرى التي لا تتطابق بشكل مباشر مع نوع البايت. أي أنه من الواضح أنه يمكن ترميز أي شيء في المخزن المؤقت للبايت ، ولكن الحصول عليه من هناك في وقت لاحق قد لا يكون مناسبًا ، بل ومكلفًا بسبب الإيماءات غير الضرورية لتحويل الذاكرة الخام إلى النوع المطلوب. لذا فإن الوظيفة التي تحفظ صفيف وحدات البايت المرسلة إلى صفيف من النوع المطلوب لا تزال مفيدة لنا. الأمر بسيط للغاية ، لكنني استغرقت وقتًا طويلاً للعثور على جميع المعلومات اللازمة وفهم EVM و JULIA لكتابتها ، ولم يتم جمع كل هذا في مكان واحد. لذلك ، أعتقد أنه سيكون مفيدًا إذا أحضرت هنا ما حفرت.
في البداية ، لنتحدث عن كيفية تخزين Solidity لمجموعة في الذاكرة. المصفوفات هي مفهوم موجود فقط في إطار Solidity ، لا يعرف EVM أي شيء عنها ، ولكنه ببساطة يخزن مجموعة افتراضية من 2 ^ 256 كتل 32 بايت. من الواضح أن الكتل الفارغة لا يتم تخزينها ، ولكن في الواقع ، لدينا جدول من الكتل غير الفارغة ، ومفتاحها هو رقم 256 بت. وهذا الرقم هو بالضبط ما يقبله كل من EVM SSTORE و SLOAD كمدخلات (هذا ليس واضحًا تمامًا من الوثائق).
لتخزين المصفوفات ، تقوم Solidity بأمر
صعب : أولاً ، يتم تخصيص مجموعة الكتل "الرئيسية" لها في مكان ما في ذاكرة ثابتة ، بالترتيب المعتاد لوضع أعضاء العقد (أو الهياكل ، ولكن هذه أغنية منفصلة) ، كما لو كانت رقم 256 بت عادي. هذا يضمن أن الصفيف يتلقى كتلة واحدة كاملة ، بغض النظر عن المتغيرات المخزنة الأخرى. يخزن هذا الكتلة طول الصفيف. ولكن نظرًا لأنه غير معروف مسبقًا ، ويمكن أن يتغير (نحن نتحدث عن المصفوفات الديناميكية هنا) ، كان على مؤلفي Solidity معرفة مكان وضع بيانات المصفوفة حتى لا يتقاطعوا عن غير قصد مع بيانات صفيف آخر. بالمعنى الدقيق للكلمة ، هذه مهمة غير قابلة للذوبان: إذا قمت بإنشاء صفين أطول من 2 ^ 128 ، فمن المؤكد أن تتقاطع حيث لا تضعهما ، ولكن في الواقع لا ينبغي لأحد القيام بذلك ، لذلك يتم استخدام هذه الخدعة البسيطة: خذ تجزئة SHA3 من رقم الكتلة الرئيسية للصفيف ، ويتم استخدام الرقم الناتج كمفتاح في جدول الكتل (الذي أذكر ، 2 ^ 256). بواسطة هذا المفتاح ، يتم وضع أول كتلة من بيانات الصفيف ، والباقي - بعد ذلك بالتسلسل ، إذا لزم الأمر. احتمال اصطدام المصفوفات غير العملاقة صغير للغاية.
وهكذا ، من الناحية النظرية ، كل ما نحتاجه هو العثور على مكان بيانات الصفيف ونسخ المخزن المؤقت للبايت الذي تم تمريره إلينا كتلة تلو الأخرى. بينما نعمل مع أنواع أصغر من نصف حجم الكتلة ، على الأقل سنفوز قليلاً بالحل "الساذج" الناتج عن المترجم.
تبقى مشكلة واحدة فقط - إذا تم عمل كل شيء على هذا النحو ، فستتحول وحدات البايت الموجودة في الصفيف إلى الوراء. لأن EVM هي نهاية كبيرة. الطريقة الأسهل والأكثر فعالية ، بالطبع ، هي نشر وحدات البايت عند الإرسال ، ولكن من أجل بساطة واجهة برمجة التطبيقات ، قررت أن أفعل ذلك في رمز العقد. إذا كنت تريد توفير المزيد ، فلا تتردد في تجاهل هذا الجزء من الوظيفة ، وافعل كل شيء في وقت الإرسال.
فيما يلي الوظيفة التي حصلت عليها لتحويل مجموعة من وحدات البايت إلى مصفوفة من أعداد صحيحة موقعة من 64 بت (ومع ذلك ، يمكن تكييفها بسهولة مع الأنواع الأخرى):
function assign_int64_storage_from_bytes(int64[] storage to, bytes memory from) internal {
مع أرقام 64 بت ، لم نربح مثل أرقام 8 بت ، مقارنة بالكود الذي يولده المترجم ، ولكن مع ذلك تستهلك هذه الوظيفة 718466 غاز (7184 غاز لكل رقم ، 898 غاز لكل بايت) مقابل 1003225 للسذاجة حلول (1003 غاز لكل رقم ، 1254 لكل بايت) ، مما يجعل استخدامه ذا معنى تام. وكما ذكرنا أعلاه ، يمكنك توفير المزيد عن طريق إزالة عنوان البايت للمتصل.
تجدر الإشارة إلى أن حد الغاز لكل وحدة في Ethereum يحدد حدًا لكمية البيانات التي يمكننا تسجيلها في معاملة واحدة. لجعل الأمور أسوأ ، يعد إلحاق البيانات إلى مصفوفة معبأة بالفعل مهمة أكثر تعقيدًا بكثير ، إلا عندما يتم ملء آخر كتلة مستخدمة من الصفيف إلى الحد الأقصى (في هذه الحالة يمكنك استخدام نفس الوظيفة ، ولكن بمسافة بادئة مختلفة). في الوقت الحالي ، يبلغ حد الغاز لكل كتلة حوالي 6 ملايين ، مما يعني أنه يمكننا توفير قدر أكبر أو أقل من 6 كيلوبايت من البيانات في المرة الواحدة ، ولكن في الواقع أقل ، بسبب النفقات الأخرى.
التغييرات القادمة
التغييرات القادمة في شبكة Ethereum في أكتوبر ، والتي ستحدث مع تنشيط EIPs التابعة
للقسطنطينية ، يجب أن تجعل حفظ البيانات أسهل وأرخص - يقترح
EIP 1087 أن رسوم تخزين البيانات لن يتم فرضها على كل أمر SSTORE ، ولكن مقابل عدد الكتل المتغيرة ، التي ستجعل النهج الساذج الذي يستخدمه المترجم ، مربحًا تقريبًا مثل كود جوليا المكتوب يدويًا (ولكن ليس تمامًا - سيبقى هناك الكثير من حركات الجسم الإضافية ، خاصة لقيم 8 بت). سيؤدي الانتقال المخطط إلى WebAssembly كلغة أساسية لـ EVM إلى تغيير الصورة بشكل أكبر ، ولكن هذا لا يزال بعيدًا جدًا ، ونحن بحاجة إلى حل المشكلات الآن.
لا تدعي هذه المشاركة أنها أفضل حل للمشكلة ، وسأكون سعيدًا إذا قدم شخص ما حلًا أكثر فاعلية - لقد بدأت للتو في البدء مع Ethereum ، وقد يفقد بعض ميزات EVM التي يمكن أن تساعدني. ولكن في عمليات البحث التي أجريتها على الإنترنت ، لم أر أي شيء بشأن هذه المشكلة ، وربما تكون الأفكار والرمز أعلاه مفيدة لشخص ما كنقطة انطلاق للتحسين.