Python多进程写入共享字典速度过慢如何优化
Python多进程批量处理文档性能低于单核问题的排查与优化
问题场景
批量处理文档时,需将各文档的处理结果汇总存储,使用Python multiprocessing 模块实现多进程处理时,性能远低于单核串行方案:
- 初始方案使用
Manager创建跨进程共享字典写入结果,速度比单核方案慢27倍,实现代码如下:
def pooler(self, payload): lis_docs=[] for keys in payload.keys(): lis_docs.append([keys,payload[keys]]) # parallel process on this manager=mp.Manager() processed_docs=manager.dict() pool=mp.Pool(os.cpu_count()-1) for doc in lis_docs: pool.apply_async(parallel_process_for_shortq,args=(self,doc,processed_docs)) pool.close() pool.join() def parallel_process_for_shortq(self,doc,processed_doc): sentences=tokenize_sentences(doc[1]) modified_text=" ".join(sentences) keywords=get_keywords(self.nlp,modified_text,4,self.s2v,self.fdist,self.normalized_levenshtein,len(sentences)) keyword_sentence_mapping = get_sentences_for_keyword(keywords, sentences) processed_doc[doc[0]]=[keywords,keyword_sentence_mapping]
所有文档ID唯一,不存在多进程覆写同一条数据的问题,最初诉求为聚合多进程返回结果、统一写入字典,规避共享字典的性能损耗。
- 实际测试性能数据:
- 测试集仅3个文档时,单核串行总耗时1.76秒,
apply_async+共享字典方案总耗时27.5秒 - 放弃共享字典,改用
starmap直接聚合子进程返回值后,耗时仍达到27秒,调整后代码如下:
- 测试集仅3个文档时,单核串行总耗时1.76秒,
def pooler(self, payload): lis_docs=[] for keys in payload.keys(): lis_docs.append([keys,payload[keys]]) manager=mp.Manager() n_cores=os.cpu_count() print(n_cores) start=time.time() with mp.Pool(n_cores-1) as pool: res=pool.starmap(parallel_process_for_shortq,zip(repeat(self),lis_docs)) pool.close() pool.join() def parallel_process_for_shortq(self,doc): sentences=tokenize_sentences(doc[1]) modified_text=" ".join(sentences) keywords=get_keywords(self.nlp,modified_text,4,self.s2v,self.fdist,self.normalized_levenshtein,len(sentences)) keyword_sentence_mapping = get_sentences_for_keyword(keywords, sentences) return [doc[0],keywords,keyword_sentence_mapping]
- 测试集扩大到900个文档时性能有所提升,单文档平均耗时1.7秒,但仍远高于单核串行0.6秒/文档的速度。
问题根因
性能损耗和共享字典无直接关系,核心来自三类开销:
- 进程启动固定开销:Python
multiprocessing默认spawn模式下,每个子进程启动需要重新导入依赖、初始化运行环境,3个文档的计算量完全覆盖不了进程启动成本,因此小批量任务下慢20余倍属于正常现象。 - 大对象重复序列化开销:通过
repeat(self)将类实例传入子进程时,实例上挂载的nlp、s2v、fdist等NLP大模型/数据对象,会在每次任务调度时经历pickle序列化、跨进程传输、反序列化流程,这部分开销远高于文档处理本身的计算量,直接抵消多进程的算力收益,是大批量任务下性能仍低于单核的核心原因。 - 冗余开销:第二版代码中保留了未使用的
mp.Manager()初始化,会额外启动独立管理进程,带来无意义的资源消耗。
优化方案
按优先级落地以下调整即可获得符合预期的多进程加速比:
- 移除无用的
Manager初始化:采用进程返回值聚合结果的方案时,不需要启动Manager服务,直接删除相关代码即可。 - 避免大对象重复序列化传输:把NLP模型、
s2v、fdist这类初始化一次即可复用的大对象,放到进程初始化阶段加载,不要通过任务参数传给子进程,参考实现如下:
# 定义进程内全局变量存复用的大对象 _worker_nlp = None _worker_s2v = None _worker_fdist = None _worker_levenshtein = None def _init_worker(nlp, s2v, fdist, normalized_levenshtein): # 每个进程启动时仅执行一次初始化,全局持有复用对象 global _worker_nlp, _worker_s2v, _worker_fdist, _worker_levenshtein _worker_nlp = nlp _worker_s2v = s2v _worker_fdist = fdist _worker_levenshtein = normalized_levenshtein def _parallel_worker(doc): # 子进程任务直接调用全局持有的对象,无需每次传参序列化 doc_id, doc_content = doc sentences = tokenize_sentences(doc_content) modified_text = " ".join(sentences) keywords = get_keywords( _worker_nlp, modified_text, 4, _worker_s2v, _worker_fdist, _worker_levenshtein, len(sentences) ) keyword_sentence_mapping = get_sentences_for_keyword(keywords, sentences) return (doc_id, [keywords, keyword_sentence_mapping]) # 主进程pooler方法调整 def pooler(self, payload): lis_docs = list(payload.items()) n_cores = os.cpu_count() - 1 # 初始化进程池时传入初始化函数和大对象,每个进程仅加载一次 with mp.Pool( n_cores, initializer=_init_worker, initargs=(self.nlp, self.s2v, self.fdist, self.normalized_levenshtein) ) as pool: # 直接传入文档列表,设置合理chunksize减少IPC调度开销 res = pool.map(_parallel_worker, lis_docs, chunksize=16) # 所有任务完成后一次性聚合为字典 processed_docs = dict(res) return processed_docs
- 合理设置
chunksize:处理千级文档量时,设置chunksize为16~64区间值,避免进程每次只取1个任务,减少跨进程调度开销。 - 小批量任务走串行逻辑:单批次文档量小于10个时,进程启动开销远大于计算收益,直接串行处理即可,无需启动多进程。
预期效果
落地以上调整后,千级文档量级的任务多进程加速比可以达到CPU核心数的70%~90%,不会再出现比单核慢的情况;10个文档以内的小批次走串行逻辑,可保持原有单核处理速度。
内容的提问来源于stack exchange,提问作者shubh gupta
相关产品推荐
相关产品推荐

