پرش به محتوای اصلی
پرش به محتوای مقاله

زبان برنامه‌نویسی Goose با حذف حافظه Heap سرعت C++ را ۳.۳ برابر کرد

·۲۷ شهریور ۱۴۰۵۱۶ دقیقه مطالعه۲ بازدید
لوگوی زبان برنامه‌نویسی غوز: تصویر یک غوز در حال پرواز با رنگ‌های آبی و سفید
لوگوی زبان برنامه‌نویسی غوز: تصویر یک غوز در حال پرواز با رنگ‌های آبی و سفید
اشتراک‌گذاری
واقعاً چه چیز جدید است؟

معرفی یک مدل حافظه کاملاً Heapless که ایمنی حافظه را بدون نیاز به Garbage Collection یا Lifetime Annotations و با سرعتی ۳.۳ برابر بیشتر از C++ فراهم می‌کند.

اگر امروز برای بهینه‌سازی کدها ساعت‌ها وقت صرف مدیریت حافظه در C++ می‌کنید، زبان Goose ادعا می‌کند که می‌تواند همان کار را با سرعتی ۳.۳ برابر بیشتر و بدون خطرات امنیتی همیشگی انجام دهد. این یک ادعای جسورانه است: دستیابی به حداکثر سرعت بدون پذیرفتن پیچیدگی‌های خردکننده زبان Rust. این زبان با حذف کامل Heap از مدل حافظه خود، به این سرعت دست می‌یابد و رویکردی ساختاری را برای مدیریت حافظه معرفی می‌کند که نیاز به Garbage Collection، تخصیص‌دهنده‌ها (Allocators) و یادداشت‌های پیچیده Lifetime که در Rust دیده می‌شود را از بین می‌برد.

برای دهه‌ها، برنامه‌نویسان سیستم با یک انتخاب سخت روبرو بودند: یا سرعت خام و خطرناک C++ را بپذیرند یا به سراغ ایمنی سخت‌گیرانه Rust بروند. صنعت پذیرفته بود که ایمنی حافظه یا به قیمت کاهش سرعت (به دلیل وجود Garbage Collector) تمام می‌شود یا نیازمند یادگیری منحنی دشوار Borrow Checker است. Goose اکنون به عنوان راه سوم معرفی شده است تا سرعت و ایمنی را بدون بار ذهنی سینتکس‌های پیچیده فراهم کند.

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

معماری بدون Heap

در قلب Goose ایده‌ای رادیکال نهفته است: حذف کامل حافظه Heap (Heap) — شبیه به این است که به جای اجاره کردن اتاق‌های پراکنده در یک هتل بزرگ، تمام وسایل خود را در چند جعبه مرتب و متوالی در یک اتاق داشته باشید. به جای Heap، کامپایلر تعدادی پشته داده (Data Stack) را مدیریت می‌کند که هر مقدار پویا به‌صورت خطی در آن‌ها قرار می‌گیرد. رشد حافظه با یک جابه‌جایی ساده اشاره‌گر (Pointer Bump) انجام می‌شود و حافظه بلافاصله پس از خروج از محدوده (Scope)، آزاد می‌گردد.

طبق مستندات پروژه، این طراحی نیاز به شمارش ارجاعات (Reference Counting)، تخریب‌کننده‌ها (Destructors) و تخصیص‌دهنده‌های سنتی را از بین می‌برد. چون کامپایلر این پشته‌ها را به‌صورت استاتیک تخصیص می‌دهد، آزاد کردن یک ساختار با میلیون‌ها المان تنها به یک عملیات ذخیره‌سازی ساده نیاز دارد، فارغ از اینکه داده‌ها چقدر تو در تو باشند. در این مدل، هیچ داده‌ای هرگز در حافظه جابه‌جا نمی‌شود. یک ارجاع به یک آرایه در حال رشد، تا زمانی که خود آرایه وجود دارد معتبر می‌ماند. این ویژگی به برنامه‌نویس اجازه می‌دهد ارجاعات تایپ‌شده را حفظ کند؛ در حالی که در C++ باید فضا رزرو کرد و در Rust ایمن، اغلب مجبور به استفاده از ایندکس‌های u32 هستیم.

لوگوی زبان برنامه‌نویسی Goose: تصویری از یک غاز در کنار نام پروژه و توضیحات GitHub.

جزئیات مدل حافظه

