为什么在 python for 循环中计算临时变量占用这么多内存?
Why tempory variable calculating in python for-loop takes so much memory usage?
下面两段代码是等价的,只是第一个占用700M左右内存,后面一个只占用100M左右内存(通过windows任务管理器)。这里发生了什么?
def a():
lst = []
for i in range(10**7):
t = "a"
t = t * 2
lst.append(t)
return lst
_ = a()
def a():
lst = []
for i in range(10**7):
t = "a" * 2
lst.append(t)
return lst
_ = a()
存在这种差异是因为 Python 解释器中的 string interning:
String interning is the method of caching particular strings in memory as they are instantiated. The idea is that, since strings in Python are immutable objects, only one instance of a particular string is needed at a time. By storing an instantiated string in memory, any future references to that same string can be directed to refer to the singleton already in existence, instead of taking up new memory.
让我用一个简单的例子来展示它:
>>> t1 = 'a'
>>> t2 = t1 * 2
>>> t2 is 'aa'
False
>>> t1 = 'a'
>>> t2 = 'a'*2
>>> t2 is 'aa'
True
当您使用第一个变体时,Python 字符串驻留不会被使用,因此解释器会创建额外的内部变量来存储时间数据。它不能以这种方式优化多行代码。
我不是Python大师,但我认为口译员是这样工作的:
t = "a"
t = t * 2
在第一行中,它为 t
创建了一个对象。在第二行,它为 =
符号右侧的 t
创建一个临时对象,并将结果写入内存中的第三个位置(稍后调用 GC)。所以第二个变体应该比第一个变体使用至少 3 倍的内存。
P.S。您可以阅读有关字符串实习的更多信息 here.
@vurmux 给出了不同内存使用的正确原因:字符串实习,但似乎缺少一些重要的细节。
CPython 实现在编译期间实习了一些字符串,例如 "a"*2
- 有关 how/why "a"*2
实习的更多信息,请参阅此 .
澄清: 正如@MartijnPieters 在他的评论中正确指出的那样:重要的是编译器是否执行常量折叠(例如计算两个常量的乘积 "a"*2
) 或不。如果完成了常量折叠,将使用生成的常量,并且列表中的所有元素将是对同一对象的引用,否则不是。即使所有字符串常量都被驻留(并因此执行常量折叠 => 字符串驻留) - 谈论实习仍然很草率:常量折叠是这里的关键,因为它也解释了根本没有实习的类型的行为,例如浮动(如果我们使用 t=42*2.0
)。
是否发生常量折叠,可以通过dis
-module轻松验证(我称你的第二个版本a2()
):
>>> import dis
>>> dis.dis(a2)
...
4 18 LOAD_CONST 2 ('aa')
20 STORE_FAST 2 (t)
...
正如我们所见,在 运行 期间没有执行乘法运算,而是直接加载乘法运算的结果(在编译期间计算)——结果列表由引用组成到同一个对象(用 18 LOAD_CONST 2
加载的常量):
>>> len({id(s) for s in a2()})
1
那里,每个引用只需要 8 个字节,这意味着大约需要 80
Mb(+列表的过度分配 + 解释器所需的内存)内存。
在 Python3.7 中,如果生成的字符串超过 4096 个字符,则不会执行常量折叠,因此将 "a"*2
替换为 "a"*4097
会导致以下字节码:
>>> dis.dis(a1)
...
4 18 LOAD_CONST 2 ('a')
20 LOAD_CONST 3 (4097)
22 BINARY_MULTIPLY
24 STORE_FAST 2 (t)
...
现在,不预先计算乘法,结果字符串中的引用将是不同的对象。
优化器还不够聪明,无法识别 t
实际上是 "a"
in t=t*2
,否则它可以执行常量折叠,但目前您的第一个版本的结果字节码(我称之为 a2()
):
...
5 22 LOAD_CONST 3 (2)
24 LOAD_FAST 2 (吨)
26 BINARY_MULTIPLY
28 STORE_FAST 2 (吨)
...
它会 return 一个包含 10^7
个不同对象(但所有对象都相等)的列表:
>>> len({id(s) for s in a1()})
10000000
即每个字符串需要大约 56 个字节(sys.getsizeof
returns 51,但是因为 pymalloc-memory-allocator 是 8 字节对齐的,所以将浪费 5 个字节)+ 每个引用 8 个字节(假设 64 位-CPython 版本),因此大约 610
Mb(+列表的过度分配+解释器所需的内存)。
您可以通过 sys.intern
:
强制驻留字符串
import sys
def a1_interned():
lst = []
for i in range(10**7):
t = "a"
t = t * 2
# here ensure, that the string-object gets interned
# returned value is the interned version
t = sys.intern(t)
lst.append(t)
return lst
实际上,我们现在不仅可以看到需要更少的内存,而且可以看到列表引用了同一个对象(在线查看稍微小一点的大小(10^5
)here):
>>> len({id(s) for s in a1_interned()})
1
>>> all((s=="aa" for s in a1_interned())
True
字符串驻留可以节省大量内存,但有时很难理解,whether/why一个字符串是否被驻留。调用 sys.intern
明确消除了这种不确定性。
t
引用的额外临时对象的存在不是问题:CPython 使用引用计数进行内存管理,因此只要没有引用对象就会被删除 - 无需任何交互垃圾收集器,在 CPython 中仅用于分解循环(不同于 Java 的 GC,因为 Java 不使用引用计数)。因此,临时变量实际上是临时的——这些对象不能累积以对内存使用产生任何影响。
临时变量t
的问题只是它阻止了编译过程中的窥孔优化,"a"*2
而不是t*2
。
下面两段代码是等价的,只是第一个占用700M左右内存,后面一个只占用100M左右内存(通过windows任务管理器)。这里发生了什么?
def a():
lst = []
for i in range(10**7):
t = "a"
t = t * 2
lst.append(t)
return lst
_ = a()
def a():
lst = []
for i in range(10**7):
t = "a" * 2
lst.append(t)
return lst
_ = a()
存在这种差异是因为 Python 解释器中的 string interning:
String interning is the method of caching particular strings in memory as they are instantiated. The idea is that, since strings in Python are immutable objects, only one instance of a particular string is needed at a time. By storing an instantiated string in memory, any future references to that same string can be directed to refer to the singleton already in existence, instead of taking up new memory.
让我用一个简单的例子来展示它:
>>> t1 = 'a'
>>> t2 = t1 * 2
>>> t2 is 'aa'
False
>>> t1 = 'a'
>>> t2 = 'a'*2
>>> t2 is 'aa'
True
当您使用第一个变体时,Python 字符串驻留不会被使用,因此解释器会创建额外的内部变量来存储时间数据。它不能以这种方式优化多行代码。
我不是Python大师,但我认为口译员是这样工作的:
t = "a" t = t * 2
在第一行中,它为 t
创建了一个对象。在第二行,它为 =
符号右侧的 t
创建一个临时对象,并将结果写入内存中的第三个位置(稍后调用 GC)。所以第二个变体应该比第一个变体使用至少 3 倍的内存。
P.S。您可以阅读有关字符串实习的更多信息 here.
@vurmux 给出了不同内存使用的正确原因:字符串实习,但似乎缺少一些重要的细节。
CPython 实现在编译期间实习了一些字符串,例如 "a"*2
- 有关 how/why "a"*2
实习的更多信息,请参阅此
澄清: 正如@MartijnPieters 在他的评论中正确指出的那样:重要的是编译器是否执行常量折叠(例如计算两个常量的乘积 "a"*2
) 或不。如果完成了常量折叠,将使用生成的常量,并且列表中的所有元素将是对同一对象的引用,否则不是。即使所有字符串常量都被驻留(并因此执行常量折叠 => 字符串驻留) - 谈论实习仍然很草率:常量折叠是这里的关键,因为它也解释了根本没有实习的类型的行为,例如浮动(如果我们使用 t=42*2.0
)。
是否发生常量折叠,可以通过dis
-module轻松验证(我称你的第二个版本a2()
):
>>> import dis
>>> dis.dis(a2)
...
4 18 LOAD_CONST 2 ('aa')
20 STORE_FAST 2 (t)
...
正如我们所见,在 运行 期间没有执行乘法运算,而是直接加载乘法运算的结果(在编译期间计算)——结果列表由引用组成到同一个对象(用 18 LOAD_CONST 2
加载的常量):
>>> len({id(s) for s in a2()})
1
那里,每个引用只需要 8 个字节,这意味着大约需要 80
Mb(+列表的过度分配 + 解释器所需的内存)内存。
在 Python3.7 中,如果生成的字符串超过 4096 个字符,则不会执行常量折叠,因此将 "a"*2
替换为 "a"*4097
会导致以下字节码:
>>> dis.dis(a1)
...
4 18 LOAD_CONST 2 ('a')
20 LOAD_CONST 3 (4097)
22 BINARY_MULTIPLY
24 STORE_FAST 2 (t)
...
现在,不预先计算乘法,结果字符串中的引用将是不同的对象。
优化器还不够聪明,无法识别 t
实际上是 "a"
in t=t*2
,否则它可以执行常量折叠,但目前您的第一个版本的结果字节码(我称之为 a2()
):
... 5 22 LOAD_CONST 3 (2) 24 LOAD_FAST 2 (吨) 26 BINARY_MULTIPLY 28 STORE_FAST 2 (吨) ...
它会 return 一个包含 10^7
个不同对象(但所有对象都相等)的列表:
>>> len({id(s) for s in a1()})
10000000
即每个字符串需要大约 56 个字节(sys.getsizeof
returns 51,但是因为 pymalloc-memory-allocator 是 8 字节对齐的,所以将浪费 5 个字节)+ 每个引用 8 个字节(假设 64 位-CPython 版本),因此大约 610
Mb(+列表的过度分配+解释器所需的内存)。
您可以通过 sys.intern
:
import sys
def a1_interned():
lst = []
for i in range(10**7):
t = "a"
t = t * 2
# here ensure, that the string-object gets interned
# returned value is the interned version
t = sys.intern(t)
lst.append(t)
return lst
实际上,我们现在不仅可以看到需要更少的内存,而且可以看到列表引用了同一个对象(在线查看稍微小一点的大小(10^5
)here):
>>> len({id(s) for s in a1_interned()})
1
>>> all((s=="aa" for s in a1_interned())
True
字符串驻留可以节省大量内存,但有时很难理解,whether/why一个字符串是否被驻留。调用 sys.intern
明确消除了这种不确定性。
t
引用的额外临时对象的存在不是问题:CPython 使用引用计数进行内存管理,因此只要没有引用对象就会被删除 - 无需任何交互垃圾收集器,在 CPython 中仅用于分解循环(不同于 Java 的 GC,因为 Java 不使用引用计数)。因此,临时变量实际上是临时的——这些对象不能累积以对内存使用产生任何影响。
临时变量t
的问题只是它阻止了编译过程中的窥孔优化,"a"*2
而不是t*2
。