行业动态

了解最新公司动态及行业资讯

当前位置:首页>新闻中心>行业动态
全部 1755 公司动态 599 行业动态 608

太疯狂了(python3.9性能提升)python优化执行速度,让 Python 代码飙升330倍:从入门到精通的四种性能优化实践,性能优化,

时间:2025-07-10   访问量:1008

花下猫语:性能优化是每个程序员的必修课,但你是否想过,除了更换算法,还有哪些“大招”?这篇文章堪称典范,它将一个普通的函数,通过四套组合拳,硬生生把性能提升了 330 倍!作者不仅展示了“术”,更传授了“道”。让我们一起跟随作者的思路,体验一次酣畅淋漓的优化之旅。

PS.本文选自最新一期Python 潮流周刊,如果你对优质文章感兴趣,诚心推荐你订阅我们的专栏。

作者:Itamar Turner-Trauring

译者:豌豆花下猫@Python猫

英文:330× faster: Four different ways to speed up your code

声明:本翻译是出于交流学习的目的,为便于阅读,部分内容略有改动。转载请保留作者信息。

温馨提示: 本文原始版本与当前略有不同,比如曾经提到过500倍加速;本文已根据实际情况重新梳理,使论证更清晰。

当你的 Python 代码慢如蜗牛,而你渴望它快如闪电时,其实有很多种提速方式,从并行化到编译扩展应有尽有。如果只盯着一种方法,往往会错失良机,最终的代码也难以达到极致性能。

为了不错过任何潜在的提速机会,我们可以从“实践”的角度来思考。每种实践:

以独特方式加速你的代码涉及不同的技能和知识可以单独应用也可以组合应用,获得更大提升

为了让这一点更具体,本文将通过一个案例演示多种实践的应用,具体包括:

效率(Efficiency): 消除浪费或重复的计算。编译(Compilation): 利用编译型语言,并巧妙绕开编译器限制。并行化(Parallelism): 充分发挥多核CPU的威力。流程(Process): 采用能产出更快代码的开发流程。

我们将看到:

仅用效率实践,就能带来近 2倍 提速。仅用编译实践,可实现 10倍 提速。两者结合,速度更上一层楼。最后加上并行化实践,最终实现 330倍 惊人加速。

我们的例子:统计字母频率

我们有一本英文书,简·奥斯汀的《诺桑觉寺》:

with open("northanger_abbey.txt") as f: TEXT = f.read()

我们的目标是分析书中字母的相对频率。元音比辅音更常见吗?哪个元音最常见?

下面是最初的实现:

from collections import defaultdict def frequency_1(text): # 一个当键不存在时默认值为0的字典 counts = defaultdict(lambda: 0) for character in text: if character.isalpha(): counts[character.lower()] += 1 return counts

运行结果如下:

sorted( (count, letter) for (letter, count) in frequency_1(TEXT).items() ) [(1, à), (2, é), (3, ê), (111, z), (419, q), (471, j), (561, x), (2016, k), (3530, v), (5297, b), (5404, p), (6606, g), (7639, w), (7746, f), (7806, y), (8106, c), (8628, m), (9690, u), (13431, l), (14164, d), (20675, s), (21107, r), (21474, h), (22862, i), (24670, n), (26385, a), (26412, o), (30003, t), (44251, e)]

毫无意外,出现频率最高的字母是 "e"。

那我们如何让这个函数更快?

流程实践:测量与测试

软件开发不仅依赖于源代码、库、解释器、编译器这些“产物”,更离不开你的工作“流程”——也就是你做事的方法。性能优化同样如此。本文将介绍两种在优化过程中必不可少的流程实践:

通过基准测试和性能分析来测量代码速度。测试优化后的代码,确保其行为与原始版本一致。

我们可以先用 line_profiler 工具分析函数,找出最耗时的代码行:

Line # Hits % Time Line Contents ======================================== 3 def frequency_1(text): 4 # 一个当键不存在时默认值为0的字典 5 # available: 6 1 0.0 counts = defaultdict(lambda: 0) 7 433070 30.4 for character in text: 8 433069 27.3 if character.isalpha(): 9 339470 42.2 counts[character.lower()] += 1 10 1 0.0 return counts

效率实践:减少无用功

效率实践的核心,是用更少的工作量获得同样的结果。这类优化通常在较高的抽象层面进行,无需关心底层CPU细节,因此适用于大多数编程语言。其本质是通过改变计算逻辑来减少浪费。

减少内循环的工作量

从上面的性能分析可以看出,函数大部分时间都花在 counts[character.lower()] += 1 这行。显然,对每个字母都调用 character.lower() 是种浪费。我们一遍遍地把 "I" 转成 "i",甚至还把 "i" 转成 "i"。

