+80 ترفند بهینه سازی گس در سالیدیتی

در دنیای برنامه نویسی قراردادهای هوشمند، هزینه گس (Gas) نه‌تنها یک عدد ساده، بلکه عاملی تعیین‌کننده در موفقیت یا شکست یک پروژه بلاکچینی است. هر خط کدی که در سالیدیتی می‌نویسید، می‌تواند هزینه‌ای برای کاربران ایجاد کند. و این یعنی بهینه‌سازی دقیق، دیگر یک انتخاب نیست، بلکه یک ضرورت است. در این راهنمای جامع، با بیش از ۸۰ تکنیک واقعی برای بهینه سازی گس در سالیدیتی آشنا می‌شوید؛ نکاتی که نه‌فقط مصرف گس را کاهش می‌دهند، بلکه عملکرد کلی قرارداد شما را نیز ارتقاء می‌بخشند. اگر قصد دارید قراردادهای هوشمندی بنویسید که هم سریع اجرا شوند و هم اقتصادی باشند، این مقاله را از دست ندهید.

ترفندهای بهینه سازی گس همیشه نتیجه بخش نیستند

بعضی از تکنیک های بهینه سازی گس در سالیدیتی فقط در شرایط خاص عملکرد مناسبی دارند. برای نمونه، به‌طور شهودی ممکن است فکر کنید که:

نسبت به حالت زیر کم بازده تر است:

زیرا در نسخه اول، معکوس کردن شرط، نیاز به اجرای دستورالعمل‌های اضافه دارد. با این حال، نکته جالب اینجاست که در بسیاری از موارد، چنین تغییری نه تنها باعث کاهش مصرف گس نمی‌شود، بلکه حتی ممکن است هزینه تراکنش را افزایش دهد. دلیل این موضوع آن است که رفتار کامپایلر سالیدیتی همیشه قابل پیش بینی نیست.

به همین خاطر، قبل از انتخاب یک الگوریتم نهایی، بهتر است تاثیر واقعی گزینه‌های مختلف را اندازه گیری کنید. برخی از این ترفندها بیش از آنکه نسخه نهایی قابل اعتماد باشند، صرفاً توجه شما را به قسمت‌هایی از کد جلب می‌کنند که رفتار غیرمنتظره‌ای از سمت کامپایلر دارند.

از سوی دیگر، در این مقاله مشخص شده که کدام ترفندها قابلیت استفاده عمومی ندارند. همچنین، از آنجا که عملکرد کامپایلر در سطوح محلی می‌تواند بر اثربخشی ترفندها تاثیر بگذارد، بهتر است هر دو نسخه بهینه و غیر بهینه کد را آزمایش کنید تا مطمئن شوید که واقعاً بهبود حاصل می‌شود.

نکته دیگری که باید در نظر بگیرید این است که فعال کردن گزینه --via-ir در کامپایلر سالیدیتی، ممکن است رفتار بهینه سازی را تغییر دهد. بنابراین هنگام استفاده از این گزینه نیز، باید دوباره تاثیر ترفندها را بررسی کنید.

مراقب پیچیدگی و خوانایی کد باشید

بیشتر روش‌های بهینه سازی گس در سالیدیتی باعث می‌شوند کد خوانایی خود را از دست بدهد و پیچیده‌تر شود. بنابراین، یک توسعه دهنده حرفه‌ای باید به‌صورت ذهنی بین سادگی و بهینه بودن تعادل ایجاد کند و تصمیم بگیرد که کدام بهینه سازی واقعاً ارزش اجرا دارد و از کدام موارد باید صرف‌نظر کرد.

شرح کامل تمام مباحث در اینجا ممکن نیست

در این مقاله امکان ارائه توضیح کامل برای هر تکنیک بهینه سازی وجود ندارد و در واقع ضرورتی هم برای آن نیست. چون منابع آنلاین زیادی برای مطالعه عمیق‌تر این مباحث وجود دارد. برای مثال، بررسی جامع یا حتی نسبتاً کامل درباره راهکارهای لایه دوم (Layer 2) و کانال های وضعیت (State Channels) از حوزه این مقاله خارج است. می‌توان آن موضوعات را از منابع تخصصی دیگر پیگیری کرد.

هدف اصلی این مقاله، ارائه جامع‌ترین فهرست از ترفندهای بهینه سازی موجود است. اگر با یکی از ترفندها آشنایی ندارید، می‌توانید آن را به عنوان نقطه شروعی برای مطالعه بیشتر در نظر بگیرید. از طرف دیگر، اگر عنوان یک بخش برایتان آشناست، می‌توانید به سرعت از روی آن عبور کنید و زمان خود را صرف بخش‌هایی کنید که به اطلاعات جدیدتری نیاز دارند.

ترفندهای وابسته به کاربرد خاص را بررسی نمی‌کنیم

برخی از روش‌های بهینه سازی گس در سالیدیتی فقط در موقعیت‌های بسیار خاص مفید هستند. برای مثال، می‌توان عدد اول بودن را با مصرف گس پایین‌تری بررسی کرد، اما این کار در عمل به‌ندرت نیاز می‌شود. پس پرداختن به آن در این مقاله، ارزش کلی محتوا را کاهش می‌دهد.

۱. مهم‌ترین اصل: تا جای ممکن از نوشتن مقدار صفر به یک در حافظه دائم (Storage) خودداری کنید

مقداردهی اولیه به یک متغیر ذخیره شده در حافظه دائم (storage) یکی از پرهزینه‌ترین عملیات‌هایی است که یک قرارداد می‌تواند انجام دهد.

زمانی که یک متغیر از مقدار صفر به مقدار غیرصفر تغییر می‌کند، کاربر باید در مجموع ۲۲,۱۰۰ واحد گس پرداخت کند؛ که شامل ۲۰,۰۰۰ گس برای تغییر مقدار صفر به غیرصفر و ۲,۱۰۰ گس بابت دسترسی اولیه (cold access) به آن متغیر می‌شود.

به همین دلیل، کتابخانه امنیتی OpenZeppelin ReentrancyGuard برای جلوگیری از حمله بازدرآمد (Reentrancy)، به‌جای استفاده از مقادیر ۰ و ۱، از مقادیر ۱ و ۲ استفاده می‌کند. چون در این حالت، تغییر مقدار متغیر از عددی غیرصفر به عددی دیگر نیز غیرصفر، فقط ۵,۰۰۰ گس هزینه دارد.

این تکنیک یکی از ساده‌ترین و در عین حال مؤثرترین روش‌ها برای کاهش هزینه‌های گس در قراردادهای سالیدیتی است.

۲. متغیرهای storage را کش (Cache) کنید: فقط یک بار بخوانید و یک بار بنویسید

در کدهای بهینه سالیدیتی، یک الگوی رایج و بسیار مهم وجود دارد:
هیچ‌وقت متغیرهای storage را چند بار نخوانید یا بنویسید.

خواندن از storage حداقل ۱۰۰ واحد گس هزینه دارد، زیرا سالیدیتی برخلاف حافظه موقت (memory)، خواندن‌های storage را کش نمی‌کند. از طرف دیگر، نوشتن در storage هزینه بسیار بالاتری دارد. بنابراین، برای صرفه‌جویی در گس، باید مقدار متغیر را فقط یک بار از storage بخوانید، در یک متغیر موقت ذخیره کنید و در نهایت فقط یک بار در storage بنویسید.

در مثال زیر، تفاوت بین یک پیاده سازی ناکارآمد و یک پیاده سازی بهینه را می‌بینید:

در کد بالا، متغیر number دو بار از storage خوانده می‌شود:
یک بار در require(number < 10) و یک بار دیگر در number + 1.

اما در نسخه بهینه تر، کد به شکل زیر است:

در این نسخه، متغیر number فقط یک بار از storage خوانده می‌شود و در متغیر _number کش می‌شود. سپس تمامی عملیات‌ها روی همین نسخه موقت انجام می‌گیرند، و در پایان نتیجه نهایی فقط یک بار در storage ذخیره می‌شود.

۳. متغیرهای مرتبط را در یک اسلات فشرده کنید (Packing)

زمانی که چند متغیر مرتبط را به‌صورت فشرده در یک اسلات حافظه ذخیره می‌کنید، مصرف گس به شکل محسوسی کاهش می‌یابد. دلیل این صرفه‌جویی، کاهش تعداد عملیات های پرهزینه مرتبط با حافظه دائم (storage) است.

روش اول: فشرده سازی دستی (بالاترین کارایی)

در این روش، دو متغیر از نوع uint80 را در یک متغیر uint160 ذخیره می‌کنیم. با استفاده از بیت‌شیفت (Bit Shifting) می‌توان هر دو مقدار را در یک اسلات ذخیره کرد. این کار باعث می‌شود هم هنگام ذخیره سازی و هم در زمان خواندن مقادیر، گس کمتری مصرف شود.

روش دوم: تکیه بر فشرده سازی ضمنی EVM (کمتر بهینه ولی قابل قبول)

در این روش نیز متغیرها در یک اسلات قرار می‌گیرند، اما عملیات فشرده سازی توسط EVM انجام می‌شود. این مدل در بسیاری از مواقع مفید است، اما در مقایسه با روش دستی ممکن است هنگام اجرای تراکنش کمی گس بیشتری مصرف کند.

روش سوم: بدون فشرده سازی (پرهزینه‌ترین حالت)

در این روش هیچ فشرده سازی انجام نمی‌شود. به همین دلیل، متغیرها در دو اسلات مجزا ذخیره می‌شوند. این طراحی ساده‌تر است، اما باعث افزایش قابل توجه هزینه‌های گس می‌شود، مخصوصاً زمانی که نیاز به خواندن یا نوشتن مقادیر در یک تراکنش داشته باشید.

۴. ساختارهای Struct را فشرده (Packed) طراحی کنید

فشرده سازی اعضای یک ساختار (Struct)، دقیقاً مشابه فشرده سازی متغیرهای مرتبط، باعث کاهش مصرف گس می‌شود.
نکته مهم این است که در سالیدیتی، اعضای struct به ترتیب و پشت سر هم در حافظه دائم (storage) ذخیره می‌شوند. ترتیب قرارگیری فیلدها مستقیماً بر تعداد اسلات های مصرفی تأثیر دارد.

Struct بدون فشرده سازی (ناکارآمد)

در ساختار زیر، هر فیلد در یک اسلات جداگانه ذخیره می‌شود. در نتیجه، سه اسلات برای سه فیلد مصرف می‌شود، حتی اگر برخی فیلدها ظرفیت کامل اسلات را اشغال نکنند.

Struct فشرده شده (بهینه)

در این نسخه، ترتیب فیلدها به‌گونه‌ای چیده شده که time و person در یک اسلات جا می‌گیرند، چون مجموع اندازه آن‌ها کمتر از 256 بیت است.

در این ساختار، time و person روی هم فقط ۲۲۴ بیت فضا اشغال می‌کنند. بنابراین هر دو در یک اسلات ذخیره می‌شوند و تنها دو اسلات برای این struct کافی است. نتیجه این بهینه‌سازی، کاهش محسوس هزینه گس هنگام خواندن یا نوشتن اطلاعات است.

۵. اندازه رشته ها را کمتر از ۳۲ بایت نگه دارید

در سالیدیتی، نوع داده string از نوع داده های پویا (Dynamic) است؛ به این معنا که طول رشته می‌تواند در زمان اجرا تغییر کند و گسترش یابد.

اما نحوه ذخیره سازی رشته ها در حافظه دائم (storage) بسته به طول آن‌ها تفاوت دارد:

  • اگر طول رشته کمتر از ۳۲ بایت باشد، تمام داده های رشته به‌همراه اطلاعات طول آن، در همان یک اسلات حافظه ذخیره می‌شوند. در این حالت، مقدار (طول * ۲) در کم‌ارزش‌ترین بایت اسلات (Least Significant Byte) قرار می‌گیرد، و محتوای واقعی رشته از سمت دیگر اسلات (بایت‌های پرارزش‌تر) شروع می‌شود.

  • ولی اگر طول رشته ۳۲ بایت یا بیشتر شود، اسلات تعریف‌شده فقط شامل مقدار (طول * ۲) + ۱ خواهد بود. خود محتوای رشته در این حالت به موقعیت دیگری منتقل می‌شود که آدرس آن از طریق هش keccak256 آن اسلات محاسبه می‌شود. این ساختار نه‌تنها حافظه بیشتری مصرف می‌کند، بلکه خواندن و نوشتن رشته را نیز پرهزینه‌تر می‌سازد.

مثال رشته (کمتر از ۳۲ بایت)

مثال رشته (بیشتر از ۳۲ بایت)

می‌توانیم این موضوع را با استفاده از اسکریپت تست زیر در Foundry به‌صورت عملی بررسی کنیم:

این نتیجه‌ای است که پس از اجرای تست دریافت می‌کنیم.

اگر مقدار رشته‌ای که طول آن بیشتر از ۳۲ بایت است را از حافظه بخوانیم، می‌توانیم با کنار هم قرار دادن مقدار هگزادسیمال داده (بدون بخش طول)، آن را در زبان هایی مانند Python به رشته اصلی تبدیل کنیم.

اما نکته مهم‌تر این است که اگر طول رشته کمتر از ۳۲ بایت باشد، می‌توان آن را در یک متغیر از نوع bytes32 ذخیره کرد و با استفاده از اسمبلی، هنگام نیاز مقدار آن را بازیابی یا استفاده کرد. این روش هم حافظه کمتری مصرف می‌کند و هم در مقایسه با string معمولی، مصرف گس بسیار کمتری دارد.

مثال:

کدی که در بالا ارائه شد، قابلیت بهینه سازی بیشتری دارد، اما به‌صورت ساده و شفاف نوشته شده تا درک آن برای همه حتی مبتدی ها آسان باشد.

۶. متغیرهایی که تغییر نمی‌کنند باید constant یا immutable باشند

در سالیدیتی، اگر مطمئن هستید که مقدار یک متغیر بعد از تعریف هرگز تغییر نخواهد کرد، بهتر است آن را با یکی از دو کلیدواژه‌ی constant یا immutable تعریف کنید.

دلیل این کار بسیار ساده است:
متغیرهای constant و immutable به‌صورت مستقیم در بایت کد نهایی قرارداد جای می‌گیرند و دیگر نیازی به اختصاص اسلات در حافظه دائم (storage) ندارند.
از آنجا که خواندن از storage یکی از عملیات‌های پرهزینه از نظر گس محسوب می‌شود، حذف این خواندن‌ها به‌طور چشمگیری در مصرف گس صرفه‌جویی می‌کند.

