分类: 云计算
2012-07-12 08:56:00
点对点通信是MPI库提供的基础通信设施。
点对点通信在概念上很简单:一个进程发送消息,而一个进程接收。但现实中它没那么简单。例如,一个进程可能有许多等待接收的消息。这种情况下,一个关键的问题是MPI和接收进程如何决定要接收什么消息。
另一个问题是发送和接收例程是初始化通信操作并立即返回,还是在返回前等待初始化通信操作完成。在两种情况下底下的通信操作都是相同的,但是编程接口确很不相同。
基础
源和目标:点对点通信设施是双方的,需要双方进程的共同主动参与。一个进程(源)发送,另一个进程(目标)接收。
通常,源和目标进标异步操作。哪怕是单个消息的发送和接收通常也不是同步的。源进程发送消息后,可能要很久之后目标进程才会接收它。或者目标进程可能在消息还未发出时就做好准备接收它。
因 为发送和接收通常不是同步的,所以进程可能会有一个或多个已经发送但没有被接收的消息。这些发送了但没被接收的消息被称为待处理的(pending)消 息。MPI一个很重要的特性是待处理的消息不是由一个简单的FIFO队列维护,相反,每个待处理消息都有几个属性,同时目标进程(接收进程)可以使用这些 属性来决定接收哪些消息。
消息:由信封(envelop)和消息主体(message body)两部分组成。
信封和包装信件的纸质信封相似。它包含目标地址、回信地址、以及任何其它传送和分发信件所需的信息,比如服务类型(如空邮)。
MPI消息的信封有4部分:
1、源:发送进程;
2、目标:接收进程;
3、通信者:指明源和目标同时所属的一个进程组;
4、标签(tag):用于分类消息。
标签域是必需的,但它的使用交由程序决定。一对通信进程可以使用标签的值来区分消息的类型。例如,一个标签值可以用于包含数据的消息,而另一个标签用于包含状态信息的消息。
消息主体由三部分组成:
1、缓冲区:消息数据;
2、数据类型:消息数据的类型;
3、计数器:缓冲区包含的具有该数据类型的项数。
可以把缓冲区想像成一个数组,维度由计数器决定,数组元素的类型由数据类型给出。使用数据类型和计数器,而不是字节和字节数,可以平滑地处理结构化数据和不连续的数据。同时还允许异构主机间的透明通信支持。
发送和接收消息:发送消息很直接。源(发送者的ID)隐式地被确定,但消息的其它部分(信封和主体)由发送进程显式地给出。
接收消息没有这么简单,一个进程可能有多个待处理的消息。
要接收一个消息,一个进程指明一个消息信封,MPI把它和待处理消息比对。如果匹配,则接收消息。否则,一直等到匹配的消息发送,才结束接收操作。
此外,接收消息的进程必须提供存储,消息的主体可以拷贝入内。接收进程必须小心,要提供足够的存储来存放整个消息在。
阻塞发送和接收
MPI_Send和MPI_Recv两个函数是MPI里基本的点对点通信例程。两个函数都会阻塞调用进程,直到通信操作完成。阻塞可能会造成死锁。
发送消息:MPI_Send
函数原型为:
int MPI_Send(void *buf, int count, MPI_Datatype dtype, int dest, int tag, MPI_Comm comm);
所有的参数都是输入参数,前三个是消息主体,后三个是消息信封(源进程隐式定义)。函数返回一个错误码。
接收消息:MPI_Recv
函数原型:
int MPI_Recv(void *buf, int count, MPI_Datatype dtype, int source, int tag, MPI_Comm comm, MPI_Status *status);
前三个参数是消息主体,后三个参数是信封(目标进程隐式定义)。source和tag都可以使用通配符,表示任何进程和任何标签,否则只接收特定发送进程的含特定标签的消息。通信者不能使用通配符。如果收到的消息比接收进程准备接受的数据大时,会出错。
最后一个参数是关于接收的消息的信息。当source或tag使用通配符时,可以从status里获取这些信息。同时还能得到真正接收到的数据的数量。
buf和status是输出参数,其余都是输入参数。
返回值是一个错误码。
示例代码:
#include
#include
void main(int argc, char **argv)
{
int myrank;
MPI_Status status;
double a[100];
MPI_Init(&argc, &argv);
MPI_Comm_rank(MPI_COMM_WORLD, &myrank);
if (myrank == 0)
MPI_Send(a, 100, MPI_DOUBLE, 1, 17, MPI_COMM_WORLD);
else if (myrank == 1)
MPI_Recv(a, 100, MPI_DOUBLE, 0, 17, MPI_COMM_WORLD, &status);
MPI_Finalize();
}
运行时的行为
根据模型,使用MPI_Send发送消息时,以下事情之一可能会发生:
1、消息被拷贝到MPI内部缓冲,并稍后在后台传送到目标;或者
2、消息原封不动地放在程序的变量里,直到目标准备好接收时,消息才被传送到目标。
第一个选项允许发送进程在拷贝完成时着手做其它事情。第二个选项最小化了拷贝和内存使用,但倒致发送进程的额外延迟。这个延迟可能会很显著。
令人惊奇的是,选项1里,在任何非本地动作发生甚至开始之前,也就是说任何与发送消息相关的事情发生之间,MPI_Send调用可能就返回了。选项2隐含了发送者和接收者之间的同步。
总结起来,根上面的模型草案,当使用MPI_Send发送消息时,消息可能立即被缓冲并稍后异步地分发出去,或者发送与接收进程同步。
阻塞与完成
MPI_Send和MPI_Recv都会阻塞调用进程。两者都在所调用的通信操作完成里返回。
MPI_Recv完成的意思很简单直观--一个匹配的消息已经到达,消息的数据被拷贝到调用的输出参数里。换句话说,传递给MIP_Recv的变量包含了一个消息并已经可以使用了。
对于MPI_Send,完成的意思简单但不直观。当调用指定的消息已经交给MPI时MPI_Send调用完成。换句话说,传递给MPI_Send的变量现在可以被覆写并重用。前面已经提到有两个选项。
如果传递给MPI_Send的消息比MIP可用的内部缓冲大,那么缓冲不能被使用。在这种情况下,发送进程必须阻塞,直到目标进程开始接收这个消息,或直到有更多可用的缓冲。一般说来,被拷贝进MPI内部缓冲的消息会占用缓冲空间,直到目标进程开始接收这个消息。
注意MPI_Recv调用在匹配的待处理的消息的信封(源、标签、通信者)后会接收消息。对于正确的执行来说数据类型匹配也是需要的,但MPI不会检查它。相反,程序员负责数据类型的匹配。
死锁
当两个或多个进程阻塞并都等待另一个进程的执行时,死锁发生。由于都依赖于对方先开始执行,所以没有一个进程可以继续执行。
下面的代码会造成死锁:
if (myrank == 0) {
MPI_Recv(b, 100, MPI_DOUBLE, 1, 19, MPI_COMM_WORLD, &status);
MPI_Send(a, 100, MPI_DOUBLE, 1, 17, MPI_COMM_WORLD);
}
else if (myrank == 1) {
MPI_Recv(a, 100, MPI_DOUBLE, 0, 17, MPI_COMM_WORLD, &status);
MPI_Send(a, 100, MPI_DOUBLE, 0, 19, MPI_COMM_WORLD);
}
死锁的原因是两个进程都先接收再发送。
通常避免死锁需要在程序中小心地组织通信。程序员应该能够解释程序为何有(或没有)死锁。
下面的代码解决了上面代码的死锁问题:
if (myrank == 0) {
MPI_Recv(b, 100, MPI_DOUBLE, 1, 19, MPI_COMM_WORLD, &status);
MPI_Send(a, 100, MPI_DOUBLE, 1, 17, MPI_COMM_WORLD);
}
else if (myrank == 1) {
MPI_Send(a, 100, MPI_DOUBLE, 0, 19, MPI_COMM_WORLD);
MPI_Recv(a, 100, MPI_DOUBLE, 0, 17, MPI_COMM_WORLD, &status);
}
进程0先接收后发送,而进程1先发送再接收,从而避免死锁。下面的代码略有改动:
if (myrank == 0) {
MPI_Send(a, 100, MPI_DOUBLE, 1, 17, MPI_COMM_WORLD);
MPI_Recv(b, 100, MPI_DOUBLE, 1, 19, MPI_COMM_WORLD, &status);
}
else if (myrank == 1) {
MPI_Send(a, 100, MPI_DOUBLE, 0, 19, MPI_COMM_WORLD);
MPI_Recv(a, 100, MPI_DOUBLE, 0, 17, MPI_COMM_WORLD, &status);
}
两个进程都先发送后接收。当MPI的缓冲可用时,死锁不会出现。但当消息数量增多后,程序迟早会发生死锁。通常,依赖于MPI内部缓冲来避免死锁会使得程序降低可移植性和扩展性。写程序的最好方式是不论MPI内部缓冲如何都能保证运行完成。
非阻塞发送和接收
MPI提供了另一种方式来执行发送和接收操作。我们可以分离发送和接收操作的初始化和它们的完成。这通过两个分离的MPI调用来完成。第一个调用初始化操作,第二个调用完成它。在这两个操作之间,程序可以做任何其它事情。
两个分离的调用的底层的通信操作和单个调用是一样的,但是接口不同。
投递、完成、和请求句柄
每个通信操作的发送和接收的非阻塞的接口需要两个调用:一个初始化操作,第二个完成它。初始化一个发送调用被称为投递一个发送请求。初始化一个接收操作被称为投递一个接收请求。
一旦一个发送或接收操作被投递,MPI提供了两种不同的方式来完成它。一个进程可以测试操作是否完成,而不需要阻塞在完成操作上。另一种方式是,它可以等待操作完成。
在调用一个非阻塞例程投递一个发送或接收操作后,投递进程需要某种方式来引用被投递的操作。MPI使用请求句柄来达到这个目的。非阻塞发送和接收例程都返回请求句柄,它可用来标识被投递的操作。
总而言之,发送和接收操作可以通过非阻塞例程被投递(初始化)。投递操作由请求句柄标识。通过请求句柄,进程可以检查已投递的操作的状态或等待它们的完成。
非阻塞地投递发送请求
MPI_Isend可以投递一个发送请求而不会阻塞在等待完成上。
函数原型:
int MPI_Isend(void *buf, int count, MPI_Datatype dtype, int dest, int tag, MPI_Comm comm, MPI_Request *request);
参数与MPI_Send相似,但多了request的输出参数。参数被传递给MPI_Isend后不能被读写,直到发送操作完成。
返回值为错误码。
非阻塞地投递接收请求
MPI_Irecv可以投递一个接收请求而不会阻塞在等待完成上。
函数原型:
int MPI_Irecv(void *buf, int count, MPI_Datatype dtype, int source, int tag, MPI_Comm comm, MPI_Request *request);
参数与MPI_Recv相似,但多了request的输出参数。参数被传递给MPI_Irecv后不能被读写,直到接收操作完成。
返回值为错误码。
完成:等待或检测
投递的发送和接收必须完成。如果发送和接收操作由一个非阻塞例程投递,那么它的完成状态可以通过调用一组完成例程来检查。MPI同时提供了阻塞和非阻塞完成例程。阻塞例程为MPI_Wait以及它的变体。非阻塞例程为MPI_Test以及它的变体。
等待:
MPI_Wait的函数原型:
int MPI_Wait(MPI_Request *request, MPI_Status *status);
reuest参数为已投递的发送和接收操作的请求句柄。status为输出参数,接收操作时包含接收到的消息的信息(源、标签、真实接收的数据数量等),发送操作时可能包含一个错误码(表示发送是否出错,而MPI_Wait本身返回的错误码不同)。
返回一个错误码。
检测:
MPI_Test的函数原型:
int MPI_Test(MPI_Request *request, int *flag, MPI_Status *status);
request和status参数和MPI_Wait基本相同。flag为输出参数,如果发送或接收完成时值为true。当flag值为false时status的值没有定义。
非阻塞发送和接收的优缺点:
非阻塞例程可以更容易地写出无死锁的程序。
在延迟很大的系统上,很早地投递接收操作经常是高效简单的方法,来掩盖通信的开销。在物理分布的主机群(比如工作站簇)上延迟会很大,而共享内存的多处理器会相对较小。一般来说掩盖通信的开销需要在算法和代码结构上很小心。
从坏的方面来说,非阻塞发送和接收例程会增加代码复杂性,这让代码更难调试和维护。
示例代码:
#include
void main(int argc, char **argv)
{
int myrank;
MPI_Request request;
MPI_Status status;
double a[100], b[100];
MPI_Init(&argc, &argv);
MPI_Comm_rank(MPI_COMM_WORLD, &myrank);
if (myrank == 0) {
MPI_Irecv(b, 100, MPI_DOUBLE, 1, 19, MPI_COMM_WORLD, &request);
MPI_Send(a, 100, MPI_DOUBLE, 1, 17, MPI_COMM_WORLD);
MPI_Wait(&request, &status);
} else if (myrank == 1) {
MPI_Irecv(b, 100, MPI_DOUBLE, 0, 17, MPI_COMM_WORLD, &request);
MPI_Send(a, 100, MPI_DOUBLE, 0, 19, MPI_COMM_WORLD);
MPI_Wait(&request, &status);
}
MPI_Finalize();
}
上面的代码没有死锁。
发送模式
MPI提供了以下四种发送模式:
1、标准模式发送;
2、同步模式发送;
3、预准备模式发送;
4、缓冲模式发送。
标准模式是最广泛使用的。
接收模式只有一种。不论发送模式如何,MPI_Recv和MPI_Irecv的调用是相同的。
对于四种发送模式,阻塞和非阻塞调用都可用。
标准模式发送
标准模式是MPI的通用发送模式。其它三个模式在特殊的条件下有用。
如之前所述,标准模式下,消息或拷贝到MPI内部缓冲里并稍后发送,或源和目标进程同步。MPI实现可以自由选择缓冲或同步,或视情况而定,比如消息大小,可用资源,等。
标准模式发送的好处是MPI可以基于情况选择缓冲和同步。通常MPI在进行权衡方面,特别在涉及底层资源和MPI内部资源的时候,会做得更好。
同步模式发送要求MPI同步发送和接收进程。当同步模式发送操作完成时,发送进程可以假设接收进程已经开始接收消息了。目标进程不需要完成接收,但是必须已经开始接收了。
预准备模式需要在发送之前,目标进程已经投递了一个匹配的接收。如果目标没有投递接收,则结果没有定义。你需要保证这个条件得到满足。在某些情况下,不需要额外的工作就可以知道目标进程的状态信息。当知道接收已经被投递时,MPI可以通过在内部使用更短的协议来降低通信开销。
缓冲模式发送要求MPI使用缓冲。这种方式的不足是你必须负责管理缓冲。在任何时候,可用缓冲空间不足以完成调用时,结果没有定义。函数MPI_Buffer_attach和MPI_Buffer_detach为MPI制造可用的缓冲。
各模式对应的函数
发送模式 | 阻塞函数 | 非阻塞函数 |
---|---|---|
标准 | MPI_Send | MPI_Isend |
同步 | MPI_Ssend | MPI_Issend |
预准备 | MPI_Rsend | MPI_Irsend |
缓冲 | MPI_Bsend | MPI_Ibsend |
其它模式的函数参数(阻塞与非阻塞)与标准模式的函数参数相同。