راهنمای مسافران برای کاهش #ifdef ها در XLA

خلاصه: #ifdef ها برای قابلیت نگهداری مضر هستند. لطفاً سعی کنید از آنها اجتناب کنید. برای جزئیات بیشتر در مورد چگونگی انجام این کار، به پایین بروید.

مقدمه

دستورات شرطی پیش‌پردازنده ( #if ، #ifdef ، #ifndef و غیره) روشی مناسب برای اجرای کد موجود در محیطی متفاوت از آنچه در ابتدا برای آن در نظر گرفته شده بود، هستند. مثال‌ها عبارتند از:

  • ایجاد امکان کار با کدهای مخصوص CUDA موجود در محیط ROCm یا SYCL.
  • اضافه کردن ویژگی‌های جدید کتابخانه (برای مثال، از نسخه جدیدتر cuDNN یا hipDNN) ضمن حفظ پشتیبانی از نسخه‌های قدیمی‌تر.

با این حال، آنها هزینه نگهداری بالایی دارند (جزئیات بیشتر در بخش بعدی)، به خصوص هنگام بازسازی کد موجود.

این راهنما معایب شرط‌های پیش‌پردازنده را توضیح می‌دهد و جایگزین‌هایی برای رایج‌ترین موارد استفاده به سبک کتاب آشپزی ارائه می‌دهد. هدف از این راهنما کمک به مشارکت‌کنندگان در طراحی تغییرات قابل نگهداری و قابل حمل برای پلتفرم از ابتدا و همچنین به عنوان یک مرجع مشترک در طول بررسی کد است.

انگیزه

پیش‌پردازنده C اولین مرحله در خط لوله کامپایل C++ است. شرط‌های پیش‌پردازنده، جریان توکن‌های پیش‌پردازنده را که از کد منبع و فایل‌های هدر خوانده شده و به کامپایلر C++ تحویل داده می‌شوند، دستکاری می‌کنند. این واقعیت که قبل از کامپایلر اجرا می‌شود و در سطح توکن پیش‌پردازنده عمل می‌کند، دلیل اصلی آن است که منجر به یک پایگاه کد دشوار برای نگهداری می‌شود:

  • پیچیدگی کد: استفاده از #ifdef ها راحت است زیرا می‌توان آنها را تقریباً در هر جایی از کد وارد کرد. از آنجایی که آنها بر اساس توکن‌ها عمل می‌کنند، تقریباً هیچ محدودیت نحوی یا معنایی وجود ندارد. توسعه‌دهندگان نیازی به فکر کردن در مورد یک انتزاع مناسب ندارند؛ آنها می‌توانند فقط شرط‌ها را درست در جایی که مورد نیاز است وارد کنند. عدم اجبار به طراحی یک انتزاع مناسب، خواندن کد و استدلال در مورد آن را دشوارتر می‌کند. داشتن حتی یک انتزاع ساده مانند یک تابع آزاد، آزمایش آسان‌تر این تابع یا شبیه‌سازی رفتار آن را - چه اکنون و چه بعداً - امکان‌پذیر می‌کند.

    همچنین زیرمجموعه‌ای از هشدارهای کامپایلر را بی‌معنی می‌کند زیرا کامپایلر فقط یک جریان توکن ارزیابی‌شده را می‌بیند. یک مثال رایج، نیاز به ویژگی [[maybe_unused]] روی یک پارامتر تابع است که فقط در یک شاخه از یک شرط پیش‌پردازنده استفاده می‌شود.

  • قابلیت آزمایش (ساخت): از آنجایی که شرط‌های پیش‌پردازنده روی جریان‌های توکن عمل می‌کنند، تمام شاخه‌های گرفته نشده نه از نظر نحوی و نه از نظر معنایی نیازی به صحت ندارند و بنابراین صحت آنها بررسی نمی‌شود. این امر به شدت مانع از بازسازی‌های بزرگتر می‌شود که در آن یک توسعه‌دهنده از find-and-replace استفاده می‌کند و به کامپایلر متکی است تا به آنها بگوید کجا اصلاحات دستی را انجام دهند. تغییرات نادرست در شاخه‌های پیش‌پردازنده کامپایل نشده یا بدون توجه وارد می‌شوند یا در اواخر فرآیند شناسایی می‌شوند (اگر CI اتفاقاً آن شاخه پیش‌پردازنده خاص را بسازد). مورد دوم همچنین برای توسعه روزمره مرتبط است (به نکته بعدی مراجعه کنید).

  • قابلیت آزمایش (پوشش): داشتن شرط‌های پیش‌پردازنده بیشتر به معنای داشتن پیکربندی‌های ساخت بیشتر برای آزمایش است و تعداد پیکربندی‌ها با هر شرط جدید معرفی شده به صورت تصاعدی افزایش می‌یابد. آزمایش همه آنها از قبل غیرممکن است. به عنوان مثال، XLA تعداد زیادی شرط بر اساس شماره نسخه cuDNN دارد که همه آنها در CI اعمال نمی‌شوند. می‌توان استدلال کرد که تا زمانی که پیکربندی‌هایی را که برای ما مهم هستند آزمایش می‌کنیم، این موضوع اهمیت کمتری دارد - که احتمالاً درست است. اما کدی که بی‌جهت خراب شده باشد منجر به گزارش‌های اشکالی می‌شود که باید به آنها رسیدگی شود، حتی اگر فقط گفته شود که یک پیکربندی پشتیبانی نمی‌شود.

    مهم‌تر از آن، پیکربندی‌های ساختِ احتمالاً خراب، زمان از دست رفته‌ی تکرار توسعه‌دهنده را افزایش می‌دهند. یک تغییر ممکن است هنگام اجرای bazel test به صورت محلی به خوبی کار کند، اما CI ممکن است پیکربندی کمی متفاوت ایجاد کند و با شکست مواجه شود. رفع اشکالات اغلب بی‌خطر هستند - مانند اضافه کردن یک ویژگی [[maybe_unused]] - اما این رفت و برگشت اضافی برای توسعه‌دهنده در هر درخواست pull زمان اضافی به همراه دارد.

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

  • ابزار: تجزیه و تحلیل C++ کار بسیار پیچیده‌ای است که امروزه اکثر ابزارها برای تجزیه و تحلیل معنایی به یک رابط کامپایلر متکی هستند و سپس مستقیماً روی AST عمل می‌کنند. نمونه‌های قابل توجه شامل clang-tidy ، include-cleaner و سرورهای زبان ( clangd ) هستند که تکمیل کد، ناوبری و برجسته‌سازی نحو را در IDE شما ارائه می‌دهند. دسته دیگری از ابزارها به ابزار کامپایلر متکی هستند، از جمله ابزارهای پاکسازی و پوشش کد.

    همه این ابزارها فقط یک پیکربندی ساخت واحد را می‌بینند، بنابراین استفاده گسترده از شرط‌های پیش‌پردازنده مانع از کاربردپذیری آنها می‌شود. برای مثال، include-cleaner پیشنهاد می‌کند #include هایی را که فقط در یک شاخه پیش‌پردازنده ارزیابی نشده استفاده می‌شوند، حذف کنید. به طور مشابه، IDE شما وقتی کد ROCm برای ساخت CUDA یا CPU پیکربندی شده باشد، هایلایت سینتکس یا پیمایش کد را برای آن نشان نمی‌دهد و ویرایش آن را دشوار می‌کند.

کاهش‌ها

دسته اول - نادیده گرفتن موارد تست در تست‌های واحد

بسیار رایج است که فقط یک مورد آزمایشی خاص پشتیبانی می‌شود:

  • روی یک بک‌اند خاص.
  • با یک مدل پردازنده گرافیکی خاص.
  • وقتی کتابخانه X حداقل از نسخه Y باشد.

پیش از این، رد کردن تست‌ها با استفاده از دستورات شرطی پیش‌پردازنده رایج بود:

TEST(Foo, Bar) {
#ifdef TENSORFLOW_USE_ROCM
  GTEST_SKIP();
#endif
  // ...
}

جایگزین: از StreamExecutor بپرسید

StreamExecutor لایه انتزاعی سخت‌افزار XLA است و stream_executor::DeviceDescription آن اطلاعات لازم برای تصمیم‌گیری مشابه در زمان اجرا را دارد:

TEST_F(FooTest, Bar) {
  // `device_description()` is provided by `HloPjRtGpuTestBase`. Other test
  // fixtures expose it via `executor->GetDeviceDescription()`.
  const se::DeviceDescription& device = device_description();

  // Skip based on the backend platform.
  if (device.gpu_compute_capability().IsRocm()) {
    GTEST_SKIP() << "Not supported on ROCm.";
  }

  // Skip based on the GPU model / compute capability.
  if (const auto* cc =
          device.gpu_compute_capability().cuda_compute_capability();
      cc != nullptr && !cc->IsAtLeastHopper()) {
    GTEST_SKIP() << "Requires Hopper or newer.";
  }

  // Skip based on the runtime or library version.
  if (device.runtime_version() < se::SemanticVersion{12, 2, 0}) {
    GTEST_SKIP() << "Requires CUDA runtime >= 12.2.";
  }
  if (device.dnn_version() < se::SemanticVersion{9, 0, 0}) {
    GTEST_SKIP() << "Requires cuDNN >= 9.0.";
  }
  // ...
}

DeviceDescription هم قابلیت‌های محاسباتی و هم شماره نسخه‌های ساختاریافته را از طریق se::SemanticVersion در معرض نمایش قرار می‌دهد:

  • بک‌اند و معماری: device.gpu_compute_capability().IsCuda() ، device.gpu_compute_capability().IsRocm() ، device.gpu_compute_capability().IsOneAPI() ، و اکسسوری‌هایی برای CudaComputeCapability ، RocmComputeCapability و OneAPIComputeCapability .
  • نسخه‌های زمان اجرا و درایور: device.runtime_version() ، device.driver_version() ، device.kernel_mode_driver_version() ، device.compile_time_toolkit_version() .
  • نسخه‌های کتابخانه: device.dnn_version() ، device.cub_version() .

اگر یک نسخه یا ویژگی سخت‌افزاری خاص هنوز در DeviceDescription موجود نیست، لطفاً آن را به DeviceDescription اضافه کنید (یا از مشارکت‌کننده بخواهید که آن را اضافه کند) به جای اینکه به #ifdef برگردید.

بلندمدت (نظر نویسنده)

در درازمدت، هیچ یک از تست‌های سطح بالاتر ما نباید نیازی به تصمیم‌گیری بر اساس backendها، انواع سخت‌افزار یا نسخه‌های runtime/driver داشته باشند. در عوض، تست‌ها باید از StreamExecutor بپرسند که آیا یک ویژگی خاص در دسترس است یا خیر، و StreamExecutor بر اساس تمام جزئیات لازم، آن را تعیین می‌کند. همه این منطق باید در یک مکان قرار گیرد، اگرچه هنوز طراحی مشخصی ندارد.

دسته دوم - می‌تواند یک دستور if زمان اجرا باشد

کلاس دیگری از شرط‌های پیش‌پردازنده، کدی را محافظت می‌کند که بدون شرط نیز به خوبی کامپایل می‌شود:

void Foo::Bar() {
#if TENSORFLOW_USE_ROCM
  // Do something that would also compile in CUDA/CPU mode
#endif
  // ...
}

جایگزین: if از یک زمان اجرا استفاده کنید

شرط پیش‌پردازنده را با یک شرط زمان اجرا جایگزین کنید:

void Foo::Bar() {
  if (stream_executor_.GetDeviceDescription()
          .gpu_compute_capability()
          .IsRocm()) {
    // Do something that compiles fine everywhere
  }
  // ...
}

اینکه آیا همه این موارد باید در یک تابع جداگانه لحاظ شوند یا خیر، به صلاحدید بررسی‌کننده بستگی دارد. در حال حاضر، این فقط یک کد «عادی» است و رویه‌های بررسی کد عادی اعمال می‌شود.

این دسته از شرط‌های پیش‌پردازنده اغلب هنگام بررسی شرطی که به راحتی در DeviceDescription مربوط به StreamExecutor در دسترس نیست، ظاهر می‌شوند. به همه توصیه می‌شود از مشارکت‌کنندگان بخواهند به جای پذیرش شرط پیش‌پردازنده، اطلاعات مربوطه را به DeviceDescription اضافه کنند.

دسته سوم - نیاز به دسترسی به زمان اجرای سطح پایین دارد

رایج‌ترین (و همچنین موجه‌ترین) دسته از دستورات شرطی پیش‌پردازنده، آن‌هایی هستند که از کدی محافظت می‌کنند که در غیر این صورت کامپایل نمی‌شود. اغلب این به این دلیل است که از چیزی از یک هدر سطح پایین مخصوص backend (مانند API CUDA یا ROCm) استفاده می‌کند:

#if TENSORFLOW_USE_ROCM
#include <something/something/rocm.h>
#endif

void Foo::Bar() {
#if TENSORFLOW_USE_ROCM
  // Do something that would *NOT* compile in CUDA/CPU mode
#endif
  // ...
}

همین امر در مورد محافظ‌های نسخه در هدرهای کتابخانه (برای مثال، #if CUDNN_VERSION >= 90000 ) نیز صدق می‌کند، زمانی که کد به نمادهایی اشاره می‌کند که در نسخه‌های قدیمی‌تر هدر وجود ندارند.

جایگزین اول: بگذارید StreamExecutor با آن برخورد کند

اولین سوالی که باید پرسید این است که آیا کامپوننت تغییر یافته اصلاً باید به مشخصات سطح پایین زمان اجرا وابسته باشد یا خیر. در حالت ایده‌آل، فقط لایه انتزاع سخت‌افزاری ما یعنی StreamExecutor باید به این ویژگی وابسته باشد. بنابراین اگر این کد بخشی از StreamExecutor نیست، اولین گزینه این است که ببینیم آیا می‌توان آن را به xla/stream_executor/ منتقل کرد یا خیر.

این به معنای ایجاد یک API (یک تابع، یک کلاس یا هر چیز دیگری که منطقی باشد) در StreamExecutor است که برای همه backendها وجود دارد. این API می‌تواند پیاده‌سازی‌های متفاوتی بر اساس backend داشته باشد. همچنین ممکن است امکان پرس‌وجو در مورد اینکه آیا این ویژگی خاص در دسترس است یا خیر را فراهم کند، که این امر راه‌حل runtime- if که در بالا توضیح داده شد را فعال می‌کند.

البته، این بدان معناست که StreamExecutor اکنون باید با دسترسی به APIهای سطح پایین backend سر و کار داشته باشد. Alternative II توضیح می‌دهد که چگونه می‌توان این را بدون (تعداد زیاد) #ifdef مدل‌سازی کرد.

روش دوم: کد را در تارگت‌های جداگانه بنویسید

اگر استفاده از هدرهای مخصوص backend را نتوان به StreamExecutor وارد کرد یا تغییر در داخل StreamExecutor باشد، بهترین گزینه تقسیم کد به واحدهای کامپایل جداگانه است. در مثال ما، یک هدف ساخت با یک فایل منبع C++ حاوی کد ROCm و یک هدف ساخت دیگر با یک فایل منبع C++ حاوی کد غیر ROCm داریم - معمولاً یک پیاده‌سازی stub که absl::UnimplementedError در تمام توابع تعریف شده برمی‌گرداند.

این مزایای زیر را دارد:

  • تقسیم کد در چندین فایل به این معنی است که باید لایه‌ای از انتزاع بین آنها وجود داشته باشد - حداقل یک تابع آزاد. این امر مشارکت‌کنندگان و داوران را تشویق می‌کند تا در مورد سطح مناسب انتزاع فکر کنند و در درازمدت منجر به کد با کیفیت بالاتر شود.
  • هر دو فایل فقط شامل یک پیکربندی ساخت واحد هستند - که به شما امکان هایلایت کامل سینتکس و ابزار IDE را در هر دو می‌دهد.
  • هر دو هدف ساخت می‌توانند به طور جداگانه در یک نمودار ساخت ساخته شوند. نیازی به تغییر پرچم‌های ساخت Bazel و حذف حافظه پنهان ساخت نیست.
  • هر دو هدف ساخت می‌توانند تست‌های مخصوص به خود را داشته باشند. برای مثال، اگر یک API خاص فقط برای ROCm وجود داشته باشد، می‌توانیم تست‌های واحدی داشته باشیم که فقط پیاده‌سازی ROCm را آزمایش کنند، به جای تست‌های سطح بالاتر که در سایر موارد نادیده گرفته می‌شوند، که می‌تواند سرعت تست را افزایش دهد. (البته انصافاً: در بسیاری از موارد دلایل خوبی برای داشتن تست‌های سطح بالاتر نیز وجود دارد.)

یک طرح بتنی می‌تواند به این شکل باشد:

// feature.h
// Do NOT include any platform-specific headers (CUDA/ROCm) here.
#include "absl/status/status.h"

absl::Status Foo();
// feature_rocm.cc
#include "feature.h"
#include "something/something/rocm.h"

absl::Status Foo() {
  // Do something ROCm-specific.
}
// feature_stub.cc
#include "feature.h"
#include "absl/status/status.h"

absl::Status Foo() {
  return absl::UnimplementedError("This is a ROCm-only feature.");
}
# BUILD
load("@local_config_rocm//rocm:build_defs.bzl", "if_rocm_is_configured")

cc_library(
    name = "feature_rocm",
    srcs = [
        "feature.h",
        "feature_rocm.cc",
    ],
    tags = ["manual"],  # Exclude this from wildcard builds when ROCm is not enabled
    deps = [
        "@com_google_absl//absl/status",
        "@local_config_rocm//rocm:rocm_headers",
    ],
)

cc_library(
    name = "feature_stub",
    srcs = [
        "feature.h",
        "feature_stub.cc",
    ],
    deps = ["@com_google_absl//absl/status"],
)

cc_library(
    name = "feature",
    hdrs = ["feature.h"],
    deps = if_rocm_is_configured(
        [":feature_rocm"],
        [":feature_stub"],
    ) + [
        "@com_google_absl//absl/status",
    ],
)

توجه داشته باشید که feature.h فقط در ویژگی hdrs از هدف :feature (و در srcs دو هدف پیاده‌سازی) قرار دارد. این تضمین می‌کند که مصرف‌کنندگان از جمله feature.h (و ابزارهای وابستگی خودکار) به :feature وابسته هستند نه مستقیماً به :feature_rocm یا :feature_stub .

همچنین مهم است که feature.h شامل هیچ هدر مخصوص پلتفرم (مانند هدرهای CUDA، ROCm یا cuDNN) نباشد ؛ فقط فایل‌های .cc (و هدرهای خصوصی) باید شامل این موارد باشند. الگوی هدف ساخت مشابه برای CUDA ( if_cuda_is_configured from //xla/tsl/platform/default:cuda_build_defs.bzl ) و SYCL نیز کار می‌کند.

بلندمدت (نظر نویسنده)

در مثال قبلی، اهداف :feature_rocm و :feature_stub نماد یکسانی به نام Foo تعریف می‌کنند، بنابراین نمی‌توان آنها را بدون ایجاد نقض ODR به یک فایل باینری یکسان پیوند داد. در دراز مدت، XLA می‌تواند زیرساخت افزونه‌ای داشته باشد که در آن چندین backend بتوانند همزمان بارگیری شوند. در این صورت، طراحی بالا باید کمی تغییر کند تا :feature_rocm و :feature_stub نمادهایی با نام‌های مختلف تعریف کنند و :feature یک تابع dispatch با نام اصلی داشته باشد - اگرچه در حال حاضر مشخص نیست که آیا این امر به یک اولویت به اندازه کافی بالا تبدیل خواهد شد یا خیر.