مقالات معمّقة حول التكنولوجيا التي تشكّل المستقبل.

كيف تجعل مترجمات JIT اللغات الديناميكية سريعة

كيف تُحسّن مترجمات JIT اللغات الديناميكية مثل Ruby وPython وJavaScript بإزالة العمليات المكررة، مع أمثلة حقيقية من YJIT وZJIT.

جوهرة Ruby تدخل آلة متوهجة وتخرج كمذنب ضوئي سريع

يُقال عن Ruby إنه بطيء. وكذلك Python. وكذلك JavaScript — أو على الأقل كان كذلك، حتى جعله V8 سريعاً بما يكفي لتشغيل أحمال العمل على الخادم. قصة كيف تصبح اللغات الديناميكية سريعة هي قصة الترجمة اللحظية (JIT - Just-In-Time)، وهي من أكثر المجالات إثارةً للاهتمام في علوم الحاسب التطبيقية. الفصل الأحدث: يزيل ZJIT الآن عمليات تحميل وتخزين الكائنات الزائدة على مستوى التمثيل الوسيط، وهو نفس نوع التحسينات الذي جعل TurboFan في V8 فعّالاً جداً.

إذا تساءلت يوماً لماذا يعمل كود Ruby بجزء صغير من سرعة C، أو كيف أصبح JavaScript سريعاً بما يكفي لتشغيل VS Code، فالإجابة تكمن في فهم ما تفعله مترجمات JIT فعلياً — وما الذي يجعل تحسين اللغات الديناميكية أصعب بطبيعته من تحسين اللغات الثابتة.

المشكلة الجوهرية في اللغات الديناميكية

عندما يرى مترجم C التعبير a + b، فهو يعرف أنواع a وb وقت الترجمة. إذا كانا عددين صحيحين، يُصدر تعليمة ADD واحدة. وإذا كانا عشريين، يُصدر عملية جمع للأعداد العشرية. تنفذ المعالجة هذه التعليمة في دورة واحدة. لا يوجد غموض ولا حاجة لاتخاذ قرارات وقت التشغيل.

أما مفسّر Ruby عندما يرى a + b، فهو لا يعرف تقريباً شيئاً. قد يكون a عدداً صحيحاً أو عشرياً أو نصاً أو مصفوفة، أو أي كائن يعرّف دالة +. على المفسّر أن: يتحقق من نوع a، ويجد دالة + لذلك النوع، ويتحقق من نوع b، وربما يُحوّل الأنواع، ويعالج الحالات الاستثنائية (الفيض الحسابي، والكائنات المجمّدة)، وأخيراً ينفذ العملية. قد تتطلب هذه العملية + الواحدة عشرات التعليمات وعمليات البحث في الذاكرة وقرارات التفرّع.

# What the programmer writes:
def sum(a, b)
a + b
end
# What the interpreter actually has to do (pseudocode):
def sum(a, b)
method = lookup_method(a.class, :+)      # Hash lookup
raise NoMethodError unless method         # Branch
if a.is_a?(Integer) && b.is_a?(Integer)   # Type checks
result = integer_add(a.value, b.value)  # Actual math
if overflow?(result)                     # Overflow check
result = promote_to_bignum(result)    # Bignum promotion
end
elsif a.is_a?(Float) || b.is_a?(Float)
result = float_add(to_float(a), to_float(b))
elsif a.is_a?(String)
result = string_concat(a, b.to_s)
else
result = call_method(method, a, [b])    # Generic dispatch
end
result
end

هذه الكلفة الإضافية — فحص الأنواع والبحث عن الدوال وتوجيه الاستدعاء — هي الثمن الذي تدفعه مقابل الديناميكية. فالمرونة في كتابة a + b وجعلها تعمل مع الأعداد الصحيحة والعشرية والنصوص والكائنات المخصصة مكلفة في وقت التشغيل.

كيف تردّ مترجمات JIT على ذلك

مهمة مترجم JIT أن يراقب ما يفعله البرنامج فعلياً أثناء التشغيل، ثم يولّد شيفرة آلة محسّنة بناءً على تلك الملاحظات. الفكرة المفتاحية: رغم أن شيفرة Ruby يمكن أن تعمل على أي نوع، فإن نقطة الاستدعاء الواحدة في الواقع تستقبل عادةً الأنواع نفسها دائماً.

