Новосибирский Государственный Университет
Опубликован: 18.02.2011 | Доступ: свободный | Студентов: 936 / 287 | Оценка: 4.27 / 3.95 | Длительность: 04:10:00
Специальности: Программист
Лекция 6:

Автоматическое распараллеливание

Аннотация: История развития многоядерных процессоров Intel и актуальность создания параллельных программ. Различные модели использования памяти в многопроцессорных системах. Плюсы и минусы многопоточных программ. Возможности компилятора Intel по автоматическому распараллеливанию программ.

Вы каждый имеете дома компьютер. Сейчас уже стандарт подразумевает, что все вычислительные системы для персональных компьютеров, которые находятся на рынке, как правило, многопроцессорные, многоядерные. Здесь я перечисляю некоторые процессоры, вычислительные системы, которые начали выходить на рынок с несколькими ядрами. Если верить этому перечню, все началось в 2005 г. С появлением Intel Pentium Processor Extreme Edition, который представил технологию двойного ядра. Достаточно давно рынок формируется за счет многопроцессорных многоядерных архитектур.

Вот некая схема, которая показывает, что из себя представляет чип с двумя ядрами. Подразумевается, что ядро — это некая подсистема процессора, которая умеет делать вычисления. Как правило, поскольку ядро это — не полноценный процессор, оно какие-то механизмы делит с другим ядром. Обычно это кэш второго уровня и шина данных. Возникает вопрос: классифицируются ли каким-то образом многопроцессорные системы? Самым главным при выполнении приложения является то, как мы работаем с памятью. И вот эти системы многопроцессорные классифицированы на основе того, как они взаимодействуют с памятью? И здесь существует три основных классификации:

  • Массивно-параллельные компьютеры или системы с распределенной памятью. (MMP системы), в которой каждый процессор полностью автономен, и существует некая коммуникационная среда для того, чтобы эти процессоры связывать. Какие у этой системы достоинства? Эта система обычно хорошо масштабируемая, а недостатками является медленное межпроцедурное взаимодействие. Для того, чтобы эти системы заставить работать совместно, огромное количество времени будет тратиться на коммуникации, потому что у этих систем все свое и какой-то общей памяти у них нет.
  • Системы с общей памятью (SMP системы), у которых все процессоры равноудалены от памяти. Связь с памятью осуществляется через общую шину данных. В данном случае, поскольку у нас есть общая память, легче делать межпроцессорные взаимодействия. Тут разные потоки могут работать с одной и той же памятью, модифицировать ее, читать и т.д., с одной стороны — хорошее межпроцедурное взаимодействие, с другой – плохая масштабируемость, то есть добавить в такую систему новые вычислительные мощности просто уже не получается. Здесь еще возникают затраты на синхронизацию системы кэшей. Вот представьте, у вас два процессора, каждый из них имеет свою подсистему КЭШей. Он пытается в свою подсистему КЭШей подгружать те адреса из памяти, с которыми он работает. И что возникает, если один процессор берет и пишет по какому-то адресу, а этот адрес находится в обоих подсистемах КЭШей и у того и у другого процессора. Это означает, что один процессор по этому адресу записал, и та информация, которая в КЭШе другого процессора лежит, становится недостоверной. Мы каким-то образом должны отработать систему, чтобы вот эту ячейку памяти в подсистеме второго процессора пометить как недостоверную, чтобы процессор новую модифицированную ячейку считал из памяти. Возникает проблема синхронизации подсистемы КЭШей.
  • Система с неоднородным доступом к памяти (NUMA). Память физически распределена между процессорами. Единое адресное пространство поддерживается на аппаратном уровне. Но получается так, что у каждого процессора есть своя память и есть память соседа. Он может и то и другое использовать, но использование своей памяти эффективнее. Достоинство – хорошее межпроцессорное взаимодействие и масштабируемость. В такую систему нам легче добавить новые элементы, поскольку процессор идет уже со своей памятью. Мы его каким-то образом вставили, задача уже немного легче. Недостатки: разное время доступа к разным системам памяти.

