多进程:只使用物理核心?

39

我有一个函数foo,它消耗大量内存,并且我想并行运行几个实例。

假设我的CPU有4个物理核心,每个核心有两个逻辑核心。

我的系统可以容纳4个foo实例并行,但不能容纳8个。此外,由于这8个核心中有4个是逻辑核心,因此我不希望使用所有8个核心能够提供超出仅使用4个物理核心的收益。

因此,我想在仅限4个物理核心上运行foo。换句话说,我希望确保multiprocessing.Pool(4)(4是由于内存限制我可以同时运行该函数的最大数量)将作业分发到四个物理核心(而不是例如两个物理核心及其两个逻辑后代的组合)。

如何在Python中实现?

编辑:

我之前使用了来自multiprocessing的代码示例,但我不依赖于任何库,为避免混淆,我已将其删除。


1
@GáborErdős: 你确定吗?import psutils``psutil.cpu_count(logical=False)似乎知道区别。 - user189035
@Yugi:不,我不认为这是一个重复的问题,尽管我的问题可能表达得不够准确(在强调“全部”部分上有些过分)。 - user189035
@zvone:有趣:如果我调用multiprocessing.Pool(4),并且没有其他进程运行,那么只有物理核心会被调用来执行计算? - user189035
1
我不太确定,但我猜操作系统应该足够聪明,如果那是最优的话,它就会这样做。 - zvone
1
@zvone:“你不能从一个应该得到的东西中获得实际存在的东西。”在其他语言中(例如R),多进程有一个特定选项,只能汇集物理核心。因此,不能假设操作系统会智能地管理它。 - user189035
显示剩余4条评论
3个回答

86

我知道这个话题现在已经很老了,但由于它仍然是谷歌搜索“multiprocessing logical core”时的第一个答案......我觉得我必须给出一个额外的答案,因为我可以看到2018年甚至更晚的人们在这里很容易感到困惑(有些答案确实有点令人困惑)。

我认为这里没有比这更好的地方来警告读者一些上面的答案,所以抱歉再次将话题提起。

-->要计算CPU(逻辑/物理)请使用PSUTIL模块

对于一个4核心/8线程i7来说,它将返回:

import psutil 
psutil.cpu_count(logical = False)

4

psutil.cpu_count(logical = True)

8

这就是如此简单。

您不必担心操作系统,平台,硬件或任何其他问题。 我确信它比multiprocessing.cpu_count()好得多,后者有时会给出奇怪的结果,据我个人经验。

--> 要使用N个物理核心(由您选择),请使用Yugi描述的multiprocessing模块。

只需计算您有多少个物理处理器,启动一个具有4个工作进程的multiprocessing.Pool。

或者还可以尝试使用joblib.Parallel()函数。

2018年的joblib不是Python标准分发的一部分,而只是multiprocessing模块的包装器,正如Yugi所述。

--> 大多数情况下,不要使用超过可用核心数量的核心(除非您已经针对非常特定的代码进行了基准测试并证明值得这样做)

关于“如果指定了超过可用核心数量的核心,操作系统将处理这些事情”的错误信息充斥着各处。 这是绝对错误的。 如果你使用比可用核心更多的核心,你将面临巨大的性能下降。 例外情况是如果工作进程是I / O限制的。 因为操作系统调度程序将尽其所能处理每个任务并分别工作,根据情况,在频繁切换的过程中最多可能花费100%的工作时间,这将是灾难性的。

不要只是相信我:试试它,进行基准测试,您会看到它是多么清晰明了。

--> 是否可以决定代码在逻辑核心还是物理核心上执行?

如果您问这个问题,则意味着您不了解物理和逻辑核心的设计方式,因此您可能应该更多地了解处理器的体系结构。

例如,如果您想在核心3而不是核心1上运行,则可能确实有一些解决方案,但仅当您知道如何编写操作系统的内核和调度程序时才可用,我认为如果您问这个问题就不是这种情况。

如果您在4个物理/8个逻辑处理器上启动4个CPU密集型进程,则调度程序将将每个进程分配给1个不同的物理核心(4个逻辑核心将保持未被使用/较差)。 但是,在4个逻辑/8个线程处理器上,如果处理单元是(0,1)(1,2)(2,3)(4,5)(5,6)(6,7),则无论进程在0号还是1号执行,都没有区别: 它是同一处理单元。

