V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
推荐学习书目
Learn Python the Hard Way
Python Sites
PyPI - Python Package Index
http://diveintopython.org/toc/index.html
Pocoo
值得关注的项目
PyPy
Celery
Jinja2
Read the Docs
gevent
pyenv
virtualenv
Stackless Python
Beautiful Soup
结巴中文分词
Green Unicorn
Sentry
Shovel
Pyflakes
pytest
Python 编程
pep8 Checker
Styles
PEP 8
Google Python Style Guide
Code Style from The Hitchhiker's Guide
LeeReamond
V2EX  ›  Python

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

  •  3
     
  •   LeeReamond · 2022-03-26 22:00:23 +08:00 · 2566 次点击
    这是一个创建于 1002 天前的主题,其中的信息可能已经有所发展或是发生改变。

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

    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 微秒级的差距,我通常开发中还是喜欢使用修改后的写法。

    使用场景:

    • 对少量代码进行定性类的,非严肃执行时间比较。
    • 由于中间块是直接由字节码生成,理论上只多出了函数栈调用的时间,存在一定误差,但也可以忽略不计,所以其实感觉上做严肃比较也没啥问题。。

    目前的问题:

    • 执行上有一个不优雅的问题是固定会多执行一次,比如以 timeit(100)创建 block ,实际上 block 会被执行 101 次,因为解释器本身按照 with 逻辑执行 block 内部内容没办法把它拦截掉。
    • 由于将 block 行为转化成了 scope 行为(实际上定义了函数),代码实质功能有所变化。为了准备相同的上下文环境,会在触发上下文管理器时所有的 scope 里将原 scope 内容重新执行一遍,给人一种很肮脏的感觉。考虑过另一种实现方式是将 locals 全部转化为 globals 再实例化,不过想了想感觉似乎问题更多。有待论坛老哥提供更好的解决方案。
    • 目前的实现方案中存在一个严重问题是依赖于 python 动态分析生成字节码的能力。但转化为字节码后显然就丢失了原代码信息,比如你很难定位字节码的 xx 位置对应代码里的第 yy 行,这导致在同一个 scope 内多次执行计数时他们的对应关系会消失,实际上只能执行最初遇到的 timeit 块。所以上文中 demo 里的内容实际上无法实现,我实际操作时每次只能执行一种情况。感觉挺硬伤的,不知有无老哥提建议改善。当然分置于不同 scope 的话是互相不会影响的,不过如果要定义 scope 的话也需要写代码,又绕回去了。
    • 由于目前的实现方式并未对栈进行规范,(因使用上通常是以 from import * 的形式来解放双手,但同时希望模块尽可能的轻量,占用可以忽略不计的内存),所以只是单纯对字节码进行裁剪,不支持嵌套使用。虽然感觉计时这种东西嵌套并无意义。。也许有用,等待老哥补充。
    • 字节码不是大佬,照着标准书实现了一圈,可能在复杂环境下会产生各种奇怪 bug ,欢迎各种测试。
    • 开发时使用的是 win 平台,多平台字节码解释器实现有没有坑我不知道,版本上有没有坑我也不知道。。由于 py 部分 api 变动,比如 CodeType 接收参数从 py3.6 的 str 变成了 3.7 的 bytes 。理论上应该是有做支持,具体能不能跑我不知道

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

    第 1 条附言  ·  2022-03-27 21:36:38 +08:00
    贴中所述代码的分支为了发布删除了,转移到根目录 /pipeit/timer_archive.py 里,需要测试引入 timeit 类即可。
    11 条回复    2022-03-28 21:14:13 +08:00
    Vinceeeent
        1
    Vinceeeent  
       2022-03-26 22:06:44 +08:00 via Android
    点赞
    zhailw
        2
    zhailw  
       2022-03-26 23:38:23 +08:00 via Android
    有一个小问题啊,不知道楼主有没有想过用 for _ in timeit(1e6): 这种形式啊?不是更简单么?也比 with 多不了几个字符,但是感觉更优雅一点?
    LeeReamond
        3
    LeeReamond  
    OP
       2022-03-27 01:59:10 +08:00
    @zhailw 也不是不行,但是咋实现呢
    zhailw
        4
    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
        5
    zhailw  
       2022-03-27 13:36:49 +08:00
    貌似回复贴不支持代码块,缩进有点问题,但是应该也能看懂吧
    @zhailw
    LeeReamond
        6
    LeeReamond  
    OP
       2022-03-27 16:41:55 +08:00
    @zhailw 虽然 for 的语义不太清晰,但实现起来简单很多
    Juszoe
        7
    Juszoe  
       2022-03-27 17:21:44 +08:00
    去年那贴我有印象,没想到真做出来了
    txoooy
        8
    txoooy  
       2022-03-27 17:26:53 +08:00 via iPhone
    为什么我感觉装饰器更好一些
    @timeit(count=1000)
    learningman
        9
    learningman  
       2022-03-27 21:30:35 +08:00
    @txoooy #8 装饰器就必须要开个函数,楼主这样搞能测语句
    wcsjtu
        10
    wcsjtu  
       2022-03-28 16:39:56 +08:00
    正好我最近也在做类似的事。。。。发表一下个人看法

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

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

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

    至于误差问题,cpython 的 for 循环 overhead 确实高于其他普遍语言数量级以上,除了迭代器实现外甚至还受函数调用位置(由 locals 和 globals 修改规则)影响,不过实际上可以搞一个空 block 做对照组做减法可以很容易算出纯代码段开销。
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   3010 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 24ms · UTC 13:57 · PVG 21:57 · LAX 05:57 · JFK 08:57
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.