优化思路:我们可以先分别统计大写和小写字母的数量,最后再合并,而不是每次都做小写转换。

def frequency_2(text): split_counts = defaultdict(lambda: 0) for character in text: if character.isalpha(): split_counts[character] += 1 counts = defaultdict(lambda: 0) for character, num in split_counts.items(): counts[character.lower()] += num return counts # 确保新函数结果与旧函数完全一致 assert frequency_1(TEXT) == frequency_2(TEXT)

说明:这里的 assert 就是流程实践的一部分。一个更快但结果错误的函数毫无意义。虽然你在最终文章里看不到这些断言,但它们在开发时帮我抓出了不少bug。

基准测试(也是流程实践的一环)显示,这个优化确实让代码更快了:

| frequency_1(TEXT) | 34,592.5 µs | | frequency_2(TEXT) | 25,798.6 µs |

针对特定数据和目标进行优化

我们继续用效率实践,这次针对具体目标和数据进一步优化。来看下最新代码的性能分析:

Line # Hits % Time Line Contents ======================================== 3 def frequency_2(text): 4 1 0.0 split_counts = defaultdict(lambda: 0) 5 433070 33.6 for character in text: 6 433069 32.7 if character.isalpha(): 7 339470 33.7 split_counts[character] += 1 8 9 1 0.0 counts = defaultdict(lambda: 0) 10 53 0.0 for character, num in split_counts.items(): 11 52 0.0 counts[character.lower()] += num 12 1 0.0 return counts

可以看到,split_counts[character] += 1 依然是耗时大户。怎么加速?答案是用 list 替换 defaultdict(本质上是 dict)。list 的索引速度远快于 dict:

list 存储条目只需一次数组索引dict 需要计算哈希、可能多次比较,还要内部数组索引

但 list 的索引必须是整数,不能像 dict 那样用字符串,所以我们要把字符转成数字。幸运的是,每个字符都能用 ord() 查到数值:

ord(a), ord(z), ord(A), ord(Z) # (97, 122, 65, 90)

用 chr() 还能把数值转回字符:

chr(97), chr(122) # (a, z)

所以可以用 my_list[ord(character)] += 1 计数。但前提是我们得提前知道 list 的大小。如果处理任意字母字符,list 可能会很大:

ideograph = ord(ideograph), ideograph.isalpha() # (178057, True)

再回顾下我们的目标:

处理对象是英文文本,这是题目要求。输出结果里确实有少量非标准英文字母(如 à),但极其罕见。(严格说 à 应该归为 a,但这里偷懒没做……)我们只关心相对频率,不是绝对精确计数。

基于这些,我决定简化问题:只统计 A 到 Z,其他字符都忽略,包括带重音的。对英文文本来说,这几乎不影响字母相对频率。

这样问题就简单了:字符集有限且已知,可以放心用 list 替代 dict!

优化后实现如下:

def frequency_3(text): # 创建长度为128的零列表;ord(z)是122,128足够了 split_counts = [0] * 128 for character in text: index = ord(character) if index < 128: split_counts[index] += 1 counts = {} for letter in abcdefghijklmnopqrstuvwxyz: counts[letter] = ( split_counts[ord(letter)] + split_counts[ord(letter.upper())] ) return counts

由于输出只包含A到Z,正确性检查也要稍作调整:

def assert_matches(counts1, counts2): """确保A到Z的计数匹配""" for character in abcdefghijklmnopqrstuvwxyz: assert counts1[character] == counts2[character] assert_matches( frequency_1(TEXT), frequency_3(TEXT) )

新实现更快了:

| frequency_2(TEXT) | 25,965.5 µs | | frequency_3(TEXT) | 19,443.5 µs |

编译实践:切换到更快的语言

接下来我们切换到编译型语言——Rust。

其实可以直接把 frequency_1() 移植到 Rust,编译器会自动做一些在 Python 里需要手动优化的事。

但大多数时候,无论用什么语言,效率实践都得靠你自己。这也是为什么“效率”和“编译”是两种不同的实践:它们带来的性能提升来源不同。我们在 frequency_2() 和 frequency_3() 里做的优化,同样能让 Rust 代码更快。

为证明这一点,我把上面三个 Python 函数都移植到了 Rust(前两个源码可点击展开查看):

前两个版本在 Rust 中的实现

