【问题标题】:Performance gain using MPI使用 MPI 获得性能提升
【发布时间】:2011-07-17 08:05:32
【问题描述】:

我测试了将第一个 N 整数相加的(几乎)“令人尴尬的并行”(即完全可并行化)算法并行化的性能增益:

串行算法很简单:

N = 100000000
print sum(range(N))

在我的双核笔记本电脑 (Lenovo X200) 上的执行时间:0m21.111s。

并行化(使用 mpi4py)版本使用 3 个节点;节点 0 计算整数下半部分的总和,节点 1 计算上半部分的总和。两者都将结果(通过comm.send)发送到节点 2,节点 2 将两个数字相加并打印结果:

from mpi4py import MPI

comm = MPI.COMM_WORLD
rank = comm.Get_rank()

N = 100000000

if rank == 0: 
  s = sum(range(N/2))
  comm.send(s,dest=2,tag=11)
elif rank == 1:
  s = sum(range(N/2+1,N))
  comm.send(s,dest=2,tag=11)
elif rank == 2:
  s1 = comm.recv(source=0, tag=11)
  s2 = comm.recv(source=1, tag=11)
  print s1+s2

我的双核笔记本的两个核心都用完了;现在执行时间:15.746s。

我的问题:至少在理论上,执行时间应该接近一半。哪个开销吃掉了丢失的 4 秒? (当然不是 s1+s2)。那些发送/接收命令那么耗时吗??

编辑:在阅读答案并重新思考问题后,我认为 4 秒(在某些运行中甚至超过)被生成两个列表导致的高内存流量吃掉了长度 50000000;我的笔记本电脑的两个内核共享一个公共内存(至少是主内存;我认为它们有单独的 L2 缓存),这正是瓶颈:所以,很多时候,两个内核都想同时访问内存(为了获取下一个列表元素),其中一个必须等​​待......

如果我使用xrange 而不是range,则会延迟生成下一个列表元素并且分配的内存很少。 我对其进行了测试,使用 xrange 运行与上述相同的程序只需 11 秒!

【问题讨论】:

  • “执行时间应该差不多减半”?真的吗?为什么?我还没有看到这个理论结果。阿姆达尔定律没有说明这一点。你能提供一些关于这个理论的链接或参考吗?
  • 你在什么硬件上?这可能是来自两个内核的并发内存访问降低缓存效率的问题。如果将range() 替换为xrange() 会发生什么情况,从而几乎消除了内存访问?
  • @Sven Marnach:这是一个很好的建议——我会试试的。

标签: python parallel-processing mpi


【解决方案1】:

你的时间安排如何,你的笔记本电脑是什么?

如果您从 shell 进行计时,您可能(正如 BiggAl 建议的那样)在启动 python 时遇到延迟。这是真正的开销,值得了解,但可能不是您最关心的问题。而且我无法想象这会导致 4 秒的开销... [编辑添加:尽管 BiggAl 建议它确实可能是,在 Windows 下]

我认为更可能的担忧是内存带宽限制。虽然您将通过此设置充分使用两个内核,但您只有这么多的内存带宽,这可能最终成为这里的限制。每个内核都试图写入大量数据(范围(N/2)),然后将其读入(总和)以进行相当适度的计算(一个 整数),所以我怀疑计算不是瓶颈。

我在 Nehalem 机器上使用 timeit 运行了相同的设置,每个内核的内存带宽非常好,并且确实获得了预期的加速:

from mpi4py import MPI
import timeit

comm = MPI.COMM_WORLD
rank = comm.Get_rank()

N = 10000000

def parSum():
    if rank == 0:
        ...etc

def serSum():
    s = sum(range(N))

if rank == 0:
    print 'Parallel time:'
    tp = timeit.Timer("parSum()","from __main__ import parSum")
    print tp.timeit(number=10)

    print 'Serial time:'
    ts = timeit.Timer("serSum()","from __main__ import serSum")
    print ts.timeit(number=10)

我是从那里得到的

$ mpirun -np 3 python ./sum.py
Parallel time:
1.91955494881
Serial time:
3.84715008736

如果您认为这是内存带宽问题,您可以通过计算进行测试 人工计算繁重;说使用 numpy 并对更复杂的范围函数求和:sum(numpy.sin(range(N/2+1,N))),比如说。这应该会使平衡从内存访问转向计算。

【讨论】:

  • 我没有想到这一点,但是请记住,由于缺少写时复制,在 Windows 上分叉成本要高得多 - 我发现创建第二个进程可能需要整个第二个在我的机器上,然后根据库加载时间,您最终可以等待库在每个进程中加载​​。正如一个比我更聪明的人曾经说过的 - parallel is good when copy is cheap。不过有趣的点。
  • 嗯;有趣的;我不做很多Windows的东西,所以这对我来说是新的。通过进行两组计时——一组在 mpirun 之外(例如,在 shell 中)和一组在程序内,只包装计算(一旦 mpirun 已经启动了两个进程),一个应该能够处理这种影响的大小,以及这是否是罪魁祸首。
  • @BiggAl:在我的机器上,Windows XP 在虚拟化环境中运行非常缓慢,运行一个空的 Python 脚本只需不到 0.1 秒。
  • @Sven 很有趣 - 是来自 shell 还是空闲?如果你导入一些东西,比如os,怎么样?这比我桌上的物理双核 XP 机器启动我尝试过的任何东西都要快得多......无论如何 - 我说的是复制状态 - 如果你在 unix 上分叉,你将对大多数事情使用共享状态,直到一个进程发生变化他们。在 Windows 上,所有内容都会立即复制。
  • @BiggAl:来自cmd,但 Python 可能已经缓存在内存中。添加一些导入不会改变任何东西。复制状态预计比从磁盘加载二进制文件要快,但这无关紧要,因为它不会在这里发生——使用 MPI 时,进程是单独启动的。
【解决方案2】:

接下来,我假设您使用的是 Python 2.x。

根据笔记本电脑的硬件规格,进程 0 和 1 之间可能存在严重的内存争用。

range(100000000/2) 在我的 PC 上创建了一个占用 1.5GB RAM 的列表,因此您正在查看两个进程之间的 3GB RAM。使用两个核心来迭代两个列表可能会导致内存带宽问题(和/或交换)。这是并行化不完善的最可能原因。

使用 xrange 而不是 range 不会生成列表,并且应该通过使计算受 CPU 限制来更好地并行化。

顺便说一句,您的代码中有一个错误:第二个(x)range 应该以N/2 开头,而不是N/2+1