据我所知(但专家可能会确认,也许它与非常特定的硬件规格有所不同),在0或1上执行代码没有或几乎没有差异。在处理单元(0,1)中,我不确定0是逻辑还是物理,还是反之。从我的理解来看(可能是错误的),两者都是来自同一处理单元的处理器,它们只是共享它们的高速缓存/对硬件(包括RAM)的访问,并且0并不比1更是物理单位。

此外,您应该让操作系统决定。因为操作系统调度程序可以利用某些平台上存在的硬件逻辑内核加速(例如i7、i5、i3等),这是您无法控制的其他东西,而这可能真正对您有所帮助。

如果在4个物理/8个逻辑核心上启动5个CPU密集型任务,则行为将是混乱的,几乎是不可预测的,主要取决于您的硬件和操作系统。调度程序会尽力而为。几乎每次,您都会面临非常糟糕的性能。

假设我们仍然谈论的是4(8)经典架构:因为调度程序尽最大努力(因此经常切换属性),所以,根据您正在执行的进程,启动5个逻辑核心可能比启动8个逻辑核心更糟糕(在后者中,至少他知道一切将以100%使用,所以输了输了,他不会太努力避免它,不会经常切换,因此不会浪费太多时间)。

然而,几乎可以肯定地说(但要在硬件上进行基准测试以确保):如果使用超过可用物理核心的物理核心,则几乎任何多进程程序都会运行得更慢。

许多事情可能会干扰...... 程序、硬件、操作系统的状态、它使用的调度程序、您今天早晨吃的水果、您姐姐的名字... 如果您对某些事情持怀疑态度,只需进行基准测试即可,没有其他简单的方法来确定您是否失去了性能。有时计算机科学确实很奇怪。

--> 在Python中,大多数时间附加逻辑核是无用的(但并非总是如此)

在Python中进行真正并行任务的主要方法有两种。

  • 多进程(无法利用逻辑核心)
  • 多线程(可以利用逻辑核心)

例如,要并行运行4个任务

--> 多进程将创建4个不同的Python解释器。对于每个解释器,您必须启动一个Python解释器、定义读/写权限、定义环境、分配大量内存等。不妨直说:您将从0开始启动一个全新的程序实例。它可能需要大量时间,因此您必须确信这个新程序将工作足够长时间,以便它值得。

如果你的程序有足够多的工作量(至少几秒钟的工作时间),那么由于操作系统会将消耗CPU的进程分配到不同的物理核心上,程序可以快速运行并获得很高的性能表现,这是非常好的。由于操作系统几乎总是允许进程之间进行通信(虽然速度较慢),它们甚至可以交换一些数据。

--> 多线程编程则完全不同。在Python解释器内,它只会创建一个小的内存块,多个CPU可以同时共享和使用该内存块。创建线程比创建新进程要快得多(在旧电脑上创建新进程有时需要花费很长的时间,而创建线程只需要极短的时间)。您不需要创建新的进程,而是创建更轻的“线程”。

线程可以非常快速地在线程之间共享内存,因为它们实际上在同一块内存上同时操作(而在处理不同进程时,则必须复制/交换内存)。

但:为什么大多数情况下不能使用多线程?看起来非常方便?

在Python中有一个非常大的限制:在Python解释器中只能执行一条语句,这称为全局解释器锁(GIL)。因此,在大多数情况下,使用多线程甚至会降低性能,因为不同的线程必须等待访问相同的资源。对于纯计算处理(没有IO),多线程是无用的,如果您的代码是纯Python,则甚至更糟。但是,如果您的线程涉及任何等待IO,则多线程可能非常有益。

-->在使用多进程时,为什么不能使用逻辑核心?

逻辑内核没有自己的内存访问。它们只能在其所属物理处理器的内存访问和缓存上工作。例如,同一处理单元的逻辑核心和物理核心很可能同时在缓存内存的不同位置上使用相同的C/C++函数,从而使处理速度大大加快。

然而,这些是C/C++函数!Python是一个大型的C/C++封装器,需要比其等效的C++代码更多的内存和CPU。很可能在2018年,无论你想做什么,两个大的python进程将需要比单个物理+逻辑单元承受得起的更多的内存和缓存读写,并且比等效的C/C++真正多线程的代码消耗更多。这几乎总是会导致性能下降。请记住,每个不在处理器缓存中的变量,在内存中读取的时间将增加1000倍。如果你的缓存已经完全满了,强制使用2个进程,那么你猜会发生什么:它们将一次一个地使用它,永久切换,导致数据在每次切换时被愚蠢地刷新和重新读取。当数据从内存中读取或写入时,你可能认为你的CPU“正在”工作,但事实并非如此。它正在等待数据!什么都没做。

