На Хабре уже были статьи об OpenCL, CUDA и GPGPU со сравнениями производительности, базовыми понятиями и примерами, поэтому рассказывать об основах и принципах работы я тут не буду, даже код не покажу. Но я хочу описать в чем заключаются реальные трудности при использовании GPU (про ограничения и их последствия), почему нельзя сравнивать производительность CPU и GPU, а также про то насколько “универсален” OpenCL на самом деле.
Мое знакомство с GPGPU началось 1,5 года назад и продолжается до сих пор в виде активной разработки исследовательского проекта. Тогда у меня был выбор: OpenCL или CUDA, разницы в выборе особо, на тот момент, не было, но в университете начали читать курс про OpenCL, так я его и выбрал. Сразу скажу, что писал я только для карт с архитектурой от NVidia, поэтому буду говорить про нее (чаще всего о Fermi).
В этом месте был большой абзац об истории и состоянии дел в области расчетов на GPU, но после описания проблем пост оказался слишком длинным и абзац был жестоко урезан (есть надежда, что он вернется в следующей части). Поэтому перейдем сразу к тому, почему портированные на GPU алгоритмы далеко не всегда работают быстро, т.е. дают на практике 0.5Х-10Х прироста производительности вместо обещанных 20Х-100Х относительно CPU (иначе бы уже каждое приложение его использовало).
Итак, все мы знаем, что архитектура GPU отличается от CPU весьма существенно, но мало кто задумывается над тем, насколько сильно это отличие и насколько сильно оно затрагивает разработку алгоритмов для GPU. Человек, хоть и является довольно параллельной системой, привык думать об алгоритмах последовательно. Последние ХХцать лет процессоры нам в этом потакали и мы все привыкли к тому, что одна команда выполняется после другой. А еще мы привыкли к тому, что ресурсы, доступные программе, практически не ограничены (не вспоминаем про микроконтроллеры), а данные можно получить почти сразу. На этом основаны почти все техники программирования и оптимизации. Только вот с GPU это не проходит и я хочу попробовать описать последствия наших привычек.
Если где-то до этой команды было ветвление и потоки пошли по разным путям, то GPU будет выполнять обе ветки последовательно.
Таким образом попытка упростить расчеты для какого-то частного случая (если известно общее и короткое решение проблемы) может приводить не ускорению расчетов (что всегда происходит на CPU), а к сложению времени расчета общего и частного случая.
Другой пример: каждое ядро выбирает разный алгоритм в зависимости от типа данных, например надо рассчитать расстояние от точки до геометрической фигуры и каждое ядро получает разную фигуру и, соответственно, разный алгоритм. В итоге общее время будет суммой времени выполнения алгоритма для каждого объекта.
И оказывается, что мы считаем все точно также последовательно как и CPU (а при многих вложенных ветвлениях мы считаем на GPU гораздо больше, чем на CPU), только вот при последовательном расчете GPU будет в десятки раз медленнее. Обратите внимание сколько if-ов в Ваших программах, хотя если все 32 потока пойдут по одному пути, то все ок, но так ли это часто происходит во всех ветвлениях?
А еще один поток может получить доступ только к 16 байтам из этих 128 байт за один раз.
В результате получается, что пропускная способность памяти больше 150Гб/с, но только при условии, что все 128 байт используются постоянно. Если же каждый поток должен считать одну большую структуру, которая весит 40 байт, то каждый поток должен сделать 3 запроса к памяти и скачать 3*128 байт. А если данные для каждого потока располагаются в разных местах (а поток получает указатель на них и затем загружает, вполне нормальная ситуация для CPU, когда память расходуется рационально), то полезная пропускная способность памяти получается 40*32/(128*3*32), то есть порядка 10% от реальной.
И снова мы приблизились к показателям пропускной способности памяти доступной CPU. Можно конечно вспомнить про то, что есть кэш, но он появился только на Fermi и он не такой уж большой, хоть и помогает ощутимо. С другой стороны можно вспомнить, что на первых версиях GPU даже при последовательном считывании 128 байт в случае если они считываются не последовательно и/или смещены хотя бы на один байт, производится отдельный запрос к памяти для каждого потока.
А в прошлом примере для получения данных всеми процессами надо сделать 3*32 запроса… почти 80 тысяч циклов… Что делать в это время? Выполнять другие потоки и тут появляются новые ограничения.
Сначала кажется, что много, но они выделяются на все активные потоки, а не только выполняющиеся (причем они выделяются статически по максимуму, сколько надо в самом худшем ветвлении, столько и будет выделено). А активных потоков должно быть 1536, чтобы скрывать латентность памяти (попробуйте посчитать легко ли скрыть 80 тысяч циклов из прошлого примера), то есть остается 21 регистр на поток. Попробуйте реализовать сложный алгоритм и уложиться в 21 регистр (это не только переменные, но и промежуточные результаты операций, счетчики циклов итд итп). С другой стороны, можно попробовать использовать меньше, чем полторы тысячи активных потоков и тут появляется следующее ограничение.
То есть доступно всего 3 опции: 1536 потоков если каждый использует меньше 21 регистра, 1024 потока если используется меньше 32 регистров или 512 потоков, меньше никак. При этом меньшее число потоков означает серьезные проблемы с попыткой скрыть латентность памяти и простоем целого мультипроцессора в течении тысяч циклов.
А это уже гораздо хуже, чем CPU. А самое плохое происходит если каждый поток использует больше 64 регистров.
Я до сих пор не могу поверить, что в глобальной памяти, а не в локальной, но так говорит документация. То есть появляются дополнительные запросы к памяти. Кстати, для вызова функций используется стек, который также занимает регистры (да да, функции — это плохо).
Для борьбы с использованием регистров и оптимизацией загрузки есть есть еще разделяемая память (shared которая, не помню как по-русски правильно). Но ее только 16/48Кб и она разделяется между всеми активными группами, то есть если каждая группа кушает 25кб памяти, то больше одной группы запустить не получится со всеми вытекающими последствиями.
На самом деле тут все не так уж страшно, задержка эта измеряется десятками микросекунд. Но если Вы запускаете ядро 1000 раз, то это уже десятки миллисекунд, что в случае расчетов в реальном времени (рендеринга например) сразу создает ограничение в 15FPS даже без учета времени самих расчетов.
Только я разошелся, а уже получился слишком длинный список. А ведь еще надо вспомнить про синхронизацию, атомные операции, копирование данных на девайс, балансировку нагрузки для каждой карты (SLI тут не работает), точность, специальные функции, кривые драйвера, отладку и много чего еще. Да и про реальную универсальность OpenCL надо сказать многое. Ну да ладно, отложим это до следующих частей.
А, в целом, разработчики конечно знают (с приходом опыта после долгих мучений) о многих (но не всех) ограничениях и пытаются оптимизировать код так, чтобы их обойти, но представьте сколько времени уходит на то, чтобы переделать алгоритмы, которые разрабатывались без оглядки на GPU, да и не все алгоритмы можно переделать в принципе. Но я потихоньку “постигаю дзен” и понимаю, что не все так плохо и обещанные терафлопы получить таки можно, и об этом я также обещаю написать в следующих частях истории об OpenCL.
Предисловие
Мое знакомство с GPGPU началось 1,5 года назад и продолжается до сих пор в виде активной разработки исследовательского проекта. Тогда у меня был выбор: OpenCL или CUDA, разницы в выборе особо, на тот момент, не было, но в университете начали читать курс про OpenCL, так я его и выбрал. Сразу скажу, что писал я только для карт с архитектурой от NVidia, поэтому буду говорить про нее (чаще всего о Fermi).
В этом месте был большой абзац об истории и состоянии дел в области расчетов на GPU, но после описания проблем пост оказался слишком длинным и абзац был жестоко урезан (есть надежда, что он вернется в следующей части). Поэтому перейдем сразу к тому, почему портированные на GPU алгоритмы далеко не всегда работают быстро, т.е. дают на практике 0.5Х-10Х прироста производительности вместо обещанных 20Х-100Х относительно CPU (иначе бы уже каждое приложение его использовало).
Почем получается медленно?
Итак, все мы знаем, что архитектура GPU отличается от CPU весьма существенно, но мало кто задумывается над тем, насколько сильно это отличие и насколько сильно оно затрагивает разработку алгоритмов для GPU. Человек, хоть и является довольно параллельной системой, привык думать об алгоритмах последовательно. Последние ХХцать лет процессоры нам в этом потакали и мы все привыкли к тому, что одна команда выполняется после другой. А еще мы привыкли к тому, что ресурсы, доступные программе, практически не ограничены (не вспоминаем про микроконтроллеры), а данные можно получить почти сразу. На этом основаны почти все техники программирования и оптимизации. Только вот с GPU это не проходит и я хочу попробовать описать последствия наших привычек.
Ограничение первое: 32 потока (warp) всегда выполняют ОДНУ команду
Если где-то до этой команды было ветвление и потоки пошли по разным путям, то GPU будет выполнять обе ветки последовательно.
Таким образом попытка упростить расчеты для какого-то частного случая (если известно общее и короткое решение проблемы) может приводить не ускорению расчетов (что всегда происходит на CPU), а к сложению времени расчета общего и частного случая.
Другой пример: каждое ядро выбирает разный алгоритм в зависимости от типа данных, например надо рассчитать расстояние от точки до геометрической фигуры и каждое ядро получает разную фигуру и, соответственно, разный алгоритм. В итоге общее время будет суммой времени выполнения алгоритма для каждого объекта.
И оказывается, что мы считаем все точно также последовательно как и CPU (а при многих вложенных ветвлениях мы считаем на GPU гораздо больше, чем на CPU), только вот при последовательном расчете GPU будет в десятки раз медленнее. Обратите внимание сколько if-ов в Ваших программах, хотя если все 32 потока пойдут по одному пути, то все ок, но так ли это часто происходит во всех ветвлениях?
Ограничение второе: при каждом обращении к памяти всегда считываются 128 байт последовательно, даже если нам нужен только 1 байт
А еще один поток может получить доступ только к 16 байтам из этих 128 байт за один раз.
В результате получается, что пропускная способность памяти больше 150Гб/с, но только при условии, что все 128 байт используются постоянно. Если же каждый поток должен считать одну большую структуру, которая весит 40 байт, то каждый поток должен сделать 3 запроса к памяти и скачать 3*128 байт. А если данные для каждого потока располагаются в разных местах (а поток получает указатель на них и затем загружает, вполне нормальная ситуация для CPU, когда память расходуется рационально), то полезная пропускная способность памяти получается 40*32/(128*3*32), то есть порядка 10% от реальной.
И снова мы приблизились к показателям пропускной способности памяти доступной CPU. Можно конечно вспомнить про то, что есть кэш, но он появился только на Fermi и он не такой уж большой, хоть и помогает ощутимо. С другой стороны можно вспомнить, что на первых версиях GPU даже при последовательном считывании 128 байт в случае если они считываются не последовательно и/или смещены хотя бы на один байт, производится отдельный запрос к памяти для каждого потока.
Ограничение третье: латентность памяти составляет примерно 800 циклов для каждого запроса
А в прошлом примере для получения данных всеми процессами надо сделать 3*32 запроса… почти 80 тысяч циклов… Что делать в это время? Выполнять другие потоки и тут появляются новые ограничения.
Ограничение четвертое: на все активные потоки мультипроцессора выделяется 32к регистров
Сначала кажется, что много, но они выделяются на все активные потоки, а не только выполняющиеся (причем они выделяются статически по максимуму, сколько надо в самом худшем ветвлении, столько и будет выделено). А активных потоков должно быть 1536, чтобы скрывать латентность памяти (попробуйте посчитать легко ли скрыть 80 тысяч циклов из прошлого примера), то есть остается 21 регистр на поток. Попробуйте реализовать сложный алгоритм и уложиться в 21 регистр (это не только переменные, но и промежуточные результаты операций, счетчики циклов итд итп). С другой стороны, можно попробовать использовать меньше, чем полторы тысячи активных потоков и тут появляется следующее ограничение.
Ограничение пятое: планировщик потоков Fermi может запускать потоки только группами в 512 штук (до ферми было проще, в районе 128)
То есть доступно всего 3 опции: 1536 потоков если каждый использует меньше 21 регистра, 1024 потока если используется меньше 32 регистров или 512 потоков, меньше никак. При этом меньшее число потоков означает серьезные проблемы с попыткой скрыть латентность памяти и простоем целого мультипроцессора в течении тысяч циклов.
А это уже гораздо хуже, чем CPU. А самое плохое происходит если каждый поток использует больше 64 регистров.
Ограничение шестое: если поток использует больше 64 регистров, то они хранятся в глобальной памяти
Я до сих пор не могу поверить, что в глобальной памяти, а не в локальной, но так говорит документация. То есть появляются дополнительные запросы к памяти. Кстати, для вызова функций используется стек, который также занимает регистры (да да, функции — это плохо).
Для борьбы с использованием регистров и оптимизацией загрузки есть есть еще разделяемая память (shared которая, не помню как по-русски правильно). Но ее только 16/48Кб и она разделяется между всеми активными группами, то есть если каждая группа кушает 25кб памяти, то больше одной группы запустить не получится со всеми вытекающими последствиями.
Ограничение седьмое: запуск каждого ядра сопровождается небольшой задержкой
На самом деле тут все не так уж страшно, задержка эта измеряется десятками микросекунд. Но если Вы запускаете ядро 1000 раз, то это уже десятки миллисекунд, что в случае расчетов в реальном времени (рендеринга например) сразу создает ограничение в 15FPS даже без учета времени самих расчетов.
Тут должно было быть заключение, но будет в следующий раз
Только я разошелся, а уже получился слишком длинный список. А ведь еще надо вспомнить про синхронизацию, атомные операции, копирование данных на девайс, балансировку нагрузки для каждой карты (SLI тут не работает), точность, специальные функции, кривые драйвера, отладку и много чего еще. Да и про реальную универсальность OpenCL надо сказать многое. Ну да ладно, отложим это до следующих частей.
А, в целом, разработчики конечно знают (с приходом опыта после долгих мучений) о многих (но не всех) ограничениях и пытаются оптимизировать код так, чтобы их обойти, но представьте сколько времени уходит на то, чтобы переделать алгоритмы, которые разрабатывались без оглядки на GPU, да и не все алгоритмы можно переделать в принципе. Но я потихоньку “постигаю дзен” и понимаю, что не все так плохо и обещанные терафлопы получить таки можно, и об этом я также обещаю написать в следующих частях истории об OpenCL.