多进程编程以利用多核
背景:
python evaluate.py跑的太慢了,python enhance.py跑的也不快,所以我想把这两个文件改一下,内部写成多进程的模式。
注意:
Python的多线程和多进程要区分。Python的多线程(包括threading模块和ThreadPoolExecutor线程池模块)是用户级线程,且其C语言实现时用了GIL(全局解释器锁),导致根本无法利用多核。
所以Python想要利用多核,需要使用多进程(multiprocessing模块或者multiprocessing.pool.Pool进程池模块)
然后有一个点要清楚:
就是OS对线程的实现和Python啊C语言啊提供给你的线程不是一个东西,用户线程的话比如c语言库会给你维护一个线程池,里面的线程最终还是得依赖os的线程实现来做。OS一般都是在内核态实现线程,所以确实是可以在多核上并行的。Python的线程无法并行跟不怪OS,是Python自己用了GIL锁的问题。
其实编程语言设置线程的概念也是为了屏蔽底层的os实现,不管底层os是用的用户级线程实现,还是内核级线程实现,编程语言都可以帮你搞定。像刚才说的python,它的multithread就实现得不好。
然后就是怎么修改代码了
我先修改的enhance.py
-
首先import
import torch.multiprocessing as mp#注意这边不能import multiprocessing,而是要import torch.multiprocessing #mp是Python一般写多进程用的;torch.mp的用法和mp几乎一样,区别在于torch.mp一般用在需要共享 GPU 和 CUDA 资源时,这里模型推理(即用模型处理输入)是需要用GPU的,所以应该用torch.mp
-
然后在
__main__
开头加上mp.set_start_method('spawn')
不设置这个的话,默认的创建子进程的方式是fork,跑起来会报错的
-
fork:
- 该方法通过复制当前进程来创建子进程。子进程会继承父进程的所有资源,包括打开的文件描述符和内存中的数据。
- 由于直接复制父进程,启动速度快且内存利用高效。
- 但是,
fork
方法有一些缺陷,比如在父进程中某些状态(如锁)可能不适合在子进程中使用。另外,对于一些不支持fork
方法的资源,可能会导致不可预期的行为。
-
spawn:
- 该方法通过启动一个全新的Python解释器进程来创建子进程。在启动时,只有必要的资源会被传递给子进程,其他资源(如打开的文件描述符)不会被继承。(可能会报错:打开文件过多)
- 这种方式启动的进程是完全独立的,并且没有继承父进程的状态。
- 虽然
spawn
启动速度较慢且内存开销较大,但它避免了fork
方法的一些问题,尤其是在涉及多线程和C扩展模块时。
有利有弊吧。
-
-
重新组织代码,把循环执行的部分组织成一个函数,从而能够利用多线程(进程)来执行
下面是原本的代码:
def enhance(config: DictConfig) -> None: ###此处省略一大堆无须修改的代码 for idx, scene_listener_pair in enumerate(scene_listener_pairs, 1): ###代码块A logger.info("Done!") if __name__ == "__main__": enhance()
下面是修改后的代码:
def process_scene_listener_pair(idx, scene_listener_pair, scenes, songs, config, gains, listener_dict, enhanced_folder, enhancer, compressor, separation_model, model_sample_rate, device, sources_order, normalise, num_scenes): try: if config.separator.device!='cpu': torch.cuda.set_per_process_memory_fraction(0.1)#limit cuda usage percentage to be 10% per process ###代码块A finally: if config.separator.device!='cpu': torch.cuda.empty_cache() def print_error(value): print("error: ", value) def enhance(config: DictConfig) -> None: ###此处省略一大堆无须修改的代码 pool = mp.Pool(processes=8) for idx, scene_listener_pair in enumerate(scene_listener_pairs, 1): pool.apply_async(process_scene_listener_pair, args=(idx, scene_listener_pair, scenes, songs, config, gains, listener_dict, enhanced_folder, enhancer, compressor, separation_model, model_sample_rate, device, sources_order, normalise, num_scenes), error_callback=print_error) pool.close() pool.join() logger.info("Done!") if __name__ == "__main__": mp.set_start_method('spawn') enhance()
其实就是把循环执行的部分组织成一个函数,注意把需要的参数都传进去
-
非常重要的一点就是设置
torch.cuda.set_per_process_memory_fraction(0.1)
,注意:0.1(比例)*8(最大进程数)要小于1,这样可以保证GPU的显存不会用完,不然一跑就报错显存不足
然后我又试了一下0.06*16,跑了一会就发现会报错显存不足(而且刚开始可以跑的那些的速度并没有开8个进程的速度快
需要注意:如果设置的内存比例过低,而每个进程实际需要更多内存,可能会导致进程失败。如果设置合理,则只是可能执行得较慢。
进一步提升的空间,这边只用了1个GPU,而这台机器有4个,要用多个gpu的话得用pytorch的dataparallel
-
及时释放不用的显存(和第4点搭配使用)
torch.cuda.empty_cache()
-
def print_error(value): print("error: ", value)
注意
pool.apply_async
有个特点,就是报错的进程不会把报错信息打印出来,所以你只会看到结果的数量不太对,但看不到报错。所以要定义这个函数error_callback=print_error
作为参数传给pool.apply_async
从而打印报错信息 -
哦还要提一下这个
apply_asyncd
函数和apply
函数的区别:apply
函数虽然在进程池中调用,但实际上是串行执行的apply_async
方法才真正利用了多核优势
我然后修改的是evaluate.py
注意:我发现evaluate.py里没有用到模型推理(即用模型处理数据),所以不需要用GPU
直接放修改后的代码了:
import multiprocessing as mp #不需要那个torch了
def evaluate_scene_listener_pair(idx, scene_listener_pair, scenes, songs, config, gains, listener_dict, enhanced_folder, enhancer, results_file,num_scenes):
###代码块A
###因为没有用GPU,所以首尾不用像上面那样写了
def print_error(value):
print("error: ", value)
def run_calculate_aq(config: DictConfig) -> None:
###此处省略一大堆无须修改的代码
pool=mp.Pool(processes=64)
for idx, scene_listener_pair in enumerate(scene_listener_pairs, 1):
pool.apply_async(evaluate_scene_listener_pair, args=(idx, scene_listener_pair, scenes, songs, config, gains, listener_dict, enhanced_folder, enhancer, results_file,num_scenes), error_callback=print_error)
pool.close()
pool.join()
logger.info("Done!")
if __name__ == "__main__":
run_calculate_aq()#不需要那个spawn了
怎么看cpu有多少个核呢?
命令:lscpu
打印出来的一大堆参数只用看下面的:
CPU(s) 64 #系统中总共有64个逻辑处理器(逻辑核心)
thread per core 2 #每个物理核心支持2个线程
core per socket 16 #每个插槽(物理CPU)有16个物理核心
socket 2 #系统中有2个物理CPU插槽
其实只用看CPU(s)即可,64
剩余的其实告诉你为什么是64:2*16*2=64
补充一点:
运行一个.py文件怎么知道会不会用GPU呢?其实就看有没有用到模型(包括模型的训练和使用),没用到模型肯定是用CPU跑的,用到模型了除非指定了用CPU跑,否则在检测到电脑有GPU时就会默认用GPU跑。
python enhance.py
的结果没问题,但是python evaluate.py
的结果出现了问题
应该有800个结果,但少了80个左右,少的也很均匀,又等了半小时毫无反应,我就主动Ctrl+C结束了,发现并没有什么报错,所有没跑完的进程都是由最后的Ctrl+C终止的,不知道为啥。
然后我修改了evaluate.py
的代码,为了不让已经跑出的分数再跑一遍,我进行了以下修改:
首先,
if config.evaluate.batch_size == 1:
results_file = ResultsFile(
"scores.csv",
header_columns=scores_headers,
append_results=True,###这行是我新加的,使再次运行时不是重新创建表格,而是在原有表格后面补
)
然后,读表格,用列表existing_scene_listener_pair
把scene名称和listener名称成对存下来
existing_scene_listener_pair=[]
with open("scores.csv", "r", encoding="utf-8", newline="") as csv_file:
csv_file.readline()#skip header_line
for line in csv_file:
words=line.strip().split(',')
existing_scene_listener_pair.append([words[0],words[2]])
然后在主循环那边判断一下,如果已经有分数了,就不用再算一次了
pool=mp.Pool(processes=64)
for idx, scene_listener_pair in enumerate(scene_listener_pairs, 1):
scene_id, listener_id = scene_listener_pair
tem=[str(scene_id),str(listener_dict[listener_id].id)]
if tem in existing_scene_listener_pair:
continue
pool.apply_async(evaluate_scene_listener_pair, args=(idx, scene_listener_pair, scenes, songs, config, gains, listener_dict, enhanced_folder, enhancer, results_file,num_scenes), error_callback=print_error)
pool.close()
pool.join()
然后我又跑了一次,进程数还是设置的64,发现还是跑不出结果,就是有切换(等十几分钟会显示在做新的任务)但表格的内容并没有增加。然后我改成16就好了,估计是在同时运行64个进程时,系统可能会耗尽内存资源,导致一些进程被操作系统杀死。
(P.S. 有一首歌的歌名里有逗号,导致用逗号分隔时会出问题,最后可能会有8个scene_listener_pair被算2遍,手动删除即可)
结果展示
这里不放出来了。新的模型确实使平均分提高了。