Отсюда возникает такая ситуация. Вот у вас вы видите Windows ask-менеджер, это обычная система, на которой вы видите какое-то количество ядер. Мы запускаем обычное однопоточное приложение на этой системе. И вдруг мы понимаем, что оно выполняется, но как-то странно. При выполнении оно кочует по процессорам, по ядрам. Из каких соображений оно кочует, это известно, наверное, только операционной системе. Многие вещи внутри многопроцессорной системы заложены на то, чтобы энергозатраты были эффективны. Оно увидело, что я на ядре запустил, прошло какое-то время, ядро разогрелось, оно его переключает на другое ядро. Однопоточное приложение мигрирует между разными ядрами. Вот оно выполнялось на одном ядре, оно выстроило под себя систему памяти, заполнило КЭШи. И тут операционная система решает: "А иди на следующее ядро". И нужно заново формировать эту систему КЭШа. В одном случае оно выгоняет его на ядро этого же процессора. И там, по крайней мере, один кэш нижнего уровня общий, и адреса, которые начинала эта система использовать, принадлежат именно вот этому процессору. А в другом случае операционная система выгнала этот процесс на ядро другого процессора. И с другого процессора доступ к той памяти, с которой работало это приложение, оно дольше… Получается, что одно и тоже приложение при разных запусках показывает разное время выполнения. Причем достаточно заметная процентовка.

Плюсы и минусы использования многопоточных приложений.

Плюсы:

  • вычислительные ресурсы увеличиваются пропорционально количеству используемых реальных ядер

Минусы:

  • усложнение разработки
  • необходимость синхронизировать потоки
  • потоки конкурируют за ресурсы
  • создание потоков имеет свою цену

Вам нужно понять, что для вас главное. Если для вас главное – энегропотребление, то возникает вопрос, а нужна вам автопараллелизация, может, ваша задача будет отлично выполняться на одном ядре, экономя при этом энергоресурсы. Например, Atom, поскольку там одна из важных задач – энергопотребление, то там этот вопрос более жестко стоит, а приоритетом является энергопотребление. Нужно ли параллелизовать программу? Как правило из-за того, что создание потоков имеет свою цену, энергозатраты многопоточного приложения существенно превышают затраты однопоточного приложения.

Понятно, что существует способ создания многопоточного приложения достаточно сложного, использующего специальные библиотеки. В этих методах нужно разбираться, заниматься синхронизацией и т.д. поэтому компилятор предлагает сове простое решение, главное достоинство которого только в том, что вы добавляете компилятору специальный ключик, и у вас получичается многопоточное приложение. Главный недостаток этого метода в том, что простые решения, как правило, не могут демонстрировать мощного результата. В данном случае эта автопараллелизация, которая реализована в компиляторе, создает многопоточность только на основании того, что она делает многопоточным обработку каких-то отдельных циклов. Большего она делать не умеет. Но тем не менее, если у вас реально приложение какое-то вычислительное, в вашем реальном приложении есть циклы, которые делают много работы, эта автопараллелизация может быть очень эффективной. И не надо ничего переписывать, достаточно добавить ключик.

Пытаясь показать выгоды, я написал простой тест. После этого этот тест неким образом скомпилировал с опцией Q-параллел и без опции Q-параллел, чтобы вы мне поверили, что я достиг параллелизации. Я здесь привожу окно таск-менеджера, и вы видите, что первый поток выполнялся только на одном ядре, соответственно, первое приложение выполнялось только на одном ядре, а вторая, собранная с Q-параллел, выполнялась на всех четырех ядрах. Есть возможность посмотреть на время: в первом случае время выполнения 2,3 секунды, во втором случае – 1 мин 60 сек. Это реальное время, но вот эта утилита time помимо этого приводит еще и пользовательское время, которое вычисляется умножением времён, которое было затрачено на разных ядрах. Первое приложение выполнялось 2, 23 сек., а второе – 6 сек. Если эти времена сложить, то будет ясно, что второе приложение употребило энергии в 3 раза больше, чем первое. Хотя разница во времени даже не двукратная, а меньше.

