طراحی مترجم

مدل داده

برنامه های StableHLO محاسباتی بر روی تانسورها (آرایه های n بعدی) هستند که در مدل فعلی با استفاده از کلاس Tensor پیاده سازی می شوند. کلاس ذخیره سازی زیربنایی برای یک شی Tensor ، detail::Buffer ، mlir::ShapedType تانسور را همراه با یک شی mlir::HeapAsmResourceBlob ذخیره می کند که نشان دهنده یک لکه قابل تغییر از داده های تانسور است که به صورت آرایه بایتی پیوسته به صورت عمده به مینور قرار گرفته است. سفارش . detail::Buffer برای ساده‌سازی مدیریت حافظه، مرجع شمارش می‌شوند.

عناصر منفرد یک تانسور با استفاده از کلاس Element نشان داده می شوند، که از یک اتحادیه متمایز استفاده می کند که یکی از APInt ، APFloat یا pair<APFloat,APFloat> برای ذخیره نگه می دارد. آخرین مورد برای ذخیره عناصر با انواع پیچیده استفاده می شود.

Tensor دارای API های زیر برای تعامل با عناصر فردی خود است:

  • 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 = dyn_cast<DenseElementsAttr>(attrs[0]);
  DenseElementsAttr rhsData = dyn_cast<DenseElementsAttr>(attrs[1]);
  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(cast<FloatAttr>(element.getValue()).getValue());
  }

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

در حال حاضر، ما فعالانه روی ادغام مفسر در فولدینگ ثابت کار نمی کنیم، زیرا قصد نداریم پوشه را برای StableHLO پیاده سازی کنیم. با این حال، در آینده، ما در حال برنامه ریزی برای استفاده از مفسر برای تا زدن ثابت در MHLO هستیم، در این مرحله، ارگونومی قطعه کد بالا را بهبود خواهیم بخشید (مثلاً می‌توانیم یک تابع کمکی داشته باشیم که عملوندهای ثابت را در اشیاء Tensor بسته می‌کند و بسته‌بندی می‌کند. Tensor به OpFoldResult منجر می شود).

تست مفسر StableHLO

مفسر به عنوان ورودی (A) یک برنامه StableHLO و (B) مقادیر داده را برای تغذیه به برنامه می گیرد و مقادیر داده خروجی را تولید می کند که با مقادیر داده مورد انتظار ارائه شده توسط کاربر مطابقت دارند. مقادیر داده ها (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 Op. تست ها را می توانید در اینجا پیدا کنید.

دستورالعمل های تست

(G1) آیا ما نیاز به آزمایش برای همه انواع پشتیبانی شده برای هر عملیات داریم؟

برای تصمیم گیری می توانیم از ترکیبی از قوانین زیر استفاده کنیم:

  1. در حین پیاده‌سازی یک عملیات، اگر کدی در تابع eval مربوطه وجود داشته باشد تا نوع خاصی را مدیریت کند، وجود تست (ها) برای پوشش آن نوع ضروری است. به عنوان مثال، برای add op، کد انحصاری برای رسیدگی به انواع عدد صحیح، بولی، ممیز شناور و مختلط وجود دارد و از این رو برای هر دسته از انواع به یک تست نیاز داریم.

  2. اگر مجموعه ای از انواع به طور یکنواخت در تابع eval مربوطه مدیریت شود، یک تست واحد برای همه آن انواع باید کافی باشد. به عنوان مثال، برای add op، همه انواع انواع عدد صحیح ( si4 ، u4 ، si8 ، u8 و غیره) به طور یکسان با استفاده از API های llvm::APInt مدیریت می شوند، و از این رو می توانیم از افزودن تست ها برای هر یک از آن گونه ها صرف نظر کنیم. و در عوض یک آزمون نماینده واحد اضافه کنید. برای جلوگیری از ابهام در انتخاب نماینده، باید از دستورالعمل های زیر استفاده کنیم:

    • اگر همه انواع، که به طور یکنواخت مدیریت می شوند، دارای یک نوع ابتدایی هستند (یعنی اگر همه انواع عدد صحیح، ممیز شناور یا مختلط هستند)، یکی را با حداکثر عرض بیت انتخاب کنید.
    • اگر همه انواع، که به طور یکنواخت مدیریت می شوند، دارای ترکیبی از انواع اولیه هستند، آنگاه یکی را با نوع اولیه زیر، به ترتیب اولویت انتخاب کنید: عدد صحیح، ممیز شناور، بولی، مختلط.

(G2) چگونه در مورد تعداد تست های مورد نیاز برای پوشش رفتار یک عملیات تصمیم می گیریم؟

هدف پوشش جامع منطق مفسر برای عملیات (یعنی تمام موارد گوشه ای از پیاده سازی) با حداقل تعداد تست است. به حداقل رساندن تعداد تست ها برای نگهداری مهم است. هرچه تعداد تست‌های کمتری داشته باشیم، بررسی آن‌ها و اطمینان از اینکه آنها به طور جامع کار را پوشش می‌دهند آسان‌تر است. در نتیجه، ما انتظار داریم که اکثر عملیات ساده‌تر فقط یک تست داشته باشند. اگر به دلایل خوبی پوشش جامع غیرعملی است، بهتر است در >= 90 درصد متوقف شوید. این موضوع به صورت موردی در طول بررسی درخواست کشش تصمیم گیری می شود.

(G3) در مورد اضافه کردن آزمایشات برای زیرساخت مترجم چطور؟

زیرساخت مترجم اغلب ساده است و می تواند به پایگاه اعتماد ما اضافه شود. تنها بخش غیر مهم این است که چگونه انواع مختلف در فضای ذخیره سازی مفسر زیرین بسته بندی شده و از آن خارج می شوند. همانطور که در (G1) بحث شد، ما فقط انواعی از عملیات را آزمایش خواهیم کرد که به طور متفاوتی مدیریت می شوند. با این وجود، ممکن است کد بسته بندی/غیر بسته بندی، مربوط به انواع مختلف انواع عدد صحیح/مقطعی شناور، در طول آزمایش به طور کامل پوشش داده نشود. برای اطمینان از پوشش کامل، می‌توانیم یک constant عملیاتی را انتخاب کنیم که از انواع عناصر StableHLO پشتیبانی می‌کند و آزمایش‌های جامع بنویسیم.

(G4) اگر اجرای یک op به عملیات های دیگر بستگی دارد، آیا باید برای دومی تست بنویسیم؟

خیر. برای مثال، اجرای batch_norm_grad می تواند بر اساس divide ، subtract ، multiply و موارد دیگر باشد. ما باید از آزمایش عملیات دوم در حین آزمایش اولی خودداری کنیم.

(G5) آیا باید آزمایش هایی بنویسیم تا رفتارهای تعریف شده / تعریف نشده پیاده سازی را اعمال کنیم؟

ما نباید تست هایی بنویسیم که رفتارهای تعریف شده یا تعریف نشده عملیات را اعمال می کنند. آزمایش‌هایی که رفتارهای تعریف‌شده توسط پیاده‌سازی را اعمال می‌کنند، رفتار محلی مفسر را نشان می‌دهند که نباید تعمیم داده شود. آزمون‌هایی که رفتار تعریف‌نشده را اعمال می‌کنند، به درک رفتار کارگزار کمک نمی‌کنند.

(G6) هنگام نوشتن آزمون‌ها برای انواع ممیز شناور، نتیجه مورد انتظار باید با چه دقتی در بررسی‌ها مشخص شود؟

برای عملیات ابتدایی (جمع، تفریق، ضرب، تقسیم و مربع)، انتظار می‌رود که پیاده‌سازی زیر مشخصات 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 است، و کار در حال انجام است تا بر اساس تجربه ما در استفاده از StableHLO در عمل و بر اساس بازخورد سهامداران، شماره 1156 را کشف کنیم. همانطور که این کار ادامه دارد، ما زیرساخت را مطابق با آن به روز خواهیم کرد.

(G7) چیزی در مورد سبک کدنویسی آزمون ها وجود دارد؟

  1. مطمئن شوید که از نام واقعی ورودی ها/خروجی ها به جای پیش فرض مقادیر SSA استفاده کنید (مثلا %0، %1، و غیره)
  2. در صورت وجود، مطمئن شوید که آزمون‌ها از قالب چاپ شده زیبا استفاده می‌کنند.

(G8) آیا باید مثالی را که قبلاً در مشخصات ارائه شده است اضافه کنیم؟ بله (برای کامل بودن تست).