تصميم مترجم

نموذج البيانات

برامج SttableHLO هي عمليات حاسوبية على المصفوفات (الصفائف n)، التي يتم تنفيذها في النموذج الحالي باستخدام الفئة Tensor. تخزِّن فئة مساحة التخزين الأساسية لكائن Tensor، detail::Buffer، mlir::ShapedType من الموتر مع كائن mlir::HeapAsmResourceBlob يمثّل كائنًا ثنائيًا متغيِّرًا من بيانات متعدّد الفقرة كصفيف بايت متجاورة بترتيب رئيسي. تم احتساب detail::Buffer عناصر كمرجع لتبسيط إدارة الذاكرة.

يتم تمثيل العناصر الفردية من متوتر باستخدام الفئة Element، التي تستخدم اتحادًا مميزًا يضم أيًا من APInt أو APFloat أو pair<APFloat,APFloat> للتخزين. يُستخدم الأخير لتخزين العناصر ذات الأنواع المعقدة.

يضم Tensor واجهات برمجة التطبيقات التالية للتفاعل مع عناصره الفردية:

  • Element Tensor::get(llvm::ArrayRef<int64_t> index): لاستخراج عنصر متعدّد فردي بمؤشر متعدد الأبعاد index ككائن Element.
  • void Tensor::set(llvm::ArrayRef<int64_t> index, Element element);: لتعديل عنصر Element element إلى موقد بمؤشر متعدد الأبعاد index.

آلية عمل الترجمة الفورية

دالة الدخول إلى المترجم الفوري هي

SmallVector<Tensor> eval(func::FuncOp func, ArrayRef<Tensor> args);

والتي تقوم بما يلي:

  1. لتتبع وسيطات SSA لـ func وقيم وقت التشغيل Tensor المرتبطة بها، المتوفرة في args، باستخدام خريطة جدول الرموز، M.
  2. لكل عملية ضمن func، بترتيب SSACFG:
    • يستدعي eval في الجزء العلوي. بالنسبة إلى كل معامل SSA من op، يمكنك استخراج قيمة وقت التشغيل من M لتقديمها كوسيطة لاستدعاء eval.
    • يتتبع نتائج SSA للعملية والقيمة التي تم تقييمها في M.

يكون مستوى eval المشار إليه في (2) مسؤولًا عن تنفيذ دلالات التنفيذ للعملية. في ما يلي مثال على stablehlo::AddOp. في هذا المثال، يتم استخراج العناصر الفردية من متدرجي lhs وrhs بشكل زوجي ككائنات Element وتتم إضافتها بعد ذلك. يتم تخزين نتيجة الإضافة، الكائن Element، في المتسلسل result الأخير.

Tensor eval(AddOp op, const Tensor &lhs, const Tensor &rhs) {
  Tensor result(op.getType());

  for (auto it = result.index_begin(); it != result.index_end(); ++it)
    result.set(*it, lhs.get(*it) + rhs.get(*it));

  return result;
}

بشكل عام، تم تحسين تصميم المترجم لسهولة قراءة دوال eval للعمليات الفردية لأنها تهدف إلى أن يكون بمثابة تطبيق مرجعي لأداة StableHLO. على سبيل المثال، بدلاً من تعريف eval كدالة نموذج وتصنيفها بأنواع العناصر، نضمّن تفاصيل حول كيفية التعامل مع أنواع العناصر المختلفة في Element::operator+ وغيرها، ما يبسّط تنفيذ eval.

استخدام أداة الترجمة الفورية للطي المستمر

يمكننا استخدام آلية الترجمة الفورية لطي العمليات ذات قيم المعاملات الثابتة. يوضِّح مقتطف الرمز التالي فكرةً عن تنفيذ عملية طي stablehlo::AddOp باستخدام معاملات مكتوبة بنقاط عائمة:

OpFoldResult AddOp::fold(FoldAdaptor adaptor) {
  auto attrs = adaptor.getOperands();
  DenseElementsAttr lhsData = attrs[0].dyn_cast<DenseElementsAttr>();
  DenseElementsAttr rhsData = attrs[1].dyn_cast<DenseElementsAttr>();
  if (!lhsData || !rhsData) return {};

  auto lhs = Tensor(lhsData);
  auto rhs = Tensor(rhsData);
  auto result = eval(*this, lhs, rhs);

  SmallVector<APFloat> values;
  for (auto i = 0; i < result.getNumElements(); ++i) {
    Element element = result.get(i);
    values.push_back(element.getValue().cast<FloatAttr>().getValue());
  }

  return DenseElementsAttr::get(result.getType(), values);
}

في الوقت الحالي، لا نعمل بنشاط على دمج أداة الترجمة في الطي الثابت، لأننا لا نخطط لتنفيذ مجلد في StableHLO. مع ذلك، نخطط في المستقبل للاستفادة من أداة الترجمة الفورية للطي المستمر في MHLO، وفي هذه المرحلة سنعمل على تحسين هندسة بيئة العمل لمقتطف الرمز أعلاه (على سبيل المثال، يمكن أن تكون لدينا وظيفة مساعِدة تجمع المعاملات الثابتة في كائنات Tensor وفك ضغط نتائج Tensor في OpFoldResult).