--> 那么你如何利用逻辑核心呢?

像我说的,默认的Python没有真正的多线程(因此不能真正使用逻辑核心),因为有全局解释器锁。你可以在程序的某些部分强制删除GIL,但如果你不知道自己在做什么,我认为最好不要这样做。

去除GIL绝对是很多研究的课题(请查看试验性的PyPy或Cython项目,它们都试图这样做)。

目前还没有真正的解决方案,因为它比看起来复杂得多。

我承认,还有另一种可以起作用的解决方案:

  • 用C编写函数
  • 使用ctype将其包装到Python中
  • 使用Python多线程模块调用你封装的C函数

这将100%起作用,并且你将能够在Python中使用所有逻辑核心进行真正的多线程处理。 GIL不会打扰你,因为你不会执行真正的Python函数,而是执行C函数。

例如,一些库如NumPy可以在所有可用的线程上工作,因为它们是用C编写的。但如果您到达此点,我总是认为直接用C/C++编写程序可能是一个明智的考虑,因为这与原始的Python精神有很大区别。

**--> 别总是使用所有可用的物理内核 **

我经常看到人们这样做:“好的,我有8个物理内核,所以我将使用8个内核来处理我的工作。”它经常可以正常工作,但有时候会变得不太可取,特别是如果您的工作需要大量I/O。

对于高I/O需求的任务,尝试使用N-1个核心,并且你会发现在每个任务/平均值上,单个任务总是在N-1个内核上运行得更快。实际上,你的计算机执行许多不同的任务:USB、鼠标、键盘、网络、硬盘等等...即使在一个工作站上,周期性任务也会在后台执行,而你并不知道。如果你不让一个物理核心来管理这些任务,你的计算将会被定期中断(从内存中刷出/重新放回内存),这也会导致性能问题。

你可能会认为:“嗯,后台任务只会使用5%的CPU时间,所以还剩下95%”。但事实并非如此。

处理器一次只能处理一个任务。每次切换时,需要浪费相当大的时间将所有东西放回内存缓存/寄存器中的原位。然后,如果由于某种奇怪的原因,操作系统调度程序太频繁地进行此切换(这是无法控制的),所有这些计算时间都会永久丢失,你无能为力。

如果(有时会发生)由于某种未知原因,调度程序问题影响了30个任务的性能,而不仅仅是1个任务,这可能导致非常有趣的情况,即在29/30个物理核心上运行会比在30/30个物理核心上运行显着更快。

更多的CPU并不总是最好的

在使用multiprocessing.Pool时,很频繁地使用一个共享于进程之间的multiprocessing.Queue或管理器队列,以允许它们之间进行基本通信。但有时(我可能已经说过100次了,但我重申一下),在硬件相关的情况下,使用更多的CPU可能会在进程间通信/同步时创建瓶颈(但你应该为你特定的应用程序、代码实现和硬件进行基准测试)。在这些特定情况下,运行在较低的CPU数量上可能是有趣的,甚至尝试将同步任务委托给更快的处理器(当然,这里指的是在集群上运行的科学密集计算)。由于多进程通常被用于集群上,你必须注意到集群通常会因为节省能源而降低频率。因此,单核性能可能非常糟糕(通过更高数量的CPU来平衡),当你从本地计算机(少量核心,高单核性能)扩展你的代码到集群(大量核心,较低的单核性能)时,你的代码会根据单核性能/内核数量比率出现瓶颈,这使得有时非常恼人。

每个人都有使用尽可能多CPU的冲动。但是,针对这些情况进行基准测试是必需的。

典型的情况(例如在数据科学中)是有N个进程并行运行,并且你想要将结果汇总到一个文件中。因为你无法等待作业完成,所以通过一个特定的写入进程来完成它。写入程序将在他的multiprocessing.Queue中写入输出文件中的所有内容(单核且硬盘受限的过程)。N个进程填充multiprocessing.Queue。

想象一下,如果您有31个CPU向一个非常慢的CPU写入信息,那么您的性能将会降低(如果超过系统处理临时数据的能力,则可能会崩溃)。