При параллелизации нужно понимать, что существует хорошо и плохо масштабируемые алгоритмы. Вот я построил такие графики. Вот программа умножения матрицы: по одной оси находится время, а по второй количество используемых процессоров. Вот вы видите отлично масштабируемое приложение, на одном ядре оно выполнялось больше 4 сек., на 16 ядрах оно выполняется меньше 1 сек. А вот хитрое приложение, которое работает одновременно с огромным количеством массивов. Вы видите, что уже на 2 ядрах, на 3, на 4 ядрах начинает это приложение упираться в память. И, начиная с какого-то момента, при увеличении количества ядер у нас производительность только падает. Ядра конкурируют за память.

Поскольку автопараллелизация – это цикловая оптимизация, автопараллелизация означает, что если у нас внутри цикла выполнялись какие-то итерации, то при автопараллелизации — нам заранее не известно, но мы подразумеваем, что какие-то итерации будут выполняться одновременно. Причем, как они будут поделены между разными потоками, мы не знаем. Поэтому можно сказать, что при автопараллелизации мы заменяем скалярное выполнение каких-то вычислений на одновременное. То есть наличие любой зависимости внутри автопараллелизуемого цикла просто препятствует возможности делать параллелизацию.

Существуют опции, с помощью которых компилятор выдает диагностику о том, почему он параллелил циклы и почему он не параллелил циклы. Выгодность параллелизации определяется количеством работы, которая внутри цикла на каждой итерации делается. Если работа делается большая, то она окупит затраты на создание потоков. Если работы делается мало, то вполне вероятно, что затраты на создание потоков не окупится. Поскольку компилятор не знает зачастую даже количество итераций вашего цикла во время компиляций, ему принять абсолютно правильное решение о том, выгодна ли параллелизация во время компиляции, достаточно сложно.

Существуют такие интересные эффекты, связанные с той же Нумой, называются Нума first touch эффект, которые оказывают нестандартные воздействия на параллелизацию, на производительность программы. Вот мы начинаем выполнять приложение и с автопараллелизацией, приложение выполняется в одном потоке, в какой-то момент оно распараллеливается, возникает несколько потоков, потом опять в одном потоке и т.д. Допустим, мы работаем с массивом, и у нас где-то существует инициализирующий цикл, который этот массив заполняет изначальными значениями. Вот если мы посмотрели: здесь работы мало, параллелизация не должна окупаться. Но тут мы забыли что, что при этой ситуации, при инициализации, реально выделяется память для этого массива. Она реально привязывается к какому-то конкретно процессору. Если мы не будем этот цикл распараллеливать, то у нас вся память выделится на одном процессоре. И потом, когда у нас будет несколько потоков, работающих на разных процессорах, они будут работать с той памятью, которая в большинстве случаев лежит у соседа. Время доступа к памяти из-за однородности будет увеличено. А если мы возьмем и этот инициализирующий цикл распараллелим, то с одной стороны для этого цикла мы затраты на создание потока не окупили, но у нас более удачно память распределилась между разными проектами, и на последующих вычислениях мы получили от этого выигрыш. Вот такие плохо прогнозируемые эффекты существуют, которые во время компиляции учесть невозможно.

