ما زلنا نتعامل مع أعمال GraalVM ، وهذه المرة لدينا ترجمة للمقالة التي كتبها Aleksandar Prokopec "تحت غطاء تحسينات GraalVM JIT" ، التي نُشرت في الأصل على المدونة متوسطة . تحتوي المقالة على بعض الروابط المهمة ، وسنحاول لاحقًا ترجمة هذه المقالات أيضًا.

آخر مرة في Medium ، نظرنا في مشكلات أداء Java Streams API على GraalVM مقارنة بـ Java HotSpot VM. يتميز GraalVM بالأداء العالي ، وفي تلك التجارب حققنا تسارعًا من 1.7 إلى 5 مرات. بالطبع ، ستعتمد القيم المحددة لاكتساب الأداء دائمًا على الكود الذي يتم تشغيله وبيانات التحميل ، لذا قبل إجراء أي استنتاجات ، يجب أن تحاول تشغيل الكود الخاص بك على GraalVM بنفسك.
في هذه المقالة ، سنتعمق في الدواخل الداخلية لـ GraalVM ونرى كيف يحدث تجميع JIT.
تحسينات JIT في GraalVM
دعونا نلقي نظرة على عدد من التحسينات عالية المستوى التي يستخدمها برنامج التحويل البرمجي GraalVM. في هذه المقالة ، سنتطرق فقط إلى التحسينات الأكثر إثارة للاهتمام إلى جانب أمثلة محددة من عملهم. إذا كنت تريد أن تعمق أكثر ، فإن نظرة عامة جيدة على تحسينات برنامج التحويل البرمجي GraalVM في عمل بعنوان "جعل عمليات التجميع مثالية مع تجميع JIT العدواني" .
رمز مصدر
إذا لم تلمس التجميع في وقت مبكر ، فسوف تقوم معظم برامج التحويل البرمجي JIT في الأجهزة الافتراضية الحديثة بإجراء تحليل داخلي. هذا يعني أنه في كل لحظة معينة من الوقت هناك تحليل لطريقة واحدة. لهذا السبب ، يكون التحليل داخل الإجراء أسرع بكثير من التحليل بين الإجراءات في البرنامج بأكمله ، والذي عادةً ما لا يكون لديه وقت لإكماله في الوقت المخصص لعمل برنامج التحويل البرمجي JIT. في برنامج التحويل البرمجي الذي يستخدم تحسينات داخلية إجرائية (على سبيل المثال ، تحسين طريقة واحدة في كل مرة) ، فإن أحد التحسينات الأساسية الأكثر أهمية هو تضمين. تعد ميزة Inlining مهمة لأنها تزيد من الطريقة بشكل فعال ، مما يعني أن المترجم يمكنه رؤية المزيد من الفرص لتحسين عدة أجزاء من الشفرة المستخدمة في نفس الوقت في أساليب غير مرتبطة على ما يبدو.
خذ ، على سبيل المثال ، طريقة volleyballStars
من مقالة سابقة:
@Benchmark public double volleyballStars() { return Arrays.stream(people) .map(p -> new Person(p.hair, p.age + 1, p.height)) .filter(p -> p.height > 198) .filter(p -> p.age >= 18 && p.age <= 21) .mapToInt(p -> p.age) .average().getAsDouble(); }
في هذا المخطط ، نرى أجزاء من التمثيل الوسيط (IR) لهذه الطريقة في GraalVM ، في الوقت الحالي مباشرة بعد تحليل كود جافا المقابل.

