什么是_md5.md5文件,为什么hashlib.md5要慢得多?

11

在使用标准库的 hashlib.md5 实现时,发现速度过慢,因此找到了这个未记录的 _md5

在 MacBook 上:

>>> timeit hashlib.md5(b"hello world")
597 ns ± 17.2 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)
>>> timeit _md5.md5(b"hello world")
224 ns ± 3.18 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)
>>> _md5
<module '_md5' from '/usr/local/Cellar/python/3.7.6_1/Frameworks/Python.framework/Versions/3.7/lib/python3.7/lib-dynload/_md5.cpython-37m-darwin.so'>

在 Windows 系统上:

>>> timeit hashlib.md5(b"stonk overflow")
328 ns ± 21.8 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)
>>> timeit _md5.md5(b"stonk overflow")
110 ns ± 12.5 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)
>>> _md5
<module '_md5' (built-in)>

在 Linux 操作系统中:
>>> timeit hashlib.md5(b"https://adventofcode.com/2016/day/5")
259 ns ± 1.33 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)
>>> timeit _md5.md5(b"https://adventofcode.com/2016/day/5")
102 ns ± 0.0576 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)
>>> _md5
<module '_md5' from '/usr/local/lib/python3.8/lib-dynload/_md5.cpython-38-x86_64-linux-gnu.so'>

对于短消息的哈希处理速度更快。对于长消息,性能类似。

为什么这个更快的实现方式被隐藏在下划线扩展模块中?为什么 hashlib 默认没有使用它?_md5 模块是什么,为什么没有公共 API?


在更长的字符串(例如5兆字节长)上,您会得到什么样的相对性能? - Jeremy Friesner
有趣的是,使用5 MB的urandom时,性能似乎相似(在误差范围内)。但是对于挖掘AdventCoin,我需要哈希短消息。 - wim
2
我的猜测是,小字符串测试的结果受到设置调用的开销的支配,而不是算法本身。 - Jeremy Friesner
3个回答

9

直到 Python 2.5,哈希和摘要是在它们自己的模块中实现的(例如[Python 2.Docs]: md5 - MD5消息摘要算法)。
v2.5开始,添加了[Python 2.6.Docs]: hashlib - 安全哈希和消息摘要。它的目的是:

  1. 提供一种统一的访问哈希/摘要的方法(通过它们的名称)

  2. 切换(默认情况下)到外部加密提供程序(似乎将委托给某个专门从事该领域的实体是合理的步骤,因为维护所有这些算法可能会过度)。当时,OpenSSL是最好的选择:足够成熟,已知且兼容(有一堆类似的Java提供者,但那些都没什么用)

作为 #2. 的副作用,Python 实现被隐藏在公共的 API 中(将它们重命名为:_md5_sha1_sha256_sha512,并添加了后者:_blake2_sha3),因为冗余通常会引起混淆。
但是,另一个副作用是 _hashlib.so 依赖于 OpenSSLlibcrypto*.so(这是特定于 Nix(至少是 Linux)的,在 Win 上,静态的 libeay32.lib 被链接到 _hashlib.pyd 中,还有 _ssl.pyd(我认为这很糟糕),直到 v3.7+,其中 OpenSSL.dllPython 安装的一部分)。
可能在超过 90% 的机器上,情况都很顺利,因为 OpenSSL 默认已安装,但对于那些没有安装的机器,许多东西可能会出问题,因为例如 hashlib 被许多模块导入(其中一个例子是 random,它本身被许多其他模块导入),所以与加密完全无关的琐碎代码(至少在第一眼看上去是这样)将停止工作。这就是为什么旧的实现被保留下来(但再次强调,它们只是备用方案,因为 OpenSSL 版本正在/应该得到更好的维护)。
[cfati@cfati-ubtu16x64-0:~/Work/Dev/StackOverflow/q059955854]> ~/sopr.sh
### Set shorter prompt to better fit when pasted in StackOverflow (or other) pages ###

[064bit-prompt]> python3 -c "import sys, hashlib as hl, _md5, ssl;print(\"{0:}\n{1:}\n{2:}\n{3:}\".format(sys.version, _md5, hl._hashlib, ssl.OPENSSL_VERSION))"
3.5.2 (default, Oct  8 2019, 13:06:37)
[GCC 5.4.0 20160609]
<module '_md5' (built-in)>
<module '_hashlib' from '/usr/lib/python3.5/lib-dynload/_hashlib.cpython-35m-x86_64-linux-gnu.so'>
OpenSSL 1.0.2g  1 Mar 2016
[064bit-prompt]>
[064bit-prompt]> ldd /usr/lib/python3.5/lib-dynload/_hashlib.cpython-35m-x86_64-linux-gnu.so
        linux-vdso.so.1 =>  (0x00007fffa7d0b000)
        libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f50d9e4d000)
        libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f50d9a83000)
        libcrypto.so.1.0.0 => /lib/x86_64-linux-gnu/libcrypto.so.1.0.0 (0x00007f50d963e000)
        /lib64/ld-linux-x86-64.so.2 (0x00007f50da271000)
        libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007f50d943a000)
