- 内容提要
- 前言
- 作者简介
- 封面简介
- 第1章 理解高性能 Python
- 第2章 通过性能分析找到瓶颈
- 2.1 高效地分析性能
- 2.2 Julia 集合的介绍
- 2.3 计算完整的 Julia 集合
- 2.4 计时的简单方法——打印和修饰
- 2.5 用 UNIX 的 time 命令进行简单的计时
- 2.6 使用 cProfile 模块
- 2.7 用 runsnakerun 对 cProfile 的输出进行可视化
- 2.8 用 line_profiler 进行逐行分析
- 2.9 用 memory_profiler 诊断内存的用量
- 2.10 用 heapy 调查堆上的对象
- 2.11 用 dowser 实时画出变量的实例
- 2.12 用 dis 模块检查 CPython 字节码
- 2.13 在优化期间进行单元测试保持代码的正确性
- 2.14 确保性能分析成功的策略
- 2.15 小结
- 第3章 列表和元组
- 第4章 字典和集合
- 第5章 迭代器和生成器
- 第6章 矩阵和矢量计算
- 第7章 编译成 C
- 第8章 并发
- 第9章 multiprocessing 模块
- 第10章 集群和工作队列
- 第11章 使用更少的 RAM
- 第12章 现场教训
9.5 使用进程间通信来验证素数
素数是除了自己和1以外没有其他因子的数字。这正是绝大多数公因数是2的理由(每一个偶数都不能是素数)。然后,小素数(比如3、5、7)成为了更大的非素数的公因子(比如,对应于9、15、21)。
比如说给我们一个大数字,要我们验证它是否是素数。我们可能会有一个大范围的因子要去搜索。图9-16显示了一直到10000000的非素数的每一个因子出现的频率。小因子要比大因子出现的几率大得多,但是没有可预测的模式。
图9-16 非素数的因子频率
让我们定义一个新的问题——假设我们有一个小数字集,我们的任务是有效地利用CPU资源来计算出每一个数字是否是素数,一时刻计算一个数字。可能我们会只有一个大数字要检测。使用一个CPU来做检测不再有意义,我们想要在许多CPU之间协调工作。
在本节中我们会看看一些更大的数字,一个数字有15位数(digits),4个数字有18位数(digits):
小的非素数:112272535095295。
大的非素数1:00109100129100369。
大的非素数2:100109100129101027。
素数1:100109100129100151。
素数2:100109100129162907。
通过使用更小的非素数和一些更大的非素数,我们得到验证,我们所选择的过程不但在检测素数方面更快,而且在检测非素数方面不会变得更慢。我们会假设并不知道所给的数字大小或类型,所以我们想要对所有的使用情况来说可能是最快的结果。
协同带来了开销——同步数据和检查共享数据的开销可能是相当大的。我们在这儿会过一遍几种能够以不同的方式来做任务同步的方法。注意我们在这里没有涉及某些特殊化的消息传递接口(MPI),我们打算看看内置的模块和Redis(很普遍)。
如果你想要使用MPI,我们假设你已经知道正在做什么。MPI4PY项目将会是一个起步的好地方。当很多进程要相互协同时,无论你是否有一台或多台机器,如果你想要控制延迟,它就是一个理想的技术。
在下列运行中,每个测试执行20遍,采用了最小的时间来展示对那种方法来说最快的速度。在这些例子中,我们要使用各种不同的技术来共享一个标记(通常是一个字节)。我们可能使用一个基本对象,比如一个Lock,但是接着我们只可以共享一个比特的状态。我们会选择性地来向你展示如何共享一个原生类型,从而有可能共享更多富有表达力的状态(即使我们为这个例子不需要一个更有表达力的状态)。
我们必须强调共享状态倾向于让事情变得复杂化——你会轻易地在另一个撕扯头发的状态中结束。要小心并且要尝试让事情尽可能保持简单。可能有一种情况,开发者花在其他挑战上的时间超过了更低效的资源利用。
首先我们要讨论结果,接着我们要过一遍代码。
图9-17展示了第一种方法来尝试使用进程间通信来更快地检测素数。基准就是没有使用任何进程间通信的串行版本,每一个想要加速我们代码的尝试至少需要比它快。
Less Naïve Pool版本具有一个可预测(并且良好)的速度。它是足够好了,从而相当难以被击败。不要忽略你搜索快速解决方案中的那些显而易见的方案——有时一个笨拙又足够好的解决方案就是你所需要的全部。
Less Naïve Pool解决方案中的方式就是采用我们在检测中的数字,在可用的CPU之间均匀地划分可能的因子区间,接着把工作推送到每一个CPU上。如果任何CPU发现了一个因子,它就提早结束,但是它不会交流这个因子,其他CPU会继续完成它们那部分区间工作。这意味着对于一个18位数的数字来说(我们4个更大的数字的例子),无论它是素数还是非素数,搜索时间都是相同的。
Redis和Manager的解决方案在遇到检测更多的因子数来求素数时会更慢,归因于通信开销。它们使用一个共享标记来表明已经发现一个因子,应该放弃搜索。
Redis让你不仅与其他Python进程共享状态,而且还与其他工具和其他机器来共享,甚至通过一个web浏览器接口来暴露状态(可能对远程监控有用)。Manager是multiprocessing的一部分,它提供了一个高级的Python对象的同步集合(包括原生对象、list和dict)。
对于更大的非素数的情况,尽管有检查共享状态的开销,但是在提早通知已经发现一个因子所节省的搜索时间面前,显得相形见绌。
然而,对于素数的情况,没有办法来提早结束,因为不会发现因子,所以检查共享标记的开销将占主导地位。
图9-18显示了我们能够凭借一点点努力来得到一个明显更快的结果。Less Naïve Pool的结果仍旧是我们的基准,但是RawValue和MMap(内存映射)的结果比之前Redis和Manager的结果要快得多。真正的魔法来自于采用了最快的解决方案,并且执行了一些更不明显的代码运算来做出了一个接近最优的MMap解决方案——对于非素数,这个最终版本比Less Naïve Pool解决方案要快,对于素数几乎一样快。
在接下来的小节中,我们将过一遍在Python中使用IPC(进程间通信)的各种各样的方式来解决我们的合作搜索问题。我们希望你会看到IPC(进程间通信)是相当的简单,但是一般会伴随着一定的开销。
图9-17 更慢的方式来使用IPC(进程间通信)验证素数
图9-18 更快的方式来使用IPC(进程间通信)验证素数
9.5.1 串行解决方案
我们将以与之前所使用的相同的串行因子检查的代码作为开始,再次展示于例9-10中。就如之前所要注意的那样,对于任何有大因子的非素数,我们可以更有效地并行搜索因子空间。一个串行清扫器还是会给我们一个有意义的基线,从而基于它来工作。
例9-10 串行验证
def check_prime(n): if n % 2 == 0: return False from_i = 3 to_i = math.sqrt(n) + 1 for i in xrange(from_i, int(to_i), 2): if n % i == 0: return False return True
9.5.2 Naïve Pool解决方案
Naïve Pool解决方案使用一个multiprocessing.Pool来工作,类似于我们在9.4节所见到的“寻找素数”和在9.3节中使用4个派生(fork)进程的“使用多进程和多线程来估算Pi”。我们有一个数字来检测它是否为素数,并且我们把可能的因子区间划分为四个子区间元组,再把它们发送到Pool中。
在例9-11中,我们使用了一个新方法,create_range.create(我们不会展示出来——它比较枯燥)来把工作空间分割成相同大小的区域,在range_to_check中的每一项是要在其中搜索的一对上下边界。对于第一个18位数的非素数(00109100129100369),使用4个进程,我们会有如下因子区间ranges_to_check == [(3, 79100057), (79100057, 158200111), (158200111, 237300165), (237300165, 316400222)](316400222是100109100129100369的平方根加一)。在__main__中,我们首先创建了一个Pool,接着check_prime通过一个map为每一个可能的素数来分割得到ranges_to_check。如果结果是False,那么我们已经发现了一个因子,就不会是一个素数。
例9-11 Naïve Pool解决方案
def check_prime(n, pool, nbr_processes): from_i = 3 to_i = int(math.sqrt(n)) + 1 ranges_to_check = create_range.create(from_i, to_i, nbr_processes) ranges_to_check = zip(len(ranges_to_check) * [n], ranges_to_check) assert len(ranges_to_check) == nbr_processes results = pool.map(check_prime_in_range, ranges_to_check) if False in results: return False return True if __name__ == "__main__": NBR_PROCESSES = 4 pool = Pool(processes=NBR_PROCESSES) ...
我们在例9-12中修改了之前的check_prime,采用一个有上下边界的区间来做检测。在传递一个完整的可能的因子列表去检测的过程中,没有什么值,所以我们通过仅仅传递定义区间的两个数字来节省了时间和内存。
例9-12 check_prime_in_range
def check_prime_in_range((n, (from_i, to_i))): if n % 2 == 0: return False assert from_i % 2 != 0 for i in xrange(from_i, int(to_i), 2): if n % i == 0: return False return True
对于“小的非素数”的情况,通过Pool的验证时间是0.1秒,明显要比原来的串行解决方案中的0.00002秒时间更长。尽管有这一个更糟的结果,整体结果却得到了一个全面的速度提升。我们可能会接受认为出现这一个更慢的结果不是问题——但是如果我们可能得到许多更小的非素数来检测,那会怎么样呢?我们有能力证明能够避免这种减速,我们会看看接下来的Less Naïve Pool的解决方案。
9.5.3 Less Naïve Pool解决方案
之前的解决方案在验证更小的非素数方面比较低效。对于任何更小的非素数(小于18个位数),有可能比串行方法要慢,这是因为发送分割的工作,以及不知道是否会找到一个非常小的因子(更可能的因子)所带来的开销。如果找到了一个小因子,那么进程还是得等到其他更大的因子搜索完成。
我们可以从在进程间发信号通知已经找到了一个小因子来开始,但是既然它发生得如此频繁,就会增加许多的通信开销。在例9-13中所呈现的解决方案是一种更实用的方法——为可能的小因子很快地执行一个串行检测,如果没有找到任何因子,那么启动一个并行搜索。在发起一个相对代价高昂的并行操作之前结合一个串行的预检是一种普遍的避免并行计算的部分开销的做法。
例9-13 对于小的非素数的情况,改进了Naïve Pool的解决方案
def check_prime(n, pool, nbr_processes): # cheaply check high-probability set of possible factors from_i = 3 to_i = 21 if not check_prime_in_range((n, (from_i, to_i))): return False # continue to check for larger factors in parallel from_i = to_i to_i = int(math.sqrt(n)) + 1 ranges_to_check = create_range.create(from_i, to_i, nbr_processes) ranges_to_check = zip(len(ranges_to_check) * [n], ranges_to_check) assert len(ranges_to_check) == nbr_processes results = pool.map(check_prime_in_range, ranges_to_check) if False in results: return False return True
对于我们的每一个测试数字,这种解决方案的速度等于或比原来的串行搜索要更快。这是我们的新基准。
重要的是,这种Pool方式给了我们面对素数检测场景的一种最优的情况。如果我们有素数,那么没有办法提早退出,我们不得不在可以退出前人工检查所有可能的因子。
没有最快的方式来检测完这些因子:任何增加复杂性的方法具有更多的指令,所以检测所有因子的情况会导致最多的指令要被执行。看看后面讲到的各种各样的mmap解决方案来讨论如何尽可能为素数得到接近于当前的这个结果。
9.5.4 使用Manager.Value作为一个标记
multiprocessing.Manager()让我们在进程之间共享更高级的Python对象作为被管理的共享对象,更低级的对象封装于代理对象中。封装和安全性会付出速度的代价,但也会提供巨大的灵活性。你既可以共享更低级的对象(例如,整数和浮点数),也可以共享列表和字典。
在例9-14中我们创建了一个Manager, 接着创建了一个1字节(character)的manager. Value(b”c”, FLAG_CLEAR)标记。如果你想要共享字符串或数字,你可能会创建任何的ctypes原语(和array.array原语一样)。
注意FLAG_CLEAR和FLAG_SET被分配给1字节(各自是b’0’和b’1’)。我们选择使用以b开头的作为显式(如果留作一个隐式的字符串,它可能默认成一个Unicode或者字符串对象,这要取决于你的环境和Python版本)。
现在我们能够在我们所有的进程之间做标记表明一个因子已经找到了,所以搜索能够被提早取消。困难之处就是要平衡读取标记的开销相对于可能会挽救下来的速度。因为标记被同步了,我们就不用太频繁地检测了——这增加了更多的开销。
例9-14 把一个Manager.Value对象作为一个标记
SERIAL_CHECK_CUTOFF = 21 CHECK_EVERY = 1000 FLAG_CLEAR = b'0' FLAG_SET = b'1' print "CHECK_EVERY", CHECK_EVERY if __name__ == "__main__": NBR_PROCESSES = 4 manager = multiprocessing.Manager() value = manager.Value(b'c', FLAG_CLEAR) # 1-byte character ...
check_prime_in_range现在会留意共享标记,并且例程会检查看看一个素数是否已经被其他的进程所识别出。即使我们已经开始了并行搜索,我们也必须如例9-15中所示的那样在开始做串行检测前清除标记。在已经完成串行检测之后,如果我们没有找到因子,那么我们就知道标记肯定还是为否。
例9-15 使用一个Manager.Value来清理标记
def check_prime(n, pool, nbr_processes, value): # cheaply check high-probability set of possible factors from_i = 3 to_i = SERIAL_CHECK_CUTOFF value.value = FLAG_CLEAR if not check_prime_in_range((n, (from_i, to_i), value)): return False from_i = to_i ...
我们该以怎样的频率来检查共享标记呢?每一次检查都有开销,既是因为我们给紧致的内循环增加了更多的指令,又因为做检查需要在共享变量上创建一把锁,这就增加了更多的开销。我们所选择的解决方案就是每1000次循环检查一次标记。每次我们检查看看value.value是否已经被设置成了FLAG_SET,如果已经设置了,我们就退出搜索。如果进程在搜索中找到了一个因子,那么它就设置value.value = FLAG_SET并退出(请看例9-16)。
例9-16 把Manager.Value对象作为一个标记来传递
def check_prime_in_range((n, (from_i, to_i), value)): if n % 2 == 0: return False assert from_i % 2 != 0 check_every = CHECK_EVERY for i in xrange(from_i, int(to_i), 2): check_every -= 1 if not check_every: if value.value == FLAG_SET: return False check_every = CHECK_EVERY if n % i == 0: value.value = FLAG_SET return False return True
在这个代码中的1000次迭代检查使用一个check_every本地计数器来执行。它证明了这种方法尽管可读性好,但是只得到了次优的速度。在本节结束前,我们会用一个可读性较差但是明显更快的方式来取代它。
你可能会对我们检查共享标记的总次数产生好奇。在两个大素数的情况下,我们使用4个进程对标记检查了316405次(我们在下面所有的例子中检查了这样的许多次)。既然每次检查有加锁的开销,这个代价真的合乎常理。
9.5.5 使用Redis作为一个标记
Redis是一个在内存中的键/值对存储引擎。它提供了自己的锁,每一个运算是原子性的,所以我们不必担心在Python内部使用锁(或者从任何其他的接口语言)。
通过使用Redis,我们让数据存储变得和语言无关——任何与Redis有接口的语言或工具都能以一种可兼容的方式来共享数据。你可以轻易地在Python、Ruby、C++和PHP之间平等地共享数据。你可以在本地机器或者网络上共享数据。为了共享给其他机器,你所需要做的全部事情就是改变Redis默认只在localhost共享的配置。
Redis让你存储:
字符串列表。
字符串集合。
有序的字符串集合。
字符串散列。
Redis在RAM中存储一切,把快照存储到磁盘(有选择地使用日志),并支持到一个实例集群的主从复制。用Redis的一种可能性就是使用它来在集群中共享工作负载和其他机器的读写状态,而Redis扮演着一个快速的中心化的数据仓库角色。
我们能够把一个标记作为一个文本字符串来读写(在Redis中的所有值都是字符串),就像之前我们已经使用过的Python标记的方式一样。我们创建了一个StrictRedis接口作为一个全局对象,和其他外部的Redis服务器来通信。我们可以在check_prime_in_range内部创建一个新连接,但是这会更慢,并且能消耗可利用的数量有限的Redis句柄。
我们使用类似字典的存取方式来与Redis服务器通信。我们能使用rds[SOME_KEY] = SOME_VALUE来设置一个值,并且能使用rds[SOME_KEY]来读回字符串。
例9-17与之前的Manager例子很类似——我们使用Redis来作为本地Manager的代替物。结果它表现出类似的存取开销。你应该注意到Redis支持其他(更复杂)的数据结构。它是一个强大的存储引擎,我们用这个例子只是用它来共享一个标记。我们鼓励你自己去熟悉它的特点。
例9-17 为我们的标记使用一个外部的Redis服务器
FLAG_NAME = b'redis_primes_flag' FLAG_CLEAR = b'0' FLAG_SET = b'1' rds = redis.StrictRedis() def check_prime_in_range((n, (from_i, to_i))): if n % 2 == 0: return False assert from_i % 2 != 0 check_every = CHECK_EVERY for i in xrange(from_i, int(to_i), 2): check_every -= 1 if not check_every: flag = rds[FLAG_NAME] if flag == FLAG_SET: return False check_every = CHECK_EVERY if n % i == 0: rds[FLAG_NAME] = FLAG_SET return False return True def check_prime(n, pool, nbr_processes): # cheaply check high-probability set of possible factors from_i = 3 to_i = SERIAL_CHECK_CUTOFF rds[FLAG_NAME] = FLAG_CLEAR if not check_prime_in_range((n, (from_i, to_i))): return False ... if False in results: return False return True
为了确认数据存储在了这些Python实例外部,我们可以在命令行中调用redis- cli,就如在例9-18中的那样,还可以得到存储在键redis_primes_flag中的值。你会注意到返回项是一个字符串(不是一个整数)。从Redis返回的所有值都是字符串,所以如果你想要用Python来操控它们,你必须首先把它们转换成一个合适的数据类型。
例9-18 redis-cli例子
$ redis-cli redis 127.0.0.1:6379> GET "redis_primes_flag" "0"
一个强有力的赞成使用Redis来共享数据的论据就是它存活于Python的世界之外——你的团队中的非Python开发者能理解它,还有许多为Redis的工具。在阅读(但没必要运行和调试)你的代码和跟踪发生了什么事情时,它们能够看到状态。从团队速度的角度来讲,尽管存在使用Redis的通信开销,这可能对你是一个巨大的胜利。尽管Redis对于你的项目有额外的依赖性,但你应该意识到它是一个非常普遍的部署工具,并且易于调试和理解。把它视作一个强大的工具添加到你的武器库中去。
Redis有许多配置项。它默认使用一个TCP接口(正是我们所使用的),尽管基准文档提到套接字(socket)可能会快很多。它也陈述了尽管TCP/IP让你在不同类型的OS上跨网络共享数据,其他配置选项可能会更快(但是也受限于你的通信开销):
当服务器和客户端基准程序运行于相同的盒子上时,既可以使用TCP/IP回路,也可以使用UNIX domain socket。这取决于平台,但是UNIX domain sockets相比TCP/IP回路,能够达到大约超过50%的吞吐量(在Linux实例上)。redis基准的默认行为是使用TCP/IP回路。当管道被高负载使用时(例如,长管道),UNIX domain socket相比TCP/IP回路的性能收益会倾向于减弱。
——Redis文档
9.5.6 使用RawValue作为一个标记
multiprocessing.RawValue是一个围绕ctypes字节块的薄薄的包装器。它缺少同步原语,所以几乎不会阻碍我们搜寻最快的方式来为进程间设置标记。它几乎和接下来的mmap例子一样快(只是因为受阻于多出来的一些指令,所以运行要慢一点)。
我们再次能够使用任何ctypes原语,也有一个RawArray选项来共享基本对象数组(变现类似于array.array)。RawValue避开了任何加锁——它使用起来更快,但是你不会得到原子操作。
一般来说,如果你避开了Python在进程间通信(IPC)期间所提供的同步,你会遭受挫折(再一次回到撕扯头发的境地)。无论如何,在这个问题中,不介意一个或多个进程同时设置标记——标记只会在一个方向上切换,每过一段时间读取它时,就会知道搜索是否能被取消。
因为我们在并行搜索期间从不重置标记状态,我们就不必同步。注意这样可能不会适用于你的问题。如果你避开了同步,请确保你以正确的理由来这样做。
如果你想要像更新一个共享计数器那样来做,请看一下Value的文档,并以value.get_lock()来使用一个上下文管理器,因为对一个Value隐式加锁不允许原子操作。
这个例子看上去与之前的Manager例子很类似。仅有的差异就是在例9-19中我们创建了RawValue作为一个1字节(byte)的标记。
例9-19 创建并传递一个RawValue
if __name__ == "__main__": NBR_PROCESSES = 4 value = multiprocessing.RawValue(b'c', FLAG_CLEAR) # 1-byte character pool = Pool(processes=NBR_PROCESSES) ...
使用受管理的值和原始值的灵活性正是在multiprocessing中为共享数据所做的干净设计而得到的收益。
9.5.7 使用mmap作为一个标记
最后,我们得到了共享字节的最快的方式。例9-20展示了使用mmap模块的内存映射(共享内存)的解决方式。共享内存块中的字节没有被同步,它们几乎没有什么开销。它们表现得就像一个文件——在这种情况下,它们就是具有类似文件接口的一个内存块。我们必须要定位到一个位置上并连续地进行读或写。典型情况下,mmap被用来给出一个较大文件的一个短小的视图(内存映射),但是在我们的用例中,不是指明一个文件号作为第一个参数,而是传递-1来表明我们想要一个匿名的内存块。我们也能够指明我们是否想要只读或只写存取权限(我们想要全部权限,这是默认的权限)。
例9-20 由mmap来使用一个共享内存标记
sh_mem = mmap.mmap(-1, 1) # memory map 1 byte as a flag def check_prime_in_range((n, (from_i, to_i))): if n % 2 == 0: return False assert from_i % 2 != 0 check_every = CHECK_EVERY for i in xrange(from_i, int(to_i), 2): check_every -= 1 if not check_every: sh_mem.seek(0) flag = sh_mem.read_byte() if flag == FLAG_SET: return False check_every = CHECK_EVERY if n % i == 0: sh_mem.seek(0) sh_mem.write_byte(FLAG_SET) return False return True def check_prime(n, pool, nbr_processes): # cheaply check high-probability set of possible factors from_i = 3 to_i = SERIAL_CHECK_CUTOFF sh_mem.seek(0) sh_mem.write_byte(FLAG_CLEAR) if not check_prime_in_range((n, (from_i, to_i))): return False ... if False in results: return False return True
mmap支持很多方法,能够被用来在它所代表的文件中腾挪(包括find、readline和write)。我们正在以最基本的方式来使用它——我们在开始每一个读或写之前都定位到内存块的起点处,既然我们只共享一个字节,我们显示地使用read_byte和write_byte。
没有加锁和解释数据的Python开销,我们直接用操作系统来处理字节,所以这是最快的通信方式。
9.5.8 使用mmap作为一个标记的终极效果
尽管前面mmap的结果整体上已经是最好了,我们还是情不自禁地想到对于出现素数的代价最高的情况下,我们只能回到Naïve Pool的结果。目标就是去接受无法从内循环中提早退出的现实,并且最小化任何没有直接关联的开销。
本节呈现了一种稍微复杂一点的解决方案。尽管这个mmap的结果还是最快的,但是能够对其他我们已见到的基于标记的方法做出相同的修改。
在之前的例子中,我们已经使用了CHECK_EVERY。这意味着我们可以有check_ next局部变量来跟踪、减少,并使用布尔测试——每一个运算给每一次迭代增添了一点额外的时间。在验证一个大素数的情况下,这种额外的管理开销发生了超过300000次。
在例9-21中展示的第一个优化就是认识到我们可以用一个向前看(look-ahead)的值取代递减的计数器,接着我们只要在内循环中做一个布尔变量的比较即可。这样就移除了递减,归因于Python的解释风格,递减相当慢。这个优化用CPython2.7在这个测试中可以工作,但是它不可能在更智能的编译器(例如,PyPy或Cython)中提供任何收益。当检测我们其中一个大素数时,这个步骤节省了0.7秒的时间。
例9-21 开始优化掉我们代价高昂的逻辑
def check_prime_in_range((n, (from_i, to_i))): if n % 2 == 0: return False assert from_i % 2 != 0 check_next = from_i + CHECK_EVERY for i in xrange(from_i, int(to_i), 2): if check_next == i: sh_mem.seek(0) flag = sh_mem.read_byte() if flag == FLAG_SET: return False check_next += CHECK_EVERY if n % i == 0: sh_mem.seek(0) sh_mem.write_byte(FLAG_SET) return False return True
我们也能够通过把循环展开成两阶段过程的方式来整体替换计数器所代表的逻辑,就如在例9-22中所显示的那样。首先,外循环涉及期望区间,但是以逐步的方式作用在CHECK_EVERY上。其次,一个新的内循环取代了check_every逻辑——它检查局部因子区间,接着就结束了。这就等价于if not check_every:test。我们用之前sh_mem的逻辑跟踪它来检查早退标记。
例9-22 优化掉我们代价高昂的逻辑
def check_prime_in_range((n, (from_i, to_i))): if n % 2 == 0: return False assert from_i % 2 != 0 for outer_counter in xrange(from_i, int(to_i), CHECK_EVERY): upper_bound = min(int(to_i), outer_counter + CHECK_EVERY) for i in xrange(outer_counter, upper_bound, 2): if n % i == 0: sh_mem.seek(0) sh_mem.write_byte(FLAG_SET) return False sh_mem.seek(0) flag = sh_mem.read_byte() if flag == FLAG_SET: return False return True
这对速度的影响是巨大的。对于非素数的情况甚至提高得更多,但是更重要的是,我们的素数检测已经几乎和Less Naïve Pool的版本一样快了(现在只慢了0.05秒)。在我们已经用进程间通信做了很多额外工作的前提下,这是非常令人感兴趣的结果。然而,要注意的是,它是针对CPython的,当通过编译器运行时不可能得到任何收益。
我们甚至能够走得更远(但坦率地说,这有点儿蠢)。查找不在局部域中声明的变量开销有点高。我们可以创建全局FLAG_SET和频繁使用的.seed()和.read_byte()方法的的局部引用来避免代价更高的查找。然而,结果代码(例9-23)的可读性甚至比前面的还差,我们真的不推荐你这样做。当检测更大的素数时,这个最终结果比Less Naïve Pool版本要慢上1.5%。在我们对非素数得到了4.8倍加速的前提下,我们可能采用这个例子来看看它究竟(应该)能运行多快。
例9-23 打破“不要伤害团队速度”的规则来竭力得到一个额外的加速
def check_prime_in_range((n, (from_i, to_i))): if n % 2 == 0: return False assert from_i % 2 != 0 FLAG_SET_LOCAL = FLAG_SET sh_seek = sh_mem.seek sh_read_byte = sh_mem.read_byte for outer_counter in xrange(from_i, int(to_i), CHECK_EVERY): upper_bound = min(int(to_i), outer_counter + CHECK_EVERY) for i in xrange(outer_counter, upper_bound, 2): if n % i == 0: sh_seek(0) sh_mem.write_byte(FLAG_SET) return False sh_seek(0) if sh_read_byte() == FLAG_SET_LOCAL: return False return True
使用手工循环展开和创建全局对象的局部引用的行为是愚蠢的。整体上,它让代码变得更难以理解,从而受困于更低的团队速度,事实上这是编译器的工作(例如,一个类似PyPy的JIT编译器或者一个类似Cython的静态编译器)。
人们不应该做这种类型的操作,因为它是很脆弱的。我们用Python 3+没有测出这种优化方式,而且我们不想要——我们不真心期待这些递进的提高在另外的Python版本(当然不是在不同的实现上,类似PyPy或IronPython)上能工作。
我们向你展示过了,所以你知道它或许是可能的,并且提醒你保持理智,你真的应该让编译器来为你负责这种类型的工作。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论