این کار باعث صرفه‌جویی قابل توجهی در مصرف گس می‌شود، چون دیگر نیازی به خواندن از حافظه دائم (storage) نداریم. همان‌طور که می‌دانید، عملیات خواندن از storage یکی از پرهزینه‌ترین بخش‌های اجرای قرارداد در اتریوم است.

۷. استفاده از Mapping به جای Array برای حذف بررسی طول (Length Check) و کاهش گس

زمانی که می‌خواهید مجموعه‌ای از آیتم ها را ذخیره کنید که قابل مرتب سازی با ترتیب مشخص باشند و بتوانید آن‌ها را با یک کلید یا اندیس ثابت فراخوانی کنید، استفاده از آرایه یک انتخاب رایج است. این روش به‌خوبی کار می‌کند، اما آیا می‌دانستید که با استفاده از یک mapping می‌توان بیش از ۲۰۰۰ واحد گس در هر عملیات خواندن صرفه‌جویی کرد؟

مثال زیر را ببینید:

تنها با جایگزینی mapping به‌جای array، توانستیم ۲۱۰۲ گس صرفه‌جویی کنیم. چرا؟ چون در پس‌زمینه، زمانی که مقدار یک اندیس از آرایه خوانده می‌شود، سالیدیتی به‌صورت خودکار بایت کدی تولید می‌کند که بررسی کند آیا اندیس در بازه معتبر قرار دارد یا خیر (یعنی باید کوچکتر از طول آرایه باشد). در غیر این صورت، با خطای Panic مواجه می‌شوید (Panic(0x32) به‌طور دقیق).

این بررسی از خواندن حافظه تخصیص‌نیافته یا بدتر از آن، حافظه اشتباهی جلوگیری می‌کند.

اما در mapping، به‌دلیل ساختار ساده آن (نگاشت کلید به مقدار)، چنین بررسی‌ای وجود ندارد و داده به‌طور مستقیم از حافظه خوانده می‌شود.

نکته مهم این است که اگر از mapping به این شیوه استفاده می‌کنید، باید مطمئن شوید که در حال خواندن اندیسی خارج از محدوده تعریف‌شده خود نیستید.

۸. استفاده از unsafeAccess برای حذف بررسی طول آرایه ها

روش دیگری برای حذف بررسی طول آرایه ها در زمان خواندن، بدون نیاز به جایگزینی array با mapping، استفاده از تابع unsafeAccess در کتابخانه Arrays.sol از مجموعه کتابخانه های OpenZeppelin است.

این تابع به توسعه دهندگان اجازه می‌دهد که به‌طور مستقیم به مقدار یک اندیس خاص از آرایه دسترسی داشته باشند، بدون اینکه بررسی اعتبار طول (length check) انجام شود. این یعنی سالیدیتی دیگر بررسی نمی‌کند که آیا اندیس ورودی کوچکتر از طول آرایه هست یا نه؛ بنابراین، مقداری از گس که معمولاً صرف این بررسی می‌شود، حذف خواهد شد.

با این حال، بسیار مهم است که فقط زمانی از این روش استفاده شود که کاملاً مطمئن باشید اندیسی که وارد تابع می‌شود، از طول آرایه تجاوز نمی‌کند. در غیر این صورت، رفتار ناخواسته و حتی آسیب‌پذیری امنیتی در قرارداد ایجاد خواهد شد.

۹. استفاده از بیت مپ (Bitmap) به جای bool در زمان استفاده گسترده از مقادیر بولی

در بسیاری از قراردادها—به‌ویژه در سناریوهایی مثل ایردراپ (Airdrop) یا مینت NFT—الگوی رایجی وجود دارد که در آن، برای هر آدرس بررسی می‌شود که آیا از قبل استفاده شده یا نه. معمولاً برای این کار از متغیرهایی با نوع bool استفاده می‌شود.

اما واقعیت این است که یک مقدار بولی فقط یک بیت نیاز دارد، در حالی که هر اسلات حافظه در سالیدیتی ۲۵۶ بیت فضا دارد. بنابراین، به جای استفاده از mapping(address => bool) که برای هر آدرس یک اسلات جداگانه مصرف می‌کند، می‌توان با استفاده از بیت مپ (Bitmap)، تا ۲۵۶ مقدار bool را تنها در یک اسلات ذخیره کرد.

این تکنیک باعث صرفه‌جویی چشمگیر در مصرف حافظه و در نتیجه کاهش هزینه گس می‌شود.

۱۰. استفاده از SSTORE2 یا SSTORE3 برای ذخیره سازی حجم زیادی از داده

SSTORE

SSTORE یک دستور (opcode) در ماشین مجازی اتریوم (EVM) است که امکان ذخیره داده‌های دائمی را به‌صورت کلید–مقدار فراهم می‌کند. همان‌طور که در EVM رایج است، هم کلید و هم مقدار، هر دو ۳۲ بایت هستند.

هزینه های مربوط به نوشتن (SSTORE) و خواندن (SLOAD) در این روش از نظر مصرف گس بسیار بالا هستند.
نوشتن ۳۲ بایت داده با استفاده از SSTORE برابر با ۲۲,۱۰۰ گس هزینه دارد، که معادل حدود ۶۹۰ گس برای هر بایت است.

از طرف دیگر، نوشتن بایت‌کد یک قرارداد هوشمند تنها ۲۰۰ گس برای هر بایت هزینه دارد.

SSTORE2

SSTORE2 یک مفهوم منحصربه‌فرد است که برای ذخیره سازی داده ها، به‌جای استفاده از حافظه دائم (storage)، از بایت کد یک قرارداد هوشمند استفاده می‌کند. این روش از ویژگی ذاتی بایت‌کد—یعنی تغییرناپذیری (immutability)—برای نوشتن داده استفاده می‌کند.

  • داده فقط یک‌بار نوشته می‌شود؛ در واقع به‌جای استفاده از SSTORE، از دستور CREATE برای ساخت یک قرارداد حاوی داده استفاده می‌کنیم.

  • برای خواندن داده، به‌جای استفاده از SLOAD، از دستور EXTCODECOPY استفاده می‌کنیم تا بایت‌کد قرارداد ذخیره شده را بازیابی کنیم.

  • هرچه حجم داده های مورد نیاز برای ذخیره بیشتر باشد، صرفه‌جویی در گس چشمگیرتر خواهد شد. این روش مخصوصاً در ذخیره سازی داده های حجیم بسیار مقرون‌به‌صرفه‌تر از SSTORE سنتی عمل می‌کند.

مثال:

نوشتن داده با SSTORE2

هدف ما این است که داده ای خاص (در قالب bytes) را به‌عنوان بایت کد یک قرارداد هوشمند ذخیره کنیم. برای انجام این کار، دو مرحله اصلی باید طی شود:

  • کپی داده به حافظه (Memory): ابتدا باید داده مورد نظر را در حافظه کپی کنیم. ماشین مجازی اتریوم (EVM) داده‌ها را از حافظه خوانده و آن‌ها را به‌عنوان کد زمان اجرا (runtime bytecode) در قرارداد جدید ذخیره می‌کند.

  • بازگرداندن و ذخیره آدرس قرارداد جدید: پس از دیپلوی کردن قرارداد جدیدی که داده در بایت کد آن ذخیره شده، باید آدرس این قرارداد را ذخیره کنیم تا در آینده بتوانیم داده را از آن بخوانیم.

    • ما اندازه کد قرارداد را در جای چهار صفر (0000) بین 61 و 80 در کد زیر قرار می‌دهیم:
      0x61000080600a3d393df300. به‌عنوان مثال، اگر اندازه کد ۶۵ بایت باشد، کد تبدیل می‌شود به: 0x61004180600a3d393df300 (0x0041 = 65)
    • این بایت کد مسئول انجام مرحله اولی است که در بالا توضیح داده شد.
    • در مرحله دوم، آدرس قرارداد جدیدی را که دیپلوی شده، بازمی‌گردانیم.

بایت‌کد قرارداد نهایی = ۰۰ + داده (در اینجا، 00 که معادل دستور STOP است به ابتدای داده اضافه می‌شود تا اطمینان حاصل شود که بایت کد هنگام فراخوانی آدرس به‌صورت اشتباهی اجرا نشود.)

خواندن داده
  • برای دریافت داده ذخیره شده، ابتدا باید آدرس قراردادی را در اختیار داشته باشید که داده در آن ذخیره شده است.
  • اگر اندازه بایت کد قرارداد (code size) برابر با صفر باشد، عملیات بازمی‌گردد (revert می‌شود) که دلیل آن نیز کاملاً مشخص است.
  • اکنون کافی است بایت‌کد قرارداد را از آدرس مربوطه و از موقعیت مناسب بازگردانیم. این موقعیت، دقیقاً از بایت دوم آغاز می‌شود؛ چون اولین بایت، دستور STOP با کد 0x00 است.

اطلاعات تکمیلی برای علاقه‌مندان:

  • همچنین می‌توان با استفاده از دستور CREATE2، آدرس قرارداد را به‌صورت پیش‌بینی‌پذیر (pre-deterministic) محاسبه کرد. در این روش، بدون نیاز به ذخیره آدرس قرارداد (pointer)، می‌توان آن را چه در زنجیره (on-chain) و چه خارج از زنجیره (off-chain) محاسبه کرد.
SSTORE3

برای درک بهتر SSTORE3، ابتدا بیایید یک ویژگی مهم از SSTORE2 را مرور کنیم:

  • در SSTORE2، آدرس قراردادی که ایجاد می‌شود به داده ای که قصد ذخیره آن را داریم وابسته است.

نوشتن داده

SSTORE3 ساختاری متفاوت دارد، به‌طوری که آدرس قرارداد جدید مستقل از داده ای است که ارائه می‌کنیم.

در اینجا ابتدا داده مورد نظر را با استفاده از SSTORE در حافظه دائمی ذخیره می‌کنیم. سپس در دستور CREATE2، یک INIT_CODE ثابت را به‌عنوان ورودی قرار می‌دهیم. این کد اولیه به‌صورت داخلی داده هایی را که در storage ذخیره کرده‌ایم، خوانده و آن را به‌عنوان بایت‌کد قرارداد جدید مستقر می‌کند.

این طراحی به ما این امکان را می‌دهد که تنها با استفاده از salt (که می‌تواند کمتر از ۲۰ بایت باشد)، آدرس قرارداد حاوی داده را به‌صورت کارآمد محاسبه کنیم. بنابراین می‌توانیم اشاره‌گر (pointer) را به‌صورت فشرده‌شده با دیگر متغیرها ذخیره کنیم و در نتیجه، هزینه ذخیره سازی را کاهش دهیم.

خواندن داده

حالا تصور کنید چطور می‌توانیم داده را بخوانیم.

  • پاسخ ساده است: می‌توانیم آدرس قرارداد مستقرشده را تنها با ارائه salt محاسبه کنیم.
  • پس از دریافت آدرس، با استفاده از همان دستور EXTCODECOPY می‌توانیم داده مورد نظر را دریافت کنیم.

به طور خلاصه:

  • SSTORE2 در مواقعی مفید است که عملیات نوشتن کم انجام می‌شود، اما خواندن زیاد است، به‌ویژه زمانی که طول pointer بیشتر از ۱۴ بایت باشد.

  • SSTORE3 گزینه بهتری است زمانی که تعداد دفعات نوشتن بسیار کم است ولی خواندن مکرر انجام می‌شود، و طول pointer کمتر از ۱۴ بایت باشد.

۱۱. استفاده از اشاره گرهای storage به‌جای memory در مواقع مناسب

در سالیدیتی، اشاره گرهای storage (Storage Pointers) متغیرهایی هستند که به یک مکان مشخص در حافظه دائم قرارداد اشاره می‌کنند. البته این اشاره گرها دقیقاً مشابه اشاره گرها در زبان هایی مثل C یا ++C نیستند، اما عملکرد مشابهی در زمینه ارجاع به داده ها دارند.

استفاده صحیح از این اشاره گرها بسیار مفید است، زیرا می‌تواند:

  • از خواندن‌های غیرضروری از storage جلوگیری کند

  • و باعث به‌روزرسانی بهینه تر داده ها با کاهش مصرف گس شود

در ادامه، یک مثال ارائه شده است که نشان می‌دهد استفاده از storage pointer چگونه می‌تواند مفید باشد.

در مثال بالا، تابعی داریم که آخرین زمان مشاهده یک کاربر را با استفاده از اندیس مشخص بازیابی می‌کند. این تابع مقدار lastSeen را از ساختار (struct) دریافت کرده و آن را از block.timestamp کم می‌کند تا مدت زمان سپری‌شده از آخرین فعالیت کاربر (برحسب ثانیه) محاسبه شود.

اما در این پیاده سازی، تمام ساختار (struct) از حافظه دائم (storage) به حافظه موقت (memory) کپی می‌شود—حتی متغیرهایی که اصلاً به آن‌ها نیاز نداریم.

این روش از نظر عملکرد صحیح است، اما از نظر مصرف گس کارآمد نیست. چون عملیات کپی کل ساختار به حافظه، هزینه‌ای غیرضروری به همراه دارد.

حالا اگر راهی وجود داشت که فقط مقدار lastSeen را مستقیماً از storage بخوانیم، بدون استفاده از اسمبلی، عملکرد به مراتب بهینه‌تر می‌شد.

اینجا دقیقاً جایی است که اشاره گرهای storage وارد عمل می‌شوند. با استفاده از آن‌ها، می‌توانیم فقط به بخش مورد نظر از struct دسترسی داشته باشیم، بدون اینکه کل ساختار را به memory منتقل کنیم. این روش، هم خوانایی بالایی دارد و هم مصرف گس را به‌طور محسوسی کاهش می‌دهد.

«پیاده‌سازی بالا حدود ۵,۰۰۰ واحد گس کمتر از نسخه اول مصرف می‌کند.» چرا چنین صرفه‌جویی اتفاق می‌افتد، در حالی که تنها تغییری که انجام دادیم این بود که memory را به storage تغییر دادیم؟ مگر نگفته بودند که storage پرهزینه است و باید از آن اجتناب کرد؟

در اینجا، ما اشاره گر storage برای users[_id] را در یک متغیر با اندازه ثابت روی stack ذخیره کرده‌ایم. اشاره گر یک struct در سالیدیتی، در اصل اسلات اولیه‌ای است که ساختار از آن‌جا شروع می‌شود—در این مثال، اسلات مربوط به user[_id].id.

