Ташкент 2022
Лабораторная работа № 9
Алгоритмы работы нейрокомпьютеров
НЕЙРОКОМПЬЮ́ТЕР (от нейро... и компьютер), вычислительная система, в
которой аппаратное и программное обеспечение оптимизировано для
реализации алгоритмов решения задач на основе принципов
работы нейронных сетей.
Появление нейрокомпьютеров обусловлено прежде всего отказом от
логического базиса (И, ИЛИ, НЕ), используемого в компьютерах с т. н. фон-
неймановской архитектурой (см. Дж. фон Нейман, Вычислительная машина),
сначала на уровне описания алгоритмов решения задач, затем на уровне
элементной базы вычислительных машин с постепенным переходом на
использование в качестве базовых элементов вычислительной системы
формальных нейронов (с соответствующими алгоритмами настройки
весовых коэффициентов входных сигналов), которые в простейшем случае
моделируют функции биологических нейронов. Нейросетевые алгоритмы
решения задач обеспечивают максимально возможный уровень
распараллеливания вычислений при современной аппаратной реализации
по сравнению с другими алгоритмами. По способу обработки сигналов
различают цифровые, аналоговые и аналого-цифровые нейрокомпьютеров
(именно они обусловили тенденцию возврата к аналоговым методам
обработки информации, обладающим высоким быстродействием и низкой
стоимостью).
Нейрокомпьютеров применяют в основном для решения общесистемных
задач (обработка сигналов – речевых, сейсмических, радиолокационных,
гидроакустических и других; обработка изображений – биометрических,
аэрокосмических и других; реализация систем принятия решений, система
защиты информации и других), задач промышленного назначения (в
геоинформационных, телекоммуникационных, космических, навигационных
системах, для диагностики технических систем и других), социально-
экономических задач (в медицине, биоинформатике, образовании,
экономике и других).
Основные преимущества нейрокомпьютеров: параллелизм вычислений, что
обусловливает высокое быстродействие; низкие требования к стабильности
и точности параметров элементарных узлов; устойчивость к помехам и
разрушениям (выходу из строя) формальных нейронов при большой
пространственной размерности системы (причём устойчивые и надёжные
нейронные сети могут создаваться из низконадёжных элементов, имеющих
большой разброс параметров).
Архитектура нейрокомпьютеров
Известны реализации Н. в виде совокупности нейрочипов (цифровых,
аналоговых, аналого-цифровых); встраиваемых блоков, аппаратно
реализующих различные физические принципы действия (оптические,
молекулярные, квантовые); классических рабочих станций или суперЭВМ
различной архитектуры с программным обеспечением, реализующим
нейросетевые алгоритмы решения задач (см. Нейронная сеть).
Специализированные нейрочипы часто реализуются на
основе процессорных матриц (систолических процессоров). Такие
нейрочипы близки к обычным RISC-процессорам [англ. restricted (reduced)
instruction set computer – «компьютер с сокращённым набором команд»] и
объединяют в своём составе некоторое число процессорных элементов, а
управляющая и дополнительная логика, как правило, строится на базе
дополнительных схем. Основное отличие нейрочипов от других процессоров
– это обеспечение высокого параллелизма вычислений за счёт применения
специализированного нейросетевого логического базиса или конкретных
архитектурных решений. Использование возможности представления
нейросетевых алгоритмов для реализации на нейросетевом логическом
базисе является основной предпосылкой резкого увеличения
производительности нейрочипов.
Различают также нейросигнальные процессоры, ядро которых составляют
типовые процессоры цифровой обработки сигналов (ПЦОС), а реализованная
на кристалле дополнительная логика обеспечивает выполнение характерных
нейросетевых операций (например, дополнительный векторный процессор и
т. п.).
Архитектура нейрокомпьютеров на базе мультипроцессорных
вычислительных комплексов (ВК) с раздельной памятью строится на основе
элементарных машин (ЭМ), объединяемых в вычислительный комплекс
посредством коммуникационной сети. Каждая ЭМ состоит из центрального
процессора (ЦП) и оперативной памяти (ОП); т. к. каждый процессор имеет
свою собственную память, то отсюда название – вычислительный комплекс с
разделённой оперативной памятью. Так как вычислительный комплекс
состоит из достаточно автономных ЭМ, эту структуру можно считать и
многомашинным вычислительным комплексом. ЭМ реализуются на базе т.
н. транспьютеров [transputer = TRANSfer (передатчик) + comPUTER
(вычислитель), разработан фирмой «Inmos» (Великобритания) в 1979].
Транспьютер – элемент построения многопроцессорных систем,
выполненный на одном кристалле большой (сверхбольшой) интегральной
схемы (СБИС). Объективной причиной их появления стала возможность
реализовать в одном кристалле 16-разрядный (а несколько позднее 32-
разрядный) микропроцессор, память и 4 канальных адаптера
(последовательных интерфейсов связи, работающих независимо друг от
друга и независимо от центрального процессора). Это позволило создавать
вычислительные системы, собирая их как конструктор, впервые с
возможностью подборки числа процессоров, адекватных сложности
решаемой задачи. Под ЭВМ с массовым параллелизмом в отличие от
классических многопроцессорных ЭВМ с числом процессоров 2, 4, 8, 16 (как
правило, не более) понимают ЭВМ с числом процессоров 4, 8, 10, 16, 32, 128,
256, 512, 1024 и более, в которых соблюдается принцип линейного (или
почти линейного) роста производительности в зависимости от числа
процессоров (физического объёма или стоимости). Появление таких ЭВМ
связано с разработкой фирмой «Inmos» транспьютера T414. В отличие от
обычных микропроцессоров в транспьютере на кристалле были
реализованы: 32-хразрядный микропроцессор; внутрикристалльное
оперативное запоминающее устройство (ОЗУ) объёмом 2 Кбайта; четыре
канальных адаптера. Для разработки следующей версии транспьютера T800
фирме «Inmos» понадобилось около 5 лет. В него были введены 2 блока:
блок выполнения операций с плавающей запятой; внутрикристалльное ОЗУ
объёмом 4 Кбайт. Преимуществами транспьютерной идеологии построения
сверхвысокопроизводительных ЭВМ были также развитое программное
обеспечение, система интерфейсных СБИС (канальные, графические, ввода
изображений и др.), система проблемноориентированных СБИС (обработка
сигналов, изображений, дисковые системы, реализация тригонометрических
функций и т. п.). Интерфейсные и проблемно-ориентированные
транспьютерные СБИС имели, как и транспьютеры, свойство
каскадируемости с использованием стандартных каналов связи.
Транспьютерные системы, разработанные в 1990-х гг., были наиболее
эффективной реализацией нейрокомпьютеров, как программно-аппаратных
эмуляторов (воспроизведение работы других программ или устройств,
которые предназначены для запуска одной системы в оболочке другой).
Лабораторная работа № 10
Изучение структуры и работы пакета OpenMP
Одним из наиболее популярных средств программирования для
компьютеров с общей памятью, базирующихся на традиционных языках
программирования и использовании специальных комментариев, в
настоящее время является технология OpenMP. За основу берётся
последовательная программа, а для создания её параллельной версии
пользователю предоставляется набор директив, функций и переменных
окружения. Предполагается, что создаваемая параллельная программа
будет переносимой между различными компьютерами с разделяемой
памятью, поддерживающими OpenMP API. Технология OpenMP нацелена на
то, чтобы пользователь имел один вариант программы для параллельного и
последовательного выполнения. Однако возможно создавать программы,
которые работают корректно только в параллельном режиме или дают в
последовательном режиме другой результат. Более того, из-за накопления
ошибок округления результат вычислений с использованием различного
количества нитей может в некоторых случаях различаться.
Распараллеливание в OpenMP выполняется явно при помощи вставки в текст
программы специальных директив, а также вызова вспомогательных
функций. При использовании OpenMP предполагается SPMD-модель (Single
Program Multiple Data) параллельного программирования, в рамках которой
для всех параллельных нитей используется один и тот же код. Программа
начинается с последовательной области – сначала работает один процесс
(нить), при входе в параллельную область порождается ещё некоторое число
процессов, между которыми в дальнейшем распределяются части кода. По
завершении параллельной области все нити, кроме одной (нитимастера),
завершаются, и начинается последовательная область. В программе может
быть любое количество параллельных и последовательных областей. Кроме
того, параллельные области могут быть также вложенными друг в друга. В
отличие от полноценных процессов, порождение нитей является
относительно быстрой операцией, поэтому частые порождения и
завершения нитей не так сильно влияют на время выполнения программы.
Для написания эффективной параллельной программы необходимо, чтобы
все нити, участвующие в обработке программы, были равномерно
загружены полезной работой. Это достигается тщательной балансировкой
загрузки, для чего предназначены различные механизмы OpenMP.
Существенным моментом является также необходимость синхронизации
доступа к общим данным. Само наличие данных, общих для нескольких
нитей, приводит к конфликтам при одновременном несогласованном
доступе. Поэтому значительная часть функциональности OpenMP
предназначена для осуществления различного рода синхронизаций
работающих нитей. OpenMP не выполняет синхронизацию доступа
различных нитей к одним и тем же файлам. Если это необходимо для
корректности программы, пользователь должен явно использовать
директивы синхронизации или соответствующие библиотечные функции.
При доступе каждой нити к своему файлу никакая синхронизация не
требуется.
Значительная часть функциональности OpenMP реализуется при помощи
директив компилятору. Они должны быть явно вставлены пользователем,
что позволит выполнять программу в параллельном режиме. Директивы
OpenMP в программах на языке Фортран оформляются комментариями и
начинаются с комбинации символов !$OMP, C$OMP или *$OMP, а в языке Си
— указаниями препроцессору, начинающимися с #pragma omp. Ключевое
слово omp используется для того, чтобы исключить случайные совпадения
имён директив OpenMP с другими именами. Формат директивы на Си/Си++:
#pragma omp directive-name [опция[[,] опция]...]
Формат директивы на Фортране:
!$OMP directive-name [опция[[,] опция]...]
С$OMP directive-name [опция[[,] опция]...]
*$OMP directive-name [опция[[,] опция]...]
Объектом действия большинства директив является один оператор или блок,
перед которым расположена директива в исходном тексте программы. В
OpenMP такие операторы или блоки называются ассоциированными с
директивой. Ассоциированный блок должен иметь одну точку входа в
начале и одну точку выхода в конце. Порядок опций в описании директивы
несущественен, в одной директиве большинство опций может встречаться
несколько раз. После некоторых опций может следовать список переменных
(для Фортрана также и имён COMMON-блоков, заключённых в слеши),
разделяемых запятыми.
Все директивы OpenMP можно разделить на 3 категории: определение
параллельной области, распределение работы, синхронизация. Каждая
директива может иметь несколько дополнительных атрибутов – опций
(clause). Отдельно специфицируются опции для назначения классов
переменных, которые могут быть атрибутами различных директив. Чтобы
задействовать функции библиотеки OpenMP периода выполнения
(исполняющей среды), в программу нужно включить заголовочный файл
omp.h (для программ на языке Фортран – файл omp_lib.h или модуль
omp_lib). Если вы используете в приложении только OpenMP-директивы,
включать этот файл не требуется. Функции назначения параметров имеют
приоритет над соответствующими переменными окружения.
Все функции, используемые в OpenMP, начинаются с префикса omp_. Если
пользователь не будет использовать в программе имён, начинающихся с
такого префикса, то конфликтов с объектами OpenMP заведомо не будет. В
языке Си, кроме того, является существенным регистр символов в названиях
функций. Названия функций OpenMP записываются строчными буквами. Для
того чтобы программа, использующая функции OpenMP, могла оставаться
корректной для обычного компилятора, можно прилинковать специальную
библиотеку, которая определит для каждой функции соответствующую
«заглушку» (stub). Например, в компиляторе Intel соответствующая
библиотека подключается заданием ключа –openmp-stubs.
После получения выполняемого файла необходимо запустить его на
требуемом количестве процессоров. Для этого обычно нужно задать
количество нитей, выполняющих параллельные области программы,
определив значение переменной среды OMP_NUM_THREADS. Например, в
Linux в командной оболочке bash это можно сделать при помощи следующей
команды:
export OMP_NUM_THREADS=n
После запуска начинает работать одна нить, а внутри параллельных областей
одна и та же программа будет выполняться всем набором нитей.
Стандартный вывод программы в зависимости от системы будет выдаваться
на терминал или записываться в файл с предопределенным именем.
В OpenMP переменные в параллельных областях программы разделяются на
два основных класса:
SHARED (общие; под именем A все нити видят одну переменную) и
PRIVATE (приватные; под именем A каждая нить видит свою переменную).
Отдельные правила определяют поведение переменных при входе и выходе
из параллельной области или параллельного цикла: REDUCTION,
FIRSTPRIVATE, LASTPRIVATE, COPYIN.
По умолчанию, все COMMON-блоки, а также переменные, порожденные вне
параллельной области, при входе в эту область остаются общими (SHARED).
Исключение составляют переменные - счетчики итераций в цикле, по
очевидным причинам. Переменные, порожденные внутри параллельной
области, являются приватными (PRIVATE). Явно назначить класс переменных
по умолчанию можно с помощью клаузы DEFAULT.
Лабораторная работа № 11
Выполнение алгоритмов на базе пакета OpenMP
Самый популярный способ распределения задач в OpenMP — параллельный
цикл. Не секрет, что программы почти всю свою жизнь проводят выполняя
циклы, при этом если между итерациями цикла нет зависимостей — то цикл
называется векторизуемым (его итерации можно поделить между потоками
и выполнить независимо друг от друга).Параллельный цикл позволяет задать
опцию schedule, изменяющую алгоритм распределения итераций между
потоками. Всего поддерживается 3 таких алгоритма. Далее полагаем, что у
нас p потоков выполняют n итераций:
Опции планирования:
schedule(static)
— статическое планирование. При использовании такой опции итерации
цикла будут поровну (приблизительно) поделены между потоками. Нулевой
поток получит первые np итераций, первый — вторые и т.д.;
schedule(static, 10)
— блочно-циклическое распределение итераций. Каждый поток получает
заданное число итераций в начале цикла, затем (если остались итерации)
процедура распределения продолжается. Планирование выполняется один
раз, при этом каждый поток «узнает» итерации которые должен выполнить;
schedule(dinamic), schedule(dynamic, 10)
— динамическое планирование. По умолчанию параметр опции равен 1.
Каждый поток получает заданное число итераций, выполняет их и
запрашивает новую порцию. В отличии от статического планирования,
выполняется многократно (во время выполнения программы). Конкретное
распределение итераций между потоками зависит от темпов работы потоков
и трудоемкости итераций;
schedule(guided), schedule(guided, 10)
— разновидность динамического планирования с изменяемым при каждом
последующем распределении числе итераций. Распределение начинается с
некоторого начального размера, зависящего от реализации библиотеки до
значения, задаваемого в опции (по умолчанию 1). Размер выделяемой
порции зависит от количества еще нераспределенных итераций
В большинстве случаев самым оптимальным вариантом является static, т.к.
выполняет распределение единственный раз, играть с этим параметром
имеет смысл если в вашей задаче сильно отличается трудоемкость итераций.
Например, если вы считаете сумму элементов квадратной матрицы,
расположенных ниже главной диагонали — то static даст не лучший
результат, т.к. первый поток выполнит значительно меньше операций и
будет простаивать.
В статье про параллельный цикл также описаны опции nowait и reduction.
Первая из них очень редко даст ощутимый выигрыш, а вторую я рекомендую
использовать как можно чаще (вместо критических секций). В той
статье reduction использовалась во всех примерах и за счет этого удалось
избежать явного использования критических секций, однако это удается не
всегда и поэтому стоит знать что у нее внутри. Итак, параллельно вычислить
сумму элементов массива можно так:
int sum_arr(int *a, const int n) {
int sum = 0;
#pragma omp parallel reduction (+: sum)
{
#pragma omp for
for (int i = 0; i < n; ++i)
sum += a[i];
}
return sum;
}
Выглядит это красиво, но на самом деле в каждом потоке создается
локальная переменная для хранения суммы части массива (вычисление
которой назначено текущему потоку), ей присваивается значение 0 (т.к.
редукция с оператором +). Каждый поток вычисляет сумму, но необходимо
ведь сложить все эти значение чтобы получить окончательный результат? —
Делается это с помощью критической секции или атомарной операции
примерно следующим образом:
int sum_arr(int *a, const int n) {
int sum = 0;
#pragma omp parallel
{
int local_sum = 0;
#pragma omp for
for (int i = 0; i < n; ++i)
local_sum += a[i];
#pragma omp atomic
sum += local_sum;
}
return sum;
}
Такой подход используется постоянно, поэтому я рекомендую внимательно
рассмотреть этот код. Чуть более сложным примером является
параллельный поиск максимума/минимума [9]. В качестве задачи для
проверки усвоения материала предлагаю попробовать построить
гистограмму (например для изображения).
Параллельные задачи (parallel tasks)
Параллельные задачи — это более гибкий механизм, чем параллельный
цикл. Параллельный цикл описывается внутри параллельной области, при
этом могут возникнуть проблемы. Например, мы написали параллельную
функцию вычисления суммы элементов одномерного массива, и нашу
функцию решили применить для вычисления суммы элементов матрицы, но
сделать это также параллельно. Получится вложенный параллелизм. Если
(теоретически) наш код запущен на 8 ядрах — то фактически будет создано
64 потока. Ну а если кому-нибудь придет в голову идея делать параллельно
еще что-нибудь?
Иногда такую ситуацию нелегко обнаружить, например, на нашем форуме
можно найти параллельную реализацию решения СЛАУ методом Крамера,
при этом параллельно вычисляется n определителей. Функция вычисления
определителя вызывает функцию сведения матрицы к треугольному виду,
которая может быть распараллелена.
Проблема параллельного цикла в том, что число создаваемых потоков
зависит от того какие функции распараллелены и как они друга друга
вызывают. Очень сложно все это отслеживать и, тем более, поддерживать.
Решение проблемы — параллельные задачи, которые не создают поток, а
лишь выполняют добавление задачи в очередь, освободившийся поток
выбирает задачу из пула. Я описывал этот механизм в статье
«Параллельные задачи (tasks) OpenMP» и не буду повторяться (рекомендую
прочитать материал по ссылке — в статье рассматривается
возможность распараллеливания рекурсивных функций с помощью
механизма задач). Отмечу лишь то, что в параллельные задачи были
предложены в стандарте OpenMP 3.0 (в 2008 году) поэтому их поддержка
отсутствует в Microsoft C++. Кроме того, в свежем стандарте OpenMP 4.5 была
предложена конструкция taskloop, за счет которой использовать
параллельные задачи для распараллеливания циклов теперь также удобно
как и параллельный цикл.
Параллельные секции
Механизм параллельных секций видится мне достаточно низкоуровневым.
Тем не менее, он полезен в ряде случаев. Как было отмечено выше,
параллельный цикл можно применять только в случаях, если итерации цикла
не зависят друг от друга, т.е. тут нельзя:
for (int i = 1; i < n; ++i)
a[i] = a[i-1]+1;
Если же у нас в программе появляется несколько фрагментов, не зависящих
друг от друга, но имеющий зависимости внутри себя — то их
распараллеливают с помощью механизма параллельных секций:
#pragma omp parallel
{
#pragma omp sections
{
#pragma omp section
{
for (int i = 1; i < n; ++i)
a[i] = a[i-1]+1;
}
#pragma omp section
Лабораторная работа № 12
Реализация примеров с использованием OpenMP
Порядок создания параллельных программ
1. Написать и отладить последовательную программу
2. Дополнить программу директивами OpenMP
3. Скомпилировать программу компилятором с поддержкой OpenMP
4. Задать переменные окружения
5. Запустить программу
Пример программы: сложение двух векторов