Великие умы обсуждают «флопсы» на ватт
Шёл 2022 год. Я обратил внимание на новый интересный проект CUTLASS, отличающийся очень высокой скоростью выполнения операций умножения матриц. Я взял большую задачу по умножению матриц — 8192 x 8192 x 8192, и померял производительность в PyTorch, где используется библиотека cuBLAS.
python mm_bench.py
> CuBLAS: 258 Teraflops
Получилось неплохо — задействованными оказались 83% «флопсов». А теперь проверим производительность CUTLASS с использованием профилировщика из этого проекта.
./cutlass_profiler --operation=Gemm --m=8192 --n=8192 --k=8192
> CUTLASS: 288 Teraflops
!!! Производительность возросла на 10%? Просто фантастика. CuBLAS — это высокооптимизированная библиотека для умножения больших матриц, рассчитанная на ситуации, когда скорость проведения вычислений упирается только в быстродействие оборудования. И вот, CUTLASS+автонастройка обходят эту библиотеку на 10%? И как я раньше не наткнулся на этот CUTLASS?
Следующий шаг — прицепить ядра CUTLASS к Python и, используя мой предыдущий скрипт, сравнить их производительность с тем, что выдаёт cuBLAS.
python cutlass_mm_bench.py
> CuBLAS: 258 Teraflops
> CUTLASS: 257 Teraflops
Но тут как-то так получилось, что в свете Python все краски производительности CUTLASS померкли. Это, само по себе, не так уж и удивительно. Как известно, очень сложно обеспечить единообразие результатов замеров производительности в разных средах.
Я долго и нудно изучал два скрипта и наконец обнаружил, что профилировщик CUTLASS, по умолчанию, инициализирует матрицы весьма странным образом: в качестве входных данных используются исключительно целые числа. Не будучи вполне уверен в том, имеет ли это какое-то значение, я попробовал следующее:
zero_inputs = torch.zeros(N, N)
randn_inputs = torch.randn(N, N)
benchmark(zero_inputs) # 295 Teraflops
benchmark(randn_inputs) # 257 Teraflops
Что? Как значения, хранящиеся в матрице, могут подействовать на скорость проведения вычислений? Мне известно о том, что Nvidia, в GPU A100, применяет какие-то загадочные механизмы сжатия данных. Но я совсем не ожидал, что они будут функционировать при умножении матриц. Попробуем другие распределения данных — наподобие равномерного распределения [0,1].
Такие результаты меня, мягко скажем, обескуражили. Получается, что содержимое тензора, над которым выполняются вычисления, влияет на производительность операции умножения матриц.
Конечно, существуют ситуации, когда время выполнения вычислений зависит от содержимого тензора. Скажем — при использовании косвенной индексации (например — A[b]), или когда ведётся работа с разреженными данными.
Но при умножении матриц ничего такого не применяется! Вне зависимости от того, что именно содержится в матрице, ядро умножения матриц:
Выполняет одно и то же количество вычислений.
Выполняет одни и те же вычисления в одинаковом порядке.
Обращается к одним и тем же адресам памяти.
Обращается к одним и тем же адресам памяти в одинаковом порядке.
В моей ментальной модели матричных вычислений и аппаратного обеспечения GPU не было и намёка на то, что данные, содержащиеся в матрице, способны влиять на скорость умножения матриц. И всё же, такое влияние есть.
Как оказалось, виной всему — динамическая мощность, потребляемая полупроводниками.
Мощность, потребляемая полупроводниками
Предельная потребляемая мощность GPU Nvidia A100 составляет 400 Вт (тот GPU A100, на котором я проводил эти тесты, имеет предельную мощность в 330 Вт). Но, подсказка на что содержится в самом понятии «предельная потребляемая мощность», GPU потребляет 400 Вт не всегда. Например, когда устройство полностью бездействует, утилита NVIDIA-SMI сообщила мне о том, что потребляет оно лишь 88 Вт.
А вот когда GPU работает под нагрузкой — серьёзно растёт и потребляемая им мощность. В частности — она доходит до уровня предельной мощности.
Для того чтобы не превышать предельной мощности, устройство, входящее в состав GPU, называемое регулятором напряжения (Voltage Regulator Module), снижает напряжение, подаваемое на GPU, что приводит к снижению тактовой частоты и снижению производительности.
Другими словами — если окажется так, что наш GPU затребует такую мощность, которая достигнет уровня предельной мощности, его производительность будет ограничена.
Большинство из нас принимают как данность то, что «если GPU что-то делает — растёт потребляемая им мощность». Но, на самом деле, имеются два отдельных механизма, работа которых и требует мощности, потребляемой GPU.
Сначала поговорим о статической потребляемой мощности. Её можно представить себе как мощность, которая неизбежно теряется из-за тока, который течёт по схеме даже когда устройство бездействует. Количество потребляемой статической мощности пропорционально количеству полупроводников, на которые подаётся напряжение. Так как в GPU не особенно широко применяется отключение неиспользуемых модулей (power gating), то в нашем случае статическая потребляемая мощность — это та мощность, которая используется GPU в режиме бездействия (как показано выше, это — 88 Вт).
А вот динамическая потребляемая мощность — это то, что является причиной замеченной мной странности. В частности, речь идёт о том, что некая мощность потребляется при смене состояний транзистора. Если транзистору никогда не нужно менять состояние, то дополнительной мощности он потреблять не будет. С другой стороны — если он быстро переключается между состояниями — это значит, что он потребляет очень много динамической мощности. Умножим это «очень много» на миллиарды транзисторов, которые имеются в GPU, и получим общее увеличение мощности, потребляемой системой.
Иначе говоря — причина, по которой умножение выполняется быстрее в том случае, если работа ведётся с матрицами, содержащими нули, заключается в том, что при этом снижается количество «переключений» такого количества транзисторов, которого достаточно для того, чтобы устройство не превышало бы своей предельной потребляемой мощности!
В результате это (по большей части) объясняет то, что мы недавно наблюдали (мне сложно говорить об этом с полной уверенностью в своих словах, так как я не могу подсчитать того, сколько раз переключается каждый отдельный транзистор). Когда в матрицах записаны только нули — это даёт самый быстрый результат, скорее всего, из-за того, что каждый бит, над которыми проводятся вычисления, так же равен нулю, и хранилище результатов вычислений постоянно пребывает в одном и том же состоянии. Когда в матрицах записаны только единицы — вычисления тоже выполняются достаточно быстро. Вероятно это так из-за того, что выполнение каждой из инструкций тензорного ядра приводит к появлению одного и того же результата, всегда одинакового. Использование при формировании входных данных равномерного распределения приводит к немного более быстрым вычислениям, чем использование нормального распределения. Вероятно это так из-за того, что, в первом случае, хранилищу не приходится принимать то положительные, то отрицательные результаты вычислений. А использование нормального распределения, скорее всего, отличается наихудшей производительностью из-за того, что оно даёт довольно большой разброс значений на транзисторах, участвующих в вычислениях (?).
Вот результаты применения разных интересных распределений данных для заполнения матриц.
Randn: Значения заданы с использованием нормального распределения.
Checkerboard: Значения заданы с использованием нормального распределения, но в матрице, в шахматном порядке, расставлены нули.
Rand: Значения заданы с использованием равномерного распределения.
Sparse: Значения заданы с использованием нормального распределения, но (случайные) 75% элементов замаскированы.
Ternary: Каждый элемент может принимать только значения 1, -1 или 0.
One Bit: В каждом из значений установлен лишь один бит (4-й бит).
All Pies: Каждое значение представляет собой математическую константу — число π.
Twos: Каждое значение в матрицах — это число 2.
Zeros: Все значения в матрицах — это 0.
Кто сказал, что операции с неструктурированными разреженными матрицами на тензорных ядрах выполняются неэффективно? :)
Воздействие ограничения потребляемой мощности и тактовой частоты
Вот ещё одно доказательство того, что причина всего этого — динамическая мощность, потребляемая устройством.
Грубо говоря — используемая устройством мощность пропорциональна тактовой частоте, умноженной на количество выполняемых переключений транзисторов.
Мощность ~= тактовая частота * «переключение транзисторов на такт»
Когда мощность, потребляемая устройством, превышает заданную предельную мощность — мы сталкиваемся с принудительным ограничением его производительности.
В результате получается следующее:
Если уменьшить предельную потребляемую мощность — мы усугубим этот эффект.
Если уменьшить тактовую частоту — мы смягчим этот эффект.
А теперь посмотрим на то, как всё это работает! Для того чтобы это сделать, я сравню относительную производительность при обработке хорошо предсказуемых входных данных (нули) и плохо предсказуемых входных данных (нормальное распределение).
Как и ожидалось, видно, что если предельная потребляемая мощность снижается с 330 Вт до 100 Вт (минимум) — увеличение производительности, связанное с использованием предсказуемых входных данных, возрастает. Интересно то, что на самом низком уровне предельной потребляемой мощности (100 Вт) всё становится наоборот. Могу предположить, что это так из-за того, что GPU в такой ситуации так ограничен в потребляемой мощности, что даже вычисления, где обрабатываются одни лишь нули, приводят к слишком большому потреблению мощности. Не будем забывать о том, что вклад в динамическую мощность, потребляемую устройством, вносит каждый транзистор GPU, а не только те, что хранят данные! Сюда входят и, скажем, транзисторы, которые хранят программный счётчик, и сведения о том, сколько итераций цикла нужно выполнить, и те, которые сообщают другим транзисторам о том, что им нужно что-то сделать. В общем-то, энергия расходуется на все задачи, которые может выполнять GPU.
Теперь, чтобы проверить воздействие тактовой частоты на обработку «предсказуемых» и «непредсказуемых» входных данных, я задам ограничение потребляемой мощности в 200 Вт и буду менять ограничение тактовой частоты GPU.
Видно, что на высших значениях тактовой частоты относительная производительность практически не меняется. Вероятно это так из-за того, что в системе, даже при обработке «предсказуемых» данных, применяется ограничение производительности. Затем, по мере уменьшения тактовой частоты, разрыв в производительности уменьшается, так как на обработку «предсказуемых» входных данных влияет ограничение частоты, а на обработку «непредсказуемых» данных — нет. В результате, в самой левой части графика, обработка и тех, и других видов данных демонстрирует одинаковую производительность, так как в обоих случаях производительность упирается в лимит частоты, заданный вручную, а GPU не применяет никаких мер по ограничению производительности.
Тут можно проверить и ещё одну интересную идею. А именно — выяснить то, какую максимальную тактовую частоту может поддерживать GPU, выполняя умножение матриц при заданных входных данных и заданной максимальной потребляемой мощности.
Маркетинговая и «реальная» производительность
Наблюдение, в соответствии с которым GPU не может работать на пиковой тактовой частоте из-за принудительного ограничения мощности — это один из главных факторов, отделяющих «реальную» производительность умножения матриц от маркетинговых спецификаций Nvidia.
Вот показатель, который используется в маркетинговых данных Nvidia:
FLOPS = Tensor Cores on GPUMax Clock SpeedFLOP per Tensor Core Instruction
Например, в Nvidia H100 имеется 528 тензорных ядер на GPU (4 на каждый мультипроцессор), их максимальная тактовая частота — 1,830 ГГц, а показатель «FLOP на инструкцию тензорного ядра» равен 1024. Получается, что 1,830e9 * 528 * 1024 = 989 TFLOPS. Именно это число и указывает Nvidia.
Но этого уровня можно достичь, лишь поддерживая тактовую частоту в 1,83 Ггц. А как мы уже видели — GPU просто не обладает для этого достаточной мощностью!
Обратите внимание на то, что в обоих случаях GPU обладает более высоким показателем предельной потребляемой мощности, чем тот, который я могу проверить (это, соответственно, 400 Вт у A100 и 700 Вт у H100). Поэтому эти GPU способны поддерживать и более высокие тактовые частоты, чем те, что приведены на графике. Правда, видно, что, особенно в случае с H100, максимальная поддерживаемая тактовая частота гораздо ниже теоретической! Другими словами — максимум H100 ограничен, в основном, не вычислительной мощью или пропускной способностью системы, а потребляемой мощностью.
Многие уже заметили, что потребляемая мощность становится всё более важным ограничивающим фактором GPU. Поэтому, хотя у H100, теоретически, в 3 раза больше TFLOPS, чем у A100, реальная производительность этого GPU, как правило, близка к 2-кратной производительности A100. Это так из-за ограничения потребляемой мощности, о которой мы говорили. А показатель «флопов на ватт» у H100 превышает такой же показатель у A100 даже меньше, чем в 2 раза.
Итоги
Всё это должно вызвать у вас чрезвычайное любопытство относительно реального улучшения производительности, которое даст видеоускоритель Nvidia B100. Производительность этого GPU, теоретически, в 1,75 раза выше, чем у H100 при том же энергопотреблении. Я создал опрос на сервисе Manifold, участники которого могут делать прогнозы относительно того, какой будет максимальная производительность B100.
Напоследок — вот вам слегка изменённая версия твита roon:
О, а приходите к нам работать? 🤗 💰
Мы в wunderfund.io занимаемся высокочастотной алготорговлей с 2014 года. Высокочастотная торговля — это непрерывное соревнование лучших программистов и математиков всего мира. Присоединившись к нам, вы станете частью этой увлекательной схватки.
Мы предлагаем интересные и сложные задачи по анализу данных и low latency разработке для увлеченных исследователей и программистов. Гибкий график и никакой бюрократии, решения быстро принимаются и воплощаются в жизнь.
Сейчас мы ищем плюсовиков, питонистов, дата-инженеров и мл-рисерчеров.