分享一个自用的 timeit 给代码计时的奇技淫巧

2022-03-26 22:00:23 +08:00
 LeeReamond

如题,联动一下自己去年发的帖吧。

Python 因为本身比较慢,我觉得应该大多数程序员,跟我一样,写东西时都有最优化实现的需求。典型场景比如生产环境遇到连接两个大的字节流,用那种实现比较快?随便一想就有很多种写法,比如

ret = b'1'+b'2'
# 或
ret = bytearray();ret += b'1';ret += b'2'
# 或
ret = ''.join([b'1', b'2'])

再比如生成一个静态元组时是使用列表更快还是元组更快。等等等等不一而足,总之生产环境写码时是避免不了各种测试的,大概也是 py 独一份的问题了。

大家都知道 python 自带有 timeit 模块用来计时足以应付上述场景。但是我一直觉得 timeit 很不好用。首先需要引入包,然后把需要测试的部分单独封装起来,最后还要重载入才能得到结果,尤其在面对小型测试时很麻烦,所以我往常通常在类似测试时更喜欢自己封装一个上下文管理器做替代。由于 python 的上下文管理器生成新的 block 但不生成独立 scope ,不需要进行剥离封装,也不需要担心对原功能产生任何影响,总之不用动用任何脑细胞,非常哈皮。

也就是大概变成这样:

>>> foo = [b'1'] * 100
>>> with timeit():
>>>     for _ in range(1000000):
>>>         bar = ''.join(foo)
[line 1] time cost: 0.013489246368408203

唯一一点不太爽的是,测试时需要自己写 for 循环,多打一行不短的代码,在深度思考场或者大量测试的场合下会让人非常烦躁。

所以去年发了个帖子,问问有没有大佬知道奇技淫巧,可以以 hook 的方式提前下个钩子把管理的内容提取出来,这样就不用写烦人的 for _ in range 了,可惜当时讨论了一下大家都没有思路。昨天周末摸鱼时间突然开窍,按照 hack 进字节码解释器在 python 里实现 goto 的思路摸索了一下,写了一会搞了个雏形。应该还是有不少坑,欢迎大家测试:

pip install git+https://github.com/GoodManWEN/pipeit.git@test_install

大体思路是通过 py 的高动态特性和反射功能,可以抽取当前 scope 的状态流然后动态生成一份字节码,再然后再反过来实例化,这样就达到了提取出上下文管理器中间部分的目的。实现上到也说不上是很优雅,但是因为依赖的都是久经测试的 bil ,所以同样也说不是上是很肮脏。

目前的 demo 可以做到以下的用法:

>>> # 前两年在 v2 很多人讨论的用魔术方法重载运算符,达到解放传统 py 函数式痛苦的反写体验的功能
>>> # 但由于涉及到对象和方法调用,理所当然会比原生慢一些,假设我们现在想知道具体慢多少
>>> from pipeit import *
>>> 
>>> foo = list(range(50))
>>> with timeit(1e6): # 循环百万次,自动转换结果到单轮时间
>>>     bar = foo | Filter(lambda x: x%3==0) | Map(lambda x: x*10) | Reduce(lambda x, y:x+y) | int

[line 6] time cost per loop: 8.272400856018066μs
>>> bar

4080
>>> with timeit(1e6):
>>>     bar = reduce(lambda x,y:x+y, map(lambda x:x*10, filter(lambda x:x%3 == 0, foo)))

[line 10] time cost per loop: 5.8983259201049805μs
>>> bar 

4080

也就是直接 with timeit(循环次数):再加 tab 键就可以对单独行进行测速了。 在我的机器上跑的结果来看原生是比封装版快了 30%左右,不过考虑到本身也是 1 微秒级的差距,我通常开发中还是喜欢使用修改后的写法。

使用场景:

目前的问题:

总之还是有很多问题,权当抛砖引玉吧。希望以后 v 友遇到测速都能少写几行代码

2377 次点击
所在节点    Python
11 条回复
Vinceeeent
2022-03-26 22:06:44 +08:00
点赞
zhailw
2022-03-26 23:38:23 +08:00
有一个小问题啊,不知道楼主有没有想过用 for _ in timeit(1e6): 这种形式啊?不是更简单么?也比 with 多不了几个字符,但是感觉更优雅一点?
LeeReamond
2022-03-27 01:59:10 +08:00
@zhailw 也不是不行,但是咋实现呢
zhailw
2022-03-27 13:33:15 +08:00
```python
import time
import timeit as original_timeit


def timeit(times):
start_time = time.time()
yield from range(int(times))
used_time = time.time() - start_time
print('average used time:' + str(used_time / times))


rounds = int(1e6)

for _ in timeit(rounds):
"-".join(map(str, range(100)))

print('original_timeit: ', original_timeit.timeit('"-".join(map(str, range(100)))', number=rounds) / rounds)

print('done')
```
average used time:8.731926918029785e-06

original_timeit: 8.62705747198197e-06

done

@LeeReamond
zhailw
2022-03-27 13:36:49 +08:00
貌似回复贴不支持代码块,缩进有点问题,但是应该也能看懂吧
@zhailw
LeeReamond
2022-03-27 16:41:55 +08:00
@zhailw 虽然 for 的语义不太清晰,但实现起来简单很多
Juszoe
2022-03-27 17:21:44 +08:00
去年那贴我有印象,没想到真做出来了
txoooy
2022-03-27 17:26:53 +08:00
为什么我感觉装饰器更好一些
@timeit(count=1000)
learningman
2022-03-27 21:30:35 +08:00
@txoooy #8 装饰器就必须要开个函数,楼主这样搞能测语句
wcsjtu
2022-03-28 16:39:56 +08:00
正好我最近也在做类似的事。。。。发表一下个人看法

- 如果只是想自动循环, 修改 with stmt 的 ast, 加上循环语句,然后重新 compile & eval 就行了。

- 不过 Python 的循环会严重干扰实际的性能。 特别是对于 LZ 说的 [生成一个静态元组时是使用列表更快还是元组更快] case, 迭代 range 本身耗时远高于待测试的代码。

- 我觉得还是用 C++写个 repeat 函数靠谱....
LeeReamond
2022-03-28 21:14:13 +08:00
@wcsjtu 用 ast 模块是一种方案,但是离不开反射获取源码,由于实际生产环境的代码可能有复杂的嵌套关系反射本身很容易出故障。

至于误差问题,cpython 的 for 循环 overhead 确实高于其他普遍语言数量级以上,除了迭代器实现外甚至还受函数调用位置(由 locals 和 globals 修改规则)影响,不过实际上可以搞一个空 block 做对照组做减法可以很容易算出纯代码段开销。

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/843100

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX