[ Автор: Илья Евсеев ]
[ Организация: ИВВиБД ]
[ Подразделение: ЦСТ ]
Исходный текст доступен по адресу http://www.emanual.ru/download/www.eManual.ru_396.html
MPI for beginners
MPI для начинающих.
Учебное пособие + примеры MPI - это стандарт на программный инструментарий для обеспечения связи между ветвями параллельного приложения. MPI расшифровывается как "Message passing interface" ("Взаимодействие через передачу сообщений"). Несколько путает дело тот факт, что этот термин уже применяется по отношению к аппаратной архитектуре ЭВМ. Программный инструментарий MPI реализован в том числе и для ЭВМ с такой архитектурой. MPI предоставляет программисту единый механизм взаимодействия
ветвей внутри параллельного приложения независимо от машинной
архитектуры (однопроцессорные / многопроцессорные с общей/раздельной
памятью), взаимного расположения ветвей (на одном процессоре /
на разных) и API операционной системы.
Программа, использующая MPI, легче отлаживается (сужается простор для совершения стереотипных ошибок параллельного программирования) и быстрее переносится на другие платформы (в идеале, простой перекомпиляцией). В настоящее время разными коллективами разработчиков написано несколько программных пакетов, удовлетворяющих спецификации MPI, в частности: MPICH, LAM, HPVM и так далее. Они выступают базовыми при переносе MPI на новые архитектуры ЭВМ. Здесь в пособии рассматриваются разновидности MPICH. Это сделано по двум причинам: MPICH написан авторами спецификации, и наиболее распространен. Таким образом, далее по тексту термин MPI используется не только для обозначения изложенных в спецификации сведений, но и, в определенной мере, для описания характеристик конкретной базовой реализации. Увы. Минимально в состав MPI входят: библиотека программирования (заголовочные и библиотечные файлы для языков Си, Си++ и Фортран) и загрузчик приложений. Дополнительно включаются: профилирующий вариант библиотеки (используется на стадии тестирования параллельного приложения для определения оптимальности распараллеливания); загрузчик с графическим и сетевым интерфейсом для X-Windows и проч. Структура каталогов MPICH выполнена в полном соответствии с традициями Юникса: bin, include, lib, man, src, ... Минимальный набор функций прост в освоении и позволяет быстро написать надежно работающую программу. Использование же всей мощи MPI позволит получить БЫСТРО работающую программу - при сохранении надежности. Первоначально весь текст данного пособия был собран в одном документе. Теперь он разбит на три части: вводно-философская часть: сравнительные характеристики MPI, его плюсы и минусы, сильные и слабые стороны, прошлое и будущее, стихи и проза, лед и пламень, ... руководство пользователя по нескольким реализациям MPI: MPICH, WinMPICH, WMPI, PowerMPI и HPMPI; ссылки на документацию, в т.ч. по MPICH руководство программиста - оставлено здесь. Рекомендуется читать их именно в таком порядке. Некоторые сведения, к сожалению, повторяются, потому что разделение первоначального текста на части было выполнено несколько поспешно. Параллельное приложение состоит из нескольких ветвей, или процессов, или задач, выполняющихся одновременно. Разные процессы могут выполняться как на разных процессорах, так и на одном и том же - для программы это роли не играет, поскольку в обоих случаях механизм обмена данными одинаков. Процессы обмениваются друг с другом данными в виде сообщений. Сообщения проходят под идентификаторами, которые позволяют программе и библиотеке связи отличать их друг от друга. Для совместного проведения тех или иных расчетов процессы внутри приложения объединяются в группы. Каждый процесс может узнать у библиотеки связи свой номер внутри группы, и, в зависимости от номера приступает к выполнению соответствующей части расчетов. Термин "процесс" используется также в Юниксе, и здесь нет путаницы: в MPI ветвь запускается и работает как обычный процесс Юникса, связанный через MPI с остальными процессами, входящими в приложение. В остальном процессы следует считать изолированными друг от друга: у них разные области кода, стека и данных (короче, смотрите описание Юниксовских процессов). Говорят, что процессы имеют раздельную память (separate memory). Особенность MPI: понятие области связи (communication domains). При запуске приложения все процессы помещаются в создаваемую для приложения общую область связи. При необходимости они могут создавать новые области связи на базе существующих. Все области связи имеют независимую друг от друга нумерацию процессов. Программе пользователя в распоряжение предоставляется коммуникатор - описатель области связи. Многие функции MPI имеют среди входных аргументов коммуникатор, который ограничивает сферу их действия той областью связи, к которой он прикреплен. Для одной области связи может существовать несколько коммуникаторов таким образом, что приложение будет работать с ней как с несколькими разными областями. В исходных текстах примеров для MPI часто используется идентификатор MPI_COMM_WORLD. Это название коммуникатора, создаваемого библиотекой автоматически. Он описывает стартовую область связи, объединяющую все процессы приложения. В manual pages особо подчеркивается принадлежность описываемой функции к той или иной категории. При первом чтении этот раздел можно пропустить, возвращаясь к нему по мере ознакомления с документацией. Вкратце: Блокирующие - останавливают (блокируют) выполнение процесса до тех пор, пока производимая ими операция не будет выполнена. Неблокирующие функции возвращают управление немедленно, а выполнение операции продолжается в фоновом режиме; за завершением операции надо проследить особо. Неблокирующие функции возвращают квитанции ("requests"), которые погашаются при завершении. До погашения квитанции с переменными и массивами, которые были аргументами неблокирующей функции, НИЧЕГО ДЕЛАТЬ НЕЛЬЗЯ. Локальные - не инициируют пересылок данных между ветвями. Большинство информационных функций является локальными, т.к. копии системных данных уже хранятся в каждой ветви. Функция передачи MPI_Send и функция синхронизации MPI_Barrier НЕ являются локальными, поскольку производят пересылку. Следует заметить, что, к примеру, функция приема MPI_Recv (парная для MPI_Send) является локальной: она всего лишь пассивно ждет поступления данных, ничего не пытаясь сообщить другим ветвям. Коллективные - должны быть вызваны ВСЕМИ ветвями-абонентами того коммуникатора, который передается им в качестве аргумента. Несоблюдение для них этого правила приводит к ошибкам на стадии выполнения программы (как правило, к повисанию). Регистр букв : важен в Си, не играет роли в Фортране. Все идентификаторы начинаются с префикса "MPI_". Это правило без исключений. Не рекомендуется заводить пользовательские идентификаторы, начинающиеся с этой приставки, а также с приставок "MPID_", "MPIR_" и "PMPI_", которые используются в служебных целях. Если идентификатор сконструирован из нескольких слов, слова в нем разделяются подчерками: MPI_Get_count, MPI_Comm_rank. Иногда, однако, разделитель не используется: MPI_Sendrecv, MPI_Alltoall. Порядок слов в составном идентификаторе выбирается по принципу "от общего к частному": сначала префикс "MPI_", потом название категории ( Type, Comm, Group, Attr, Errhandler и т.д.), потом название операции ( MPI_Errhandler_create, MPI_Errhandler_set, ...). Наиболее часто употребляемые функции выпадают из этой схемы: они имеют "анти-методические", но короткие и стереотипные названия, например MPI_Barrier, или MPI_Unpack. Имена констант (и неизменяемых пользователем переменных) записываются полностью заглавными буквами: MPI_COMM_WORLD, MPI_FLOAT. В именах функций первая за префиксом буква - заглавная, остальные маленькие: MPI_Send, MPI_Comm_size. Такая нотация по первому времени может показаться непривычной, однако ее использование быстро входит в навык. Надо признать, что она делает исходные тексты более читабельными. Здесь в сжатом виде изложены основные отличия особенности реализации MPI для Фортрана. С их учетом программисты, пишущие на языке Фортран, смогут приступить к изучению дальнейшего материала. Регистр символов в исходном тексте для Фортрана значения не имеет. MPI_Comm_rank и MPI_COMM_RANK для него, в отличие от Си, является одним и тем же идентификатором. В начале исходного текста должна быть строка include 'mpif.h' Этот файл содержит описания переменных и констант. Все, что в Си является функциями, в Фортране сделано подпрограммами. В Си код ошибки MPI возвращается пользовательской программе как код завершения функции; в Фортране код ошибки возвращается в дополнительном параметре: Си: errcode = MPI_Comm_rank( MPI_COMM_WORLD, &rank ); Фортран: CALL MPI_COMM_RANK ( MPI_COMM_WORLD, rank, ierror ) Все параметры в Фортране передаются не по значению, а по ссылке. Соответственно, там, где в Си используется символ "&" для вычисления адреса переменной, в Фортране не надо писать ничего. Это иллюстрирует пример для предыдущего пункта, где в переменную rank функция/подпрограмма записывает номер вызвавшей ее задачи. Там, где MPI требуется знать местонахождение в памяти таких данных, которые по ссылке не передашь (например, при конструировании пользовательских типов), используется подпрограмма MPI_ADDRESS. В Фортране нет структур. Там, где в Си для описания данных в MPI применяется структура, в Фортране применяется целочисленный массив, размер и номера ячеек которого описаны символическими константами: В Си пишем: MPI_Status status; /* переменная типа структура */ MPI_Probe( MPI_ANY_SOURCE, MPI_ANY_TAG, MPI_COMM_WORLD, &status ); /* теперь проверяем поля заполненной структуры */ if( status.MPI_SOURCE == 1 ) { ... } if( status.MPI_TAG == tagMsg1 ) { ... } В Фортране, соответственно: INTEGER status(MPI_STATUS_SIZE) CALL MPI_PROBE( MPI_ANY_SOURCE,MPI_ANY_TAG,MPI_COMM_WORLD, status ) IF( status(MPI_SOURCE) .EQ. 1 ) .... IF( status(MPI_TAG) .EQ. tagMsg1 ) ...ну и так далее... Грубо говоря: имя типа-структуры переходит в размер массива, дополняясь суффиксом "_SIZE", а имена полей переходят в индексы ячеек в этом целочисленном массиве. Для компиляции и компоновки вместо команды 'mpicc' используются команды 'mpif77' или 'mpif90' для Фортрана-77 и Фортрана-90 соответственно. Это скрипты, которые после соответствующей настройки окружения (пути к библиотекам и т.д.) вызывают стандартные компиляторы Фортрана. Вот пример на Фортране, взятый из MPI-UX. Надеюсь, Convex простит мне этот маленький плагиат. Существует несколько функций, которые используются в любом, даже самом коротком приложении MPI. Занимаются они не столько собственно передачей данных, сколько ее обеспечением: Инициализация библиотеки. Одна из первых инструкций в функции main (главной функции приложения): MPI_Init( &argc, &argv ); Она получает адреса аргументов, стандартно получаемых самой main от операционной системы и хранящих параметры командной строки. В конец командной строки программы MPI-загрузчик mpirun добавляет ряд информационных параметров, которые требуются MPI_Init. Это показывается в примере 0. Аварийное закрытие библиотеки. Вызывается, если пользовательская программа завершается по причине ошибок времени выполнения, связанных с MPI: MPI_Abort( описатель области связи, код ошибки MPI ); Вызов MPI_Abort из любой задачи принудительно завершает работу ВСЕХ задач, подсоединенных к заданной области связи. Если указан описатель MPI_COMM_WORLD, будет завершено все приложение (все его задачи) целиком, что, по-видимому, и является наиболее правильным решением. Используйте код ошибки MPI_ERR_OTHER, если не знаете, как охарактеризовать ошибку в классификации MPI. Нормальное закрытие библиотеки: MPI_Finalize(); Настоятельно рекомендуется не забывать вписывать эту инструкцию перед возвращением из программы, то есть: перед вызовом стандартной функции Си exit ; перед каждым после MPI_Init оператором return в функции main ; если функции main назначен тип void, и она не заканчивается оператором return, то MPI_Finalize() следует поставить в конец main. Две информационных функции: сообщают размер группы (то есть общее количество задач, подсоединенных к ее области связи) и порядковый номер вызывающей задачи: int size, rank; MPI_Comm_size( MPI_COMM_WORLD, &size ); MPI_Comm_rank( MPI_COMM_WORLD, &rank ); Использование MPI_Init, MPI_Finalize, MPI_Comm_size и MPI_Comm_rank демонстрирует пример 0. Использование MPI_Abort будет показано далее, в примере 1. Это самый простой тип связи между задачами: одна ветвь вызывает функцию передачи данных, а другая - функцию приема. В MPI это выглядит, например, так: Задача 1 передает: int buf[10]; MPI_Send( buf, 5, MPI_INT, 1, 0, MPI_COMM_WORLD ); Задача 2 принимает: int buf[10]; MPI_Status status; MPI_Recv( buf, 10, MPI_INT, 0, 0, MPI_COMM_WORLD, &status ); Аргументы функций: Адрес буфера, из которого в задаче 1 берутся, а в задаче 2 помещаются данные. Помните, что наборы данных у каждой задачи свои, поэтому, например, используя одно и то же имя массива в нескольких задачах, Вы указываете не одну и ту же область памяти, а разные, никак друг с другом не связанные. Размер буфера. Задается не в байтах, а в количестве ячеек. Для MPI_Send указывает, сколько ячеек требуется передать (в примере передаются 5 чисел). В MPI_Recv означает максимальную емкость приемного буфера. Если фактическая длина пришедшего сообщения меньше - последние ячейки буфера останутся нетронутыми, если больше - произойдет ошибка времени выполнения. Тип ячейки буфера. MPI_Send и MPI_Recv оперируют массивами однотипных данных. Для описания базовых типов Си в MPI определены константы MPI_INT, MPI_CHAR, MPI_DOUBLE и так далее, имеющие тип MPI_Datatype. Их названия образуются префиксом "MPI_" и именем соответствующего типа (int, char, double, ...), записанным заглавными буквами. Пользователь может "регистрировать" в MPI свои собственные типы данных, например, структуры, после чего MPI сможет обрабатывать их наравне с базовыми. Процесс регистрации описывается в главе "Типы данных". Номер задачи, с которой происходит обмен данными. Все задачи внутри созданной MPI группы автоматически нумеруются от 0 до (размер группы-1). В примере задача 0 передает задаче 1, задача 1 принимает от задачи 0. Идентификатор сообщения. Это целое число от 0 до 32767, которое пользователь выбирает сам. Оно служит той же цели, что и, например, расширение файла - задача-приемник: по идентификатору определяет смысл принятой информации ; сообщения, пришедшие в неизвестном порядке, может извлекать из общего входного потока в нужном алгоритму порядке. Хорошим тоном является обозначение идентификаторов символьными именами посредством операторов "#define" или "const int". Описатель области связи (коммуникатор). Обязан быть одинаковым для MPI_Send и MPI_Recv. Статус завершения приема. Содержит информацию о принятом сообщении: его идентификатор, номер задачи-передатчика, код завершения и количество фактически пришедших данных. С одной стороны, мы передаем в MPI_Recv номер задачи, от которой ждем сообщение, и его идентификатор; а с другой - получаем их от MPI в структуре status? Это сделано потому, что MPI_Recv может быть вызвана с аргументами-джокерами ("принимай что угодно/от кого угодно"), и после такого приема данных программа узнает фактические номер/идентификатор, читая поля MPI_SOURCE и MPI_TAG из структуры status. Поле MPI_ERROR, как правило, проверять необязательно - обработчик ошибок, устанавливаемый MPI по умолчанию, в случае сбоя завершит выполнение программы ДО возврата из MPI_Recv. Таким образом, после возврата из MPI_Recv поле status.MPI_ERROR может быть равно только 0 (или, если угодно, MPI_SUCCESS); Тип MPI_Status не содержит поля, в которое записывалась бы фактическая длина пришедшего сообщения. Длину можно узнать так: MPI_Status status; int count; MPI_Recv( ... , MPI_INT, ... , &status ); MPI_Get_count( &status, MPI_INT, &count ); /* ... теперь count содержит количество принятых ячеек */ Обратите внимание, что аргумент-описатель типа у MPI_Recv и MPI_Get_count должен быть одинаковым, иначе, в зависимости от реализации: в count вернется неверное значение; или произойдет ошибка времени выполнения. Многоточия здесь означают, что последние 4 параметра
у MPI_Recv такие же, как и у предшествующей им MPI_Probe.
В примере 2 используются два джокера: MPI_ANY_SOURCE для номера задачи-отправителя ("принимай от кого угодно") и MPI_ANY_TAG для идентификатора получаемого сообщения ("принимай что угодно"). MPI резервирует для них какие-то отрицательные целые числа, в то время как реальные идентификаторы задач и сообщений лежат всегда в диапазоне от 0 до 32767. Пользоваться джокерами следует с осторожностью, потому что по ошибке таким вызовом MPI_Recv может быть захвачено сообщение, которое должно приниматься в другой части задачи-получателя. Если логика программы достаточно сложна, использовать джокеры можно ТОЛЬКО в функциях MPI_Probe и MPI_Iprobe, чтобы перед фактическим приемом узнать тип и количество данных в поступившем сообщении (вообще-то, можно принимать, и не зная количества - был бы приемный буфер достаточно вместительным, но тип для MPI_Recv надо указывать явно - а он может быть разным в сообщениях с разными идентификаторами). Достоинство джокеров: приходящие сообщения извлекаются по мере поступления, а не по мере вызова MPI_Recv с нужными идентификаторами задач/сообщений. Это экономит память и увеличивает скорость работы. Посылка данных и получение подтверждения: MPI_Send(..., anyRank ,...); /* Посылаем данные */ MPI_Recv(..., anyRank ,...); /* Принимаем подтверждение */ Ситуация настолько распространенная, что в MPI специально введены две функции, осуществляющие одновременно посылку одних данных и прием других. Первая из них - MPI_Sendrecv. Ее прототип содержит 12 параметров: первые 5 параметров такие же, как у MPI_Send, остальные 7 параметров такие же как у MPI_Recv. Один ее вызов проделывает те же действия, для которых в первом фрагменте требуется блок IF-ELSE с четырьмя вызовами. Следует учесть, что: и прием, и передача используют один и тот же коммуникатор ; порядок приема и передачи данных MPI_Sendrecv выбирает автоматически; гарантируется, что автоматический выбор не приведет к "клинчу" ; MPI_Sendrecv совместима с MPI_Send и MPI_Recv, т.е может "общаться" с ними. MPI_Sendrecv_replace помимо общего коммуникатора использует еще и общий для приема-передачи буфер. Не очень удобно, что параметр count получает двойное толкование: это и количество отправляемых данных, и предельная емкость входного буфера. Показания к применению: принимаемые данные должны быть заведомо НЕ ДЛИННЕЕ отправляемых ; принимаемые и отправляемые данные должны иметь одинаковый тип ; отправляемые данные затираются принимаемыми. MPI_Sendrecv_replace так же гарантированно не вызывает клинча. Что такое клинч? Дальше следует краткая иллюстрация этой ошибки, очень распространенной там, где для пересылок используется разделяемая память. Вариант 1: -- Ветвь 1 -- -- Ветвь 2 -- Recv( из ветви 2 ) Recv( из ветви 1 ) Send( в ветвь 2 ) Send( в ветвь 1 ) Вариант 1 вызовет клинч, какой бы инструментарий не использовался: функция приема не вернет управления до тех пор, пока не получит данные; поэтому функция передачи не может приступить к отправке данных; поэтому функция приема... и так до самого SIG_KILL;) Вариант 2: -- Ветвь 1 -- -- Ветвь 2 -- Send( в ветвь 2 ) Send( в ветвь 1 ) Recv( из ветви 2 ) Recv( из ветви 1 ) Вариант 2 вызовет клинч, если функция передачи возвращает управление только после того, как данные попали в пользовательский буфер на приемной стороне. Скорее всего, именно так и возьмется реализовывать передачу через разделяемую память/семафоры программист-проблемщик. Однако при использовании MPI зависания во втором варианте не произойдет! MPI_Send, если на приемной стороне нет готовности (не вызван MPI_Recv), не станет ее дожидаться, а положит данные во временный буфер и вернет управление программе НЕМЕДЛЕННО. Когда MPI_Recv будет вызван, данные он получит не из пользовательского буфера напрямую, а из промежуточного системного. Буферизация - дело громоздкое - может быть, и не всегда сильно экономит время (особенно на SMP-машинах), зато повышает надежность: делает программу более устойчивой к ошибкам программиста. MPI_Sendrecv и MPI_Sendrecv_replace также делают программу более устойчивой: с их использованием программист лишается возможности перепутать варианты 1 и 2. Действительно, зачем? Стандартные функции пересылки данных, например, memcpy, прекрасно обходятся без подобной информации - им требуется знать только размер в байтах. Вместо одного такого аргумента функции MPI получают два: количество элементов некоторого типа и символический описатель указанного типа (MPI_INT, и т.д.). Причин тому несколько: Пользователю MPI позволяет описывать свои собственные типы данных, которые располагаются в памяти не непрерывно, а с разрывами, или наоборот, с "налезаниями" друг на друга. Переменная такого типа характеризуется не только размером, и эти характеристики MPI хранит в описателе типа. В учебнике по MPI приведен пример конструирования сложного типового шаблона путем последовательного создания нескольких пользовательских типов. Затем производится транспонирование матрицы через вызов MPI_Sendrecv, где для передачи в качестве описателя используется указанный шаблон. Приложение MPI может работать на гетерогенном вычислительном комплексе (коллективе ЭВМ с разной архитектурой). Одни и те же типы данных на разных машинах могут иметь разное представление, например: на плавающую арифметику существует 3 разных стандарта (IEEE,IBM,Cray); тип char в терминальных приложениях Windows представлен альтернативной кодировкой ГОСТ, а в Юниксе - кодировкой KOI-8r ; ориентация байтов в многобайтовых числах на ЭВМ с процессорами Intel отличается от общепринятой (у Intel - младший байт занимает младший адрес, у всех остальных - наоборот). Если приложение работает в гетерогенной сети, через сеть задачи обмениваются данными в формате XDR (eXternal Data Representation), принятом в Internet. Перед отправкой и после приема данных задача конвертирует их в/из формата XDR. Естественно, при этом MPI должен знать не просто количество передаваемых байт, но и тип содержимого. Обязательным требованием к MPI была поддержка языка Фортран в силу его инерционной популярности. Фортрановский тип CHARACTER требует особого обращения, поскольку переменная такого типа содержит не собственно текст, а адрес текста и его длину. Функция MPI, получив адрес переменной, должна извлечь из нее адрес текста и копировать сам текст. Это и произойдет, если в поле аргумента-описателя типа стоит MPI_CHARACTER. Ошибка в указании типа приведет: при отправке - к копированию служебных данных вместо текста, при приеме - к записи текста на место служебных данных. И то, и другое приводит к ошибкам времени выполнения. Такие часто используемые в Си типы данных, как структуры, могут содержать в себе некоторое пустое пространство, чтобы все поля в переменной такого типа размещались по адресам, кратным некоторому четному числу (часто 2, 4 или 8) - это ускоряет обращение к ним. Причины тому чисто аппаратные. Выравнивание данных настраивается ключами компилятора. Разные задачи одного и того же приложения, выполняющиеся на одной и той же машине (даже на одном и том же процессоре), могут быть построены с разным выравниванием, и типы с одинаковым текстовым описанием будут иметь разное двоичное представление. MPI будет вынужден позаботиться о правильном преобразовании. Например, переменные такого типа могут занимать 9 или 16 байт: typedef struct { char c; double d; } CharDouble; Создание собственных типов описывается дальше по тексту. Все вышеперечисленные функции приема-передачи оперируют массивами - непрерывными последовательностями однотипных данных. Однако программисту может потребоваться пересылать данные, которые либо разнотипны, либо не-непрерывно расположены в памяти, либо то и другое сразу (особо тяжелый случай). Вариант 1 (настолько тупой и медленный, что никогда не применяется). Каждый элемент в разнотипном наборе данных посылается отдельно: #define msgTag 10 struct { int i; float f[4]; char c[8]; } s; MPI_Send(&s.i, 1, MPI_INT, targetRank, msgTag, MPI_COMM_WORLD ); MPI_Send( s.f, 4, MPI_FLOAT, targetRank, msgTag+1, MPI_COMM_WORLD ); MPI_Send( s.c, 8, MPI_CHAR, targetRank, msgTag+2, MPI_COMM_WORLD ); ... и на приемной стороне столько же раз вызывается MPI_Recv. Вариант 2 ("классический"). Функция приема/передачи вызывается один раз, но до/после нее многократно вызывается функция упаковки/распаковки: Передача ======== int bufPos = 0; char tempBuf[ sizeof(s) ]; MPI_Pack(&s.i, 1, MPI_INT, tempBuf, sizeof(tempBuf), &bufPos, MPI_COMM_WORLD ); MPI_Pack( s.f, 4, MPI_FLOAT, tempBuf, sizeof(tempBuf), &bufPos, MPI_COMM_WORLD ); MPI_Pack( s.c, 8, MPI_CHAR, tempBuf, sizeof(tempBuf), &bufPos, MPI_COMM_WORLD ); MPI_Send( tempBuf, bufPos, MPI_BYTE, targetRank, msgTag, MPI_COMM_WORLD ); Прием ===== int bufPos = 0; char tempBuf[ sizeof(s) ]; MPI_Recv( tempBuf, sizeof(tempBuf), MPI_BYTE, sourceRank, msgTag, MPI_COMM_WORLD, &status ); MPI_Unpack( tempBuf, sizeof(tempBuf), &bufPos,&s.i, 1, MPI_INT, MPI_COMM_WORLD); MPI_Unpack( tempBuf, sizeof(tempBuf), &bufPos, s.f, 4, MPI_FLOAT,MPI_COMM_WORLD); MPI_Unpack( tempBuf, sizeof(tempBuf), &bufPos, s.c, 8, MPI_CHAR, MPI_COMM_WORLD); Вариант 2 обозван здесь классическим, потому что пришел в MPI из PVM, где предлагается в качестве единственного. Он прост в понимании, за что его все и любят. Замечания по применению: MPI_BYTE - это особый описатель типа; который не описывает тип данных для конкретного языка программирования (в Си он ближе всего к unsigned char). Использование MPI_BYTE означает, что содержимое соответствующего массива НЕ ДОЛЖНО подвергаться НИКАКИМ преобразованиям - и на приемной, и на передающей стороне массив будет иметь одну и ту же длину и одинаковое ДВОИЧНОЕ представление. Зачем функциям упаковки/распаковки требуется описатель области связи? Описатель, помимо прочего, несет в себе информацию о распределении подсоединенных к области связи задач по процессорам и компьютерам. Если процессоры одинаковые, или задачи выполняются на одном и том же процессоре, данные просто копируются, иначе происходит их преобразование в/из формата XDR (eXternal Data Representation - разработан фирмой Sun Microsystems, используется в Интернете для взаимодействия разнотипных машин). Учтите, что коммуникаторы у функций упаковки/распаковки и у соответствующей функции передачи/приема должны совпадать, иначе произойдет ошибка ; По мере того как во временный буфер помещаются данные или извлекаются оттуда, MPI сохраняет текущую позицию в переменной, которая в приведенном примере названа bufPos. Не забудьте проинициализировать ее нулем перед тем как начинать упаковывать/извлекать. Естественно, что передается она не по значению, а по ссылке. Первый же аргумент - "адрес временного буфера" - во всех вызовах остается неизменным ; В примере НЕКОРРЕКТНО выбран размер временного буфера: использовалось НЕВЕРНОЕ предположение, что в XDR-формате данные займут места не больше, чем в формате используемого ветвью процессора; или что XDR-преобразование заведомо не будет применено. Правильным же решением будет для определения необходимого размера временного буфера на приемной стороне использовать связку MPI_Probe / MPI_Get_count / MPI_Recv, а на передающей - функцию MPI_Pack_size: int bufSize = 0; void *tempBuf; MPI_Pack_size( 1, MPI_INT, MPI_COMM_WORLD, &bufSize ); MPI_Pack_size( 4, MPI_FLOAT, MPI_COMM_WORLD, &bufSize ); MPI_Pack_size( 8, MPI_CHAR, MPI_COMM_WORLD, &bufSize ); tempBuf = malloc( bufSize ); /* ... теперь можем упаковывать, не опасаясь переполнения */ Однако и вариант 2 замедляет работу: по сравнению с единственным вызовом memcpy на SMP-машине или одном процессоре занудная упаковка/распаковка - дело весьма небыстрое! Вариант 3 ("жульнический"). Если есть уверенность, что одни и те же типы данных в обеих ветвях приложения имеют одинаковое двоичное представление, то: Передача: MPI_Send( &s, sizeof(s), MPI_BYTE, ... ); Прием: MPI_Recv( &s, sizeof(s), MPI_BYTE, ... ); А все, чем чреват такой подход, подробно перечислено в предыдущей главе. Общие правила:
пользовательские описатели типов создаются на базе
созданных ранее пользовательских описателей,
и на базе встроенных описателей;
встроенные описатели имеются для ВСЕХ стандартных типов Си:
MPI_INT, MPI_CHAR, MPI_LONG, MPI_FLOAT, MPI_DOUBLE и так далее;
тип MPI_BYTE служит для передачи двоичных данных;
после конструирования, но перед использованием описатель должен
быть зарегистрирован функцией MPI_Type_commit;
после использования описатель должен быть очищен функцией
MPI_Type_free; При первом прочтении можете пропустить описание конструкторов MPI_Type_vector и MPI_Type_indexed, сразу перейдя к MPI_Type_struct. MPI_Type_contiguous : самый простой конструктор типа, он создает описание массива. В следующем примере оба вызова MPI_Send делают одно и то же. int a[16]; MPI_Datatype intArray16; MPI_Type_contiguous( 16, MPI_INT, &intArray16 ); MPI_Type_commit( &intArray16 ); MPI_Send( a, 16, MPI_INT, ... ); MPI_Send( a, 1, intArray16, ... ); MPI_Type_free( &intArray16 ); Функция MPI_Type_count вернет количество ячеек в переменной составного типа: после MPI_Type_count( intArray16, &count ) значение count станет равным 16. Как правило, прямой необходимости использовать эти функции нет, и тем не менее. MPI_Type_vector : служит для описания множества однотипных равноудаленных в памяти массивов данных. Позволяет весьма изощренные манипуляции с данными. Он создает описание для не-непрерывной последовательности элементов, которые, в свою очередь, составлены из непрерывной последовательности ячеек базового (уже определенного) типа: MPI_Type_vector( int count, /* количество элементов в новом типе */ int blocklength, /* количество ячеек базового типа в одном элементе */ int stride, /* расстояние между НАЧАЛАМИ эл-тов, в числе ячеек */ MPI_Datatype oldtype, /* описатель базового типа, т.е. типа ячейки */ MPI_Datatype &newtype /* cсылка на новый описатель */ ); То есть: новый тип состоит из элементов; каждый элемент является массивом ячеек базового типа; расстояние в количестве ячеек задается между НАЧАЛАМИ элементов, а НЕ между КОНЦОМ предыдущего и НАЧАЛОМ следующего; таким образом, элементы могут и располагаться с разрывами, и "налезать" друг на друга. Функция MPI_Type_hvector полностью ей аналогична, за одним исключением: расстояние между элементами задается не в количестве ячеек базового типа, а в байтах. В примере показывается применение обеих этих функций. MPI_Type_indexed : расширение "векторного" описателя; длины массивов и расстояния между ними теперь не фиксированы, а у каждого массива свои. Соответственно, аргументы #2 и #3 здесь - не переменные, а массивы: массив длин и массив позиций. Пример: создание шаблона для выделения верхней правой части матрицы. #define SIZE 100 float a[ SIZE ][ SIZE ]; int pos[ SIZE ] int len[ SIZE ]; MPI_Datatype upper; ... for( i=0; i<SIZE; i++ ) { /* xxxxxx */ pos[i] = SIZE*i + i; /* .xxxxx */ len[i] = SIZE - i; /* ..xxxx */ } /* ...xxx */ MPI_Type_indexed( SIZE, /* количество массивов в переменной нового типа */ len, /* длины этих массивов */ pos, /* их позиции от начала переменной, */ /* отсчитываемые в количестве ячеек */ MPI_FLOAT, /* тип ячейки массива */ &upper ); MPI_Type_commit( &upper ); /* Поступающий поток чисел типа 'float' будет * размещен в верхней правой части матрицы 'a' */ MPI_Recv( a, 1, upper, .... ); Аналогично работает функция MPI_Type_hindexed, но позиции массивов от начала переменной задаются не в количестве ячеек базового типа, а в байтах. MPI_Type_struct : создает описатель структуры. Наверняка будет использоваться Вами чаще всего. MPI_Type_struct( count, /* количество полей */ int *len, /* массив с длинами полей */ /* (на тот случай, если это массивы) */ MPI_Aint *pos, /* массив со смещениями полей */ /* от начала структуры, в байтах */ MPI_Datatype *types, /* массив с описателями типов полей */ MPI_Datatype *newtype ); /* ссылка на создаваемый тип */ Здесь используется тип MPI_Aint: это просто скалярный тип, переменная которого имеет одинаковый с указателем размер. Введен он исключительно для единообразия с Фортраном, в котором нет типа "указатель". По этой же причине имеется и функция MPI_Address: в Си она не нужна (используются оператор вычисления адреса & и основанный на нем макрос offsetof() ); а в Фортране оператора вычисления адреса нет, и используется MPI_Address. Пример создания описателя типа "структура": #include <stddef.h> /* подключаем макрос 'offsetof()' */ typedef struct { int i; double d[3]; long l[8]; char c; } AnyStruct; AnyStruct st; MPI_Datatype anyStructType; int len[5] = { 1, 3, 8, 1, 1 }; MPI_Aint pos[5] = { offsetof(AnyStruct,i), offsetof(AnyStruct,d), offsetof(AnyStruct,l), offsetof(AnyStruct,c), sizeof(AnyStruct) }; MPI_Datatype typ[5] = { MPI_INT,MPI_DOUBLE,MPI_LONG,MPI_CHAR,MPI_UB }; MPI_Type_struct( 5, len, pos, typ, &anyStructType ); MPI_Type_commit( &anyStructType ); /* подготовка закончена */ MPI_Send( st, 1, anyStructType, ... ); Обратите внимание: структура в примере содержит 4 поля, а массивы
для ее описания состоят из 5 элементов. Сделано это потому, что MPI
должен знать не только смещения полей, но и размер всей структуры.
Для этого и служит псевдотип MPI_UB ("upper bound").
MPI_Type_extent и MPI_Type_size : важные информационные функции. Их характеристики удобно представить в виде таблицы: Вид данных sizeof MPI_Type_extent MPI_type_size стандартный тип массив структура Описатель типа MPI с перекрытиями и разрывами
Можно сказать, что MPI_Type_extent сообщает, сколько места
переменная типа занимает при хранении в памяти, а MPI_Type_size -
какой МИНИМАЛЬНЫЙ размер она будет иметь при передаче (ужатая
за счет неиспользуемого пространства). MPI_Type_size отсутствует
в примере, потому что закомментирована
в WinMPICH - в binding.h сказано, что стандарт на нее сформулирован
неправильно.
Под термином "коллективные" в MPI подразумеваются три группы функций: функции коллективного обмена данными; точки синхронизации, или барьеры; функции поддержки распределенных операций. Коллективная функция одним из аргументов получает описатель области связи (коммуникатор). Вызов коллективной функции является корректным, только если произведен из всех процессов-абонентов соответствующей области связи, и именно с этим коммуникатором в качестве аргумента (хотя для одной области связи может иметься несколько коммуникаторов, подставлять их вместо друг друга нельзя). В этом и заключается коллективность: либо функция вызывается всем коллективом процессов, либо никем; третьего не дано. Как поступить, если требуется ограничить область действия для коллективной функции только частью присоединенных к коммуникатору задач, или наоборот - расширить область действия? Создавайте временную группу/область связи/коммуникатор на базе существующих, как это показано в разделе про коммуникаторы. Этим занимается всего одна функция: int MPI_Barrier( MPI_Comm comm ); MPI_Barrier останавливает выполнение вызвавшей ее задачи до тех пор, пока не будет вызвана изо всех остальных задач, подсоединенных к указываемому коммуникатору. Гарантирует, что к выполнению следующей за MPI_Barrier инструкции каждая задача приступит одновременно с остальными. Это единственная в MPI функция, вызовами которой гарантированно синхронизируется во времени выполнение различных ветвей! Некоторые другие коллективные функции в зависимости от реализации могут обладать, а могут и не обладать свойством одновременно возвращать управление всем ветвям; но для них это свойство является побочным и необязательным - если Вам нужна синхронность, используйте только MPI_Barrier. Когда может потребоваться синхронизация? В примерах синхронизация используется перед аварийным завершением: там ветвь 0 рапортует об ошибке, и чтобы ни одна из оставшихся ветвей вызовом MPI_Abort не завершила нулевую досрочно-принудительно, перед MPI_Abort поставлен барьер. Это утверждение непроверено, но: АЛГОРИТМИЧЕСКОЙ необходимости в барьерах, как представляется, нет. Параллельный алгоритм для своего описания требует по сравнению с алгоритмом классическим всего лишь двух дополнительных операций - приема и передачи из ветви в ветвь. Точки синхронизации несут чисто технологическую нагрузку вроде той, что описана в предыдущем абзаце. Иногда случается, что ошибочно работающая программа перестает врать, если ее исходный текст хорошенько нашпиговать барьерами. Как правило, барьерами нивелируются ошибки под кодовым названием "гонки" (в англоязычной литературе используется термин "backmasking"; я НЕ уверен, что под этими терминами понимается строго одно и то же). Однако программа начнет работать медленнее, например: ветвь Без барьеров: 0 xxxx....xxxxxxxxxxxxxxxxxxxx 1 xxxxxxxxxxxx....xxxxxxxxxxxx 2 xxxxxxxxxxxxxxxxxxxxxx....xx Воткнем барьеры: 0 xxxx....xx(xxxxxxxx(||||xxxxxxxx(||xx 1 xxxxxx(||||x....xxxxxxx(xxxxxxxx(||xx 2 xxxxxx(||||xxxxxxxx(||||..xxxxxxxx(xx ----------------------------- > Время Обозначения: x нормальное выполнение . ветвь простаивает - процессорное время отдано под другие цели ( вызван MPI_Barrier | MPI_Barrier ждет своего вызова в остальных ветвях Так что "задавить" ошибку барьерами хорошо только в качестве временного решения на период отладки. MPI_Bcast рассылает содержимое буфера из задачи, имеющей в указанной области связи номер root, во все остальные: MPI_Bcast( buf, count, dataType, rootRank, communicator ); Она эквивалентна по результату (но не по внутреннему устройству) следующему фрагменту: MPI_Comm_size( communicator, &commSize ); MPI_Comm_rank( communicator, &myRank ); if( myRank == rootRank ) for( i=0; i<commSize; i++ ) MPI_Send( buf, count, dataType, i, tempMsgTag, communicator ); MPI_Recv( buf, count, dataType, rootRank, tempMsgTag, communicator, &status ); MPI_Gather ("совок") собирает в приемный буфер задачи root передающие буфера остальных задач. Ее аналог: MPI_Send( sendBuf, sendCount, sendType, rootRank, ... ); if( myRank == rootRank ) { MPI_Type_extent( recvType, &elemSize ); for( i=0; i<commSize; i++ ) MPI_Recv( ((char*))recvBuf) + (i * recvCount * elemSize), recvCount, recvType, i, ... ); } Заметьте, что а) recvType и sendType могут быть разные и, таким образом, будут задавать разную интерпретацию данных на приемной и передающей стороне; б) задача-приемник также отправляет данные в свой приемный буфер. Векторный вариант "совка" - MPI_Gatherv - позволяет задавать РАЗНОЕ количество отправляемых данных в разных задачах-отправителях. Соответственно, на приемной стороне задается массив позиций в приемном буфере, по которым следует размещать поступающие данные, и максимальные длины порций данных от всех задач. Оба массива содержат позиции/длины НЕ в байтах, а в количестве ячеек типа recvCount. Ее аналог: MPI_Send( sendBuf, sendCount, sendType, rootRank, ... ); if( myRank == rootRank ) { MPI_Type_extent( recvType, &elemSize ); for( i=0; i<commSize; i++ ) MPI_Recv( ((char*))recvBuf) + displs[i] * recvCounts[i] * elemSize, recvCounts[i], recvType, i, ... ); } MPI_Scatter ("разбрызгиватель") : выполняет обратную "совку" операцию - части передающего буфера из задачи root распределяются по приемным буферам всех задач. Ее аналог: if( myRank == rootRank ) { MPI_Type_extent( recvType, &elemSize ); for( i=0; i<commSize; i++ ) MPI_Send( ((char*)sendBuf) + i*sendCount*elemSize, sendCount, sendType, i, ... ); } MPI_Recv( recvBuf, recvCount, recvType, rootRank, ... ); И ее векторный вариант - MPI_Scatterv, рассылающая части неодинаковой длины в приемные буфера неодинаковой длины. MPI_Allgather аналогична MPI_Gather, но прием осуществляется не в одной задаче, а во ВСЕХ: каждая имеет специфическое содержимое в передающем буфере, и все получают одинаковое содержимое в буфере приемном. Как и в MPI_Gather, приемный буфер последовательно заполняется данными изо всех передающих. Вариант с неодинаковым количеством данных называется MPI_Allgatherv. MPI_Alltoall : каждый процесс нарезает передающий буфер на куски и рассылает куски остальным процессам; каждый процесс получает куски от всех остальных и поочередно размещает их приемном буфере. Это "совок" и "разбрызгиватель" в одном флаконе. Векторный вариант называется MPI_Alltoallv. Пример использования коллективных функций передачи данных здесь. В учебнике, изданном MIT Press, есть хорошая СХЕМА для всех перечисленных в этом разделе функций. Понять, что как работает, по ней нелегко, зато вспоминать, если однажды уже разобрался, удобно. Помните, что коллективные функции несовместимы с "точка-точка": недопустимым, например, является вызов в одной из принимающих широковещательное сообщение задач MPI_Recv вместо MPI_Bcast. |