在 python 3.6 上工作但不在 3.7.3 上工作的方法的记忆

Posted

技术标签:

【中文标题】在 python 3.6 上工作但不在 3.7.3 上工作的方法的记忆【英文标题】:Memoization of method working on python 3.6 but not on 3.7.3 【发布时间】:2019-09-13 23:41:39 【问题描述】:

我使用装饰器通过 lru_cache 将 memoization 扩展到本身不可散列的对象的方法(遵循***.com/questions/33672412/python-functools-lru-cache-with-class-methods-release-object)。 此记忆在 python 3.6 上运行良好,但在 python 3.7 上显示意外行为。

观察到的行为: 如果使用关键字参数调用 memoized 方法,则 memoization 在两个 python 版本上都可以正常工作。如果在没有关键字 arg 语法的情况下调用它,它适用于 3.6,但不适用于 3.7。

==> 什么可能导致不同的行为?

下面的代码示例显示了一个重现该行为的最小示例。

test_memoization_kwarg_call 适用于 python 3.6 和 3.7。 test_memoization_arg_call 在 python 3.6 上通过,但在 3.7 上失败。

import random
import weakref
from functools import lru_cache


def memoize_method(func):
    # From ***.com/questions/33672412/python-functools-lru-cache-with-class-methods-release-object
    def wrapped_func(self, *args, **kwargs):
        self_weak = weakref.ref(self)

        @lru_cache()
        def cached_method(*args_, **kwargs_):
            return func(self_weak(), *args_, **kwargs_)

        setattr(self, func.__name__, cached_method)
        print(args)
        print(kwargs)
        return cached_method(*args, **kwargs)

    return wrapped_func


class MyClass:
    @memoize_method
    def randint(self, param):
        return random.randint(0, int(1E9))


def test_memoization_kwarg_call():
    obj = MyClass()
    assert obj.randint(param=1) == obj.randint(param=1)
    assert obj.randint(1) == obj.randint(1)


def test_memoization_arg_call():
    obj = MyClass()
    assert obj.randint(1) == obj.randint(1)

请注意,奇怪的是,assert obj.randint(1) == obj.randint(1) 行在 python 3.6 中使用时不会导致 test_memoization_kwarg_call 中的测试失败,但在 test_memoization_arg_call 中的 python 3.7 中会失败。

Python 版本:分别为 3.6.8 和 3.7.3。

更多信息

user2357112 建议检查import dis; dis.dis(test_memoization_arg_call)。 在 python 3.6 上,这给出了

 36           0 LOAD_GLOBAL              0 (MyClass)
              2 CALL_FUNCTION            0
              4 STORE_FAST               0 (obj)

 37           6 LOAD_FAST                0 (obj)
              8 LOAD_ATTR                1 (randint)
             10 LOAD_CONST               1 (1)
             12 CALL_FUNCTION            1
             14 LOAD_FAST                0 (obj)
             16 LOAD_ATTR                1 (randint)
             18 LOAD_CONST               1 (1)
             20 CALL_FUNCTION            1
             22 COMPARE_OP               2 (==)
             24 POP_JUMP_IF_TRUE        30
             26 LOAD_GLOBAL              2 (AssertionError)
             28 RAISE_VARARGS            1
        >>   30 LOAD_CONST               0 (None)
             32 RETURN_VALUE

在 python 3.7 上,这给出了

 36           0 LOAD_GLOBAL              0 (MyClass)
              2 CALL_FUNCTION            0
              4 STORE_FAST               0 (obj)

 37           6 LOAD_FAST                0 (obj)
              8 LOAD_METHOD              1 (randint)
             10 LOAD_CONST               1 (1)
             12 CALL_METHOD              1
             14 LOAD_FAST                0 (obj)
             16 LOAD_METHOD              1 (randint)
             18 LOAD_CONST               1 (1)
             20 CALL_METHOD              1
             22 COMPARE_OP               2 (==)
             24 POP_JUMP_IF_TRUE        30
             26 LOAD_GLOBAL              2 (AssertionError)
             28 RAISE_VARARGS            1
        >>   30 LOAD_CONST               0 (None)
             32 RETURN_VALUE

