高性能计算,是一个非常广泛的话题,可以从专用硬件/处理器/体系结构/GPU,说到操作系统/线程/进程/并行/并发算法,再到集群/网格计算,最后到天河二号(TH-1)。

必须收藏:20个开发技巧教你开发高性能计算代码-LMLPHP

我们这次的分享会从个人的实践项目探索出发,与大家分享自己摸爬滚打得出的心得体会,一如既往的坚持原创。其中内容涉及到优化规划 / 执行 / 多进程 / 开发心理等约20个要点,其中例子代码片段,使用Python。

高性能计算,在商业软件应用开发过程中,要解决的核心问题,用很白话的方式来说,“在有限的硬件条件下,如何让一段原本跑不动的代码,跑起来,甚至飞起来。”

性能提升经验

举2个例子,随意感受下。

(1)635万条用户阅读文档的历史行为数据,数据处理时间,由50小时,优化到15秒。(是的,你没有看错)

(2)基于Mongo的宽表创建,由20小时,优化到出去打杯水的功夫。

在大数据的时代,一个优秀的程序员,可以写出性能比其他人的程序高出数百倍,甚至数千倍,具备这样的技能,对产品的贡献无疑是很大的,对个人而言,也是自己履历上亮点和加分项。

聊聊历史

2000年前后,由于PC硬件限制,那一代的程序员,比如,国内的求伯君 / 雷军,国外的比尔盖茨 / 卡马特,都是可以从机器码 / 汇编的角度来提升程序性能。

必须收藏:20个开发技巧教你开发高性能计算代码-LMLPHP

到2005年前后,PC硬件性能发展迅速,高性能优化常常听到,来自嵌入式设备和移动设备。那个年代的移动设备主流使用J2ME开发,可用内存128KB。那个年代的程序员,需要对程序大小(OTA下载,有数据流量限制,如128KB),内存使用都精打细算,真的是掐着指头算。比如,通常一个程序,只有一个类,因为新增一个类,会多使用几K内存。数据文件会合并为一个,减少文件数,这样需要算,比如从第几个字节开始,是什么数据。

必须收藏:20个开发技巧教你开发高性能计算代码-LMLPHP

2008年前后,第一代iOS / Android智能手机上市,App可用内存达到1GB,App可以通过WIFI下载,App大小也可以达到一百多MB。我刚才看了下我的P30,就存储空间而言,QQ使用了4G,而微信使用了10G。设备性能提升,可用内存和存储空间大了,程序员们终于“解放”了,直到–大数据时代的到来。

在大数据时代下,数据量疯狂增长,一个大的数据集操作,你的程序跑一晚上才出结果,是常有的事。

必须收藏:20个开发技巧教你开发高性能计算代码-LMLPHP

基础知识

本次分享假设读者已经了解了线程/进程/GIL这些概念,如果不了解,也没有关系,可以读下以下的摘要,并记住下面3点基础知识小结即可。

什么是进程?什么是线程?两者的差别?

以下内容来自Wikipedia: https://en.wikipedia.org/wiki/Thread_(computing)

必须收藏:20个开发技巧教你开发高性能计算代码-LMLPHP

Threads differ from traditional multitasking operating-system processes in several ways:

  • processes are typically independent, while threads exist as subsets of a process
  • processes carry considerably more state information than threads, whereas multiple threads within a process share process state as well as memory and other resources
  • processes have separate address spaces, whereas threads share their address space
  • processes interact only through system-provided inter-process communication mechanisms
  • context switching between threads in the same process typically occurs faster than context switching between processes

著名的GIL (Global interpreter lock)

以下内容来自 wikipedia.

A global interpreter lock (GIL) is a mechanism used in computer-language interpreters to synchronize the execution of threads so that only one native thread can execute at a time.[1] An interpreter that uses GIL always allows exactly one thread to execute at a time, even if run on a multi-core processor. Some popular interpreters that have GIL are CPython and Ruby MRI.

基础知识小结:

  • 因为著名的GIL,为了线程安全,Python里的线程,只能跑在同一个CPU核,无法做到真正的并行
  • 计算密集型应用,选用多进程
  • IO密集型应用,选用多线程

实践要点

以上都是一些铺垫,从现在开始,我们进入正题,如何开发高性能代码。

一直以来,我都在思考,如何做有效的分享?首先,我坚持原创,如果同样的内容可以在网络上找到,那就没有分享的必要,浪费自己和其他人的时间。其次,对不同的人,采用不同的方法,讲不同的内容。

