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

مترجم JIT في بايثون أخيراً قادم

بايثون 3.15 تأتي بمترجم JIT من نوع copy-and-patch بعد سنوات من التطوير. كيف يعمل، وما التسريع المتوقع، ولماذا تأخر CPython كل هذا الوقت.

ثعبان يرتدي حقيبة طيران مصنوعة من مسارات الدوائر الإلكترونية، يستعد للانطلاق على مسار سباق

ظلت بايثون توصف بأنها «بطيئة» منذ وُجدت. الرد المعتاد من مجتمع بايثون، «استخدم امتدادات C للحلقات الثقيلة»، كان دائماً اعترافاً ضمنياً بأن نموذج التنفيذ الافتراضي للغة محدود من الأساس. يفسّر CPython الـ bytecode تعليمةً تلو الأخرى، وتُوزَّع كل تعليمة عبر عبارة switch. الأسلوب بسيط ومحمول وسهل التصحيح، لكنه أيضاً أبطأ بنحو 100 مرة من C المُجمَّع في الأعمال الحسابية الثقيلة.

بايثون 3.15 تغيّر هذه المعادلة. بعد سنوات من العمل التجريبي، يصل مترجم copy-and-patch الفوري (JIT) كميزة مفعّلة افتراضياً. لن يجعل بايثون بسرعة C، فلا شيء يفعل ذلك دون الترجمة الساكنة، لكن الاختبارات الأولية تُظهر تحسناً بنسبة 15 إلى 30% على الشيفرات الواقعية، مع أنماط محددة تحقق مكاسب أكبر بكثير. بالنسبة للغة ظلّ شعار «أعد كتابة المسار الساخن بلغة C» قصتها في الأداء لثلاثين عاماً، فإن JIT يسرّع فعلياً شيفرة بايثون الخالصة إنجازٌ حقيقي.

لماذا لم يحصل CPython على JIT قط

ليس ذلك لأن أحداً لم يحاول. فـ PyPy يملك JIT منذ أكثر من عقد، وغالباً ما يشغّل شيفرة بايثون أسرع من CPython بخمس إلى عشر مرات. لكن PyPy تطبيق منفصل بوقت تشغيل خاص به، ولم يبلغ قط حصة CPython في السوق، لأن منظومة الامتدادات بلغة C، مثل NumPy وpandas وscikit-learn، وكل ما يجعل بايثون لغة علم البيانات، مرتبطة بواجهة CPython البرمجية C API.

نوقش بناء JIT داخل CPython نفسه وجُرّب أكثر من مرة، والتحديات موثقة جيداً. بنية CPython تجعل الترجمة الفورية صعبة: الـ bytecode مُحدَّد الأنواع ديناميكياً (والـ JIT يحتاج معلومات الأنواع ليولّد شيفرة فعّالة، وبايثون لا توفرها بشكل ساكن)، وواجهة C تسمح لشيفرة C بالتلاعب المباشر بكائنات بايثون بطرق تكسر افتراضات الـ JIT، وجامع القمامة القائم على العد المرجعي (reference counting) يفرض عبئاً محاسبياً لا يستطيع الـ JIT إزالته بسهولة.

المحاولات السابقة، مثل Unladen Swallow من غوغل (2009) وPyston من Dropbox (2014)، سعت إلى تركيب ترجمة فورية مبنية على LLVM فوق CPython. وجدت الاثنتان أن تكلفة ترجمة LLVM أعلى من أن تُبرَّر بأحمال بايثون المعتادة. فـ LLVM مصمم للترجمة المسبقة (AOT) لقواعد شيفرة كبيرة، واستخدامه لترجمة دوال بايثون القصيرة فورياً يضيف أجزاء من الميلي ثانية من وقت الترجمة مقابل ميكروثوانٍ من وقت التنفيذ. صارت الترجمة أغلى من التسريع الذي تحققه.

copy-and-patch: نهج مختلف للـ JIT

تقنية copy-and-patch، التي قُدِّمت في ورقة بحثية عام 2021، تتبع نهجاً مختلفاً جوهرياً في الترجمة الفورية. فبدلاً من تحويل الـ bytecode إلى تمثيل وسيط وتشغيل مراحل تحسين عليه (وهو النهج القائم على LLVM)، يعمل copy-and-patch على قوالب شيفرة مُترجمة مسبقاً.

