Python 高级编程(第2版)--第12章 优化——一些强大的技术

优化——一些强大的技术

  • 优化是一个迭代过程,在这个过程中,并不是每次迭代都会有更好的结果。
  • 主要先决条件是通过测试验证并且正常工作的代码。
  • 应该始终专注于优化当前的应用程序的瓶颈。

总之,一些性能问题只是由某些有质量缺陷的代码或应用程序的使用上下文引起的。例如,以下问题可能会降低应用程序的运行速度。

  • 基本内置类型的使用不当。
  • 太复杂。
  • 硬件资源使用模式与执行环境不匹配。
  • 过于长时间的等待来自第三方 API 或后台服务的响应。
  • 在应用程序的时间关键部分做太多。

可以通过以下非算法方法优化你的程序。

  • 降低复杂度。
  • 架构体系的权衡。
  • 缓存。

降低复杂度

当尝试提高应用程序性能时,第一个也是最明显的方面就是复杂度。定义应用程序复杂度的两种常用的方式是:

  • 循环复杂度(Cyclomatic complexity),往往与应用程序的性能相关。
  • 朗道记法(Landau notation),也称为大 O 记法,在客观判断性能中,这是一种非常有用的算法分类方法。

循环复杂度

循环复杂度通过代码测量线性路径的数量。所有的 if、for 和 while 循环都计算在一个度量中。循环复杂度不是代码质量的得分,而是客观地判断其性能的度量指标。flake8(以及 mccabe 插件),它可以测量代码的循环复杂度。

大 O 记法

大 O 记法是定义函数复杂度的最典型的方法。这个度量指标定义了算法如何受输入数据大小的影响。

为了测量大 O 记法,除去所有常数和低阶项,那么当输入数据增长时,这样便于集中在真正权重的部分。

符号        类型
O(1)        常量,不依赖于输入的数据
O(n)        线性,按照 n 增长
O(n ㏒n)    对数
O(n²)       平方复杂度
O(n³)       立方复杂度
O(n!)       阶乘复杂度

当讨论用大 O 记法表示复杂性时,通常回顾最坏的情况。CPython 中的列表使用了过度分配的数组作为内部存储而不是链表。如果数组已满,当添加一个新元素时,就需要分配一个新数组并将所有现有元素(引用)复制到内存中的一个新区域。如果从最坏情况的复杂性的角度来看,很明显 list.append() 方法具有 O(n) 复杂度。

当解决问题时,我们通常会知道很多关于输入数据的细节,例如它的大小或统计分布。当优化应用程序时,从头到尾的了解输入数据的情况,这是非常值得的。

简化

为了降低代码的复杂度,数据存储的方式是根本。应该仔细选择数据结构。

由于 Python 中的 list 类型的实现细节,在列表中搜索特定的值并不是一个廉价的操作。如果你需要在列表上快速搜索,你可以尝试 Python 标准库中的 bisect 模块。通过使用二分法算法可以有效地找到元素索引。注意,bisect 模块中的每个函数都要求必须是有序序列才能正常工作。如果列表已经是有序的,则可以使用 bisect 将新元素插入到该列表中,而无需重新排序。

使用集合而不是列表:做同样的工作,使用集合类型将更快,因为它使用与 dict 类型相同的哈希表来查找存储的值。此外,集合确保元素的唯一性。额外的优点是更短和更明确的代码。

削减外部调用,减少工作负载。复杂度的一部分是由调用其他函数、方法和类引入的。一般来说,尽可能多的把代码放在循环之外。这对于嵌套循环更是加倍的重要。

使用集合模块

collections模块提供了高性能的容器类型,它可以替换内置的容器类型。此模块中可用的主要类型有。

  • deque:具有额外特性的类列表类型。
  • defaultdict:具有内置默认工厂特性的类字典类型。
  • namedtuple:为成员分配键的类元组类型。

deque

deque 是列表的替代实现。列表基于数组,而 deque 基于双向链表。因此,当你需要在它的中间或头部插入一些东西时,deque 会快得多。但是当你需要进行随机访问时,它就会比较慢。在从列表切换到 deque 之前,你应该总是确保分析你的代码,因为在数组中快速的一些事情(例如随机访问)在链表中却是非常低效。

defaultdict

defaultdict 类型类似于字典类型,但为新键添加了默认工厂。这避免了编写额外的测试来初始化映射实体,并且比 dict.setdefault 方法更加高效。defaultdict 似乎就像 dict 之上的语法糖,只是允许你写较短的代码。defaultdict 类型使用工厂作为参数,因此可以与内置类型或者构造函数不接受参数的类一起使用。

namedtuple

namedtuple 是一个类工厂,它接受一个类型名称和一个属性列表,并从中创建一个类。然后,该类可以用于实例化类似元组的对象,并为其元素提供访问器。另一方面,它是基于元组的,所以通过索引访问其元素非常快。生成的类可以被子类化以添加更多的操作。相比其他数据类型,最初使用 namedtuple 的增益可能不明显。主要的优点是,它比普通元组更容易使用、理解和解释。

