Вкратце: директивы #ifdef плохо влияют на удобство сопровождения кода. Пожалуйста, старайтесь их избегать. Подробнее о том, как это сделать, читайте ниже.
Введение
Условные операторы препроцессора ( #if , #ifdef , #ifndef и т. д.) — это удобный способ заставить существующий код работать в среде, отличной от той, для которой он изначально предназначался. Примеры включают:
- Обеспечение работоспособности существующего кода, специфичного для CUDA, в среде ROCm или SYCL.
- Добавление новых функций библиотеки (например, из более новых версий cuDNN или hipDNN) при сохранении поддержки более старых версий.
Однако они сопряжены с высокими затратами на обслуживание (подробнее в следующем разделе), особенно при рефакторинге существующего кода.
В этом руководстве в формате пошагового руководства объясняются недостатки условных операторов препроцессора и предлагаются альтернативы для наиболее распространенных случаев их использования. Оно призвано помочь участникам проекта с самого начала разрабатывать поддерживаемые и платформенно-переносимые изменения, а также служить общим справочным материалом во время проверки кода.
Мотивация
Препроцессор C — это первый этап в конвейере компиляции C++. Условные операторы препроцессора обрабатывают поток токенов препроцессора, считываемых из исходного кода и заголовочных файлов и передаваемых компилятору C++. Тот факт, что он выполняется до компилятора и работает на уровне токенов препроцессора, является основной причиной, по которой его код становится трудноподдерживаемым:
Сложность кода: директивы
#ifdefудобны в использовании, поскольку их можно вставлять практически в любое место кода. Так как они работают с токенами, синтаксических или семантических ограничений практически нет. Разработчикам не нужно думать о подходящей абстракции; они могут просто вставлять условные операторы там, где это необходимо. Отсутствие необходимости разрабатывать подходящую абстракцию делает код более читабельным и сложным для понимания. Даже простая абстракция, такая как свободная функция, упрощает тестирование этой функции или имитацию её поведения — сейчас или позже.Это также делает бессмысленным подмножество предупреждений компилятора, поскольку компилятор будет видеть только один вычисленный поток токенов. Распространенный пример — необходимость атрибута
[[maybe_unused]]для параметра функции, который используется только в одной ветви условного оператора препроцессора.Тестируемость (сборка): Поскольку условные операторы препроцессора работают с потоками токенов, все незавершенные ветви не обязательно должны быть синтаксически или семантически корректными и, следовательно, не проверяются на корректность. Это серьезно затрудняет масштабные рефакторинги, когда разработчик применяет поиск и замену и полагается на компилятор, который указывает, где нужно внести исправления вручную. Некорректные изменения в незавершенных ветвях препроцессора либо остаются незамеченными, либо обнаруживаются на поздних этапах процесса (если система непрерывной интеграции (CI) собирает именно эту ветвь препроцессора). Последнее также актуально для повседневной разработки (см. следующий пункт).
Тестируемость (покрытие): Чем больше условных операторов препроцессора, тем больше конфигураций сборки нужно тестировать, и количество конфигураций экспоненциально возрастает с каждым новым введенным условием. Уже сейчас невозможно протестировать все из них. Например, в XLA довольно много условных операторов, основанных на номерах версий cuDNN, которые не все проверяются в CI. Можно утверждать, что это менее актуально, пока мы тестируем те конфигурации, которые нас интересуют, — что, вероятно, верно. Но ненужный неработающий код приводит к сообщениям об ошибках, которые кто-то должен исправить, даже если это просто сообщение о том, что конфигурация не поддерживается.
Что еще более важно, потенциально неработающие конфигурации сборки увеличивают время простоя разработчика в процессе итераций. Изменение может отлично работать при локальном запуске
bazel test, но CI может собрать немного другую конфигурацию и завершиться с ошибкой. Исправления часто безобидны — например, добавление атрибута[[maybe_unused]]— но этот дополнительный цикл обходится разработчику дополнительным временем при каждом запросе на слияние.Следовательно, уменьшение количества условных операторов препроцессора уменьшает количество конфигураций сборки, что, в свою очередь, снижает вероятность дополнительных обращений к системе непрерывной интеграции при запросе на слияние.
Инструментарий: Парсинг C++ — настолько сложная задача, что большинство современных инструментов полагаются на интерфейс компилятора для парсинга и семантического анализа, а затем работают непосредственно с AST. К числу известных примеров относятся
clang-tidy,include-cleanerи языковые серверы (clangd), которые обеспечивают автодополнение кода, навигацию и подсветку синтаксиса в вашей IDE. Другой класс инструментов использует инструментарий компилятора, включая санитайзеры и инструменты анализа покрытия кода.Все эти инструменты видят только одну конфигурацию сборки, поэтому чрезмерное использование условных операторов препроцессора затрудняет их использование. Например,
include-cleanerпредлагает удалять директивы#include, которые используются только в невыполняемой ветке препроцессора. Аналогично, ваша IDE не будет отображать подсветку синтаксиса или навигацию по коду для кода ROCm, если он настроен для сборки CUDA или CPU, что затруднит его редактирование.
Меры по смягчению последствий
Категория I — Пропуск тестовых случаев в модульных тестах
Нередко определенный тестовый случай поддерживается только следующим образом:
- На определённом бэкэнде.
- При наличии определенной модели графического процессора.
- Когда библиотека 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 .
Долгосрочная перспектива (мнение автора)
В долгосрочной перспективе ни одному из наших тестов высокого уровня не следует принимать решения, основываясь на бэкэндах, вариантах оборудования или версиях среды выполнения/драйверов. Вместо этого тесты должны запрашивать у StreamExecutor информацию о доступности той или иной функции, и StreamExecutor будет определять это на основе всех необходимых данных. Вся эта логика должна находиться в одном месте, хотя конкретного решения пока нет.
Категория II — может быть оператором 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 вместо того, чтобы принимать условный оператор предварительной обработки.
Категория III — Требуется доступ к низкоуровневой среде выполнения.
Наиболее распространенный (и наиболее оправданный) класс условных операторов препроцессора — это те, которые защищают код, который иначе не скомпилируется. Чаще всего это происходит потому, что они используют что-то из низкоуровневого заголовочного файла, специфичного для бэкэнда (например, 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 ), когда код ссылается на символы, которых нет в более старых версиях заголовочного файла.
Альтернатива I: Пусть StreamExecutor сам с этим разберется.
Первый вопрос, который следует задать, — должен ли измененный компонент вообще зависеть от низкоуровневых особенностей среды выполнения. В идеале, зависимость должна быть только от нашего уровня аппаратной абстракции StreamExecutor . Поэтому, если этот код не является частью StreamExecutor , первым делом следует проверить, можно ли его добавить в xla/stream_executor/ .
Это означает создание API (функции, класса или чего-либо еще, что имеет смысл) в StreamExecutor , который будет существовать для всех бэкендов. Этот API может иметь различные реализации в зависимости от бэкенда. Он также может позволять запрашивать информацию о доступности той или иной функции, что обеспечивает возможность использования описанного выше решения if условием выполнения.
Конечно, это означает, что StreamExecutor теперь должен иметь дело с доступом к низкоуровневым бэкэнд-API. Альтернатива II описывает, как это можно смоделировать без (слишком большого количества) директив #ifdef .
Альтернатива II: Код в отдельных целевых объектах.
Если использование специфичных для бэкенда заголовочных файлов не может быть добавлено в StreamExecutor или изменение находится внутри StreamExecutor , лучшим вариантом является разделение кода на отдельные единицы компиляции. В нашем примере у нас есть одна цель сборки с одним исходным файлом C++, содержащим код ROCm, и другая цель сборки с одним исходным файлом C++, содержащим код, не относящийся к ROCm — как правило, это заглушка, которая возвращает 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 из //xla/tsl/platform/default:cuda_build_defs.bzl ) и SYCL.
Долгосрочная перспектива (мнение автора)
В предыдущем примере цели :feature_rocm и :feature_stub определяют один и тот же символ Foo , поэтому их нельзя связать в одном и том же бинарном файле без нарушения ODR. В долгосрочной перспективе XLA могла бы иметь инфраструктуру плагинов, позволяющую загружать несколько бэкендов одновременно. В этом случае описанную выше структуру необходимо немного изменить, чтобы :feature_rocm и :feature_stub определяли символы с разными именами, а :feature имела функцию диспетчеризации с исходным именем — хотя в настоящее время неясно, станет ли это достаточно приоритетной задачей.