[064bit-prompt]>
[064bit-prompt]> openssl version -a
OpenSSL 1.0.2g  1 Mar 2016
built on: reproducible build, date unspecified
platform: debian-amd64
options:  bn(64,64) rc4(16x,int) des(idx,cisc,16,int) blowfish(idx)
compiler: cc -I. -I.. -I../include  -fPIC -DOPENSSL_PIC -DOPENSSL_THREADS -D_REENTRANT -DDSO_DLFCN -DHAVE_DLFCN_H -m64 -DL_ENDIAN -g -O2 -fstack-protector-strong -Wformat -Werror=format-security -Wdate-time -D_FORTIFY_SOURCE=2 -Wl,-Bsymbolic-functions -Wl,-z,relro -Wa,--noexecstack -Wall -DMD32_REG_T=int -DOPENSSL_IA32_SSE2 -DOPENSSL_BN_ASM_MONT -DOPENSSL_BN_ASM_MONT5 -DOPENSSL_BN_ASM_GF2m -DSHA1_ASM -DSHA256_ASM -DSHA512_ASM -DMD5_ASM -DAES_ASM -DVPAES_ASM -DBSAES_ASM -DWHIRLPOOL_ASM -DGHASH_ASM -DECP_NISTZ256_ASM
OPENSSLDIR: "/usr/lib/ssl"
[064bit-prompt]>
[064bit-prompt]> python3 -c "import _md5, hashlib as hl;print(_md5.md5(b\"A\").hexdigest(), hl.md5(b\"A\").hexdigest())"
7fc56270e7a70fa81a5935b72eacbe29 7fc56270e7a70fa81a5935b72eacbe29
根据 [Python 3.Docs]: hashlib.algorithms_guaranteed

包含哈希算法名称的集合,保证在所有平台上都由此模块支持。请注意,尽管某些上游供应商提供了奇怪的“符合FIPS”的Python版本,但“md5”仍在此列表中。

下面是一个自定义的Python 2.7安装示例(我相当长时间以前构建的,值得一提的是它动态链接到OpenSSL .dll)。
[cfati@CFATI-5510-0:e:\Work\Dev\StackOverflow\q059955854]> sopr.bat
### Set shorter prompt to better fit when pasted in StackOverflow (or other) pages ###

[prompt]> "F:\Install\pc064\HPE\OPSWpython\2.7.10__00\python.exe" -c "import sys, ssl;print(\"{0:}\n{1:}\".format(sys.version, ssl.OPENSSL_VERSION))"
2.7.10 (default, Mar  8 2016, 15:02:46) [MSC v.1600 64 bit (AMD64)]
OpenSSL 1.0.2j-fips  26 Sep 2016

[prompt]> "F:\Install\pc064\HPE\OPSWpython\2.7.10__00\python.exe" -c "import hashlib as hl;print(hl.md5(\"A\").hexdigest())"
7fc56270e7a70fa81a5935b72eacbe29

[prompt]> "F:\Install\pc064\HPE\OPSWpython\2.7.10__00\python.exe" -c "import ssl;ssl.FIPS_mode_set(True);import hashlib as hl;print(hl.md5(\"A\").hexdigest())"
Traceback (most recent call last):
  File "<string>", line 1, in <module>
ValueError: error:060A80A3:digital envelope routines:FIPS_DIGESTINIT:disabled for fips
关于速度问题,我只能做出推测:
  • Python的实现显然是专门为Python编写的,这意味着它比通用版本更"优化"(是的,这在语法上不正确),并且驻留在python*.so(或python可执行文件)中。

  • OpenSSL的实现驻留在libcrypto*.so中,并且由包装器_hashlib.so访问,该包装器在Python类型(PyObject*)和OpenSSL类型(EVP_MD_CTX*)之间进行来回转换。

考虑到以上因素,前者(至少对于小消息,在这种情况下,开销(函数调用和其他Python底层操作)占总时间的比例相当大)会更快一些。还有其他因素需要考虑(例如是否使用了OpenSSL汇编器加速)。

更新#0

以下是我自己的一些基准测试。

code00.py:

#!/usr/bin/env python

import sys
import timeit
from hashlib import md5 as md5_openssl
from _md5 import md5 as md5_builtin


MD5S = (
    md5_openssl,
    md5_builtin,
)