所以,这次分享,听众大都是有开发经验的python程序员,所以,我们不在一些基础的内容上花太多时间,不了解也没关系,下来自已看看也都能看懂。这次我们更多来从实践问题出发,我总结了约20个要点和开发技巧,希望能对大家今后的工作有帮助。

规划和设计尽可能早,而实现则尽可能晚

接到一个项目时,我们可以先识别下,哪些部分可能会出现性能问题,做到心里有数。在设计上,可以早点想着,比如,选用合适的数据结构,把类和方法设计解耦,便于将来做优化。

在我们以前的项目中,见过有些项目,因为早期没有去提前设计,后期想优化,发现改动太大,风险非常高。

但是,这里一个常见的错误是,上来就优化。在软件开发的世界里,这点一直被经常提起。我们需要控制自己想早优化的心理,而应优先把大框架搭起来,实现主要功能,然后再考虑性能优化。

先简单实现,再评估,做好计划,再优化实施

评估改造成本和收益,比如,一个模块费时一小时,如果优化,需要花费开发和测试时间3小时,可能节省30分钟,性能提升50%;另一模块,费时30秒,如果优化,开发和测试需要花费同样的时间,可以节省20秒,性能提升67%。你会优先优化哪个模块?

我们建议优先考虑第一个模块,因为收益更大,可节省30分钟;而第二个模块,费时30秒,不优化也能接受,应该把优化优先级放到最低。

另一个情况,如第2个模块被其它模块高频调用,那我们又要重新评估优先级。

优化时,我们要控制我们可能产生的冲动:优化一切能优化的部分。

当我们没有“锤子”时,我们遇到问题很苦恼,缺乏技能和工具;但是,当我们拥有“锤子”时,我们又很容易看一切事物都像“钉子”。

开发调试时,使用Sampling数据,并配合开关配置

开发时,对费时的计算,可以设置sampling参数,调动时,传入不同的参数,既可以快速测试,又可以安全管理调试和生产代码。千万不要用注释的方式,来开/关代码。

参考以下示意代码:

	# Bad
	def calculate_bad():
	    # uncomment for debugging
	    # data = load_sampling_data()
	    data = load_all_data()

	# Good
	def calculate(sampling=False):
	    if sampling:
	        data = load_sampling_data()
	    else:
	        data = load_all_data()

梳理清楚数据Pipeline,建立性能评估机制

我自己写了个Decorator @timeit 可以很方便地打印代码的用时。

	@timeit
	def calculate():
	    pass

这样生成的log,菜市场大妈都看的懂。上了生产后,也可以通知配置来控制是否打印。

[2020-07-09 14:44:09,138] INFO: TrialDataContainer.load_all_data - Start
...
[2020-07-09 14:44:09,158] INFO: preprocess_demand - Start
[2020-07-09 14:44:09,172] INFO: preprocess_demand - End - Spent: 0.012998 s
...
[2020-07-09 14:44:09,186] INFO: preprocess_warehouse - Start
[2020-07-09 14:44:09,189] INFO: preprocess_warehouse - End - Spent: 0.002611 s
...
[2020-07-09 14:44:09,454] INFO: preprocess_substitution - Start
[2020-07-09 14:44:09,628] INFO: preprocess_substitution - End - Spent: 0.178258 s
...
[2020-07-09 14:44:10,055] INFO: preprocess_penalty - Start
[2020-07-09 14:44:20,823] INFO: preprocess_penalty - End - Spent: 10.763566 s

[2020-07-09 14:44:20,835] INFO: TrialDataContainer.load_all_data - End - Spent: 11.692677 s
[2020-07-09 14:44:20,836] INFO: ObjectModelsController.build - Start
[2020-07-09 14:44:20,836] INFO: ObjectModelsController.build_penalties - Start
[2020-07-09 14:44:20,836] INFO: ObjectModelsController.build_penalties - End - Spent: 0.000007 s
[2020-07-09 14:44:20,837] INFO: ObjectModelsController.build_warehouses - Start
[2020-07-09 14:44:20,848] INFO: ObjectModelsController.build_warehouses - End - Spent: 0.011002 s

另外,Python也提供了Profiling工具,可以用于费时函数的定位。

优先处理数据读取性能

一个完整的项目,可能会有很多性能提升的部分,我建议,优先处理数据读取,原因是,问题容易定位,修改代码相对独立,见效快。

举例来说,很多机器学习项目,都需要建立数据样本数据,用于模型训练。而数据样本的建立,常通过创建一个宽表来实现。很多DB都提供了很多提升操作性能的方法。假设我们使用MongoDB,其提供了pipeline函数,可以把多个数据操作,放在一个语句中,一次传给DB。