إذا استُدعيت sum(a, b) عشرة آلاف مرة وكان a وb دائماً عددين صحيحين، يستطيع المترجم توليد شيفرة آلة متخصصة تفترض أنهما سيظلان عددين صحيحين. فيُصدر تعليمة جمع صحيحة واحدة مع 'حارس' — وهو فحص سريع للنوع يتجه إلى المسار البطيء إذا انتُهك هذا الافتراض يوماً ما.

Interpreter execution of sum(a, b) where a and b are Integers:
1. Load a from stack
2. Check if a is an object reference
3. Load a's class pointer
4. Look up :+ in method table (hash lookup)
5. Check method visibility
6. Load b from stack
7. Check if b is an object reference
8. Check if b is compatible type
9. Unbox a's integer value
10. Unbox b's integer value
11. Perform addition
12. Check for overflow
13. Box result as new Integer object
14. Return result
JIT-compiled version (after profiling shows a,b are always Integers):
1. Guard: check a is Integer (bail if not)
2. Guard: check b is Integer (bail if not)
3. ADD instruction
4. Guard: check no overflow (bail if overflow)
5. Return result

هذه عملية من 14 خطوة اختُصرت إلى 5 خطوات، حيث الخطوات 1 و2 و4 مجرد مقارنات بسيطة. أما الحساب الفعلي — عملية ADD — فيستغرق دورة معالج واحدة. هكذا انتقل JavaScript من 'بطيء جداً لأي شيء جدي' إلى 'سريع بما يكفي لتشغيل بيئة تطوير متكاملة كاملة.'

رحلة JIT في Ruby: من MJIT إلى YJIT ثم ZJIT

تاريخ Ruby مع الترجمة اللحظية دراسة حالة حول صعوبة هذه المشكلة. مرّ Ruby بعدة تطبيقات لـ JIT، وكل واحدة اتخذت نهجاً مختلفاً.

MJIT (Ruby 2.6، عام 2018) حوّل تعليمات bytecode في Ruby إلى شيفرة C، ثم استدعى GCC أو Clang لترجمتها. أنتج ذلك شيفرة محسّنة جيداً لكن زمن الإحماء كان سيئاً جداً — فترجمة C تستغرق ثواني لا أجزاء من الألف من الثانية. وبحلول الوقت الذي تصبح فيه الشيفرة المترجمة جاهزة، قد يكون البرنامج قد انتهى من التنفيذ.

YJIT (Ruby 3.1، عام 2022) كان مساهمة من Shopify، كُتب أولاً بلغة C ثم أُعيد كتابته بلغة Rust. يستخدم YJIT تقنية تُسمى 'تنسيخ الكتل الأساسية الكسول' — إذ يترجم الشيفرة كتلةً أساسية كل مرة، ولا يفعل ذلك إلا عندما تُنفَّذ تلك الشيفرة فعلاً، ويُنشئ نسخاً متخصصة بناءً على الأنواع التي يلاحظها. يمنحه هذا إحماءً سريعاً (بالمللي ثانية لا الثواني) مع أداء قمّة جيد. يحسّن YJIT عادةً أداء Ruby بنسبة 15 إلى 30% في أحمال العمل الواقعية مثل تطبيقات Rails.

ZJIT هو التطور التالي، وهنا تصبح الأمور مثيرة فعلاً. يقدّم ZJIT تمثيلاً وسيطاً (IR) — وهو تمثيل منظم للبرنامج بين bytecode وشيفرة الآلة. هذا التمثيل يتيح تحسينات المترجمات الكلاسيكية التي لم يكن YJIT يستطيع تنفيذها بسهولة بنهجه المباشر من bytecode إلى شيفرة الآلة.

إزالة عمليات التحميل والتخزين الزائدة

التحسين المحدد الذي أضافه ZJIT مؤخراً — إزالة عمليات تحميل وتخزين الكائنات الزائدة — يبدو معقداً لكن أثره كبير. إليك السبب.