یک برنامه در Goose از پشته فراخوانی بومی، داده‌های استاتیک و N پشته داده تشکیل شده است که مقدار N را کامپایلر تعیین می‌کند. هر پشته داده یک فضای آدرس بزرگ با یک اشاره‌گر Bump است. در هر پشته، حداکثر یک مقدار تغییرپذیر زنده است و همیشه در راس قرار دارد. این سازوکار تضمین می‌کند که رشد داده‌ها هرگز باعث جابه‌جایی داده‌های موجود نشود و نیازی به بررسی ظرفیت نباشد.

تمام این محدودیت‌ها در زمان کامپایل اثبات می‌شوند و در زمان اجرا (Runtime) هیچ چیزی جز اشاره‌گرهای Bump وجود ندارد. برای مثال، یک آرایه با رشد یک‌طرفه مانند var scratch: u8[>..] = [] اجازه می‌دهد میلیون‌ها داده با سرعت بالا از طریق Pointer Bump اضافه شوند و کل ساختار در لحظه خروج از Scope با یک دستور ذخیره‌سازی در راس پشته پاک شود.

بردهای ساختاری حافظه

  • چیدمان متوالی: یک رشته، یک آرایه از رشته‌ها و یک رکورد با فیلدهای متغیر-اندازه، هر کدام یک بلوک متوالی هستند و هیچ اشاره‌گر داخلی ندارند.
  • رکورد بدون تخصیص: رکوردی که در C++ به ۱۶۰ بایت و یک بار تخصیص حافظه نیاز دارد، در Goose تنها ۲۹ بایت است و هیچ تخصیصی نمی‌طلبد.
  • بهره‌وری حافظه نهان (Cache): چون داده‌ها کاملاً تخت (Flat) هستند، یک دفتر سفارشات (Order Book) می‌تواند به‌صورت یک آرایه واحد باشد که بدون واسطه و بدون Indirection از طریق Cache جریان یابد.
  • بازگشت با هزینه صفر: بازگرداندن یک آرایه رشدپذیر به‌صورت Value هیچ هزینه‌ای ندارد چون مستقیماً در مقصد ساخته می‌شود.

بنچمارک‌های عملکرد

بر اساس گزارش‌های منتشر شده توسط تیم توسعه، Goose در ۱۶ محک مختلف برتری محسوسی نسبت به زبان‌های تثبیت‌شده دارد. این نتایج بر اساس میانگین هندسی است و زمان کل اجرای فرآیند، شامل مرحله تخریب (Teardown) را اندازه‌گیری می‌کند:

  • سرعت: با استفاده از بک‌اند MSVC، این زبان ۳.۳۰ برابر سریع‌تر از C++ استاندارد است. با بک‌اند Clang این عدد به ۳.۳۵ می‌رسد و ۱.۱۲ برابر سریع‌تر از ایمن‌ترین حالت Rust است.
  • بهره‌وری حافظه: مصرف حافظه در نقطه اوج (Peak Memory) در Goose حدود ۱.۹۳ برابر کمتر از C++ و ۱.۲۴ برابر کمتر از Rust است.
  • بهینه‌سازی: حتی در برابر کدهای C++ که به‌صورت دستی بهینه‌ شده‌اند، Goose برتری سرعت ۱.۱۶ تا ۱.۱۹ برابری را حفظ می‌کند.

این دستاوردها از بهره‌وری‌های ساختاری ناشی می‌شوند. برای مثال، رکوردی که در C++ به ۱۶۰ بایت و یک تخصیص نیاز دارد، در Goose تنها ۲۹ بایت است و صفر تخصیص می‌طلبد. در مقایسه، همین ساختار در Rust به ۱۵۳ بایت و ۴ تخصیص حافظه نیاز دارد.

ایمنی حافظه بدون یادداشت‌های پیچیده

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

برخلاف Rust، در اینجا قوانین پیچیده Aliasing یا انحصار (Exclusivity) وجود ندارد. کامپایلر توابع را بر اساس ریشه تخصصی می‌کند، به این معنا که بررسی‌های ایمنی در زمان کامپایل رخ می‌دهند، نه در زمان اجرا. اگر ارجاعی در متغیری ذخیره شود که عمرش بیشتر از منبع است، کامپایلر بلافاصله خطا داده و هر دو طرف تضاد را نام می‌برد. برای مثال، تلاش برای ذخیره ارجاعی که ریشه آن یک آرایه محلی inner است در متغیری مانند w که عمرش بیشتر از آن Scope است، منجر به خطای زمان کامپایل می‌شود.

ساختارهای داده پیشرفته

