Тесты PyTorch: почему CI показывает имена, которых нет в исходнике

Тесты PyTorch: почему CI показывает имена, которых нет в исходнике

Открываете pull request в PyTorch, смотрите в CI-логи — а там имена тестов, которых вы никогда не писали. TestLinalgCUDA.test_matmul_cuda_float32 — откуда взялся CUDA, почему float32, где ваш TestTorch? Запускаете pytest test/test_torch.py::TestTorch::test_matmul локально — «no tests collected». Знакомо?

Это не баг и не потерянный тест. Это архитектурное решение, которое позволяет PyTorch проверять тысячи комбинаций устройств и типов данных без тысяч рукописных тестов. Разберёмся, как оно работает и как перестать паниковать при виде сгенерированных имён.

Почему «no tests collected» — это нормально

В большинстве тестовых файлов PyTorch класс, который вы видите в исходнике — это шаблон, а не финальный класс. Когда Python импортирует файл, функция instantiate_device_type_tests() разворачивает этот шаблон в конкретные классы для каждого устройства: TestTorchCPU, TestTorchCUDA, TestTorchMPS, TestTorchXPU. Если тест ещё параметризован по dtype, к имени метода добавляется суффикс устройства и типа данных.

Ваш test_matmul превращается в test_matmul_cuda_float32, test_matmul_cpu_float64, test_matmul_mps_float16 и так далее. Один метод в исходнике — десяток реальных тестов в рантайме.

Поэтому pytest test/test_torch.py::TestTorch::test_matmul не находит ничего — класс TestTorch в его исходном виде не существует после инстанциации. Правильный способ запустить конкретный тест — фильтрация по паттерну:

pytest test/test_torch.py -k "test_matmul_cuda_float32" -x

Или, если хотите все варианты test_matmul на всех устройствах:

pytest test/test_torch.py -k "test_matmul"

Это работает, потому что pytest ищет по именам сгенерированных классов и методов, а не по исходным шаблонным именам.

Device-generic тесты: одна функция — все устройства

PyTorch работает на CPU, CUDA, MPS, XPU и других бэкендах. Каждый бэкенд поддерживает десятки dtype: float16, float32, float64, bfloat16, int8, int32, bool и другие. Написать отдельный тест для каждой комбинации — это тысячи строк дублирующегося кода, который невозможно поддерживать.

Решение — тестовые шаблоны. Вы пишете один метод с параметрами device и dtype, а инфраструктура генерирует из него все нужные варианты:

def test_basic(self, device, dtype):
    x = torch.randn(3, 4, device=device, dtype=dtype)
    y = x.matmul(x.T)
    self.assertEqual(y.shape, (3, 3))

Когда файл импортируется, instantiate_device_type_tests() создаёт из одного шаблонного класса конкретные классы для каждого устройства. Имена генерируются по схеме <ClassName><DEVICE>.<method>_<device>_<dtype>. То есть ваш test_basic на CUDA с float32 становится test_basic_cuda_float32, на MPS с float16 — test_basic_mps_float16.

Это не просто удобство. Это способ гарантировать, что каждая операция ведёт себя одинаково на каждом устройстве и с каждым типом данных — без ручного дублирования. Если вы добавляете новый бэкенд в PyTorch, вам не нужно переписывать тесты: достаточно зарегистрировать новое устройство, и вся существующая тестовая матрица автоматически расширится.

OpInfos: метаданные вместо ручных тестов

Операторов в PyTorch сотни — от torch.matmul до torch.nn.functional.scaled_dot_product_attention. Писать тест для каждого оператора вручную было бы абсурдно. Вместо этого PyTorch использует OpInfos — метаданные, описывающие как тестировать каждый оператор.

Каждый OpInfo содержит имя оператора, его варианты (functional, method, in-place), поддерживаемые dtype, примеры входных данных (sample inputs), ожидаемые пропуски (skips), декораторы, правила tolerances и многое другое. Всё это хранится в реестре op_db, определённом в common_methods_invocations.py.

Обобщённые тесты в файлах вроде test_ops.py читают OpInfo и применяют одни и те же проверки к разным операторам: корректность forward pass, проверка градиентов, совместимость с torch.compile, валидация через Meta/FakeTensor. Один тестовый шаблон — десятки операторов, каждый со своим набором устройств и типов данных.

