در دنیای برنامه نویسی قراردادهای هوشمند، هزینه گس (Gas) نهتنها یک عدد ساده، بلکه عاملی تعیینکننده در موفقیت یا شکست یک پروژه بلاکچینی است. هر خط کدی که در سالیدیتی مینویسید، میتواند هزینهای برای کاربران ایجاد کند. و این یعنی بهینهسازی دقیق، دیگر یک انتخاب نیست، بلکه یک ضرورت است. در این راهنمای جامع، با بیش از ۸۰ تکنیک واقعی برای بهینه سازی گس در سالیدیتی آشنا میشوید؛ نکاتی که نهفقط مصرف گس را کاهش میدهند، بلکه عملکرد کلی قرارداد شما را نیز ارتقاء میبخشند. اگر قصد دارید قراردادهای هوشمندی بنویسید که هم سریع اجرا شوند و هم اقتصادی باشند، این مقاله را از دست ندهید.
ترفندهای بهینه سازی گس همیشه نتیجه بخش نیستند
بعضی از تکنیک های بهینه سازی گس در سالیدیتی فقط در شرایط خاص عملکرد مناسبی دارند. برای نمونه، بهطور شهودی ممکن است فکر کنید که:
1 2 3 4 5 6 |
if (!cond) { // حالت False } else { // حالت True } |
1 2 3 4 5 6 |
if (cond) { // حالت True } else { // حالت False } |
زیرا در نسخه اول، معکوس کردن شرط، نیاز به اجرای دستورالعملهای اضافه دارد. با این حال، نکته جالب اینجاست که در بسیاری از موارد، چنین تغییری نه تنها باعث کاهش مصرف گس نمیشود، بلکه حتی ممکن است هزینه تراکنش را افزایش دهد. دلیل این موضوع آن است که رفتار کامپایلر سالیدیتی همیشه قابل پیش بینی نیست.
به همین خاطر، قبل از انتخاب یک الگوریتم نهایی، بهتر است تاثیر واقعی گزینههای مختلف را اندازه گیری کنید. برخی از این ترفندها بیش از آنکه نسخه نهایی قابل اعتماد باشند، صرفاً توجه شما را به قسمتهایی از کد جلب میکنند که رفتار غیرمنتظرهای از سمت کامپایلر دارند.
از سوی دیگر، در این مقاله مشخص شده که کدام ترفندها قابلیت استفاده عمومی ندارند. همچنین، از آنجا که عملکرد کامپایلر در سطوح محلی میتواند بر اثربخشی ترفندها تاثیر بگذارد، بهتر است هر دو نسخه بهینه و غیر بهینه کد را آزمایش کنید تا مطمئن شوید که واقعاً بهبود حاصل میشود.
نکته دیگری که باید در نظر بگیرید این است که فعال کردن گزینه --via-ir
در کامپایلر سالیدیتی، ممکن است رفتار بهینه سازی را تغییر دهد. بنابراین هنگام استفاده از این گزینه نیز، باید دوباره تاثیر ترفندها را بررسی کنید.
مراقب پیچیدگی و خوانایی کد باشید
بیشتر روشهای بهینه سازی گس در سالیدیتی باعث میشوند کد خوانایی خود را از دست بدهد و پیچیدهتر شود. بنابراین، یک توسعه دهنده حرفهای باید بهصورت ذهنی بین سادگی و بهینه بودن تعادل ایجاد کند و تصمیم بگیرد که کدام بهینه سازی واقعاً ارزش اجرا دارد و از کدام موارد باید صرفنظر کرد.
شرح کامل تمام مباحث در اینجا ممکن نیست
در این مقاله امکان ارائه توضیح کامل برای هر تکنیک بهینه سازی وجود ندارد و در واقع ضرورتی هم برای آن نیست. چون منابع آنلاین زیادی برای مطالعه عمیقتر این مباحث وجود دارد. برای مثال، بررسی جامع یا حتی نسبتاً کامل درباره راهکارهای لایه دوم (Layer 2) و کانال های وضعیت (State Channels) از حوزه این مقاله خارج است. میتوان آن موضوعات را از منابع تخصصی دیگر پیگیری کرد.
هدف اصلی این مقاله، ارائه جامعترین فهرست از ترفندهای بهینه سازی موجود است. اگر با یکی از ترفندها آشنایی ندارید، میتوانید آن را به عنوان نقطه شروعی برای مطالعه بیشتر در نظر بگیرید. از طرف دیگر، اگر عنوان یک بخش برایتان آشناست، میتوانید به سرعت از روی آن عبور کنید و زمان خود را صرف بخشهایی کنید که به اطلاعات جدیدتری نیاز دارند.
ترفندهای وابسته به کاربرد خاص را بررسی نمیکنیم
برخی از روشهای بهینه سازی گس در سالیدیتی فقط در موقعیتهای بسیار خاص مفید هستند. برای مثال، میتوان عدد اول بودن را با مصرف گس پایینتری بررسی کرد، اما این کار در عمل بهندرت نیاز میشود. پس پرداختن به آن در این مقاله، ارزش کلی محتوا را کاهش میدهد.
۱. مهمترین اصل: تا جای ممکن از نوشتن مقدار صفر به یک در حافظه دائم (Storage) خودداری کنید
مقداردهی اولیه به یک متغیر ذخیره شده در حافظه دائم (storage) یکی از پرهزینهترین عملیاتهایی است که یک قرارداد میتواند انجام دهد.
زمانی که یک متغیر از مقدار صفر به مقدار غیرصفر تغییر میکند، کاربر باید در مجموع ۲۲,۱۰۰ واحد گس پرداخت کند؛ که شامل ۲۰,۰۰۰ گس برای تغییر مقدار صفر به غیرصفر و ۲,۱۰۰ گس بابت دسترسی اولیه (cold access) به آن متغیر میشود.
به همین دلیل، کتابخانه امنیتی OpenZeppelin ReentrancyGuard برای جلوگیری از حمله بازدرآمد (Reentrancy)، بهجای استفاده از مقادیر ۰ و ۱، از مقادیر ۱ و ۲ استفاده میکند. چون در این حالت، تغییر مقدار متغیر از عددی غیرصفر به عددی دیگر نیز غیرصفر، فقط ۵,۰۰۰ گس هزینه دارد.
این تکنیک یکی از سادهترین و در عین حال مؤثرترین روشها برای کاهش هزینههای گس در قراردادهای سالیدیتی است.
۲. متغیرهای storage را کش (Cache) کنید: فقط یک بار بخوانید و یک بار بنویسید
در کدهای بهینه سالیدیتی، یک الگوی رایج و بسیار مهم وجود دارد:
هیچوقت متغیرهای storage را چند بار نخوانید یا بنویسید.
خواندن از storage حداقل ۱۰۰ واحد گس هزینه دارد، زیرا سالیدیتی برخلاف حافظه موقت (memory)، خواندنهای storage را کش نمیکند. از طرف دیگر، نوشتن در storage هزینه بسیار بالاتری دارد. بنابراین، برای صرفهجویی در گس، باید مقدار متغیر را فقط یک بار از storage بخوانید، در یک متغیر موقت ذخیره کنید و در نهایت فقط یک بار در storage بنویسید.
در مثال زیر، تفاوت بین یک پیاده سازی ناکارآمد و یک پیاده سازی بهینه را میبینید:
1 2 3 4 5 6 7 8 9 10 11 |
// SPDX-License-Identifier: MIT pragma solidity 0.8.20; contract Counter1 { uint256 public number; function increment() public { require(number < 10); number = number + 1; } } |
در کد بالا، متغیر number
دو بار از storage خوانده میشود:
یک بار در require(number < 10)
و یک بار دیگر در number + 1
.
اما در نسخه بهینه تر، کد به شکل زیر است:
1 2 3 4 5 6 7 8 9 |
contract Counter2 { uint256 public number; function increment() public { uint256 _number = number; require(_number < 10); number = _number + 1; } } |
در این نسخه، متغیر number
فقط یک بار از storage خوانده میشود و در متغیر _number
کش میشود. سپس تمامی عملیاتها روی همین نسخه موقت انجام میگیرند، و در پایان نتیجه نهایی فقط یک بار در storage ذخیره میشود.
۳. متغیرهای مرتبط را در یک اسلات فشرده کنید (Packing)
زمانی که چند متغیر مرتبط را بهصورت فشرده در یک اسلات حافظه ذخیره میکنید، مصرف گس به شکل محسوسی کاهش مییابد. دلیل این صرفهجویی، کاهش تعداد عملیات های پرهزینه مرتبط با حافظه دائم (storage) است.
روش اول: فشرده سازی دستی (بالاترین کارایی)
در این روش، دو متغیر از نوع uint80
را در یک متغیر uint160
ذخیره میکنیم. با استفاده از بیتشیفت (Bit Shifting) میتوان هر دو مقدار را در یک اسلات ذخیره کرد. این کار باعث میشود هم هنگام ذخیره سازی و هم در زمان خواندن مقادیر، گس کمتری مصرف شود.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
// SPDX-License-Identifier: MIT pragma solidity 0.8.20; contract GasSavingExample { uint160 public packedVariables; function packVariables(uint80 x, uint80 y) external { packedVariables = uint160(x) << 80 | uint160(y); } function unpackVariables() external view returns (uint80, uint80) { uint80 x = uint80(packedVariables >> 80); uint80 y = uint80(packedVariables); return (x, y); } } |
روش دوم: تکیه بر فشرده سازی ضمنی EVM (کمتر بهینه ولی قابل قبول)
در این روش نیز متغیرها در یک اسلات قرار میگیرند، اما عملیات فشرده سازی توسط EVM انجام میشود. این مدل در بسیاری از مواقع مفید است، اما در مقایسه با روش دستی ممکن است هنگام اجرای تراکنش کمی گس بیشتری مصرف کند.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
contract GasSavingExample2 { uint80 public var1; uint80 public var2; function updateVars(uint80 x, uint80 y) external { var1 = x; var2 = y; } function loadVars() external view returns (uint80, uint80) { return (var1, var2); } } |
روش سوم: بدون فشرده سازی (پرهزینهترین حالت)
در این روش هیچ فشرده سازی انجام نمیشود. به همین دلیل، متغیرها در دو اسلات مجزا ذخیره میشوند. این طراحی سادهتر است، اما باعث افزایش قابل توجه هزینههای گس میشود، مخصوصاً زمانی که نیاز به خواندن یا نوشتن مقادیر در یک تراکنش داشته باشید.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
contract NonGasSavingExample { uint256 public var1; uint256 public var2; function updateVars(uint256 x, uint256 y) external { var1 = x; var2 = y; } function loadVars() external view returns (uint256, uint256) { return (var1, var2); } } |
۴. ساختارهای Struct را فشرده (Packed) طراحی کنید
فشرده سازی اعضای یک ساختار (Struct)، دقیقاً مشابه فشرده سازی متغیرهای مرتبط، باعث کاهش مصرف گس میشود.
نکته مهم این است که در سالیدیتی، اعضای struct به ترتیب و پشت سر هم در حافظه دائم (storage) ذخیره میشوند. ترتیب قرارگیری فیلدها مستقیماً بر تعداد اسلات های مصرفی تأثیر دارد.
Struct بدون فشرده سازی (ناکارآمد)
در ساختار زیر، هر فیلد در یک اسلات جداگانه ذخیره میشود. در نتیجه، سه اسلات برای سه فیلد مصرف میشود، حتی اگر برخی فیلدها ظرفیت کامل اسلات را اشغال نکنند.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
// SPDX-License-Identifier: MIT pragma solidity 0.8.20; contract Unpacked_Struct { struct unpackedStruct { uint64 time; // فقط 64 بیت (8 بایت) استفاده میکند ولی یک اسلات کامل را اشغال میکند uint256 money; // چون کامل 256 بیت است، در اسلات جدیدی ذخیره میشود address person; // فقط 160 بیت است اما باز هم در اسلات مجزایی ذخیره میشود } unpackedStruct details = unpackedStruct(53_000, 21_000, address(0xdeadbeef)); function unpack() external view returns (unpackedStruct memory) { return details; } } |
Struct فشرده شده (بهینه)
در این نسخه، ترتیب فیلدها بهگونهای چیده شده که time
و person
در یک اسلات جا میگیرند، چون مجموع اندازه آنها کمتر از 256 بیت است.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
contract Packed_Struct { struct packedStruct { uint64 time; // 64 بیت address person; // 160 بیت uint256 money; // به دلیل اندازه کامل، در اسلات جداگانه ذخیره میشود } packedStruct details = packedStruct(53_000, address(0xdeadbeef), 21_000); function unpack() external view returns (packedStruct memory) { return details; } } |
time
و person
روی هم فقط ۲۲۴ بیت فضا اشغال میکنند. بنابراین هر دو در یک اسلات ذخیره میشوند و تنها دو اسلات برای این struct کافی است. نتیجه این بهینهسازی، کاهش محسوس هزینه گس هنگام خواندن یا نوشتن اطلاعات است.
۵. اندازه رشته ها را کمتر از ۳۲ بایت نگه دارید
در سالیدیتی، نوع داده string
از نوع داده های پویا (Dynamic) است؛ به این معنا که طول رشته میتواند در زمان اجرا تغییر کند و گسترش یابد.
اما نحوه ذخیره سازی رشته ها در حافظه دائم (storage) بسته به طول آنها تفاوت دارد:
-
اگر طول رشته کمتر از ۳۲ بایت باشد، تمام داده های رشته بههمراه اطلاعات طول آن، در همان یک اسلات حافظه ذخیره میشوند. در این حالت، مقدار
(طول * ۲)
در کمارزشترین بایت اسلات (Least Significant Byte) قرار میگیرد، و محتوای واقعی رشته از سمت دیگر اسلات (بایتهای پرارزشتر) شروع میشود. -
ولی اگر طول رشته ۳۲ بایت یا بیشتر شود، اسلات تعریفشده فقط شامل مقدار
(طول * ۲) + ۱
خواهد بود. خود محتوای رشته در این حالت به موقعیت دیگری منتقل میشود که آدرس آن از طریق هشkeccak256
آن اسلات محاسبه میشود. این ساختار نهتنها حافظه بیشتری مصرف میکند، بلکه خواندن و نوشتن رشته را نیز پرهزینهتر میسازد.
مثال رشته (کمتر از ۳۲ بایت)
1 2 3 4 5 6 7 8 9 10 11 12 13 |
// SPDX-License-Identifier: MIT pragma solidity 0.8.20; contract StringStorage1 { // Uses only one slot // slot 0: 0x(len * 2)00...hex of (len * 2)(hex"hello") // Has smaller gas cost due to size. string public exampleString = "hello"; function getString() public view returns (string memory) { return exampleString; } } |
مثال رشته (بیشتر از ۳۲ بایت)
1 2 3 4 5 6 7 8 9 10 11 |
contract StringStorage2 { // Length is more than 32 bytes. // Slot 0: 0x00...(length*2+1). // keccak256(0x00): stores hex representation of "hello" // Has increased gas cost due to size. string public exampleString = "This is a string that is slightly over 32 bytes!"; function getStringLongerThan32bytes() public view returns (string memory) { return exampleString; } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
// SPDX-License-Identifier: MIT pragma solidity 0.8.20; import "forge-std/Test.sol"; import "../src/StringLessThan32Bytes.sol"; contract StringStorageTest is Test { StringStorage1 public store1; StringStorage2 public store2; function setUp() public { store1 = new StringStorage1(); store2 = new StringStorage2(); } function testStringStorage1() public { // test for string less than 32 bytes store1.getString(); bytes32 data = vm.load(address(store1), 0); // slot 0 emit log_named_bytes32("Full string plus length", data); // the full string and its length*2 is stored at slot 0, because it is less than 32 bytes } function testStringStorage2() public { // test for string longer than 32 bytes store2.getStringLongerThan32bytes(); bytes32 length = vm.load(address(store2), 0); // slot 0 stores the length*2+1 emit log_named_bytes32("Length of string", length); // uncomment to get original length as number // emit log_named_uint("Real length of string (no. of bytes)", uint256(length) / 2); // divide by 2 to get the original length bytes32 data1 = vm.load(address(store2), keccak256(abi.encode(0))); // slot keccak256(0) emit log_named_bytes32("First string chunk", data1); bytes32 data2 = vm.load(address(store2), bytes32(uint256(keccak256(abi.encode(0))) + 1)); emit log_named_bytes32("Second string chunk", data2); } } |
این نتیجهای است که پس از اجرای تست دریافت میکنیم.
اگر مقدار رشتهای که طول آن بیشتر از ۳۲ بایت است را از حافظه بخوانیم، میتوانیم با کنار هم قرار دادن مقدار هگزادسیمال داده (بدون بخش طول)، آن را در زبان هایی مانند Python به رشته اصلی تبدیل کنیم.
اما نکته مهمتر این است که اگر طول رشته کمتر از ۳۲ بایت باشد، میتوان آن را در یک متغیر از نوع bytes32
ذخیره کرد و با استفاده از اسمبلی، هنگام نیاز مقدار آن را بازیابی یا استفاده کرد. این روش هم حافظه کمتری مصرف میکند و هم در مقایسه با string
معمولی، مصرف گس بسیار کمتری دارد.
مثال:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 |
// SPDX-License-Identifier: MIT pragma solidity 0.8.21; contract EfficientString { bytes32 shortString; function getShortString() external view returns(string memory) { string memory value; assembly { // get slot 0 let slot0Value := sload(shortString.slot) // to get the byte that holds the length info, we mask it to rmove the string and divide it by 2 to get the length let len := div(and(slot0Value, 0xff), 2) // to get string, we mask the slot value to remove the length// we are sure that it can't take more than a byte because of the length check in the `storeShortString` function let str := and(slot0Value, 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00) // store length in memory mstore(0x80, len) // store string in memory mstore(0xa0, str) // make `value` reference 0x80 so that solidity does the returning for us value := 0x80// update the free memory pointer mstore(0x40, 0xc0) } return value; } function storeShortString(string calldata value) external { assembly { // require that the length is less than 32 if gt(value.length, 31) { revert(0, 0) } // multiply the length, so we can store length*2 following solidity's convention let length := mul(value.length, 2) // get the string itself let str := calldataload(value.offset) // or the length and str to get what we need to store in storage let toBeStored := or(str, length) // store it in storage sstore(shortString.slot, toBeStored) } } } |
۶. متغیرهایی که تغییر نمیکنند باید constant
یا immutable
باشند
در سالیدیتی، اگر مطمئن هستید که مقدار یک متغیر بعد از تعریف هرگز تغییر نخواهد کرد، بهتر است آن را با یکی از دو کلیدواژهی constant
یا immutable
تعریف کنید.
دلیل این کار بسیار ساده است:
متغیرهای constant
و immutable
بهصورت مستقیم در بایت کد نهایی قرارداد جای میگیرند و دیگر نیازی به اختصاص اسلات در حافظه دائم (storage) ندارند.
از آنجا که خواندن از storage یکی از عملیاتهای پرهزینه از نظر گس محسوب میشود، حذف این خواندنها بهطور چشمگیری در مصرف گس صرفهجویی میکند.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
// SPDX-License-Identifier: MIT pragma solidity 0.8.20; contract Constants { uint256 constant MAX_UINT256 = 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff; function get_max_value() external pure returns (uint256) { return MAX_UINT256; } } // This uses more gas than the above contract contract NoConstants { uint256 MAX_UINT256 = 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff; function get_max_value() external view returns (uint256) { return MAX_UINT256; } } |
این کار باعث صرفهجویی قابل توجهی در مصرف گس میشود، چون دیگر نیازی به خواندن از حافظه دائم (storage) نداریم. همانطور که میدانید، عملیات خواندن از storage یکی از پرهزینهترین بخشهای اجرای قرارداد در اتریوم است.
۷. استفاده از Mapping به جای Array برای حذف بررسی طول (Length Check) و کاهش گس
زمانی که میخواهید مجموعهای از آیتم ها را ذخیره کنید که قابل مرتب سازی با ترتیب مشخص باشند و بتوانید آنها را با یک کلید یا اندیس ثابت فراخوانی کنید، استفاده از آرایه یک انتخاب رایج است. این روش بهخوبی کار میکند، اما آیا میدانستید که با استفاده از یک mapping
میتوان بیش از ۲۰۰۰ واحد گس در هر عملیات خواندن صرفهجویی کرد؟
مثال زیر را ببینید:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
/// get(0) gas cost: 4860 contract Array { uint256[] a; constructor() { a.push() = 1; a.push() = 2; a.push() = 3; } function get(uint256 index) external view returns(uint256) { return a[index]; } } /// get(0) gas cost: 2758 contract Mapping { mapping(uint256 => uint256) a; constructor() { a[0] = 1; a[1] = 2; a[2] = 3; } function get(uint256 index) external view returns(uint256) { return a[index]; } } |
تنها با جایگزینی 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) - این بایت کد مسئول انجام مرحله اولی است که در بالا توضیح داده شد.
- در مرحله دوم، آدرس قرارداد جدیدی را که دیپلوی شده، بازمیگردانیم.
- ما اندازه کد قرارداد را در جای چهار صفر (0000) بین 61 و 80 در کد زیر قرار میدهیم:
بایتکد قرارداد نهایی = ۰۰ + داده (در اینجا، 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 چگونه میتواند مفید باشد.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
// SPDX-License-Identifier: MIT pragma solidity 0.8.20; contract StoragePointerUnOptimized { struct User { uint256 id; string name; uint256 lastSeen; } constructor() { users[0] = User(0, "John Doe", block.timestamp); } mapping(uint256 => User) public users; function returnLastSeenSecondsAgo(uint256 _id) public view returns (uint256) { User memory _user = users[_id]; uint256 lastSeen = block.timestamp - _user.lastSeen; return lastSeen; } } |
در مثال بالا، تابعی داریم که آخرین زمان مشاهده یک کاربر را با استفاده از اندیس مشخص بازیابی میکند. این تابع مقدار lastSeen
را از ساختار (struct) دریافت کرده و آن را از block.timestamp
کم میکند تا مدت زمان سپریشده از آخرین فعالیت کاربر (برحسب ثانیه) محاسبه شود.
اما در این پیاده سازی، تمام ساختار (struct) از حافظه دائم (storage) به حافظه موقت (memory) کپی میشود—حتی متغیرهایی که اصلاً به آنها نیاز نداریم.
این روش از نظر عملکرد صحیح است، اما از نظر مصرف گس کارآمد نیست. چون عملیات کپی کل ساختار به حافظه، هزینهای غیرضروری به همراه دارد.
حالا اگر راهی وجود داشت که فقط مقدار lastSeen
را مستقیماً از storage بخوانیم، بدون استفاده از اسمبلی، عملکرد به مراتب بهینهتر میشد.
اینجا دقیقاً جایی است که اشاره گرهای storage وارد عمل میشوند. با استفاده از آنها، میتوانیم فقط به بخش مورد نظر از struct دسترسی داشته باشیم، بدون اینکه کل ساختار را به memory منتقل کنیم. این روش، هم خوانایی بالایی دارد و هم مصرف گس را بهطور محسوسی کاهش میدهد.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
// This results in approximately 5,000 gas savings compared to the previous version. contract StoragePointerOptimized { struct User { uint256 id; string name; uint256 lastSeen; } constructor() { users[0] = User(0, "John Doe", block.timestamp); } mapping(uint256 => User) public users; function returnLastSeenSecondsAgoOptimized(uint256 _id) public view returns (uint256) { User storage _user = users[_id]; uint256 lastSeen = block.timestamp - _user.lastSeen; return lastSeen; } } |
«پیادهسازی بالا حدود ۵,۰۰۰ واحد گس کمتر از نسخه اول مصرف میکند.» چرا چنین صرفهجویی اتفاق میافتد، در حالی که تنها تغییری که انجام دادیم این بود که 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 پرهزینه هستند، و بهتر است تا حد امکان از آنها پرهیز کنیم.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
contract StorageContract { address immutable public writer; uint256 public x; constructor(address _writer) { writer = _writer; } function setX(uint256 x_) external { require(msg.sender == address(writer), "only writer can set"); x = x_; } } contract Writer { StorageContract public storageContract; // cost: 49291 function set(uint256 x_) external { storageContract.setX(x_); } function setStorageContract(address _storageContract) external { storageContract = StorageContract(_storageContract); } } |
این روش هم در زمان دیپلوی و هم در زمان اجرا گس بیشتری مصرف میکند. در این مدل، ابتدا قرارداد Writer
دیپلوی میشود. سپس قرارداد StorageContract
با آدرس Writer
بهعنوان نویسنده (writer) مستقر میگردد. در مرحله بعد، آدرس StorageContract
در متغیر داخلی Writer
ذخیره میشود. این فرآیند شامل چندین مرحله است و پرهزینه است، زیرا آدرس StorageContract
در حافظه دائم (storage) ذخیره میشود. بهعنوان نمونه، فراخوانی تابع Writer.setX()
در این روش حدود ۴۹,۰۰۰ واحد گس مصرف میکند.
راهحل کارآمدتر این است که قبل از دیپلوی قراردادها، آدرسهایی را که StorageContract
و Writer
در آن مستقر خواهند شد، پیشبینی کنیم و همان آدرسها را مستقیماً در سازنده (constructor) هر دو قرارداد تنظیم کنیم.
در ادامه، یک نمونه پیاده سازی از این روش ارائه میشود:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
import {LibRLP} from "https://github.com/vectorized/solady/blob/main/src/utils/LibRLP.sol"; contract StorageContract { address immutable public writer; uint256 public x; constructor(address _writer) { writer = _writer; } // cost: 47158 function setX(uint256 x_) external { require(msg.sender == address(writer), "only writer can set"); x = x_; } } contract Writer { StorageContract immutable public storageContract; constructor(StorageContract _storageContract) { storageContract = _storageContract; } function set(uint256 x_) external { storageContract.setX(x_); } } // one time deployer. contract BurnerDeployer { using LibRLP for address; function deploy() public returns(StorageContract storageContract, address writer) { StorageContract storageContractComputed = StorageContract(address(this).computeAddress(2)); // contracts nonce start at 1 and only increment when it creates a contract writer = address(new Writer(storageContractComputed)); // first creation happens here using nonce = 1 storageContract = new StorageContract(writer); // second create happens here using nonce = 2 require(storageContract == storageContractComputed, "false compute of create1 address"); // sanity check } } |
در این مدل، فراخوانی تابع Writer.setX()
حدود ۴۷,۰۰۰ واحد گس مصرف میکند. یعنی با محاسبه آدرس StorageContract
قبل از دیپلوی آن، توانستیم بیش از ۲,۰۰۰ واحد گس صرفهجویی کنیم. این آدرس از قبل در زمان دیپلوی Writer
استفاده شد و به همین دلیل، دیگر نیازی به استفاده از تابع setter
نبود.
برای استفاده از این تکنیک، لزومی به تعریف قرارداد جداگانه نیست؛ میتوانید این منطق را مستقیماً در اسکریپت دیپلوی خود پیاده سازی کنید.
۲. سازندهها (Constructors) را payable تعریف کنید
1 2 3 4 5 6 7 8 |
// SPDX-License-Identifier: UNLICENSED pragma solidity 0.8.20; contract A {} contract B { constructor() payable {} } |
payable
در سازنده باعث صرفهجویی حدود ۲۰۰ گس در زمان دیپلوی قرارداد میشود. دلیل این صرفهجویی آن است که توابعی که payable
نیستند، بهصورت ضمنی شامل شرط زیر هستند:
1 |
require(msg.value == 0); |
در مورد توابع معمولی (مثل 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 و یک تابع داخلی آورده شده است:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 |
// SPDX-License-Identifier: MIT pragma solidity 0.8.19; /** deployment gas cost: 195435 gas per call: restrictedAction1: 28367 restrictedAction2: 28377 restrictedAction3: 28411 */ contract Modifier { address owner; uint256 val; constructor() { owner = msg.sender; } modifier onlyOwner() { require(msg.sender == owner); _; } function restrictedAction1() external onlyOwner { val = 1; } function restrictedAction2() external onlyOwner { val = 2; } function restrictedAction3() external onlyOwner { val = 3; } } /** deployment gas cost: 159309 gas per call: restrictedAction1: 28391 restrictedAction2: 28401 restrictedAction3: 28435 */ contract InternalFunction { address owner; uint256 val; constructor() { owner = msg.sender; } function onlyOwner() internal view { require(msg.sender == owner); } function restrictedAction1() external { onlyOwner(); val = 1; } function restrictedAction2() external { onlyOwner(); val = 2; } function restrictedAction3() external { onlyOwner(); val = 3; } } |
عملیات | دیپلوی | 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
با پیام متنی استفاده شود، کامپایلر باید حداقل ۶۴ بایت (برای طول و محتوای پیام) را در حافظه ذخیره و بازگرداند، که باعث مصرف گس بسیار بیشتری میشود.
در اینجا یک مثال آورده شده است:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
// SPDX-License-Identifier: MIT pragma solidity 0.8.20; contract CustomError { error InvalidAmount(); function withdraw(uint256 _amount) external pure { if (_amount > 10 ether) revert InvalidAmount(); } } // This uses more gas than the above contract contract NoCustomError { function withdraw(uint256 _amount) external pure { require(_amount <= 10 ether, "Error: Pass in a valid amount"); } } |
۹. بهجای دیپلوی کردن فکتوری اختصاصی، از فکتوری های create2
موجود استفاده کنید
عنوان این بخش کاملاً گویاست: اگر به آدرس قابل پیش بینی (deterministic) نیاز دارید، معمولاً میتوانید از یک فکتوری از قبل دیپلوی شده استفاده کنید، و نیازی به ساخت و دیپلوی فکتوری اختصاصی ندارید.
این کار باعث صرفه جویی در هزینه گس و کاهش پیچیدگی در معماری قرارداد خواهد شد.
فراخوانی بین قراردادها
۱. بهجای آغاز انتقال از قرارداد مقصد، از انتقال با هوک (transfer hook) برای توکن ها استفاده کنید
فرض کنید قرارداد A طوری طراحی شده که توکن B را بپذیرد (برای مثال یک NFT یا توکن سازگار با استاندارد ERC1363).
رویکرد ساده و ابتدایی این فرآیند معمولاً به شکل زیر انجام میشود:
-
msg.sender
به قرارداد A اجازه میدهد که توکن B را خرج کند (approve
) -
سپس
msg.sender
تابعی در قرارداد A را فراخوانی میکند تا انتقال انجام شود -
قرارداد A، توکن B را فراخوانی میکند تا انتقال را انجام دهد
-
توکن B، انتقال را انجام داده و تابع
onTokenReceived()
را در قرارداد A فراخوانی میکند -
قرارداد A مقداری را از تابع
onTokenReceived()
بازمیگرداند -
توکن 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
1 2 3 4 5 6 7 |
contract AddLiquidity{ receive() external payable { IWETH(weth).deposit{msg.value}(); AAVE.deposit(weth, msg.value, msg.sender, REFERRAL_CODE) } } |
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 در زیر است:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
function multicall(bytes[] calldata data) public payable override returns (bytes[] memory results) { results = new bytes[](data.length); for (uint256 i = 0; i < data.length; i++) { (bool success, bytes memory result) = address(this).delegatecall(data[i]); if (!success) { // Next 5 lines from https://ethereum.stackexchange.com/a/83577 if (result.length < 68) revert(); assembly { result := add(result, 0x04) } revert(abi.decode(result, (string))); } results[i] = result; } } |
۲. برای لیست مجاز (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 دارای این آدرس است:
1 |
0x00000000000000ADc04C56Bf30aC9d3c0aAF14dC |
استفاده از چنین آدرسی در صورتی که بهعنوان آرگومان به یک تابع ارسال شود (نه در صورتی که مستقیماً فراخوانی شود)، باعث کاهش مصرف گس میشود؛ چرا که صفرهای متوالی در ابتدای داده ها باعث فشردهتر شدن calldata میشوند.
نکته مهم: این موضوع فقط به قراردادهای هوشمند محدود نمیشود. اگر آدرس EOA (کاربر معمولی) نیز شامل صفرهای زیاد در ابتدای خود باشد، هنگام استفاده بهعنوان آرگومان، مصرف گس کاهش پیدا میکند.
در گذشته، برخی از کاربران هنگام تولید آدرس های vanity برای کیف پول های معمولی (EOA) از الگوریتمهای تصادفی ضعیف استفاده کردند، که منجر به هک شدن کلید خصوصی شد. با این حال، این نگرانی در مورد آدرس های قراردادهای هوشمند که از طریق CREATE2
با یک salt خاص ساخته میشوند وجود ندارد، زیرا این قراردادها هیچ کلید خصوصی ندارند.
۲. در صورت امکان از اعداد صحیح علامت دار (signed integers) در calldata استفاده نکنید
در زبان Solidity، اعداد علامت دار (مانند int256
) با استفاده از نمایش مکمل دو (two’s complement) ذخیره میشوند. این روش باعث میشود که حتی اعداد منفی کوچک، در حافظه و در calldata دارای بیت های غیر صفر فراوان باشند.
مثال:
عدد -1
در مکمل دو به شکل زیر نمایش داده میشود:
1 |
0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff |
ff
است و در نتیجه فضای calldata بیشتری اشغال میکند، که بهطور مستقیم باعث افزایش مصرف گس میشود.
۳. استفاده از calldata
معمولاً ارزانتر از memory
است
در زبان Solidity، خواندن داده ها مستقیماً از calldata
نسبت به خواندن از memory
مصرف گس کمتری دارد. دلیل این موضوع آن است که:
-
calldata
یک ناحیه فقطخواندنی است که مستقیماً به ورودی های تابع متصل است -
در مقابل،
memory
نیاز به عملیات کپی داده دارد که شامل مصرف گس اضافه و زمان اجرا بیشتر است.
پس از calldata
در ورودی توابع زمانی استفاده کنید که نیازی به تغییر داده ها در داخل تابع وجود ندارد. اگر نیاز به تغییر یا پردازش mutative روی دادهها دارید، باید آنها را به memory
منتقل کنید.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
// SPDX-License-Identifier: MIT pragma solidity 0.8.20; contract CalldataContract { function getDataFromCalldata(bytes calldata data) public pure returns (bytes memory) { return data; } } contract MemoryContract { function getDataFromMemory(bytes memory data) public pure returns (bytes memory) { return data; } } |
۴. فشرده سازی (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
برای متوقفکردن اجرای تابع همراه با پیام خطا استفاده میشود. این کار در اغلب موارد با استفاده از اسمبلی قابل بهینه سازی است تا مصرف گس کاهش یابد.
در اینجا یک مثال آورده شده است:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
/// calling restrictedAction(2) with a non-owner address: 24042 contract SolidityRevert { address owner; uint256 specialNumber = 1; constructor() { owner = msg.sender; } function restrictedAction(uint256 num) external { require(owner == msg.sender, "caller is not owner"); specialNumber = num; } } /// calling restrictedAction(2) with a non-owner address: 23734 contract AssemblyRevert { address owner; uint256 specialNumber = 1; constructor() { owner = msg.sender; } function restrictedAction(uint256 num) external { assembly { if sub(caller(), sload(owner.slot)) { mstore(0x00, 0x20) // store offset to where length of revert message is stored mstore(0x20, 0x13) // store length (19) mstore(0x40, 0x63616c6c6572206973206e6f74206f776e657200000000000000000000000000) // store hex representation of message revert(0x00, 0x60) // revert with data } } specialNumber = num; } } |
از مثال بالا میبینیم که با استفاده از اسمبلی برای نمایش همان پیام خطا، صرفهجویی بیش از ۳۰۰ گس نسبت به روش مرسوم در سالیدیتی حاصل شده است. این کاهش مصرف گس ناشی از هزینه های گسترش حافظه (memory expansion) و بررسیهای اضافی تایپ است که کامپایلر سالیدیتی در پشتصحنه انجام میدهد.
۲. فراخوانی توابع از طریق اینترفیس موجب هزینه های گسترش حافظه میشود، بنابراین از اسمبلی برای استفاده مجدد از داده های موجود در حافظه استفاده کنید
زمانی که از یک قرارداد A قصد دارید تابعی را در قرارداد B فراخوانی کنید، رایجترین روش استفاده از اینترفیس است. در این حالت، یک instance از قرارداد B با استفاده از آدرس آن ساخته میشود و سپس تابع مورد نظر فراخوانی میشود. این روش ساده و متداول است، اما کامپایلر Solidity هنگام کامپایل این نوع فراخوانی، داده هایی که باید به قرارداد B ارسال شوند را در یک موقعیت جدید از حافظه ذخیره میکند. این کار اغلب باعث گسترش حافظه (Memory Expansion) و مصرف گس اضافی میشود، حتی در مواردی که ضرورتی ندارد.
با استفاده از اسمبلی درون خطی (inline assembly)، میتوانیم کد را به شکل بهینه تری بنویسیم و مقداری از گس را ذخیره کنیم؛ مثلاً با استفاده مجدد از موقعیتهایی از حافظه که دیگر نیازی به آنها نداریم یا (در صورتی که داده ارسالی به قرارداد B کمتر از ۶۴ بایت باشد) استفاده از فضای scratch memory برای ساخت calldata.
مثال مقایسه بین دو حالت:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
/// 30570 contract Sol { function set(address addr, uint256 num) external { Callme(addr).setNum(num); } } /// 30350 contract Assembly { function set(address addr, uint256 num) external { assembly { mstore(0x00, hex"cd16ecbf") mstore(0x04, num) if iszero(extcodesize(addr)) { revert(0x00, 0x00) // revert if address has no code deployed to it } let success := call(gas(), addr, 0x00, 0x00, 0x24, 0x00, 0x00) if iszero(success) { revert(0x00, 0x00) } } } } contract Callme { uint256 num = 1; function setNum(uint256 a) external { num = a; } } |
از مقایسه بالا مشاهده میشود که اجرای تابع set(uint256)
در قرارداد Assembly
حدود ۲۲۰ گس کمتر از حالت استفاده از اینترفیس در قرارداد Sol
مصرف میکند.
نکته مهم: هنگام استفاده از اسمبلی برای فراخوانی خارجی (external calls)، حتماً بررسی کنید که آدرسی که به آن فراخوانی میکنید دارای کد باشد. این کار با استفاده از extcodesize(addr)
انجام میشود. اگر خروجی صفر بود، به معنای این است که در آن آدرس هیچ قراردادی مستقر نشده و باید با revert
اجرای تابع را متوقف کنید. اهمیت این بررسی در آن است که فراخوانی به یک آدرس بدون کد، همیشه true
برمیگرداند، که میتواند منطق قرارداد شما را مختل کند.
۳. عملیات رایج ریاضی مانند min و max نسخه های بهینه تری از نظر مصرف گس دارند
نسخه غیربهینه:
1 2 3 |
function max(uint256 x, uint256 y) public pure returns (uint256 z) { z = x > y ? x : y; } |
نسخه بهینه:
1 2 3 4 5 6 |
function max(uint256 x, uint256 y) public pure returns (uint256 z) { /// @solidity memory-safe-assembly assembly { z := xor(x, mul(xor(x, y), gt(y, x))) } } |
چرا نسخه دوم بهینه تر است؟
نسخه اول از عملگر سهتایی ?:
استفاده میکند که در سطح opcode، شامل پرش های شرطی (conditional jumps) است. این نوع پرش ها در ماشین مجازی اتریوم (EVM) نسبتاً پرهزینه هستند. اما نسخه دوم از اسمبلی و عملیات ریاضی بدون شاخه (branchless) استفاده میکند، بهطوری که با بهرهگیری از xor
، mul
و gt
، مقدار بزرگتر را بدون هیچگونه پرش شرطی محاسبه میکند.
۴. به جای ISZERO(EQ())
از SUB
یا XOR
برای بررسی نابرابری استفاده کنید (در برخی شرایط بهینهتر است)
در استفاده از اسمبلی خطی (inline assembly) برای مقایسه برابری دو مقدار – مثلاً بررسی اینکه caller()
همان owner
است یا نه – معمولاً این الگو را میبینیم:
روش رایج:
1 2 3 |
if eq(caller(), sload(owner.slot)) { revert(0x00, 0x00) } |
اما روشی که در بسیاری از مواقع مصرف گس کمتری دارد، استفاده از sub
یا xor
است:
روش بهینه تر:
1 2 3 |
if eq(caller, sload(owner.slot)) { revert(0x00, 0x00) } |
چرا این روش بهینه تر است؟
-
در روش اول،
eq()
یک مقایسه انجام میدهد و سپسiszero()
روی آن اعمال میشود تا نابرابری را بررسی کند. این دو مرحله جداگانه هستند. -
در روش دوم،
sub()
یاxor()
تنها یک دستور است که اگر خروجی آن صفر نباشد (یعنی نابرابر باشند)، شرط برقرار میشود. در EVM این عملیاتها گاهی گس کمتری نسبت بهeq + iszero
مصرف میکنند.
این بهینه سازی به نسخه کامپایلر و زمینه استفاده کد بستگی دارد. همیشه باید قبل از اعمال چنین تغییراتی، تست بنچمارک گس بگیرید و عملکرد را در شرایط واقعی بررسی کنید.
۵. استفاده از اسمبلی خطی (inline assembly) برای بررسی address(0)
نوشتن قراردادها با استفاده از اسمبلی خطی معمولاً بهینه ترین روش از نظر مصرف گس است. این روش اجازه میدهد حافظه را مستقیماً مدیریت کرده و با استفاده از تعداد کمتری از اپکدها، همان منطق را اجرا کنیم — بدون نیاز به تکیه بر کد تولید شده توسط کامپایلر سالیدیتی.
یکی از رایجترین سناریوهایی که میتوان از اسمبلی برای بهینه سازی استفاده کرد، پیاده سازی مکانیزم احراز هویت (authentication) است. برای مثال، بررسی اینکه آدرس ارسالشده address(0)
نباشد.
در زیر یک مثال آورده شده است:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
// SPDX-License-Identifier: MIT pragma solidity 0.8.20; contract NormalAddressZeroCheck { function check(address _caller) public pure returns (bool) { require(_caller != address(0x00), "Zero address"); return true; } } contract AddressZeroCheckAssembly { // Saves about 90 gas function checkOptimized(address _caller) public pure returns (bool) { assembly { if iszero(_caller) { mstore(0x00, 0x20) mstore(0x20, 0x0c) mstore(0x40, 0x5a65726f20416464726573730000000000000000000000000000000000000000) // load hex of "Zero Address" to memory revert(0x00, 0x60) } } return true; } } |
۶. استفاده از selfbalance
بهجای address(this).balance
(در برخی شرایط بهینه تر است)
در سالیدیتی، زمانی که میخواهید موجودی (Balance) قرارداد جاری را بخوانید، معمولاً از دستور زیر استفاده میکنید:
1 |
address(this).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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
contract ExpensiveLogger { event BlockData(uint256 blockTimestamp, uint256 blockNumber, uint256 blockGasLimit); // cost: 26145 function returnBlockData() external { emit BlockData(block.timestamp, block.number, block.gaslimit); } } contract CheapLogger { event BlockData(uint256 blockTimestamp, uint256 blockNumber, uint256 blockGasLimit); // cost: 22790 function returnBlockData() external { assembly { mstore(0x00, timestamp()) mstore(0x20, number()) mstore(0x40, gaslimit()) log1(0x00, 0x60, 0x9ae98f1999f57fc58c1850d34a78f15d31bee81788521909bea49d7f53ed270b // event hash of BlockData ) } } } |
مثال بالا نشان میدهد که چطور میتوانیم با استفاده از حافظه (memory) برای ذخیره سازی داده هایی که قصد داریم در رویداد BlockData
ثبت کنیم، تقریباً ۲٬۰۰۰ گس صرفهجویی داشته باشیم.
در این مثال، نیازی به بهروزرسانی pointer حافظه آزاد (free memory pointer) نیست، زیرا اجرای تابع بلافاصله پس از ثبت event به پایان میرسد و ما هرگز دوباره به کد سالیدیتی بازنمیگردیم.
اکنون، بیایید یک مثال دیگر بررسی کنیم؛ جایی که نیاز داریم free memory pointer را بهروزرسانی کنیم:
- استفاده از اسمبلی برای هش کردن دادههایی با اندازه حداکثر ۹۶ بایت
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
contract ExpensiveHasher { bytes32 public hash; struct Values { uint256 a; uint256 b; uint256 c; } Values values; // cost: 113155function setOnchainHash(Values calldata _values) external { hash = keccak256(abi.encode(_values)); values = _values; } } contract CheapHasher { bytes32 public hash; struct Values { uint256 a; uint256 b; uint256 c; } Values values; // cost: 112107 function setOnchainHash(Values calldata _values) external { assembly { // cache the free memory pointer because we are about to override it let fmp := mload(0x40) // use 0x00 to 0x60 calldatacopy(0x00, 0x04, 0x60) sstore(hash.slot, keccak256(0x00, 0x60)) // restore the cache value of free memory pointer mstore(0x40, fmp) } values = _values; } } |
در مثال بالا، مشابه با نمونه اول، ما از اسمبلی برای ذخیره سازی مقادیر در ۹۶ بایت اول حافظه استفاده کردهایم که بیش از ۱٬۰۰۰ گس صرفهجویی به همراه دارد. همچنین توجه داشته باشید که در این مورد خاص، چون دوباره به کد سالیدیتی بازمیگردیم، در ابتدای بلوک اسمبلی، آدرس حافظه آزاد (free memory pointer) را کش (cache) کرده و در انتهای آن بهروزرسانی میکنیم.
این کار برای آن است که سازگاری با فرضیات کامپایلر سالیدیتی درباره وضعیت حافظه حفظ شود و در ادامه اجرای برنامه مشکلی پیش نیاید.
۸. استفاده از اسمبلی برای استفاده مجدد از فضای حافظه هنگام انجام بیش از یک فراخوانی خارجی
یکی از عملیاتهایی که باعث افزایش حافظه توسط کامپایلر سالیدیتی میشود، انجام فراخوانیهای خارجی (external calls) است. هنگام فراخوانی یک تابع از یک قرارداد دیگر، کامپایلر باید امضای تابع (function signature) مورد نظر و آرگومانهای مربوط به آن را در حافظه رمزگذاری (encode) کند. همانطور که میدانیم، سالیدیتی حافظه را پاک نمیکند یا مجدداً از آن استفاده نمیکند؛ بنابراین برای هر فراخوانی جدید، داده ها در آدرس جدیدی از حافظه ذخیره میشوند که منجر به گسترش فضای حافظه (memory expansion) و افزایش مصرف گس میشود.
با استفاده از اسمبلی داخلی (inline assembly)، ما میتوانیم این مشکل را حل کنیم. اگر آرگومان های تابع مورد نظر، کمتر از ۹۶ بایت باشند، میتوانیم از فضای حافظه موقت (scratch space) یا آدرس حافظه آزاد (free memory pointer) برای ذخیره این داده ها استفاده کنیم.
علاوه بر این، اگر بیش از یک فراخوانی خارجی انجام میدهیم، میتوانیم از همان فضای حافظه قبلی برای ذخیره آرگومانهای جدید استفاده کنیم، بدون آنکه حافظه را مجدداً افزایش دهیم. در چنین سناریویی، سالیدیتی بهطور پیشفرض به اندازه طول داده های بازگشتی، حافظه را افزایش میدهد؛ زیرا این داده ها در حافظه ذخیره میشوند.
اما اگر داده بازگشتی کمتر از ۹۶ بایت باشد، میتوانیم آن را نیز در scratch space ذخیره کنیم تا از افزایش حافظه و مصرف گس بیشتر جلوگیری شود.
در ادامه، مثالی از این تکنیک آورده شده است:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 |
contract Called { function add(uint256 a, uint256 b) external pure returns(uint256) { return a + b; } } contract Solidity { // cost: 7262 function call(address calledAddress) external pure returns(uint256) { Called called = Called(calledAddress); uint256 res1 = called.add(1, 2); uint256 res2 = called.add(3, 4); uint256 res = res1 + res2; return res; } } contract Assembly { // cost: 5281 function call(address calledAddress) external view returns(uint256) { assembly { // check that calledAddress has code deployed to it if iszero(extcodesize(calledAddress)) { revert(0x00, 0x00) } // first call mstore(0x00, hex"771602f7") mstore(0x04, 0x01) mstore(0x24, 0x02) let success := staticcall(gas(), calledAddress, 0x00, 0x44, 0x60, 0x20) if iszero(success) { revert(0x00, 0x00) } let res1 := mload(0x60) // second call mstore(0x04, 0x03) mstore(0x24, 0x4) success := staticcall(gas(), calledAddress, 0x00, 0x44, 0x60, 0x20) if iszero(success) { revert(0x00, 0x00) } let res2 := mload(0x60) // add results let res := add(res1, res2) // return data mstore(0x60, res) return(0x60, 0x20) } } } |
ما با استفاده از 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) جلوگیری شود.
در ادامه، نمونهای مشابه از این تکنیک را مشاهده میکنید:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
contract Solidity { // هزینه گس: 261032 function call() external returns (Called, Called) { Called called1 = new Called(); Called called2 = new Called(); return (called1, called2); } } contract Assembly { // هزینه گس: 260210 function call() external returns(Called, Called) { bytes memory creationCode = type(Called).creationCode; assembly { let called1 := create(0x00, add(0x20, creationCode), mload(creationCode)) let called2 := create(0x00, add(0x20, creationCode), mload(creationCode)) // اگر هرکدام از آدرسهای بازگشتی صفر باشند، عملیات لغو میشود if iszero(and(called1, called2)) { revert(0x00, 0x00) } // آدرسها را در حافظهی scratch ذخیره میکنیم mstore(0x00, called1) mstore(0x20, called2) // بازگشت آدرسها return(0x00, 0x40) } } } contract Called { function add(uint256 a, uint256 b) external pure returns(uint256) { return a + b; } } |
ما با استفاده از اسمبلی درونخطی (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
بعدی دیگر بررسی نمیشوند. این کار باعث صرفهجویی در گس میشود، زیرا از ارزیابی شرطهای بعدی جلوگیری میکند.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
// SPDX-License-Identifier: MIT pragma solidity 0.8.20; contract Require { function dontSplitRequireStatement(uint256 x, uint256 y) external pure returns (uint256) { require(x > 0 && y > 0); // هر دو شرط قبل از ریورت یا اجرای ادامه بررسی میشوند return x * y; } } contract RequireTwo { function splitRequireStatement(uint256 x, uint256 y) external pure returns (uint256) { require(x > 0); // اگر x <= 0 باشد، تراکنش ریورت میشود و شرط "y > 0" دیگر بررسی نمیشود require(y > 0); return x * y; } } |
۳. عبارت های revert
را نیز تفکیک کنید
مشابه با تفکیک عبارتهای require
، معمولاً با حذف عملگرهای بولی از داخل شرطهای if
میتوانید مقداری در مصرف گس صرفهجویی کنید.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
contract CustomErrorBoolLessEfficient { error BadValue(); function requireGood(uint256 x) external pure { if (x < 10 || x > 20) { revert BadValue(); } } } contract CustomErrorBoolEfficient { error TooLow(); error TooHigh(); function requireGood(uint256 x) external pure { if (x < 10) { revert TooLow(); } if (x > 20) { revert TooHigh(); } } } |
۴. همیشه از بازگشت های دارای نام (Named Returns) استفاده کنید
کامپایلر سالیدیتی در صورتی که متغیر بازگشتی در امضای تابع بهطور صریح تعریف شده باشد (یعنی از named return استفاده شده باشد)، کد بهینه تری تولید میکند. در عمل، موارد استثنا بسیار کمی برای این قاعده وجود دارد. بنابراین، اگر در جایی با بازگشت بدون نام (anonymous return) مواجه شدید، توصیه میشود نسخه دارای نام را نیز امتحان کنید تا ببینید کدام یک در نهایت مصرف گس کمتری دارد.
مثال زیر بازگشت بدون نام را نشان میدهد:
1 2 3 4 5 6 7 8 9 10 11 |
// SPDX-License-Identifier: MIT pragma solidity 0.8.20; contract NamedReturn { function myFunc1(uint256 x, uint256 y) external pure returns (uint256) { require(x > 0); require(y > 0); return x * y; } } |
و این هم نسخهای با بازگشت دارای نام که بهینه تر است:
1 2 3 4 5 6 7 8 |
contract NamedReturn2 { function myFunc2(uint256 x, uint256 y) external pure returns (uint256 z) { require(x > 0); require(y > 0); z = x * y; } } |
در نسخه دوم، متغیر z
بهعنوان متغیر خروجی تعریف شده و درون بدنه تابع مقداردهی میشود. این روش بهطور معمول منجر به تولید bytecode کوچکتر و مصرف گس کمتر در زمان اجرا میشود.
۵. شرط های if-else که دارای “نفی” (negation) هستند را معکوس بنویسید
این همان مثالی است که در ابتدای مقاله به آن اشاره شده بود. در قطعه کد زیر، نسخه دوم با حذف عملگر !
، از یک نفی غیرضروری جلوگیری میکند. از نظر تئوری، استفاده از عملگر !
میتواند منجر به افزایش اندک در هزینه محاسباتی شود. اما همانطور که در ابتدای مقاله گفته شد، همیشه باید هر دو روش را بنچمارک (مقایسه عملکردی) کنید؛ چرا که کامپایلر سالیدیتی گاهی خودش این بهینه سازی ها را بهطور خودکار انجام میدهد.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
function cond() public { if (!condition) { action1(); } else { action2(); } } function cond() public { if (condition) { action2(); } else { action1(); } } |
۶. از ++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 2 3 4 5 6 7 8 |
for (uint256 i; i < limit; ) { // داخل بدنهی حلقه unchecked { ++i; } } |
دو تفاوت اصلی این حلقه با حلقه معمولی:
-
استفاده از
++i
بهجایi++
(که در بخش قبلی توضیح داده شد) -
قرار دادن افزایش شمارنده در بلاک
unchecked
، چون متغیرlimit
بهصورت طبیعی جلوی سرریز را میگیرد.
این ساختار باعث صرفه جویی در مصرف گس میشود.
۹. حلقه های do-while ارزانتر از حلقه های for هستند
اگر بخواهید بهینه سازی مصرف گس را تا حد ممکن پیش ببرید، حتی به قیمت استفاده از کدی که کمی غیرمعمول به نظر برسد، حلقه های do-while در سالیدیتی از حلقه های for
بهصرفهتر هستند؛ حتی در حالتی که برای جلوگیری از اجرای حلقه، یک شرط if
قبل از آن اضافه کنید.
بهعبارت دیگر، استفاده از ساختار do { ... } while();
معمولاً گس کمتری نسبت به حلقه های for
مصرف میکند، و این میتواند در کدهایی که بارها تکرار میشوند یا در محیطهایی با محدودیت منابع (مثل بلاکچین) تفاوت قابل توجهی ایجاد کند.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
// SPDX-License-Identifier: MIT pragma solidity 0.8.20; // times == 10 in both tests contract Loop1 { function loop(uint256 times) public pure { for (uint256 i; i < times;) { unchecked { ++i; } } } } contract Loop2 { function loop(uint256 times) public pure { if (times == 0) { return; } uint256 i; do { unchecked { ++i; } } while (i < times); } } |
۱۰. از تبدیل غیرضروری متغیرها پرهیز کنید؛ متغیرهایی که از uint256
کوچکتر هستند (مثل bool
و address
) در صورتی که بسته بندی (packing) نشده باشند، کارایی کمتری دارند
استفاده از uint256
برای متغیرهای عدد صحیح معمولاً بهینهتر است، مگر اینکه واقعاً به نوع کوچکتری نیاز داشته باشید. دلیل این موضوع این است که ماشین مجازی اتریوم (EVM) در زمان اجرای عملیات، همه مقادیر عددی کوچکتر از uint256
را به uint256
تبدیل میکند. این تبدیل باعث مصرف گس اضافی میشود.
بنابراین، اگر متغیرهای uint8
، bool
یا address
را بهصورت مجزا و بدون بستهبندی در ساختارهایی مانند struct
یا آرایهها استفاده کنید، کد شما گس بیشتری مصرف خواهد کرد. برای بهینه سازی، یا از uint256
استفاده کنید، یا اگر حتماً متغیرهای کوچکتر نیاز دارید، آنها را در کنار هم طوری قرار دهید که بسته بندی شوند و حافظه به صورت فشرده تخصیص یابد.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
// SPDX-License-Identifier: MIT pragma solidity 0.8.20; contract Unnecessary_Typecasting { uint8 public num; function incrementNum() public { num += 1; } } // Uses less gas contract NoTypecasting { uint256 public num; function incrementNumCheap() public { num += 1; } } |
۱۱. استفاده از میانبر منطقی (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 استفاده کنید
کامپایلر سالیدیتی هنگام بهینه سازی، روی دو جنبه اصلی تمرکز دارد:
-
هزینه استقرار (Deployment) قرارداد هوشمند
-
هزینه اجرای توابع (Runtime Execution)
پارامتری به نام runs
در تنظیمات کامپایلر وجود دارد که مشخص میکند کامپایلر فرض کند یک تابع چند بار در طول عمر قرارداد اجرا خواهد شد. این عدد تعیینکننده نحوهی بهینه سازی است و در اینجا یک موازنه وجود دارد:
-
مقادیر پایین برای
runs
(مثل ۲۰ یا ۵۰) بهینه سازی را بر کاهش حجم کد هنگام استقرار متمرکز میکنند. این باعث میشود کد اولیه قرارداد کوچکتر باشد و هزینه گس استقرار کاهش پیدا کند، ولی کد زمان اجرا ممکن است بهینه نباشد و در درازمدت گس بیشتری مصرف کند. -
مقادیر بالا برای
runs
(مثل ۵۰۰۰ یا حتی ۲۰۰۰۰) تمرکز را بر بهینه سازی زمان اجرای توابع میگذارند. در این حالت، کد اولیه قرارداد بزرگتر میشود و ممکن است استقرار کمی گرانتر باشد، ولی در عوض، توابع در زمان اجرا گس بسیار کمتری مصرف میکنند.
با توجه به این موازنه، اگر قرارداد شما قرار است بارها و بهصورت مکرر توسط کاربران یا دیگر قراردادها مورد استفاده قرار گیرد، توصیه میشود که مقدار runs
را تا حد ممکن بزرگ انتخاب کنید. این کار شاید در ابتدا هزینه استقرار را کمی بالا ببرد، اما در بلندمدت باعث صرفهجویی چشمگیر در مصرف گس خواهد شد.
۱۴. برای توابع پرتکرار، از نامهایی با چینش بهینه استفاده کنید
در ماشین مجازی اتریوم (EVM)، فراخوانی توابع از طریق یک jump table انجام میشود. در این جدول، توابع براساس ترتیب هگزادسیمال selectorهایشان مرتب میشوند؛ یعنی اگر دو تابع با selectorهایی مثل 0x000071c3
و 0xa0712d68
داشته باشیم، تابعی که selector آن عدد هگزادسیمال کوچکتری دارد (در اینجا 0x000071c3
) زودتر بررسی میشود.
بنابراین، اگر تابعی در قرارداد شما بسیار پرکاربرد است، بهتر است برای آن نامی انتخاب کنید که selector آن در ترتیب هگزادسیمال جلوتر باشد. این کار باعث میشود که آن تابع در جدول پرش سریعتر بررسی شود و مقدار کمی گس صرفهجویی شود. البته باید توجه داشت که اگر بیش از چهار تابع در قرارداد وجود داشته باشد، EVM به جای جستجوی خطی از جستجوی دودویی (binary search) برای انتخاب تابع استفاده میکند، ولی حتی در این حالت هم داشتن selector کوچکتر میتواند در مصرف گس مؤثر باشد.
علاوه بر این، اگر selector تابع دارای صفرهای پیشوند باشد (leading zeros)، هزینه calldata
نیز کاهش مییابد. چرا که در کالدیتا، بایتهای صفر فقط ۴ گس مصرف میکنند ولی بایتهای غیر صفر ۱۶ گس.
به مثال زیر دقت کنید:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
// SPDX-License-Identifier: MIT pragma solidity 0.8.20; contract FunctionWithLeadingZeros { uint256 public totalSupply; // selector = 0xa0712d68 function mint(uint256 amount) public { totalSupply += amount; } // selector = 0x000071c3 ← این تابع ارزانتر از تابع بالاست function mint_184E17(uint256 amount) public { totalSupply += amount; } } |
۱۵. استفاده از شیفت بیت بهجای ضرب یا تقسیم در توانهای عدد ۲ مقرونبهصرفهتر است
در زبان سالیدیتی، زمانی که میخواهید عددی را در توانهایی از عدد ۲ ضرب یا تقسیم کنید (مانند ۲، ۴، ۸، ۱۶ و…)، استفاده از عملیات شیفت بیت (bit shifting) بهجای عملگرهای *
و /
باعث صرفهجویی در مصرف گس میشود.
برای مثال، دو عبارت زیر از نظر محاسباتی معادل هستند:
1 2 |
10 * 2 10 << 1 # shift 10 left by 1 |
1 2 |
8 / 4 8 >> 2 # shift 8 right by 2 |
shr
(شیفت به راست) و shl
(شیفت به چپ)، فقط ۳ گس مصرف میکنند، در حالیکه اپکدهای ضرب (mul
) و تقسیم (div
) ۵ گس هزینه دارند.
علاوه بر تفاوت مستقیم در مصرف گس، صرفهجویی اصلیتر از اینجا میآید که بر خلاف عملگرهای ضرب و تقسیم، در شیفت بیت ها سالیدیتی بررسی overflow/underflow یا تقسیم بر صفر انجام نمیدهد. به همین دلیل، نه تنها ارزانتر است بلکه سریعتر هم اجرا میشود.
با این حال باید هنگام استفاده از شیفت بیت دقت کنید، زیرا به دلیل نبود بررسیهای محافظتی، استفاده نادرست ممکن است منجر به بروز خطاهای خطرناک مانند سرریز عدد (overflow) یا مقداردهی نادرست شود.
۱۶. در برخی موارد، ذخیره کردن مقادیر calldata
در متغیر محلی (cache) مقرونبهصرفهتر است
اگرچه دستور calldataload
در ماشین مجازی اتریوم (EVM) جزو دستورهای کمهزینه است، اما کامپایلر سالیدیتی گاهی اوقات در صورت کش کردن مقدار calldata
در یک متغیر محلی، کد بهینه تری تولید میکند.
در شرایطی که نیاز به دسترسی مکرر به یک مقدار از calldata
دارید، بهتر است هر دو روش را تست و مقایسه (benchmark) کنید تا مشخص شود کدام گزینه مصرف گس کمتری دارد.
1 2 3 4 5 6 7 8 9 10 11 |
contract LoopSum { function sumArr(uint256[] calldata arr) public pure returns (uint256 sum) { uint256 len = arr.length; for (uint256 i = 0; i < len; ) { sum += arr[i]; unchecked { ++i; } } } } |
۱۷. از الگوریتم های بدون شاخه (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
است
این موضوع تا حدود نسخه ۰.۸.۱۲ سالیدیتی صحت داشت، اما دیگر درست نیست. اگر مجبور هستید از نسخههای قدیمی استفاده کنید، همچنان میتوانید هر دو حالت را بنچمارک بگیرید و مقایسه کنید.
راستی! برای دریافت مطالب جدید در کانال تلگرام یا پیج اینستاگرام سورس باران عضو شوید.
- انتشار: ۳ مرداد ۱۴۰۴
دسته بندی موضوعات
- آموزش ارز دیجیتال
- آموزش برنامه نویسی
- آموزش متنی برنامه نویسی
- اطلاعیه و سایر مطالب
- پروژه برنامه نویسی
- دوره های تخصصی برنامه نویسی
- رپورتاژ
- فیلم های آموزشی
- ++C
- ADO.NET
- Adobe Flash
- Ajax
- AngularJS
- apache
- ARM
- Asp.Net
- ASP.NET MVC
- AVR
- Bootstrap
- CCNA
- CCNP
- CMD
- CSS
- Dreameaver
- EntityFramework
- HTML
- IOS
- jquery
- Linq
- Mysql
- Oracle
- PHP
- PHPMyAdmin
- Rational Rose
- silver light
- SQL Server
- Stimulsoft Reports
- Telerik
- UML
- VB.NET&VB6
- WPF
- Xml
- آموزش های پروژه محور
- اتوکد
- الگوریتم تقریبی
- امنیت
- اندروید
- اندروید استودیو
- بک ترک
- بیسیک فور اندروید
- پایتون
- جاوا
- جاوا اسکریپت
- جوملا
- دلفی
- دوره آموزش Go
- دوره های رایگان پیشنهادی
- زامارین
- سئو
- ساخت CMS
- سی شارپ
- شبکه و مجازی سازی
- طراحی الگوریتم
- طراحی بازی
- طراحی وب
- فتوشاپ
- فریم ورک codeigniter
- فلاتر
- کانستراکت
- کریستال ریپورت
- لاراول
- معماری کامپیوتر
- مهندسی اینترنت
- هوش مصنوعی
- یونیتی
- کتاب های آموزشی
- Android
- ASP.NET
- AVR
- LINQ
- php
- Workflow
- اچ تی ام ال
- بانک اطلاعاتی
- برنامه نویسی سوکت
- برنامه نویسی موبایل
- پاسکال
- پایان نامه
- پایتون
- جاوا
- جاوا اسکریپت
- جی کوئری
- داده کاوی
- دلفی
- رباتیک
- سئو
- سایر کتاب ها
- سخت افزار
- سی اس اس
- سی پلاس پلاس
- سی شارپ
- طراحی الگوریتم
- فتوشاپ
- مقاله
- مهندسی نرم افزار
- هک و امنیت
- هوش مصنوعی
- ویژوال بیسیک
- نرم افزار و ابزار برنامه نویسی
- وردپرس