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

让代码飙升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.

相关推荐
热点推荐
C罗离开后,葡萄牙队7号球衣光速易主?欧足联官网显示葡萄牙对阵丹麦比赛中,莱奥身披7号球衣

C罗离开后,葡萄牙队7号球衣光速易主?欧足联官网显示葡萄牙对阵丹麦比赛中,莱奥身披7号球衣

极目新闻
2026-10-01 18:40:10
真不愧是“化痰冠军”,才喝2回,痰根拔掉,润喉又顺畅

真不愧是“化痰冠军”,才喝2回,痰根拔掉,润喉又顺畅

王二哥老搞笑
2026-10-01 20:43:05
林诗栋4-0王楚钦夺冠,现场却齐喊“王皓下课”,王皓罕见连鼓两次掌回怼

林诗栋4-0王楚钦夺冠,现场却齐喊“王皓下课”,王皓罕见连鼓两次掌回怼

火锅局
2026-10-01 13:09:57
桂林市文促会深夜发布致歉信:工作严重疏漏,向邱孟煌先生诚恳致歉,依规开展内部问责,将以此为戒

桂林市文促会深夜发布致歉信:工作严重疏漏,向邱孟煌先生诚恳致歉,依规开展内部问责,将以此为戒

政知新媒体
2026-10-01 23:19:50
比黄金还稀缺!A股:真正高股息现金牛,全市场仅9家,低至4元

比黄金还稀缺!A股:真正高股息现金牛,全市场仅9家,低至4元

长风价值掘金
2026-10-01 13:44:39
全红婵不对劲,不是她以后不比赛了,而是她今年19岁了,居然开始了自己的精致路线

全红婵不对劲,不是她以后不比赛了,而是她今年19岁了,居然开始了自己的精致路线

LULU生活家
2026-09-30 20:32:43
政府、国家、祖国三者不可混淆

政府、国家、祖国三者不可混淆

觉民行道
2026-10-01 11:31:29
广州白鹅潭万象城通知:出于公众安全考量,取消10月1日至10月3日灯光秀活动,致以最诚挚歉意;网友:广州人今天都在这里吧

广州白鹅潭万象城通知:出于公众安全考量,取消10月1日至10月3日灯光秀活动,致以最诚挚歉意;网友:广州人今天都在这里吧

南方都市报
2026-10-01 21:54:22
亚运会争议判罚!中国柔道运动员误咬伤日本选手,被裁判直接判负

亚运会争议判罚!中国柔道运动员误咬伤日本选手,被裁判直接判负

全景体育V
2026-10-01 18:42:29
迪拜航空客机上唯一医护乘客披露救治细节:印度籍机长“手部几乎被切断”

迪拜航空客机上唯一医护乘客披露救治细节:印度籍机长“手部几乎被切断”

澎湃新闻
2026-10-01 16:30:27
鸿蒙智行被小米小鹏反超,网友留言不忍直视

鸿蒙智行被小米小鹏反超,网友留言不忍直视

ZAKER新闻
2026-10-01 19:55:26
重磅!北大法学院赵宏教授大放厥词,公开场合胡言乱语

重磅!北大法学院赵宏教授大放厥词,公开场合胡言乱语

平老师666
2026-10-01 13:28:41
反转又反转!美国女死囚被注射两剂致死药物后仍活着还打鼾,紧急送医治疗

反转又反转!美国女死囚被注射两剂致死药物后仍活着还打鼾,紧急送医治疗

澎湃新闻
2026-10-01 16:10:04
中国柔道名将亚运会被罚下判负!咬对手手臂20秒不松口 牙印曝光

中国柔道名将亚运会被罚下判负!咬对手手臂20秒不松口 牙印曝光

念洲
2026-10-01 20:55:42
目不识丁,出尽洋相?于和伟在金鹰奖上的发言,给内娱明星上一课

目不识丁,出尽洋相?于和伟在金鹰奖上的发言,给内娱明星上一课

喜欢历史的阿繁
2026-10-01 12:34:18
广东43岁男子用土豆当主食,1个月花300元,半年瘦了25斤!脂肪肝好转,血压、血糖稳了;网友:这应该是最便宜的减肥法了

广东43岁男子用土豆当主食,1个月花300元,半年瘦了25斤!脂肪肝好转,血压、血糖稳了;网友:这应该是最便宜的减肥法了

蓬勃新闻
2026-10-01 17:42:38
“极不寻常的外交斥责”!美媒爆:鲁比奥要求出席联大的伊朗代表团立即离境

“极不寻常的外交斥责”!美媒爆:鲁比奥要求出席联大的伊朗代表团立即离境

环球网资讯
2026-10-01 11:18:07
最新反转!让空姐跪下道歉的男子彻底“社死”,东航的态度很明确

最新反转!让空姐跪下道歉的男子彻底“社死”,东航的态度很明确

军情观察家
2026-10-01 16:06:40
关晓彤西班牙度假,头秃装嫩土气狂晒脚丫,与助理深夜偷溜去酒吧

关晓彤西班牙度假,头秃装嫩土气狂晒脚丫,与助理深夜偷溜去酒吧

白面书誏
2026-10-01 14:51:49
球报:热苏斯的言论激怒了C罗,他突然离开的决定让队友震惊

球报:热苏斯的言论激怒了C罗,他突然离开的决定让队友震惊

懂球帝
2026-10-01 03:15:21
2026-10-02 01:28:49
Python猫 incentive-icons
Python猫
人生苦短,我用Python。博客:https://pythoncat.top
758文章数 8122关注度
往期回顾 全部

科技要闻

5999元起!华为Mate 90系列发布

头条要闻

美国一女囚犯被执行死刑 注射两剂药物后其"鼾声大作"

头条要闻

美国一女囚犯被执行死刑 注射两剂药物后其"鼾声大作"

体育要闻

“今天的表现,我们可以昂首离开球场”

娱乐要闻

奚梦瑶晒四太豪礼!22只龙凤镯近300万

财经要闻

智谱发上亿Token 能挽回开发者信任吗?

汽车要闻

2027款极氪001将于明年一季度上市 现款猎装同步推新配色

态度原创

本地
亲子
数码
时尚
公开课

本地新闻

中秋逛白塔寺,体验国医妙荟雅集

亲子要闻

铅笔有橡皮,但人生无草稿#健康跃动计划

数码要闻

亚马逊发布全新Kindle:取消传统边框 正面屏幕实现全面平整化

在米兰,用时装唤醒内心的自我

公开课

李玫瑾:为什么性格比能力更重要?

无障碍浏览 进入关怀版