如果我们粗暴地单条处理,在一个项目中我们试过,需要近20个小时,花了半天的时间来优化,跑起来,离开座位去接杯水,回来就已经跑完了,费时降为1分钟。

注意,很多时候我们没有动力去优化数据读取的性能,因为数据读取可能次数并不多,但事实上,特别是在试算阶段,数据读取的次数其实并不少,因为我们总是没有停止过对数据的改变,比如加个字段,加个特征什么的,这时候,数据读取的代码就要经常被用到,那么优化的收益就体现出来了。

再考虑降低时间复杂度,考虑使用预处理,用空间换时间

我们如果把性能优化当做一桌宴席,那么可以把数据读取部分的性能优化,当作开胃小菜。接下来,我们进入更好玩的部分,优化时间复杂度,用空间换时间。

举例来说,如果你的程序的复杂度为O(n^2),在数据很大时,一定会非常低效,如果能优化为复杂度为O(n),甚至O(1),那就会带来几个数据级的性能提升。

比如上面提到的,使用倒排表,来做数据预处理,用空间换时间,达到从50小时到15秒的性能提升。

因著名的GIL,使用多进程提升性能,而非多线程

在Python的世界里,由于著名的GIL,如果要提升计算性能,其基本准则为:对于I/O操作密集型应用,使用多线程;对于计算密集型应用,使用多进程。

一个多进程的例子:

我们准备了一个长数组,并准备了一个相对比较费时的等差数列求和计算函数。

	MAX_LENGTH = 20_000
	data = [i for i in range(MAX_LENGTH)]

	def calculate(num):
	    """Calculate the number and then return the result."""
	    result = sum([i for i in range(num)])
	    return result

单进程执行例子代码:

	def run_sinpro(func, data):
	    """The function using a single process."""
	    results = []

	    for num in data:
	        res = func(num)
	        results.append(res)

	    total = sum(results)

	    return total

	%%time
	result = run_sinpro(calculate, data)
	result
CPU times: user 8.48 s, sys: 88 ms, total: 8.56 s
Wall time: 8.59 s

1333133340000

从这里我们可以看到,单进程需要 ~9 秒。

接下来,我们来看看,如何使用多进程来优化这段代码。

	# import multiple processing lib
	import sys

	from multiprocessing import Pool, cpu_count
	from multiprocessing import get_start_method, \
	                            set_start_method, \
	                            get_all_start_methods

	def mulp_map(func, iterable, proc_num):
	    """The function using multi-processes."""
	    with Pool(proc_num) as pool:
	        results = pool.map(func, iterable)

	    return results

	def run_mulp(func, data, proc_num):
	    results = mulp_map(func, data, proc_num)
	    total = sum(results)

	    return total

	%%time
	result = run_mulp(calculate, data, 4)
	result
CPU times: user 14 ms, sys: 19 ms, total: 33 ms
Wall time: 3.26 s

1333133340000

同样的计算,使用单进程,需要约9秒;在8核的机器上,如果我们使用多进程则只需要3秒,耗时节省了 66%。

多进程:设计好计算单元,应尽可能小

我们来设想一个场景,假设你有10名员工,同时你有10项工作,每项工作中,都由相同的5项子工作组成。你会如何来做安排呢?理所当然的,我们应该把这10名员工,分别安排到这10项工作中,让这10项工作并行执行,没毛病,对吧?但是,在我们的项目中,如果这样来设计并行计算,很可能出问题。

这里是一个真实的例子,最后性能提升的效果很差。原因是什么呢?(此处可按Pause键,思考一下)

主要的原因有2个,并行的计算单元颗粒度不应太大,大了以后,通常会有数据交换或共享问题。其次,颗粒度大了以后,完成时间会差别比较大,形成短板效应。也就是,颗粒度大了以后,任务完成时间可能会差别很大。

在一个真实的例子中,并行计算需要1个小时,最后分析后才发现,只有一个进程需要1小时,而其他进程的任务都在5分钟内完成了。

另一个好处是,出错了,好定位,代码也好维护。所以,计算单元应尽可能小。

多进程:避免进程间通信或同步

当我们把计算单元设计的足够小后,应该尽量避免进程间通信或同步,避免造成等待,影响整体执行时间。

多进程:调试是个问题,除了log外,尝试gdb / pdb

并行计算的公认问题是,难调试。通常的IDE只可以中断一个进程。通过打印log,并加上pid,来定位问题,会是一个比较好的方法。注意,并行计算时,不要打太多log。如果你按照上面讲的,先调通了单进程的实现,那么这时,最重要是,打印进程的启动点,进程数据和关闭点,就可以了。比如,观测到某个进程拖了大家的后腿,那就要好好看看那个进程对应的数据。

