在`for`循环中缓存变量

9

我是一个有用的助手,可以为您翻译文本。

我一直在阅读有关JavaScript性能提升的技巧,其中一条建议是在循环比较语句中缓存所有不变的变量,我想知道这是否也适用于.NET。

假设我有一个简单的for循环,以下哪个更快或它们是否相同?

无缓存:

for (int i = 0; i < someArray.Length; i++)
{

}

使用缓存:

for (int i = 0, count = someArray.Length; i < count; i++)
{

}

根据文章“缓存”, Length 的价值在循环中削减了一次操作,因为访问局部变量比访问对象成员更快。实际上声明一个本地变量是否比简单访问成员更快?编译器会注意到这一点并自动缓存该值吗?声明本地变量比访问成员有什么负面影响吗?
虽然速度可能是关键因素,但不是唯一的因素。我的下一个问题可能是哪一个更有效率。哪个使用更少的内存分配?哪一个执行较少的堆栈操作等等......
从评论中看来,访问数组长度非常快。假设我改用IList<>,缓存 Count 的值是否比每次迭代检索它更快?

3
在开始阅读之前:微优化剧场的悲伤悲剧注:该文章介绍了在软件开发中过度关注微小性能优化所带来的不必要的麻烦和风险。作者呼吁开发者们应该更专注于代码质量、可读性和可扩展性等更重要的方面。 - Steve
2
@Steve 我知道在微观优化上浪费时间是浪费时间,但这更多的是出于好奇而不是主要的性能优化发现。然而,如果您正在迭代超过1,000,000个项目的数组,则可能会从微观优化分类转移到相当大的性能优化!不过,那篇文章很值得一读 :) 谢谢! :P - jduncanator
我不会对你的意图进行评判,它们非常明确。只是保持事情的客观性。我现在只是查看您的指令生成的IL代码,第二个循环似乎根本没有优化,但这可以在JIT编译的代码上轻松更改。 - Steve
4
即使迭代超过一百万项,也不意味着这很重要(即使它确实有好处)。这取决于迭代的成本与循环体中所涉及事项的成本。如果整个循环的总时间为3毫秒,则由于微小优化而节省1毫秒是非常好的。但如果需要一个小时,则没有那么重要。 - Jon Skeet
我需要修复我的先前的断言,第二个循环除了初始设置之外,比第一个循环少两个操作码。 - Steve
显示剩余6条评论
4个回答

3

提供另一种答案。对长度值进行缓存可能在某些特定情况下有所帮助。

在我的情况下,我们使用了一个带有selenium 的样例代码进行 UI 测试。 代码如下:

for (int i = 0; i < Grid.VisibleRows.Length; i++) {
    // do something
}

我们的网格只是表示HTML表格的类。VisibleRows得到了一个IWebElement[],然后使用Length属性。每次迭代都需要去UI并获取所有行。
当然,另一种实现可能只需要一次访问UI并获取长度(而不是在内存中计算行数),但是将代码移出for循环仍意味着性能有所提高-我将往返UI的次数从多次减少到只有一次。
因此,代码现在看起来像这样:
var rowsLength = Grid.VisibleRows.Length;
 for (int i = 0; i < rowsLength ; i++) {
        // do something
    }

对于我们来说,手动缓存效果很好。我们使用了一个秒表检查,我们成功将那段代码的执行时间从32993毫秒减少到12020毫秒。


1
我曾尝试过缓存与数组长度比较。 Array.Length 更快。我认为这是由于虚拟机的内部结构和“安全性”所致。当您使用 .Length 表示法时,它会考虑到数组永远不会溢出。对于变量,它是未知的,并且需要进行额外的测试。汇编代码看起来是一样的,但虚拟机的内部行为是另一回事。

但另一方面,您正在进行过早的优化。


0
在编译语言中,你所做的一切都只是过早优化。我想解释型语言可能会节省一点,但即使在那里,对于我来说,这似乎也是一种不寻常的编写for循环的方式,而回报却非常微小。
直接回答C#的问题,编译器不会通过缓存来优化任何内容。我可以很容易地在循环期间创建一个新数组并指定新长度。因此,每次评估停止条件时,它都会加载数组长度。或者更糟糕的是,我可能没有使用“传统”样式的停止条件,而需要评估一个函数才能知道何时停止。
话虽如此,这里有一个简单的程序:
static void Main( string[] args ) {
    int[] integers = new int[] { 1, 2, 3, 4, 5 };

    for( int i = 0; i < integers.Length; i++ ) {
        Console.WriteLine( i );
    }
}

这里是去除 nops 后的 IL 代码:

IL_000d:  call       void [mscorlib]System.Runtime.CompilerServices.RuntimeHelpers::InitializeArray(class [mscorlib]System.Array,
                                                                                                     valuetype [mscorlib]System.RuntimeFieldHandle)
IL_0012:  stloc.0
IL_0013:  ldc.i4.0
IL_0014:  stloc.1
IL_0015:  br.s       IL_0024
IL_0018:  ldloc.1
IL_0019:  call       void [mscorlib]System.Console::WriteLine(int32)
IL_0020:  ldloc.1
IL_0021:  ldc.i4.1
IL_0022:  add
IL_0023:  stloc.1
IL_0024:  ldloc.1
IL_0025:  ldloc.0
IL_0026:  ldlen
IL_0027:  conv.i4
IL_0028:  clt
IL_002a:  stloc.2
IL_002b:  ldloc.2
IL_002c:  brtrue.s   IL_0017

你问题的关键答案是,将数组推送到堆栈的位置0,然后在IL_0026期间调用获得数组的长度,然后IL_0028执行小于比较,如果评估为true,则最终转到IL_0017。

通过缓存数组的长度,你只能节省ldlen和stloc调用。由于获取数组的长度不会太多浪费时间,因此ldlen指令应该很快。

编辑:

与列表的主要区别在于以下指令:

IL_002b:  callvirt   instance int32 class [mscorlib]System.Collections.Generic.List`1<int32>::get_Count()

callvirt会占用更多时间,但这个函数实际上只是返回一个私有变量。

你最好关注那些需要毫秒级别优化的事情 - 比如分块数据库调用或优化SQL查询等,而不是试图删减单一IL操作。


有道理,但这是否仍适用于 IList<> - jduncanator
10
请注意,即使使用IL也不能完全说明问题。如果JIT能够确定在循环期间数组变量不会更改(指引用不同的数组),我希望它能准确地发现这种极为常见的模式并进一步针对数组情况进行优化。 - Jon Skeet
在这种特定情况下并不重要,但是您提供的IL是否已禁用优化进行编译?当前的C#编译器优化将cltbrtrue转换为blt,据我所见。 - Bob
虽然缓存 Array.Length 对性能影响非常微小,但对于其他属性例如 List.Count ,情况就不同了。 - tigrou

-1

我怀疑这样做不会使您的代码更快,实际上可能会使它变得更慢。获取数组长度非常便宜(可能是L1命中),预先计算长度可能会干扰JIT的边界检查消除。

在C#中,写作:     array [i] 实际上更像写作:     if(i>=array.Length)         throw ...;     array [i]

CLR作者花了很多时间使JIT在不必要时消除这些检查。以第二种方式编写循环可能会使CLR无法消除检查。


实现一个 IList<> 怎么样? - jduncanator
这完全取决于实现方式。如果它没有被缓存到某个地方,你可能不应该在循环结束条件检查中计算它。 - mattsills

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