الفكرة كالتالي: لكل تعليمة bytecode (مثل LOAD_FAST وBINARY_ADD وCALL_FUNCTION وغيرها)، يُترجم المترجم مسبقاً تطبيقاً بلغة C إلى شيفرة آلة، مع «ثقوب» بديلة للبيانات الخاصة بكل متغير، مثل تخصيصات السجلات والقيم الثابتة وإزاحات الذاكرة. عند التشغيل، تصبح الترجمة الفورية مجرد: انسخ القالب المترجم مسبقاً، واملأ الثقوب بالقيم المحددة لهذه الدالة. لا مراحل تحسين، ولا خوارزميات تخصيص سجلات، ولا اختيار تعليمات. مجرد memcpy ثم patch.

Traditional JIT pipeline:
Python bytecode
→ Parse into IR
→ Type inference
→ Optimization passes (CSE, DCE, loop unrolling, inlining...)
→ Register allocation
→ Instruction selection
→ Machine code
Total: 1-100ms per function
Copy-and-patch pipeline:
Python bytecode
→ For each instruction, copy pre-compiled template
→ Fill in holes (constants, offsets)
→ Done — machine code ready
Total: 1-100μs per function (1000x faster compilation)

المقايضة في جودة الشيفرة. تنتج LLVM شيفرة آلة محسّنة للغاية. أما copy-and-patch فينتج شيفرة هي في جوهرها نسخة مترجمة من حلقة المفسّر، فكل تعليمة bytecode لا تزال قالباً منفصلاً، مع حد أدنى من التحسين عبر التعليمات. الشيفرة الناتجة أفضل من التفسير (لا عبء توزيع، ولا عبارة switch، وتنبؤ أفضل للفروع)، لكنها أضعف مما ينتجه مترجم محسِّن كامل.

بالنسبة لبايثون، هذه المقايضة ممتازة. دوال بايثون عادةً قصيرة، تُستدعى مرات كثيرة، وتستغرق كل منها ميكروثوانٍ. JIT يترجم خلال ميكروثوانٍ ويسرّع التنفيذ بنسبة 20 إلى 30% أنفع من آخر يترجم خلال ميلي ثوانٍ ويسرّع التنفيذ بنسبة 200%، لأن تكلفة الترجمة في النهج الأول تُستهلك تقريباً فوراً.

ما الذي يصبح أسرع؟

لا يسرّع الـ JIT كل شيفرة بايثون بالتساوي كأن شيئاً سحرياً حدث. فهم ما يستفيد أكثر يتطلب فهم ما يقضي المفسّر وقته فيه.

عبء توزيع الـ bytecode. في المفسّر، تحتاج كل تعليمة bytecode إلى جلب الـ opcode التالي، وفك ترميزه، والقفز إلى معالجه عبر عبارة switch. قد يشكّل هذا العبء 30 إلى 50% من زمن التنفيذ الكلي في الحلقات الضيقة. الـ JIT يلغيه تماماً، إذ تُترجم التعليمات إلى شيفرة آلة متتالية بقفزات مباشرة.

عمليات مخصّصة للأنواع. قدّم بايثون 3.11 المفسّر التكيفي المتخصص (specializing adaptive interpreter)، الذي يستبدل العمليات العامة بعمليات خاصة بالأنواع بعد رصد الأنواع الفعلية المستخدمة. يتحول BINARY_ADD إلى BINARY_ADD_INT عندما يرى عددين صحيحين. يترجم الـ JIT هذه التعليمات المتخصصة إلى شيفرة آلة فعّالة، فجمع الأعداد الصحيحة يصبح تعليمة add واحدة بدلاً من استدعاء دالة.

تنبؤ الفروع. حلقة التوزيع المركزية في المفسّر، وهي عبارة switch بمئات الحالات، كابوس لمتنبئ الفروع في المعالج. يستبدلها الـ JIT بتدفق تحكم مباشر يتنبأ به المعالج بدقة. وفي المعالجات الحديثة حيث يكلّف سوء التنبؤ 15 إلى 20 دورة، يمثل هذا وحده جزءاً كبيراً من التسريع.

ما لا يصبح أسرع: استدعاءات امتدادات C (NumPy وpandas)، وعمليات الإدخال والإخراج (الشبكة والقرص)، والعمليات التي تهيمن عليها تخصيصات الذاكرة مثل إنشاء ملايين الكائنات الصغيرة. إذا كان برنامجك بايثون يقضي 95% من وقته في امتدادات C و5% في بايثون الخالصة، فإن الـ JIT يسرّع ذلك الـ 5%، وهو تحسن قابل للقياس لكنه ليس تحولاً جذرياً.

خط أنابيب التخصص