كائنات Ruby تخزّن متغيراتها الداخلية في جدول خصائص. في كل مرة تقرأ فيها @name، يحمّل المفسّر القيمة من جدول خصائص الكائن في الذاكرة. وفي كل مرة تكتب فيها @name = value، يخزّن فيه. في دالة تصل إلى المتغير الداخلي نفسه عدة مرات، يحمّله المفسّر من الذاكرة في كل مرة — لأنه في الحالة العامة قد يكون شيء ما غيّر القيمة بين القراءتين.

class Rectangle
def area
@width * @height
end
def perimeter
# @width and @height are each loaded TWICE from memory
2 * (@width + @height)
end
def scale(factor)
@width = @width * factor    # Load @width, multiply, store @width
@height = @height * factor  # Load @height, multiply, store @height
self
end
end
# In a tight loop, these redundant loads add up:
rects.each { |r| r.scale(2).perimeter }

باستخدام التمثيل الوسيط، يستطيع ZJIT تنفيذ إزالة التحميل: إذا كان @width قد حُمّل سابقاً ولم يُعدّل منذ ذلك الحين، فيُعاد استخدام القيمة من سجل المعالج بدلاً من تحميلها من الذاكرة مرة أخرى. ويستطيع أيضاً تنفيذ إزالة التخزين: إذا كتبت في @width مرتين متتاليتين دون أن يقرأه أحد بينهما، فيمكن حذف التخزين الأول.

هذه التحسينات أساسية في مترجمات اللغات الثابتة — فـ GCC وLLVM تطبقها منذ عقود. لكن تطبيقها على لغة ديناميكية أصعب بكثير بسبب مشكلة الأسماء المستعارة (aliasing). في Ruby، يمكن لاستدعاء أي دالة أن يعدّل المتغيرات الداخلية لأي كائن (عبر instance_variable_set أو method_missing أو خطافات التتبع). على المترجم أن يثبت أنه بين قراءتين لـ @width لا يمكن لأي شيء أن يغيّره — وهذا يتطلب تحليل ما قد تفعله كل عملية تقع في الوسط.

ميزة التمثيل الوسيط

السبب الذي جعل ZJIT يعتمد تمثيلاً وسيطاً (وهو سبب استخدام TurboFan في V8، وDFG/FTL في JavaScriptCore، وC2 في HotSpot للتمثيلات الوسيطة أيضاً) هو أنه يجعل هذه التحسينات قابلة للتركيب. فالتمثيل الوسيط في جوهره رسم بياني من العمليات، ويمكن تطبيق التحسينات عليه كتحويلات على الرسم البياني.

  • طيّ الثوابت: إذا كان طرفا عملية الجمع ثابتين معروفين، يُستبدل الحساب بنتيجته. يتحول 2 + 3 إلى 5 وقت الترجمة.
  • حذف الشيفرة الميتة: إذا لم تُستخدم نتيجة عملية ما أبداً، تُحذف تماماً.
  • حذف التعابير الفرعية المشتركة: إذا ظهر الحساب نفسه مرتين، يُحسب مرة واحدة ويُعاد استخدام النتيجة.
  • حذف التحميل/التخزين: إزالة عمليات الذاكرة الزائدة كما وُصف أعلاه.
  • تحليل الهروب: إذا أُنشئ كائن ولم يغادر الدالة الحالية أبداً، يُخصَّص على المكدس بدلاً من الكومة (أو يُلغى تخصيصه تماماً).
  • التضمين (Inlining): استبدال استدعاء الدالة بجسمها، مما يكشف فرصاً أكثر للتحسينات الأخرى.

هذه التحسينات تتراكم. تضمين دالة يكشف عملياتها داخل سياق المستدعي، فقد يكشف عن قيم ثابتة، فيتيح طيّ الثوابت، فيصبح بعض الكود ميتاً، فيُحذف. قرار تضمين واحد قد يتسلسل ليزيل عشرات العمليات.

شبكة الأمان لإلغاء التحسين