نکته کلیدی اینجاست: اشاره گرهای storage حالت “lazy” دارند، یعنی فقط زمانی که به آن‌ها ارجاع داده می‌شود (خوانده یا نوشته می‌شوند) عمل می‌کنند. در ادامه، ما فقط به کلید lastSeen در داخل struct دسترسی پیدا می‌کنیم. در نتیجه، فقط یک بار داده از storage بارگذاری می‌شود و روی stack قرار می‌گیرد، در حالی که در نسخه اولیه، سه یا بیشتر عملیات خواندن از storage و یک عملیات کپی به memory انجام می‌شد تا تنها یک بخش کوچک از داده به stack منتقل شود.

نکته مهم: هنگام استفاده از اشاره گرهای storage باید بسیار مراقب باشید که به اشاره گرهای معلق (dangling pointers) ارجاع ندهید.

۱۲. از صفر شدن موجودی توکن های ERC20 جلوگیری کنید؛ همیشه مقدار کمی نگه دارید

این نکته با بخش «جلوگیری از نوشتن مقدار صفر» که پیش‌تر گفته شد مرتبط است، اما به دلیل پیاده سازی خاص آن، شایسته است به‌طور جداگانه به آن اشاره شود.

اگر یک آدرس دائماً موجودی حساب خود را به صفر می‌رساند و دوباره شارژ می‌کند، این رفتار باعث می‌شود که عملیات نوشتن از صفر به غیرصفر (zero to one writes) به دفعات تکرار شود. و همان‌طور که گفته شد، این نوع نوشتن بسیار پرهزینه است.

۱۳. به جای شمارش از صفر تا n، از n تا صفر شمارش کنید

زمانی که یک متغیر storage را به صفر تغییر می‌دهید، مقداری از گس به‌صورت بازپرداخت (refund) برمی‌گردد. بنابراین اگر متغیر شمارنده شما در پایان به صفر برسد، مجموع گس مصرفی شما کمتر خواهد شد. در نتیجه، شمارش معکوس (از n به صفر) در بسیاری از موارد، مقرون‌به‌صرفه‌تر از شمارش افزایشی (از صفر به n) است.

۱۴. برای زمان و شماره بلاک، نیازی به استفاده از uint256 نیست

برای ذخیره سازی تایم استمپ ها و شماره بلاک ها، استفاده از uint256 ضرورتی ندارد.

  • یک uint48 برای ذخیره تایم استمپ تا میلیون‌ها سال آینده کافی است.

  • شماره بلاک در اتریوم تقریباً هر ۱۲ ثانیه یک‌بار افزایش می‌یابد. بنابراین اندازه عددی که واقعاً نیاز دارید، بسیار کمتر از ۲۵۶ بیت خواهد بود.

با انتخاب نوع داده مناسب (مثل uint48 یا uint64) می‌توانید هم در فضا صرفه‌جویی کنید و هم هزینه گس را کاهش دهید.

صرفه جویی در گس هنگام دیپلوی قرارداد

۱. استفاده از nonce حساب برای پیش‌بینی آدرس قراردادهای وابسته به یکدیگر، و حذف متغیرهای storage و توابع تنظیم آدرس

در مدل سنتی دیپلوی قراردادهای هوشمند، آدرس قرارداد را می‌توان به‌صورت قطعی (deterministic) محاسبه کرد. این آدرس بر پایه آدرس حساب دیپلوی‌کننده و مقدار nonce آن محاسبه می‌شود. کتابخانه LibRLP از پروژه Solady می‌تواند در این محاسبه به ما کمک کند.

سناریوی مثال زیر را در نظر بگیرید؛

قرارداد StorageContract فقط به قرارداد Writer اجازه می‌دهد که مقدار متغیر x را تنظیم کند. بنابراین باید از آدرس Writer مطلع باشد.
از طرف دیگر، برای اینکه Writer بتواند در StorageContract داده ای بنویسد، باید آدرس آن را هم بداند.

در نسخه ابتدایی این مسئله، پیاده سازی به این صورت انجام می‌شود که یک تابع setter پس از دیپلوی قرارداد، آدرس طرف مقابل را در یک متغیر storage ذخیره می‌کند. اما همان‌طور که می‌دانیم، متغیرهای ذخیره شده در storage پرهزینه هستند، و بهتر است تا حد امکان از آن‌ها پرهیز کنیم.

این روش هم در زمان دیپلوی و هم در زمان اجرا گس بیشتری مصرف می‌کند. در این مدل، ابتدا قرارداد Writer دیپلوی می‌شود. سپس قرارداد StorageContract با آدرس Writer به‌عنوان نویسنده (writer) مستقر می‌گردد. در مرحله بعد، آدرس StorageContract در متغیر داخلی Writer ذخیره می‌شود. این فرآیند شامل چندین مرحله است و پرهزینه است، زیرا آدرس StorageContract در حافظه دائم (storage) ذخیره می‌شود. به‌عنوان نمونه، فراخوانی تابع Writer.setX() در این روش حدود ۴۹,۰۰۰ واحد گس مصرف می‌کند.

راه‌حل کارآمدتر این است که قبل از دیپلوی قراردادها، آدرس‌هایی را که StorageContract و Writer در آن مستقر خواهند شد، پیش‌بینی کنیم و همان آدرس‌ها را مستقیماً در سازنده (constructor) هر دو قرارداد تنظیم کنیم.

در ادامه، یک نمونه پیاده سازی از این روش ارائه می‌شود:

در این مدل، فراخوانی تابع Writer.setX() حدود ۴۷,۰۰۰ واحد گس مصرف می‌کند. یعنی با محاسبه آدرس StorageContract قبل از دیپلوی آن، توانستیم بیش از ۲,۰۰۰ واحد گس صرفه‌جویی کنیم. این آدرس از قبل در زمان دیپلوی Writer استفاده شد و به همین دلیل، دیگر نیازی به استفاده از تابع setter نبود.

برای استفاده از این تکنیک، لزومی به تعریف قرارداد جداگانه نیست؛ می‌توانید این منطق را مستقیماً در اسکریپت دیپلوی خود پیاده سازی کنید.

۲. سازنده‌ها (Constructors) را payable تعریف کنید

قرار دادن کلیدواژه payable در سازنده باعث صرفه‌جویی حدود ۲۰۰ گس در زمان دیپلوی قرارداد می‌شود. دلیل این صرفه‌جویی آن است که توابعی که payable نیستند، به‌صورت ضمنی شامل شرط زیر هستند:
در نتیجه، هنگام دیپلوی یک سازنده غیر‌قابل پرداخت، بایت کد بیشتری تولید می‌شود. این حجم بالاتر باعث افزایش گس مصرفی به‌دلیل calldata بزرگ‌تر می‌شود.

در مورد توابع معمولی (مثل transfer یا mint)، دلایل خوبی برای غیر‌قابل پرداخت بودن آن‌ها وجود دارد. اما در مورد سازنده ها، معمولاً قرارداد توسط یک آدرس دارای دسترسی (privileged) دیپلوی می‌شود که می‌توان منطقی فرض کرد اتر اشتباهی ارسال نمی‌کند. در این شرایط، قراردادن payable در سازنده منطقی و بهینه است.

البته این موضوع در صورتی که کاربران مبتدی قرارداد را دیپلوی کنند ممکن است صدق نکند، و در آن موارد احتیاط بیشتری لازم است.

۳. کاهش حجم دیپلوی با بهینه سازی هش IPFS یا استفاده از گزینه --no-cbor-metadata در کامپایلر

کامپایلر سالیدیتی در زمان ساخت قرارداد، ۵۱ بایت متادیتا را به انتهای بایت‌کد نهایی قرارداد اضافه می‌کند. از آن‌جایی که هر بایت هنگام دیپلوی ۲۰۰ واحد گس هزینه دارد، حذف این بخش می‌تواند بیش از ۱۰,۰۰۰ گس از هزینه دیپلوی قرارداد کم کند.

با این حال، حذف متادیتا همیشه بهترین گزینه نیست، چرا که می‌تواند بر قابلیت تایید قرارداد در پلتفرم هایی مثل Etherscan تأثیر منفی بگذارد.

راه‌حل جایگزین این است که توسعه دهنده ها بتوانند با اعمال تغییراتی در کامنت های کد (مثل تغییر فاصله ها)، باعث شوند که هش IPFS نهایی دارای صفرهای بیشتری در ابتدای خود باشد. این کار باعث فشرده تر شدن متادیتا و در نتیجه کاهش گس مصرفی در زمان دیپلوی می‌شود.

۴. اگر قرارداد فقط یک‌بار استفاده می‌شود، در انتهای سازنده از selfdestruct استفاده کنید

در برخی موارد، از قراردادها تنها برای دیپلوی چند قرارداد دیگر در یک تراکنش استفاده می‌شود، و تمام منطق آن‌ها فقط در سازنده (constructor) اجرا می‌شود.

اگر تمام عملکرد قرارداد در سازنده انجام می‌گیرد و بعد از آن دیگر نیازی به قرارداد نیست، می‌توان در پایان عملیات از دستور selfdestruct استفاده کرد تا قرارداد بلافاصله پس از اجرا حذف شود.

این کار باعث صرفه‌جویی در گس می‌شود، زیرا بایت کد قرارداد دیگر نیازی به نگهداری در بلاکچین ندارد و حذف آن از storage، بخشی از هزینه را به‌شکل گس برگشتی (gas refund) بازمی‌گرداند.

نکته مهم: با اینکه دستور selfdestruct قرار است در یکی از هاردفورک‌های آینده از بین برود، اما بر اساس EIP-6780، همچنان در داخل سازنده قراردادها پشتیبانی خواهد شد. بنابراین، استفاده از آن در زمان دیپلوی (نه پس از آن) همچنان راهکار معتبر و قابل استفاده‌ای برای بهینه سازی خواهد بود.

۵. درک مزایا و معایب استفاده از توابع internal در مقابل modifier‌ها

modifier ها، بایت کد مربوط به پیاده سازی خود را مستقیماً در محل استفاده تزریق می‌کنند، در حالی که توابع داخلی به محل پیاده سازی خود در بایت کد زمان اجرا پرش (jump) می‌کنند. این تفاوت باعث ایجاد برخی تبادل ها (trade-offs) میان این دو گزینه می‌شود.

استفاده چندباره از یک modifier باعث تکرار بایت کد و افزایش اندازه کد زمان اجرا (runtime code) می‌شود، اما در عوض هزینه گس زمان اجرا را کاهش می‌دهد، زیرا دیگر نیازی به پرش به آدرس تابع و بازگشت از آن وجود ندارد. بنابراین، اگر هزینه گس در زمان اجرا برای شما اهمیت بیشتری دارد، modifier ها گزینه مناسب‌تری هستند. اما اگر هزینه گس در زمان دیپلوی یا کاهش اندازه کد ایجاد‌شده (creation code) اولویت داشته باشد، استفاده از توابع داخلی انتخاب بهتری است.

با این حال، modifier ها یک محدودیت دارند: فقط می‌توانند در ابتدا یا انتهای یک تابع اجرا شوند. یعنی اجرای آن‌ها در وسط یک تابع به‌طور مستقیم ممکن نیست، مگر با استفاده از توابع داخلی—که در این صورت، هدف اصلی مادیفایر بودن را از دست می‌دهند. این موضوع باعث می‌شود مادیفایرها انعطاف‌پذیری کمتری نسبت به توابع داخلی داشته باشند. در مقابل، توابع internal را می‌توان در هر نقطه‌ای از تابع فراخوانی کرد.

در ادامه، مثالی از تفاوت مصرف گس بین استفاده از یک modifier و یک تابع داخلی آورده شده است:

عملیات دیپلوی restrictedAction1 restrictedAction2 restrictedAction3
Modifiers 195,435 28,367 28,377 28,411
Internal Functions 159,309 28,391 28,401 28,435

از جدول بالا می‌توان دریافت که قراردادی که از مادیفایرها (modifiers) استفاده می‌کند، در زمان دیپلوی بیش از ۳۵,۰۰۰ واحد گس بیشتر نسبت به قراردادی که از توابع داخلی (internal functions) استفاده می‌کند، مصرف می‌کند. علت این اختلاف، تکرار منطق onlyOwner در سه تابع مختلف است که منجر به افزایش اندازه بایت‌کد و در نتیجه مصرف گس بالاتر هنگام دیپلوی می‌شود.

در زمان اجرا (runtime)، مشخص است که هر تابعی که از مادیفایر استفاده می‌کند، حدود ۲۴ گس کمتر از معادل آن با تابع داخلی مصرف می‌کند.
این اختلاف به این دلیل است که در استفاده از modifier، نیازی به پرش به محل تابع و بازگشت از آن نیست.

۶. هنگام دیپلوی قراردادهای مشابهی که به‌ندرت فراخوانی می‌شوند، از کلون ها (Clones) یا متاپراکسی ها (Metaproxies) استفاده کنید

وقتی بخواهید چند قرارداد هوشمند مشابه را دیپلوی کنید، هزینه گس دیپلوی برای هرکدام می‌تواند زیاد باشد. برای کاهش این هزینه ها، می‌توانید از کلون های مینیمال (Minimal Clones) یا متاپراکسی ها (Metaproxies) استفاده کنید.

در این روش، قرارداد کلون در بایت کد خود آدرس قرارداد اصلی (implementation) را ذخیره می‌کند و از طریق آن به‌صورت پراکسی تعامل می‌کند.

استفاده از کلون ها باعث کاهش چشمگیر هزینه دیپلوی می‌شود، اما در عوض، هزینه تعامل در زمان اجرا بیشتر است. علت این موضوع، استفاده از دستور delegatecall در کلون ها است که نسبت به فراخوانی مستقیم گس بیشتری مصرف می‌کند.

بنابراین، کلون‌ها فقط زمانی گزینه مناسبی هستند که لازم نباشد زیاد با آن‌ها تعامل کنید. برای مثال، پروژه Gnosis Safe از همین روش استفاده می‌کند تا هزینه های دیپلوی را کاهش دهد.

برای یادگیری بیشتر درباره نحوه استفاده از کلون ها و متاپراکسی ها جهت کاهش هزینه گس هنگام دیپلوی قراردادهای هوشمند، به پست‌های وبلاگ ما مراجعه کنید:

۷. توابع مدیریتی را می‌توان payable تعریف کرد

می‌توان توابعی را که فقط توسط مدیر (admin) فراخوانی می‌شوند، به‌صورت payable تعریف کرد تا در مصرف گس صرفه‌جویی شود. دلیل این صرفه‌جویی آن است که کامپایلر دیگر بررسی msg.value را انجام نمی‌دهد (بررسی ضمنی که در توابع non-payable وجود دارد).