لا يعمل الـ JIT وحده. إنه المرحلة الأخيرة في خط أنابيب أداء بدأ مع المفسّر المتخصص في بايثون 3.11، وتواصل عبر التحسينات التدريجية في الإصدارات من 3.12 إلى 3.14.

  1. المستوى 0: المفسّر. كل شيفرة تبدأ هنا. تفسير bytecode قياسي مع التخصص التكيفي. بعد استدعاء الدالة عدة مرات، تُستبدل التعليمات الساخنة بنسخ متخصصة بالأنواع.
  2. المستوى 1: bytecode مترجَم فورياً. يترجم JIT من copy-and-patch الـ bytecode المتخصص إلى شيفرة آلة. هذا يلغي عبء التوزيع ويتيح تحسينات أساسية مثل طي الثوابت (constant folding) وإزالة الشيفرة الميتة داخل القوالب المترجمة.
  3. المستوى 2 (مستقبلاً): تحسين قائم على التتبع. تسجيل مسارات التنفيذ عبر مسارات الشيفرة الساخنة، وترجمة المسارات كاملةً، عبر حدود الدوال، إلى شيفرة آلة محسّنة. هذا مخطط له ولم يُشحن بعد.

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

أثر الذاكرة وزمن البدء

تستهلك مترجمات JIT ذاكرة للشيفرة المترجمة. ذاكرة copy-and-patch معتدلة، فالشيفرة المترجمة أكبر من الـ bytecode لكنها أصغر من ما تنتجه JITs المبنية على LLVM، لأنه لا يوجد تضخم ناتج عن التحسين. يستخدم التطبيق الحالي نحو 1.5 إلى 3 أضعاف ذاكرة الـ bytecode الذي يحل محله، ولا يترجم سوى الدوال التي تُستدعى بتكرار كافٍ لتستفيد فعلاً.

زمن بدء التشغيل يهم السكربتات قصيرة العمر. يضيف الـ JIT عبئاً لتحميل مكتبة القوالب وإعداد بنية الترجمة. بالنسبة للسكربتات التي تعمل أقل من ثانية، قد يتجاوز عبء الـ JIT مكسب التسريع. يعالج CPython هذا بترجمة الدوال فقط بعد استدعائها عدداً من المرات يتجاوز عتبة محددة، فالسكربتات قصيرة العمر تبقى في المفسّر ولا تدفع أي ضريبة JIT.

هذا قابل للتهيئة. يتحكم خيار -X jit في سلوك الـ JIT، وتضبط متغيرات البيئة عتبة الترجمة. بالنسبة للدوال الخادمة بلا خادم (serverless) وأدوات سطر الأوامر التي يهم فيها زمن البدء، يمكنك رفع العتبة أو تعطيل الـ JIT كلياً. أما الخوادم طويلة التشغيل وسكربتات معالجة البيانات التي يهم فيها الأداء المستقر، فالإعدادات الافتراضية كافية.

ماذا يعني هذا لمنظومة بايثون

لا يغيّر الـ JIT موقع بايثون في تسلسل الأداء، فـ C وRust وGo وJava لا تزال أسرع بكثير في الأعمال الحسابية الثقيلة. ما يغيّره هو العتبة التي يحتاج عندها مطورو بايثون إلى اللجوء لتلك البدائل.

تسريع بنسبة 20 إلى 30% على شيفرة بايثون الخالصة يعني أن بعض الأحمال التي كانت تتطلب امتدادات C أو إعادة كتابة صارت تعمل بسرعة كافية بايثون الخالصة. سكربتات معالجة البيانات التي كانت تستغرق 10 دقائق تستغرق 7، وخوادم الويب التي تعالج 1000 طلب في الثانية تعالج 1300. هذه ليست أرقاماً ثورية، لكنها الفرق بين «بايثون سريعة بما يكفي» و«نحتاج إلى إعادة كتابة هذا بلغة Go».

والأهم أن بنية الـ JIT تمهّد لتحسينات مستقبلية. يمكن توسيع نهج copy-and-patch بقوالب أفضل، وتخصص أكثر، وفي النهاية ترجمة قائمة على التتبع. كل إصدار من بايثون يمكنه أن يشحن قوالب أفضل دون تغيير البنية الأساسية للـ JIT. التسريع بنسبة 20 إلى 30% في 3.15 هو حد أدنى، لا سقف.

بعد ثلاثة عقود بوصفها إحدى أشهر لغات العالم وأبطأها، يستثمر CPython أخيراً بجدية في الأداء. لن يُرضي الـ JIT من يقولون «بايثون بطيئة جداً»، فلا شيء سيفعل ذلك، لأن بايثون بطيئة فعلاً لبعض الأحمال، مع JIT أو بدونه. لكن لغالبية شيفرة بايثون، حيث كانت سرعة التنفيذ «مقبولة لكن ليست رائعة»، يقرّبها الـ JIT من «جيدة حقاً». وهذا أكبر مما يبدو.