كل ما يفعله مترجم JIT قائم على التخمين. يفترض أن الأنواع لن تتغير، وأن الدوال لن تُعاد تعريفها، وأن التعديل المباشر على الصنف (monkey-patching) لن يُبطل شيفرته المحسّنة. وعندما تنكسر هذه الافتراضات، يحتاج JIT إلى 'إلغاء التحسين' — أي التخلص من الشيفرة المحسّنة والعودة إلى المفسّر.

إلغاء التحسين من أصعب أجزاء تصميم JIT. ربما حذفت الشيفرة المحسّنة متغيرات محلية، أو أعادت ترتيب العمليات، أو ضمّنت استدعاءات متداخلة بعمق. وللعودة إلى المفسّر، يجب على JIT إعادة بناء حالة المفسّر — كل المتغيرات المحلية، وأكوام الاستدعاء، والعداد البرمجي — من المعلومات المتوفرة في الشيفرة المحسّنة. وهذا يتطلب الاحتفاظ ببيانات وصفية (تُسمى 'الاستبدال على المكدس' أو خرائط OSR) تربط حالات الشيفرة المحسّنة بحالات المفسّر.

عندما يحدث إلغاء التحسين بكثرة — وهي حالة تُسمى 'اضطراب إلغاء التحسين' (deopt thrashing) — قد يكون الأداء أسوأ من التفسير البحت. يقضي JIT وقته في ترجمة شيفرة محسّنة، وتشغيلها لفترة قصيرة، ثم إلغاء تحسينها، وتكرار ذلك. تتعامل V8 مع هذا بتتبع عدد مرات الإلغاء، ثم التوقف في النهاية عن تحسين دالة معينة. أما YJIT فيتبع نهجاً أبسط: يولّد نسخاً متعددة من كل مسار تنفيذ لمجموعات أنواع مختلفة، مما يقلل الحاجة إلى الإلغاء مقابل توليد شيفرة أكثر.

لماذا يهم هذا خارج Ruby

رحلة JIT في Ruby تعكس ما يحدث في اللغات الديناميكية عموماً. مترجم Python القائم على 'النسخ والتصحيح' (copy-and-patch JIT) الذي دخل في CPython 3.13 هو الخطوة الأولى نحو ترجمة لحظية حقيقية للغة Python. وظلت LuaJIT سريعة بشكل لافت لسنوات باستخدام مترجم تتبّعي عدواني. أما JIT في PHP 8.0 فصاعداً فيستخدم LLVM لتحسيناته القائمة على التمثيل الوسيط.

النمط ثابت: تبدأ بمفسّر، وتضيف أدوات تحليل الأداء لفهم سلوك وقت التشغيل، وتترجم المسارات الساخنة مع تخصيص الأنواع، وتُدخل تمثيلاً وسيطاً للتحسينات الكلاسيكية، ثم تُحسّن. تواجه كل لغة التحديات نفسها — الإرسال الديناميكي، والكائنات القابلة للتغيير، وeval، وmonkey-patching — وتصل إلى حلول متشابهة.

بالنسبة للمطورين الذين يستخدمون هذه اللغات، الخلاصة العملية أن الفجوة في الأداء بين اللغات الديناميكية والثابتة تضيق. لن تُغلق تماماً أبداً — فعمليات فحص الأنواع والحراس لها كلفة، وإلغاء التحسين جزء متأصل من العملية. لكن JIT جيد التحسين يمكن أن يقترب بين 2 و5 أضعاف من شيفرة C المكافئة في أحمال العمل الحسابية، وهذا 'سريع بما يكفي' لغالبية التطبيقات.

الخلاصة الأدق: اكتب شيفرة مباشرة وبسيطة. مترجمات JIT تحسّن الأنماط المتوقعة. نقاط الاستدعاء أحادية الشكل (حيث تستقبل الدالة دائماً الأنواع نفسها) تُحسَّن بشكل جيد. نقاط الاستدعاء متعددة الأشكال (حيث تتنوع الأنواع) أصعب. أما نقاط الاستدعاء فائقة التعدد (عشرات الأنواع) فقد لا تُحسَّن أبداً. الشيفرة السهلة على الإنسان في الفهم غالباً ما تكون سهلة على JIT في التحسين — وهذا توافق جميل في المصالح.