--> 要点

  • 使用psutil来计算逻辑/物理处理器,而不是multiprocessing.cpu_count()或任何其他方法
  • 多进程只能在物理核心上工作(或者至少要进行基准测试来证明在您的情况下这不是真的)
  • 多线程可以在逻辑核心上工作,但您需要用C编写并包装您的函数,或者删除全局锁定解释器(每次这样做时,世界某个地方就会有一只小猫惨遭毒手)
  • 如果您正在尝试在纯Python代码上运行多线程,性能将会大幅下降,因此您99%的时间应该使用多进程
  • 除非您的进程/线程有很长的暂停时间可以利用,否则永远不要使用超过可用数量的核心,并且如果您想尝试,请正确进行基准测试
  • 如果您的任务需要进行I/O操作,应该让1个物理核心来处理I/O。如果您有足够的物理核心,这将是值得的。对于多进程实现,它需要使用N-1个物理核心。对于经典的双向多线程,这意味着要使用N-2个逻辑核心。
  • 如果您需要更高的性能,请尝试PyPy(不适用于生产)或Cython,甚至可以用C编写代码

最后但并非最不重要的:如果您真的寻求性能,您应该绝对、始终、始终进行基准测试,而不是猜测任何事情。基准测试常常会揭示出奇怪的平台/硬件/驱动程序非常特定的行为,这些您根本不知道。


10
“如果你的代码是纯Python,那么多线程通常是无用的甚至更糟。” - 不是这样的。如果你的代码涉及大量IO操作,比如网页爬虫,单独的线程将在等待操作系统返回数据(比如套接字/文件)时释放全局解释器锁(GIL)...在这种情况下,我曾经看到过使用基于线程的并行化几乎实现了线性性能提升(我的项目是一个纯Python的BT客户端)。 - Shihab Shahriar Khan

17

注意: 这种方法在Windows上不起作用,只在Linux上进行了测试。

使用multiprocessing.Process:

使用Process()时,为每个进程分配一个物理核心非常容易。您可以创建一个for循环,遍历每个核心,并使用taskset -p [mask] [pid]将新进程分配到新核心:

import multiprocessing
import os

def foo():
    return

if __name__ == "__main__" :
    for process_idx in range(multiprocessing.cpu_count()):
        p = multiprocessing.Process(target=foo)
        os.system("taskset -p -c %d %d" % (process_idx % multiprocessing.cpu_count(), os.getpid()))
        p.start()

我在工作站上有32个内核,所以我将在这里放置部分结果:

pid 520811's current affinity list: 0-31
pid 520811's new affinity list: 0
pid 520811's current affinity list: 0
pid 520811's new affinity list: 1
pid 520811's current affinity list: 1
pid 520811's new affinity list: 2
pid 520811's current affinity list: 2
pid 520811's new affinity list: 3
pid 520811's current affinity list: 3
pid 520811's new affinity list: 4
pid 520811's current affinity list: 4
pid 520811's new affinity list: 5
...

正如您所看到的,这里列出了每个进程的先前和新亲和力。第一个进程是针对所有核心(0-31)的,然后被分配给核心0,第二个进程默认分配给核心0,然后将其亲和力更改为下一个核心(1),如此类推。

使用multiprocessing.Pool

警告:此方法需要调整pool.py模块,因为我不知道您可以从Pool()中提取pid的方式。此外,这些更改已在python 2.7multiprocessing.__version__ ='0.70a1'上进行了测试。

Pool.py中,找到调用_task_handler_start()方法的行。在下一行,您可以使用以下代码将进程池中的进程分配给每个“物理”核心(我在此处放置了import os,以便读者不会忘记导入它):

import os
for worker in range(len(self._pool)):
    p = self._pool[worker]
    os.system("taskset -p -c %d %d" % (worker % cpu_count(), p.pid))

完成了。测试:

import multiprocessing

def foo(i):
    return

if __name__ == "__main__" :
    pool = multiprocessing.Pool(multiprocessing.cpu_count())
    pool.map(foo,'iterable here')

结果:

pid 524730's current affinity list: 0-31
pid 524730's new affinity list: 0
pid 524731's current affinity list: 0-31
pid 524731's new affinity list: 1
pid 524732's current affinity list: 0-31
pid 524732's new affinity list: 2
pid 524733's current affinity list: 0-31
pid 524733's new affinity list: 3
pid 524734's current affinity list: 0-31
pid 524734's new affinity list: 4
pid 524735's current affinity list: 0-31
pid 524735's new affinity list: 5
...
请注意,这个对pool.py的修改将任务轮流分配给核心。所以,如果你分配的任务比cpu核心更多,你最终会有多个任务在同一个核心上运行。
编辑:
OP想要的是一个能够在特定核心上启动池的pool()。为此,需要对multiprocessing进行更多的调整(首先撤销上述更改)。
警告:
不要尝试复制和粘贴函数定义和函数调用。只需复制并粘贴应该在self._worker_handler.start()之后添加的部分(如下所示)。请注意,我的multiprocessing.__version__告诉我版本为'0.70a1',但只要您添加所需内容即可,版本并不重要。 multiprocessingpool.py
__init__()定义中添加cores_idx = None参数。在我的版本中,添加后它看起来像这样:
def __init__(self, processes=None, initializer=None, initargs=(),
             maxtasksperchild=None,cores_idx=None)

此外,您应该在self._worker_handler.start()之后添加以下代码:

if not cores_idx is None:
    import os
    for worker in range(len(self._pool)):
        p = self._pool[worker]
        os.system("taskset -p -c %d %d" % (cores_idx[worker % (len(cores_idx))], p.pid))

multiprocessing__init__.py:

Pool()的定义中添加一个cores_idx=None参数,以及在返回部分中的其他Pool()函数调用中。在我的版本中,它看起来像:

def Pool(processes=None, initializer=None, initargs=(), maxtasksperchild=None,cores_idx=None):
    '''
    Returns a process pool object
    '''
    from multiprocessing.pool import Pool
    return Pool(processes, initializer, initargs, maxtasksperchild,cores_idx)

完成了。以下示例在仅使用0号和2号核心上运行5个工作进程池:

import multiprocessing


def foo(i):
    return

if __name__ == "__main__":
    pool = multiprocessing.Pool(processes=5,cores_idx=[0,2])
    pool.map(foo,'iterable here')

结果:

pid 705235's current affinity list: 0-31
pid 705235's new affinity list: 0
pid 705236's current affinity list: 0-31
pid 705236's new affinity list: 2
pid 705237's current affinity list: 0-31
pid 705237's new affinity list: 0
pid 705238's current affinity list: 0-31
pid 705238's new affinity list: 2
pid 705239's current affinity list: 0-31
pid 705239's new affinity list: 0
当然,您仍然可以像删除“cores_idx”参数一样拥有multiprocessing.Poll()的通常功能。

1
@user189035,你在实现这个过程中遇到了什么具体的问题吗?因为使用multiprocessing.Process部分似乎可以很好地实现我的答案。除非我漏掉了什么。 - Kennet Celeste
@user189035 或许我漏掉了什么,因为现在我想起来,可能需要两者的结合。但如果你遇到麻烦,请告诉我,我会处理它。 - Kennet Celeste
我不理解你最后的评论。我也很难理解你的回答。能否请您追加您的答案,以显示如何查看 foo 的实例是否确实在物理核心上运行,而不是逻辑核心? - user189035
1
@user189035 cores_idx 参数是一个列表,你可以在其中指定 CPU 核心。不要分配比你的 CPU 核心更高的索引,否则会引发异常(我应该放置 asserts)。例如,cores_idx=[0] 仅使用核心 0,cores_idx=[0,1,2,3] 使用前 4 个核心。如果你不放置 cores_idx,任何/所有核心都可能像平常一样被使用。 - Kennet Celeste
@user189035使用multiprocessing.__version__,我的版本是0.70a1。不要紧,只需按照我提到的添加cores_idx即可。 - Kennet Celeste
显示剩余6条评论

2
我找到了一种解决方案,它不需要更改python模块的源代码。它使用了这里建议的方法。运行该脚本后,可以通过执行以下命令来检查只有物理核心处于活动状态:
lscpu

在bash中返回:

CPU(s):                8
On-line CPU(s) list:   0,2,4,6
Off-line CPU(s) list:  1,3,5,7
Thread(s) per core:    1

[可以从Python中运行上面链接的脚本。] 无论如何,在运行上述脚本后,在Python中键入以下命令:

import multiprocessing
multiprocessing.cpu_count()

返回4。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接