def main(*argv):
    base_text = b"A"
    number = 1000000
    print("timeit attempts number: {:d}".format(number))
    #x = []
    #y = {}
    for count in range(0, 16):
        factor = 2 ** count
        text = base_text * factor
        globals_dict = {"text": text}
        #x.append(factor)
        print("\nUsing a {:8d} (2 ** {:2d}) bytes message".format(len(text), count))
        for func in MD5S:
            globals_dict["md5"] = func
            t = timeit.timeit(stmt="md5(text)", globals=globals_dict, number=number)
            print("    {:12s} took: {:11.6f} seconds".format(func.__name__, t))
            #y.setdefault(func.__name__, []).append(t)
    #print(x, y)


if __name__ == "__main__":
    print("Python {:s} {:03d}bit on {:s}\n".format(" ".join(elem.strip() for elem in sys.version.split("\n")),
                                                   64 if sys.maxsize > 0x100000000 else 32, sys.platform))
    rc = main(*sys.argv[1:])
    print("\nDone.\n")
    sys.exit(rc)

输出:

  • Win 10 pc064(在Dell Precision 5510笔记本电脑上运行):

    [提示]> "e:\Work\Dev\VEnvs\py_pc064_03.07.06_test0\Scripts\python.exe" code00.py
    Python 3.7.6 (tags/v3.7.6:43364a7ae0, Dec 19 2019, 00:42:30) [MSC v.1916 64 bit (AMD64)] 64bit on win32
    
    timeit尝试次数:1000000
    
    使用12 ** 0)字节消息
        openssl_md5花费时间:0.449134秒
        md5花费时间:0.120021秒
    
    使用22 ** 1)字节消息
        openssl_md5花费时间:0.460399秒
        md5花费时间:0.118555秒
    
    使用42 ** 2)字节消息
        openssl_md5花费时间:0.451850秒
        md5花费时间:0.121166秒
    
    使用82 ** 3)字节消息
        openssl_md5花费时间:0.438398秒
        md5花费时间:0.118127秒
    
    使用162 ** 4)字节消息
        openssl_md5花费时间:0.454653秒
        md5花费时间:0.122818秒
    
    使用322 ** 5)字节消息
        openssl_md5花费时间:0.450776秒
        md5花费时间:0.118594秒
    
    使用642 ** 6)字节消息
        openssl_md5花费时间:0.555761秒
        md5花费时间:0.278812秒
    
    使用1282 ** 7)字节消息
        openssl_md5花费时间:0.681296秒
        md5花费时间:0.455921秒
    
    使用2562 ** 8)字节消息
        openssl_md5花费时间:0.895952秒
        md5花费时间:0.807457秒
    
    使用5122 ** 9)字节消息
        openssl_md5花费时间:1.401584秒
        md5花费时间:1.499279秒
    
    使用10242 ** 10)字节消息
        openssl_md5花费时间:2.360966秒
        md5花费时间:2.878650秒
    
    使用20482 ** 11)字节消息
        openssl_md5花费时间:4.383245秒
        md5花费时间:5.655477秒
    
    使用40962 ** 12)字节消息
        openssl_md5花费时间:8.264774秒
        md5花费时间:10.920909秒
    
    使用81922 ** 13)字节消息
        openssl_md5花费时间:15.521947秒
        md5花费时间:21.895179秒
    
    使用163842 ** 14)字节消息
        openssl_md5花费时间:29.947287秒
        md5花费时间:43.198639秒
    
    使用327682 ** 15)字节消息
        openssl_md5花费时间:59.123447秒
        md5花费时间:86.453821秒
    
    完成。
    
  • Ubuntu 16 pc064(在上述机器上运行的VirtualBox中的VM):

    [064bit-prompt]> python3 code00.py
    Python 3.5.2 (default, Oct  8 2019, 13:06:37) [GCC 5.4.0 20160609] 64bit on linux<br><div class="h-2"></div>timeit尝试次数:1000000<br><div class="h-2"></div>使用1(2 ** 0)字节消息
        openssl_md5花费时间:0.246166秒
        md5
    
    <p>结果似乎与您的不同。在我的情况下:</p>
    
    <ul>
    <li><p>从[~<em>512B</em> .. ~<em>1KiB</em>]大小的消息开始,<em>OpenSSL</em>实现似乎比内置实现更好。</p>
    </li>
    <li><p>我知道结果太少,无法得出结论,但两种实现似乎都与消息大小成线性比例(就时间而言),但内置斜率似乎要陡峭一些-这意味着它在长期内表现会更差。</p>
    </li>
    </ul>
    
    <p>总之,如果您的所有消息都很小,并且内置实现最适合您,请使用它。</p>
    
    <br>
    
    <hr>
    
    <h3>更新 <em>#1</em></h3>
    
    <p>图形化表示(我不得不将<em>timeit</em>的迭代次数减少一个数量级,因为对于大型消息来说,花费的时间太长了):</p>
    
    <p><a rel="nofollow noreferrer" href="https://istack.dev59.com/eY0ZR.webp"><img src="https://istack.dev59.com/eY0ZR.webp" alt="Img0"></a></p>
    
    <p>并放大到两个图相交的区域:</p>
    
    <p><a rel="nofollow noreferrer" href="https://istack.dev59.com/YcFn8.webp"><img src="https://istack.dev59.com/YcFn8.webp" alt="Img1"></a></p>
    
    