اختبار أداة StableHLO الفورية

يأخذ المترجم اللغوي كمدخلات (أ) لبرنامج StableHLO، و (ب) يتم إدخال قيم البيانات إلى البرنامج، وينشئ قيم بيانات المخرجات التي تتم مطابقتها مقابل قيم البيانات المتوقعة التي يقدمها المستخدم. يتم ترميز قيم البيانات (B) بشكل ثابت في البرنامج نفسه باستخدام عمليات stablehlo.constant. يقيّم المترجم الفوري برنامج الإدخال. يتم التحقق من مخرجات العملية الخاضعة للاختبار من خلال عمليات التحقق (على سبيل المثال check.expect_eq وcheck.expect_almost_eq)، كما هو موضّح أدناه. check.expect_eq وcheck.expect_eq_const تحقق من التساوي في البت لأي نوع متوافق، ثم check.expect_almost_eq وcheck.expect_almost_eq_const تحققان من المساواة تقريبًا في ظل التسامح، كما هو موضح في إرشادات الاختبار (G6)، للأنواع المعقّدة والنقاط العائمة.

// CHECK-LABEL: Evaluated results of function: add_op_test_ui4
func.func @add_op_test_ui4() {
  %0 = stablehlo.constant dense<[0, 2]> : tensor<2xui4>
  %1 = stablehlo.constant dense<[15, 3]> : tensor<2xui4>
  %2 = stablehlo.add %0, %1 : tensor<2xui4>
  check.expect_eq_const %2, [15, 5] : tensor<2xui4>
  func.return
}

تكون أداة الاختبار stablehlo-translate --interpret (الكود) مسئولة عن تحليل البرنامج، وتفسير كل دالة بما في ذلك العمليات التي تشكل الدالة. ولدينا مجموعة اختبار مخصّصة تتألّف من عدة اختبارات حول السلوكيات المختلفة في وقت التشغيل، وذلك لكل إصدار من عمليات StableHLO. يمكن العثور على الاختبارات هنا (على سبيل المثال، interpret_*.mlir).

إرشادات بشأن إجراء الفحوصات

(G1) هل نحتاج إلى اختبار جميع الأنواع المتوافقة في كل عملية؟

يمكننا استخدام مجموعة من القواعد التالية لتحديد ذلك:

  1. أثناء تنفيذ عملية تشغيل، إذا كان هناك رمز برمجي في دالة eval المقابلة لمعالجة نوع معيّن، يجب إجراء اختبارات لتغطية هذا النوع. على سبيل المثال، في عملية add، يتوفّر رمز حصري للتعامل مع الأنواع الصحيحة والمنطقية والنقاط العائمة والأنواع المعقدة، وبالتالي نحتاج إلى اختبار واحد لكل فئة من الأنواع.

  2. إذا تم التعامل مع مجموعة من الأنواع بشكل موحّد في دالة eval المقابلة، يكفي إجراء اختبار واحد لجميع هذه الأنواع. على سبيل المثال، بالنسبة إلى العملية add، يتم التعامل مع جميع صيغ أنواع الأعداد الصحيحة (si4، وu4، وsi8، وu8 وما إلى ذلك) بالطريقة نفسها باستخدام واجهات برمجة تطبيقات llvm::APInt، وبالتالي يمكننا تخطي إضافة الاختبارات لكل خيار من هذه المتغيرات، وإضافة اختبار تمثيلي واحد بدلاً من ذلك. لتجنب الغموض في اختيار الممثل، ينبغي لنا استخدام الإرشادات التالية:

    • إذا كانت جميع الأنواع، التي يتم التعامل معها بشكل موحّد، لها النوع الأساسي نفسه (أي إذا كانت جميعها عددًا صحيحًا أو نقاطًا عائمة أو أنواعًا مركّبة)، عليك اختيار النوع ذي الحد الأقصى لعرض البت.
    • إذا كانت جميع الأنواع، التي يتم التعامل معها بشكل موحّد، تحتوي على مزيج من الأنواع الأساسية، عليك اختيار النوع بالنوع الأساسي التالي بترتيب تنازلي حسب التفضيل: العدد الصحيح، والنقطة العائمة، والمنطقية، والمعقدة.

(الجولة 2) كيف نحدد عدد الاختبارات اللازمة لتغطية سلوك العملية؟

