网易首页 > 网易号 > 正文 申请入驻

让代码飙升330倍:从入门到精通的四种性能优化实践

0
分享至

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

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

作者:Itamar Turner-Trauring

译者:豌豆花下猫@Python猫

英文:330× faster: Four different ways to speed up your code(https://pythonspeed.com/articles/different-ways-speed)

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

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

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

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

  • 以独特方式加速你的代码

  • 涉及不同的技能和知识

  • 可以单独应用

  • 也可以组合应用,获得更大提升

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

  1. 效率(Efficiency):消除浪费或重复的计算。

  2. 编译(Compilation):利用编译型语言,并巧妙绕开编译器限制。

  3. 并行化(Parallelism):充分发挥多核CPU的威力。

  4. 流程(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"。

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

流程实践:测量与测试

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

  1. 通过基准测试和性能分析来测量代码速度。

  2. 测试优化后的代码,确保其行为与原始版本一致。

我们可以先用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)

再回顾下我们的目标:

  1. 处理对象是英文文本,这是题目要求。

  2. 输出结果里确实有少量非标准英文字母(如 'à'),但极其罕见。(严格说 'à' 应该归为 'a',但这里偷懒没做……)

  3. 我们只关心相对频率,不是绝对精确计数。

基于这些,我决定简化问题:只统计 '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 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 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

 {     letmut counts: HashMap

 = HashMap::new();     for index in ('a'asusize)..=('z'asusize) {         let character =             char::from_u32(index asu32).unwrap();         let upper_index =             character.to_ascii_uppercase() asusize;         counts.insert(             character,             split_counts[index] + split_counts[upper_index],         );     }     counts } #[pyfunction] fn frequency_3_rust(text: &str) -> HashMap

 {     letmut split_counts = [0u32; 128];     for character in text.chars() {         let character = character asusize;         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)

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

并行化实践:榨干多核CPU

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

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

fn sum(mut a: [u32; 128], b: [u32; 128]) -> [u32; 128] {     for i in0..128 {         a[i] += b[i];     }     a } #[pyfunction] fn frequency_parallel_rust(     py: Python<'_>,     text: &str, ) -> HashMap

 {     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 asusize] += 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+ 个开源项目或工具资源,每周精选,助力你打破信息差,告别信息过载,成为更优秀的人!

如果你正在寻找优质的Python文章和项目,我必须向你推荐!

它精选全网的优秀文章、教程、开源项目、软件工具、播客、视频、热门话题等丰富内容,让你紧跟技术最前沿,获取最新的第一手学习资料!

欢迎点击下方图片,了解这份全世界知识密度最高、知识广度最大的 Python 技术周刊。

特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。

Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.

相关推荐
热点推荐
6岁时惨遭伯母伤害失明,“山西挖眼案”受害男孩郭斌高考721分

6岁时惨遭伯母伤害失明,“山西挖眼案”受害男孩郭斌高考721分

猫头鹰视频
2026-06-26 14:50:11
深夜,美股存储芯片重挫!黄金、白银,突变!霍尔木兹海峡,传来大消息!

深夜,美股存储芯片重挫!黄金、白银,突变!霍尔木兹海峡,传来大消息!

证券时报e公司
2026-06-27 01:27:05
兜兜转转!小托马斯回归绿军!!

兜兜转转!小托马斯回归绿军!!

柚子说球
2026-06-27 02:00:49
陈露宣布与过去和解!泪流满面,称13年把全部青春给了爱的男孩

陈露宣布与过去和解!泪流满面,称13年把全部青春给了爱的男孩

乡野小珥
2026-06-26 07:13:35
美团回应“带娃送外卖”等视频:经核实,“小洪”3月至今完成19单,发布15条视频并开通带货功能、定期直播;提醒MCN不要策划苦情剧本

美团回应“带娃送外卖”等视频:经核实,“小洪”3月至今完成19单,发布15条视频并开通带货功能、定期直播;提醒MCN不要策划苦情剧本

极目新闻
2026-06-26 17:42:55
25岁男子与42岁已婚女子发展为情人,得知女方在丈夫处住宿后,要求其分居被拒,强行拖拽她跳下超13米高大桥致其受伤;被判11年3个月

25岁男子与42岁已婚女子发展为情人,得知女方在丈夫处住宿后,要求其分居被拒,强行拖拽她跳下超13米高大桥致其受伤;被判11年3个月

扬子晚报
2026-06-26 19:37:30
俄罗斯前防长谢尔盖·伊万诺夫去世,普京向其亲属表示慰问

俄罗斯前防长谢尔盖·伊万诺夫去世,普京向其亲属表示慰问

环球网资讯
2026-06-26 20:54:19
极目深度丨致命爱情:无法分手的男友、难以逃脱的控制

极目深度丨致命爱情:无法分手的男友、难以逃脱的控制

极目新闻
2026-06-26 20:07:10
克林顿爆粗、奥巴马翻白眼、特朗普开骂…5个美国总统没一个能收拾内塔尼亚胡

克林顿爆粗、奥巴马翻白眼、特朗普开骂…5个美国总统没一个能收拾内塔尼亚胡

可达鸭面面观
2026-06-26 18:25:33
白玉兰奖揭晓,最佳女主角杨紫:爸妈,我拿到这个奖了,女儿没有让你们失望

白玉兰奖揭晓,最佳女主角杨紫:爸妈,我拿到这个奖了,女儿没有让你们失望

红星新闻
2026-06-26 22:45:22
《人民日报》罕见发文,对谢娜巡演明确定性:无实力收割流量!

《人民日报》罕见发文,对谢娜巡演明确定性:无实力收割流量!

观察鉴娱
2026-06-26 11:24:40
谷歌干了一件地震局都没干成的事:在地震中救了委内瑞拉几万人的命

谷歌干了一件地震局都没干成的事:在地震中救了委内瑞拉几万人的命

知识圈
2026-06-26 13:15:11
上海403分本科线“引全网怒喷”——凭啥沪爷高考,拿的是站票?

上海403分本科线“引全网怒喷”——凭啥沪爷高考,拿的是站票?

妍妍教育日记
2026-06-26 08:45:06
资本全跑了,演员排队找工作,中国电影怎么就走到这步了?

资本全跑了,演员排队找工作,中国电影怎么就走到这步了?

情感大头说说
2026-06-26 10:27:45
男子20多年前考入大学后不满专业任性辍学,与家人从此断联,近日在山林中被浙江民警发现,家属们驱车千里赶来重逢,民警:好好陪伴亲人

男子20多年前考入大学后不满专业任性辍学,与家人从此断联,近日在山林中被浙江民警发现,家属们驱车千里赶来重逢,民警:好好陪伴亲人

极目新闻
2026-06-26 10:05:21
知名网红带货翻车,助农卖茶叶被曝光是假货,获利已经超千万

知名网红带货翻车,助农卖茶叶被曝光是假货,获利已经超千万

新游戏大妹子
2026-06-26 13:06:36
世界杯太残酷了:随着塞内加尔5-0,第3支出局的亚洲球队诞生

世界杯太残酷了:随着塞内加尔5-0,第3支出局的亚洲球队诞生

侧身凌空斩
2026-06-27 05:16:18
某地瑜伽馆惊现印度男人教练不堪入目,网友说:瑜伽是印度房中术

某地瑜伽馆惊现印度男人教练不堪入目,网友说:瑜伽是印度房中术

黯泉
2026-06-23 17:44:53
雷军后院失火,黄仁勋判了WPS死刑?

雷军后院失火,黄仁勋判了WPS死刑?

不正确
2026-06-26 19:14:53
韩国队哭晕!2天被连捅4刀 小组第三排名跌至第7 出线概率降至5成

韩国队哭晕!2天被连捅4刀 小组第三排名跌至第7 出线概率降至5成

我爱英超
2026-06-27 05:20:25
2026-06-27 06:27:00
Python猫 incentive-icons
Python猫
人生苦短,我用Python。博客:https://pythoncat.top
731文章数 8120关注度
往期回顾 全部

科技要闻

拿了500亿的梁文锋,只挖地基,不信销售

头条要闻

世界杯:塞内加尔5-0十人伊拉克 盖伊世界波双响

头条要闻

世界杯:塞内加尔5-0十人伊拉克 盖伊世界波双响

体育要闻

我在世界杯的每次奔跑,都为了证明你没看错

娱乐要闻

玥儿不回北京,马筱梅解释后妈身份

财经要闻

"索具龙头"领大额罚单

汽车要闻

11.99万起 捷途自由者7 PLUS/山海T1四驱版上市

态度原创

教育
家居
时尚
艺术
亲子

教育要闻

广东头部前50名高中格局与生源分布

家居要闻

绿意盎然 自然之境

殡葬专业,我可以干一辈子

艺术要闻

莫兰迪不多见的简约风景画!

亲子要闻

“人永远不知道自己的天赋技能点被随机点到哪了”

无障碍浏览 进入关怀版