قياس الأداء والمعايرة
قياس الأداء والمعايرة
قياس الأداء في Java أصعب مما يبدو للوهلة الأولى. إنّ JVM بيئة تشغيل تكيّفية للغاية: تُفسّر البايت كود، وتُنشئ ملفًا تشخيصيًا للمسارات الساخنة، وتُجمّلها إلى كود نيتف أثناء التنفيذ، وتُجري جمع القمامة، وتُدمج استدعاءات الدوال عبر مواقع الاستدعاء — وكل ذلك أثناء تشغيل المعيار القياسي. تجاهل هذه الديناميكيات يعني أن أرقامك مجرد وهم. ستتعلم في هذا الدرس لماذا تكذب القياسات الساذجة، وما هو الإحماء ولماذا يهم، وكيف يحل Java Microbenchmark Harness (JMH) المشكلة بشكل صحيح.
لماذا القياس الساذج يكذب
أول ما يخطر ببال معظم المطوّرين هو تغليف الكود بـ System.nanoTime() وحساب الفارق. هذا الأسلوب معطوب في قياسات الدقيقة من عدة أوجه مختلفة.
تجميع JIT ليس فوريًا. عندما يصادف JVM دالةً لأول مرة يُفسّرها ببطء. بعد استدعاء الدالة نحو 10,000 مرة (عتبة C1) يُجمّل JIT الكود إلى كود نيتف مُحسَّن. وبعد 10,000 مرة أخرى قد يُعيد التجميل مع تحسينات C2 الأكثر عدوانية. إن شغّل معيارك دالةً 100 مرة فإن التنفيذات الـ 80 الأولى مُفسَّرة والـ 20 الأخيرة مُجمَّلة: والمتوسط لا معنى له.
إزالة الكود الميت. إن استطاع JIT إثبات أن نتيجة حساب ما لن تُستخدم فإنه يُزيله كليًا. معيار يحسب مجموعًا ويتجاهل النتيجة قد لا يقيس شيئًا على الإطلاق.
طيّ الثوابت. جسم حلقة يعتمد فقط على ثوابت وقت التجميل قد يُحسب مرة واحدة وتُزال الحلقة. تقيس عملية لا شيء.
تدخّل جامع القمامة. توقّف جمع القمامة في منتصف القياس يُضخّم التوقيت. بدون التحكّم في GC تختلف النتائج المتتالية بحسب حالة GC.
اضطراب جدولة نظام التشغيل. سلب الخيوط لمعالج المهام، وضبط تردد المعالج (turbo boost، وأوضاع توفير الطاقة)، وتأثيرات ذاكرة NUMA تُضيف ضوضاء إلى النتائج.
فهم الإحماء
الإحماء هو المرحلة التي ينتقل فيها JVM بقطعة من الكود من التنفيذ المُفسَّر إلى الكود النيتف المُحسَّن بالكامل. يتدرّج خط أنابيب تجميل JVM المتعدد المستويات كما يلي:
- المستوى 0: تفسير بحت.
- المستوى 1-3: مُجمّل C1 (مُجمّل العميل) — تجميل سريع مع تحسينات أساسية.
- المستوى 4: مُجمّل C2 (مُجمّل الخادم) — تحسينات تخمينية عدوانية، ودمج استدعاءات، وتحليل الهروب.
يجب أن يقيس المعيار فقط إنتاجية حالة الثبات على المستوى 4. وهذا يعني تشغيل الكود عددًا كافيًا من التكرارات — عادةً آلاف الاستدعاءات — قبل البدء بتسجيل القياسات. يختلف العدد الفعلي من تكرارات الإحماء اللازمة بحسب تعقيد الدالة وقرارات تحليل JVM.
انظر هذا المثال المضلّل:
من الناحية العملية الكتلة الثانية أسرع 5-20 مرة من الأولى حتى على كود بسيط. لا الرقمان خطأ — إنهما يقيسان حالتين مختلفتين للـ JVM. يعمل الكود الإنتاجي دائمًا في حالة الثبات؛ كذلك يجب أن يعمل معيارك.
Java Microbenchmark Harness (JMH)
طوّر مهندسو أداء JVM في Oracle أداة JMH وتوزّع عبر OpenJDK، وهي الأداة القياسية لكتابة معايير الدقيقة الصحيحة في Java. تتعامل تلقائيًا مع الإحماء، ومنع إزالة الكود الميت (عبر Blackhole واستهلاك النتيجة)، وعمليات JVM المتشعّبة، والتجميع الإحصائي.
إضافة JMH إلى مشروع Maven
معيار JMH أدنى صالح
Blackhole ضروري. بدون استهلاك النتيجة يحق لـ JIT تحديد أن الحساب غير مستخدم وإزالته كليًا. bh.consume(value) يُنشئ تبعية مزيّفة تُبطل هذا التحسين دون إضافة عبء ملحوظ في حد ذاتها.
شرح تعليمات JMH الرئيسية
@BenchmarkMode—Mode.AverageTimeأوMode.ThroughputأوMode.SampleTimeأوMode.SingleShotTime. اختر بحسب ما يهمك: متوسط الكُمون، أو الإنتاجية، أو توزيع النسب المئوية.@Fork— يُشغّل كل معيار في JVM جديد. يعزل هذا حالة JIT بين المعايير ويمنع قرارات تحليل أحد المعايير من التأثير على آخر. لا تشغّل قط بـ@Fork(0)في القياسات الإنتاجية.@Warmup/@Measurement— تتحكّم في مرحلتي الإحماء والقياس بشكل منفصل. تكرارات الإحماء تُتجاهل؛ فقط تكرارات القياس تُساهم في النتيجة المُبلَّغ عنها.@State—Scope.Benchmark(مشترك)، أوScope.Thread(نسخة لكل خيط)، أوScope.Group(لكل مجموعة معايير). يحدد مشاركة الكائنات في المعايير متعددة الخيوط.@Setup/@TearDown— تهيئة الحالة وتنظيفها؛ لا تُضعا داخل دالة@Benchmarkأبدًا.
تشغيل JMH وقراءة المخرجات
ابنِ JAR شاملًا وشغّله من سطر الأوامر:
يطبع JMH جدولًا كهذا:
قيمة ± هي فاصل الثقة بنسبة 99.9% عبر عمليات التشعّب والتكرارات. فاصل ضيّق يعني أن القياس مستقر. فاصل واسع يعني تباينًا عاليًا — تحتاج إلى المزيد من التكرارات وعمليات التشعّب أو جهاز أكثر عزلًا.
taskset (Linux) وعطّل ضبط التردد.
مصائد المعايرة الشائعة لتجنّبها
- دمج حلقات المعيار: إن كانت دالة معيارك سريعة جدًا بحيث تستغرق تكرارات الاستدعاء نانوثانيات قليلة، فقد يدمج JIT التكرارات ويُقلّل تكاليف الإعداد. استخدم
@OperationsPerInvocationأو أعد هيكلة الدالة. - تكرارات إحماء قليلة جدًا: الدوال المعقدة ذات رسوم الاستدعاء العميقة تحتاج إحماءً أطول. ابدأ بخمسة تكرارات على الأقل بثانية واحدة لكل منها وتحقق بـ
-prof gcأن GC لا يتدخّل. - قياس الشيء الخطأ: قياس
HashMap.get()مع مفاتيح String مُنشأة داخل جسم المعيار يقيس تخصيص String وحساب التجزئة، لا الاسترجاع وحده. - تجاهل معدّل التخصيص: استخدم
-prof gcلرؤية البايتات المُخصَّصة لكل عملية. دالة تُخصّص كثيرًا ستُطلق توقفات GC في الإنتاج حتى لو بدت سريعة بمعزل عن سياقها.
الخلاصة
القياس الساذج بـ System.nanoTime() ينتج نتائج غير موثوقة لأن تجميل JIT وإزالة الكود الميت وGC تعمل بالتزامن مع قياسك. الإحماء هو انتقال JVM من التنفيذ المُفسَّر إلى الكود المُحسَّن بالكامل — القياسات المأخوذة قبل اكتمال الإحماء تعكس أداء المُفسِّر لا أداء الإنتاج. JMH يحل كل هذه المشكلات عبر مراحل إحماء محكومة وعمليات JVM متشعّبة واستهلاك النتائج بـ Blackhole والتجميع الإحصائي. استخدم JMH لأي معيار تنوي التصرّف بناءً على نتائجه.