والهدف من ذلك هو أن يشمل ذلك شرحًا شاملاً للمترجم الفوري للعملية (أي جميع الحالات الأساسية في عملية التنفيذ) بأقل عدد من الاختبارات. من المهم تقليل عدد الاختبارات لضمان إمكانية الحفاظ عليها. فكلما قلّ عدد الاختبارات التي نملكها، أصبح من الأسهل مراجعتها والتأكد من أنّها تغطي العملية بشكل شامل. ونتيجةً لذلك، نتوقع أن يتم إجراء اختبار واحد فقط لمعظم العمليات الأبسط. إذا كانت التغطية الشاملة غير عملية لسبب وجيه، فلا بأس من التوقف عند نسبة >= 90%. سيتم تحديد ذلك على أساس كل حالة على حدة أثناء مراجعة طلب السحب.

(G3) ماذا عن إضافة اختبارات للبنية الأساسية للمترجم؟

غالبًا ما تكون البنية الأساسية للترجمة الفورية مباشرة ويمكن إضافتها إلى قاعدة ثقتنا. الجزء الوحيد الذي ليس بسيطًا هو كيفية وضع الأنواع المختلفة من المحتوى في وحدة تخزين الترجمة الفورية وتفريغها من هذه البيانات. كما ناقشنا في (G1)، سنختبر فقط تلك الأنواع من العمليات التي يتم التعامل معها بشكل مختلف. مع ذلك، من المحتمل ألا يتم تغطية رمز التغليف/تفريغ التغليف بالكامل أثناء الاختبار، وذلك وفقًا للمتغيرات المختلفة لأنواع الأعداد الصحيحة/النقاط العائمة. ولضمان التغطية الكاملة، يمكننا اختيار عملية مثل constant تتوافق مع جميع أنواع عناصر StableHLO وكتابة اختبارات شاملة.

(إحصاءات Google 4) إذا كان تنفيذ إحدى العمليات يعتمد على عمليات أخرى، هل علينا كتابة اختبارات لها؟

لا، على سبيل المثال، يمكن أن يستند تنفيذ batch_norm_grad إلى divide وsubtract وmultiply وغيرها. يجب أن نتجنب اختبار العمليات الأخيرة أثناء اختبار الأولى.

(إحصاءات Google 5) هل علينا كتابة اختبارات لممارسة السلوكيات المحدّدة التنفيذ / غير المحدّدة؟

يجب ألا نكتب اختبارات تمارس السلوكيات المحددة في التنفيذ أو غير المحددة للتكرار التحسيني. تُظهر الاختبارات التي تمارس سلوكيات محدّدة التنفيذ سلوكًا محليًا للمترجم الفوري، وهو ما يجب عدم تعميمه. لا تساهم الاختبارات التي تمارس سلوكًا غير معروف في فهم سلوك العملية.

(إحصاءات Google 6) أثناء كتابة اختبارات لأنواع النقاط العائمة، ما مدى الدقة الذي يجب تحديد النتيجة المتوقّعة فيها في عمليات التحقّق؟

بالنسبة للعمليات الأولية (الجمع والطرح والضرب والقسمة والمربع)، من المتوقع أن يوفر التطبيق وفقًا لمواصفات IEEE نتيجة تقريبية في حدود 0.5 ULP من النتيجة الدقيقة الرياضية. مع ذلك، يمكننا أن نتخيل بأمان النتيجة المتوقعة من هذه العمليات أن تكون على بُعد 1 من ULP تقريبًا. ومع ذلك، قد لا يصلح ذلك مع الدوال المتصاعدة (sine وcosine وما إلى ذلك) التي تكون ضمانات الدقة محدّدة لها (الأساس).

يستخدم التنفيذ الحالي قيمة السماح "واحد يناسب الجميع" وهي 0.0001. يوضح المثال التالي مدى التسامح أعلاه عمليًا.

func.func @check_tolerance() {
  %0 = stablehlo.constant dense<0.2> : tensor<f32>

  // The following check succeeds as %0 is almost equal to the provided
  // constant modulo the tolerance, mentioned above.
  check.expect_almost_eq_const %0, dense<0.19999> : tensor<f32>

  // The following check fails as %0 is not bitwise equal to the provided
  // constant.
  check.expect_eq_const %0, dense<0.19999> : tensor<f32>

  func.return
}

وهذه هي الخطوة الأولى فقط في اختبار الدقة العددية لعمليات StableHLO. في الوقت الحالي، هناك مجال غير محدد لمواصفات StableHLO، وهناك عمل مستمر لمعرفة ذلك #1156 بناءً على تجربتنا في استخدام StableHLO عمليًا وبناءً على الملاحظات الواردة من الأطراف المعنية. ومع استمرار هذه العملية، سنُحدِّث البنية الأساسية وفقًا لذلك.

(G7) هل هناك أي شيء عن أسلوب الترميز في الاختبارات؟

  1. تأكد من استخدام الاسم الفعلي للمدخلات/المخرجات بدلاً من استخدام القيم الافتراضية لقيم SSA (مثل %0 و%1 وما إلى ذلك).
  2. التأكد من أن الاختبارات تستخدم تنسيقًا مطبوعًا بالكامل، إن وُجِد.

(G8) هل يجب تضمين المثال الذي سبق أن تم تقديمه في المواصفات؟ نعم (لاكتمال الاختبار)