Ну, и некие детали, которые облегчат вам понимание, что такое автопараллелизация, как она делается. Существует такой OpenMP интерфейс – это программный интерфейс, который поддерживает многоплатформенное многопроцессорное программирование с общей памятью на C/C ++ и Фортране на многих архитектурах. Этот интерфейс подразумевает, что вы с помощью вставки директив эффективно добьетесь того, что вас будет создано многопоточное приложение. Этот интерфейс организован с помощью OpenMP-библиотек. Компилятор осуществляет автопараллелизацию с помощью OpenMP-библиотек. Поскольку вот эти общие библиотеки используются, то некоторые вещи, которые в OpenMP определены, работают и для автопараллелизации. Например, есть переменное окружение OMP_NUM_THREADS, которое говорит, сколько ядер вы можете использовать по умолчанию в вашем приложении. Устанавливая значение переменной окружения, вы можете воздействовать на уже созданное приложение; сколько конкретно это приложение будет использовать при работе ядер. Вот пример, который показывает, что одно и то же приложение при разной установке этой переменной может работать несколько по другому. Интересно то, что тот же наш Vtune позволяет исследовать многопоточные приложения. Он позволяет смотреть даже такую информацию: сколько времени выполнялось приложение на разных потоках. Отсюда вы можете составить себе мнение, насколько у вас удачно произошло распределение работы. На первых итерациях чего-то много делается, а чем дальше, тем меньше и меньше. Если мы такой цикл поделим на несколько потоков по количеству итераций, то у нас будет неравномерное распределение работы. То есть поток, который будет обрабатывать начальные итерации, он будет остальные тормозить. На нем работа будет идти дольше.

Как выглядит параллелизация цикла. Для того, чтобы сделать параллелизацию цикла, мы создаем функцию, в которую в качестве аргументов передаются все объекты, с которыми работал изначальный цикл, который мы автопараллелизуем. При этом ему еще передаются переменные, которые определяют тот интервал в итерационном пространстве, который этот поток должен будет обрабатывать. То есть у вас изначально был цикл от 1 до 100 итераций, вы решили его распараллелить. Ваш цикл работал с массивом а и b, вы создаете функцию, в которой передаете в качестве параметров а и b, потом в качестве параметров нижнюю границу, откуда начинать обработку цикла, и верхнюю: там от 1 до 10, от 11 до 20. И после этого эта функция формально вызывается в нескольких потоках, и этой функции передаются разные итерационные промежутки. Если вы OpenMP знаете, то там есть LostPrivate, FisrtPrivate… Автопараллелизация все вот эти вещи использует.

Все цикловые оптимизации, в том числе и автопараллелизация, связаны. Поскольку автопараллелизация параллелит циклы, сразу возникает идея: наверное, она будет тем лучше работать, чем больше у нас будут циклы. Поэтому, в связи с другими цикловыми оптимизациями, возникает желание в случае, если у нас включена автопараллелизация сначала попытаться внутри компилятора создать большие циклы, объединить все циклы, какие мы можем объединить. После этого мы вот эти вот большие циклы будем параллелить, создадим параллельные функции. А потом, если у нас такая потребность есть, внутри все эти функции сделаем назад, большие циклы разобьем на маленькие.

Предвыборка – это возможность внутрь процедуры, внутрь вашего кода вставлять вызов инструкций mm_prefstch, которая передает процессору ваши пожелания подгрузить какие-то определенные адреса в кэш. Какие эффективные случаи предвыборки видел: у вас идет работа со списком, в котором лежат объекты какого-то класса. Поскольку это список, то вы вытаскиваете объекты какого-то класса, и у вас, как правило, данный адрес не находится в КЭШе. А у него там еще огромное количество указателей внутри этого объекта. И иногда вы посмотрели и видите, что у вас огромное количество объектов находится мимо КЭШа. Вы можете вставить эти инструкции, чтобы необходимые адреса подгрузить в память. Иногда это бывает неэффективно, потому что запрос, который вставили вы, и запрос операционной системы, их трудно по времени сильно разнести, и выгоды мало. А иногда это может быть эффективно, если вы четко себе представляете, какие вам адреса потребуются, вы инструкции вставляете, у вас процессор их начинает в кэш затягивать.