يمكنك أن تفكر في IR هذه كنوع من شجرة بناء الجملة المجردة على المنشطات - بفضل ذلك ، تكون بعض التحسينات أسهل في الأداء. لا يهم كيف يعمل هذا IR ، ولكن إذا كنت ترغب في فهم هذا الموضوع بشكل أعمق ، يمكنك إلقاء نظرة على وثيقة تسمى "Graal IR: تمثيل وسيط تصريحي موسع" .
الاستنتاج الرئيسي هنا هو أن تدفق التحكم في الطريقة المشار إليه بواسطة العقد الصفراء من الرسم البياني والخطوط الحمراء ينفذ بشكل متتابع أساليب واجهة Stream
: Stream.filter
، Stream.mapToInt
، IntStream.average
. افتقار المترجم إلى معرفة دقيقة لما هو مدون في هذه الطرق ، فإن المترجم غير قادر على تبسيط الطريقة - وهنا يأتي الإنقاذ!
يعد التحويل المسمى inlining شيئًا مفهومًا للغاية: فهو يبحث فقط عن أماكن لاستدعاء الأساليب ويستبدلها بنص الأسلوب المضمّن المقابل ، ويدمجها في الداخل. دعونا نلقي نظرة على IR لطريقة volleyballStars
بعد تضمين جزء من الطرق. يظهر فقط الجزء الذي يتبع استدعاء IntStream.average
:

يوضح الرسم التخطيطي أن الدعوة إلى getAsDouble
(رقم العقدة 71) قد اختفت من IR. لاحظ أن طريقة getAsDouble
للكائن getAsDouble
IntStream.average
إرجاعها من IntStream.average
(آخر استدعاء في أسلوب volleyballStars
) محددة في JDK كما يلي:
public double getAsDouble() { if (!isPresent) { throw new NoSuchElementException("No value present"); } return value; }
هنا يمكننا العثور على تحميل الحقل isPresent
(العقدة رقم 190 ، LoadField
) وقراءة حقل value
. ومع ذلك ، لا يوجد أي أثر لليسار في استثناء NoSuchElementException
، ولا يوجد رمز آخر يلقي به.
هذا بسبب تخمين برنامج التحويل البرمجي GraalVM: لن تقوم طريقة volleyballStars
بطرح استثناء. لا تتوفر هذه المعرفة عادةً أثناء تجميع getAsDouble
- يمكن استدعاؤها من العديد من الأماكن المختلفة في البرنامج ، وفي بعض الحالات الأخرى سيستمر الاستثناء. ومع ذلك ، في طريقة معينة volleyballStars
، من غير المحتمل أن يحدث استثناء لأن مجموعة النجوم المحتملة للكرة الطائرة ليست فارغة أبدًا. لهذا السبب ، يقوم GraalVM بإزالة الفرع وإدراج FixedGuard
- وهي عقدة تؤدي إلى إلغاء تحسين الكود في حالة انتهاك افتراضنا. هذا مثال بسيط إلى حد ما ، وفي الحياة الواقعية هناك حالات أكثر تعقيدًا حول كيفية مساعدة التضمين في تحسينات أخرى.
نعلم أن شجرة استدعاء البرنامج عادة ما تكون عميقة جدًا أو ربما لا تنتهي. لذلك ، يجب إيقاف الربط الموجود في مرحلة ما - يحتوي على قيود محددة للغاية على وقت التشغيل وحجم الذاكرة. مع العلم بذلك ، يصبح من الواضح: تحديد ما ومتى يكون المضمّن أمرًا صعبًا للغاية.
متعدد الأشكال المضمنة
تعمل ميزة Inlining فقط إذا كان المحول البرمجي يمكنه تحديد الطريقة المحددة التي تستهدفها عملية استدعاء الأسلوب. ولكن في Java ، هناك عادةً العديد من الاستدعاءات غير المباشرة لتلك الأساليب التي تكون تطبيقاتها غير معروفة في الإحصائيات ، والتي يتم البحث عنها في وقت التشغيل باستخدام الإرسال الظاهري.
على سبيل المثال ، IntStream.average
أسلوب IntStream.average
. تنفيذه النموذجي يبدو كالتالي:
@Override public final OptionalDouble average() { long[] avg = collect( () -> new long[2], (ll, i) -> { ll[0]++; ll[1] += i; }, (ll, rr) -> { ll[0] += rr[0]; ll[1] += rr[1]; }); return avg[0] > 0 ? OptionalDouble.of((double) avg[1] / avg[0]) : OptionalDouble.empty(); }
لا تدع البساطة الواضحة للرمز تخدعك! يتم تعريف هذه الطريقة من حيث collect
المكالمات ، ويحدث السحر هنا. تنمو شجرة الاستدعاء في هذه الطريقة (على سبيل المثال ، التسلسل الهرمي للمكالمات) بسرعة حيث نقع في عمق collect
. مجرد إلقاء نظرة على هذا الرسم البياني:

