Справочник от Автор24
Поделись лекцией за скидку на Автор24

Разработка программ с использованием MPI для параллельных и распределённых систем. Часть 2

  • 👀 247 просмотров
  • 📌 196 загрузок
Выбери формат для чтения
Статья: Разработка программ с использованием MPI для параллельных и распределённых систем. Часть 2
Найди решение своей задачи среди 1 000 000 ответов
Загружаем конспект в формате pdf
Это займет всего пару минут! А пока ты можешь прочитать работу в формате Word 👇
Конспект лекции по дисциплине «Разработка программ с использованием MPI для параллельных и распределённых систем. Часть 2» pdf
ЛЕКЦИЯ 5 РАЗРАБОТКА ПРОГРАММ С ИСПОЛЬЗОВАНИЕМ MPI ДЛЯ ПАРАЛЛЕЛЬНЫХ И РАСПРЕДЕЛЁННЫХ СИСТЕМ. (Часть 2) Стандарт MPI По мере распространения многопроцессорных систем появилась острая необходимость в выработке единого подхода к их программированию. Такая проблема уже стояла в своѐ время, когда создавались средства последовательного программирования. Она была разрешена путѐм создания универсальных языков программирования высокого уровня и систем программир ования основанных на них. Похожий подход стали применять и для параллельного программирования. Чтобы его реализовать, необходима разработка соответствующих стандартов. Одним из таких стандартов является MPI. На основе этого стандарта создаются средства параллельного программирования для модели распределѐнной памяти. Рассмотрим стандарт MPI подробнее. В начале 90-х годов было принято решение создать единый, стандарт на систему параллельного программирования. Для этого в 1992, инициативной группой состоящей из ряда участников (научных коллективов и фирм производителей), была создана организация MPIForum, которая начала разработку стандарта средств параллельного программирования. Этот стандарт получил название Message Passing Interface или сокращѐнно - MPI. На сегодняшний день, MPI является наиболее популярным стандартом для программирования кластерных систем. Дадим его определение. MPI (Message Passing Interface – интерфейс передачи сообщений) – стандарт на программный инструментарий для обеспечения связи между ветвями параллельной программы. Стандарт MPI фиксирует интерфейс, который должны соблюдать как система программирования MPI на каждой вычислительной системе, так и пользователь (программист) при создании своих программ. C точки зрения программистов, стандарт MPI представляет собой спецификацию библиотеки подпрограмм взаимодействия параллельных процессов. На основе стандарта участниками проекта MPIForum создаются базовые реализации. Используя эти базовые реализации, под конкретные аппаратные и программные платформы создаются конечные реализации – конкретные, готовые к использованию программные системы. Создавая MPI, разработчики ставили перед собой следующие цели: 1) Разработать программный интерфейс взаимодействия ветвей параллельной программы. 2) Достичь эффективности коммуникаций при использовании программного интерфейса. Это должно быть достижимо как эффективным функционированием самого интерфейса, так и максимально – широким набором предоставляемого программисту инструментария 1 3) 4) 5) 6) 7) для лучшего выбора. Обеспечить возможность выполнения параллельной программы в разнородной среде. Это относится, с одной стороны, к возможности переноса программы с одной среды на другую. С другой стороны, возможностью выполнения параллельной программы на т.н. гетерогенных – неоднородных вычислительных системах. В последнем случае одни ветви программы выполняются на подсистеме с одной платформой, а другие на подсистеме с другой платформой. Использование стилей языков Фортран 77 и Си для интерфейса. Чтобы воспользоваться предоставляемым интерфейсом инструментарием, разработчик параллельной программы обращается к нему путѐм вызова подпрограмм и инструкций в стиле своего языка программирования. Достижение надежных коммуникаций интерфейса (пользователь не должен бороться с ошибками в коммуникациях). Разработчику программы достаточно лишь грамотно воспользоваться предлагаемым инструментарием для пересылок. За достоверность и надѐжность пересылок отвечает сам интерфейс. Определить интерфейс, который бы не отличался от уже используемых (таких как PVM) и обеспечить расширение его для достижения гибкости. Определить интерфейс, который можно реализовать на многих существующих многопроцессорных вычислительных системах. Это необходимо для придания программному интерфейсу универсальности, а разрабатываемым под него программам – переносимости. Таким образом, с точки зрения использования, MPI – это: 1) Стандартизованная библиотека подпрограмм (в языках C и C++ – подпрограммы называются функциями). 2) Программное средство запуска и выполнения параллельных программ на вычислительной системе. 3) Программное средство взаимодействия потоков параллельной программы. В настоящее время создано несколько модификаций стандарта MPI (1, 2, 3 …). Возмѐм за основу популярный мире стандарт MPI-1.1. Дадим краткую характеристику программирования с использованием MPI. MPI предназначен для программирования в моделях с распределѐнной памятью SPMD и MPMD. Краткие характеристики этих моделей были даны выше. Большинство реализаций MPI предназначено для программирования в модели SPMD, поэтому именно еѐ мы взяли за основу в данном пособии. Параллельная программа создаѐтся на языках C, C++ или Fortran, при этом в текст программ(ы) вставляются вызовы подпрограмм (для C и C++ – 2 функций) библиотеки MPI. После компиляции, как правило, каждый поток параллельной программы запускается в рамках отдельного процесса опер ационной системы. Иногда, в рамках процесса запускается несколько потоков, но этот случай мы рассматривать не будем, поскольку здесь кроме MPI задействуются ещѐ другие средства многопотокового программирования в рамках процесса, такие как Posix, OpenMP и другие. В случае SPMD, для выполнения параллельной программы запускается несколько процессов с одинаковым программным кодом и одинаковым набором переменных, каждый из которых выполняет свой поток команд. Каждому процессу выделяется своя о бласть памяти, в том числе и для области данных. Каждый процесс назначается для выполнения на процессор. При этом, в многопроцессорной системе, в частности на кластере, как правило, на один процессор (процессорное ядро) назначается один процесс. Но иногда, если это необходимо и возможно, на процессор (ядро) можно назначить выполнение нескольких процессов в режиме разделения времени. В частности, это бывает необходимо, когда параллельная программа испытывается и отлаживается. Еѐ выполняют на обычной однопроцессорной машине, чтобы не занимать ресурсы многопроцессорной системы, занятой выполнением уже готовых программ. Поскольку, как уже было отмечено, как правило, при выполнении программы в стандарте MPI, каждый еѐ поток выполняется отдельным процессом, то в литературе, когда говорят о процессе MPI, подразумевают поток. То есть, понятие процесса считается синонимом потока. После запуска параллельной программы, каждому запущенному процессу присваивается свой идентификационный номер, который может быть им использован для выбора последовательности действий, которые должна выполнить поток параллельной программы. Параллельные процессы (потоки) независимо выполняют заданные им по программе вычисления, но в случае необходимости, взаимодействуют и передают данные друг другу путѐм вызова подпрограмм (для C и C++ – функций) библиотеки MPI. Библиотека подпрограмм MPI содержит около полутора сотен подпрограмм. Эти подпрограммы можно разделить на следующие основные категории: 1) подпрограммы инициализации и финализации работы с MPI; 2) подпрограммы двухточечного обмена данными. Двухточечным обменом называется передача данных между двумя процессами; 3) подпрограммы коллективного обмена данными. Коллективным называется такой обмен данными, в котором участвуют или все процессы параллельной программы или все процессы некоторой группы. Пример такой операции – широковещательная рассылка данных от одного процесса всем остальным; 4) подпрограммы по работе с группами процессов; 5) подпрограммы по работе с областями связи; 6) подпрограммы работы с типами и структурами данных; 3 7) подпрограммы работы с топологиями процессов; 8) подпрограммы измерения времени; 9) некоторые другие. Программирование в стандарте MPI На рис. 8 показана параллельная программа для нашего примера, разработанная в стандарте MPI. Программа создана в модели SPMD, а это означает, что все запущенные процессы будут выполнять одну и ту же программу. Каждый запущенный процесс будет выполнять один поток программы. То есть, фактически на рис. 8 представлена последовательная программа для каждого потока, но каждый поток будет выполнять еѐ по-своему, в зависимости от своего значения переменной rank, в которой будет содержаться номер процесса выполняющего поток (т.е. номер потока). Программа написана на языке C с использованием функций библиотеки MPI. Рассмотрим программу подробно и на еѐ примере разберѐм основные особенности программирования в стандарте MPI. Программа, приведѐнная на рис. 8 реализует разработанный ранее алгоритм, схема которого показана на рис. 7. Текст программы, преднамеренно лишѐн комментариев. Это сделано для того, чтобы не было лишних текстовых нагромождений, лишающих программу наглядности. Все компоненты текста программы касающиеся MPI, выделены. Библиотека функций MPI Библиотека функций MPI включается в программу путѐм объявления в программе заголовочного файла mpi.h оператором #include. Идентификаторы MPI В MPI имеется три вида идентификаторов, имена функций, имена типов данных и имена переопределѐнных констант. #include #include"fun.h" #include"mpi.h" void main(int argc,char **argv) { float A[100],B[100],C[100],D[100],E[100]; int i,size,rank; MPI_Status status; MPI_Init(&argc,&argv); MPI_Comm_size(MPI_COMM_WORLD,&size); MPI_Comm_rank(MPI_COMM_WORLD,&rank); if (size==4) { if(rank==0) 4 { fun_in(A,B,C,D,100); for(i=1;i<4;i++) { MPI_Send(&A[25*i],25,MPI_FLOAT,i,0,MPI_COMM_WORLD); MPI_Send(&B[25*i],25,MPI_FLOAT,i,1,MPI_COMM_WORLD); MPI_Send(&C[25*i],25,MPI_FLOAT,i,2,MPI_COMM_WORLD); } MPI_Scatter(D,25,MPI_FLOAT,D,25,MPI_FLOAT,0,MPI_COMM_WORLD); for (i=0;i<25;i++) E[i]=A[i]*B[i]+C[i]*D[i]; MPI_Gather(E,25,MPI_FLOAT,E,25,MPI_FLOAT,0,MPI_COMM_WORLD); fun_out(E,100); }else { MPI_Recv(A,25,MPI_FLOAT,0,0,MPI_COMM_WORLD,&status); MPI_Recv(B,25,MPI_FLOAT,0,1,MPI_COMM_WORLD,&status); MPI_Recv(C,25,MPI_FLOAT,0,2,MPI_COMM_WORLD,&status); MPI_Scatter(D,25,MPI_FLOAT,D,25,MPI_FLOAT,0,MPI_COMM_WORLD); for (i=0;i<25;i++) E[i]=A[i]*B[i]+C[i]*D[i]; MPI_Gather(E,25,MPI_FLOAT,E,25,MPI_FLOAT,0,MPI_COMM_WORLD); } } MPI_Finalize(); } Рис. 8. Пример программы для двух процессов Каждый идентификатор, независимо от вида, начинается с префикса MPI_, который в программе на языках C и C++ всегда пишется заглавными буквами. Имена функций MPI состоят или из двух частей, префикса и названия операции, или из трѐх частей, префикса, категории функции и названия операции. Например, имя функции MPI_Send состоит из двух частей, префикса MPI_ и названия операции Send (послать). Имя функции MPI_Comm_size состоит из трѐх частей, префикса MPI_, категории функции Comm_ (работа с областями связи) и названия операции size (получение размера области связи). В C и C++, первая часть имени функции всегда пишется заглавными буквами, вторая часть начинается с заглавной буквы, после которой следуют строчные, а третья часть всегда пишется строчными буквами. Имена типов данных начинаются с префикса, после которого пишется название типа. В нашей программе объявлена переменная status, которая имеет тип MPI_Status. 5 Имена переопределѐнных констант состоят из двух или трѐх частей, причем в программе написанной на языках C и C++ все части пишутся заглавными буквами. Имя переопределѐнной константы состоящее из двух частей, содержит префикс и название константы, а имя состоящее из трѐх ч астей, ещѐ содержит категорию константы. В нашей программе используются две переопределѐнные константы, MPI_FLOAT и MPI_COMM_WORLD. Инициализация MPI Любая программа, в которой будут использоваться функции MPI, должна выполнить инициализацию среды. Для этого, все запущенные процессы обязательно должны выполнить функцию MPI_Init. Функция может быть выполнена каждым процессом только один раз MPI_Init. До вызова этой функции, использовать другие функции библиотеки MPI (кроме одной, которую мы не рассматриваем) нельзя. Кроме инициализации среды MPI, функция MPI_Init рассылает всем процессам параметры командной строки через переменные argc и argv. Области связи в MPI программе Организация обменов данными между процессами в рамках простой параллельной программы не представляет особого труда. Другое дело если программа сложная, разрабатывается по частям и в течение длительного времени, и не одним, а группой разработчиков. В такой программе, множество обменов данными превращается в плохо согласованный запутанный клубок, в котором велика вероятность совершения различных ошибок. Чтобы избежать такой ситуации, все обмены данными в параллельной программе необходимо структурировать. Именно для этого в MPI введено понятие области связи. Область связи – это группа процессов программы, в которую они объединяются для совершения операций обмена данными между собой. Области связи могут создаваться, ликвидироваться, объединятся и разделятся в теч ении всего периода выполнения программы. Каждый процесс параллельной программы может одновременно входить в одну или несколько областей связи. Каждая область связи имеет свой размер (число входящих в неѐ процессов), а каждый процесс, входящий в область связи имеет в рамках неѐ свой индивидуальный номер. Все обмены данными в параллельной программе могут осуществляться только в рамках какой либо области связи. Для идентификации области связи в программе, используются специальные переменные или константы, которые называются – коммуникаторами. Изначально, при запуске параллельной программы на выполнение, все запущенные процессы объединяются в одну область связи, называемую мировой. Эта область связи продолжает существовать на протяжении всей работы программы. Она идентифицируется коммуникатором – переопределѐнной константой MPI_COMM_WORLD. 6 В нашей программе используются две функции для работы с областями связи. Вызвав первую из них, процесс получает размер области связи в пер еменную size, а вызвав другую – свой номер в этой области связи в переменную rank. Это функции: MPI_Comm_size(MPI_COMM_WORLD,&size); и MPI_Comm_rank(MPI_COMM_WORLD,&rank); В качестве идентификатора области связи, в функции подставлен коммуникатор MPI_COMM_WORLD. Передача данных в MPI Существует две разновидности передачи данных. Первая – это двухточечный обмен между двумя процессами, вторая – это коллективный обмен, в котором участвуют все процессы области связи. При двухточечной передаче данных, один процесс посылает данные, а другой их принимает. Нулевой процесс нашей программы (который соответственно выполняет нулевой поток) посылает элементы массивов A, B и C другим процессам используя функцию двухточечной посылки MPI_Send. Он это делает в цикле, поскольку должен осуществить посылку данных каждому из трѐх процессов индивидуально. Синтаксис функции посылки данных MPI_Send следующий: int MPI_Send(void *buf, int count, MPI_Datatype type, int source, int tag, MPI_Comm comm) где: buf – адрес начальной ячейки в памяти, где содержатся посылаемые данные. count – сколько посылается элементов типа type. type – тип посылаемых элементов (о типах данных в MPI см. ниже). source – номер процесса получателя данных. tag – идентификатор сообщения (тэг сообщения) comm – идентификатор (коммуникатор) области связи в рамках которой происходит передача. Для приѐма данных, процессы 1, 2 и 3 используют функцию двухточечтного приѐма данных MPI_Recv. Синтаксис этой функции похож на MPI_Send. int MPI_Recv(void *buf, int count, MPI_Datatype type, int dest, int tag, MPI_Comm comm., MPI_Status *status) где: buf – адрес начальной ячейки в памяти, куда будут записываться принимаемые данные. 7 count – сколько принимается элементов типа type. type – тип принимаемых элементов (о типах данных в MPI см. ниже). source – номер процесса отправителя данных. tag – идентификатор сообщения (тэг сообщения) comm – идентификатор (коммуникатор) области связи в рамках которой происходит передача. status – структура, в которую заносятся параметры принятого сообщения. Используя функции двухточечного обмена, нулевой процесс рассылает массивы A, B и C процессам, при этом каждому он посылает по 25 – элементов каждого массива. Посылка осуществляется с адреса, указанного в первом параметре функции MPI_Send. Например, чтобы послать второму процессу «его» 25 элементов массива A, необходимо извлечь адрес начального элемента. Начальным элементом будет A[50]. Тогда, подставив в MPI_Send выражение &A[i*25], мы указали адрес (&-оператор извлечения адреса) i*25 –й ячейки массива A. При i=2, i*25=50. В свою очередь, принимающие процессы, вызывая функции MPI_Recv, заносят принятые данные в свои массивы, при этом размещают они их в начале. Подстановка в качестве первого параметра имени массива без указания элементов, автоматически означает подстановку адреса еѐ нулевой ячейки. То есть A=&A[0]. Типы данных в MPI Необходимо отметить одну важную деталь, для идентификации типа передаваемого сообщения, в нашей программе в функциях в качестве параметра подставлена переопределѐнная константа MPI_FLOAT. Причина использования переопределѐнной константы простая. MPI предназначен для с оздания как переносимых, кросс платформенных программ, так и для создания программ исполняемых в неоднородных средах. Конкретные параметры типов данных в таких системах могут быть разными. Например, на одной с истеме целочисленный тип данных int может иметь два байта, а на другой системе четыре байта. Чтобы система передачи данных MPI могла организовать корректную пересылку и необходимое преобразование данных, в MPI введены собственные типы данных. Например, MPI_FLOAT, MPI_INT, MPI_CHAR и так далее. Кроме переопределѐнных констант типов данных, в MPI ещѐ имеются собственные типы, с некоторыми из них мы уже столкнулись, это MPI_Comm и MPI_Status. Кроме этого в MPI имеются средства создания пользовательских типов данных, которые подчас бывают очень полезны и необходимы в работе. Коллективные передачи. 8 Если в двухточечном обмене участвуют только два процесса параллельной программы, то в коллективной передаче участвуют все процессы выбранной области связи. Операциями коллективных передач являются: 1) широковещательные рассылки данных по всем процессам области связи; 2) разделение массивов данных на части и распределение их по всем процессам области связи; 3) сбор данных с множества процессов области связи; 4) взаимный обмен данными между процессами некоторой области связи; 5) операции приведения (редукции) результатов, полученных на разных процессах области связи. Как уже было сказано, по умолчанию, в программе создаѐтся одна, о бщая для всех процессов область связи MPI_COMM_WORLD. Именно она одна и присутствует в нашей программе. Это означает, что во всех коллективных операциях нашей программы должны участвовать все процессы. Для выполнения операции коллективного обмена, в MPI имеются специальные функции. При совершении операции коллективного обмена, функции коллективного обмена, в обязательном порядке должны быть вызваны всеми пр оцессами области связи. В нашей программе использованы две такие функции MPI_Scatter (функция рассылки массива данных равноразмерными блоками по всем процессам) и MPI_Gather (функция сбора равноразмерных блоков со всех процессов в единый массив). Функция MPI_Scatter используется в программе для распределения элементов массива D. Конечно, можно было воспользоваться двухточечным обменом, как в случае с массивами A, B и C, или, наоборот, для распределения этих массивов можно использовать коллективный обмен. Но, такой «смешенный стиль» распределения исходных данных употреблѐн в программе специально, в учебных целях. Распределение элементов массива D осуществляется нулевым процессом. Массив D делится на равные блоки, по 25 элементов в каждом. Число таких блоков равно числу процессов в области связи MPI_COMM_WORLD. Блоки распределяются по всем процессам области связи, а это означает, что свой блок получает и процесс отправитель. Распределяются блоки в порядке возрастания номера процесса в области связи. Так, первый блок (элементы 024) достанется нулевому процессу, второй блок (элементы 25-49) первому, третий блок (элементы 50-74) второму и четвѐртый блок (элементы 76-99) третьему процессу. Ниже приведѐн синтаксис функции MPI_Scatter. int MPI_Scatter(void *sbuf, int scount, MPI_Datatype stype, void *rbuf, int rcount, MPI_Datatype rtype,int root,MPI_Comm comm) 9 где: buf – адрес начальной ячейки в памяти процесса отправителя, где с одержатся рассылаемые данные. scount – сколько элементов типа type в посылаемых блоках. stype – тип элементов в посылаемых блоках. rbuf – адрес начальной ячейки в памяти процесса, куда будут записываться принимаемые данные. rcount – сколько элементов типа type в принимаемом блоке. rtype – тип элементов в принимаемом блоке. root – номер процесса отправителя данных. comm – идентификатор (коммуникатор) области связи в рамках которой происходит коллективный обмен. Функция MPI_Gather является, по своему действию, обратной функции MPI_Scatter. В нашей программе она собирает блоки элементов массива E со всех процессов, в единый массив на нулевом процессе. Ниже приведѐн синтаксис функции MPI_Gather. int MPI_Gather(void *sbuf, int scount, MPI_Datatype stype, void *rbuf, int rcount, MPI_Datatype rtype,int root,MPI_Comm comm) где: buf – адрес начальной ячейки в памяти процесса отправителя, где содержатся данные отсылаемого блока. scount – сколько элементов типа type в посылаемом блоке. stype – тип элементов в посылаемом блоке. rbuf – адрес ячейки памяти принимающего процесса, начиная с которого упорядоченно будут записываться элементы принимаемых блоков. rcount – сколько элементов типа type в принимаемых блоках. rtype – тип элементов в принимаемых блоках. root – номер процесса собирателя данных. comm – идентификатор (коммуникатор) области связи в рамках которой происходит коллективный обмен. Завершение работы с MPI После того, как все необходимые действия с функциями MPI сделаны, необходимо завершить работу со средой MPI. Для этого всеми процессами параллельной программы должна быть вызвана функция MPI_Finalize. После вызова функции MPI_Finalize процессы могут продолжать свою работу, но вызывать какие либо функции MPI уже нельзя. Пример процедуры выполнения программы на кластере 10 Как правило, пользовательские программы могут выполняться на кластере в двух режимах, монопольном и пакетном. Монопольный режим, это когда все ресурсы кластера отводятся для решения одной задачи конкретного пользователя. Такой режим используется редко и только при особой необходимости. Пакетный режим используется в большинстве случаев. Именно его мы и рассмотрим. Последовательность подготовки выполнения параллельной программы в пакетном режиме следующая: 1) Создаѐтся исходный текст параллельной программы. 2) Исходный текст готовой параллельной программы транслируется (компилируется и линкуется) под конкретную систему, в данном случае под кластер. 3) Создаются файлы (или файл) с исходными данными. 4) Формируется запрос на исполнение программы. В запросе, как правило указывается, сколько процессоров (процессорных ядер) нужно для выполнения программы, а также исполняемый файл программы. Кроме этого, иногда указывается распределение процессов MPI по узлам кластера, приоритет программы, максимально-возможное время выполнения еѐ и некоторые другие параметры. 5) Программа ставится в очередь на выполнение. Продвижение еѐ по очереди зависит от приоритета программы, числа требуемых для выполнения процессорных ядер, приоритета пользователя, макс имально-возможного времени выполнения. 6) Одновременно, на кластере могут выполняться несколько параллельных программ. Между ними распределены имеющиеся аппаратные ресурсы (процессорные ядра, память, и.т.д.) кластера. Специальная программа диспетчер ресурсов отслеживает имеющиеся и освобождаемые ресурсы кластера, после чего программапланировщик, назначает на выполнение программу из очереди, выделив для неѐ ресурсы из имеющихся свободных. Программа назначается на выполнение, если наступила еѐ очередь, и для неѐ имеются требуемые ресурсы кластера. 7) По окончании выполнения, программа снимается с кластера, а ресурсы, отведѐнные ей, освобождаются для других программ. Для пользователя формируются файлы (файл) с результатами работы и файл-отчѐт о выполнении программы и произошедших в ходе этого выполнения ошибках. Примечания Во многих кластерных системах, для языка C и MPI, трансляция программы в машинный код осуществляется командой: 11 ла> mpicc <имя файла с исходным текстом>.c -o <имя исполняемого фай- Постановка программы в очередь, часто делается следующей командой (возможны варианты): mpirun – np N –maxtime TIME <имя исполняемого файла> где N – число требуемых процессорных ядер (фактически число запускаемых процессов MPI), а TIME – максимально-возможное время выполнения. Существуют и другие команды постановки программы в очередь на выполнение. В иных вариантах команд постановки в очередь указывается распределение процессов по узлам кластера, параметры постановки в оч ередь, имена рабочих директорий и другие параметры. 12
«Разработка программ с использованием MPI для параллельных и распределённых систем. Часть 2» 👇
Готовые курсовые работы и рефераты
Купить от 250 ₽
Решение задач от ИИ за 2 минуты
Решить задачу
Найди решение своей задачи среди 1 000 000 ответов
Найти
Найди решение своей задачи среди 1 000 000 ответов
Крупнейшая русскоязычная библиотека студенческих решенных задач

Тебе могут подойти лекции

Автор(ы) Joseph Brodsky
Смотреть все 462 лекции
Все самое важное и интересное в Telegram

Все сервисы Справочника в твоем телефоне! Просто напиши Боту, что ты ищешь и он быстро найдет нужную статью, лекцию или пособие для тебя!

Перейти в Telegram Bot