python多处理初始化的开销比好处还差

时间:2019-05-11 22:16:28

标签: python multiprocessing

我想在python 3.7中使用trie搜索,以匹配具有某些给定单词的字符串。 特里搜索算法实际上是相当快的,但是我也想使用我的CPU拥有的所有内核。假设我的电脑有8个核心,而我想使用7个。 因此,我将单词数据库分为7个同样大的列表,并在每个列表中创建了一个trie。 (这是使代码并行化的基本思想)

但是,当我从多处理模块中调用Process()时,Process()。start()方法在真实数据库上可能要花费几秒钟。 (搜索本身大约需要一微秒)。

说实话,我还不是一个专业的程序员,这意味着我可能在代码中犯了一些重大错误。有人看到进程开始如此缓慢的原因吗?

请考虑我使用比下面的Trie更大的数据库来测试脚本。我还测试了脚本,每次仅调用1个进程,而且速度也大大降低。 我想提供更少的代码,但是我认为很高兴看到运行中的问题。如果需要,我还可以提供其他信息。

import string
import sys
import time

from multiprocessing import Process, Manager
from itertools import combinations_with_replacement


class TrieNode:

    def __init__(self):

        self.isString = False
        self.children = {}

    def insertString(self, word, root):
        currentNode = root
        for char in word:
            if char not in currentNode.children:
                currentNode.children[char] = TrieNode()
            currentNode = currentNode.children[char]
        currentNode.isString = True

    def findStrings(self, prefix, node, results):
        # Hänge das Ergebnis an, wenn ein Ende gefunden wurde
        if node.isString:
            results.append(prefix)

        for char in node.children:
            self.findStrings(prefix + char, node.children[char], results)

    def findSubStrings(self, start_prefix, root, results):
        currentNode = root
        for char in start_prefix:

            # Beende Schleife auf fehlende Prefixes oder deren Kinder
            if char not in currentNode.children:
                break
            # Wechsle zu Kindern in anderem Falle
            else:
                currentNode = currentNode.children[char]

        # Verwende findStrings Rekursiv zum auffinden von End-Knoten
        self.findStrings(start_prefix, currentNode, results)

        return results


def gen_word_list(num_words, min_word_len=4, max_word_len=10):
    wordList = []
    total_words = 0
    for long_word in combinations_with_replacement(string.ascii_lowercase, max_word_len):
        wordList.append(long_word)
        total_words += 1

        if total_words >= num_words:
            break

        for cut_length in range(1, max_word_len-min_word_len+1):
            wordList.append(long_word[:-cut_length])
            total_words += 1
            if total_words >= num_words:
                break

    return wordList


if __name__ == '__main__':
    # Sample word list

    wordList = gen_word_list(1.5 * 10**5)

    # Configs
    try:
        n_cores = int(sys.argv[-1] or 7)
    except ValueError:
        n_cores = 7

    # Repetitions to do in order to estimate the runtime of a single run
    num_repeats = 20
    real_num_repeats = n_cores * num_repeats

    # Creating Trie
    root = TrieNode()

    # Adding words
    for word in wordList:
        root.insertString(word, root)

    # Extending trie to use it on multiple cores at once
    multiroot = [root] * n_cores

    # Measure time
    print('Single process ...')
    t_0 = time.time()
    for i in range(real_num_repeats):
        r = []
        root.findSubStrings('he', root, r)
    single_proc_time = (time.time()-t_0)
    print(single_proc_time/real_num_repeats)

    # using multicore to speed up the process
    man = Manager()

    # Loop to test the multicore Solution
    # (Less repetitions are done to compare the timings to the single-core solution)
    print('\nMultiprocess ...')
    t_00 = time.time()
    p_init_time = 0
    procs_append_time = 0
    p_start_time = 0
    for i in range(num_repeats):

        # Create Share-able list
        res = man.list()

        procs = []

        for i in range(n_cores):
            t_0 = time.time()
            p = Process(target=multiroot[i].findSubStrings, args=('a', multiroot[i], res))
            t_1 = time.time()
            p_init_time += t_1 - t_0
            procs.append(p)
            t_2 = time.time()
            procs_append_time += t_2 - t_1
            p.start()
            p_start_time += time.time() - t_2

        for p in procs:
            p.join()
    multi_proc_time = time.time() - t_00
    print(multi_proc_time / real_num_repeats)
    init_overhead = p_init_time / single_proc_time
    append_overhead = procs_append_time / single_proc_time
    start_overhead = p_start_time / single_proc_time
    total_overhead = (multi_proc_time - single_proc_time) / single_proc_time
    print(f"Process(...) overhead: {init_overhead:.1%}")
    print(f"procs.append(p) overhead: {append_overhead:.1%}")
    print(f"p.start() overhead: {start_overhead:.1%}")
    print(f"Total overhead: {total_overhead:.1%}")


Single process ...
0.007229958261762347

Multiprocess ...
0.7615800397736686
Process(...) overhead: 0.9%
procs.append(p) overhead: 0.0%
p.start() overhead: 8.2%
Total overhead: 10573.8%

2 个答案:

答案 0 :(得分:3)

一般想法

要考虑的事情很多,Multiprocessing > Programming guidelines中已经描述了大多数事情。最重要的是要记住,您实际上正在使用多个进程,因此有3种(或4种)处理变量的方式:

  • ctypes共享状态变量上的同步包装器(例如 multiprocessing.Value)。实际变量始终是“一个对象” 内存,默认情况下,包装器使用“锁定”来设置/获取实数 值。

  • 代理(例如Manager().list())。这些变量类似于共享状态变量,但是放置在特殊的“服务器进程”中,并且对它们的所有操作实际上都在管理器进程和活动进程之间发送腌制的值:

    • results.append(x)腌制x并将其从管理器进程发送到进行此调用的活动进程, 然后就没腌了

    • results的任何其他访问(例如len(results),结果迭代)都涉及相同的酸洗/发送/取消酸洗过程。

    因此对于通用变量,代理通常比任何其他方法慢得多,并且在许多情况下使用管理器 与单进程运行相比,“本地”并行化将提供较差的性能。 但是经理服务器可以远程使用,因此当您希望并行化工作时使用它们是合理的 使用分布在多台机器上的工人

  • 在子流程创建期间可用的对象。对于“ fork”启动方法,子过程创建期间所有可用的对象仍然可用并且“不共享”,因此更改它们只能“局部地为子过程”更改。但是在更改它们之前,每个进程实际上都“共享”了每个此类对象的内存,所以:

    • 如果将它们用作“只读”,则不会复制或“通信”任何内容。

    • 如果更改了它们,则将它们复制到子流程中,并且副本也将被更改。这称为写时复制或COW。 请注意,重新引用对象,例如分配变量 引用它,或将其附加到列表中会增加对象的ref_count,即 被认为是“变化”。

行为也可能会根据“启动方法”而有所不同:例如对于“ spawn” /“ forkserver”方法,可更改的全局变量实际上不是由子进程看到的“相同对象”值,可能与父进程中的不一样。

因此multiroot[i](在Process(target=..., args=(..., multiroot[i], ...))中使用)的初始值是共享的,但是:

  • 如果您不使用“ fork”启动方法(默认情况下Windows不使用它),则对于每个子进程,至少对所有arg进行一次酸洗。因此,如果start很大,multiroot[i].children可能会花费很长时间。

  • 即使您使用的是fork:最初的multiroot[i]似乎是共享的,而不是被复制的,但是我不确定会发生什么 如果在findSubStrings方法内部分配了变量(例如currentNode = ...),则可能是写时复制(COW),因此TrieNode的整个实例都将被复制。

可以采取什么措施来改善这种情况:

  • 如果您使用的是fork启动方法,请确保“数据库”对象(TrieNode实例)是真实的 readonly和do n't事件中包含带有变量分配的方法。例如,您可以将findSubStrings移到另一个类,并确保在启动子过程之前调用所有instance.insertString

  • 您正在将man.list()实例用作results的{​​{1}}参数。这意味着对于每个子流程 创建了一个不同的“包装器”,并且所有findSubStrings操作都在酸洗results.append(prefix),然后将其发送 到服务器进程。如果您使用prefix的进程数量有限,那么这没什么大不了的。如果您正在产卵 大量的子流程,则可能会影响性能。而且我认为默认情况下,它们都使用“锁定”,因此并发追加迁移相对较慢。如果Pool中的项目顺序无关紧要(我对前缀树没有经验,也不记得其背后的理论),那么您可以完全避免与并发相关的任何开销 results

    • results.append方法内创建新的results列表。完全不要使用findSubStrings
    • 要获取“最终”结果:遍历res = man.list()返回的每个结果对象; 得到结果; “合并它们”。

使用弱引用

在findSubStrings中使用pool.apply_async()将导致currentNode = root的COW。这就是为什么弱引用(root)可以带来一些额外的好处。

示例

currentNodeRef = weakref.ref(root)