بدءًا من نقطة ما في عملية اجتياز شجرة الاستدعاء ، تقع الطبقة الداخلية في مقابل استدعاء opWrapSink
من إطار عمل opWrapSink
Java ، وهي طريقة مجردة:

abstract<P_IN> Sink<P_IN> wrapSink(Sink<P_OUT> sink);
عادةً لن يذهب المتسابق إلى أبعد من ذلك ، لأنه مكالمة غير مباشرة. سيحدث تحديد طريقة معينة فقط أثناء تنفيذ البرنامج ، والآن لا يعرف المطيع ببساطة ما الذي يجب عمله بعد ذلك.
في حالة GraalVM ، يحدث شيء آخر: يحفظ ملف تعريف لنوع الطريقة المستهدفة لكل نقطة اتصال غير مباشرة. ملف التعريف هذا هو في الأساس مجرد جدول يوضح عدد wrapSink
كل تطبيق من تطبيقات wrapSink
. في حالتنا ، يعرف الملف الشخصي ثلاثة تطبيقات مختلفة في فصول مجهولة: ReferencePipeline$2
، و ReferencePipeline$3
، و ReferencePipeline$4
. تسمى هذه التطبيقات باحتمال 50٪ و 25٪ و 25٪ على التوالي.
0.500000: Ljava/util/stream/ReferencePipeline$2; 0.250000: Ljava/util/stream/ReferencePipeline$4; 0.250000: Ljava/util/stream/ReferencePipeline$3; notRecorded: 0.000000
توفر هذه المعلومات مساعدة لا تقدر بثمن للمترجم ، مما يسمح لك بإنشاء تبديل أنواع - عبارة عن رمز switch
قصير يقوم بفحص نوع الطريقة في وقت التشغيل ، ثم يستدعي طريقة محددة لكل حالة من الحالات المذكورة أعلاه. تُظهر الصورة أدناه جزءًا من طريقة العرض الوسيطة التي تُظهر تبديل الأنواع (ثلاثة if
عقدًا) مع تحديد لمعرفة ما إذا كان نوع المستلم هو شخص ما من ReferencePipeline$2
أو ReferencePipeline$3
أو ReferencePipeline$4
. يمكن الآن أن تكون كل مكالمة مباشرة في التفريعات الناجحة لكل من عمليات التحقق من InstanceOf
مضمنة أو توصيل بعض التحسينات الإضافية بها. إذا لم ينجح أي من الأنواع في الاختبار ، فسيتم فك الشفرة في عقدة Deopt
(كبديل ، يمكنك تشغيل الإرسال الظاهري).

إذا كنت تريد أن تفهم التضمين متعدد الأشكال بشكل أعمق ، فإنني أوصي بالعمل الكلاسيكي حول هذا الموضوع ، "تضمين الأساليب الافتراضية" .
تحليل الهروب الجزئي
دعنا نعود إلى مثال الكرة الطائرة لدينا. لاحظ أنه لا يوجد أي كائن من الكائنات المخصصة داخل لامدا ينتقل إلى وظيفة map
يهرب من نطاق أسلوب volleyballStars
. بمعنى آخر ، في اللحظة التي تنتهي فيها طريقة volleyballStars
، لا توجد منطقة ذاكرة من شأنها أن تشير إلى كائنات من النوع Person
. على وجه الخصوص ، يتم استخدام سجل قيمة getHeight
فقط لتصفية الارتفاع.
في مرحلة ما أثناء تجميع طريقة volleyballStars
، نأتي إلى IR الموضحة في الرسم البياني أدناه. تبدأ الكتلة التي تبدأ بـ عقدة Begin
-1621 بتخصيص كائن Person
(في عقدة Alloc
) ، والتي تتم تهيئتها مع كل من قيمة الحقل age
بزيادة قدرها 1 والقيمة السابقة لحقل height
. تتم قراءة حقل height
مسبقًا في العقدة LoadField -1539. يتم تغليف نتيجة التخصيص في AllocatedObject
-2137 وإرسالها إلى استدعاء الأسلوب accept
-1625. لا يمكن للمترجم أن يفعل أي شيء أكثر في هذه اللحظة - من وجهة نظره ، نجا الكائن من أسلوب volleyballStars
. ( ملاحظة المترجم: يُطلق على "الهروب من كائن" "الهروب" باللغة الإنجليزية ، وبالتالي فإن اسم التحسين هو "تحليل الهروب" ).