这是个细致活,特别是,当多进程启动后,可能跑着数小时,你也不知道在发生什么?可以使用linux下的top,或windows下的activity等工具来观测进程的状态。也可以使用gdb / pdb这样的工具,进入某个进程中,看看卡在哪里。

多进程:避免大量数据作为参数传输

在真实的项目中,我们设计的计算单元,不会像上面的简单例子一样,通常都会带有不少参数。这时需要注意,当大数据作为参数传输时,会导致内存消耗很大,并且,子进程的创建也会很慢。

多进程:Fork? Spawn?

Python的多进程支持3种模式去启一个进程,分别是,spawn, fork, forkserver。他们之间的差别是启动速度,和继承的资源。spawn只继承必要的资源,而fork和forkserver则与父进程完全相同。

依赖于不同的操作系统,和不同版本的python,其默认模式也不同。对python 3.8,Windows默认spawn;从python 3.8开始,macOS也默认使用spawn;Unix类OS默认fork;fork和forkserver在windows上不可用。

灵魂拷问:多进程一定比单进程快吗?

讲到这里,我们的分享基本可以结束了,对吧?按照python multiprocessing API,找几个例子,并参考我上面说的几点,能解决80%以上的问题。够了,毕竟性能优化也不是天天需要。以下内容可能要从事性能优化一年后,才会思考到,这里写出来,供参考,帮助以后少走些弯路。

比如,多进程一定更快吗?

正如第一点所说,任何优化都有开销。当多进程解决不了你的问题时,别忘了试试,改回单进程,说不定就解决了。(这也是一个真实的例子,花了2周去优化一个,10进程也需要3小时才能执行完的程序,改回单进程后,直接跑进30分钟内了。)

优化心理:手里有了锤子,一切都长的像钉子

同上要点,有时候需要的,可能是优化数据结构,而不是多进程。

优化心理:不要迷信“专家”

相信很多团队都这样,当项目遇到重大技术问题,比如性能需要优化,管理者都会召集一些专家来帮忙。根据我的观察,80%的情况下,没有太多帮助,有时甚至更糟。

原因很简单,用一句话来说,你花了20个小时解决不了的问题,其他人用5分钟,根据你提供的信息,指出问题所在,可能性很低,无论他相关的经验有多么丰富。如果不信,你可以回想下自己的经验,或将来注意观察下,再回过头来看这个观点。为什么可能更糟?因为依赖心理。有了专家的依赖,人们是不会真拼的,“反正有专家指引”。就像尼采说过,“人们要完成一件看似不可能的事时,需要鼓胀到超过自己的能力。”,所以,如果这件事真的很难,你“疯狂”地相信,“这件事只有你能解决,只能靠你自己,其他人都无法解决”,说不定效果更好。

在一个持续近一个月的性能优化项目中,我脑海中时常响起《名侦探柯南》中的一句台词:真相只有一个。我坚定无比地相信,解法离我越来越近,哪怕事实是,一次又一次地失败,但这份信念到最后的成功帮助很大。

优化心理:优化可能是一个长期过程,每天都在迷茫中挣扎

性能优化的过程,漫长而煎熬,如果能有一个耐心的听众,会帮助很大。他/她可能不会帮你指出问题的解决办法,只是耐心地听着,只说,“it will be fine.” 但这样的述说,会帮助理清思路,能灵感迸发也说不定。这跟生活中其它事情的道理,应该也是一样的吧。

优化心理:管理者帮助争取时间,减轻心理压力

比如,有经验的管理者,会跟业务协商,分阶段交付。而有些同学,则会每隔几小时就过来问下,“性能有提升吗?” 然后脸上露出一种诡异的表情:“真的有那么难?”

目前我所有知道的一个案例,其性能优化持续了近一年,期间几拨外协人员,来了,又走了,搞得奔溃。

所以,我们呼吁,项目管理者应该多理解开发人员,帮助开发人员挡住外部压力,而不是直接透传压力,或者甚至增大压力。

References

https://baike.baidu.com/item/高性能计算

  • https://www.liaoxuefeng.com/wiki/1016959663602400/1017627212385376
  • https://en.wikipedia.org/wiki/Thread_(computing)
  • https://en.wikipedia.org/wiki/Global_interpreter_lock#:~:text=A global interpreter lock (GIL,on%20a%20multi%2Dcore%20processor.
  • https://git.huawei.com/x00349737/nqutils
  • https://docs.python.org/3/library/profile.html

点击关注,第一时间了解华为云新鲜技术~

04-22 15:12