注意:

  • import string import sys import time import weakref from copy import deepcopy from multiprocessing import Pool from itertools import combinations_with_replacement class TrieNode: def __init__(self): self.isString = False self.children = {} def insertString(self, word, root): current_node = root for char in word: if char not in current_node.children: current_node.children[char] = TrieNode() current_node = current_node.children[char] current_node.isString = True # findStrings: not a method of TrieNode anymore, and works with reference to node. def findStrings(prefix, node_ref, results): # Hänge das Ergebnis an, wenn ein Ende gefunden wurde if node_ref().isString: results.append(prefix) for char in node_ref().children: findStrings(prefix + char, weakref.ref(node_ref().children[char]), results) # findSubStrings: not a method of TrieNode anymore, and works with reference to node. def findSubStrings(start_prefix, node_ref, results=None): if results is None: results = [] current_node_ref = node_ref for char in start_prefix: # Beende Schleife auf fehlende Prefixes oder deren Kinder if char not in current_node_ref().children: break # Wechsle zu Kindern in anderem Falle else: current_node_ref = weakref.ref(current_node_ref().children[char]) # Verwende findStrings Rekursiv zum auffinden von End-Knoten findStrings(start_prefix, current_node_ref, results) return results def gen_word_list(num_words, min_word_len=4, max_word_len=10): wordList = [] total_words = 0 for long_word in combinations_with_replacement(string.ascii_lowercase, max_word_len): wordList.append(long_word) total_words += 1 if total_words >= num_words: break for cut_length in range(1, max_word_len-min_word_len+1): wordList.append(long_word[:-cut_length]) total_words += 1 if total_words >= num_words: break return wordList if __name__ == '__main__': # Sample word list wordList = gen_word_list(1.5 * 10**5) # Configs try: n_cores = int(sys.argv[-1] or 7) except ValueError: n_cores = 7 # Repetitions to do in order to estimate the runtime of a single run real_num_repeats = 420 simulated_num_repeats = real_num_repeats // n_cores # Creating Trie root = TrieNode() # Adding words for word in wordList: root.insertString(word, root) # Create tries for subprocesses: multiroot = [deepcopy(root) for _ in range(n_cores)] # NOTE: actually all subprocesses can use the same `root`, but let's copy them to simulate # that we are using different tries when splitting job to sub-jobs # localFindSubStrings: defined after `multiroot`, so `multiroot` can be used as "shared" variable def localFindSubStrings(start_prefix, root_index=None, results=None): if root_index is None: root_ref = weakref.ref(root) else: root_ref = weakref.ref(multiroot[root_index]) return findSubStrings(start_prefix, root_ref, results) # Measure time print('Single process ...') single_proc_num_results = None t_0 = time.time() for i in range(real_num_repeats): iteration_results = localFindSubStrings('help', ) if single_proc_num_results is None: single_proc_num_results = len(iteration_results) single_proc_time = (time.time()-t_0) print(single_proc_time/real_num_repeats) # Loop to test the multicore Solution # (Less repetitions are done to compare the timings to the single-core solution) print('\nMultiprocess ...') p_init_time = 0 apply_async_time = 0 results_join_time = 0 # Should processes be joined between repeats (simulate single job on multiple cores) or not (simulate multiple jobs running simultaneously) PARALLEL_REPEATS = True if PARALLEL_REPEATS: t_0 = time.time() pool = Pool(processes=n_cores) t_1 = time.time() p_init_time += t_1 - t_0 async_results = [] final_results = [] t_00 = time.time() for repeat_num in range(simulated_num_repeats): final_result = [] final_results.append(final_result) if not PARALLEL_REPEATS: t_0 = time.time() pool = Pool(processes=n_cores) t_1 = time.time() p_init_time += t_1 - t_0 async_results = [] else: t_1 = time.time() async_results.append( ( final_result, pool.starmap_async( localFindSubStrings, [('help', core_num) for core_num in range(n_cores)], ) ) ) t_2 = time.time() apply_async_time += t_2 - t_1 if not PARALLEL_REPEATS: for _, a_res in async_results: for result_part in a_res.get(): t_3 = time.time() final_result.extend(result_part) results_join_time += time.time() - t_3 pool.close() pool.join() if PARALLEL_REPEATS: for final_result, a_res in async_results: for result_part in a_res.get(): t_3 = time.time() final_result.extend(result_part) results_join_time += time.time() - t_3 pool.close() pool.join() multi_proc_time = time.time() - t_00 # Work is not really parallelized, instead it's just 'duplicated' over cores, # and so we divide using `real_num_repeats` (not `simulated_num_repeats`) print(multi_proc_time / real_num_repeats) init_overhead = p_init_time / single_proc_time apply_async_overhead = apply_async_time / single_proc_time results_join_percent = results_join_time / single_proc_time total_overhead = (multi_proc_time - single_proc_time) / single_proc_time print(f"Pool(...) overhead: {init_overhead:.1%}") print(f"pool.starmap_async(...) overhead: {apply_async_overhead:.1%}") print(f"Results join time percent: {results_join_percent:.1%}") print(f"Total overhead: {total_overhead:.1%}") for iteration_results in final_results: num_results = len(iteration_results) / n_cores if num_results != single_proc_num_results: raise AssertionError(f'length of results should not change! {num_results} != {single_proc_num_results}') 模拟多个作业的运行(例如,每个作业应以不同的前缀启动,但在示例中,我使用相同的前缀来使每次运行具有一致的“负载”),并且每个作业均为“并行化”。
  • PARALLEL_REPEATS=True模拟单个作业的运行 在所有内核上并行化,并且比单进程慢 解决方案。
  • 似乎并行性只有在每个 池中的工作程序被发出PARALLEL_REPEATS=False超过1次。

示例输出:

apply_async

答案 1 :(得分:0)

首先,我要感谢所有参与其中的人,因为每个答案都为解决方案做出了贡献。

正如第一批评论所指出的,每次创建一个新进程都会导致python将所需的数据转移到该进程中。这可能需要花费几秒钟的时间,并且会导致不希望的延迟。

为我带来了最终解决方案的是,在程序启动期间,一次使用多处理库的Process类创建了一次进程(每个内核一个)。

然后您可以使用同一模块的Pipe类与流程进行通信。

我发现这里的乒乓示例确实有帮助:https://www.youtube.com/watch?v=s1SkCYMnfbY&t=900s

它仍然不是最佳选择,因为试图同时与该进程通信的多个管道会导致该进程崩溃。

但是,我应该能够使用队列解决此问题。如果有人对解决方案感兴趣,请随时询问。