在性能方面的第一个优点是 namedtuple 仍然具有元组的特点。这意味着它是不可变的,所以底层的数组存储被精确地分配所需的大小。另一方面,字典需要使用内部散列表的过度分配,以确保 get/set 操作的平均复杂度足够低。因此,namedtuple 在内存效率方面胜过字典。

实际上,namedtuple 是基于元组的,这可能也有益于它的性能。其元素可以通过整数索引访问,类似于另外两个简单序列对象,即列表和元组。这个操作既简单又快速。在字典或自定义类实例(也使用字典存储属性)的情况下,元素访问需要散列表查找。它是高度优化的,以确保良好的性能与集合大小无关,但所提到的 O(1) 复杂度实际上只是平均复杂性。

在对性能至关重要的代码段中,使用列表或元组而不是字典,有时可能是明智的做法。

namedtuple 是一个很好的类型,它结合了字典和元组的优点。

  • 在可读性更重要的部分,可能优先使用属性访问。
  • 在性能关键部分,元素可以通过其索引访问。

架构体系的权衡

当不能通过降低复杂度或选择合适的数据结构进一步改善你的代码的时候,一个好的方法可能是考虑做一些权衡。通常可以通过以下方式提高性能。

  • 使用启发式算法和近似算法替换精确求解算法。
  • 将一些工作推迟到延迟任务队列中处理。
  • 使用概率性的数据结构。

使用启发式和近似算法

一些算法问题根本没有在运行时间上可以让用户接受的技术解决方案。例如,考虑一个程序处理一些复杂的优化问题,如旅行推销员问题(TSP)或车辆路径问题(VRP)。这两个问题是组合优化中的 NP-hard 问题。用于具有低复杂度的此类问题的确切算法是未知的。这意味着可以实际解决的问题的规模会受到很大限制。

使用启发式(heuristics)或近似算法(approximation algorithms)是有意义的。

  • 启发式通过交易最优性、完整性、准确性或速度的精度来解决给定的问题。它们专注于速度,但与精确算法的结果相比,可能真的很难证明它们的解决方案的质量。
  • 近似算法在理念上与启发式相似,但是与启发式算法不同的是,它具有可证明的解决方案质量和运行时间界限。

发式的另一个好处是,它们并不总是需要为每个需要解决的新问题从头开始构建。它们的高级版本,称为元启发式(metaheuristics),提供解决数学优化问题的策略,可以应用在许多情况下。

一些流行的启发式算法包括以下几种。

  • 模拟退火。
  • 遗传算法。
  • 禁忌搜索。
  • 蚁群优化。
  • 进化计算。

使用任务队列和延迟处理

在 Web 应用程序中发送电子邮件。在这种情况下,增加的响应时间可能不一定是由你的实现导致。响应时间可能受到某些第三方服务(例如电子邮件服务器)影响。如果你只是花大部分时间等待其他服务回复,你能否优化你的应用程序呢?

解决这种类型的问题的惯用模式是使用消息/任务队列。当你需要执行耗时不确定的操作时,只需将其添加到处理它的工作队列中,并立即响应接受请求的用户即可。

在应用程序的关键部分正确地使用任务/消息队列还可以给你带来其他好处。

  • 为 HTTP 请求提供服务的 Web 工作者可以从额外的工作中解脱出来,从而可以更快地处理请求。
  • 消息队列通常更不受外部服务的瞬时故障的影响。
  • 通过良好的消息队列实现,你可以轻松地在多台计算机上分配工作。

