python自动化测试安装包(一个简单原生)
python自动化测试安装包(一个简单原生)与目标:在以下部分中,我设置了一个基本基准来测试基于Rust/PyO3和Cython的本机模块的性能。为了进行比较,我使用三种方法开发了相同的算法:在本文中,我不会开发用于构建原生扩展的可用工具。但是,您可以阅读我之前的文章中对几种优化技术的简短回顾。一旦选择了框架,真正的问题是,我们要优化什么?使用本机模块的优缺点是什么?在本文中,我设置了一个简单的基线代码来衡量我们可以实现的性能改进,并就本地调用产生的开销成本多于收益的上下文提供一些提示。
W将 Python 原生模块集成到您的项目中非常耗时;这值得么?
首先,您需要选择实现框架。例如,使用 AOT 方法(机器代码的提前生成),您可以使用像Cython这样的标准工具编写扩展程序,或者选择基于C/C 的绑定生成器(例如boost::python、pybind11、CFFI ) 或最后,使用PyO3框架测试像Rust这样的新手。
接下来,您需要决定要优化什么,找到关键代码部分并决定需要将多少代码移入本机模块。
跨越 Python/Native 模块层 — 作者的图片
在本文中,我不会开发用于构建原生扩展的可用工具。但是,您可以阅读我之前的文章中对几种优化技术的简短回顾。
一旦选择了框架,真正的问题是,我们要优化什么?使用本机模块的优缺点是什么?
在本文中,我设置了一个简单的基线代码来衡量我们可以实现的性能改进,并就本地调用产生的开销成本多于收益的上下文提供一些提示。
测量什么?在以下部分中,我设置了一个基本基准来测试基于Rust/PyO3和Cython的本机模块的性能。为了进行比较,我使用三种方法开发了相同的算法:
- 纯Python实现:此基准测试的基线。
- 带有类型提示的Cython实现。
- 使用PyO3框架的Rust实现。
与目标:
- 衡量原生模块与纯Python方法的性能改进。
- 测量从Python 解释器调用本机Cython或Rust函数的开销。
- 了解原生扩展的性能改进和开销的影响之间的权衡。
本机扩展权衡 — 图片作者
还有一些限制:
- 该测试使用仅具有基本数据类型的函数。在具有结构化或自定义数据类型的本机模块上,在Python 层和本机代码层之间复制和转换这些对象的操作是有代价的。
- 该函数的本机代码is_prime仅使用循环块中的基本测试运算符和数学运算。这不是一项繁重的计算任务,可以利用 SIMD/矢量化指令、并行性、线程等。在复杂的计算工作中,性能升级可能要高得多。
我们从一个基于我之前文章的简单函数开始。
这段代码提供了一个基于 Rust的is_prime函数实现。在此基线上,我们将添加:
- 一个纯Python实现is_prime
- 的Cython实现is_prime。
- 对数千次运行进行基准测试并对其进行跟踪以进行进一步分析的功能。
我们的基准测试将使用一个简单的素数测试函数。该函数摘自我之前的文章“混合 Rust Python 项目”。该函数is_prime通过将其除以前面的数字来检查其输入的素数。对于一个数字“ ”,我们测试和num之间的数字的除法余数。2√num
→ Rust «is_prime» = #[pyfunction] (1)
fn is_prime(num: u32) -> bool {
match num {
0 | 1 => false
_ => {
let limit = (num as f32).sqrt() as u32 ; (2) (2..=limit).any(|i| num % i == 0) == false (3)
}
}
}
- (1) : Rust 宏#[pyfunction]为 Python 绑定生成代码。
- (2) : 计算我们的试除数系列的上界。
- (3):生成试验并测试除法的其余部分。
尽管它很简单,但这个函数是我们测试的一个很好的候选,因为它的计算复杂度在我们的测试用例之间可能会有很大的不同:
- 测试素数。例如,该数字在返回 true 之前12899涉及112 次测试。
- 测试一个非质数。例如,前面的数字 12898只需要1 次测试就可以返回 false。
基于“A Mixed Rust Python Project”一文定义的代码和结构。我只是添加一个带有纯Python和Cython is_prime实现的 bench 目录。
$ tree mybench mybench ├── bench │ ├── bench.py │ └── cytest.pyx ├── Cargo.toml ├── pyproject.toml ├── src │ └── lib.rs └── test └ ── test.py 3个目录,6个文件
在我们的pyproject.toml文件中,我们向包中添加一个依赖项click来管理我们的命令行参数。
[project.optional-dependencies]
test = [
“假设”,
“同情”,
“点击”
]
为了实现Rust--release代码的最佳性能,我们使用标志编译项目以启用优化。
$ cd mybench
$ maturin 开发 --release --extras 测试
纯 Python 实现
我们的纯 python 实现很简单,只有一个“for循环”来测试我们的试验。<<pure_python_is_prime>>代码集成到bench.py文件中。
→ Python «pure_python_is_prime» = def is_prime_py(num: int) -> bool:
if num == 0 or num == 1:
return False
else :
limit = math.sqrt(num)
for i in range(2 int(limit ) 1):
如果num % i == 0:
返回 False
返回 True
我们设置了此代码的“仪表化”版本,以计算与特定num输入参数相关的操作的大致数量。
→ Python «instrumented_is_prime» = def is_prime_py_instrumented(num):
if num == 0 or num == 1:
return ( False 1) (1)
else :
ntests = 0
limit = math.sqrt(num)
for i in range( 2 int(limit) 1):
ntests = 1
if num % i == 0:
return ( False ntests) (2)
return ( True ntests) (3)
- (1) : 如果我们的数字是 0 或 1,我们返回元组(False 1)。False是素数检验结果,1是涉及的检验次数。
- (2):如果我们的试除法0有余数,则num不是素数。“ ntests”是测试操作计数器。
- (3):final case,所有的试验测试都是否定的,num是一个素数。
Cython代码类似于Python ;我们添加类型信息来加速这段代码。Cython使用类型注释来生成优化的本机代码。
→ Cython « mybench /bench/cytest.pyx» = import math def is_prime_cy(int num): (1)
cdef int i (2)
cdef int limit if num == 0 or num == 1:
return False
else :
limit = int(math.sqrt(num))
for i in range(2 limit 1):
if num % i == 0:
return False
return True
- (1):我们将函数参数声明为int.
- (2):我们键入两个临时变量i和limitas int。
此函数将使用两个输入参数运行基准测试:
- nb_runs:我们函数的运行次数。最终时间是这些运行之间的平均值。
- num:作为素数测试的数字。
→ Python «bench_number» = def bench_number(nb_runs: int num: int):
run_infos = is_prime_py_instrumented(num) (1)
tm_rust = timeit.timeit(
stmt= lambda : mybench.is_prime(num) number=nb_runs
) (2 )
tm_cython = timeit.timeit(
stmt= lambda : cytest.is_prime_cy(num) number=nb_runs
) (3)
tm_python = timeit.timeit(stmt= lambda : is_prime_py(num) number=nb_runs) (4)
返回run_infos tm_rust、tm_cython、tm_python
- (1):我们运行is_prime_py_instrumented函数来估计代码的复杂性(在操作方面)。
- (2):我们对Rust is_prime函数进行基准测试,运行它nb_runs多次。该timeit函数平均每次运行的运行时间。
- (3) — (4):Cython和纯 Python版本的逻辑相同。
我们最终编写了我们的主要基准程序,集成了前面的代码片段,导入了我们的Rust和Cython模块,并集成了一个简单的命令行界面。
→ Python « mybench /bench/bench.py» = import click import timeit import math import pathlib import typing import json import sys import pyximport pyximport.install() (1) import mybench (2) import cytest (3) (4) < <pure_python_is_prime>> (5) <<instrumented_is_prime>> (6) <<bench_number>> (7) @click.command()@click.option( "--nb_runs" type=int 默认=1000, 帮助= “运行次数。” ) @click.option( "--num" type=int default=12899 help= " 如果定义了 output_serie ,则要测试的数字或上限\ 。" ) @click.option( "--output_serie" type=str default= None help= "系列结果的输出文件。" ) def bench(nb_runs: int num: int output_serie: str): if output_serie is None : (8) ( run_infos tm_rust tm_cython tm_python ) = bench_number(nb_runs num) print( "在{} 上运行{}素性测试,\ 结果 ' {} ' 和{}测试" .format( nb_runs num run_infos[0] run_infos[1] ) ) print( "Bench Rust/PyO3 {:.3f} {} " .format(tm_rust mybench.is_prime(num))) print( "Bench Cython {:.3f} {} " .format(tm_cython cytest.is_prime_cy(num))) print( "Bench Python {:.3f} {} " .format(tm_python is_prime_py(num)))其他: (9) prime_list = [tst_num for tst_num in range(2 num 1) if is_prime_py(tst_num)] with open(output_serie "w " ) as fp: fp.write( "num nb_runs result nb_tests \ tm_rust tm_cython tm_python \n " )用于tst_num 在prime_list[0::100]: (10) ( run_infos tm_rust tm_cython tm_python ) = bench_number(nb_runs tst_num) fp.write( " {} {} {} {} {} { } {}\n " .format( tst_num nb_runs run_infos[0] run_infos[1] tm_rust tm_cython tm_python ) )如果__name__ == "__main__" : bench()
- (1) :pyximport是Cython提供的用于直接编译和导入的工具。我们不必编写使用转译器setup.py进行构建的自定义cython。
- (2) : 我们导入我们在mode 下Rust编译的原生模块。release
- (3) : 我们导入我们的Cython原生模块。
- (4) : 我们的纯 Python 函数是在<<pure_python_is_prime>>代码块中定义的。
- (5)<<instrumented_is_prime>> :在代码块中定义的检测版本。
- (6):我们的基准测试功能,运行我们的三个实现并在<<bench_number>>代码块中定义。
- (7):我们定义了一个简单的命令行界面来运行实验。
我们可以使用两种模式运行我们的程序:
- 在单一实验模式(8)中选择单一数字进行素性测试。
- 在串联实验模式(9)中。我们提供系列的上限。测试此限制下的每个素数。此模式允许测试运行具有多种复杂性。我们对每 100 个元素(10)生成的素数序列进行实验。
首先,我们可以测试我们的基准函数命令行界面。
$ cd mybench $ python bench/bench.py --help用法:bench.py [OPTIONS]选项:-- nb_runs INTEGER 运行次数。 --num INTEGER 如果定义了 output_serie,则要测试的数字或上限 。 --output_serie TEXT 系列结果的输出文件。 --help 显示此消息并退出。
在初始测试中,我们12899通过1 000 000运行检查数字的素数。
$ cd mybench $ python bench/bench.py --nb_runs 1000000 --num 12899 > 在 12899 上运行 1000000 次素性测试,结果为 'True' 有 112 次测试> Bench Rust/PyO3 0.529 True > Bench Cython 0.526 True > Bench Python 4.349 True
这个初始测试表明,测试数字的素数12899需要在我们的函数112中进行内部测试。is_prime结果是True(12899是一个素数)。纯Python版本的运行时间是4.3s. 对于Cython和Rust,我们的性能结果与0.52s.
在这种情况下,我们在纯 Python和Cython/Rust原生扩展x12之间获得了性能提升比。
我们可以检查Cython/Rust开销的成本。
$ cd mybench $ python bench/bench.py --nb_runs 1000000 --num 1
通过测试特殊情况“ 1”,我们在函数中只执行了一个测试is_prime。在这种情况下,我们的计算成本主要是调用is_prime函数的成本:我们观察Cython和Rust与纯Python的调用开销。
> 在 1 上运行1000000次素性测试,1次测试结果为“假”
> Bench Rust/PyO3 0.166 False
> Bench Cython 0.113 False
> Bench Python 0.134 False
我们可以看到,使用Cython,对于这种特定情况,我们没有可测量的开销。但是,对于Py03,会出现少量开销。
我们现在将尝试一个更一般的情况。首先,我们将测试所有低于指定阈值的素数并记录它们的性能。然后,我们可以绘制几个运行设置的性能改进。
$ cd mybench $ python bench/bench.py --nb_runs 1000000 --num 100000 --output_serie runs.csv
runs.csv此运行生成具有以下格式的输出文件“ ”。
NUM,nb_runs,结果,nb_tests,tm_rust,tm_cython,tm_python
2 1000000,诚然,0 0.1748378580014105 0.17820978199961246 0.3463963739995961
547 1000000,诚然,22 0.23610526800075604 0.24908054699881177 1.2034144149984058
1229 1000000,真实,34 0.2719646179994015 0.2854190660000313 ,1.6644533799990313
1993 1000000,诚然,43 0.29658469800051535 0.3141682330006006 1.961818502000824
2749 1000000,诚然,51 0.32396009900003264 0.33482105799885176 2.2520081020011276
3581 1000000,诚然,58 0.3524247669993201 0.35497432999909506 2.4976866420001897
4421 1000000,真实,65 0.37128741100059415 0.37557477799964545 2.7788527150005393
5281 1000000 真 71 0.3890202149996185 0.394951800999479 3.0130012549998355
6143 1000000 真 77 0.4090290809999715 0.41361685099946044 3.184734868000305
首先,我们的运行轨迹图。x 轴上是测试的数量(运行的复杂性)。y 轴是三个实现的平均运行时间。
将pandas 导入为 pd df = pd.read_csv( " mybench /runs.csv" )
sdf = df[[ "nb_tests" "tm_rust" "tm_python" "tm_cython" ]]
sdf.plot(x= "nb_tests" 种类= “线”)
运行跟踪图 — 图片作者
毫不奇怪,我们可以看到平均运行时间随着三种实现的复杂性的增加呈线性关系。与原生实现相比,纯Python实现 的运行时间以更陡峭的斜率增加。
其次,纯 Python运行与原生(Rust Cython 平均)运行之间的比率图。
将熊猫 导入为 pd df = pd.read_csv( " mybench /runs.csv" )
df[ "tm_native" ] = (df[ "tm_rust" ] df[ "tm_cython" ]) / 2.0
df[ "ratio" ] = df [ "tm_python" ] / df[ "tm_native" ]
sdf = df[[ "nb_tests" "ratio" ]]
sdf.plot(x= "nb_tests" y= "ratio" kind= "scatter" )
性能比图 — 图片作者
在这种情况下,我们观察到改进率随着更多计算量大的代码移入本机模块而增加。
包起来一个简单的总结:
- 在我们的测试用例中,Cython和Rust/PyO3的调用开销与Cython相比略有优势。
- 随着更多的计算复杂性被转移到本机扩展中,性能改进也会增加。例如,如果您在循环中使用本机函数,您可以通过将循环代码移动到扩展中来获得性能。
- 对于没有循环的简单函数,使用本机扩展可能是矫枉过正。
- 该测试使用简单的数据类型。如果您使用更复杂的数据类型(例如,列表或数组),则在Python和扩展之间移动这些类型是有代价的。因此,本地实现这些类型并在Python解释器中公开它们是有帮助的。这是NumPy [1] 使用的方法:在Python解释器中公开的原始 C 内存缓冲区。
[1] Van Der Walt S. Colbert SC 和 Varoquaux G. (2011)。NumPy 数组:一种用于高效数值计算的结构。科学与工程计算,13 (2) 22-30。