بعد ذلك ، يقرر المترجم أن يقوم بتضمين مكالمة accept
- يبدو هذا معقولًا. نتيجة لذلك ، وصلنا إلى IR التالية:

وهنا يبدأ برنامج التحويل البرمجي JIT في تحليل الهروب الجزئي: يلاحظ أن AllocatedObject
يستخدم فقط لقراءة حقل height
(أذكر ، height
استخدام height
فقط في حالة التصفية ، تحقق من أن الارتفاع أكبر من 198). لذلك ، يمكن للمترجم إعادة تعيين قراءة حقل height
-2167 حتى يعمل مباشرة مع العقدة التي تمت كتابتها مسبقًا إلى كائن Person
(عقدة Alloc
-2136) ، وهذا هو LoadField
-1539 LoadField
بنا. علاوة على ذلك ، لا تذهب عقدة Alloc
فيما يلي إلى إدخال أي عقدة أخرى ، لذلك يمكنك ببساطة حذفها - هذا رمز ميت!
هذا التحسين هو ، في الواقع ، السبب الرئيسي وراء تجربة نموذج volleyballStars
تسارع خمسة أضعاف بعد التحول إلى GraalVM. على الرغم من أن جميع كائنات Person
ليست مطلوبة ويتم تجاهلها بعد الإنشاء مباشرة ، إلا أنها لا تزال بحاجة إلى تخصيصها على كومة الذاكرة المؤقتة ، ولا يزال يتعين تهيئة ذاكرتها. يسمح لك تحليل الهروب الجزئي بإزالة التخصيصات أو تأجيلها عن طريق نقلها إلى فروع الأكواد هذه حيث تهرب الكائنات حقًا والتي تحدث كثيرًا أقل.
يمكنك الحصول على فهم أعمق لتحليل الهروب الجزئي في ورقة تسمى Partial Escape Analysis and Scalar Replacement for Java .
النتائج
في هذه المقالة ، نظرنا إلى ثلاثة تحسينات GraalVM: مضمنة ، مضمنة متعددة الأشكال ، وتحليل جزئي للهروب. هناك العديد من التحسينات المختلفة: تعزيز الدورات وتقسيمها ، وتكرار المسارات ، وترقيم القيم العالمية ، والتواء الثوابت ، وإزالة الكود الميت ، وتنفيذ المضاربة ، وما إلى ذلك.
إذا كنت ترغب في معرفة المزيد حول كيفية عمل GraalVM ، فلا تتردد في فتح صفحة المنشور . إذا كنت تريد أن تتأكد من قدرة GraalVM على تسريع الكود ، فيمكنك تنزيل الثنائيات وتجربتها بنفسك.
من المترجم: مواد إضافية
في المؤتمرات ، يتحدث كل من JPoint و Joker عن GraalVM. على سبيل المثال ، في آخر JPoint 2019 ، زارنا Thomas Wuerthinger (مدير الأبحاث في Oracle Labs ، المسؤول عن GraalVM) و Oleg Shelaev ، أحد اثنين من المبشرين الرسميين بالتكنولوجيا.
يمكنك مشاهدة مقاطع الفيديو هذه ومقاطع الفيديو الأخرى على قناة YouTube الخاصة بنا:
نذكرك أن JPoint التالي سيعقد في الفترة من 15 إلى 16 مايو 2020 في موسكو ، ويمكن بالفعل شراء التذاكر على الموقع الرسمي .