如何使用 pytest-benchmark 对 Python 代码进行基准测试 什么是基准测试?
基准测试是测试代码性能的实践,
以查看它的速度(延迟)或工作量(吞吐量)。
在软件开发中,这一步通常被忽视,
但对于创建和维护快速且高性能的代码至关重要。
基准测试为开发人员提供了必要的指标,
以了解他们的代码在各种工作负载和条件下的表现。
出于与编写单元测试和集成测试以防止功能回归相同的原因,
你应该编写基准测试以防止性能回归。
性能错误也是错误!
用Python编写FizzBuzz
In order to write benchmarks, we need some source code to benchmark.
To start off we are going to write a very simple program,
FizzBuzz .
The rules for FizzBuzz are as follows:
Write a program that prints the integers from 1
to 100
(inclusive):
For multiples of three, print Fizz
For multiples of five, print Buzz
For multiples of both three and five, print FizzBuzz
For all others, print the number
There are many ways to write FizzBuzz .
So we’ll go with the my favorite:
从 1
到 100
进行迭代,使用范围为 101
。
对于每个数字,计算 3
和 5
的模(除法后的余数)。
如果余数为 0
,则说明该数字是给定因数的倍数。
如果 15
的余数为 0
,则打印 FizzBuzz
。
如果 3
的余数为 0
,则打印 Fizz
。
如果 5
的余数为 0
,则打印 Buzz
。
否则,只需打印数字。
按步骤操作
为了跟随本教程的逐步操作,你需要安装 Python 和安装 pipenv
。
🐰 本文的源代码在GitHub 上提供 。
创建一个名为 game.py
的 Python 文件,
并将其内容设置为上述的 FizzBuzz 实现。
然后运行 python game.py
。
输出应如下所示:
🐰 砰!你正在破解编码面试!
在进一步操作之前,讨论微基准测试和宏基准测试之间的区别是很重要的。
微基准测试 vs 宏基准测试
有两大类软件基准测试:微基准测试和宏基准测试。
微基准测试的操作层次和单元测试类似。
例如,为一个确定单个数字是 Fizz
、 Buzz
,还是 FizzBuzz
的函数设立的基准测试,就是一个微基准测试。
宏基准测试的操作层次和集成测试类似。
例如,为一函数设立的基准测试,该函数可以玩完整个 FizzBuzz 游戏,从 1
到 100
,这就是一个宏基准测试。
通常,尽可能在最低的抽象级别进行测试是最好的。
在基准测试的情况下,这使得它们更易于维护,
并有助于减少测量中的噪声。
然而,就像有一些端到端测试对于健全性检查整个系统根据预期组合在一起非常有用一样,
拥有宏基准测试对于确保您的软件中的关键路径保持高性能也非常有用。
在 Python 中进行基准测试
在 Python 中进行基准测试的两个热门选项是:
pytest-benchmark
和
airspeed velocity (asv)
pytest-benchmark
是一个强大的基准测试工具,与流行的pytest
测试框架 集成。
它允许开发者通过与单元测试一起运行基准测试来测量和比较代码的性能。
用户可以轻松地在本地比较基准测试结果,并将结果导出为各种格式,如 JSON。
airspeed velocity (asv)
是 Python 生态系统中另一个高级基准测试工具。
asv
的一个主要优势是它能够生成详细的交互式 HTML 报告,使得可视化性能趋势和识别回归变得简单。
此外,asv
开箱即支持相对持续基准测试 。
两者都由 Bencher支持 。 那么为什么选择 pytest-benchmark
?
pytest-benchmark
能与 pytest
无缝集成,
而 pytest
是 Python 生态系统中的事实标准单元测试工具。
我建议使用 pytest-benchmark
来基准测试代码延迟,
尤其是如果你已经在使用 pytest
。
也就是说,pytest-benchmark
非常适合测量挂钟时间。
重构 FizzBuzz
为了测试我们的 FizzBuzz 应用程序,
我们需要将逻辑从程序的主要执行中解耦。
基准工具无法对主要执行进行基准测试。
为了做到这一点,我们需要做些更改。
让我们将 FizzBuzz 逻辑重构为几个函数:
def play_game (n, should_print):
play_game
:接收一个整数 n
,用这个数字调用 fizz_buzz
,如果 should_print
为 True
则打印结果。
fizz_buzz
:接收一个整数 n
并执行实际的 Fizz
、Buzz
、FizzBuzz
或数字逻辑,返回结果为字符串。
然后将主执行更新为如下所示:
程序的主执行部分遍历从 1
到 100
(包含)的数字,并为每个数字调用 play_game
,should_print
设置为 True
。
基准测试 FizzBuzz
为了对我们的代码进行基准测试,我们需要创建一个运行基准测试的测试函数。
在 game.py
的底部添加以下代码:
def test_game (benchmark):
创建一个名为 test_game
的函数,接收一个 pytest-benchmark
的 benchmark
fixture。
创建一个 run_game
函数,迭代从 1
到 100
(包括 100
)。
对于每个数字,调用 play_game
,将 should_print
设置为 False
。
将 run_game
函数传递给 benchmark
运行器。
现在我们需要配置我们的项目以运行基准测试。
使用 pipenv
创建一个新的虚拟环境:
Creating a Pipfile for this project...
Launching subshell in virtual environment...
source /usr/bencher/.local/share/virtualenvs/test-xnizGmtA/bin/activate
在新的 pipenv
环境中安装 pytest-benchmark
:
$ pipenv install pytest-benchmark
Creating a Pipfile for this project...
Installing pytest-benchmark...
Resolving pytest-benchmark...
Added pytest-benchmark to Pipfile's [packages] ...
Pipfile.lock not found, creating...
Locking [packages] dependencies...
Resolving dependencies...
Locking [dev-packages] dependencies...
Updated Pipfile.lock (be953321071292b6175f231c7e2e835a3cd26169a0d52b7b781b344d65e8cce3)!
Installing dependencies from Pipfile.lock (e8cce3)...
现在我们准备好对我们的代码进行基准测试,运行 pytest game.py
:
======================================================= test session starts ========================================================
platform darwin -- Python 3.12.7, pytest-8.3.3, pluggy-1.5.0
benchmark: 4.0.0 (defaults: timer=time.perf_counter disable_gc=False min_rounds=5 min_time=0.000005 max_time=1.0 calibration_precision=10 warmup=False warmup_iterations=100000)
rootdir: /usr/bencher/examples/python/pytest-benchmark
------------------------------------------------- benchmark: 1 tests -------------------------------------------------
Name (time in us) Min Max Mean StdDev Median IQR Outliers OPS (Kops/s) Rounds Iterations
----------------------------------------------------------------------------------------------------------------------
test_game 10.5416 237.7499 10.8307 1.3958 10.7088 0.1248 191;10096 92.3304 57280 1
----------------------------------------------------------------------------------------------------------------------
Outliers: 1 Standard Deviation from Mean; 1.5 IQR (InterQuartile Range) from 1st Quartile and 3rd Quartile.
OPS: Operations Per Second, computed as 1 / Mean
======================================================== 1 passed in 1.68s =========================================================
🐰 让我们卷心菜和甜菜根翻起来!我们得到了首个基准测试指标!
最后,我们可以让疲惫的开发者休息一下……
开玩笑的,我们的用户想要一个新功能!
用 Python 编写 FizzBuzzFibonacci
我们的关键绩效指标 (KPI) 下降了,所以我们的产品经理 (PM) 希望我们添加一个新功能。经过大量头脑风暴和许多用户访谈后,决定传统的 FizzBuzz 已经不够了。现在的孩子们想要一个新游戏,FizzBuzzFibonacci。
The rules for FizzBuzzFibonacci are as follows:
Write a program that prints the integers from 1
to 100
(inclusive):
For multiples of three, print Fizz
For multiples of five, print Buzz
For multiples of both three and five, print FizzBuzz
For numbers that are part of the Fibonacci sequence, only print Fibonacci
For all others, print the number
The Fibonacci sequence is a sequence in which each number is the sum of the two preceding numbers.
For example, starting at 0
and 1
the next number in the Fibonacci sequence would be 1
.
Followed by: 2
, 3
, 5
, 8
and so on.
Numbers that are part of the Fibonacci sequence are known as Fibonacci numbers. So we’re going to have to write a function that detects Fibonacci numbers.
There are many ways to write the Fibonacci sequence and likewise many ways to detect a Fibonacci number.
So we’ll go with the my favorite:
def is_fibonacci_number (n):
next_value = previous + current
创建一个名为 is_fibonacci_number
的函数,该函数接收一个整数并返回一个布尔值。
从 0
开始迭代到给定的数字 n
(含)。
初始化我们的斐波那契数列,以 0
和 1
作为 previous
和 current
数字。
当 current
数字小于当前迭代 i
时进行迭代。
将 previous
和 current
数字相加得到 next_value
数字。
更新 previous
数字为 current
数字。
更新 current
数字为 next_value
数字。
一旦 current
大于或等于给定数字 n
,我们将退出循环。
检查 current
数字是否等于给定数字 n
,如果是则返回 True
。
否则,返回 False
。
现在我们需要更新我们的 fizz_buzz
函数:
def fizz_buzz_fibonacci (n):
if is_fibonacci_number(n):
将 fizz_buzz
函数重命名为 fizz_buzz_fibonacci
以使其更具描述性。
调用我们的 is_fibonacci_number
辅助函数。
如果 is_fibonacci_number
的结果为 True
,那么返回 Fibonacci
。
如果 is_fibonacci_number
的结果为 False
,则执行相同的 Fizz
、Buzz
、FizzBuzz
或数字逻辑以返回结果。
由于我们将 fizz_buzz
重命名为 fizz_buzz_fibonacci
,因此我们还需要更新我们的 play_game
函数:
def play_game (n, should_print):
result = fizz_buzz_fibonacci(n)
我们的主执行逻辑和 test_game
函数可以保持完全不变。
基准测试 FizzBuzzFibonacci
现在我们可以重新运行我们的基准测试:
======================================================= test session starts ========================================================
platform darwin -- Python 3.12.7, pytest-8.3.3, pluggy-1.5.0
benchmark: 4.0.0 (defaults: timer=time.perf_counter disable_gc=False min_rounds=5 min_time=0.000005 max_time=1.0 calibration_precision=10 warmup=False warmup_iterations=100000)
rootdir: /usr/bencher/examples/python/pytest-benchmark
--------------------------------------------------- benchmark: 1 tests --------------------------------------------------
Name (time in us) Min Max Mean StdDev Median IQR Outliers OPS (Kops/s) Rounds Iterations
-------------------------------------------------------------------------------------------------------------------------
test_game 726.9592 848.2919 735.5682 13.4925 731.4999 4.7078 146;192 1.3595 1299 1
-------------------------------------------------------------------------------------------------------------------------
Outliers: 1 Standard Deviation from Mean; 1.5 IQR (InterQuartile Range) from 1st Quartile and 3rd Quartile.
OPS: Operations Per Second, computed as 1 / Mean
======================================================== 1 passed in 1.97s =========================================================
回顾终端历史记录,
我们可以大致比较 FizzBuzz 和 FizzBuzzFibonacci 游戏的性能:10.8307 us
vs 735.5682 us
。
你的结果可能会与我的稍有不同。
然而,这两个游戏之间的差距可能在 50 倍的范围内。
这对我来说似乎不错! 特别是为我们的游戏添加一个像 斐波那契 这样华丽的功能。
孩子们一定会喜欢的!
用Python扩展FizzBuzzFibonacci
我们的游戏大获成功!孩子们确实喜欢玩FizzBuzzFibonacci。
如此之多,以至于高管决定开发续集。
但这是一个现代世界,我们需要年经常性收入(ARR),而不是一次性购买!
我们新游戏的愿景是开放式的,不再局限于1
到100
之间(即便是包括在内)。
不,我们要开拓新的领域!
The rules for Open World FizzBuzzFibonacci are as follows:
Write a program that takes in any positive integer and prints:
For multiples of three, print Fizz
For multiples of five, print Buzz
For multiples of both three and five, print FizzBuzz
For numbers that are part of the Fibonacci sequence, only print Fibonacci
For all others, print the number
为了使我们的游戏适用于任何数字,我们需要接受一个命令行参数。
更新主执行代码如下所示:
if len (args) > 1 and args[ 1 ].isdigit():
print ( "Please, enter a positive integer to play..." )
导入sys
包。
收集所有从命令行传递给我们的游戏的参数(args
)。
获取传递给我们游戏的第一个参数,并检查是否为数字。
如果是,则将第一个参数解析为整数i
。
用新解析的整数i
玩我们的游戏。
如果解析失败或没有传递参数,则默认为提示输入一个有效数字。
现在我们可以用任何数字来玩我们的游戏!
运行python game.py
后跟一个整数来玩我们的游戏:
如果我们省略或提供了一个无效的数字:
Please, enter a positive integer to play...
Please, enter a positive integer to play...
哇,经过彻底的测试!持续集成通过了。我们的老板们很高兴。
让我们发布吧!🚀
结束
🐰 … 也许这是你的职业生涯的结束?
开玩笑的!其实一切都在燃烧!🔥
起初,一切看似进行得顺利。
但在周六早上02:07,我的寻呼机响了起来:
📟 你的游戏起火了!🔥
从床上匆忙爬起来后,我试图弄清楚发生了什么。
我试图搜索日志,但这非常困难,因为一切都在不停地崩溃。
最后,我发现了问题。孩子们!他们非常喜欢我们的游戏,以至于玩了高达一百万次!
在一股灵感的闪现中,我添加了两个新的基准测试:
def test_game_100 (benchmark):
def test_game_1_000_000 (benchmark):
play_game( 1_000_000 , False )
针对数字一百(100
)的游戏进行微基准测试 test_game_100
针对数字一百万(1_000_000
)的游戏进行微基准测试 test_game_1_000_000
当我运行它时,我得到了这个结果:
======================================================= test session starts ========================================================
platform darwin -- Python 3.12.7, pytest-8.3.3, pluggy-1.5.0
benchmark: 4.0.0 (defaults: timer=time.perf_counter disable_gc=False min_rounds=5 min_time=0.000005 max_time=1.0 calibration_precision=10 warmup=False warmup_iterations=100000)
rootdir: /usr/bencher/examples/python/pytest-benchmark
等等……等一下……
-------------------------------------------------------------------------------------------------- benchmark: 3 tests --------------------------------------------------------------------------------------------------
Name (time in us) Min Max Mean StdDev Median IQR Outliers OPS Rounds Iterations
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
test_game_100 15.4166 (1.0) 112.8749 (1.0) 15.8470 (1.0) 1.1725 (1.0) 15.6672 (1.0) 0.1672 (1.0) 1276;7201 63,103.3078 (1.0) 58970 1
test_game 727.0002 (47.16) 1,074.3327 (9.52) 754.3231 (47.60) 33.2047 (28.32) 748.9999 (47.81) 33.7283 (201.76) 134;54 1,325.6918 (0.02) 1319 1
test_game_1_000_000 565,232.3328 (>1000.0) 579,829.1252 (>1000.0) 571,684.6334 (>1000.0) 6,365.1577 (>1000.0) 568,294.3747 (>1000.0) 10,454.0113 (>1000.0) 2;0 1.7492 (0.00) 5 1
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Outliers: 1 Standard Deviation from Mean; 1.5 IQR (InterQuartile Range) from 1st Quartile and 3rd Quartile.
OPS: Operations Per Second, computed as 1 / Mean
======================================================== 3 passed in 7.01s =========================================================
什么!15.8470 us
x 1,000
应该是 15,847.0 us
而不是 571,684.6334 us
🤯
尽管我的斐波那契数列代码在功能上是正确的,但其中肯定存在性能问题。
修复 Python 中的 FizzBuzzFibonacci
让我们再来看看 is_fibonacci_number
函数:
def is_fibonacci_number (n):
next_value = previous + current
现在考虑性能时,我意识到我有一个不必要的额外循环。
我们可以完全去掉 for i in range(n + 1):
循环,
只需将 current
值与给定的数字 (n
) 进行比较 🤦
def is_fibonacci_number (n):
next_value = previous + current
更新我们的 is_fibonacci_number
函数。
用 0
和 1
分别作为 previous
和 current
数字来初始化我们的斐波那契数列。
在 current
数字小于给定数字 n
时迭代。
将 previous
和 current
数字相加,得到 next_value
数字。
将 previous
数字更新为 current
数字。
将 current
数字更新为 next_value
数字。
一旦 current
大于或等于给定数字 n
,我们将退出循环。
检查 current
数字是否等于给定数字 n
并返回该结果。
现在让我们重新运行这些基准测试,看看我们的表现如何:
======================================================= test session starts ========================================================
platform darwin -- Python 3.12.7, pytest-8.3.3, pluggy-1.5.0
benchmark: 4.0.0 (defaults: timer=time.perf_counter disable_gc=False min_rounds=5 min_time=0.000005 max_time=1.0 calibration_precision=10 warmup=False warmup_iterations=100000)
rootdir: /usr/bencher/examples/python/pytest-benchmark
------------------------------------------------------------------------------------------------ benchmark: 3 tests ------------------------------------------------------------------------------------------------
Name (time in ns) Min Max Mean StdDev Median IQR Outliers OPS (Kops/s) Rounds Iterations
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
test_game_100 309.8685 (1.0) 40,197.8614 (2.38) 322.0815 (1.0) 101.7570 (1.0) 320.2877 (1.0) 5.1805 (1.0) 321;12616 3,104.8046 (1.0) 195120 16
test_game_1_000_000 724.9881 (2.34) 16,912.4920 (1.0) 753.1445 (2.34) 121.0458 (1.19) 741.7053 (2.32) 12.4797 (2.41) 656;13698 1,327.7664 (0.43) 123073 10
test_game 26,958.9946 (87.00) 129,667.1107 (7.67) 27,448.7719 (85.22) 1,555.0003 (15.28) 27,291.9424 (85.21) 165.7754 (32.00) 479;2372 36.4315 (0.01) 25918 1
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Outliers: 1 Standard Deviation from Mean; 1.5 IQR (InterQuartile Range) from 1st Quartile and 3rd Quartile.
OPS: Operations Per Second, computed as 1 / Mean
======================================================== 3 passed in 3.99s =========================================================
哇哦!我们的 test_game
基准测试又恢复到了原始的 FizzBuzz 水平附近。
我希望我能记得当时的确切得分。不过已经是三周前的事了。
我的终端历史记录无法追溯到那么久。
而 pytest-benchmark
仅在我们要求时才存储其结果。
但我认为它接近了!
test_game_100
基准测试下降了近 50 倍到 322.0815 ns
。
test_game_1_000_000
基准测试下降了超过 500,000 倍!从 571,684,633.4 ns
到 753.1445 ns
!
🐰 至少我们在性能问题进入生产环境之前抓住了它……哦,对了。算了…
在 CI 中捕获性能回归
由于我那个小小的性能错误,我们的游戏收到了大量的负面评论,这让高管们非常不满。
他们告诉我不要让这种情况再次发生,而当我询问如何做到时,他们只是告诉我不要再犯。
我该如何管理这个问题呢‽
幸运的是,我找到了这款叫做 Bencher 的超棒开源工具。
它有一个非常慷慨的免费层,因此我可以在我的个人项目中使用 Bencher Cloud 。
而在工作中需要在我们的私有云内,我已经开始使用 Bencher Self-Hosted 。
Bencher有一个内建的适配器 ,
所以很容易集成到 CI 中。在遵循快速开始指南 后,
我能够运行我的基准测试并用 Bencher 追踪它们。
$ bencher run --adapter python_pytest --file results.json "pytest --benchmark-json results.json game.py"
======================================================= test session starts ========================================================
platform darwin -- Python 3.12.7, pytest-8.3.3, pluggy-1.5.0
benchmark: 4.0.0 (defaults: timer=time.perf_counter disable_gc=False min_rounds=5 min_time=0.000005 max_time=1.0 calibration_precision=10 warmup=False warmup_iterations=100000)
rootdir: /usr/bencher/examples/python/pytest-benchmark
- test_game (Latency): https://bencher.dev/console/projects/game/perf?measures=52507e04-ffd9-4021-b141-7d4b9f1e9194&branches=3a27b3ce-225c-4076-af7c-75adbc34ef9a&testbeds=bc05ed88-74c1-430d-b96a-5394fdd18bb0&benchmarks=077449e5-5b45-4c00-bdfb-3a277413180d&start_time=1697224006000&end_time=1699816009000&upper_boundary=true
- test_game_100 (Latency): https://bencher.dev/console/projects/game/perf?measures=52507e04-ffd9-4021-b141-7d4b9f1e9194&branches=3a27b3ce-225c-4076-af7c-75adbc34ef9a&testbeds=bc05ed88-74c1-430d-b96a-5394fdd18bb0&benchmarks=96508869-4fa2-44ac-8e60-b635b83a17b7&start_time=1697224006000&end_time=1699816009000&upper_boundary=true
- test_game_1_000_000 (Latency): https://bencher.dev/console/projects/game/perf?measures=52507e04-ffd9-4021-b141-7d4b9f1e9194&branches=3a27b3ce-225c-4076-af7c-75adbc34ef9a&testbeds=bc05ed88-74c1-430d-b96a-5394fdd18bb0&benchmarks=ff014217-4570-42ea-8813-6ed0284500a4&start_time=1697224006000&end_time=1699816009000&upper_boundary=true
使用这个由一个友善的兔子给我的巧妙的时间旅行设备,
我能够回到过去,重演如果我们一直都在使用Bencher的情况下会发生什么。
你可以看到我们首次推出存在问题的FizzBuzzFibonacci实现的位置。
我马上在我的拉取请求评论中得到了CI的失败信息。
就在那天,我修复了性能问题,摆脱了那不必要的额外循环。
没有火灾。顾客都非常开心。
Bencher 是一套持续型的性能基准测试工具。
你是否曾经因为性能回归影响到了你的用户?
Bencher可以防止这种情况的发生。
Bencher让你有能力在性能回归进入生产环境 之前 就进行检测和预防。
运行 : 使用你喜爱的基准测试工具在本地或CI中执行你的基准测试。bencher
CLI简单地包装了你现有的基准测验设备并存储其结果。
追踪 : 追踪你的基准测试结果的趋势。根据源分支、测试床和度量,使用Bencher web控制台来监视、查询和绘制结果图表。
捕获 : 在CI中捕获性能回归。Bencher使用最先进的、可定制的分析技术在它们进入生产环境之前就检测到性能回归。
基于防止功能回归的原因,在CI中运行单元测试,我们也应该使用Bencher在CI中运行基准测试以防止性能回归。性能问题就是错误!
开始在CI中捕捉性能回归 — 免费试用Bencher Cloud 。