在 Python 开发人员中最受欢迎的是 Celery(参见 http://www.celeryproject.org)。它是一个完整的任务队列框架,支持多个消息服务器,也允许计划执行任务(它可以替代您的 cron 作业)。如果你需要更简单的东西,那么 RQ(参见 http://pythonrq.org)可能是一个很好的选择。它比 Celery 简单得多,并使用 Redis 键/值存储作为其消息服务器(RQ 实际上代表 Redis 队列)。

应该总是仔细考虑你的任务队列的方法。绝不能把每种类型的工作都放到队列中处理。它们善于解决几种类型的问题,但也会引入一堆新的问题。

  • 增多的系统架构复杂性。
  • 处理不止一次交付。
  • 维护和监控更多的服务。
  • 更大的处理延迟。
  • 难以记录日志。

使用概率型数据结构

概率型数据结构是为存储集合值而设计的,可以让你在一定的时间内或资源约束内回答某些特定的问题,这些问题使用其他数据结构难以处理。

有很多具有这样的概率性质的数据结构。每个结构都解决一些具体的问题,并且由于它们的随机性,就不能在每种情况下都去使用。

HyperLogLog(参见 https://en.wikipedia.org/wiki/HyperLogLog )是一种估算多重集中不同元素数量的算法。

概率型数据结构经常用于键/值存储系统中以加速键查找。在这种系统中使用的流行技术之一被称为近似成员查询(AMQ)。Bloom 过滤器(参考 https://en.wikipedia.org/wiki/Bloom_filter )就是一个用于此目的有趣的数据结构。

缓存

当你的应用程序中的某些函数需要很长时间计算时,可以考虑的有用的技术是缓存。

可以缓存运行成本高的函数或方法的结果,只要:

  • 该函数是确定性的,并且每次给定相同的输入时,结果具有相同的值;
  • 函数的返回值继续有用并且在一段时间内有效(非确定性)。

任何缓存解决方案的最重要的必要条件是拥有一个存储器,你可以取回保存的值,这通常比重新计算更快。通常,以下情况比较适合使用缓存:

  • 查询数据库的可调用项的结果;
  • 渲染为静态值的可调用项的结果,例如文件内容,Web 请求或 PDF 渲染;
  • 执行复杂计算的确定性可调用对象的结果;
  • 全局映射,用于跟踪到期时间的值,例如 Web 会话对象;
  • 需要经常和快速访问的结果。

缓存的另一个重要的使用案例是保存通过 Web 服务获得的第三方 API 的结果。通过减少网络延迟,这可以大大提高应用程序性能。

确定性缓存

确定性函数是缓存中最简单并且最安全的使用案例。如果给定完全相同的输入,确定性函数总是返回相同的值,因此通常可以无限期地存储它们的结果。唯一的限制是用于缓存的存储的大小。

Python 标准库提供了一个非常简单和可重用的实用程序,当你需要在内存中缓存确定性函数的结果时,大多数情况下可以使用它。它是来自 functools 模块的 lru_cache(maxsize, typed) 装饰器。名称来自 LRU 缓存,它代表最近最少使用(least recently used)。附加的参数可以对记忆行为进行更精细的控制。

  • maxsize:设置高速缓存的空间上限。None 表示没有限制。
  • typed:定义了不同类型的值是否应该被缓存为相同的结果。

非确定性缓存

非确定性函数的缓存比记忆化更复杂。事实上,由于这样的函数的每次执行可能给出不同的结果,通常无法使用先前的很长时间的值。你需要做的是判断一个缓存的值的有效时间。

非确定性函数的缓存通常依赖于某些外部状态,这些状态在应用程序代码中很难跟踪。典型的示例组件如下。

  • 关系型数据库以及常用的任何类型的结构化数据存储引擎。
  • 通过网络连接(Web API)访问的第三方服务。
  • 文件系统。

缓存的实际东西通常是与系统的其他组件交互的整个结果。如果要在与数据库通信时节省时间和资源,那么昂贵的查询是值得缓存的。

缓存非确定性函数的技术实际上与缓存确定性函数中使用的技术非常相似。最显着的区别是,它们通常需要选项根据其年龄使缓存的值无效。这意味着来自 functools 模块的 lru_cache() 装饰器在这种情况下的使用将非常有限。

缓存服务

Memcached

Memcached 是一个非常受欢迎和久经考验的解决方案。在简单的缓存特性中,它具有集群功能,使得可以立即建立高效的分布式缓存系统。该工具是基于 Unix 的,它可以运行于很多平台上,并且很多编程语言都可以使用它。

与 Memcached 的简单交互主要有以下 3 个方法。

  • set(key, value):保存给定键的值。
  • get(key):获取给定键的值(如果存在)。
  • delete(key):如果存在,删除给定键下的值。

Memcached 的缺点之一是它被设计为将值存储为字符串或二进制块,并且这与每个原生 Python 类型不兼容。实际上,它只兼容一种类型——字符串。这意味着更复杂的类型需要被序列化,以便可以成功存储在 Memcached 中。通常使用 JSON 序列化简单的数据结构。

另一个在使用每个缓存服务时非常常见的问题是,在使用基于键/值存储原则的缓存服务时,如何选择合适的键名称。

如果缓存具有基本参数的简单函数调用,对于这种情况,问题通常很简单。你可以将函数名称及其参数转换为字符串,并将它们连接在一起。你唯一需要关心的是,如果你在应用程序的许多部分使用缓存,要确保在为不同的函数创建的键之间没有冲突。

更棘手的情况是缓存函数具有由字典或自定义类组成的复杂参数。在这种情况下,你需要找到一种方法,以一致的方式将这种调用签名转换为高速缓存的键。

最后一个问题是,Memcached 和许多其他缓存服务一样,不喜欢很长的字符串作为键。通常,键越短越好。长键可能会降低性能或只是不适合硬编码的服务限制。通常的做法是计算 MD5、SHA 或任何其他散列函数,并将其用作缓存键。Python 标准库有一个 hashlib 模块,它提供了几个常用的哈希算法的实现。

计算哈希是有代价的。然而,有时它是唯一可行的解决方案。使用哈希函数时要注意的一个重要的事情是哈希冲突。

你可能感兴趣的:(Python,高级编程,python)