Ускорители: GPU, NPU, FPGA, ASIC и когда они оправданы
Универсальное ядро — инженерное чудо, которое умеет исполнить любую программу и ничего не знает заранее о том, какую именно. За эту неосведомлённость оно платит каждым тактом: выбирает команду, разбирает её биты, переименовывает регистры, ставит операцию в планировщик, угадывает исход перехода, поддерживает когерентность с соседями и готово в любой момент откатить всё сделанное. Полезная арифметика занимает в этом бюджете проценты.
Ускоритель — устройство, которое отказалось от части универсальности и вернуло себе эти проценты. Отказалось по-разному: GPU оставил программируемость, но потребовал однородности; матричный блок оставил одну операцию; FPGA отдал частоту, но разрешил построить схему под задачу; ASIC не оставил ничего, кроме одного алгоритма, зато навсегда. Эта глава — о том, откуда берётся выигрыш, почему он так часто не материализуется, и как посчитать заранее, окупится ли перенос, не написав ни строки кода под устройство.
Где мы находимся
Считаем известным: латентность и пропускная способность — разные величины (глава 06); внеочередное ядро тратит огромную площадь, чтобы извлечь параллелизм из последовательного кода (глава 07); кэши, предвыборка и контроллер памяти разобраны в главе 09, и мы будем ими пользоваться, не пересказывая.
SIMD внутри ядра — ускоритель нулевого уровня: та же модель исполнения, тот же конвейер, просто операция применяется к вектору. Он разобран в главе 10, и всё сказанное там про выравнивание, ширину вектора и хвосты цикла остаётся в силе. Здесь начинается то, что живёт за пределами ядра: отдельный кристалл или отдельный блок со своей памятью, своей моделью программирования и своей ценой входа.
Бюджет мощности, тепловой потолок и тёмный кремний — глава 13; именно она объясняет, почему ускорители стали массовым явлением, а не экзотикой. Стоимость фотошаблонов, выход годных и чиплеты — глава 14. Прикладная сторона инференса нейросетей — батчинг, квантизация в проде, выбор рантайма — разобрана отдельно; здесь только железо под ней.
Первопринцип: считать дёшево, двигать дорого
Разложим, во что обходится одно 32-битное целочисленное сложение на универсальном ядре. Само сложение — это примерно тридцать вентилей и один такт. Вокруг него: выборка команды из кэша с обращением к TLB и предсказателю; декодирование, а при переменной длине ещё и поиск границ; переименование с обращением к таблице отображения и списку свободных регистров; планирование — запись в станцию резервирования, пробуждение по общей шине, арбитраж за порт; спекуляция, то есть работа, которая может быть выброшена целиком, но энергию уже потратила; чтение и запись многопортового регистрового файла; отставка с проверкой порядка; снуп-трафик когерентности.
Числа на схеме — порядки величины для КМОП-узла порядка 45 нм, из доклада Марка Горовица «Computing’s Energy Problem» (ISSCC 2014). С переходом на более тонкие узлы абсолютные значения падают, но пропорции между строками почти не меняются, а разрыв между арифметикой и внешней памятью со временем только растёт: транзистор дешевеет быстрее, чем провод. Между сложением 32-битных целых и чтением того же слова из внешней DRAM — три-четыре порядка. Между сложением и передачей слова через весь кристалл — примерно три. Накладные расходы одной команды в универсальном ядре — десятки пикоджоулей, в сотни раз больше самой операции.
Отсюда главный тезис главы, который стоит запомнить дословно:
Ускоряют не арифметику. Ускоряют движение данных.
Специализированный блок выигрывает не потому, что его сумматор лучше — сумматор одинаковый. Он выигрывает потому, что не платит за команду (управление зашито в схему, декодировать нечего), держит операнды рядом (результат одного умножителя идёт прямо на вход соседнего по короткому проводу, а не через регистровый файл и кэш), читает каждый байт из внешней памяти минимальное число раз, потому что схема потоков спроектирована под конкретный алгоритм, и не спекулирует, а значит, не тратит энергию на выброшенную работу.
Проверка тезиса тривиальная: если ваша задача упирается в пропускную способность памяти, ускоритель с той же памятью не поможет никак — сколько бы арифметических блоков в нём ни было. Это не теория, а самая частая причина разочарований.
Ось «универсальность против эффективности»
Все устройства, о которых идёт речь, лежат на одной оси, и бесплатного обеда на ней нет.
Верхний правый квадрант пуст, и это не случайность. Каждый шаг влево по оси — выброшенный из схемы механизм: сначала внеочередное исполнение и спекуляция, потом когерентный кэш, потом произвольный поток управления, потом программируемость вообще. Каждый выброшенный механизм отдаёт свою энергию и площадь полезной работе — и одновременно сужает множество задач, которые устройство вообще способно выполнить. Порядок величины разрыва между крайними точками — два-три порядка по энергоэффективности на подходящей нагрузке. И ровно ноль, а чаще отрицательное значение, на неподходящей.
GPU: пропускная способность вместо латентности
Модель исполнения SIMT
CPU оптимизирован под латентность: он делает всё, чтобы одна цепочка зависимых операций прошла как можно быстрее, и ради этого держит большие кэши, глубокую спекуляцию и широкий планировщик. GPU оптимизирован под пропускную способность: ему безразлично, сколько времени займёт одна операция, важно, сколько их завершается в секунду суммарно. Из этой смены цели вырастает вся конструкция. Модель называется SIMT — single instruction, multiple threads: программист пишет функцию, описывающую работу одного потока, а запускается она сразу для десятков тысяч.
// Условная нотация ядра: тело описывает работу ОДНОГО потока,
// а запуск порождает сразу целую сетку из них.
void kernel_saxpy(const float *x, const float *y, float *out, float a, int n) {
int i = global_thread_index(); // уникальный номер потока в сетке
if (i < n) {
out[i] = a * x[i] + y[i]; // соседние потоки — соседние адреса
}
}
Аппаратно потоки не независимы: они склеены в группы фиксированного размера — варп или волна, порядок 32 или 64 потока в поколениях 2010–2020-х. Вся группа делит одну выборку команды и один счётчик команд. Это и есть главная экономия: стоимость декодирования размазывается на десятки потоков, и накладные расходы, которые на CPU съедали больше энергии, чем сама операция, падают в тридцать-шестьдесят раз. Плата за это — дивергенция ветвлений.
// Худший случай для SIMT: ветвление зависит от данных конкретного потока.
if (mask[i]) { heavy_a(i); } // выполняет часть потоков варпа
else { heavy_b(i); } // выполняет вторая часть
// Железу приходится пройти ОБЕ ветви подряд, отключая маской неактивные потоки.
// Время равно сумме времён обеих ветвей, а не максимуму из них.
Если в варпе есть хотя бы один поток, идущий по другой ветви, обе ветви исполняются последовательно, а неактивные потоки простаивают под маской. В пределе, когда все 32 потока расходятся по разным путям, эффективная производительность падает в 32 раза. Механизм тот же, что и в предикатном SIMD (глава 10), только цена заметнее, потому что группа шире. Практический вывод: сортируйте работу так, чтобы соседние по номеру потоки шли по одной ветви. Ветвление по номеру блока почти бесплатно, ветвление по значению элемента — очень дорого.
Занятость вместо кэшей
CPU прячет латентность памяти кэшами: если данные рядом, ждать не надо. GPU прячет её принципиально иначе — переключением на другую работу. Каждый вычислительный блок держит одновременно резидентными десятки варпов; когда варп упирается в чтение из памяти, планировщик за один такт переключается на другой готовый варп. Пока первый ждёт свои сотни тактов, блок продолжает считать. Отношение числа резидентных варпов к максимально возможному называется занятостью (occupancy).
Для этого нужен гигантский регистровый файл: контексты всех резидентных варпов хранятся одновременно, переключение не должно ничего сохранять. Отсюда парадокс, ломающий интуицию CPU-программиста: регистровый файл GPU крупнее, чем кэш первого уровня. На CPU наоборот. Отсюда же главное ограничение при написании ядер — регистры и разделяемая память делятся между резидентными варпами.
Мало регистров на поток → высокая занятость → латентность спрятана,
но больше обращений к памяти за промежуточными значениями
Много регистров на поток → низкая занятость → латентность видна,
но промежуточные значения не выезжают из регистров
Оптимум почти никогда не на краях и почти никогда не совпадает
с максимальной занятостью. Мерить, а не угадывать.
Важная поправка к расхожему мифу: стопроцентная занятость не является целью. Ядро с занятостью 50 % и хорошей локальностью регулярно обгоняет ядро со стопроцентной занятостью и лишним трафиком в память. Занятость — средство прятать латентность, а не метрика качества.
Коалесцирование и иерархия памяти
Контроллер памяти GPU обслуживает запросы транзакциями фиксированного размера — порядок 32 или 128 байт. Если 32 потока варпа читают 32 подряд идущих четырёхбайтовых слова, это укладывается в несколько транзакций и вся прочитанная память идёт в дело; такой доступ называется коалесцированным.
Коалесцированный: a[i] адреса 0, 4, 8, ... 124 → одна транзакция, полезно 128 из 128
Шаговый: a[i * 16] адреса 0, 64, 128, ... → 32 транзакции, полезно 4 из каждых 32
Случайный: a[index[i]] адреса произвольные → до 32 транзакций, худший случай
Разница между первым и вторым вариантом — до порядка величины по эффективной пропускной способности при абсолютно одинаковом числе арифметических операций. Именно поэтому на GPU так часто переписывают «массив структур» в «структуру массивов»: это делает доступ соседних потоков соседним по адресам. Логика та же, что в главе 09, только цена ошибки выше, потому что промах не прячется внеочередным исполнением.
| Уровень | Кто видит | Порядок латентности | Роль |
|---|---|---|---|
| Регистры | один поток | 1 такт | всё, что помещается, должно жить здесь |
| Разделяемая память блока | все потоки блока | единицы-десятки тактов | ручной кэш: программист сам решает, что положить |
| L2 на кристалле | все блоки | десятки-сотня тактов | общий, небольшой относительно объёма задачи |
| Память устройства | все блоки и DMA | сотни тактов | очень широкая шина, но огромная латентность |
Ключевое отличие от CPU — разделяемая память управляется явно. Это не кэш, который сам угадывает, а блок SRAM, в который программист вручную копирует нужный кусок, считает по нему многократно и выгружает результат. Блочное умножение матриц — самый известный пример: подматрица загружается один раз, а участвует в вычислениях много раз.
Хост и устройство: где обычно всё ломается
Ускоритель — отдельное устройство со своей памятью. Данные надо туда доставить и оттуда забрать, и пересылка идёт по шине, которая на порядок-полтора уже, чем шина к собственной памяти ускорителя. Порядки величины для дискретных ускорителей 2020-х: шина хост-устройство — десятки гигабайт в секунду, память устройства — сотни гигабайт в секунду и выше.
загрузка модулей, компиляция ядер, прогрев частот P->>D: асинхронная отправка входных данных D->>Q: дескриптор пересылки Q->>M: DMA читает закреплённую память хоста P->>D: постановка ядра в очередь Note over P: управление возвращается сразу;
хост готовит следующий блок Q->>A: запуск после завершения пересылки в этой же очереди par Перекрытие пересылок со счётом A->>M: счёт по блоку номер N and Q->>M: DMA тянет блок номер N плюс один end P->>D: ожидание события или явная синхронизация D-->>P: результат готов, буфер можно читать
Пересылка съедает счёт. Если задача читает мегабайт, делает над ним по одной операции на байт и возвращает мегабайт, время пересылки в обе стороны заведомо больше времени счёта на любом ускорителе; выигрыша здесь нет, и он не появится от смены модели устройства. Запуск не бесплатен. Постановка ядра в очередь, обход драйвера и синхронизация имеют фиксированную стоимость порядка единиц-десятков микросекунд, и ядро, которое считает две микросекунды, окупить её не может — отсюда правило укрупнять батчи и сращивать мелкие ядра в одно.
Асинхронность обязательна. Синхронная пересылка означает, что вычислитель простаивает всё время передачи. Разбив данные на блоки и организовав конвейер «пересылка блока N плюс один параллельно со счётом блока N», можно скрыть почти всё время передачи — но только если вычисление достаточно длинное, а память хоста закреплена: иначе DMA не сможет читать её напрямую и драйвер вставит лишнее копирование.
Унифицированная память — механизм, при котором хост и устройство видят одно адресное пространство, а страницы мигрируют по требованию. Она резко упрощает код и незаменима там, где заранее неизвестно, какие данные понадобятся: графы, разреженные структуры, указательные обходы. Но миграция страницы — это отказ страницы, обработчик и пересылка по той же шине, только теперь неявная и по четыре килобайта. На регулярной задаче с известным шаблоном доступа ручное управление буферами почти всегда быстрее. Правило: унифицированная память — для удобства и нерегулярности, ручные пересылки — для производительности. На интегрированных устройствах, где память физически общая, копирование действительно исчезает, и это меняет всю арифметику.
NPU и матричные блоки: специализация под одну операцию
Систолический массив
Если посмотреть, на что уходит время в обучении и инференсе нейросетей, обнаружится, что подавляющая доля — умножение матриц и свёртки, сводимые к тому же умножению. Одна операция, известная заранее, с идеально регулярным шаблоном доступа: такое грех не зашить в железо. Классическая конструкция — систолический массив: двумерная решётка одинаковых умножителей-накопителей, соединённых только с ближайшими соседями. Данные втекают с двух краёв, продвигаются на одну ячейку за такт, результат вытекает с третьего.
Систолический массив, вариант со стационарными весами
W W W W Каждая ячейка хранит один вес, загруженный один раз;
W W W W получает активацию слева и частичную сумму сверху;
W W W W выдаёт активацию вправо и сумму вниз.
W W W W Активации втекают слева, частичные суммы вытекают снизу.
↓ ↓ ↓ ↓
Вся суть в том, что ячейка не обращается ни к регистровому файлу, ни к кэшу: операнд приходит от соседа по проводу длиной в доли миллиметра. Вспомните шкалу энергии — провод на 10 мм стоит порядка сотни пикоджоулей, провод к соседу — доли пикоджоуля. Массив 128 на 128 выполняет за такт 16384 умножения с накоплением, прочитав из памяти всего 128 весов и 128 активаций.
Стационарность данных (dataflow) — ответ на вопрос, что остаётся на месте, а что течёт. При weight-stationary вес закреплён за ячейкой, а активации текут: выгодно, когда весов относительно немного, а входных данных много, то есть при большом батче. При output-stationary в ячейке накапливается один элемент результата, а операнды текут мимо: выгодно, когда результат маленький, а редукция длинная. Существуют гибриды вроде row-stationary, оптимизирующие суммарный трафик под конкретную форму слоя. Выбор стационарности — это выбор того, какой из трёх потоков данных не будет ходить в память; универсально лучшего нет, и хороший компилятор ускорителя перебирает варианты под каждый слой.
Арифметическая интенсивность и roofline
Ключевая метрика при переносе на любой ускоритель — арифметическая интенсивность: число операций на один байт, прочитанный из памяти.
Сложение векторов длины n: операций n, байт 12n → интенсивность ≈ 0,08 оп/байт
Умножение матриц n на n: операций 2n³, байт 12n² → интенсивность ≈ n / 6 оп/байт
n = 64 → порядка 10 оп/байт; n = 1024 → порядка 170
Модель roofline:
Достижимо = min( пик_вычислителя, пропускная_способность_памяти × интенсивность )
Точка перегиба = пик_вычислителя / пропускная_способность_памяти
Слева от перегиба — memory-bound: помогает только сокращение трафика
(слияние операций, переиспользование, меньшая разрядность)
Справа от перегиба — compute-bound: помогает только больше арифметики
(более широкие блоки, лучшее расписание)
Вот почему матричное умножение — эталонная нагрузка: интенсивность растёт линейно с размером блока, тогда как у поэлементных операций она константа, близкая к нулю. Задача с интенсивностью 0,08 упирается в память при любой конструкции вычислителя; задача с интенсивностью 170 способна нагрузить арифметику полностью. При этом точка перегиба у современных ускорителей ушла далеко вправо — порядок сотен операций на байт. Это значит, что подавляющее большинство реальных нагрузок находится слева от неё, то есть ограничено памятью, и практический вывод обескураживающе прост: оптимизация арифметики почти всегда бессмысленна, оптимизация трафика почти всегда работает.
Квантизация как покупка пропускной способности
Если задача ограничена памятью, есть прямой рычаг: сделать каждый операнд короче. Переход с 32-битных чисел с плавающей точкой на 16-битные вдвое сокращает трафик, переход на 8-битные целые — вчетверо. Одновременно растёт плотность арифметики: в ту же площадь помещается больше умножителей, потому что площадь умножителя растёт примерно как квадрат разрядности.
Это и есть квантизация с точки зрения железа: обмен точности на пропускную способность памяти. Матричные блоки последних поколений поэтому поддерживают целый зоопарк форматов — 16-битные с плавающей точкой с разным распределением бит между мантиссой и порядком, 8-битные целые, форматы ещё меньшей разрядности с общим масштабным множителем на группу значений. Что при этом теряется, где калибровать и чем измерять деградацию — в главе про инференс.
Почему генерация токенов упирается в память
Показательный пример, ломающий интуицию «нейросети — это про счёт». При генерации текста авторегрессионной моделью токены выходят по одному, и чтобы получить один следующий токен, нужно прочитать все веса модели, прогнав через сеть вектор длиной в несколько тысяч.
Один токен, батч 1: прочитано байт — размер модели целиком
операций — примерно 2 × число параметров
интенсивность — порядка 2 оп/байт при 8-битных весах
Перегиб ускорителя: сотни оп/байт → задача глубоко в memory-bound
→ время токена ≈ размер модели / пропускная способность памяти
→ вычислительные блоки загружены единицами процентов
Из этого следует всё практическое поведение систем генерации. Скорость упирается в пропускную способность памяти, а не в счёт. Квантизация ускоряет генерацию почти пропорционально сокращению размера весов. Батчинг работает лучше любой другой оптимизации: обрабатывая несколько запросов одновременно, вы читаете веса один раз для всех, поднимая интенсивность в число раз, равное размеру батча. А вот стадия обработки входного запроса — наоборот, compute-bound, потому что там обрабатывается вся последовательность сразу. Две стадии одного процесса имеют противоположные узкие места, и именно поэтому их разделяют. Устройство самих механизмов внимания разбирается отдельно.
FPGA: вычисление в пространстве, а не во времени
Из чего это собрано
FPGA — программируемая логическая матрица. LUT (look-up table) — таблица истинности на 4–6 входов; любая булева функция от этого числа переменных реализуется одной такой таблицей, конфигурация просто задаёт её содержимое. Триггеры — по одному-два на LUT, для конвейеризации и хранения состояния. Блочная память (BRAM) — блоки SRAM в десятки килобит, разбросанные по кристаллу; их сотни или тысячи, и у каждого свои порты, поэтому суммарная пропускная способность внутренней памяти FPGA огромна. DSP-блоки — жёсткие умножители-накопители: из LUT умножитель тоже собирается, но дорого. Коммутационная матрица — программируемые связи между всем перечисленным; она занимает большую часть площади и определяет достижимую частоту.
Что значит «пространственное вычисление»
Разница с процессором принципиальная. Процессор разворачивает алгоритм во времени: одни и те же АЛУ выполняют разные операции в разные такты, порядок задаётся потоком команд, и стоимость управления платится каждый такт. FPGA разворачивает алгоритм в пространстве: под каждую операцию строится своя схема, они соединяются проводами в том порядке, в котором данные должны через них пройти, а управления как такового нет — порядок задан топологией.
Сильные стороны следуют прямо отсюда. Произвольная разрядность: нужен 11-битный счётчик или 3-битное поле — вы строите ровно 11 и 3 бита, тогда как процессор округлит до 16 или 32; на обработке битовых потоков, кодировании и криптографии это даёт многократный выигрыш из ничего. Детерминированная латентность: схема работает по тактам, задержка от входа до выхода известна с точностью до такта, ни кэшей, ни планировщика, ни прерываний — решающий аргумент в управлении и радиотрактах, где важен гарантированный худший случай, а не средний (Микроконтроллеры и периферия). Обработка потока на лету: FPGA ставится прямо в разрыв канала данных, и данные обрабатываются по мере поступления, никогда не попадая в память целиком, — то самое устранение движения данных, ради которого всё затевалось.
Слабые стороны столь же прямые. Низкая частота: программируемая коммутация — это сотни транзисторов на каждый «провод», задержка на них велика, и типичные рабочие частоты FPGA на порядок ниже, чем у ASIC на том же техпроцессе; выигрыш добывается параллелизмом, а не тактами. Долгий цикл сборки: синтез, размещение и разводка большого проекта — это часы, иногда сутки, и результат не воспроизводится в точности при малейшем изменении, так что правка «поменять константу и посмотреть» стоит полдня (сравните с секундами компиляции ядра для GPU). Верификация: ошибка в схеме не даёт стектрейса, отладка идёт через симуляцию и встроенные логические анализаторы, которые сами занимают ресурсы кристалла. Цена крупных кристаллов: большие FPGA — одни из самых дорогих микросхем на рынке, потому что площадь огромна, а тираж мал.
HLS не отменяет понимания железа
Синтез высокого уровня (HLS) позволяет писать на C-подобном языке и получать схему. Технология рабочая, и она действительно снимает необходимость писать всё на языке описания аппаратуры вручную. Но заблуждение, будто она превращает программиста в схемотехника, стоит развеять сразу: HLS-компилятор строит конвейер из вашего цикла, и чтобы конвейер принимал новую итерацию каждый такт, нужно, чтобы итерации были независимы, обращения к памяти не конфликтовали по портам, а разрядности были заданы явно. Всё это по-прежнему решает человек — просто директивами вместо проводов. Код, написанный «как для процессора», синтезируется в схему, которая работает медленнее того же кода на процессоре, и это типичный первый результат.
ASIC: билет в один конец
ASIC — заказная микросхема под конкретную задачу: верхняя точка эффективности и нижняя точка гибкости. Ключевое понятие — NRE (non-recurring engineering), разовые затраты, не зависящие от тиража: зарплата команды за годы разработки, лицензии на средства проектирования и готовые блоки и, главное, комплект фотошаблонов — набор масок для литографии. На передовых узлах комплект масок измеряется десятками миллионов USD, на зрелых — единицами миллионов. Отдельно оплачивается партия пластин и почти всегда вторая итерация: первый кремний редко бывает годным сразу. Из чего складывается эта цена, откуда берётся выход годных и почему индустрия перешла к чиплетам — в главе 14.
Отсюда главный риск, у которого нет технического решения: алгоритм может устареть раньше, чем чип поедет. Полтора-три года в области, где подходы меняются за месяцы, — это ставка на то, что вы верно предсказали будущее; история знает достаточно чипов, приехавших идеально работающими и никому уже не нужными. Поэтому ASIC оправдан при одновременном выполнении трёх условий. Объём: NRE делится на тираж, и десятки миллионов USD разовых затрат при тираже в тысячу штук дают немыслимую цену изделия, а при тираже в десятки миллионов — копейки. Стабильность алгоритма: кодек, криптографический примитив, протокол связи, контроллер накопителя зафиксированы стандартом на десятилетие и потому идеальные кандидаты, а «текущее поколение архитектуры нейросетей» — плохой. Жёсткий бюджет энергии: там, где счёт идёт на милливатты — телефон, носимое устройство, датчик на батарейке, — двухпорядковый выигрыш не роскошь, а единственный способ уместиться в бюджет (глава 13).
Промежуточный вариант, который часто оказывается верным ответом, — специализированный блок внутри универсального чипа. Аппаратный кодек видео, криптоблок, матричный ускоритель, обработчик сигнала камеры: тираж у них равен тиражу всего чипа, а NRE размазан по нему же. Именно так специализация проникла в массовые устройства, и именно поэтому в современном телефоне универсальные ядра занимают меньшую часть площади кристалла.
Экономика: считаем выигрыш до того, как писать код
Время на CPU: T_cpu = работа / производительность_cpu
Время с ускорителем: T_acc = T_запуска
+ байт_туда / пропускная_способность_шины
+ max( работа / пик_устройства,
трафик_памяти / пропускная_способность_памяти )
+ байт_обратно / пропускная_способность_шины
Локальное ускорение: S_локально = T_cpu / T_acc
Общее ускорение: S_общее = 1 / ( (1 - f) + f / S_локально ), f — доля горячего участка
f = 0,50, S_локально = 100 → S_общее = 1,98
f = 0,80, S_локально = 100 → S_общее = 4,81
f = 0,95, S_локально = 100 → S_общее = 16,8
f = 0,99, S_локально = 100 → S_общее = 50,3
f = 0,95, S_локально = ∞ → S_общее = 20,0 — абсолютный потолок
Обратите внимание на max в третьей строке: это и есть roofline, узкое место всегда одно. И на первое слагаемое — оно фиксированное, поэтому мелкие задачи проигрывают всегда, сколь бы быстрым ни было устройство. Дальше работает закон Амдала, разобранный в общем виде в главе про многоядерность, и читается он так: если горячий участок занимает половину времени, бесконечно быстрый ускоритель даст ровно двукратное ускорение. Прежде чем что-либо переносить, долю f нужно измерить профилировщиком (профилирование CPU), а не оценить на глаз.
"""Оценка выгоды от переноса горячего участка на ускоритель.
Модель намеренно грубая и пессимистичная: она использует только величины,
известные ДО написания кода под устройство. Точный ответ даст замер, но эта
оценка за минуту отсеивает заведомо безнадёжные случаи, а таких большинство.
"""
from dataclasses import dataclass
@dataclass
class Task:
flop: float # операций в горячем участке
bytes_in: float # байт отправить на устройство
bytes_out: float # байт забрать обратно
bytes_dev: float # трафик между вычислителем и памятью устройства
hot_fraction: float # доля времени всей программы (f в законе Амдала)
cpu_flops: float # достижимая производительность CPU на этой задаче
dev_flops: float # достижимая производительность устройства
dev_membw: float # пропускная способность памяти устройства, байт/с
link_bw: float # пропускная способность шины хост-устройство, байт/с
launch_overhead: float # фиксированные накладные расходы на запуск, с
def evaluate(t: Task) -> dict:
"""Раскладка времени и вердикт. O(1) по времени и O(1) по памяти."""
t_cpu = t.flop / t.cpu_flops
t_compute = t.flop / t.dev_flops # roofline: узкое место одно —
t_memory = t.bytes_dev / t.dev_membw # либо арифметика, либо память
t_device = max(t_compute, t_memory)
t_link = (t.bytes_in + t.bytes_out) / t.link_bw
t_acc = t.launch_overhead + t_link + t_device
local = t_cpu / t_acc # ускорение самого участка
overall = 1.0 / ((1.0 - t.hot_fraction) + t.hot_fraction / local)
intensity = t.flop / max(t.bytes_dev, 1.0) # операций на байт
ridge = t.dev_flops / t.dev_membw # точка перегиба roofline
breakeven = t.dev_flops / t.link_bw # нужно операций на байт пересылки
if local <= 1.0:
verdict = "не переносить: с пересылками ускоритель медленнее"
elif overall < 1.2:
verdict = "участок слишком мал: закон Амдала съедает выигрыш"
elif t_link > t_device:
verdict = "узкое место — шина: укрупняйте батч или держите данные на устройстве"
elif t_memory > t_compute:
verdict = "упор в память устройства: сокращайте трафик и разрядность"
else:
verdict = "упор в счёт: это лучший случай"
return {
"T_cpu": round(t_cpu, 6), "T_пересылок": round(t_link, 6),
"T_устройства": round(t_device, 6), "T_всего": round(t_acc, 6),
"ускорение участка": round(local, 2),
"ускорение программы": round(overall, 2),
"интенсивность, оп/байт": round(intensity, 2),
"перегиб roofline, оп/байт": round(ridge, 1),
"нужно оп на байт пересылки": round(breakeven, 1),
"вердикт": verdict,
}
COMMON = dict(hot_fraction=0.9, cpu_flops=5e10, dev_flops=4e14, # порядки величины
dev_membw=2e12, link_bw=5e10, launch_overhead=2e-5) # для класса устройств
n = 4096
tasks = [
# Сложение больших векторов — классическая ловушка новичка.
("сложение векторов",
Task(flop=1e8, bytes_in=8e8, bytes_out=4e8, bytes_dev=1.2e9, **COMMON)),
# Умножение матриц: интенсивность растёт линейно с размером блока.
("умножение матриц",
Task(flop=2 * n ** 3, bytes_in=2 * n * n * 4, bytes_out=n * n * 4,
bytes_dev=3 * n * n * 4 * 8, **COMMON)), # блочный алгоритм
]
for name, task in tasks:
print(name)
for key, value in evaluate(task).items():
print(f" {key}: {value}")
Сложность: O(1) по времени и памяти на один вызов; развёртка по k размерам батча — O(k) по времени и O(1) по памяти, если печатать результаты по ходу. Прогнав модель по сетке параметров, вы увидите, при каком размере задачи перенос начинает окупаться, — и эта точка обычно оказывается на порядок больше, чем интуитивно кажется. Свои числа стоит проверить на реальной системе до всякого кода:
sudo lspci -vv -s 0000:01:00.0 | grep -E 'LnkCap|LnkSta' # ширина и скорость линка
ulimit -l # сколько памяти можно закрепить
grep -i huge /proc/meminfo # крупные страницы под буферы
cat /sys/bus/pci/devices/0000:01:00.0/numa_node # к какому узлу подключено
numactl --hardware # пересылка через чужой узел дороже
Программная сторона
Практическое правило выбора: начинайте с готовой библиотеки, переходите к графовому представлению, когда нужно связать много операций, и опускайтесь до ручных ядер только там, где профиль показал конкретное узкое место. Обратный порядок — самый быстрый способ потратить месяц и проиграть библиотеке.
Компилятор для ускорителя — отдельная дисциплина. Сверх обычного выбора команд, распределения регистров и планирования (генерация кода) он решает разбиение на блоки (выбор размеров тайлов так, чтобы рабочий набор помещался в разделяемую память, а внешний трафик был минимален), сращивание операций (объединение цепочки поэлементных операций в одно ядро, чтобы промежуточные тензоры вообще не появлялись в памяти — на memory-bound задачах это даёт больше, чем любая оптимизация арифметики), выбор раскладки данных (порядок измерений тензора определяет, будет ли доступ коалесцированным), подбор формата чисел и распределение регистров под ограничением занятости — редкую для CPU задачу, где экономия регистров важнее числа обращений к памяти. Каждая из них — оптимизационная задача с огромным пространством решений, поэтому хорошие компиляторы ускорителей используют автотюнинг: генерируют десятки вариантов и замеряют их на реальном железе. Это прямое признание того, что аналитическая модель железа недостаточно точна.
Между программой и вычислителем стоит драйвер, и это не тонкая прослойка (Ввод-вывод и драйверы). Модель работы всех современных ускорителей одинакова: кольцевой буфер команд в памяти, куда рантайм записывает дескрипторы, и звонок в дверь (doorbell) — запись в регистр устройства, сообщающая, что появилась работа; устройство само читает дескрипторы через DMA и исполняет их асинхронно. Отсюда правила, нарушение которых стоит производительности:
- Держите очередь непустой: схема «поставил ядро — подождал — поставил следующее» означает простой между ними. Используйте несколько очередей: пересылки и счёт в разных очередях идут параллельно, в одной — строго последовательно.
- Синхронизируйтесь событиями, а не «подождать всё» — глобальная синхронизация осушает конвейер целиком. Закрепляйте память хоста для буферов пересылок: незакреплённую DMA читать не может.
- Не создавайте контекст в горячем пути — это загрузка модулей, компиляция ядер и выделение памяти, сотни миллисекунд.
Чек-лист: выносить или нет
общего времени"} B -- "меньше половины" --> S1["Закон Амдала не даст больше
двукратного выигрыша.
Сначала оптимизируйте на CPU"] B -- "больше 80 процентов" --> C{"Работа однородна
по тысячам элементов"} C -- "нет, ветвления
зависят от данных" --> S2["Дивергенция съест выигрыш.
Смотрите в сторону SIMD
и многопоточности"] C -- "да" --> D{"Данные можно оставить
на устройстве надолго"} D -- "нет, гоняем туда-обратно" --> S3["Пересылки съедят всё:
укрупняйте батч
или оставляйте на CPU"] D -- "да" --> E{"Интенсивность выше
точки перегиба roofline"} E -- "нет" --> F["Кандидат, но упрётся в память:
сначала сокращайте трафик"] E -- "да" --> H{"Алгоритм стабилен годами
и тираж в миллионах штук"} F --> H H -- "нет" --> I["Программируемый ускоритель:
GPU или NPU"] H -- "да" --> J{"Нужна детерминированная
латентность или
нестандартная разрядность"} J -- "да, тираж средний" --> K["FPGA"] J -- "нет, тираж огромный" --> L["ASIC или блок
внутри общего кристалла"]
Типичные ошибки, встречающиеся практически в каждом первом переносе:
- Мелкие батчи. Ядро на 128 элементов не окупает даже постановку в очередь. Укрупняйте, пока время ядра не станет на два порядка больше накладных расходов запуска.
- Синхронные пересылки. Последовательность «копировать — запустить — копировать — ждать» в цикле означает, что вычислитель работает малую долю времени. Нужен конвейер из блоков.
- Забытая инициализация контекста. Первый вызов включает компиляцию ядер и подъём частот и может быть в сотни раз медленнее установившегося; если он попал в замер, вы измерили не то.
- Замер без прогрева. Устройство стартует на пониженных частотах. Правильный протокол — прогрев, затем несколько десятков итераций, затем медиана (Бенчмаркинг).
- Замер без синхронизации. Запуск асинхронный: остановив таймер сразу после постановки в очередь, вы измерите скорость драйвера, а не счёта. И сравнение с неоптимизированным CPU: однопоточная невекторизованная реализация — не базовая линия (Измерения).
- Игнорирование стоимости владения. Ускоритель в облаке стоит денег за час, и утилизация в 5 % означает, что вы платите за простой (Стоимость облака и компромиссы).
Типичные заблуждения
«Ускоритель быстрее в N раз, значит программа станет быстрее в N раз». Не станет: работает закон Амдала, плюс пересылки, плюс накладные расходы запуска. Реалистичный общий выигрыш почти всегда в разы меньше локального.
«Больше вычислительных блоков — быстрее». Только справа от точки перегиба roofline; слева добавление арифметики не меняет ничего, потому что упор в память. И родственное: «раз данные лежат в памяти устройства, всё быстро» — память устройства широкая, но её латентность больше, чем у DRAM хоста, и быстро становится только при высокой занятости и коалесцированном доступе.
«GPU — это просто много ядер». Это принципиально другая модель: группа потоков делит счётчик команд, латентность прячется переключением между варпами, а не кэшами, разделяемая память управляется вручную. Перенос многопоточного CPU-кода «как есть» обычно даёт замедление (Конкурентность и параллелизм).
«Унифицированная память убирает пересылки». Она убирает их из вашего кода, но не из железа: страницы всё равно едут по той же шине, только неявно и мелкими порциями.
«FPGA — это медленный процессор, который можно перепрограммировать». FPGA — не процессор вовсе. Её выигрыш в том, что конвейер строится в железе под задачу; на коде, написанном в процессорном стиле, она проигрывает процессору.
«Квантизация — это про качество модели». С точки зрения железа это в первую очередь покупка пропускной способности памяти; на memory-bound нагрузках ускорение почти линейно следует за сокращением размера операндов.
«ASIC всегда эффективнее». По энергии на операцию — да. Но NRE и срок разработки означают, что при малом тираже или меняющемся алгоритме он экономически бессмыслен, даже если технически идеален.
Мини-итог
- Выигрыш ускорителя берётся не из арифметики, а из устранения накладных расходов универсальности и, главное, из сокращения движения данных: разрыв между сложением и походом в DRAM — три-четыре порядка по энергии.
- Универсальность и эффективность лежат на одной оси, и движение по ней всегда обмен: каждый выброшенный механизм отдаёт энергию полезной работе и сужает класс задач.
- GPU оптимизирован под пропускную способность: SIMT-группы амортизируют выборку команды, латентность прячется занятостью, а не кэшами, а реальную скорость определяют коалесцированный доступ и явно управляемая разделяемая память.
- Матричные блоки выигрывают стационарностью данных: операнд читается из памяти один раз и переиспользуется десятки раз внутри массива по коротким проводам. Арифметическая интенсивность и roofline — минимальный инструментарий честной оценки. Большинство реальных нагрузок находится слева от точки перегиба; отсюда и эффект квантизации, и упор генерации токенов в пропускную способность памяти.
- FPGA даёт пространственное вычисление, произвольную разрядность и детерминированную латентность ценой частоты, часов сборки и сложной верификации; HLS упрощает ввод, но не отменяет мышления схемами.
- ASIC оправдан на пересечении трёх условий: большой тираж, стабильный алгоритм, жёсткий бюджет энергии. Чаще выигрывает промежуточный вариант — специализированный блок внутри универсального чипа.
- Решение о переносе — расчёт, а не вкус: измерить долю горячего участка, оценить интенсивность, посчитать пересылки и накладные расходы, и только потом писать код.
Источники
- Mark Horowitz. «Computing’s Energy Problem and what we can do about it», ISSCC 2014 — https://ieeexplore.ieee.org/document/6757323: таблица энергии на операцию, из которой следует весь первый раздел этой главы.
- Samuel Williams, Andrew Waterman, David Patterson. «Roofline: An Insightful Visual Performance Model for Multicore Architectures», CACM 2009 — https://dl.acm.org/doi/10.1145/1498765.1498785.
- Norman Jouppi et al. «In-Datacenter Performance Analysis of a Tensor Processing Unit», ISCA 2017 — https://dl.acm.org/doi/10.1145/3079856.3080246: разбор систолического массива и того, почему нагрузка оказалась memory-bound.
- Yu-Hsin Chen et al. «Eyeriss: An Energy-Efficient Reconfigurable Accelerator for Deep Convolutional Neural Networks», JSSC 2017 — https://ieeexplore.ieee.org/document/7738524: классификация стационарностей данных и их влияние на трафик.
- Vivienne Sze et al. Efficient Processing of Deep Neural Networks — https://link.springer.com/book/10.1007/978-3-031-01766-7: систематический разбор архитектур ускорителей нейросетей.
- John Hennessy, David Patterson. Computer Architecture: A Quantitative Approach — глава про архитектуры, ориентированные на предметную область; и их тьюринговская лекция «A New Golden Age for Computer Architecture», CACM 2019 — https://dl.acm.org/doi/10.1145/3282307: почему специализация стала главным источником прироста производительности.
- Спецификация OpenCL как вендоронезависимое описание модели «хост плюс устройство» — https://www.khronos.org/opencl/.
- Материалы курса Berkeley CS267 по параллельным вычислениям — https://sites.google.com/lbl.gov/cs267-spr2021: лекции по roofline и переносу задач на ускорители.
Что дальше
Мы разобрали ускорители как ответ на вопрос «куда девать транзисторы, если их нельзя все включить одновременно». Но сам этот вопрос мы принимали на веру. Почему, собственно, нельзя? Почему тактовая частота встала на нескольких гигагерцах и не растёт уже два десятилетия, откуда взялся тепловой потолок, что такое тёмный кремний и почему именно физика питания и охлаждения, а не изобретательность архитекторов, определяет облик современного чипа — в следующей главе. Она объясняет причину, следствием которой стало всё, о чём мы говорили здесь.