برای حداکثر کردن بهره‌وری Cache، Goose از خانواده‌ای از انواع آرایه‌ها استفاده می‌کند که تفاوت آن‌ها در نحوه مدیریت اندازه است. همه این‌ها به‌صورت [metadata][elements...] به‌صورت خطی و فشرده ذخیره می‌شوند و هرگز به صورت اشاره‌گر به یک بلوک جداگانه نیستند.

انواع آرایه‌ها و رشته‌ها:

  • T[k]: اندازه ثابت که در زمان کامپایل مشخص است.
  • T[] یا T[varint]: در زمان ساخت اندازه می‌گیرند و سپس منجمد می‌شوند.
  • T[..k] یا T[..]: ظرفیت به‌صورت داخلی ذخیره شده و آرایه می‌تواند در این محدوده رشد یا کوچک شود.
  • T[>..]: فقط رشد می‌کند. این ابزار اصلی زبان است؛ ارجاعات به این آرایه‌ها حتی هنگام رشد معتبر می‌مانند. این یک برتری کلیدی نسبت به std::vector یا Vec است که با تغییر اندازه، حافظه را مجدداً تخصیص داده و ارجاعات را باطل می‌کنند.
  • T[>..<]: رشد و کوچک شدن، که برای پیاده‌سازی پشته‌ها، صف‌ها و Heapها استفاده می‌شود.

رشته‌ها نیز به‌عنوان آرایه‌های u8 مدیریت می‌شوند. یک u8[>..] به‌عنوان Builder عمل می‌کند، u8[] یک رشته نهایی است که به‌صورت خطی ذخیره شده و u8[..16] یک رشته کوچک داخل یک Struct است. یک برش (Slice) با نماد T[:] پارامتری جهانی برای پردازش محدوده‌هاست که اجازه می‌دهد هر نوع آرایه‌ای بدون کپی شدن به آن تبدیل شود. یک دیکشنری با کلید u8[:] هیچ رشته‌ای را ذخیره نمی‌کند، بلکه فقط Viewهایی از آن‌ها را نگه می‌دارد.

ADTهای حالت متغیر

در Goose تنها فرم پلی‌مورفیسم پویا، Enumها (Algebraic Data Types) هستند و هیچ ارث‌بری یا vtable وجود ندارد. این‌ها در دو حالت ذخیره می‌شوند:

  • حالت ثابت (Shape): یک تگ به‌همراه فضای کافی برای بزرگ‌ترین مقدار (Payload). این‌ها قابل ایندکس‌گذاری و بازنویسی هستند.
  • حالت متغیر (Shape..): هر مقدار دقیقاً به اندازه نوع خود فضا می‌گیرد. این حالت باعث می‌شود آرایه متوالی باشد و از تغییر نوع مقادیر جلوگیری کند، اما اجازه ارجاع به محتوا را می‌دهد.

در بنچمارک‌ها، ADTهای حالت متغیر ۴ برابر حافظه کمتر و ۲ برابر سرعت بیشتری نسبت به Enumهای Rust دارند. توزیع (Dispatch) از طریق «توابع Case» انجام می‌شود؛ یعنی یک Overload برای هر Variant که از طریق یک Jump Table هدایت می‌شود. این قابلیت، عملکرد یک فراخوانی مجازی (Virtual Call) را بدون سربار vtable فراهم می‌کند.

ارجاعات نسبی و پایداری داده‌ها

به جای استفاده از آدرس‌های ۸ بایتی ماشین، Goose می‌تواند از ارجاعات نسبی (Relative References) استفاده کند که به‌صورت آفست‌های کوچک (۱، ۲ یا ۴ بایتی) ذخیره می‌شوند. یک T&<u32> فاصله فیلد تا هدف در همان آرایه را اندازه‌گیری می‌کند و ساختار را مستقل از موقعیت (Position-independent) می‌کند. یک T&<u32 in pool> از پایه یک Pool نام‌گذاری شده لینک می‌شود و به آرایه‌های دیگر اجازه می‌دهد به آن Pool لینک شوند. یک T&<u32>? از آفست ۰ به‌عنوان Null استفاده می‌کند.

این یعنی یک ساختار داده در واقع فرمت فایل خودش است. ذخیره کردن آن تنها یک نوشتن ساده از بایت‌هاست و بارگذاری آن فقط یک خواندن و یک مرحله تأیید است. تابع from_bytes فریم‌ها، تگ‌ها، طول‌ها و لینک‌ها را بررسی می‌کند تا بایت‌های مخرب یا فاسد را قبل از تبدیل به مقدار رد کند. این سازوکار نیاز به Serializerها، Schemaها یا اصلاح اشاره‌گرها (Pointer Fixups) را کاملاً حذف می‌کند.