【讨论】:

    【解决方案3】:

    我的问题:至少在理论上,执行时间应该接近一半。哪个开销会消耗掉丢失的 4 秒?

    一些想法:

    • 您使用的是 python 2 吗?如果是这样,请使用xrange,因为它创建了一个生成器/迭代器对象。它可以节省一些毫秒,因为range 将创建一个它不断添加的完全成熟的字典,而xrange 不会。如果使用 python 3,range 默认创建一个迭代器。这可能不会在实践中为您节省太多时间/内存,但 python 开发人员显然认为值得将所有东西都实现为生成器,因为这是 python 3 中的一件大事。
    • 理论上,算法位应该快 2 倍。实际上,它比这更复杂。在算法开始时设置线程或进程是有成本的,这会增加运行时间;最后,最后同步结果是有代价的(等待加入)。所以 2 倍的速度提升永远不会真正实现。对于任何算法的小值,众所周知,串行算法优于线程算法。只有当您达到一个数量级时,线程创建的成本与要完成的工作相比可以忽略不计,您才会注意到速度显着提高。
    • 工作平衡可能是个问题。在 32 位系统上,可以放入寄存器的最大数字大小(因此在给定数字大小的情况下添加 O(1))是 4294967296 (2^32)。您的总和(在较大的值下)为 4999999950000000。Bignum 加法对于您需要的肢体(数组中的元素)的数量是 O(n),因此,一旦您开始使用 bignum,而不是任何您可以使用的东西,您就会减速处理单个内​​存地址。

      y = 0
      for x in xrange(1, 100000000):
          if (x+y) > 2**32:
              print "X is " + str(x)
              print "y is " + str(y)
              break
          else:
              y += x
      

      这向您展示了 N 中的 n 加法开始变得更加昂贵。我会尝试将总和计时到该值以及从那里到 N 的值总和,然后调整您的工作队列,以便您在适当的时间拆分。

      当然,在 64 位系统上你不应该注意到这个问题,因为 2^64 大于你的总和,除非python内部不使用uint64_t。我会认为是的。

    【讨论】:

      【解决方案4】:

      请阅读此Amdahl's Law

      您的操作系统包含大量不可并行的瓶颈。您的语言库也可能存在一些瓶颈。

      有趣的是,您的英特尔硬件的内存写入顺序也可能存在一些不可并行的瓶颈。

      【讨论】:

      • 对于这类问题,您实际上会期望加速接近 2。我将类似但更复杂的问题并行化到两个内核,实际上确实得到了通过在 AMD 和 Intel 处理器上使用线程和 MPI,速度几乎提高了 2 倍。 OP 只获得了 1.34 的加速这一事实真的很奇怪,不能用这种一般的推理来解释。
      【解决方案5】:

      负载平衡是一种理论,也会有明显的通信延迟,但我不认为这些中的任何一个,即使结合起来,也会有那么大的性能损失。我猜你最大的开销是启动 2 个以上的 python 解释器实例。希望如果您尝试使用更大的数字,您应该会发现开销实际上并没有与 N 成比例,而是实际上是一个很大的常数加上一个依赖于 N 的项。因此,您可能希望阻止算法并行处理数字不到性能提升的某个量。

      我对 mpi 并不十分熟悉,但是您最好在应用程序开始时创建一个工作人员池并让他们等待任务,而不是即时创建它们。这需要更复杂的设计,但每次应用程序运行只会产生一次解释器初始化惩罚。

      【讨论】:

        【解决方案6】:

        我编写了一些代码来测试 mpi 基础架构的哪些部分占用了时间。此版本的代码可以使用从 1 到很多很多的任意数量的内核。工作在核心之间平均分配,然后发送回主机 0 到总计。主机 0 也可以工作。

        import time
        
        t = time.time()
        import pypar
        print 'pypar init time', time.time()-t, 'seconds'
        
        rank = pypar.rank()
        hosts = pypar.size()
        
        N = 100000000
        
        nStart = (N/hosts) * rank
        if rank==hosts-1:
            nStop = N
        else:
            nStop = ( ((N/hosts) * (rank+1)) )
        print rank, 'working on', nStart, 'to', nStop
        
        t = time.time()
        s = sum(xrange(nStart,nStop))
        if rank == 0:
            for p in range(1,hosts):
                s += pypar.receive(p)
                pypar.send(s,p) 
        else:
            pypar.send(s,0) 
            s = pypar.receive(0)
        if rank==0:
            print rank, 'total', s, 'in', time.time()-t, 'seconds'
        pypar.Finalize()
        

        结果:

        pypar init time 1.68600010872 seconds
        1 working on 12500000 to 25000000
        pypar init time 1.80400013924 seconds
        2 working on 25000000 to 37500000
        pypar init time 1.98699998856 seconds
        3 working on 37500000 to 50000000
        pypar init time 2.16499996185 seconds
        4 working on 50000000 to 62500000
        Pypar (version 2.1.4.7) initialised MPI OK with 8 processors
        pypar init time 1.5720000267 seconds
        0 working on 0 to 12500000
        0 total 4999999950000000 in 1.40100002289 seconds
        pypar init time 2.34000015259 seconds
        6 working on 75000000 to 87500000
        pypar init time 2.64600014687 seconds
        7 working on 87500000 to 100000000
        pypar init time 2.23900008202 seconds
        5 working on 62500000 to 75000000
        

        启动 pypar 和 mpi 库大约需要 2.5 秒。然后实际工作需要 1.4 秒,计算并与主机 0 通信。作为单核运行大约需要 11 秒。所以使用 8 核可以很好地扩展。

        启动 mpiexec 和 python 几乎不需要任何时间。正如这个可悲的测试所示:

        c:\Data\python speed testing>time  0<enter.txt
        The current time is: 10:13:07.03
        Enter the new time:
        
        c:\Data\python speed testing>mpiexec -n 1 python printTime.py
        time.struct_time(tm_year=2011, tm_mon=8, tm_mday=4, tm_hour=10, tm_min=13, tm_sec=7, tm_wday=3, tm_yday=216, tm_isdst=0)
        

        将运行求和的实际时间与设置数据和库的时间分开,可以很好地扩展性能改进。

        【讨论】:

          【解决方案7】:

          这可能是一个糟糕的负载平衡:节点 0 的工作量少于节点 1,因为对较低的 N/2 整数求和比对较高的 N/2 整数求和要快。因此,节点 2 很早就从节点 0 收到消息,并且必须等待节点 1 的时间相对较长。

          编辑:Sven Marnach 是对的;这不是负载平衡,因为 sum(range(N))sum(range(N,2*N)) 需要相同的时间。

          【讨论】:

          • 您可以打印从节点 2 中的注释 0 和 1 接收消息的时间并验证该假设。我同意。
          • 我认为这是一方面,但可能还有更多的原因 - sum(int) 和 sum(long) 性能之间会有差异,但我有其他理论关于什么正在产生额外的四秒差异。
          • 几乎可以肯定,这不是由于负载平衡问题。我在我的机器上计时了sum(range(N/2))sum(range(N//2 + 1, N)),它们所用的时间几乎完全相同。
          猜你喜欢
          • 1970-01-01
          • 1970-01-01
          • 2012-01-15
          • 1970-01-01
          • 2013-01-30
          • 2014-09-06
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          相关资源
          最近更新 更多