همچنین، این کار باعث می‌شود قرارداد نهایی کوچک‌تر و ارزان‌تر دیپلوی شود، چون تعداد دستورهای بایت کد (opcodes) در کد زمان ساخت (creation code) و زمان اجرا (runtime code) کاهش پیدا می‌کند.

۸. استفاده از خطاهای سفارشی (Custom Errors) معمولاً کوچک‌تر و کم‌هزینه‌تر از استفاده از require با پیام متنی است

خطاهای سفارشی در سالیدیتی از نظر مصرف گس ارزان‌تر از requireهایی هستند که همراه با پیام متنی (string) استفاده می‌شوند. دلیل این تفاوت در نحوه مدیریت خطاها توسط سالیدیتی است.

سالیدیتی برای خطاهای سفارشی، فقط ۴ بایت اول هش امضای خطا را ذخیره و بازمی‌گرداند. این یعنی هنگام revert، تنها ۴ بایت در حافظه ذخیره می‌شود.

در مقابل، اگر از require با پیام متنی استفاده شود، کامپایلر باید حداقل ۶۴ بایت (برای طول و محتوای پیام) را در حافظه ذخیره و بازگرداند، که باعث مصرف گس بسیار بیشتری می‌شود.

در اینجا یک مثال آورده شده است:

۹. به‌جای دیپلوی کردن فکتوری اختصاصی، از فکتوری های create2 موجود استفاده کنید

عنوان این بخش کاملاً گویاست: اگر به آدرس قابل پیش بینی (deterministic) نیاز دارید، معمولاً می‌توانید از یک فکتوری از قبل دیپلوی شده استفاده کنید، و نیازی به ساخت و دیپلوی فکتوری اختصاصی ندارید.

این کار باعث صرفه جویی در هزینه گس و کاهش پیچیدگی در معماری قرارداد خواهد شد.

فراخوانی بین قراردادها

۱. به‌جای آغاز انتقال از قرارداد مقصد، از انتقال با هوک (transfer hook) برای توکن ها استفاده کنید

فرض کنید قرارداد A طوری طراحی شده که توکن B را بپذیرد (برای مثال یک NFT یا توکن سازگار با استاندارد ERC1363).
رویکرد ساده و ابتدایی این فرآیند معمولاً به شکل زیر انجام می‌شود:

  1. msg.sender به قرارداد A اجازه می‌دهد که توکن B را خرج کند (approve)

  2. سپس msg.sender تابعی در قرارداد A را فراخوانی می‌کند تا انتقال انجام شود

  3. قرارداد A، توکن B را فراخوانی می‌کند تا انتقال را انجام دهد

  4. توکن B، انتقال را انجام داده و تابع onTokenReceived() را در قرارداد A فراخوانی می‌کند

  5. قرارداد A مقداری را از تابع onTokenReceived() بازمی‌گرداند

  6. توکن B اجرای کنترل را به قرارداد A بازمی‌گرداند

این روند بسیار ناکارآمد است. روش بهینه‌تر این است که msg.sender مستقیماً توکن B را فراخوانی کند تا عملیات انتقال انجام شود، که در نتیجه آن هوک tokenReceived در قرارداد A اجرا شود.

نکات مهم:

  • همه توکن های ERC1155 دارای هوک انتقال هستند

  • توابع safeTransfer و safeMint در استاندارد ERC721 نیز شامل هوک انتقال هستند

  • ERC1363 تابع transferAndCall را ارائه می‌دهد

  • ERC777 نیز دارای هوک انتقال است، اما این استاندارد منسوخ شده است. اگر نیاز به استفاده از توکن قابل تعویض (fungible) دارید، بهتر است از ERC1363 یا ERC1155 استفاده کنید

اگر نیاز دارید پارامترهایی را به قرارداد A ارسال کنید، می‌توانید از فیلد data استفاده کرده و آن را در قرارداد A تحلیل (parse) کنید.

۲. به‌جای استفاده از تابع deposit() برای دریافت اتر، از fallback یا receive استفاده کنید

مشابه با مورد قبلی، شما می‌توانید اتر را مستقیماً به قرارداد منتقل کنید و اجازه دهید قرارداد به این انتقال واکنش نشان دهد، بدون نیاز به فراخوانی تابع deposit(). البته این روش به معماری کلی قرارداد بستگی دارد و باید در طراحی کلی در نظر گرفته شود.

مثال: استفاده از receive در AAVE

تابع fallback نیز می‌تواند اتر دریافت کند، اما علاوه بر آن، داده های باینری (bytes) نیز می‌تواند دریافت کند. این داده ها را می‌توان با استفاده از abi.decode تجزیه کرد و به‌عنوان جایگزینی برای ارسال پارامتر به تابع deposit() از آن استفاده کرد. به این ترتیب، نیازی به تعریف توابع اختصاصی برای دریافت اتر همراه با پارامتر نخواهید داشت.

۳. هنگام انجام فراخوانی بین قراردادها، از تراکنش های دارای Access List مطابق ERC-2930 استفاده کنید تا اسلات های storage و آدرس قراردادها را pre-warm کنید

تراکنش های دارای Access List مطابق با استاندارد ERC-2930 به شما اجازه می‌دهند که از قبل هزینه گس مربوط به برخی دسترسی‌های storage و آدرس ها را پرداخت کنید و در عوض، تخفیف ۲۰۰ گس برای هر دسترسی دریافت کنید.

این روش باعث می‌شود دسترسی های بعدی به همان آدرس ها یا اسلات های storage، به‌جای پرداخت کامل، به‌صورت دسترسی گرم (warm access) انجام شود و گس کمتری مصرف کند.

اگر تراکنش شما شامل فراخوانی بین قراردادها (cross-contract call) است، تقریباً همیشه باید از Access List استفاده کنید.

به‌ویژه اگر با کلون ها (clones) یا پراکسی ها (proxies) کار می‌کنید—که تقریباً همیشه از delegatecall برای تعامل استفاده می‌کنند—استفاده از Access List می‌تواند به‌شکل محسوسی در هزینه گس صرفه‌جویی کند.

۴. در صورت امکان، داده‌های دریافتی از قراردادهای خارجی را کش (Cache) کنید (مانند کش کردن مقدار بازگشتی از اوراکل Chainlink)

به‌طور کلی، کش کردن داده ها هنگام اجرای یک تابع در صورتی که قرار است بیش از یک بار استفاده شوند، توصیه می‌شود. این کار باعث می‌شود از تکرار غیرضروری در حافظه و فراخوانی مجدد منابع خارجی گران جلوگیری شود.

مثال رایج:

فرض کنید در طول اجرای یک تابع، چند عملیات مختلف باید انجام شوند و در همه آن‌ها به قیمت ETH که از اوراکل Chainlink دریافت می‌شود نیاز دارید.

راه حل ناکارآمد:

  • چندین بار به قرارداد Chainlink اوراکل latestAnswer() یا مشابه آن را فراخوانی کنید

  • هر بار هزینه گس بالایی بابت فراخوانی خارجی پرداخت می‌کنید

راه حل بهینه:

  • یک بار مقدار قیمت را از اوراکل دریافت کنید

  • آن را در حافظه (memory) ذخیره کنید

  • سپس در تمامی محاسبات بعدی، از همین مقدار ذخیره شده استفاده کنید

۵. در قراردادهای شبیه به Router، قابلیت multicall را پیاده سازی کنید

قابلیت multicall یکی از ویژگی‌های رایج در قراردادهای مسیر‌یاب (router-like) مانند Uniswap Router یا Compound Bulker است.

اگر انتظار دارید کاربران شما چندین عملیات را به‌صورت متوالی انجام دهند، بهتر است آن‌ها را در یک تراکنش واحد و به‌صورت دسته‌ای (batch) انجام دهید. این کار را می‌توان با پیاده سازی یک تابع multicall در قرارداد انجام داد.

۶. با طراحی معماری متمرکز (Monolithic)، از فراخوانی بین قراردادها اجتناب کنید

فراخوانی بین قراردادها هزینه بر است، و بهترین راه برای صرفه جویی در گس این است که اصلاً از آن‌ها استفاده نکنید.

در طراحی قراردادها، اگرچه تقسیم بندی ماژولار (modular) ممکن است در برخی موارد باعث سازماندهی بهتر شود، اما در عمل تعامل بین چند قرارداد و ارسال داده بین آن‌ها از طریق call یا delegatecall می‌تواند هم مصرف گس را افزایش دهد و هم پیچیدگی سیستم را بالا ببرد.

الگوهای طراحی (Design Patterns)

۱. استفاده از multidelegatecall برای اجرای دسته‌ای تراکنش ها

multidelegatecall این امکان را فراهم می‌کند که msg.sender بتواند چند تابع را به‌صورت پشت‌سر‌هم در یک قرارداد فراخوانی کند، بدون اینکه متغیرهای محیطی (مانند msg.sender و msg.value) تغییر کنند.

نکته مهم: از آنجایی که msg.value در طول فراخوانی ها ثابت باقی می‌ماند، در هنگام پیاده سازی multidelegatecall در یک قرارداد، توسعه دهنده باید مراقب مسائل امنیتی یا منطقی احتمالی مرتبط با آن باشد.

مثالی از multi delegatecall، پیاده سازی Uniswap در زیر است:

۲. برای لیست مجاز (allowlists) و ایردراپ ها، به‌جای درخت مرکل (Merkle Tree) از امضاهای دیجیتال ECDSA استفاده کنید

درخت های مرکل برای اعتبارسنجی، نیاز به ارسال مقدار قابل توجهی داده در calldata دارند، و با بزرگ‌تر شدن اندازه اثبات مرکل (Merkle proof)، هزینه گس نیز افزایش می‌یابد. در مقابل، استفاده از امضاهای دیجیتال ECDSA معمولاً از نظر مصرف گس به صرفه تر از مرکل‌پروف است، به‌ویژه زمانی که تعداد زیادی کاربر یا داده در لیست مجاز باشند.

۳. برای ترکیب مرحله تایید (approval) و انتقال (transfer) در یک تراکنش، از ERC20Permit استفاده کنید

استاندارد ERC20Permit یک تابع اضافه ارائه می‌دهد که از طریق امضای دیجیتال صاحب توکن، به آدرس دیگری اجازه افزایش مجوز (approval) را می‌دهد.

در این روش، گیرنده مجوز می‌تواند:

  • تراکنش permit را ارسال کند

  • و بلافاصله پس از آن، transferFrom را اجرا کند

  • و همه این‌ها را در یک تراکنش واحد انجام دهد

مزیت مهم این مدل این است که کاربری که مجوز را صادر می‌کند (صاحب توکن)، هیچ گسی پرداخت نمی‌کند. همه هزینه ها توسط گیرنده مجوز پرداخت می‌شود و فرایند تأیید و انتقال به‌صورت batch انجام می‌گیرد.

این روش هم تجربه کاربری را ساده‌تر می‌کند و هم در هزینه ها صرفه‌جویی می‌شود.

۴. برای بازی ها یا اپلیکیشن هایی با تراکنش های زیاد و ارزش پایین، از Message Passing در لایه دوم (L2) استفاده کنید

پروژه Etherorcs یکی از اولین نمونه‌های موفق در استفاده از این الگو بود. می‌توانید برای الهام گرفتن، به مخزن گیت‌هاب آن‌ها مراجعه کنید. ایده اصلی این است که دارایی‌ها روی شبکه اصلی اتریوم (Ethereum L1) از طریق ارسال پیام (Message Passing) به زنجیره‌های جانبی یا لایه دوم مانند Polygon، Optimism یا Arbitrum منتقل می‌شوند. سپس، خود بازی یا اپلیکیشن در همان زنجیره لایه دوم اجرا می‌شود، جایی که هزینه تراکنش‌ها بسیار ارزان‌تر است.

این الگو برای اپلیکیشن‌هایی که حجم تراکنش بالا ولی ارزش دلاری پایین دارند (مانند بسیاری از بازی های بلاکچینی)، بسیار بهینه و مقیاس‌پذیر است.

۵. در صورت امکان، از State Channels استفاده کنید

State Channelها احتمالاً قدیمی‌ترین، اما همچنان قابل استفاده‌ترین، راه‌حل‌های مقیاس‌پذیری برای اتریوم هستند. برخلاف راه‌حل‌های لایه دوم (L2)، State Channelها ویژه یک اپلیکیشن خاص طراحی می‌شوند.

-start=”251″ data-end=”329″>در این مدل، کاربران تراکنش های خود را مستقیماً به شبکه ارسال نمی‌کنند. در عوض:

  • ابتدا دارایی‌های خود را به یک قرارداد هوشمند قفل می‌کنند

  • سپس، با رد و بدل کردن امضاهای دیجیتال الزام‌آور (binding signatures)، وضعیت اپلیکیشن را به‌صورت خصوصی میان خود تغییر می‌دهند

-start=”524″ data-end=”626″>در پایان تعامل، تنها نتیجه نهایی روی زنجیره ثبت می‌شود، که باعث صرفه‌جویی قابل توجهی در گس می‌شود.

اگر یکی از شرکت‌کنندگان رفتار نادرست داشته باشد، طرف صادق می‌تواند با استفاده از امضای دیجیتال طرف مقابل، قرارداد هوشمند را وادار کند تا دارایی‌های خود را آزاد کند.

۶. از سیستم واگذاری رأی (Voting Delegation) برای صرفه جویی در گس استفاده کنید

در آموزش مربوط به استاندارد ERC20Votes، این الگو به‌صورت دقیق‌تری شرح داده شده است. اما به‌طور خلاصه:

به جای آنکه هر دارنده توکن شخصاً رأی دهد، می‌توان این اختیار را به نماینده‌ای (delegate) واگذار کرد تا از طرف او رأی بدهد. این مدل باعث می‌شود:

  • تعداد کل تراکنش های رأی دهی کاهش یابد

  • در نتیجه، مصرف گس شبکه به‌طور قابل توجهی کمتر شود

این روش به‌ویژه در سازمان‌های غیرمتمرکز (DAO) یا پروتکل‌های حاکمیتی که رأی‌گیری‌های پرتعداد دارند، مقرون‌به‌صرفه و مقیاس‌پذیرتر است.

۷. استفاده از استاندارد ERC1155 برای توکن های غیرمثلی (NFT) ارزان‌تر از ERC721 است

a-start=”88″ data-end=”268″>در عمل، تابع balanceOf در استاندارد ERC721 به ندرت مورد استفاده قرار می‌گیرد، اما همین تابع هنگام mint و انتقال توکن باعث ایجاد سربار ذخیره سازی (storage overhead) می‌شود. در مقابل، استاندارد ERC1155 برای هر id، فقط یک balance را ذخیره می‌کند و همین balance برای تشخیص مالکیت</strong> توکن نیز استفاده می‌شود. در صورتی که حداکثر عرضه (max supply) هر id برابر ۱ باشد، آن توکن برای آن شناسه خاص به‌صورت NFT عمل خواهد کرد.

