Мой GPU оказался медленнее, чем CPU, который он заменил

Однажды я перенёс тяжёлое вычисление с CPU на GPU, ожидая, что оно полетит. Вместо этого оно поползло — драматически медленнее, чем обычный код, который оно должно было заменить. Я купил скорость и каким-то образом установил замедление.

Узким местом было не железо. Современный GPU — одна из самых быстрых вещей, которые можно поставить в компьютер. Проблема была в том, что я дал ему работу, оформленную таким образом, в котором он почти уникально плох, а потом удивился, что она не может выполняться.

Апгрейд, который был даунгрейдом

Ожидание было простым и, как мне казалось, безопасным: GPU быстрые, моя задача была медленной, поэтому перенос задачи на GPU должен сделать её быстрой. Это народная версия того, как работает ускорение, и она неверна вполне определённым, поучительным образом.

Когда я измерил «ускоренную» версию, она была не чуть медленнее. Она была медленнее в большое число раз — такой результат, который говорит вам, что проблема не в отсутствующей оптимизации, а в фундаментальном несоответствии. Что-то в форме того, что я делал, активно сражалось с железом.

Что на самом деле означало «положить это на GPU»

Вот что я на самом деле сделал. У меня был существующий цикл: взять одну единицу работы, вычислить её, перейти к следующей, тысячи раз подряд. Чтобы «использовать GPU», я просто заставил каждую отдельную единицу выполняться на GPU — а цикл оставил ровно там, где он был.

Так что тысячи раз подряд моя программа обращалась к GPU, передавала ему одну маленькую задачу, ждала ответа и поворачивалась за следующей. Цикл по-прежнему был главным. GPU был просто очень быстрым подрядчиком, которому я звонил, по отдельности, за каждым гвоздём, который я хотел забить.

Скрытая цена: само спрашивание чего-то стоит

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

Я построил процесс, который почти целиком состоял из акта спрашивания, а сама работа была погрешностью округления. CPU-версия была медленной, но по крайней мере оставалась занятой. GPU-версия была медленной, потому что бóльшую часть своей жизни она ждала.

GPU — это автобус, а не такси

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

Вызывать его один раз на элемент — это как гонять этот автобус туда-сюда через весь город с одним пассажиром за рейс. Автобус действительно быстрый. Ваша пропускная способность плачевна — и она плачевна именно потому, что вы используете транспорт, построенный для толпы, чтобы перевозить людей по одному. Вина не в автобусе. Она в диспетчеризации.

Исправление: перестать зацикливаться, начать батчить

Починка была не более быстрым ядром и не лучшей картой. Это была реструктуризация цикла. Вместо «для каждого элемента отправить его на GPU» я переоформил работу так, чтобы GPU мог взять много элементов сразу и обработать их вместе — превратив тысячи крошечных отправок в горстку больших. Накладные расходы, которые я платил снова и снова, схлопнулись в нечто, что я платил несколько раз вместо тысяч.

Это одна из распространённых форм того, что люди имеют в виду под «векторизацией» или батчингом: выражение многих похожих операций вместе вместо длинной последовательности маленьких шагов. Это часто разница между GPU, который ставит ваш CPU в неловкое положение, и GPU, которого ставит в неловкое положение CPU, и пропасть между этими двумя может быть огромной.

Почему это и есть весь смысл железа

Мне понадобилось время, чтобы усвоить, почему структура имела такое значение, а не относиться к ней как к детали тюнинга. Преимущество GPU здесь происходило не из того, что какая-то отдельная операция была быстрой. Оно происходило из выполнения огромного числа похожих операций в одно и то же время. Этот параллелизм — всё ценностное предложение целиком. Если ваш код не может выразить свою работу как «делай всё это в одно и то же время», параллелизму не за что ухватиться — и GPU, выполняющий последовательную, по-одному-за-раз работу, — это просто дорогой, неуклюжий, плохо оформленный CPU.

Так что «положи это на GPU» никогда не было настоящей инструкцией. Настоящей инструкцией было «переоформи работу так, чтобы её можно было сделать всю сразу». Железо всегда могло быть лишь настолько быстрым, насколько это позволяла форма задачи.

Инструмент не может спасти неправильную форму

Урок хорошо обобщается далеко за пределы GPU. Тянуться к более быстрому железу даёт мало, если структура вашего вычисления сражается с этим железом. Для такой работы производительность жила больше в форме вычисления — в том, как оно организовано, разбито на батчи и выражено, — чем в номинальной скорости того, что его выполняет. Вы можете положить медленный алгоритм на быструю машину и получить быстрое разочарование.

Я держу это замедление в уме как напоминание о том, что «я сделал это быстрее» и «я перенёс это на более быстрое железо» — не одно и то же предложение. Первое — про понимание работы. Второе — про надежду, что кремний поймёт её за тебя. Не поймёт.

— Никаких сигналов, никакой доходности, не инвестиционная рекомендация.