Когда вы видите в CI что-то вроде TestCommonCUDA.test_variant_consistency_eager_torch_matmul_cuda_float32 — это значит, что обобщённый OpInfo-тест проверяет оператор torch.matmul на CUDA с float32, и в данном случае тест проверяет consistency между разными вариантами вызова оператора (функциональный, через метод тензора, in-place).

Декоратор @ops(...) — один из примеров этого паттерна. Для не-операторных случаев есть @parametrize(...), который разворачивает один тест по пользовательским значениям: режимами, формами, конфигурациями. Идея везде одна — один body теста, инфраструктура генерирует комбинации.

Архитектура: кто за что отвечает

Тестовая инфраструктура PyTorch состоит из нескольких слоёв, и понимание их границ помогает быстро найти, куда смотреть при ошибке.

На верхнем уровне — CI-оркестрация. Скрипт test/run_test.py отвечает за запуск файлов, шардирование (разделение тестов между воркерами) и выбор затронутых тестов при pull request. Переменные окружения вроде PYTORCH_TESTING_DEVICE_ONLY_FOR сужают прогон до выбранных устройств, PYTORCH_TEST_WITH_SLOW=1 включает медленные тесты, а PYTORCH_TEST_WITH_DYNAMO=1 добавляет покрытие torch.compile.

Средний уровень — то, с чем работают контрибьюторы. Здесь живут instantiate_device_type_tests, декораторы @dtypes, @onlyCUDA, @onlyCPU, @onlyAccelerator, @ops, @parametrize, @modules, а также OpInfo-определения и утилиты для генерации входных данных (make_tensor, sample_inputs).

Нижний уровень — базовые утилиты в torch.testing._internal.common_utils.py: класс TestCase, функция run_tests(), хелперы load_tests, parametrize, общие флаги. Это фундамент, на котором построено всё остальное.

Ключевые файлы, которые стоит знать: common_device_type.py содержит instantiate_device_type_tests и все device-специфичные декораторы. opinfo/core.py — ядро OpInfo с метаданными, sample inputs, dtype-поддержкой и tolerance-конфигурацией. common_methods_invocations.py — реестр op_db. test/run_test.py — CI-раннер с шардированием.

Практика: как отлаживать CI-сбой

Когда CI падает, первое, что нужно сделать — прочитать сгенерированное имя теста. Оно сообщает вам четыре вещи: какой тест, на каком устройстве, с каким dtype и (для OpInfo-тестов) какой оператор. Это ключ к воспроизведению.

Базовый flow отладки такой. Открываете Dr. CI — это бот, который суммирует падающие джобы на pull request, группирует повторяющиеся сбои и указывает на релевантные логи. Это не замена чтению полного вывода CI, но хорошая стартовая точка. Далее — открываете failing job на hud.pytorch.org, находите сгенерированное имя теста и шард. Потом воспроизводите локально:

pytest test/test_torch.py -k "test_matmul_cuda_float32" -x

Если тест использует OpInfo-инфраструктуру, убедитесь, что sample inputs генерируются корректно для вашего dtype. Частая ошибка — использовать torch.randn в dtype-generic тестах: randn работает для float и complex, но падает на integer и boolean. Правильный выбор — make_tensor, который обрабатывает все категории dtype.

Ещё одна типичная проблема — тесты, которые зависят от порядка выполнения или глобального состояния. PyTorch ожидает, что тесты атомарны. Если ваш тест оставляет после себя модифицированный OpInfo или зарегистрированный хук, следующий тест в том же файле может упасть непредсказуемо. Это особенно заметно при шардировании: разные шарды выполняют разные подмножества тестов, и порядок может отличаться от локального прогона.

Распространённые CI-специфичные ошибки приходятся на различия в окружении (другая версия CUDA, другой драйвер), числовую точность (разные результаты на разных GPU-архитектурах) и предположения о шардировании (тест ожидает, что все другие тесты уже выполнились, а в другом шарде это не так).

Отдельная история — EXPECTTEST_ACCEPT. Многие тесты PyTorch сравнивают вывод с эталонными «snapshots», сохранёнными в репозитории. Когда поведение операции меняется намеренно (оптимизация, исправление бага), snapshot-тесты начинают падать. Переменная EXPECTTEST_ACCEPT=1 автоматически обновляет ожидаемый вывод, и вы коммитите новый snapshot вместе с кодом. Это удобно, но опасно: если вы случайно обновите snapshot, скрывающий регрессию, CI пропустит баг. Всегда проверяйте diff обновлённых snapshot-файлов перед коммитом.