ode=”” data-is-only-node=””>در نتیجه، اگر نیاز به ساخت NFT با هزینه پایین‌تر دارید، ERC1155 انتخاب بهتری است.

۸. از یک توکن ERC1155 یا ERC6909 به‌جای چندین توکن ERC20 استفاده کنید

این هدف اولیه طراحی توکن ERC1155 بوده است. هر توکن مجزا (با شناسه متفاوت) مانند یک توکن ERC20 رفتار می‌کند، اما تنها یک قرارداد هوشمند نیاز به استقرار دارد.

ایراد این روش:

توکن هایی که به این صورت طراحی شده‌اند، با اکثر پروتکل های اولیه دیفای (DeFi swapping primitives) سازگار نیستند.

ERC1155 در تمامی متدهای انتقال خود از callback استفاده می‌کند. اگر این ویژگی مطلوب شما نیست، می‌توانید به‌جای آن از ERC6909 استفاده کنید.

۹. الگوی ارتقاء UUPS از نظر مصرف گس برای کاربران به‌صرفه‌تر از Transparent Upgradeable Proxy است

در الگوی Transparent Upgradeable Proxy، در هر تراکنش، باید آدرس msg.sender با آدرس مدیر (admin) مقایسه شود تا مشخص شود آیا فراخوانی باید به قرارداد منطق (Logic Contract) ارسال شود یا خیر. این مقایسه در تمام تراکنش ها انجام می‌شود و باعث مصرف گس اضافی می‌گردد.

در مقابل، الگوی UUPS (Universal Upgradeable Proxy Standard) فقط در زمان اجرای تابع ارتقاء (upgrade) این بررسی را انجام می‌دهد. بنابراین، در اکثر تعامل‌های معمول کاربران، گس کمتری مصرف می‌شود و عملکرد بهینه‌تری ارائه می‌دهد.

۱۰. استفاده از جایگزین های OpenZeppelin را در نظر بگیرید

کتابخانه OpenZeppelin یکی از پرکاربردترین و معتبرترین کتابخانه‌های قرارداد هوشمند است، اما گزینه‌های دیگری نیز وجود دارند که از نظر مصرف گس عملکرد بهتری دارند و توسط توسعه‌دهندگان حرفه‌ای توصیه شده‌اند.

دو نمونه از این جایگزین‌های بهینه‌تر عبارت‌اند از: Solmate و Solady

Solmate کتابخانه‌ای است که پیاده سازی‌های مختلفی از الگوهای رایج قرارداد هوشمند را با تمرکز بر کاهش مصرف گس ارائه می‌دهد. این کتابخانه به‌طور خاص برای توسعه دهندگانی طراحی شده که به دنبال قراردادهای ساده‌تر، سبک‌تر و بهینه‌تر هستند.

Solady کتابخانه‌ای بسیار بهینه است که استفاده از کد اسمبلی (inline assembly) را به‌شکل گسترده به‌کار می‌برد تا عملکرد قرارداد را از لحاظ مصرف گس بهبود دهد. این کتابخانه برای پروژه‌هایی مناسب است که نیاز به حداکثر بهره‌وری و کنترل دقیق بر منطق اجرایی دارند.

بهینه سازی های مربوط به Calldata

۱. استفاده از آدرس های Vanity (با احتیاط!)

استفاده از آدرس هایی که در ابتدای آن‌ها صفرهای متوالی وجود دارد (موسوم به Vanity Addresses) می‌تواند مصرف گس را در ارسال داده ها از طریق calldata کاهش دهد.

قرارداد Seaport متعلق به OpenSea دارای این آدرس است:

استفاده از چنین آدرسی در صورتی که به‌عنوان آرگومان به یک تابع ارسال شود (نه در صورتی که مستقیماً فراخوانی شود)، باعث کاهش مصرف گس می‌شود؛ چرا که صفرهای متوالی در ابتدای داده ها باعث فشرده‌تر شدن calldata می‌شوند.

نکته مهم: این موضوع فقط به قراردادهای هوشمند محدود نمی‌شود. اگر آدرس EOA (کاربر معمولی) نیز شامل صفرهای زیاد در ابتدای خود باشد، هنگام استفاده به‌عنوان آرگومان، مصرف گس کاهش پیدا می‌کند.

در گذشته، برخی از کاربران هنگام تولید آدرس های vanity برای کیف پول های معمولی (EOA) از الگوریتم‌های تصادفی ضعیف استفاده کردند، که منجر به هک شدن کلید خصوصی شد. با این حال، این نگرانی در مورد آدرس های قراردادهای هوشمند که از طریق CREATE2 با یک salt خاص ساخته می‌شوند وجود ندارد، زیرا این قراردادها هیچ کلید خصوصی ندارند.

۲. در صورت امکان از اعداد صحیح علامت دار (signed integers) در calldata استفاده نکنید

در زبان Solidity، اعداد علامت دار (مانند int256) با استفاده از نمایش مکمل دو (two’s complement) ذخیره می‌شوند. این روش باعث می‌شود که حتی اعداد منفی کوچک، در حافظه و در calldata دارای بیت های غیر صفر فراوان باشند.

مثال:

عدد -1 در مکمل دو به شکل زیر نمایش داده می‌شود:

این رشته پر از مقدار ff است و در نتیجه فضای calldata بیشتری اشغال می‌کند، که به‌طور مستقیم باعث افزایش مصرف گس می‌شود.

۳. استفاده از calldata معمولاً ارزان‌تر از memory است

در زبان Solidity، خواندن داده ها مستقیماً از calldata نسبت به خواندن از memory مصرف گس کمتری دارد. دلیل این موضوع آن است که:

  • calldata یک ناحیه فقط‌خواندنی است که مستقیماً به ورودی های تابع متصل است

  • در مقابل، memory نیاز به عملیات کپی داده دارد که شامل مصرف گس اضافه و زمان اجرا بیشتر است.

پس از calldata در ورودی توابع زمانی استفاده کنید که نیازی به تغییر داده ها در داخل تابع وجود ندارد. اگر نیاز به تغییر یا پردازش mutative روی داده‌ها دارید، باید آن‌ها را به memory منتقل کنید.

۴. فشرده سازی (Packing) داده های calldata را در نظر بگیرید، به‌ویژه در لایه دوم (L2)

در زبان Solidity، متغیرهای ذخیره سازی (storage) به‌صورت خودکار فشرده سازی (pack) می‌شوند، اما این فشرده سازی در مورد calldata انجام نمی‌شود، حتی اگر نوع داده ها مشابه باشند.

این نوع بهینه‌سازی یک تکنیک پیشرفته و پیچیده است که ممکن است باعث افزایش پیچیدگی کد شود. اما در شرایطی که یک تابع مقادیر زیادی از ورودی را از طریق calldata دریافت می‌کند، فشرده سازی دستی calldata می‌تواند موجب صرفه‌جویی چشمگیر در گس شود.

در ABI encoding استاندارد Solidity، داده ها به‌صورت جداگانه و بدون فشرده‌سازی ارسال می‌شوند. اما اگر داده ها را به‌صورت application-specific (مثلاً packed bytes) بسته بندی کنید، می‌توانید اندازه calldata را به شکل محسوسی کاهش دهید. این موضوع در شبکه های لایه دوم که هزینه داده های calldata بیشتر از محاسبات است، اهمیت ویژه‌ای پیدا می‌کند.

با ارتقاء Dencun، بیشتر شبکه های L2 دیگر calldata را به L1 ارسال نمی‌کنند. به جای آن، از blob‌ها استفاده می‌کنند که کم‌هزینه‌تر هستند. به همین دلیل، فشرده سازی calldata هنوز هم صرفه جویی ایجاد می‌کند، ولی میزان صرفه جویی به اندازه گذشته چشمگیر نیست.

ترفندهای اسمبلی (Assembly Tricks)

نباید فرض کنید که نوشتن کد اسمبلی به‌طور خودکار باعث افزایش بهره‌وری و کاهش مصرف گس می‌شود. در ادامه، مواردی آورده شده‌اند که استفاده از اسمبلی معمولاً مؤثرتر است، اما همواره باید نسخه غیر اسمبلی را نیز تست و مقایسه کنید.

۱. استفاده از اسمبلی برای Revert با پیام خطا

در برنامه نویسی سالیدیتی، معمولاً از دستور require یا revert برای متوقف‌کردن اجرای تابع همراه با پیام خطا استفاده می‌شود. این کار در اغلب موارد با استفاده از اسمبلی قابل بهینه سازی است تا مصرف گس کاهش یابد.

در اینجا یک مثال آورده شده است:

از مثال بالا می‌بینیم که با استفاده از اسمبلی برای نمایش همان پیام خطا، صرفه‌جویی بیش از ۳۰۰ گس نسبت به روش مرسوم در سالیدیتی حاصل شده است. این کاهش مصرف گس ناشی از هزینه های گسترش حافظه (memory expansion) و بررسی‌های اضافی تایپ است که کامپایلر سالیدیتی در پشت‌صحنه انجام می‌دهد.

۲. فراخوانی توابع از طریق اینترفیس موجب هزینه های گسترش حافظه می‌شود، بنابراین از اسمبلی برای استفاده مجدد از داده های موجود در حافظه استفاده کنید

زمانی که از یک قرارداد A قصد دارید تابعی را در قرارداد B فراخوانی کنید، رایج‌ترین روش استفاده از اینترفیس است. در این حالت، یک instance از قرارداد B با استفاده از آدرس آن ساخته می‌شود و سپس تابع مورد نظر فراخوانی می‌شود. این روش ساده و متداول است، اما کامپایلر Solidity هنگام کامپایل این نوع فراخوانی، داده هایی که باید به قرارداد B ارسال شوند را در یک موقعیت جدید از حافظه ذخیره می‌کند. این کار اغلب باعث گسترش حافظه (Memory Expansion) و مصرف گس اضافی می‌شود، حتی در مواردی که ضرورتی ندارد.

با استفاده از اسمبلی درون خطی (inline assembly)، می‌توانیم کد را به شکل بهینه تری بنویسیم و مقداری از گس را ذخیره کنیم؛ مثلاً با استفاده مجدد از موقعیت‌هایی از حافظه که دیگر نیازی به آن‌ها نداریم یا (در صورتی که داده ارسالی به قرارداد B کمتر از ۶۴ بایت باشد) استفاده از فضای scratch memory برای ساخت calldata.

مثال مقایسه بین دو حالت:

از مقایسه بالا مشاهده می‌شود که اجرای تابع set(uint256) در قرارداد Assembly حدود ۲۲۰ گس کمتر از حالت استفاده از اینترفیس در قرارداد Sol مصرف می‌کند.

نکته مهم: هنگام استفاده از اسمبلی برای فراخوانی خارجی (external calls)، حتماً بررسی کنید که آدرسی که به آن فراخوانی می‌کنید دارای کد باشد. این کار با استفاده از extcodesize(addr) انجام می‌شود. اگر خروجی صفر بود، به معنای این است که در آن آدرس هیچ قراردادی مستقر نشده و باید با revert اجرای تابع را متوقف کنید. اهمیت این بررسی در آن است که فراخوانی به یک آدرس بدون کد، همیشه true برمی‌گرداند، که می‌تواند منطق قرارداد شما را مختل کند.

۳. عملیات رایج ریاضی مانند min و max نسخه های بهینه تری از نظر مصرف گس دارند

نسخه غیربهینه:

نسخه بهینه:

کد بالا از بخش ریاضی (Math) کتابخانه Solady گرفته شده است. توابع ریاضی بهینه بیشتری نیز در این کتابخانه موجود هستند، بنابراین بررسی این منبع می‌تواند در یافتن عملیات‌های کم‌مصرف‌تر به شما کمک کند.

چرا نسخه دوم بهینه تر است؟

نسخه اول از عملگر سه‌تایی ?: استفاده می‌کند که در سطح opcode، شامل پرش های شرطی (conditional jumps) است. این نوع پرش ها در ماشین مجازی اتریوم (EVM) نسبتاً پرهزینه هستند. اما نسخه دوم از اسمبلی و عملیات ریاضی بدون شاخه (branchless) استفاده می‌کند، به‌طوری که با بهره‌گیری از xor، mul و gt، مقدار بزرگ‌تر را بدون هیچگونه پرش شرطی محاسبه می‌کند.

۴. به جای ISZERO(EQ()) از SUB یا XOR برای بررسی نابرابری استفاده کنید (در برخی شرایط بهینه‌تر است)

در استفاده از اسمبلی خطی (inline assembly) برای مقایسه برابری دو مقدار – مثلاً بررسی اینکه caller() همان owner است یا نه – معمولاً این الگو را می‌بینیم:

روش رایج:

اما روشی که در بسیاری از مواقع مصرف گس کمتری دارد، استفاده از sub یا xor است:

روش بهینه تر:

چرا این روش بهینه تر است؟

  • در روش اول، eq() یک مقایسه انجام می‌دهد و سپس iszero() روی آن اعمال می‌شود تا نابرابری را بررسی کند. این دو مرحله جداگانه هستند.

  • در روش دوم، sub() یا xor() تنها یک دستور است که اگر خروجی آن صفر نباشد (یعنی نابرابر باشند)، شرط برقرار می‌شود. در EVM این عملیات‌ها گاهی گس کمتری نسبت به eq + iszero مصرف می‌کنند.

این بهینه سازی به نسخه کامپایلر و زمینه استفاده کد بستگی دارد. همیشه باید قبل از اعمال چنین تغییراتی، تست بنچمارک گس بگیرید و عملکرد را در شرایط واقعی بررسی کنید.

۵. استفاده از اسمبلی خطی (inline assembly) برای بررسی address(0)

نوشتن قراردادها با استفاده از اسمبلی خطی معمولاً بهینه ترین روش از نظر مصرف گس است. این روش اجازه می‌دهد حافظه را مستقیماً مدیریت کرده و با استفاده از تعداد کمتری از اپکدها، همان منطق را اجرا کنیم — بدون نیاز به تکیه بر کد تولید شده توسط کامپایلر سالیدیتی.

یکی از رایج‌ترین سناریوهایی که می‌توان از اسمبلی برای بهینه سازی استفاده کرد، پیاده سازی مکانیزم احراز هویت (authentication) است. برای مثال، بررسی اینکه آدرس ارسال‌شده address(0) نباشد.

در زیر یک مثال آورده شده است:

۶. استفاده از selfbalance به‌جای address(this).balance (در برخی شرایط بهینه تر است)