#[pyfunction] fn frequency_1_rust( text: &str, ) -> PyResult<HashMap<char, u32>> { let mut counts = HashMap::new(); for character in text.chars() { if character.is_alphabetic() { *counts .entry( character .to_lowercase() .next() .unwrap_or(character), ) .or_default() += 1; } } Ok(counts) } #[pyfunction] fn frequency_2_rust( text: &str, ) -> PyResult<HashMap<char, u32>> { let mut split_counts: HashMap<char, u32> = HashMap::new(); for character in text.chars() { if character.is_alphabetic() { *split_counts.entry(character).or_default() += 1; } } let mut counts = HashMap::new(); for (character, num) in split_counts.drain() { *counts .entry( character .to_lowercase() .next() .unwrap_or(character), ) .or_default() += num; } Ok(counts) }

第三个版本在 Rust 里的样子:

fn ascii_arr_to_letter_map( split_counts: [u32; 128], ) -> HashMap<char, u32> { let mut counts: HashMap<char, u32> = HashMap::new(); for index in (a as usize)..=(z as usize) { let character = char::from_u32(index as u32).unwrap(); let upper_index = character.to_ascii_uppercase() as usize; counts.insert( character, split_counts[index] + split_counts[upper_index], ); } counts } #[pyfunction] fn frequency_3_rust(text: &str) -> HashMap<char, u32> { let mut split_counts = [0u32; 128]; for character in text.chars() { let character = character as usize; if character < 128 { split_counts[character] += 1; } } ascii_arr_to_letter_map(split_counts) }

所有三个 Rust 版本的结果都和 Python 版本一致:

assert_matches(frequency_1(TEXT), frequency_1_rust(TEXT)) assert_matches(frequency_1(TEXT), frequency_2_rust(TEXT)) assert_matches(frequency_1(TEXT), frequency_3_rust(TEXT))

对所有6个版本做基准测试,清楚地说明了效率实践编译实践的性能优势是不同且互补的。能加速 Python 代码的效率优化,同样也能加速 Rust 代码。

函数 运行时间 (µs) frequency_1(TEXT) 33,741.5 frequency_2(TEXT) 25,797.4 frequency_3(TEXT) 19,432.0 frequency_1_rust(TEXT) 3,704.3 frequency_2_rust(TEXT) 3,504.8 frequency_3_rust(TEXT) 204.9

一句话:效率和编译是两种不同的速度来源。

并行化实践:榨干多核CPU

到目前为止,代码都只跑在单核CPU上。但现在的电脑大多有多核,利用并行计算又是另一种速度来源,所以它也是独立的实践。

下面是用 Rayon 库 实现的 Rust 并行版本:

fn sum(mut a: [u32; 128], b: [u32; 128]) -> [u32; 128] { for i in 0..128 { a[i] += b[i]; } a } #[pyfunction] fn frequency_parallel_rust( py: Python<_>, text: &str, ) -> HashMap<char, u32> { use rayon::prelude::*; // 确保释放全局解释器锁(GIL) let split_counts = py.allow_threads(|| { // 一个榨取 Rayon 更多性能的技巧: // 我们关心的 ASCII 字符总是由单个字节明确表示。 // 所以直接处理字节是安全的,这能让我们强制 Rayon 使用数据块。 text.as_bytes() // 并行迭代数据块 .par_chunks(8192) .fold_with( [0u32; 128], |mut split_counts, characters| { for character in characters { if *character < 128 { split_counts [*character as usize] += 1; }; } split_counts }, ) // 合并所有数据块的结果 .reduce(|| [0u32; 128], sum) }); ascii_arr_to_letter_map(split_counts) }

结果依然正确:

assert_matches(frequency_1(TEXT), frequency_parallel_rust(TEXT))

加速效果如下:

| frequency_3_rust(TEXT) | 234.5 µs | | frequency_parallel_rust(TEXT) | 105.3 µs |

流程重访:我们测对了吗?

最终函数快了330倍……真的吗?

我们是通过多次调用函数取平均运行时间来测量性能的。但我恰好知道一些背景知识:

Rust 字符串是 UTF-8,Python 用的是自己的内部格式,不是 UTF-8。所以调用 Rust 函数时,Python 需要把字符串转成 UTF-8。Python 用特定 API 转 UTF-8 时会缓存转换结果。

这意味着,我们很可能没测到 UTF-8 转换的成本,因为反复对同一个 TEXT 字符串基准测试,第一次后 UTF-8 版本就被缓存了。真实场景下,未必总有缓存。

我们可以测下单次调用新字符串的耗时。我用非并行版本,因为它速度更稳定:

from time import time def timeit(f, *args): start = time() f(*args) print("Elapsed:", int((time() - start) * 1_000_000), "µs") print("Original text") timeit(frequency_3_rust, TEXT) timeit(frequency_3_rust, TEXT) print() for i in range(3): # 新字符串 s = TEXT + str(i) print("New text", i + 1) timeit(frequency_3_rust, s) timeit(frequency_3_rust, s) print() Original text Elapsed: 212 µs Elapsed: 206 µs New text 1 Elapsed: 769 µs Elapsed: 207 µs New text 2 Elapsed: 599 µs Elapsed: 202 µs New text 3 Elapsed: 625 µs Elapsed: 200 µs

对于新字符串,第一次运行比第二次慢了大约 400µs,这很可能就是转换为 UTF-8 的成本。

当然,我们加载的书本身就是 UTF-8 格式。所以,我们可以改变 API,直接将 UTF-8 编码的 bytes 传递给 Rust 代码,而不是先加载到 Python(转换为 Python 字符串),再传递给 Rust(转换回 UTF-8),这样就能避免转换开销。

我实现了一个新函数 frequency_3_rust_bytes(),它接受 UTF-8 编码的字节(源码略,与 frequency_3_rust() 基本一样)。然后测了下单个字节串第一次和第二次的时间:

with open("northanger_abbey.txt", "rb") as f: TEXT_BYTES = f.read() assert_matches( frequency_1(TEXT), frequency_3_rust_bytes(TEXT_BYTES) ) print("新文本不再有~400µs的转换开销:") new_text = TEXT_BYTES + b"!" timeit(frequency_3_rust_bytes, new_text) timeit(frequency_3_rust_bytes, new_text) 新文本不再有~400µs的转换开销: Elapsed: 186 µs Elapsed: 182 µs

如果我们测量持续的平均时间,可以看到它与之前的版本大致相当:

| frequency_3_rust(TEXT) | 227.2 µs | | frequency_3_rust_bytes(TEXT_BYTES) | 183.8 µs |

可见传入 bytes 确实能绕过 UTF-8 转换成本。你可能还想实现

frequency_parallel_rust_bytes(),这样并行也能无转换开销。

补充:那么 collections.Counter 呢?

你可能会问,Python 标准库里不是有现成的 collections.Counter 吗?它是专门计数的 dict 子类。

# 来自 Python 3.13 的 collections/__init__.py def _count_elements(mapping, iterable): Tally elements from the iterable. mapping_get = mapping.get for elem in iterable: mapping[elem] = mapping_get(elem, 0) + 1 try: # 如果可用,加载 C 语言实现的辅助函数 from _collections import _count_elements except ImportError: pass class Counter(dict): # ...

我们可以这样使用它:

from collections import Counter def frequency_counter(text): return Counter(c.lower() for c in text if c.isalpha()) # 注意:这里的实现与原文略有不同,是为了与 frequency_1 保持完全一致的行为 # 原文的 Counter(text.lower()) 会统计非字母字符,导致结果不一致 assert_matches(frequency_1(TEXT), frequency_counter(TEXT))

这个实现比我们的第一个版本更简洁,但性能如何?

| frequency_1(TEXT) | 34,592.5 µs | | frequency_counter(TEXT) | 约 30,000 µs |

Counter 确实比我们的初始实现快点,但远不如最终优化版。这说明:即使标准库的优化实现,也可能比不上针对场景深度优化的代码。

当然,Counter 胜在简洁和可读性。很多对性能没极致要求的场景,这种权衡完全值得。

性能实践:相辅相成

全文其实一直在用“流程”实践:测试新版本正确性、做性能分析和测量。基准测试还帮我排除了不少无效优化,这里就不赘述了。

“效率”实践帮我们消除无用功,“编译”让代码更快,“并行化”则让多核CPU火力全开。每种实践都是独特的、能带来乘数效应的速度来源。

一句话:如果你想让代码更快,别只盯着一种实践,多管齐下,速度才会飞起来!

Python猫注:如果你喜欢这篇文章,那我要向你推荐一下 Python 潮流周刊!创刊仅两年,我们已坚持分享了超过 1300+ 篇优质文章,以及 1200+ 个开源项目或工具资源,每周精选,助力你打破信息差,告别信息过载,成为更优秀的人!

上一篇:满满干货(密度泛函理论简介)密度泛函理论的作用,密度泛函理论用于异质结构界面能调控与性能优化彭雪枫之子报考哈军工,没有被录取,张爱萍:你妈妈出身有问题,性能优化,

下一篇:这都可以?(思科网络平台怎么样)思科网登录入口,思科在MWC期间推出敏捷服务网络技术 加速AI创新,服务级别协议 (SLA),

在线咨询

点击这里给我发消息 售前咨询专员

点击这里给我发消息 售后服务专员

在线咨询

免费通话

24小时免费咨询

请输入您的联系电话,座机请加区号

免费通话

微信扫一扫

微信联系
返回顶部