В лекции Стэнфордского университета по курсу CS149 подробно разбираются фундаментальные концепции параллельных вычислений и многопоточности современных процессоров. Профессор наглядно объясняет разницу между латентностью и пропускной способностью памяти с помощью бытовых аналогий, а также вводит абстракции параллельного программирования в среде ISPC. Особое внимание уделяется критическому ограничению современной архитектуры — дефициту пропускной способности памяти, который превращает мощные вычислительные чипы в неэффективные устройства при выполнении простых векторных операций.
🔄 Повторение пройденного: как скрыть латентность за многопоточностью 0:05
В начале занятия профессор напоминает о ключевых идеях параллельных вычислений, рассмотренных ранее: многоядерности, SIMD-инструкциях и аппаратной многопоточности. Основная суть многопоточности формулируется простым жизненным правилом: если вы вынуждены ждать завершения какой-то долгой операции, не проиживайте время зря, а займитесь чем-то другим.
Аппаратная многопоточность призвана повысить эффективность использования исполнительных блоков процессора за счет утилизации циклов простоя. Когда один поток данных сталкивается со内部ней задержкой, аппаратное обеспечение мгновенно переключается на выполнение инструкций другого независимого потока.
Для иллюстрации этого принципа лектор приводит аналогию со своими часами консультаций для студентов. Если профессор будет тратить время, просто ожидая, пока один студент думает над подсказкой, это будет крайне неэффективно по отношению ко всей остальной очереди. Интеграция быстрого переключения между студентами позволяет преподавателю работать со 100%-й загрузкой, однако время ожидания для каждого отдельного студента при этом неизбежно возрастает.
Спикер подчеркивает, что за внедрение аппаратной многопоточности инженерам всегда приходится платить определенную цену:
- Выделение дополнительной дефицитной площади кристалла чипа для хранения контекста выполнения (регистровых файлов) каждого потока.
- Увеличение общего времени завершения конкретной задачи для каждого отдельного потока, так как они делят один и тот же физический процессор.
🔢 Простая арифметика эффективности: соотношение вычислений и задержек 8:53
Для понимания требований к глубине многопоточности рассматривается учебная программа, выполняющая три математические операции, после чего запрашивающая данные из памяти с задержкой в 12 циклов. В такой конфигурации один поток загружает процессор лишь на 20%, выполняя всего 3 цикла полезной работы из каждых 15.
Добавление второго потока позволяет занять процессор во время вынужденного простоя первого, что удваивает общую утилизацию вычислительного ядра до 40%.
По расчетам лектора, для достижения полной 100%-й эффективности выполнения этой конкретной программы процессору потребуется:
- Один базовый поток, на котором в данный момент происходит задержка.
- Четыре дополнительных потока для полного покрытия 12-циклового простоя памяти.
- Итого — 5 параллельных потоков на одно ядро.
Если изменить исходные условия и запустить программу с шестью арифметическими инструкциями перед каждым обращением к памяти, базовая утилизация вырастет до 33%. В этом случае для 100%-й загрузки процессора потребуется суммарно всего три потока.
По мнению профессора, именно соотношение математических вычислений и латентности памяти (arithmetic intensity) определяет необходимую глубину многопоточности для достижения пиковой утилизации железа. Наличие большого кэша данных эффективно поглощает часть промахов, уменьшая задержки памяти и требуя меньше параллельных потоков.
🏗️ Монстры параллелизма: от Intel CPU до массивных GPU от Nvidia 16:56
Современные процессоры представляют собой сложный слоеный пирог, сочетающий многоядерность, SIMD-архитектуру, многопоточность и суперскалярность. В качестве теоретического примера приводится гипотетический чип из 16 ядер, каждое из которых поддерживает 4 потока и выполняет 8-значные векторные инструкции SIMD. Чтобы полностью загрузить такой процессор и скрыть все задержки, программе необходимо предоставить не менее 512 абсолютно независимых задач одновременно.
Реальные процессоры Intel (например, используемые в учебной системе Myth) устроены еще более изощренно:
- Они поддерживают двухпоточную работу, известную в индустрии как Hyper-Threading.
- Обладают суперскалярной архитектурой, позволяющей аппаратно находить и выполнять несколько независимых инструкций за один такт внутри одного ядра.
- Имеют до трех 8-значных векторных ALU, что теоретически дает ускорение до 8x при переходе со скалярных вычислений на векторные.
Графические процессоры Nvidia выводят эти показатели параллелизма на совершенно иной масштаб. Один кластер современного GPU обрабатывает 32-значные векторы, выполняя по четыре таких вектора за один такт и удерживая контекст до 64 потоков на ядро при наличии 144 ядер.
Как утверждает профессор, для полной утилизации подобного графического процессора требуются сотни тысяч независимых операций параллельно. Именно по этой причине небольшие глубокие нейросети (DNN) работают на крупных GPU крайне неэффективно — для них в архитектуре просто не набирается достаточного объема параллельной работы.
Интересное отличие архитектуры GPU от Nvidia и AMD заключается в том, что их компиляторы никогда не генерируют явные векторные инструкции. Аппаратная логика чипа самостоятельно группирует независимые скалярные потоки с одинаковым счетчиком команд (PC) для их одновременного параллельного выполнения на векторных ALU.
🖥️ Разделение обязанностей: ОС против аппаратного планировщика 34:52
При создании потоков управления в коде с помощью библиотек Pthreads или стандартных средств C++ распределением этих потоков по доступным физическим ядрам и контекстам занимается операционная система (ОС).
ОС принимает высокоуровневые стратегические решения о диспетчеризации. Например, при наличии двух потоков эффективнее распределить их по разным физическим ядрам чипа, чтобы они не конкурировали за общие исполнительные ресурсы внутри одного ядра. Контекстное переключение на уровне ОС обходится дорого — оно занимает сотни тысяч циклов и происходит относительно редко.
Однако, как указывает лектор, выбор конкретной инструкции из доступных потоков на каждом отдельном такте процессора происходит исключительно на аппаратном уровне. Чип принимает эти решения самостоятельно миллиарды раз в секунду с помощью встроенного планировщика, и операционная система никак не может влиять на этот ультранизкоуровневый процесс.
🚗 Потоки данных и забитые дороги: латентность против пропускной способности 38:07
В качестве примера рассматривается простая программа на NumPy или PyTorch, выполняющая поэлементное сложение или умножение огромных массивов размером в десятки миллионов элементов. С точки зрения структуры, здесь есть бесконечный параллелизм, идеально ложащийся на SIMD-инструкции. Однако, вопреки интуиции, это худший тип параллельной программы для любого современного компьютера.
Чтобы объяснить этот парадокс, профессор предлагает аналогию с автомобильным движением по шоссе 101 из Сан-Франциско в Стэнфорд. Условно примем расстояние за 50 км, а скорость движения машин — за 100 км/ч. Латентность (время в пути) для одного автомобиля составляет ровно 30 минут.
Если ввести жесткое правило безопасности, согласно которому на дороге одновременно может находиться только один автомобиль, пропускная способность магистрали составит всего две машины в час.
Для увеличения пропускной способности у инженеров существует три классических подхода:
- Увеличить скорость движения в два раза. Это снизит латентность до 15 минут и поднимет пропускную способность до 4 машин в час, но данный путь имеет жесткие физические и экономические ограничения.
- Расширить дорогу, построив больше параллельных полос. Это увеличит общую пропускную способность, но латентность одной конкретной поездки останется прежней — 30 минут.
- Изменить правила и разрешить плотное движение машин «бампер к бампер» с интервалом в 1 км. При таком подходе (конвейеризации) автомобили будут прибывать в точку назначения каждые 36 секунд, обеспечивая пропускную способность в 100 машин в час при неизменной латентности в 30 минут.
🧺 Уроки стирки и бутылочные горлышки систем памяти 47:26
Аналогичные правила действуют при конвейерной обработке данных, что наглядно иллюстрируется обычным процессом стирки белья, состоящим из трех последовательных этапов: стирки (45 минут), сушки (60 минут) и складывания вещей (15 минут). Суммарная латентность обработки одной порции одежды составляет два часа.
Оптимизация процесса подразумевает, что как только стиральная машина освобождается, в нее сразу загружается следующая порция белья. Пропускная способность такого бытового конвейера неизбежно упирается в самое медленное звено — сушильную машину, способную выдавать только одну готовую порцию одежды в час. В результате мокрое белье начнет неизбежно скапливаться перед сушилкой, и более быстрые этапы конвейера будут вынуждены искусственно замедлиться из-за переполнения буфера.
Перенося этот принцип на компьютерное железо, спикер анализирует работу графического процессора Nvidia V100. Чип имеет 5000 мультипликаторов, работающих на частоте 1,6 ГГц, и способен выполнять около 8 триллионов математических операций в секунду. Поскольку для каждой базовой операции требуется около 12 байт данных (загрузка двух аргументов и сохранение одного результата), процессору необходимо обеспечивать колоссальную пропускную способность памяти — около 100 Терабайт в секунду.
Однако самые передовые и дорогие на планете системы памяти графических чипов выдают максимум около 1 Терабайта в секунду. Математический конвейер процессора в 100 раз шире, чем труба подачи данных из памяти. Из-за этого стандартная программа сложения массивов на NumPy работает с реальной вычислительной эффективностью всего около 1%.
По мнению лектора, аппаратный префетчинг или кэширование в данном сценарии абсолютно бесполезны, так как данные считываются последовательно и не используются повторно. Единственный способ решить эту проблему — концептуально переписать алгоритм, искусственно увеличив его арифметическую интенсивность (количество математических операций на каждый считанный из памяти байт).
🛠️ Язык ISPC: абстракции против реализации в параллельном программировании 1:03:14
Профессор призывает студентов никогда не путать абстракцию программы (то, что код должен вычислить согласно логике автора) с его физической реализацией (то, в каком порядке и какими аппаратными силами выполняются эти вычисления на чипе). Для демонстрации этих различий используется язык низкоуровневого программирования ISPC (Intel SPMD Program Compiler), созданный для эффективной векторизации там, где автоматические компиляторы C++ пасуют.
В модели SPMD (Single Program, Multiple Data) вместо привычных тяжеловесных потоков операционной системы компилятор создает так называемую «группу экземпляров программы» (gang of program instances). Архитектура ISPC предоставляет программисту две ключевые встроенные переменные:
programCount— общее количество одновременно выполняемых экземпляров в группе (например, 8).programIndex— уникальный индекс текущего экземпляра в группе от 0 до 7 (аналог ID потока).
Каждый экземпляр выполняет один и тот же последовательный код, но обрабатывает свои данные на основе собственного индекса. Распределение задач по элементам массива может выполняться циклически (через чередование round-robin, когда экземпляр 0 обрабатывает индексы 0, 8, 16...) или непрерывными блоками (когда каждый экземпляр берет свой изолированный кусок данных).
Для упрощения написания параллельного кода в ISPC встроен специальный оператор foreach. Он сообщает компилятору, что все итерации данного цикла абсолютно независимы. Это позволяет системе самостоятельно, без участия программиста, максимально эффективно распределить работу между доступными векторными ALU процессора.