در سالیدیتی، زمانی که می‌خواهید موجودی (Balance) قرارداد جاری را بخوانید، معمولاً از دستور زیر استفاده می‌کنید:

اما در بعضی از شرایط، استفاده از دستور selfbalance() از زبان میانی Yul می‌تواند کارآمدتر و گس کمتری مصرف کند.

چون address(this).balance از طریق کد سطح بالا کامپایل شده و شامل دستورات و بررسی‌های اضافی است. اما selfbalance() مستقیماً مقدار موجودی قرارداد جاری را از درون ماشین مجازی اتریوم (EVM) بازیابی می‌کند. این دستور مخصوص قرارداد جاری است (همان this) و هیچ گاه قابل استفاده برای آدرس های دیگر نیست.

نکته مهم: در برخی از نسخه های کامپایلر سالیدیتی، اگر از address(this).balance استفاده کنید، خود کامپایلر به طور خودکار آن را به selfbalance تبدیل می‌کند. بنابراین برای اطمینان از بهینه ترین حالت، باید هر دو روش را تست کنید و مصرف گس را مقایسه نمایید.

۷. استفاده از اسمبلی برای عملیات روی داده هایی با اندازه حداکثر ۹۶ بایت: هش کردن و ثبت داده های بدون ایندکس در لاگ ها (events)

سالیدیتی معمولاً هنگام نوشتن در حافظه، حافظه را گسترش می‌دهد که این کار همیشه بهینه نیست. ما می‌توانیم با استفاده از اسمبلی (inline assembly)، عملیات حافظه‌ای روی داده هایی با اندازه ۹۶ بایت یا کمتر را بهینه کنیم.

سالیدیتی ۶۴ بایت اول حافظه (از 0x00 تا 0x40) را به‌عنوان فضای موقتی یا scratch space رزرو می‌کند که توسعه دهنده ها می‌توانند آزادانه از آن استفاده کنند. این فضا برای مقاصد موقتی طراحی شده و مطمئن هستیم که به‌صورت ناخواسته بازنویسی یا خوانده نمی‌شود.

۳۲ بایت بعدی (از 0x40 تا 0x60) مخصوص نگهداری free memory pointer است. این مکان توسط کامپایلر برای مشخص‌کردن نقطه شروع حافظه آزاد استفاده می‌شود، یعنی آدرس بعدی که می‌توان داده جدیدی در حافظه ذخیره کرد.

۳۲ بایت بعد از آن (از 0x60 تا 0x80) به نام zero slot شناخته می‌شود. وقتی متغیرهای dynamic memory مثل bytes memory, string memory یا آرایه هایی از نوع دلخواه هنوز مقداردهی نشده‌اند، به این محل اشاره می‌کنند و انتظار می‌رود که مقدارشان صفر باشد.

نکته مهم: ساختارهای struct که در حافظه قرار دارند، حتی اگر شامل داده های dynamic هم باشند، وقتی مقداردهی نشده‌اند به zero slot اشاره نمی‌کنند. اما داده های dynamic مثل string memory یا bytes memory، حتی اگر داخل یک struct باشند، اگر مقداردهی نشده باشند، به 0x60 (zero slot) اشاره خواهند کرد.

بنابراین، اگر بتوانیم از scratch space برای انجام پردازش حافظه‌ای استفاده کنیم، بدون اینکه حافظه را گسترش دهیم، مصرف گس کاهش پیدا می‌کند. همچنین می‌توانیم از فضای مربوط به free memory pointer هم استفاده کنیم، به شرط اینکه قبل از پایان بلوک اسمبلی، آن را به مقدار اصلی‌اش برگردانیم.

بیایید چند مثال ببینیم.

  • مثال: استفاده از اسمبلی برای لاگ کردن تا ۹۶ بایت داده بدون ایندکس در یک event

مثال بالا نشان می‌دهد که چطور می‌توانیم با استفاده از حافظه (memory) برای ذخیره سازی داده هایی که قصد داریم در رویداد BlockData ثبت کنیم، تقریباً ۲٬۰۰۰ گس صرفه‌جویی داشته باشیم.

در این مثال، نیازی به به‌روزرسانی pointer حافظه آزاد (free memory pointer) نیست، زیرا اجرای تابع بلافاصله پس از ثبت event به پایان می‌رسد و ما هرگز دوباره به کد سالیدیتی بازنمی‌گردیم.

اکنون، بیایید یک مثال دیگر بررسی کنیم؛ جایی که نیاز داریم free memory pointer را به‌روزرسانی کنیم:

  • استفاده از اسمبلی برای هش کردن داده‌هایی با اندازه حداکثر ۹۶ بایت

در مثال بالا، مشابه با نمونه اول، ما از اسمبلی برای ذخیره سازی مقادیر در ۹۶ بایت اول حافظه استفاده کرده‌ایم که بیش از ۱٬۰۰۰ گس صرفه‌جویی به همراه دارد. همچنین توجه داشته باشید که در این مورد خاص، چون دوباره به کد سالیدیتی بازمی‌گردیم، در ابتدای بلوک اسمبلی، آدرس حافظه آزاد (free memory pointer) را کش (cache) کرده و در انتهای آن به‌روزرسانی می‌کنیم.

این کار برای آن است که سازگاری با فرضیات کامپایلر سالیدیتی درباره وضعیت حافظه حفظ شود و در ادامه اجرای برنامه مشکلی پیش نیاید.

۸. استفاده از اسمبلی برای استفاده مجدد از فضای حافظه هنگام انجام بیش از یک فراخوانی خارجی

یکی از عملیات‌هایی که باعث افزایش حافظه توسط کامپایلر سالیدیتی می‌شود، انجام فراخوانی‌های خارجی (external calls) است. هنگام فراخوانی یک تابع از یک قرارداد دیگر، کامپایلر باید امضای تابع (function signature) مورد نظر و آرگومان‌های مربوط به آن را در حافظه رمزگذاری (encode) کند. همان‌طور که می‌دانیم، سالیدیتی حافظه را پاک نمی‌کند یا مجدداً از آن استفاده نمی‌کند؛ بنابراین برای هر فراخوانی جدید، داده ها در آدرس جدیدی از حافظه ذخیره می‌شوند که منجر به گسترش فضای حافظه (memory expansion) و افزایش مصرف گس می‌شود.

با استفاده از اسمبلی داخلی (inline assembly)، ما می‌توانیم این مشکل را حل کنیم. اگر آرگومان های تابع مورد نظر، کمتر از ۹۶ بایت باشند، می‌توانیم از فضای حافظه موقت (scratch space) یا آدرس حافظه آزاد (free memory pointer) برای ذخیره این داده ها استفاده کنیم.

علاوه بر این، اگر بیش از یک فراخوانی خارجی انجام می‌دهیم، می‌توانیم از همان فضای حافظه قبلی برای ذخیره آرگومان‌های جدید استفاده کنیم، بدون آنکه حافظه را مجدداً افزایش دهیم. در چنین سناریویی، سالیدیتی به‌طور پیش‌فرض به اندازه طول داده های بازگشتی، حافظه را افزایش می‌دهد؛ زیرا این داده ها در حافظه ذخیره می‌شوند.

اما اگر داده بازگشتی کمتر از ۹۶ بایت باشد، می‌توانیم آن را نیز در scratch space ذخیره کنیم تا از افزایش حافظه و مصرف گس بیشتر جلوگیری شود.

در ادامه، مثالی از این تکنیک آورده شده است:

ما با استفاده از scratch space برای ذخیره selector تابع و آرگومان های آن، و همچنین با استفاده‌ مجدد از همان فضای حافظه برای دومین فراخوانی و ذخیره سازی داده های بازگشتی در zero slot (آدرس حافظه 0x60) حدود ۲۰۰۰ گس صرفه جویی می‌کنیم. به این ترتیب، هیچ افزایش حافظه ای اتفاق نمی‌افتد.

اگر آرگومان های تابع خارجی که می‌خواهید فراخوانی کنید بیش از ۶۴ بایت باشند و فقط یک بار قصد فراخوانی آن را دارید، نوشتن آن در اسمبلی صرفه جویی قابل توجهی در گس ایجاد نخواهد کرد. اما اگر بیش از یک بار قصد فراخوانی دارید، همچنان می‌توانید با استفاده‌ مجدد از فضای حافظه قبلی برای هر فراخوانی، در مصرف گس صرفه‌جویی کنید.

نکته مهم: همیشه به یاد داشته باشید که اگر آدرس حافظه‌ای که free memory pointer به آن اشاره می‌کند، قبلاً استفاده شده باشد، حتماً آن را به آدرس جدیدی به‌روزرسانی کنید. در غیر این صورت، ممکن است سالیدیتی داده های ذخیره شده را بازنویسی کند یا به اشتباه از آن‌ها استفاده کند.

همچنین مراقب باشید که اسلات صفر حافظه (0x60) را بازنویسی نکنید اگر در call stack شما متغیرهای داینامیک تعریف‌نشده (مانند bytes memory یا string memory) وجود داشته باشد. یک راه‌حل جایگزین این است که مقداردهی صریح برای آن متغیرهای داینامیک انجام دهید، یا پس از استفاده، مجدداً مقدار اسلات صفر را به 0x00 بازگردانید قبل از خروج از بلاک اسمبلی.

۹. استفاده از اسمبلی برای استفاده مجدد از فضای حافظه هنگام ایجاد چند قرارداد

سالیدیتی عملیات ایجاد قرارداد (Contract Creation) را مشابه فراخوانی‌های خارجی (External Calls) در نظر می‌گیرد که ۳۲ بایت داده بازگشتی دارند؛ یعنی آدرس قرارداد ایجادشده را برمی‌گردانند، یا اگر ساخت قرارداد ناموفق باشد، مقدار address(0) را.

با توجه به بخش بهینه سازی گس در سالیدیتی در فراخوانی‌های خارجی، می‌توان به‌راحتی دریافت که یکی از روش‌های بهینه سازی این است که آدرس بازگشتی قرارداد جدید را در حافظه موقتی (scratch space) ذخیره کنیم تا از گسترش حافظه (memory expansion) جلوگیری شود.

در ادامه، نمونه‌ای مشابه از این تکنیک را مشاهده می‌کنید:

ما با استفاده از اسمبلی درون‌خطی (inline assembly)، تقریباً ۱۰۰۰ واحد گس صرفه‌جویی کردیم.

نکته: در شرایطی که دو قراردادی که قرار است مستقر شوند (deploy شوند) متفاوت باشند، باید کد ایجاد (creation code) قرارداد دوم را به‌صورت دستی با دستور mstore در اسمبلی در حافظه قرار دهید، نه اینکه آن را به یک متغیر در سالیدیتی اختصاص دهید؛ چرا که این کار باعث گسترش غیرضروری حافظه (memory expansion) می‌شود و مزیت صرفه‌جویی در گس را از بین می‌برد.

۱۰. تست زوج یا فرد بودن یک عدد با بررسی بیت آخر به‌جای استفاده از عملگر باقیمانده (modulo)

روش رایج برای بررسی اینکه یک عدد زوج است یا فرد، استفاده از عبارت x % 2 == 0 است؛ که در آن، x عدد مورد نظر است. اما می‌توانید به‌جای آن، از بررسی x & uint256(1) == 0 استفاده کنید. در اینجا x به‌عنوان یک عدد صحیح بدون علامت ۲۵۶ بیتی (uint256) در نظر گرفته شده است. عملگر بیت به بیت AND (&) از نظر مصرف گس ارزان‌تر از عملگر باقیمانده (%) است. در سیستم دودویی، بیت سمت راست‌ترین (بیت صفرم) نماینده مقدار «۱» است، و سایر بیت‌ها ضرایب ۲ هستند (یعنی اعداد زوج). بنابراین اگر بیت آخر یک عدد ۰ باشد، عدد زوج است؛ و اگر ۱ باشد، عدد فرد است. به‌عبارت دیگر، افزودن عدد ۱ به یک عدد زوج، آن را فرد می‌کند.

بهینه سازی گس در کامپایلر سالیدیتی

ترفندهای زیر برای بهبود مصرف گس در کامپایلر سالیدیتی شناخته شده‌اند. با این حال، انتظار می‌رود که در طول زمان کامپایلر بهبود یابد و این ترفندها کم‌اثر یا حتی در برخی مواقع نتیجه‌ معکوس داشته باشند.

بنابراین این ترفندها را بدون بررسی و بنچمارک استفاده نکنید.

برخی از این بهینه سازی ها در حالت کامپایل با فلگ --via-ir به‌طور خودکار انجام می‌شوند و ممکن است استفاده دستی از آن‌ها در این حالت کارایی را کاهش دهد.

همیشه بنچمارک بگیرید.

۱. از strict inequalities به‌جای non-strict استفاده کنید، اما هر دو را تست کنید

بهتر است از عملگرهای مقایسه ای بدون تساوی (مثل < یا >) به‌جای عملگرهای مقایسه ای همراه با تساوی (مثل <= یا >=) استفاده کنید. دلیل این توصیه آن است که ماشین مجازی اتریوم (EVM) مستقیماً از عملگرهای «کوچکتر یا مساوی» و «بزرگتر یا مساوی» پشتیبانی نمی‌کند. در نتیجه، کامپایلر گاهی مجبور می‌شود این عبارات را به شکل غیرمستقیم (مانند !(a < b)) تبدیل کند که می‌تواند منجر به مصرف بیشتر گس شود.

با این حال، تأکید می‌شود که این موضوع بسته به زمینه کد متفاوت است، پس حتماً هر دو حالت را تست و مقایسه کنید.

۲. عبارات require را که شامل چند شرط منطقی هستند، به چند خط مجزا تقسیم کنید

وقتی عبارت‌های require را تفکیک می‌کنیم، در واقع اعلام می‌کنیم که هر شرط باید به‌صورت جداگانه برقرار باشد تا تابع به اجرای خود ادامه دهد.

اگر شرط اول مقدار false داشته باشد، تابع بلافاصله revert (بازگردانده) می‌شود و عبارت‌های require بعدی دیگر بررسی نمی‌شوند. این کار باعث صرفه‌جویی در گس می‌شود، زیرا از ارزیابی شرط‌های بعدی جلوگیری می‌کند.

این بهینه سازی ساده در برخی شرایط می‌تواند موجب کاهش مصرف گس شود، به‌ویژه زمانی که احتمال برقرار نبودن یکی از شروط زیاد است.

۳. عبارت های revert را نیز تفکیک کنید

مشابه با تفکیک عبارت‌های require، معمولاً با حذف عملگرهای بولی از داخل شرط‌های if می‌توانید مقداری در مصرف گس صرفه‌جویی کنید.

