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

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

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

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

性能提升经验

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

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

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

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

聊聊历史

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

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

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

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

基础知识

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

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

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

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。如果你按照上面讲的,先调通了单进程的实现,那么这时,最重要是,打印进程的启动点,进程数据和关闭点,就可以了。比如,观测到某个进程拖了大家的后腿,那就要好好看看那个进程对应的数据。

展开阅读全文

本文系作者在时代Java发表,未经许可,不得转载。

如有侵权,请联系nowjava@qq.com删除。

编辑于

关注时代Java

关注时代Java