不同之处在于,在 3.6 上调用缓存的 randint 方法会产生 LOAD_ATTR, LOAD_CONST, CALL_FUNCTION,而在 3.7 上会产生 LOAD_METHOD, LOAD_CONST, CALL_METHOD。这可以解释行为上的差异,但我不了解 CPython 的内部结构(?)来理解它。有什么想法吗?

【问题讨论】:

你能在 Python 3 上显示import dis; dis.dis(test_memoization_arg_call) 的输出吗?另外,你是如何运行这段代码的? 我无法重现您在 Python 3.7.0 上描述的行为。 我也发现这两种情况都适用于 3.6.4 和 3.7.2。 @user2357112 感谢您推荐dis.dis。我在上面添加了输出。在 3.6 上,对缓存的 randint 方法的调用会产生 LOAD_ATTR、LOAD_CONST、CALL_FUNCTION,而在 3.7 上会产生 LOAD_METHOD、LOAD_CONST、CALL_METHOD。这可以解释行为上的差异,但我不了解 CPython 的内部结构(?)来理解它。有什么想法吗? 这是一个已修复的错误,应该作为下一个版本的一部分发布。 Raymond Hettinger 对 Python 问题跟踪器中发生的事情进行了很好的描述:bugs.python.org/issue36650。 【参考方案1】:

我对这个问题有一个更简单的解决方案:

pip install methodtools

那么,

import random
from methodtools import lru_cache


class MyClass:
    @lru_cache()
    def randint(self, param):
        return random.randint(0, int(1E9))


def test_memoization_kwarg_call():
    obj = MyClass()
    assert obj.randint(param=1) == obj.randint(param=1)
    assert obj.randint(1) == obj.randint(1)

很抱歉,这不是“为什么”的答案,但如果您也有兴趣解决问题。这是用 3.7.3 测试的。

【讨论】:

感谢@youknowone 的有用建议。我不知道methodtools 出于好奇@youknowone:我看到methodtools 引用了Ring。使用@methodtools.lru_cache()@ring.lru() 有什么区别吗? 大致来说,methodtools.lru_cachefunctools.lru_cache 的浅包装。除了方法支持之外,它们是相同的。 ring.lru 有点不同。它具有丰富的功能,例如可选的过期、删除、更新和数据编码,例如 json。它还生成人类可读的一致字符串键。不同之处在于设计目标——因为 ring 被设计为也可以与 memcached 等外部​​后端一起使用。如果您对戒指感兴趣,请查看此文档:ring-cache.readthedocs.io/en/stable/why.html【参考方案2】:

我以前从未对 python 说过这个,但老实说这看起来像一个错误。我不知道为什么会这样,因为所有这些东西都在底层 C 中。

但这是我看到的,试图窥探黑匣子:

我在您的代码中添加了一些简单的打印:

def memoize_method(func):
    # From ***.com/questions/33672412/python-functools-lru-cache-with-class-methods-release-object
    def wrapped_func(self, *args, **kwargs):
        self_weak = weakref.ref(self)

        print('wrapping func')
        @lru_cache()
        def cached_method(*args_, **kwargs_):
            print('in cached_method', args_, kwargs_, id(cached_method))
            return func(self_weak(), *args_, **kwargs_)

        setattr(self, func.__name__, cached_method)
        return cached_method(*args, **kwargs)

    return wrapped_func

然后我像这样测试了这个函数:

def test_memoization_arg_call():
    obj = MyClass()
    for _ in range(5):
        print(id(obj.randint), obj.randint(1), obj.randint.cache_info(), id(obj.randint))
    print()
    for _ in range(5):
        print(id(obj.randint), obj.randint(2), obj.randint.cache_info(), id(obj.randint))

这是输出:

==================================
wrapping func
in cached_method (1,)  4525448992
4521585800 668415661 CacheInfo(hits=0, misses=1, maxsize=128, currsize=1) 4525448992
in cached_method (1,)  4525448992
4525448992 920166498 CacheInfo(hits=0, misses=2, maxsize=128, currsize=2) 4525448992
4525448992 920166498 CacheInfo(hits=1, misses=2, maxsize=128, currsize=2) 4525448992
4525448992 920166498 CacheInfo(hits=2, misses=2, maxsize=128, currsize=2) 4525448992
4525448992 920166498 CacheInfo(hits=3, misses=2, maxsize=128, currsize=2) 4525448992

in cached_method (2,)  4525448992
4525448992 690871031 CacheInfo(hits=3, misses=3, maxsize=128, currsize=3) 4525448992
4525448992 690871031 CacheInfo(hits=4, misses=3, maxsize=128, currsize=3) 4525448992
4525448992 690871031 CacheInfo(hits=5, misses=3, maxsize=128, currsize=3) 4525448992
4525448992 690871031 CacheInfo(hits=6, misses=3, maxsize=128, currsize=3) 4525448992
4525448992 690871031 CacheInfo(hits=7, misses=3, maxsize=128, currsize=3) 4525448992

这里有趣的是,它似乎错误地缓存了第一个位置 args 调用。 kwargs 不会发生这种情况,如果您先调用 kwargs 调用,它不会错误缓存该调用或任何后续 pos args 调用(无论出于何种原因,这意味着您的 kwargs 测试正在工作)。重要的几行是这样的:

==================================
wrapping func
in cached_method (1,)  4525448992
4521585800 668415661 CacheInfo(hits=0, misses=1, maxsize=128, currsize=1) 4525448992
in cached_method (1,)  4525448992
4525448992 920166498 CacheInfo(hits=0, misses=2, maxsize=128, currsize=2) 4525448992
4525448992 920166498 CacheInfo(hits=1, misses=2, maxsize=128, currsize=2) 4525448992

您可以看到我在函数 cached_method 中使用了两次 id 4525448992 并使用完全相同的 args/kwargs,但它没有缓存。它甚至在CacheInfo 中显示未命中本身(首先,缓存是空的。其次,由于某种原因它找不到(1,))。这都是C语言,所以我不知道如何修复它......

我想最好的答案是使用另一个 lru_cache 方法并等待开发人员修复这里发生的任何事情。

编辑:顺便说一句,好问题。

【讨论】:

【参考方案3】:

这是 Python 3.7.3 次要版本中的一个错误。它不存在于 Python 3.7.2 中,也不应该存在于 Python 3.7.4 或 3.8.0 中。它被归档为Python issue 36650。

在 C 级别,不带关键字参数的调用和带空 **kwargs dict 的调用的处理方式不同。根据函数实现方式的详细信息,函数可能会收到 NULL 的 kwargs 而不是空的 kwargs 字典。 functools.lru_cache 的 C 加速器处理带有 NULL kwargs 的调用与带有空 kwargs dict 的调用不同,导致您在此处看到的错误。

使用您正在使用的方法缓存配方,对方法的第一次调用将始终将一个空的 kwargs dict 传递给 C 级 LRU 包装器,无论是否使用了任何关键字参数,因为 return cached_method(*args, **kwargs)wrapped_func。随后的调用可能会通过NULL kwargs dict,因为它们不再通过wrapped_func。这就是为什么您无法使用test_memoization_kwarg_call 重现该错误的原因; first 调用必须不传递关键字参数。

【讨论】:

以上是关于在 python 3.6 上工作但不在 3.7.3 上工作的方法的记忆的主要内容,如果未能解决你的问题,请参考以下文章

validate_email python库在本地机器上工作,但不在aws弹性bean上

Sed 从命令行工作,但不在 Python 脚本中

Pandas Python 程序在 Python3、3.8 但不是 3.6 上运行

在 slim python 3.6 docker 映像上运行 python mysql 客户端

如何在Python 3.6上安装PIP?

f2py.exe 在某处,但目录不在路径上