Масштаб: почему без автоматической генерации не обойтись

Несколько цифр для контекста. Репозиторий PyTorch содержит десятки тысяч тестовых методов, которые после инстанциации превращаются в сотни тысяч конкретных тест-кейсов. Каждый оператор может тестироваться на 4+ устройствах и 6+ типах данных — один метод в исходнике порождает 24+ реальных теста. Добавьте сюда OpInfo-тесты, которые для каждого из сотен операторов запускают одинаковые проверки на корректность, градиенты, compile-совместимость и MetaTensor-валидацию.

Именно поэтому PyTorch CI — это не один прогон pytest, а десятки шардов на разных машинах с разными GPU, работающие параллельно. Скрипт test/run_test.py умеет разделять тестовое пространство на шарды так, чтобы каждый воркер получил примерно одинаковое время выполнения. Когда ваш pull request проходит CI, фактически десятки машин параллельно проверяют тысячи сгенерированных тестов — и все они ведут происхождение от нескольких сотен шаблонных методов, написанных людьми.

Понимание этой системы — не академическое упражнение. Это практический навык, который определяет, насколько быстро вы сможете контрибьютить в PyTorch. Контрибьютор, который знает, как читать сгенерированные имена и воспроизводить сбои, решает проблему за десять минут. Контрибьютор, который не знает — тратит часы на попытки запустить тест по исходному имени и в конце концов открывает issue с вопросом «test not found».

Что делать, если вы контрибьютор

Если вы пишете новый тест для PyTorch, несколько правил сэкономят вам часы отладки. Используйте аргумент device, который предоставляет сгенерированный тест — не хардкодьте device="cuda". Это сохраняет переносимость между устройствами. Для dtype-generic тестов используйте make_tensor вместо torch.randn. Для фильтрации при локальном запуске используйте pytest -k с сгенерированным именем, а не пытайтесь вызвать шаблонный класс напрямую.

Если ваш тест работает локально, но падает в CI — проверьте, не зависит ли он от порядка выполнения. Запустите его в изоляции (pytest -x) и в полном файле (pytest test/test_torch.py). Если результаты отличаются — у вас test pollution. Используйте @parametrize и @dtypes вместо ручного дублирования. Проверьте, что ваш тест проходит на всех устройствах, которые вы заявляете — @onlyCUDA не должен стоять на тесте, который технически работает и на CPU, иначе вы упускаете покрытие.

Часто задаваемые вопросы

Почему мой тест называется иначе в CI, чем в исходнике?

Потому что PyTorch генерирует тесты во время импорта файла. Ваш шаблонный метод test_matmul разворачивается в конкретные методы для каждого устройства и dtype: test_matmul_cuda_float32, test_matmul_cpu_float64 и так далее. CI показывает сгенерированные имена, а не шаблонные.

Как запустить конкретный тест, если его имя в CI не совпадает с исходником?

Используйте pytest -k "pattern" с частью сгенерированного имени. Например, pytest test/test_torch.py -k "test_matmul_cuda_float32". Не пытайтесь вызвать шаблонный класс напрямую — после инстанциации он не существует в виде отдельного discoverable объекта.

Что такое OpInfo и зачем они нужны?

OpInfo — это метаданные об операторе PyTorch: имя, варианты вызова, поддерживаемые dtype, примеры входных данных, tolerance-правила. Они позволяют одному обобщённому тесту проверять сотни операторов без написания отдельного теста для каждого. Когда вы добавляете новый оператор, достаточно зарегистрировать его OpInfo — и он автоматически получит покрытие от всех обобщённых тестов.

Почему torch.randn не работает в моих dtype-generic тестах?

Потому что torch.randn генерирует числа из нормального распределения и работает только для floating-point и complex dtype. Для integer, boolean и других типов данных используйте make_tensor — он корректно обрабатывает все категории dtype и принимает явные device и dtype.

Итог

Тестовая инфраструктура PyTorch — это не хаос из сгенерированных имён, а продуманная система масштабирования. Device-generic шаблоны, OpInfos и CI-шардирование вместе позволяют проверять тысячи комбинаций без тысяч рукописных тестов. Как только вы понимаете, что имя в CI — это сгенерированная развёртка вашего шаблона, отладка перестаёт быть мистикой. Читайте сгенерированное имя, воспроизводите через pytest -k, проверяйте атомарность теста — и pull request пойдёт зелёным.

← Все записи