۴. همیشه از بازگشت های دارای نام (Named Returns) استفاده کنید

کامپایلر سالیدیتی در صورتی که متغیر بازگشتی در امضای تابع به‌طور صریح تعریف شده باشد (یعنی از named return استفاده شده باشد)، کد بهینه تری تولید می‌کند. در عمل، موارد استثنا بسیار کمی برای این قاعده وجود دارد. بنابراین، اگر در جایی با بازگشت بدون نام (anonymous return) مواجه شدید، توصیه می‌شود نسخه دارای نام را نیز امتحان کنید تا ببینید کدام یک در نهایت مصرف گس کمتری دارد.

مثال زیر بازگشت بدون نام را نشان می‌دهد:

و این هم نسخه‌ای با بازگشت دارای نام که بهینه تر است:

در نسخه دوم، متغیر z به‌عنوان متغیر خروجی تعریف شده و درون بدنه تابع مقداردهی می‌شود. این روش به‌طور معمول منجر به تولید bytecode کوچکتر و مصرف گس کمتر در زمان اجرا می‌شود.

۵. شرط های if-else که دارای “نفی” (negation) هستند را معکوس بنویسید

این همان مثالی است که در ابتدای مقاله به آن اشاره شده بود. در قطعه کد زیر، نسخه دوم با حذف عملگر !، از یک نفی غیرضروری جلوگیری می‌کند. از نظر تئوری، استفاده از عملگر ! می‌تواند منجر به افزایش اندک در هزینه محاسباتی شود. اما همان‌طور که در ابتدای مقاله گفته شد، همیشه باید هر دو روش را بنچمارک (مقایسه عملکردی) کنید؛ چرا که کامپایلر سالیدیتی گاهی خودش این بهینه سازی ها را به‌طور خودکار انجام می‌دهد.

۶. از ++i به‌جای i++ برای افزایش مقدار استفاده کنید

دلیل این توصیه به نحوه‌ی ارزیابی ++i و i++ توسط کامپایلر سالیدیتی برمی‌گردد:

  • در حالت i++، ابتدا مقدار اولیه i در استک (stack) ذخیره می‌شود، سپس مقدار i افزایش می‌یابد. به‌عبارت دیگر، دو مقدار روی استک قرار می‌گیرد: یکی مقدار قبل از افزایش، و دیگری مقدار جدید.

  • اما در حالت ++i، ابتدا مقدار i افزایش می‌یابد و سپس همان مقدار جدید به‌عنوان خروجی استفاده می‌شود. بنابراین تنها یک مقدار روی استک ذخیره می‌شود.

این تفاوت ساده باعث می‌شود که استفاده از ++i در حلقه‌ها و سایر بخش‌های تکراری، از نظر مصرف گس کارآمدتر باشد، چون عملیات استک سبک‌تر انجام می‌شود. در زبان‌هایی مثل C++ این تفاوت ممکن است کم‌اهمیت باشد، اما در محیط‌هایی مانند EVM که محاسبه گس دقیق و مهم است، حتی همین تفاوت کوچک نیز می‌تواند تأثیرگذار باشد.

۷. در صورت مناسب بودن از unchecked math استفاده کنید

در سالیدیتی، محاسبات عددی به‌صورت پیش‌فرض بررسی‌شده (checked) هستند؛ یعنی اگر نتیجه عملیات ریاضی از محدوده‌ نوع داده‌ (مثلاً uint256) خارج شود، تراکنش ریورت خواهد شد. این ویژگی جلوی سرریز (overflow) یا زیرریز (underflow) را می‌گیرد.

اما در بعضی سناریوها، وقوع سرریز عملاً غیرممکن است. در این موارد می‌توان با استفاده از بلاک unchecked، بررسی را غیرفعال کرد و مصرف گس را کاهش داد.

موارد رایج برای استفاده از unchecked:

  • حلقه های for که سقف مشخص و محدودی دارند.

  • محاسباتی که ورودی‌هاشان قبلاً اعتبارسنجی شده‌اند و در بازه مناسبی قرار دارند.

  • متغیرهایی مثل شمارنده (counter) که از یک مقدار کم شروع می‌کنند و هر بار فقط ۱ یا عدد کمی به آن‌ها اضافه می‌شود.

هر زمان در کد عملیاتی ریاضی دیدید، از خودتان بپرسید:

  • آیا احتمال دارد این عملیات باعث overflow یا underflow شود؟

  • آیا نوع متغیر (مثلاً uint8 یا uint256) به‌گونه‌ای است که رسیدن به مرز آن غیرواقعی است؟

  • آیا محدودیت‌هایی در منطق کد (مثل شرط‌ها یا ورودی‌ها) از این اتفاق جلوگیری می‌کنند؟

اگر پاسخ مثبت بود، استفاده از unchecked در آن قسمت می‌تواند به کاهش هزینه گس کمک کند.

۸. حلقه های for را به‌صورت بهینه از نظر مصرف گس بنویسید

نکته: از نسخه ۰.۸.۲۲ سالیدیتی به بعد، این بهینه سازی به‌صورت خودکار توسط کامپایلر انجام می‌شود و نیازی نیست که آن را دستی انجام دهید.

در صورتی که دو ترفند قبلی را ترکیب کنیم، یک حلقه for بهینه شده از نظر مصرف گس به شکل زیر خواهد بود:

دو تفاوت اصلی این حلقه با حلقه معمولی:

  1. استفاده از ++i به‌جای i++ (که در بخش قبلی توضیح داده شد)

  2. قرار دادن افزایش شمارنده در بلاک unchecked، چون متغیر limit به‌صورت طبیعی جلوی سرریز را می‌گیرد.

این ساختار باعث صرفه جویی در مصرف گس می‌شود.

۹. حلقه های do-while ارزان‌تر از حلقه های for هستند

اگر بخواهید بهینه سازی مصرف گس را تا حد ممکن پیش ببرید، حتی به قیمت استفاده از کدی که کمی غیرمعمول به نظر برسد، حلقه های do-while در سالیدیتی از حلقه های for به‌صرفه‌تر هستند؛ حتی در حالتی که برای جلوگیری از اجرای حلقه، یک شرط if قبل از آن اضافه کنید.

به‌عبارت دیگر، استفاده از ساختار do { ... } while(); معمولاً گس کمتری نسبت به حلقه های for مصرف می‌کند، و این می‌تواند در کدهایی که بارها تکرار می‌شوند یا در محیط‌هایی با محدودیت منابع (مثل بلاک‌چین) تفاوت قابل توجهی ایجاد کند.

۱۰. از تبدیل غیرضروری متغیرها پرهیز کنید؛ متغیرهایی که از uint256 کوچکتر هستند (مثل bool و address) در صورتی که بسته بندی (packing) نشده باشند، کارایی کمتری دارند

استفاده از uint256 برای متغیرهای عدد صحیح معمولاً بهینه‌تر است، مگر اینکه واقعاً به نوع کوچکتری نیاز داشته باشید. دلیل این موضوع این است که ماشین مجازی اتریوم (EVM) در زمان اجرای عملیات، همه مقادیر عددی کوچکتر از uint256 را به uint256 تبدیل می‌کند. این تبدیل باعث مصرف گس اضافی می‌شود.

بنابراین، اگر متغیرهای uint8، bool یا address را به‌صورت مجزا و بدون بسته‌بندی در ساختارهایی مانند struct یا آرایه‌ها استفاده کنید، کد شما گس بیشتری مصرف خواهد کرد. برای بهینه سازی، یا از uint256 استفاده کنید، یا اگر حتماً متغیرهای کوچکتر نیاز دارید، آن‌ها را در کنار هم طوری قرار دهید که بسته بندی شوند و حافظه به صورت فشرده تخصیص یابد.

۱۱. استفاده از میان‌بر منطقی (Short-Circuiting) در عبارات بولی

در زبان سالیدیتی، زمانی که یک عبارت بولی مانند || (یا منطقی) یا && (و منطقی) ارزیابی می‌شود، سیستم از ویژگی‌ به نام میان‌بر منطقی (Short-circuiting) استفاده می‌کند. در این روش، شرط دوم تنها در صورتی بررسی می‌شود که شرط اول نتیجه مورد نظر را ندهد. این ویژگی می‌تواند باعث کاهش مصرف گس شود.

به عنوان مثال، در عبارت require(msg.sender == owner || msg.sender == manager)، اگر شرط اول یعنی msg.sender == owner درست باشد، شرط دوم اصلاً بررسی نمی‌شود، زیرا نتیجه کل عبارت از همان ابتدا مشخص است. در شرایطی که انتظار دارید شرط اول معمولاً برقرار باشد، بهتر است آن را در ابتدای عبارت قرار دهید. این کار باعث می‌شود در اغلب فراخوانی‌های موفق، نیازی به بررسی شرط دوم نباشد و در نتیجه، مصرف گس کاهش یابد.

از سوی دیگر، اگر از عبارت require(msg.sender == owner && msg.sender == manager) استفاده می‌کنید و می‌دانید که احتمال نادرست بودن شرط اول بالاست، جای‌گذاری آن در ابتدای شرط می‌تواند مؤثر باشد. در این حالت، به دلیل نادرستی شرط اول، شرط دوم بررسی نمی‌شود و همین موضوع در فراخوانی‌هایی که به شکست منجر می‌شوند، موجب صرفه‌جویی در مصرف گس خواهد شد.

بطور کلی، استفاده از میان‌بر منطقی نه‌تنها از نظر منطقی مفید است، بلکه از نظر اقتصادی نیز توصیه می‌شود. پیشنهاد می‌شود که همیشه عبارت ارزان‌تر را در ابتدای شرط قرار دهید، چون اگر همان شرط کافی باشد، از اجرای عبارت پرهزینه جلوگیری خواهد شد. همچنین اگر می‌دانید یک شرط احتمال بیشتری برای تأثیرگذاری دارد، آن را زودتر بنویسید تا عملکرد بهتری داشته باشید.

۱۲. متغیرها را فقط در صورت ضرورت public تعریف کنید

در زبان Solidity، وقتی یک متغیر ذخیره سازی را به صورت public تعریف می‌کنید، کامپایلر به‌طور خودکار یک تابع خواندن عمومی (با همان نام متغیر) ایجاد می‌کند. این تابع عمومی باعث بزرگ‌تر شدن جدول پرش (Jump Table) و افزایش بایت‌کد قرارداد می‌شود، چون باید کد اضافه‌ای برای خواندن آن متغیر تولید شود. در نتیجه، کل حجم قرارداد افزایش پیدا می‌کند که هم هزینه استقرار را بالا می‌برد و هم گس مصرفی فراخوانی را بیشتر می‌کند.

به همین دلیل، بهتر است تنها در صورتی که واقعاً نیاز دارید تا متغیر از بیرون قرارداد توسط سایر قراردادها یا کاربران خوانده شود، آن را public تعریف کنید.

همچنین به یاد داشته باشید که تعریف متغیر به صورت private به این معنی نیست که مقدار آن واقعاً پنهان می‌ماند. همه داده های ذخیره شده در بلاکچین شفاف هستند، و می‌توان مقدار این متغیرها را به‌راحتی با ابزارهایی مانند web3.js استخراج کرد.

این نکته به‌ویژه در مورد constantها (ثابت‌ها) اهمیت بیشتری دارد، چون این مقادیر معمولاً توسط انسان‌ها مورد استفاده قرار می‌گیرند (مثلاً برای مطالعه قرارداد)، نه توسط قراردادهای دیگر. در چنین مواردی، لزومی ندارد آن‌ها را public کنید، مگر این‌که نیاز کاربردی خاصی وجود داشته باشد.

۱۳. برای بهینه سازی بهتر، از مقادیر بسیار بزرگ برای Optimizer استفاده کنید

کامپایلر سالیدیتی هنگام بهینه سازی، روی دو جنبه اصلی تمرکز دارد:

  1. هزینه استقرار (Deployment) قرارداد هوشمند

  2. هزینه اجرای توابع (Runtime Execution)

پارامتری به نام runs در تنظیمات کامپایلر وجود دارد که مشخص می‌کند کامپایلر فرض کند یک تابع چند بار در طول عمر قرارداد اجرا خواهد شد. این عدد تعیین‌کننده نحوه‌ی بهینه سازی است و در این‌جا یک موازنه وجود دارد:

  • مقادیر پایین برای runs (مثل ۲۰ یا ۵۰) بهینه سازی را بر کاهش حجم کد هنگام استقرار متمرکز می‌کنند. این باعث می‌شود کد اولیه قرارداد کوچک‌تر باشد و هزینه گس استقرار کاهش پیدا کند، ولی کد زمان اجرا ممکن است بهینه نباشد و در درازمدت گس بیشتری مصرف کند.

  • مقادیر بالا برای runs (مثل ۵۰۰۰ یا حتی ۲۰۰۰۰) تمرکز را بر بهینه سازی زمان اجرای توابع می‌گذارند. در این حالت، کد اولیه قرارداد بزرگ‌تر می‌شود و ممکن است استقرار کمی گران‌تر باشد، ولی در عوض، توابع در زمان اجرا گس بسیار کمتری مصرف می‌کنند.

با توجه به این موازنه، اگر قرارداد شما قرار است بارها و به‌صورت مکرر توسط کاربران یا دیگر قراردادها مورد استفاده قرار گیرد، توصیه می‌شود که مقدار runs را تا حد ممکن بزرگ انتخاب کنید. این کار شاید در ابتدا هزینه استقرار را کمی بالا ببرد، اما در بلندمدت باعث صرفه‌جویی چشمگیر در مصرف گس خواهد شد.

۱۴. برای توابع پرتکرار، از نام‌هایی با چینش بهینه استفاده کنید

در ماشین مجازی اتریوم (EVM)، فراخوانی توابع از طریق یک jump table انجام می‌شود. در این جدول، توابع براساس ترتیب هگزادسیمال selector‌هایشان مرتب می‌شوند؛ یعنی اگر دو تابع با selectorهایی مثل 0x000071c3 و 0xa0712d68 داشته باشیم، تابعی که selector آن عدد هگزادسیمال کوچک‌تری دارد (در اینجا 0x000071c3) زودتر بررسی می‌شود.

بنابراین، اگر تابعی در قرارداد شما بسیار پرکاربرد است، بهتر است برای آن نامی انتخاب کنید که selector آن در ترتیب هگزادسیمال جلوتر باشد. این کار باعث می‌شود که آن تابع در جدول پرش سریع‌تر بررسی شود و مقدار کمی گس صرفه‌جویی شود. البته باید توجه داشت که اگر بیش از چهار تابع در قرارداد وجود داشته باشد، EVM به جای جستجوی خطی از جستجوی دودویی (binary search) برای انتخاب تابع استفاده می‌کند، ولی حتی در این حالت هم داشتن selector کوچکتر می‌تواند در مصرف گس مؤثر باشد.

