在上一篇中我们介绍了 mpi4py 中 I/O 相关的 hints,下面我们将介绍 mpi4py 中 I/O 操作的一致性语义。
MPI 的 I/O 操作的一致性语义指明了多个进程进行 I/O 操作的结果。MPI 程序访问文件都通过一个集合操作 MPI.File.Open 返回的文件句柄来执行,应提供以下 3 个级别的一致性:
- 使用相同文件句柄访问文件的串行一致性;
- 使用同一个集合操作(MPI.File.Open)以原子模式(atomic mode)打开的若干文件句柄访问文件的串行一致性;
- 应用程序根据需要自定义的访问一致性。
所谓串行一致性是指要求一组访问文件的操作按照一定顺序依次实施,每个访问都应是原子操作,但允许具体执行时的顺序有所不同。应用程序自定义的一致性可通过程序语句顺序加之以 MPI.File.Sync 实现。
为明确定义文件操作的一致性,这里先说明一下数据访问动作的概念:一般来说,一个普通的阻塞非集合或者集合读/写调用就是一个独立的数据访问动作,但分步集合操作(如 MPI.File.Read_all_begin/MPI.File.Read_all_end),“begin” 和 “end” 方法的一个配对构成一个完整的数据访问动作,非阻塞访问(如 MPI.File.Iread/MPI.Request.Wait)启动和完成动作的配对构成一个完整的数据访问动作。
I/O 操作的一致性语义有多种可能性,对不同的情况需要分别加以讨论,在此之前,我们先介绍与一致性语义相关的方法使用接口:
一致性语义方法接口
MPI.File.Open(type cls, Intracomm comm, filename, int amode=MODE_RDONLY, Info info=INFO_NULL)
并行打开文件方法,在前面作过介绍。
MPI.File.Close(self)
关闭文件方法,在前面作过介绍。
MPI.File.Set_atomicity(self, bool flag)
设置当前打开文件的原子模式,如果 flag
为 True,则设置为原子模式,否则设置为非原子模式。该方法执行一个集合操作,参与该操作的进程组必须传递相同的 flag
参数值。
MPI.File.Get_atomicity(self)
返回当前一致性约束的状态。如果是原子模式则返回 True,否则返回 False。
MPI.File.Sync(self)
该方法是一个集合操作,如果某个进程调用该方法,则到此为止该进程已经写入文件的数据全部刷新到磁盘上。如果其它进程也更新过文件,则此方法之后所有更新的结果都会对组内进程可见。在调用此方法之前,应用程序应确保文件句柄上的所有非阻塞操作、分步集合操作以及其它操作都已经完成,否则可能导致一些随机错误。
一致性语义的不同情况
I/O 操作的一致性语义的完整的描述请参考 MPI 标准,这里只介绍几种最常见的情况。
简单情况
在两组情况下,一致性不是一个问题,文件操作的正确性总会得到保证。
只读访问:如果所有的进程都只执行只读操作而不向文件写入任何数据,则每个进程都会正确地读到所需的数据,而不管在打开文件时使用的是什么通信子对象(MPI.COMM_WORLD,MPI.COMM_SELF 或者其他通信子)。
不同的文件:如果每个进程访问一个单独的不同的文件(即没有任何一个文件被多个进程共享),MPI 保证一个进程写入到文件中的数据会被这同一个进程在写动作之后的任何一个时刻正确地读到而无需进行特别的同步等操作。
当多个进程访问同一个文件并且至少一个进程会向文件中写入数据时,情况就会更复杂些,而且一致性语义的保证还依赖于打开文件时所使用的通信子对象。一般来说,如果使用的是包括访问文件的所有进程的通信子对象(如 MPI.COMM_WORLD)来打开文件则 MPI 会保证更强的一致性语义;而如果使用的是仅包括访问文件的部分进程构成的通信子对象(如 MPI.COMM_SELF)来打开文件则 MPI 会保证弱一些的一致性语义。不管是在什么情况下,如果 MPI 不能自动地保证 I/O 操作的一致性语义,用户可以自己采取一些步骤来保证。我们将在下面介绍在不同的情况下如何来实现。
访问使用 MPI.COMM_WORLD 打开的同一个文件
当所有进程都访问同一个使用 MPI.COMM_WORLD 打开的文件并且至少一个进程会向文件中写入数据时,如果不同的进程访问的是文件中不重叠的区域,MPI 能够自动保证一个进程在其写动作后再正确地读取其写入的数据而无需额外的同步等操作。比如说像下面这样:
# Process 0 | Process 1
fh = MPI.File.Open(MPI.COMM_WORLD, 'file', ...) | fh = MPI.File.Open(MPI.COMM_WORLD, 'file', ...)
fh.Write_at(0, [buf, 100, MPI.BYTE], ...) | fh.Write_at(100, [buf, 100, MPI.BYTE], ...)
fh.Read_at(0, [buf, 100, MPI.BYTE], ...) | fh.Read_at(100, [buf, 100, MPI.BYTE], ...)
在上面这个例子中,2 个进程使用 MPI.COMM_WORLD 打开同一个文件,每个进程向文件中不重叠的区域写入 100 个字节的数据,然后读取写入的数据。在这种情况下 MPI 保证能正确地读取到所写入的数据。
但是如果每个进程要读取其他进程刚刚写入的数据,即不同进程要访问文件中重叠的区域,在这种情况下,MPI 不能自动保证进程会读取到正确的数据,用户必须采取一些额外的措施来保证正确性,有如下 3 种选择:
- 使用原子性操作:在每个进程写之前,调用上面介绍的 MPI.File.Set_atomicity 设置对文件的访问为原子模式,如下:
# Process 0 | Process 1
fh = MPI.File.Open(MPI.COMM_WORLD, 'file', ...) | fh = MPI.File.Open(MPI.COMM_WORLD, 'file', ...)
fh.Set_atomicity(True) | fh.Set_atomicity(True)
fh.Write_at(0, [buf, 100, MPI.BYTE], ...) | fh.Write_at(100, [buf, 100, MPI.BYTE], ...)
MPI.COMM_WORLD.Barrier() | MPI.COMM_WORLD.Barrier()
fh.Read_at(100, [buf, 100, MPI.BYTE], ...) | fh.Read_at(0, [buf, 100, MPI.BYTE], ...)
在上面的例子中,设置了对文件的访问为原子模式,MPI 会保证一个进程能够立即正确地读取另一个进程写入文件的数据。如果没有设置对文件的访问为原子模式(文件被打开后默认没有开启原子模式),则 MPI 不能保证这种正确性。注意在写动作之后的一个 Barrier 同步是用来保证每个进程在完成其写动作后数据才会被另一个进程所读取。
- 关闭文件并重新打开:保证正确读取数据的另一种方式是在写动作后关闭文件并重新打开,然后再读取另一个进程所写入的数据,如下:
# Process 0 | Process 1
fh = MPI.File.Open(MPI.COMM_WORLD, 'file', ...) | fh = MPI.File.Open(MPI.COMM_WORLD, 'file', ...)
fh.Write_at(0, [buf, 100, MPI.BYTE], ...) | fh.Write_at(100, [buf, 100, MPI.BYTE], ...)
fh.Close() | fh.Close()
MPI.COMM_WORLD.Barrier() | MPI.COMM_WORLD.Barrier()
fh = MPI.File.Open(MPI.COMM_WORLD, 'file', ...) | fh = MPI.File.Open(MPI.COMM_WORLD, 'file', ...)
fh.Read_at(100, [buf, 100, MPI.BYTE], ...) | fh.Read_at(0, [buf, 100, MPI.BYTE], ...)
通过这种方法可以保证对打开的同一个文件没有重叠的访问操作,因而每个进程能够正确地读取所需的数据。注意:一个 Barrier 同步是用来保证每个进程在完成文件关闭动作后才被另一个进程重新打开。
- 保证任何一个进程的写序列不与其他进程的读/写序列并发执行:这是一个更复杂一些的保证正确性的机制。这里要说明一下操作序列的概念。一个序列是指某一个文件句柄上由 MPI.File.Sync,MPI.File.Open,MPI.File.Close 中的一对包围的文件操作(MPI.File.Open 和 MPI.File.Close 会间接地调用 MPI.File.Sync)。一个写序列是指一个包含写动作的这样的序列。比如说下面三个都是序列:sync-write-read-sync,open-write-write-close 和 sync-read-read-close,其中前两个为写序列。对一个序列,最前面和最后面的一个操作必须是 sync,open 和 close 中的任意一个。当用户将程序做了适当的安排使得一个进程的写序列不与任何其他进程的读/写序列(在时间上)并发执行时,MPI 可以保证这个进程写入文件的数据可以被另外一个进程正确地读取。下面是使用这种方法的一个例子:
# Process 0 | Process 1
# process 0 starts a write sequence first |
fh = MPI.File.Open(MPI.COMM_WORLD, 'file', ...) | fh = MPI.File.Open(MPI.COMM_WORLD, 'file', ...)
fh.Write_at(0, [buf, 100, MPI.BYTE], ...) |
fh.Sync() | fh.Sync() # needed for collective operation
|
# use a Barrier synchronizition to separate |
# process 0's write sequence from process 1's |
# write sequence in time |
MPI.COMM_WORLD.Barrier() | MPI.COMM_WORLD.Barrier()
|
| # process 1 starts its write sequence then
# next Sync is needed for collective operation |
fh.Sync() | fh.Sync()
| fh.Write_at(100, [buf, 100, MPI.BYTE], ...)
# next Sync is needed for collective operation |
fh.Sync() | fh.Sync()
|
# use a Barrier synchronizition to separate |
# process 1's write sequence from the next |
# sequence in time |
MPI.COMM_WORLD.Barrier() | MPI.COMM_WORLD.Barrier()
|
# process 1 and 2 start their read sequence |
fh.Sync() | fh.Sync() # needed for collective operation
fh.Read_at(100, [buf, 100, MPI.BYTE], ...) | fh.Read_at(0, [buf, 100, MPI.BYTE], ...)
fh.Close() | fh.Close()
在上面的例子中,为保证一个进程的写序列不与其他进程的读/写序列并发执行,我们增加了一些 MPI.File.Sync 调用将一系列的访问动作变成序列,并且使用 Barrier 同步将这些一个进程的写序列同其他进程的读/写序列在时间上分隔开。因为 MPI.File.Sync 调用是一个在打开文件的通信子上的集合操作,因此对一个进程的 MPI.File.Sync 调用,该通信子进程组内的其他进程也必须进行匹配调用,尽管其并不是用来构成一个操作序列。
以上我们使用的是非阻塞的显式偏移地址读/写方法,如果换成使用集合读/写方法,如 MPI.File.Write_at_all,则不能使用上面的方法以取得一致性语义,因为不能使用 Barrier 同步来隔开一个集合的写操作,这样会导致死锁。对这种情况,只能使用前两种选项,即设置原子操作模式和关闭文件后重新打开的方式来保证一致性语义。但值得注意的是,因为集合操作本身带有显式的 Barrier 同步语义,因此某些 Barrier 同步是可以省略的,比如下面的例子(不过似乎有些 MPI 实现还是需要 Barrier 同步才能保证正确):
# Process 0 | Process 1
fh = MPI.File.Open(MPI.COMM_WORLD, 'file', ...) | fh = MPI.File.Open(MPI.COMM_WORLD, 'file', ...)
fh.Set_atomicity(True) | fh.Set_atomicity(True)
fh.Write_at_all(0, [buf, 100, MPI.BYTE], ...) | fh.Write_at_all(100, [buf, 100, MPI.BYTE], ...)
# no need Barrier here |
# MPI.COMM_WORLD.Barrier() | # MPI.COMM_WORLD.Barrier()
fh.Read_at(100, [buf, 100, MPI.BYTE], ...) | fh.Read_at(0, [buf, 100, MPI.BYTE], ...)
访问使用 MPI.COMM_SELF 打开的同一个文件
下面我们考虑所有进程都访问同一个使用 MPI.COMM_SELF 打开的文件(MPI 允许这样做,但是一般并不推荐这样做)。在这种情况下,MPI 不能自动保证一致性语义,用户必须自己采取额外的措施来保证一个进程的写序列不与任何其他进程的读/写序列并发执行,即使没有重叠的文件访问(即不同进程访问文件中完全不重叠的区域)。最后这一点需要特别注意。
对于前面的例子,如果将打开文件的通信子 MPI.COMM_WORLD 换成 MPI.COMM_SELF,保证一致性语义的正确做法如下:
# Process 0 | Process 1
# process 0 starts a write sequence first |
fh = MPI.File.Open(MPI.COMM_SELF, 'file', ...) | fh = MPI.File.Open(MPI.COMM_SELF, 'file', ...)
fh.Write_at(0, [buf, 100, MPI.BYTE], ...) |
fh.Sync() |
|
# use a Barrier synchronizition to separate |
# process 0's write sequence from Process 1's |
# write sequence in time |
MPI.COMM_WORLD.Barrier() | MPI.COMM_WORLD.Barrier()
|
| # process 1 starts its write sequence then
| fh.Sync()
| fh.Write_at(100, [buf, 100, MPI.BYTE], ...)
| fh.Sync()
|
# use a Barrier synchronizition to separate |
# process 1's write sequence from the next |
# sequence in time |
MPI.COMM_WORLD.Barrier() | MPI.COMM_WORLD.Barrier()
|
# process 1 and 2 start their read sequence |
fh.Sync() |
fh.Read_at(100, [buf, 100, MPI.BYTE], ...) | fh.Read_at(0, [buf, 100, MPI.BYTE], ...)
fh.Close() | fh.Close()
在上面的例子中,因为打开文件所使用的通信子为 MPI.COMM_SELF,因此对文件的 MPI.File.Sync 调用实际上变成了一个本地操作,因此那些仅仅作为集合操作来匹配其他进程的 MPI.File.Sync 调用在此处不再需要了,只有那些作为一个序列开头和结尾的 MPI.File.Sync 调用才是必须的。
尽管像上面这样多个进程使用 MPI.COMM_SELF 作为通信子打开同一个文件进行访问是可行的,但并不推荐这么做。用户应该尽量使用正确的通信子(包括要访问这个文件的所有进程的通信子)来调用 MPI.File.Open。这样做的好处是,不仅 MPI 可以提供更强的一致性语义,而且可能产生更高的 I/O 性能。比如说可以使用一些集合 I/O 操作,MPI 实现对集合 I/O 操作一般能够产生比完成同样功能的非集合 I/O 操作更好的优化。
例程
下面给出使用例程。
# io_consistency.py
"""
Demonstrates how to achive consistency semantics.
Run this with 2 processes like:
$ mpiexec -n 2 python io_consistency.py
"""
import numpy as np
from mpi4py import MPI
comm = MPI.COMM_WORLD
rank = comm.Get_rank()
buf1 = bytearray(range(100*rank, 100*(rank+1)))
buf2 = bytearray([0]*100)
filename = 'temp.txt'
# access non-overlapping areas in file
# ------------------------------------------------------------------------------
# open the file, create it if it does not exist and delete it on close
fh = MPI.File.Open(comm, filename, amode= MPI.MODE_CREATE | MPI.MODE_RDWR | MPI.MODE_DELETE_ON_CLOSE)
if rank == 0:
fh.Write_at(0, [buf1, 100, MPI.BYTE])
fh.Read_at(0, [buf2, 100, MPI.BYTE])
print buf2 == buf1
elif rank == 1:
fh.Write_at(100, [buf1, 100, MPI.BYTE])
fh.Read_at(100, [buf2, 100, MPI.BYTE])
print buf2 == buf2
# close the file
fh.Close()
# reinitialize buf2
buf2 = bytearray([0]*100)
# set atomicity
# ------------------------------------------------------------------------------
# open the file, create it if it does not exist and delete it on close
fh = MPI.File.Open(comm, filename, amode= MPI.MODE_CREATE | MPI.MODE_RDWR | MPI.MODE_DELETE_ON_CLOSE)
# set atomicity
fh.Set_atomicity(True)
if rank == 0:
fh.Write_at(0, [buf1, 100, MPI.BYTE])
elif rank == 1:
fh.Write_at(100, [buf1, 100, MPI.BYTE])
# use Barrier synchronizition to make sure read after the completion of write
comm.Barrier()
if rank == 0:
fh.Read_at(100, [buf2, 100, MPI.BYTE])
print buf2 == bytearray(range(100, 200))
elif rank == 1:
fh.Read_at(0, [buf2, 100, MPI.BYTE])
print buf2 == bytearray(range(0, 100))
# close the file
fh.Close()
# reinitialize buf2
buf2 = bytearray([0]*100)
# close the file and reopen it
# ------------------------------------------------------------------------------
# open the file for write only, create it if it does not exist
fh = MPI.File.Open(comm, filename, amode= MPI.MODE_CREATE | MPI.MODE_WRONLY)
if rank == 0:
fh.Write_at(0, [buf1, 100, MPI.BYTE])
elif rank == 1:
fh.Write_at(100, [buf1, 100, MPI.BYTE])
# close the file
fh.Close()
# use Barrier synchronizition to make reopen is after the completion of the close
comm.Barrier()
# reopen the file
fh = MPI.File.Open(comm, filename, amode=MPI.MODE_RDONLY | MPI.MODE_DELETE_ON_CLOSE)
if rank == 0:
fh.Read_at(100, [buf2, 100, MPI.BYTE])
print buf2 == bytearray(range(100, 200))
elif rank == 1:
fh.Read_at(0, [buf2, 100, MPI.BYTE])
print buf2 == bytearray(range(0, 100))
# close the file
fh.Close()
# reinitialize buf2
buf2 = bytearray([0]*100)
# use Sync to separate diffrent sequences
# ------------------------------------------------------------------------------
# open the file, create it if it does not exist and delete it on close
fh = MPI.File.Open(comm, filename, amode= MPI.MODE_CREATE | MPI.MODE_RDWR | MPI.MODE_DELETE_ON_CLOSE)
# process 0 starts a write sequence first
if rank == 0:
fh.Write_at(0, [buf1, 100, MPI.BYTE])
fh.Sync()
# use a Barrier synchronizition to separate process 0's write sequence from
# process 1's write sequence in time
comm.Barrier()
# process 1 starts its write sequence then
fh.Sync()
if rank == 1:
fh.Write_at(100, [buf1, 100, MPI.BYTE])
fh.Sync()
# use a Barrier synchronizition to separate process 1's write sequence from
# the next sequence in time |
comm.Barrier()
fh.Sync()
# process 1 and 2 start their read sequence
if rank == 0:
fh.Read_at(100, [buf2, 100, MPI.BYTE])
print buf2 == bytearray(range(100, 200))
elif rank == 1:
fh.Read_at(0, [buf2, 100, MPI.BYTE])
print buf2 == bytearray(range(0, 100))
# close the file
fh.Close()
# reinitialize buf2
buf2 = bytearray([0]*100)
# set atomicity and collective write
# ------------------------------------------------------------------------------
# open the file, create it if it does not exist and delete it on close
fh = MPI.File.Open(comm, filename, amode= MPI.MODE_CREATE | MPI.MODE_RDWR | MPI.MODE_DELETE_ON_CLOSE)
# set atomicity
fh.Set_atomicity(True)
# use collective write
if rank == 0:
fh.Write_at_all(0, [buf1, 100, MPI.BYTE])
elif rank == 1:
fh.Write_at_all(100, [buf1, 100, MPI.BYTE])
# no need Barrier here
# comm.Barrier()
if rank == 0:
fh.Read_at(100, [buf2, 100, MPI.BYTE])
print buf2 == bytearray(range(100, 200))
elif rank == 1:
fh.Read_at(0, [buf2, 100, MPI.BYTE])
print buf2 == bytearray(range(0, 100))
# close the file
fh.Close()
# reinitialize buf2
buf2 = bytearray([0]*100)
# use MPI.COMM_SELF to open the file
# ------------------------------------------------------------------------------
# open the file, create it if it does not exist and delete it on close
fh = MPI.File.Open(MPI.COMM_SELF, filename, amode= MPI.MODE_CREATE | MPI.MODE_RDWR | MPI.MODE_DELETE_ON_CLOSE) # NOTE here we use MPI.COMM_SELF
# process 0 starts a write sequence first
if rank == 0:
fh.Write_at(0, [buf1, 100, MPI.BYTE])
fh.Sync()
# use a Barrier synchronizition to separate process 0's write sequence from
# process 1's write sequence in time
comm.Barrier()
# process 1 starts its write sequence then
if rank == 1:
fh.Sync()
fh.Write_at(100, [buf1, 100, MPI.BYTE])
fh.Sync()
# use a Barrier synchronizition to separate process 1's write sequence from
# the next sequence in time |
comm.Barrier()
# process 1 and 2 start their read sequence
if rank == 0:
fh.Sync()
fh.Read_at(100, [buf2, 100, MPI.BYTE])
print buf2 == bytearray(range(100, 200))
elif rank == 1:
fh.Read_at(0, [buf2, 100, MPI.BYTE])
print buf2 == bytearray(range(0, 100))
# close the file
fh.Close()
运行结果如下:
$ mpiexec -n 2 python io_consistency.py
True
True
True
True
True
True
True
True
True
True
True
True
以上介绍了 mpi4py 中 I/O 操作的一致性语义,在下一篇中我们将介绍 mpi4py 中的文件互操作性。