谢谢您提供这些基准测试数据,能否为它们添加一条折线图呢?这样更容易直观地展示趋势。 - wim

3
我的理论来自于观察 bugs.python.org 并阅读 cpython git 提交历史记录:
cpython 在 2005 年切换到 openssl md5,因为它比内置实现更快。他们在 2007 年添加了一个新的内置实现,比 openssl 更快,但从未切换回去。这两个变化都是由 Gregory P. Smith 进行的。
以下是我的证据。
2005年,Greg创建了“sha和md5模块应尽可能使用OpenSSL”的the bpo issue。该更改在this commit中实现。
2007年,Greg在this commit中添加了新的快速md5模块。
在Python 3.8中,_md5的实现似乎基本相同(我正在查看提交ea316fd21527)。
我认为,因为openssl实现已不再更快(并且可能在过去13年中一直如此),cpython维护者可能会考虑切换回_md5

1
看起来 OpenSSL 实现在实际使用中更适合处理较大的消息(即文件,大小为千字节或更大)。请参见关于此的其他答案 - wim

2

Python公共模块常常将方法委托给隐藏模块。

例如,collections.abc 模块的完整代码如下:

from _collections_abc import *
from _collections_abc import __all__

hashlib的函数是动态创建的:

for __func_name in __always_supported:
    # try them all, some may not work due to the OpenSSL
    # version not supporting that algorithm.
    try:
        globals()[__func_name] = __get_hash(__func_name)

always_supported的定义如下::

__always_supported = ('md5', 'sha1', 'sha224', 'sha256', 'sha384', 'sha512',
                      'blake2b', 'blake2s',
                      'sha3_224', 'sha3_256', 'sha3_384', 'sha3_512',
                      'shake_128', 'shake_256')

并且get_hash要么使用__get_openssl_constructor,要么使用__get_builtin_constructor

try:
    import _hashlib
    new = __hash_new
    __get_hash = __get_openssl_constructor
    algorithms_available = algorithms_available.union(
            _hashlib.openssl_md_meth_names)
except ImportError:
    new = __py_new
    __get_hash = __get_builtin_constructor

__get_builtin_constructor是针对(再次)隐藏的_hashlib模块的备用方案

def __get_openssl_constructor(name):
    if name in __block_openssl_constructor:
        # Prefer our blake2 and sha3 implementation.
        return __get_builtin_constructor(name)
    try:
        f = getattr(_hashlib, 'openssl_' + name)
        # Allow the C module to raise ValueError.  The function will be
        # defined but the hash not actually available thanks to OpenSSL.
        f()
        # Use the C function directly (very fast)
        return f
    except (AttributeError, ValueError):
        return __get_builtin_constructor(name)

hashlib 代码 中,你会看到这样的内容:
def __get_builtin_constructor(name):
    cache = __builtin_constructor_cache
    ...
    elif name in {'MD5', 'md5'}:
        import _md5
        cache['MD5'] = cache['md5'] = _md5.md5

但是md5并不在__block_openssl_constructor中,因此_hashlib/openssl版本优于_md5/builtin版本:

REPL中的确认:

>>> hashlib.md5
<built-in function openssl_md5>
>>> _md5.md5
<built-in function md5>

那些函数是MD5算法的不同实现,而openssl_md5调用了一个动态系统库。这就是为什么会有一些性能变化。第一个版本在https://github.com/python/cpython/blob/master/Modules/_hashopenssl.c中定义,而另一个版本在https://github.com/python/cpython/blob/master/Modules/md5module.c中定义,如果您想查看差异。
那么为什么定义了_md5.md5函数却从未使用呢?我猜想这样做的目的是确保某些算法始终可用,即使没有openssl

此模块中始终存在的哈希算法的构造函数包括sha1()、sha224()、sha256()、sha384()、sha512()、blake2b()和blake2s()。(https://docs.python.org/3/library/hashlib.html


1
嗨,jferard,答案的90%关于hashlib.md5将如何绑定到内置md5实现或openssl_md5实现的低级细节。 但是,这些导入/回退的机制是无聊的细节(我也可以在源代码中阅读它)。 对于这个悬赏,我更感兴趣的是解释openssl和内置md5实现的区别以及为什么明显更快的内置版本不被选择为默认选项的答案。 openssl实现在某些方面优越吗? 为什么? - wim
你能否添加分析证据以表明性能差异是由于调用动态系统库所带来的开销? - wim

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接