ساخت و مدیریت خطا

Goose ساخت بدون کپی (Copy-free construction) را تضمین می‌کند (بخش ۴.۳ مشخصات). یک مقدار مستقیماً در مقصد نهایی خود، فارغ از عمق فراخوانی‌ها، ساخته می‌شود. برای مثال، دستور items.push(parse(line)) رکورد تجزیه‌شده را مستقیماً در آرایه می‌نویسد. بازگرداندن یک آرایه رشدپذیر به‌صورت Value هیچ هزینه‌ای ندارد چون مستقیماً در متغیر فراخواننده ساخته می‌شود.

مدیریت خطا نیز با سینتکس return E from f ساده شده است. این قابلیت اجازه می‌دهد یک تابع، خطا را چندین سطح در پشته فراخوانی به بالا بفرستد بدون اینکه نیاز به Unwinder، نوع Result یا اپراتور ? در هر خط باشد. این موضوع به‌صورت استاتیک بررسی می‌شود؛ برای مثال، هر فراخوانی تابع پارسینگ باید در تابعی باشد که بتواند این بازگشت را مدیریت کند. این سیستم به‌جای Unwinder، به‌عنوان یک Discriminant مخفی در هر فریم پیاده شده و پیام خطا مستقیماً در جایی که فراخواننده می‌خواهد ساخته می‌شود.

هم‌روندی و جنریک‌ها

در Goose، رشته‌ها (Threads) به‌عنوان برنامه‌های مجزا با حافظه ایزوله در نظر گرفته می‌شوند. یک thread_fn به‌عنوان یک برنامه مستقل با پشته‌های داده و کپی‌هایی از متغیرهای Global کامپایل می‌شود. Workerها از طریق صف‌های تایپ‌شده و با استفاده از memcpy ارتباط برقرار می‌کنند. چون مقادیر در Goose متوالی و تخت هستند، انتقال داده‌ها یک کپی ساده در حافظه است که باعث حذف رقابت‌های داده‌ای (Data Races)، قفل‌ها (Locks)، Atomics و ترتیب‌های حافظه (Memory Orderings) از زبان می‌شود.

جنریک‌ها نیز بدون تشریفات هستند. هر پارامتر بدون تایپ به‌طور خودکار جنریک است. مقادیر توابع موجودات زمان کامپایل هستند، به این معنی که فراخوانی xs.filter() { it > 0 } دقیقاً به همان حلقه‌ای کامپایل می‌شود که به نظر می‌رسد. هیچ شیء Closure یا اشاره‌گر تابعی وجود ندارد؛ همه چیز Monomorphize و Inline می‌شود. یک بلوک می‌تواند از تابع محیطی خود بازگردد و توابع تودرتو می‌توانند متغیرهای محلی تابع بیرونی را ببینند.

پیاده‌سازی و ریشه در هوش مصنوعی

نکته جالب این است که کامپایلر Goose — با حدود ۳۰ هزار خط کد C++ — تقریباً به‌طور کامل توسط هوش مصنوعی و بر اساس طراحی انسانی Wouter van Oortmerssen تولید شده است. پیاده‌سازی آن از ساختار کامپایلر Lobster تقلید می‌کند. این رویکرد در تولید ابزارهای پیچیده توسط AI مشابه است با آنچه در سیستم‌های خودساز عامل‌های Seed مشاهده می‌کنیم که ابزارهای خود را بدون نیاز به فریم‌ورک‌های سنتی خلق می‌کنند.

این کامپایلر یک فایل C واحد تولید می‌کند و به این معنی است که هر جا کامپایلر C باشد، Goose اجرا می‌شود. همچنین شامل یک بک‌اند TinyCC است که اجازه می‌دهد برنامه‌ها بدون مرحله ساخت جداگانه (در حالت JIT) اجرا شوند. این زبان یک FFI مستقیم از طریق extern fn فراهم می‌کند که اجازه می‌دهد نمادهای C مستقیماً فراخوانی شوند. یک Slice از مرز FFI به‌عنوان یک جفت { data, len } عبور می‌کند و یک Struct می‌تواند از طریق یک اشاره‌گر پر شود.

موازنه و محدودیت‌ها

این عملکرد خیره‌کننده به بهای محدودیت‌های خاصی به‌دست آمده است. برنامه‌نویسان باید به مالکیت داده‌ها و محدوده (Scope) توجه ویژه‌ای داشته باشند. توابع بازگشتی (Recursive) نمی‌توانند مالک داده‌های رشدپذیر باشند و باید یک Pool قابل استفاده مجدد را به‌عنوان پارامتر بگیرند. یک Pool قابل استفاده مجدد، یک آرایه رشد-یک‌طرفه را با یک Freelist مخفی جفت می‌کند، جایی که alloc_ref یک جایگاه را بازیافت کرده یا اضافه می‌کند و free آن را بازمی‌گرداند.

سایر محدودیت‌ها عبارتند از:

  • آرایه‌های با المان‌های متغیر اندازه قابل پیمایش هستند اما نمی‌توان به آن‌ها ایندکس زد.
  • Enumهای حالت ثابت قابل ارجاع نیستند.
  • Enumهای حالت متغیر قابل بازنویسی نیستند.
  • هیچ Closure فرارکننده، اشاره‌گر تابعی و هیچ Dispatch پویایی فراتر از تگ‌های ADT وجود ندارد.
  • زبان نیازمند کامپایل کل برنامه (Whole-program compilation) است.

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

این تغییر در مدیریت حافظه، مفروضات بنیادی برنامه‌نویسی سیستم را تغییر می‌دهد. Goose با اثبات اینکه یک مدل بدون Heap می‌تواند از C++ پیشی بگیرد، نشان می‌دهد که گلوگاه نرم‌افزارهای مدرن تنها الگوریتم‌ها نیستند، بلکه سربار ساختاری نحوه تخصیص حافظه است. این بهینه‌سازی در لایه‌های زیرین، یادآور تلاش‌هایی است که در لایه حافظه شناختی Friday برای کاهش هزینه‌های پردازشی و توکن‌ها در سیستم‌های هوشمند صورت گرفته است.

گام بعدی شما

  • اگر با زبان‌های سیستمی کار می‌کنید، مستندات رسمی و آموزش‌های Goose را برای درک مدل Heapless بررسی کنید.
  • ۲۶ برنامه نمونه پروژه را (از پارسرهای JSON تا Mandelbrot رشته‌ای و درخت فایل ساخته شده از دو Pool) مطالعه کنید تا قدرت مدل پشته‌های داده را ببینید.
  • در پروژه‌هایی که نیاز به انتقال سریع داده بین رشته‌ها دارند، مدل ایزولاسیون حافظه این زبان را ارزیابی کنید.

اما داستان سخت‌افزاری این تحول حتی شگفت‌انگیزتر است — به تحلیل ما درباره‌ی تراشه‌های Blackwell مراجعه کنید.

چرا این موضوع مهم است؟

این زبان با به چالش کشیدن سلطه C++ و Rust در لایه‌های زیرین سیستم، استانداردهای جدیدی برای سرعت و ایمنی تعریف می‌کند. تخصص تیم توسعه در حذف Heap، می‌تواند منجر به نسل جدیدی از نرم‌افزارهای سیستمی شود که مصرف انرژی و حافظه را به‌شدت کاهش می‌دهند.

تأثیر برای ایران

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

·نگاه ما
تحریریه دات‌هوش

حذف Heap در Goose نشان می‌دهد که ما احتمالاً بیش از حد به مدل‌های مدیریت حافظه دهه ۷۰ میلادی وابسته بوده‌ایم. این زبان با جابه‌جایی مسئولیت مدیریت حافظه از زمان اجرا به زمان کامپایل، ثابت می‌کند که ایمنی حافظه لزوماً به معنای پذیرش سربار یا پیچیدگی سینتکسی نیست. در واقع، Goose مسیر جدیدی را باز می‌کند که در آن ساختار داده‌ها مستقیماً با معماری Cache سخت‌افزار همسو می‌شود.

منابع

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

گفتگو

پنج‌شنبه‌های هوش‌محور

بسته‌ی هفتگی دات‌هوش

۵ خبر، ۲ ابزار، ۱ پرامپت در هر شماره. به‌زودی راه‌اندازی می‌شود — هر پنج‌شنبه صبح.

خبر کلیدی
ابزار کاربردی
پرامپت حرفه‌ای
تحلیل پژوهش
به‌زودی
زاویه‌ی ایرانی
به‌زودی
تمرین این هفته
به‌زودی

راهنماهای دات‌هوش

راهنماهای کاربردیِ دات‌هوش برای کار با هوش مصنوعی — از همین‌جا شروع کنید:

دات‌هوش

راهنمای فارسی هوش مصنوعی — با نگاه به ایران

اخبار روزانه، معرفی ابزارها و مدل‌ها، و آموزشِ کار با هوش مصنوعی؛ همیشه با این پرسش که از ایران چه چیزی کار می‌کند و چه چیزی نه.