声明变量在循环内部还是外部更好?

7

我习惯这样做:

do
    local a
    for i=1,1000000 do
        a = <some expression>
        <...> --do something with a
    end
end

替代

for i=1,1000000 do
    local a = <some expression>
    <...> --do something with a
end

我的理解是,创建1000000次本地变量比只创建一次并在每次迭代中重用它的效率低。
我的问题是:这是真的还是我漏掉了另一个技术细节?我之所以问是因为我没有看到任何人这样做,但不确定原因是因为优势太小还是因为实际上更糟。通过更好的使用意味着使用更少的内存和更快的运行。

当你编写源代码时,“创建本地变量”是你要做的事情。运行时发生的事情,我想对你和我来说都是未知的。(好吧,我玩过 luac -l 并稍微了解了一下虚拟机指令集。) - Tom Blodget
4个回答

11

像任何性能问题一样,首先要进行测量。在Unix系统中,可以使用time命令:

time lua -e 'local a; for i=1,100000000 do a = i * 3 end'
time lua -e 'for i=1,100000000 do local a = i * 3 end'

输出结果:

 real   0m2.320s
 user   0m2.315s
 sys    0m0.004s

 real   0m2.247s
 user   0m2.246s
 sys    0m0.000s

在 Lua 中,更本地版本似乎要快一些,因为它不会将 a 初始化为 nil。但是,这不是使用它的理由使用最本地范围,因为这样更易读(这在所有语言中都是良好的风格:参见针对CJavaC#提出的此问题)

如果您在循环中重复使用表而不是在其中创建表,则可能会有更大的性能差异。无论如何,请进行测量并在您可以时考虑易读性。


6
非常准确。可读性比微不足道的性能调整重要100倍。 - Steve Wellens
1
冷启动!在 shell 中执行 first && second 几次,你会发现没有严格的更快。1253/1022, 1020/1022, 1023/1019, 1020/1021, 1022/1020, 1022/1022, 1028/1019, 1021/1021, 1021/1022, ... - user3125367
实际上,在这两种情况下,激活记录中都有两个预定义的插槽,并且操作码没有区别。 - user3125367
@user3125367,这两个操作码只有一个额外的LOADNIL,因为在第一个中a被默认初始化为nil。重点是性能差异微不足道,第二种方法更易读。 - ryanpattison
现在你可以在我的答案中看到它(并且自己检查)。 - user3125367
显示剩余3条评论

6
我认为人们对编译器处理变量的方式存在一些困惑。从高层次的人类角度来看,定义和销毁变量似乎会有某种“成本”相关联。
然而,对于优化编译器来说,并非总是如此。在高级语言中创建的变量更像是对内存的临时“句柄”。编译器查看这些变量,然后将其转换为中间表示形式 (接近机器语言),并确定要存储所有内容的位置,主要目的是分配寄存器(CPU使用的最直接形式的内存)。然后将IR转换为机器代码,在那里,“变量”的概念甚至不存在,只有可以存储数据的位置(寄存器,缓存,DRAM,磁盘)。
该过程包括重用同一寄存器来存储多个变量,前提是它们彼此不干扰(同时不需要:不会同时“活跃”)。
换句话说,具有以下代码的情况:
local a = <some expression>

生成的汇编代码可能如下所示:
load gp_register, <result from expression>

... 或者它可能已经从寄存器中的某些表达式获得了结果,并且变量最终完全消失(只是使用同一个寄存器)。

这意味着变量的存在没有“成本”。它直接转换为始终可用的寄存器。 "创建寄存器" 没有任何“成本”,因为寄存器总是存在的。

当您在更广泛的范围(较少的局部)创建变量时,与您想象的相反,您实际上可能会减慢代码速度。当你这样表面化地做时,你有点在与编译器的寄存器分配斗争,并使编译器更难找出为什么寄存器分配给什么。在那种情况下,编译器可能会将更多的变量溢出到堆栈中,这效率低下并且实际上带有一定的成本。聪明的编译器仍然可能发射同样有效的代码,但实际上可能会使事情变慢。在这里帮助编译器通常意味着在较小的范围内使用更多的局部变量,这样您就有最好的效率机会。

在汇编代码中,尽可能重复使用相同的寄存器是有效的,以避免堆栈溢出。在具有变量的高级语言中,情况有些相反。减少变量的范围可以帮助编译器确定它可以重复使用哪些寄存器,因为将变量放在较小的范围内可以帮助告诉编译器哪些变量不会同时存在。

现在,在像C++这样的语言中涉及用户定义的构造函数和析构函数逻辑时,重用一个 对象 可能会防止可以重新使用的对象的冗余构建和销毁。但是在像Lua这样的语言中,所有变量基本上都是普通数据(或句柄进入垃圾收集数据或userdata)。

唯一可能看到改进的情况是使用较少的局部变量,如果那样可以减轻垃圾收集器的工作量。但是,如果您只是重新分配给同一变量,则不会发生这种情况。要做到这一点,您需要重用整个表或用户数据(而无需重新分配)。换句话说,在某些情况下,重用表的相同字段而不创建一个全新的表可能会有所帮助,但是重用用于引用表的变量很不可能有所帮助,并且实际上可能会妨碍性能。


1
确实。当我看到 local a; for i=1,100000000 do a = i * 3 end 时,我可以看出它根本什么都没做。我知道编译器的作者比我聪明,所以如果C++编译器将这样的代码优化为NOP,我也不会感到惊讶。 - Tom Blodget

3
所有局部变量都是在编译(load)时“创建”的,它们只是函数激活记录中的局部块的索引。每次定义一个local时,该块就会增加1个大小。每次do..end/词法块结束时,它都会缩小回去。峰值用作总大小:
function ()
    local a        -- current:1, peak:1
    do
        local x    -- current:2, peak:2
        local y    -- current:3, peak:3
    end
                   -- current:1, peak:3
    do
        local z    -- current:2, peak:3
    end
end

上述函数有3个本地槽(在load时确定,而不是在运行时确定)。

关于您的情况,本地块大小没有区别,而且luac/5.1生成相同的列表(只有索引不同):

$  luac -l -
local a; for i=1,100000000 do a = i * 3 end
^D
main <stdin:0,0> (7 instructions, 28 bytes at 0x7fee6b600000)
0+ params, 5 slots, 0 upvalues, 5 locals, 3 constants, 0 functions
        1       [1]     LOADK           1 -1    ; 1
        2       [1]     LOADK           2 -2    ; 100000000
        3       [1]     LOADK           3 -1    ; 1
        4       [1]     FORPREP         1 1     ; to 6
        5       [1]     MUL             0 4 -3  ; - 3       // [0] is a
        6       [1]     FORLOOP         1 -2    ; to 5
        7       [1]     RETURN          0 1

vs

$  luac -l -
for i=1,100000000 do local a = i * 3 end
^D
main <stdin:0,0> (7 instructions, 28 bytes at 0x7f8302d00020)
0+ params, 5 slots, 0 upvalues, 5 locals, 3 constants, 0 functions
        1       [1]     LOADK           0 -1    ; 1
        2       [1]     LOADK           1 -2    ; 100000000
        3       [1]     LOADK           2 -1    ; 1
        4       [1]     FORPREP         0 1     ; to 6
        5       [1]     MUL             4 3 -3  ; - 3       // [4] is a
        6       [1]     FORLOOP         0 -2    ; to 5
        7       [1]     RETURN          0 1

// [n] -我的注释。


2
首先请注意:在循环内部定义变量可以确保该变量在一次迭代后不能再次使用。在for循环之前定义变量可以使变量在多个迭代中传递,就像任何其他未在循环内定义的变量一样。
此外,回答您的问题:是的,这样做效率会更低,因为它重新初始化了变量。如果Lua JIT /编译器具有良好的模式识别能力,它可能只重置变量,但我无法确认或否认这一点。

抱歉,我有点困惑。当你说“是的,它不太高效”时,你指的是哪一个?在这两种情况下,变量值都会在每次迭代中被重新定义。 - Mandrill
抱歉造成困惑。我提到的是在循环中声明变量,这时写'Yes, it is less efficient'。 - vdMeent

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