آلة البايت كود
مسار التنفيذ الافتراضي هو متجوّل شجرة: يزور كل عقدة AST ويعيد قيمة. أما الدوال كثيرة الاستخدام التي تُستدعى ملايين المرات، فتكون كلفة الإرسال لكل عقدة هي عنق الزجاجة. يضيف Phasis فوق ذلك مترجمًا تخمينيًا يحوّل البايت كود إلى إغلاقات PHP لتقليل هذه الكلفة.
متى تبدأ الآلة الافتراضية العمل
لكل JsFunction عدّاد للمكالمات الساخنة. عندما يتجاوز حدًا معينًا (100 افتراضيًا)، يحاول المحرك ترجمة جسم الدالة إلى إغلاق PHP عبر src/Bytecode/JsToPhp.php.
ينجح التجميع عندما يطابق جسم الدالة مجموعة معروفة من البنى:
- إسناد المتغيرات المحلية بأنواع متوقعة (رقم، كائن، دالة، مصفوفة، سلسلة).
- العمليات الحسابية على الأعداد.
- قراءات الأعضاء من المتغيرات المحلية ذات نوع الكائن وكذلك من
this(this.vوobj.x). - استدعاءات الطرائق على المتغيرات المحلية ذات نوع الكائن (
obj.method(args))، مع تمرير المستقبل عبر$thisValue. - استدعاءات المُنشئ
new Ctor(args)— ترفع نوع المتغير المحلي في الطرف الأيسر إلىobjectوتحمي بتراجع حالة إرجاع قيمة غير كائنية. - حلقات
for/whileذات شروط بسيطة البنية. returnبتعبير قابل للحساب.if/elseعلى شروط ذات أنواع محددة.
يفشل التجميع (وتظل الدالة ضمن متجوّل الشجرة) إذا احتوى الجسم على:
- عبارات
with. try / catch / finally(يعالجها متجوّل الشجرة لضمان دلالات الإكمال الصحيحة).- استدعاءات
eval(). - تعديل مباشر لـ
arguments. - أي شيء لم يعرف المترجم بعد كيفية تحويله.
عند نجاح التجميع، يبدّل المحرك مسار استدعاء الدالة إلى الإغلاق المجمّع. وتُنفّذ الاستدعاءات اللاحقة شيفرة PHP أصلية بكلفة استدعاء دالة واحدة، لا كلفة لكل عقدة AST.
التراجع
يفترض المسار السريع المجمّع أمورًا معينة (أن تظل الأعداد أعدادًا، وأن تبقى الخصائص على البنية نفسها). إذا خولف افتراض أثناء التشغيل، يطلق الإغلاق Bailout ويُستأنف التنفيذ في متجوّل الشجرة لذلك الاستدعاء.
هنا تبرز أهمية مطابقة المواصفات. يجب أن يتمكن متجوّل الشجرة من إعادة تنفيذ الاستدعاء الذي تراجع من البداية دون ملاحظة أي آثار جانبية من المسار السريع الفاشل. يتعامل Phasis مع هذا بطريقتين:
- بادئات خالصة — يفحص المترجم الآثار الجانبية قبل أي تخمين. إذا نفّذت الدالة إدخالًا/إخراجًا (console.log أو دوال المضيف) قبل نقطة التخمين، فلا يحذفها المسار السريع؛ بل يثبتها ويتابع.
- رفض وقت التجميع — يرفض المترجم التجميع كليًا للبنى التي تتطلب التراجع عن آثار جانبية يمكن ملاحظتها (إسناد
let r = fn()غير رقمي عندما يغيّرfn()الحالة). وتظل الدالة ضمن متجوّل الشجرة.
الحالة الدافعة: ينفّذ إسناد غير رقمي fn() في المسار السريع، ويطلق Bailout، ثم يُعاد تنفيذه في متجوّل الشجرة، ما يؤدي إلى تشغيل fn() مرتين مع مجموعتين من الآثار الجانبية. يمنع الرفض وقت التجميع تشغيل fn() في المسار السريع أصلًا، فلا يرى متجوّل الشجرة إلا الاستدعاء الوحيد.
إرسال الآلة الافتراضية (الطبقة الثانية)
تُترجم بعض الدوال إلى إغلاقات PHP، بينما تنتقل أخرى إلى مفسّر بايت كود أبسط في src/Bytecode/VM.php. والبايت كود مصفوفة مسطّحة من رموز العمليات (LOAD_LOCAL وSTORE_PROP وCALL_METHOD، …) يستهلكها إرسال عبر switch.
توجد الآلة الافتراضية للدوال التي لا يستحق الأمر ترجمتها إلى إغلاق PHP، لكنها تظل كثيرة الاستخدام بما يكفي للاستفادة من تجاوز اجتياز AST. تمر استدعاءات الطرائق على الكائنات المضمّنة (Array.prototype.map وString.prototype.split، …) عبر مسارات سريعة في الآلة الافتراضية تتجاوز آلية callFunction العامة.
مكدس استدعاءات مخصص
تستخدم Op::CALL وOp::CALL_METHOD وOp::NEW_CALL مسار استدعاء مضمنًا يبقي مكدس استدعاءات JS بالكامل داخل VM::execute، دون إطار PHP جديد لكل استدعاء JS. عند الدخول، تُدفع حالة المستدعي (pc، وتدفّق الشيفرة، ومكدس المعاملات، والمتغيرات المحلية، والبيئة، وthis، وعلامة الوضع الصارم) إلى مجموعة savedFrames لكل آلة افتراضية تضم نسخ SavedFrame، ثم تعيد حلقة الإرسال ربط متغيراتها المحلية بالدالة المستدعاة. وبعدها تستعيد Op::RET الإطار المحفوظ. وتفكّ الاستثناءات الإطارات المحفوظة قبل الرجوع إلى متجوّل الشجرة.
تُحفظ أهلية التنفيذ مؤقتًا على JsFunction: تكون vmInlineCallCache للاستدعاءات العادية (جسم مترجم بـBC، غير مرتبط بـbind()، وغير أصلي)، وvmInlineConstructCache لتعبيرات new (بالإضافة إلى isConstructable، وليست مشتقة، ولا تحتوي على مُهيّئات لحقول النسخة أو للطرائق الخاصة). وما عدا ذلك يمر عبر آلية callFunction / vmNewExpression القياسية.
ومن الفوائد المعمارية الجانبية أن سقف مكدس PHP لم يعد يحد عمق الاستدعاء التكراري في JS. الحد الوحيد الآن هو CallStack::maxDepth، المضبوط على 4 096؛ وهو أعلى بكثير من كل حالات الاختبار المشروعة في test262 (يحتاج معظمها إلى أقل من 2 k إطار) وضيق بما يكفي لإفشال اختبارات التكرار اللانهائي المرضية سريعًا ضمن مهلة المشغّل. ومسار الإغلاق المحوّل (JsToPhp) له حارسه الخاص البالغ 8 192 إطارًا لخيار التراجع غير المضمّن.
مولّدات لقطة الإطار
تُترجم أجسام المولّدات البسيطة إلى شيفرة بايتية — بلا yield*، ولا yield داخل try/catch/finally، ولا await. يُصدر المصرّف تعليمة Op::YIELD جديدة عند تعليق الجسم؛ وتلتقط Op::YIELD حالة الإرسال (cf, pc+1, stack, sp, locals, env, thisValue, strict) في كائن كومة GeneratorSnapshot، وتُعيد قيمة حارسة YieldResult من VM::execute. يخزّن المشغّل JsGenerator اللقطة ويمرّرها إلى VM::execute عند الاستدعاء التالي لـ next() / throw()، مستبدلًا قيمة الاستئناف بأعلى مكدس المعاملات.
يحلّ هذا محل مسار PHP-Fiber للجزء المدعوم. يظل المشغّل المعتمد على Fiber (executeGeneratorBody) قائمًا للأشكال التي يرفضها المصرّف — التفويض بـ yield*، والمولّدات التي تنفّذ yield داخل كتلة try، والمولّدات غير المتزامنة، والدوال غير المتزامنة التي تتضمن await. تعمل JsGenerator في وضعين: يتحقق كل من next() / throw() / return() مما إذا كان المثيل يملك لقطة أو Fiber، ثم يوجّه التنفيذ وفقًا لذلك.
لا توجد معلمة قابلة للضبط عبر أداة القياس؛ إذ يُحسم الأهلية وقت الترجمة ويُحفظ القرار مؤقتًا على JsFunction. يوفّر مسار اللقطة تبديل سياق PHP أقل لكل yield؛ أما مسار Fiber فيبقى كما هو لكل ما عدا ذلك.
ذاكرات التخزين المؤقت المضمّنة
يحمل Op::LOAD_MEMBER ذاكرة تخزين مؤقت مضمّنة لكل موضع تعليمة، ومفتاحها هوية النموذج الأولي؛ وتُستخدم لنمط أسلوب النموذج الأولي على المثيل (c.sum(...)، arr.push(...)، وكل استدعاء لأسلوب صنف). يتحقق التطابق عندما لا يكون للمستقبِل خانة خاصة بالاسم، ويكون مرجع النموذج الأولي المخزّن مطابقًا لمرجع getPrototype() لدى المستقبِل، وتظل PropertyMap::$version الخاصة بالنموذج الأولي مطابقة للإصدار المسجّل.
يعتمد إبطال ذاكرة التخزين المؤقت على PropertyMap::$version. تؤدي التغييرات البنيوية (التعيين / الحذف) إلى زيادته دائمًا. أما استبدال قيم خانات البيانات فيزيده فقط عندما تكون PropertyMap::$isUsedAsProto مفعّلة — وهي علامة حاجز كتابة تُضبط أول مرة يُلاحظ فيها أن الكائن هو [[Prototype]] لكائن آخر. لا تتحمل كائنات المثيلات التي لا تُستخدم أبدًا كنماذج أولية أي كلفة لبنية IC مع كل obj.x = v.
لا تُخزّن أدوات الوصول getter مؤقتًا أبدًا (إذ يجب إعادة تشغيلها عند كل قراءة).
يحمل PropertyMap عدّادًا ثانيًا هو $mutationVersion، يزداد دائمًا مع كل كتابة (من دون حاجز كتابة). وتثبّت ذاكرة التخزين المؤقت JSON.stringify هذا العدّاد لكل كائن أو مصفوفة متداخلة تتم زيارتها، لكي يؤدي تغيير يخص المثيل وحده مثل o.a = 99 إلى إبطال السلسلة المخزّنة مؤقتًا بشكل صحيح، حتى عندما كانت $version لدى IC ستتجاوز الزيادة.
ذاكرة JSON.stringify المؤقتة
يحمل JsObject كلًا من $jsonCacheString + $jsonCacheVersions (قائمة أزواج [obj, mutationVersion] لكل كائن متداخل تمت زيارته أثناء المرور المبسّط لتحويله إلى سلسلة). في الاستدعاء التالي لـ JSON.stringify(o) من دون بديل أو مسافات، يتحقق المسار السريع المبسّط من أن كل إصدار مثبّت لم يتغير، ثم يعيد السلسلة المخزّنة من دون إعادة المرور أو الترميز. تحقق أنماط النسخ العميق المتكررة مثل JSON.parse(JSON.stringify(o)) تسريعًا يقارب الضعف في مرحلة تحويل الكائن إلى سلسلة.
تُسقط الذاكرة المؤقتة بصمت عند عدم التطابق (أي اختلاف أي إصدار مثبّت)، وتُملأ من جديد في المرور التالي. تنتقل الكائنات التي تتضمن toJSON أو أدوات getter أو أدوات وصول أو رموزًا أو خانات [[PrimitiveValue]] إلى أداة التسلسل المطابقة للمواصفات، ولا تملأ الذاكرة المؤقتة أبدًا؛ لذا لا يتعامل المسار المخزّن مؤقتًا إلا مع بيانات متجانسة بنيويًا.
المسارات السريعة المضمّنة
تحققت أكبر مكاسب الأداء عندما علّمنا VM التعامل مع مكوّنات مضمّنة محددة:
Atomics.load/store/compareExchangeمع الجدولة التعاونيةloadSpinHook/storeNotifyHook— استعاد ذلك 4 اختبارات لحلقات الدوران في Atomics.Date.prototype.getTimezoneOffsetمع ذاكرة DST مؤقتة مباشرة عبر hashmap — تسريع بنسبة 45 % في اختبار ضغط DST لـ SpiderMonkey.String.fromCharCodeمع مسار سريع مضمّن لـ ASCII — يوفر نحو 1 M عملية إرسال لكل دورة نموذجية.decodeURI/decodeURIComponentمع مسار فائق السرعة لـ%XX%XX%XX%XXمن 12 محرفًا يستخدم فك ترميز جماعيًاhex2bin.Object.keys/Object.values/Object.entriesمع الوصول المباشر إلى خريطة الخصائص.Array.prototype.map/filter/forEach/reduceمع التكرار الأصلي فوق مصفوفة PHP الأساسية.
يُفعّل كل مسار سريع بعد «فحص الشكل» — فإذا لم يطابق المستقبِل الشكل المتوقع (e.g، مثل عرض TypedArray غير مدعوم بـ PHP string عادي)، ينتقل VM إلى المسار البطيء المطابق للمواصفات.
ما الذي لا يفعله هذا
إن VM للشيفرة البايتية ليس JIT بالمعنى المستخدم في V8. فهو لا ينشئ شيفرة آلة أصلية لكل دالة، ولا يتكهّن استنادًا إلى الأنواع المرصودة، باستثناء فحص هوية النموذج الأولي أحادي الشكل في IC. ويُحاكى تضمين حدود الدوال باستخدام مكدس الاستدعاءات المخصص (من دون إطار PHP إضافي لكل استدعاء JS)، لا بخفض جسم الدالة المستدعاة إلى شيفرة بايتية للدالة المستدعية.
يُعدّ محوّل JsToPhp أقرب ما لدى المحرك إلى JIT: فهو ينشئ مصدر PHP لأجسام الدوال الساخنة، ثم يترجمه JIT التتبّعي في الإصدار 8.5 من PHP إلى شيفرة أصلية. يمر JsToPhp عبر eval()، لذا يتحمل كلفة ترجمة لمرة واحدة، وعلى JIT التتبّعي اكتشاف الدالة كما لو كانت شيفرة مستخدم. إن تخطي خطوة eval() بإصدار تعليمات opcache مباشرة سيقلّص الفجوة أكثر — راجع المعايير § خارطة الطريق.
قياس الأداء
يشغّل bench/microbench.js نحو اثني عشر معيارًا صغيرًا (loop-arith, loop-fib, fn-recurse, fn-deep-recurse, obj-create, obj-prop, proto-method, gen-iterate, arr-push, arr-map, str-concat, str-split-join, json-roundtrip, closure, destructure). الزمن الوسيط الفعلي:
php bench/run.php
تُحفظ الأرقام الحالية في BENCH.md بعد كل تشغيل لسير عمل bench. ويُشغّل سير عمل bench ومصفوفة test262 يدويًا.