علاوه بر این، اگر selector تابع دارای صفرهای پیشوند باشد (leading zeros)، هزینه calldata نیز کاهش می‌یابد. چرا که در کال‌دیتا، بایت‌های صفر فقط ۴ گس مصرف می‌کنند ولی بایت‌های غیر صفر ۱۶ گس.

به مثال زیر دقت کنید:

همچنین برای پیدا کردن چنین نام‌هایی، ابزار Solidity Zero Finder معرفی شده که با زبان Rust نوشته شده و به شما کمک می‌کند نام‌هایی با selectorهای دارای صفر پیشوند را راحت‌تر پیدا کنید. این ابزار در GitHub در دسترس است.

۱۵. استفاده از شیفت بیت به‌جای ضرب یا تقسیم در توان‌های عدد ۲ مقرون‌به‌صرفه‌تر است

در زبان سالیدیتی، زمانی که می‌خواهید عددی را در توان‌هایی از عدد ۲ ضرب یا تقسیم کنید (مانند ۲، ۴، ۸، ۱۶ و…)، استفاده از عملیات شیفت بیت (bit shifting) به‌جای عملگرهای * و / باعث صرفه‌جویی در مصرف گس می‌شود.

برای مثال، دو عبارت زیر از نظر محاسباتی معادل هستند:

و همین‌طور:
در سطح ماشین مجازی اتریوم (EVM)، اپکدهای مربوط به عملیات شیفت مانند shr (شیفت به راست) و shl (شیفت به چپ)، فقط ۳ گس مصرف می‌کنند، در حالی‌که اپکدهای ضرب (mul) و تقسیم (div) ۵ گس هزینه دارند.

علاوه بر تفاوت مستقیم در مصرف گس، صرفه‌جویی اصلی‌تر از اینجا می‌آید که بر خلاف عملگرهای ضرب و تقسیم، در شیفت بیت ها سالیدیتی بررسی overflow/underflow یا تقسیم بر صفر انجام نمی‌دهد. به همین دلیل، نه تنها ارزان‌تر است بلکه سریع‌تر هم اجرا می‌شود.

با این حال باید هنگام استفاده از شیفت بیت دقت کنید، زیرا به دلیل نبود بررسی‌های محافظتی، استفاده نادرست ممکن است منجر به بروز خطاهای خطرناک مانند سرریز عدد (overflow) یا مقداردهی نادرست شود.

۱۶. در برخی موارد، ذخیره کردن مقادیر calldata در متغیر محلی (cache) مقرون‌به‌صرفه‌تر است

اگرچه دستور calldataload در ماشین مجازی اتریوم (EVM) جزو دستورهای کم‌هزینه است، اما کامپایلر سالیدیتی گاهی اوقات در صورت کش کردن مقدار calldata در یک متغیر محلی، کد بهینه تری تولید می‌کند.

در شرایطی که نیاز به دسترسی مکرر به یک مقدار از calldata دارید، بهتر است هر دو روش را تست و مقایسه (benchmark) کنید تا مشخص شود کدام گزینه مصرف گس کمتری دارد.

۱۷. از الگوریتم های بدون شاخه (Branchless) به‌جای شرط ها و حلقه ها استفاده کنید

کدی که تابع max در یکی از بخش‌های قبلی ارائه شد، نمونه‌ای از یک الگوریتم بدون شاخه است؛ یعنی الگوریتمی که نیازی به استفاده از دستور JUMP ندارد، که معمولاً از نظر مصرف گس مقرون‌به‌صرفه‌تر از سایر دستورها مانند عملگرهای محاسباتی است.

در حلقه های for نیز دستورهای پرش به‌صورت پیش‌فرض وجود دارند، بنابراین برای صرفه‌جویی در گس می‌توانید تکنیکی به نام unrolling loop (بازنویسی حلقه) را در نظر بگیرید.

لازم نیست که حلقه را به‌طور کامل بازنویسی کنید. به‌عنوان مثال، می‌توانید در هر تکرار حلقه دو آیتم را پردازش کنید تا تعداد پرش‌ها به نصف برسد.

این یک بهینه سازی نسبتاً افراطی محسوب می‌شود، اما باید بدانید که پرش های شرطی (JUMP) و حلقه ها دستوراتی با هزینه بالاتر هستند، بنابراین استفاده از الگوریتم های بدون شاخه می‌تواند از نظر مصرف گس بهینه‌تر باشد.

۱۸. توابع داخلی (internal) که فقط یک‌بار استفاده می‌شوند را به‌صورت inline بنویسید تا در مصرف گس صرفه‌جویی شود

استفاده از توابع داخلی در سالیدیتی کاملاً پذیرفته شده است، اما باید توجه داشت که این توابع باعث اضافه شدن برچسب‌های پرش (jump labels) در بایت‌کد نهایی می‌شوند.

به همین دلیل، اگر یک تابع داخلی تنها در یک جای کد استفاده شده باشد، بهتر است منطق آن تابع را مستقیماً در داخل همان تابع اصلی قرار دهید (یعنی آن را inline کنید). این کار با حذف پرش‌های غیرضروری در زمان اجرای تابع، مقداری از گس را کاهش می‌دهد.

۱۹. اگر طول آرایه یا رشته بیشتر از ۳۲ بایت است، برای مقایسه برابری آن‌ها از هش استفاده کنید

در بیشتر موارد نیازی به این تکنیک نخواهید داشت، اما اگر با آرایه ها یا رشته هایی با طول بیش از ۳۲ بایت سروکار دارید، مقایسه عنصر به عنصر یا کاراکتر به کاراکتر بسیار پرهزینه‌تر از محاسبه هش آن‌ها و مقایسه هش‌هاست.

۲۰. برای محاسبه توان ها و لگاریتم ها از جدول های از پیش محاسبه شده (Lookup Tables) استفاده کنید

اگر نیاز دارید توان یا لگاریتم عددی را محاسبه کنید، مخصوصاً در حالتی که پایه (base) یا توان (exponent) مقدار ثابتی دارد یا اینکه با مقادیر کسری سروکار دارید، استفاده از جدول‌های از پیش محاسبه‌شده می‌تواند بسیار کارآمدتر از محاسبه مستقیم باشد.

نمونه های موفق این رویکرد:

۲۱. قراردادهای از پیش کامپایل شده می‌توانند در برخی عملیات ضرب یا مدیریت حافظه مفید باشند

در اتریوم، قراردادهای از پیش کامپایل شده (Precompiles) در واقع توابع خاصی هستند که در سطح پایین (EVM) پیاده سازی شده‌اند و معمولاً برای عملیات رمزنگاری مانند ecrecover، sha256 و modexp استفاده می‌شوند.

اما در مواردی که نیاز به ضرب اعداد بزرگ در یک پیمانه (modulus) یا کپی کردن حجم بزرگی از داده ها در حافظه دارید، استفاده از این قراردادها ممکن است از نظر مصرف گس بسیار مقرون‌به‌صرفه‌تر باشد.

توجه: استفاده از precompiles ممکن است باعث ناسازگاری با برخی لایه‌ دوم ها (Layer 2s) شود. بنابراین قبل از تصمیم به استفاده، باید سازگاری با اکوسیستم مقصد بررسی شود.

۲۲. نوشتن n * n * n معمولاً ارزان‌تر از n ** 3 است

در زبان سالیدیتی، برای محاسبه توان اعداد (مثل n ** 3) از اپ‌کدی به نام EXP استفاده می‌شود که گس نسبتاً زیادی مصرف می‌کند:

  • هر دستور MUL فقط ۵ گس مصرف می‌کند.

  • اما دستور EXP، علاوه بر ۱۰ گس ثابت، ۵۰ گس برای هر بایت از نما (Exponent) نیز مصرف می‌کند.

بنابراین در عمل، اگر بخواهید مثلاً n را به توان ۳ برسانید، اجرای n * n * n (یعنی دو ضرب ساده) حدود ۱۰ گس مصرف می‌کند، در حالی که n ** 3 می‌تواند به‌مراتب بیشتر هزینه داشته باشد.

تکنیک های خطرناک

اگر در یک رقابت بهینه سازی مصرف گس شرکت می‌کنید، این الگوهای طراحی غیرمعمول می‌توانند مفید باشند. اما استفاده از آن‌ها در محیط تولید (production) به‌شدت توصیه نمی‌شود، یا دست‌کم باید با نهایت احتیاط انجام شود.

۱. استفاده از gasprice() یا msg.value برای ارسال اطلاعات

ارسال پارامتر به یک تابع حداقل ۱۲۸ گس هزینه دارد، چون هر بایت صفر در calldata، ۴ گس مصرف می‌کند. اما شما می‌توانید از gasprice یا msg.value به‌صورت رایگان برای ارسال مقادیری عددی استفاده کنید.

البته، این روش در محیط واقعی (production) کاربرد ندارد؛ زیرا:

  • msg.value مستقیماً نیاز به پرداخت اتریوم واقعی دارد.

  • اگر gas price شما خیلی پایین باشد، تراکنش یا انجام نمی‌شود یا منجر به اتلاف رمزارز می‌گردد.

۲. دست‌کاری متغیرهای محیطی مثل coinbase() یا block.number اگر شرایط تست اجازه دهد

بدیهی است که این روش در دنیای واقعی قابل‌اعتماد نیست، اما در شرایط آزمایشی یا رقابت، می‌تواند به‌عنوان یک کانال جانبی برای تغییر رفتار قرارداد هوشمند استفاده شود.

۳. استفاده از gasleft() برای شاخه بندی تصمیم ها در نقاط کلیدی اجرا

در طول اجرای قرارداد، گس به‌تدریج مصرف می‌شود. بنابراین اگر بخواهید مثلاً:

  • یک حلقه را پس از رسیدن به نقطه مشخصی متوقف کنید،

  • یا در مراحل بعدی اجرا، رفتار قرارداد را تغییر دهید،

می‌توانید از gasleft() برای شاخه‌بندی منطق تصمیم‌گیری استفاده کنید. این تابع با کاهش تدریجی مقدار گس باقی‌مانده، اطلاعاتی فراهم می‌کند که خودش هزینه گسی ندارد و بنابراین می‌تواند به کاهش مصرف گس کمک کند.

4. استفاده از send() برای انتقال اتر بدون بررسی موفقیت

تفاوت بین send و transfer در این است که transfer در صورت شکست عملیات، قرارداد را ریورت می‌کند، اما send مقدار false را برمی‌گرداند. می‌توانید مقدار بازگشتی send را نادیده بگیرید که منجر به استفاده از اپ‌کدهای کمتری می‌شود. نادیده گرفتن مقدار بازگشتی یک روش بسیار بد در برنامه‌نویسی است، و تأسف‌بار است که کامپایلر جلوی این کار را نمی‌گیرد. در سیستم‌های واقعی، به‌هیچ‌وجه نباید از send() استفاده شود به‌خاطر محدودیت گس آن.

5. تمام توابع را payable کنید

این یک بهینه سازی بحث‌برانگیز است زیرا اجازه تغییر وضعیت ناخواسته را در تراکنش می‌دهد و صرفه‌جویی گس زیادی ندارد. اما در شرایط رقابت گس، تمام توابع را payable کنید تا از اپ‌کدهای اضافی برای بررسی msg.value اجتناب شود.

همان‌طور که قبلاً اشاره شد، تعیین constructor یا توابع مدیریتی به‌صورت payable روشی مشروع برای صرفه‌جویی در گس است، چون فرض بر این است که دیپلوی‌کننده یا مدیر می‌داند چه می‌کند و می‌تواند کارهایی به‌مراتب مخرب‌تر از ارسال اتر انجام دهد.

6. پرش به کتابخانه خارجی (External Library Jumping)

سالیدیتی به‌صورت سنتی از ۴ بایت و جدول پرش برای تعیین تابع موردنظر استفاده می‌کند. اما می‌توان (به‌صورت بسیار ناامن!) مقصد پرش را مستقیماً به‌عنوان آرگومان calldata ارسال کرد، و با این روش، اندازه “selector تابع” را به یک بایت کاهش داد و به‌طور کامل از جدول پرش صرف‌نظر کرد. اطلاعات بیشتر در این توییت آورده شده است.

7. الحاق بایت‌کد به انتهای قرارداد برای پیاده سازی مستقیم یک الگوریتم پرهزینه به‌شکل بهینه

برخی از الگوریتم‌های محاسباتی سنگین، مانند توابع هش، بهتر است مستقیماً با بایت‌کد خام نوشته شوند تا با زبان‌هایی مانند Solidity یا حتی Yul. برای مثال، پروژه Tornado Cash تابع هش MiMC را به‌عنوان یک قرارداد هوشمند جداگانه و مستقیماً با بایت‌کد خام نوشته است.

برای جلوگیری از هزینه اضافی ۲۶۰۰ (در صورت دسترسی سرد) یا ۱۰۰ گس (در صورت دسترسی گرم) ناشی از فراخوانی یک قرارداد دیگر، می‌توانید همان بایت‌کد را به انتهای قرارداد اصلی خود بچسبانید و بین بخش‌های مختلف آن پرش (jump) انجام دهید.

ترفندهای منسوخ شده برای بهینه سازی گس در سالیدیتی

1. استفاده از external به جای public ارزان‌تر است

اگرچه همچنان بهتر است از ویژگی external برای وضوح بیشتر استفاده کنید، مخصوصاً زمانی که یک تابع قرار نیست از داخل خود قرارداد فراخوانی شود، اما این موضوع دیگر تأثیری بر صرفه‌جویی در مصرف گس ندارد.

2. استفاده از != 0 ارزان‌تر از > 0 است

این موضوع تا حدود نسخه ۰.۸.۱۲ سالیدیتی صحت داشت، اما دیگر درست نیست. اگر مجبور هستید از نسخه‌های قدیمی استفاده کنید، همچنان می‌توانید هر دو حالت را بنچمارک بگیرید و مقایسه کنید.

5/5 - (1 امتیاز)

راستی! برای دریافت مطالب جدید در کانال تلگرام یا پیج اینستاگرام سورس باران عضو شوید.

آموزش پروژه محور طراحی سایت با پایتون و جنگو مختص بازار کار
  • انتشار: ۳ مرداد ۱۴۰۴

دسته بندی موضوعات

آخرین محصولات فروشگاه

مشاهده همه

نظرات

بازخوردهای خود را برای ما ارسال کنید