为什么在 C# 中使用结构 Vector3I 而不是三个整数要慢得多?

Why is using structure Vector3I instead of three ints much slower in C#?

我在 3D 网格中处理大量数据,因此我想实现一个简单的迭代器而不是三个嵌套循环。但是,我遇到了一个性能问题:首先,我只使用 int x、y 和 z 变量实现了一个简单的循环。然后我实现了一个 Vector3I 结构并使用它 - 计算时间加倍了。现在我正在为这个问题而苦苦挣扎——这是为什么?我做错了什么?

复制示例:

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;

public struct Vector2I
{
    public int X;
    public int Y;
    public int Z;

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public Vector2I(int x, int y, int z)
    {
        this.X = x;
        this.Y = y;
        this.Z = z;
    }
}

public class IterationTests
{
    private readonly int _countX;
    private readonly int _countY;
    private readonly int _countZ;
    private Vector2I _Vector = new Vector2I(0, 0, 0);


    public IterationTests()
    {
        _countX = 64;
        _countY = 64;
        _countZ = 64;
    }

    [Benchmark]
    public void NestedLoops()
    {
        int countX = _countX;
        int countY = _countY;
        int countZ = _countZ;

        int result = 0;

        for (int x = 0; x < countX; ++x)
        {
            for (int y = 0; y < countY; ++y)
            {
                for (int z = 0; z < countZ; ++z)
                {
                    result += ((x ^ y) ^ (~z));
                }
            }
        }
    }

    [Benchmark]
    public void IteratedVariables()
    {
        int countX = _countX;
        int countY = _countY;
        int countZ = _countZ;

        int result = 0;

        int x = 0, y = 0, z = 0;
        while (true)
        {
            result += ((x ^ y) ^ (~z));

            ++z;
            if (z >= countZ)
            {
                z = 0;
                ++y;

                if (y >= countY)
                {
                    y = 0;
                    ++x;

                    if (x >= countX)
                    {
                        break;
                    }
                }
            }
        }
    }

    [Benchmark]
    public void IteratedVector()
    {
        int countX = _countX;
        int countY = _countY;
        int countZ = _countZ;

        int result = 0;

        Vector2I iter = new Vector2I(0, 0, 0);
        while (true)
        {
            result += ((iter.X ^ iter.Y) ^ (~iter.Z));

            ++iter.Z;
            if (iter.Z >= countZ)
            {
                iter.Z = 0;
                ++iter.Y;

                if (iter.Y >= countY)
                {
                    iter.Y = 0;
                    ++iter.X;

                    if (iter.X >= countX)
                    {
                        break;
                    }
                }
            }
        }
    }

    [Benchmark]
    public void IteratedVectorAvoidNew()
    {
        int countX = _countX;
        int countY = _countY;
        int countZ = _countZ;

        int result = 0;

        Vector2I iter = _Vector;

        iter.X = 0;
        iter.Y = 0;
        iter.Z = 0;
        while (true)
        {
            result += ((iter.X ^ iter.Y) ^ (~iter.Z));

            ++iter.Z;
            if (iter.Z >= countZ)
            {
                iter.Z = 0;
                ++iter.Y;

                if (iter.Y >= countY)
                {
                    iter.Y = 0;
                    ++iter.X;

                    if (iter.X >= countX)
                    {
                        break;
                    }
                }
            }
        }
    }
}

public static class Program
{
    public static void Main(string[] args)
    {
        BenchmarkRunner.Run<IterationTests>();
    }
}

我测量的是:

                 Method |     Mean |     Error |    StdDev |
----------------------- |---------:|----------:|----------:|
            NestedLoops | 333.9 us | 4.6837 us | 4.3811 us |
      IteratedVariables | 291.0 us | 0.8792 us | 0.6864 us |
         IteratedVector | 702.1 us | 4.8590 us | 4.3073 us |
 IteratedVectorAvoidNew | 725.8 us | 6.4850 us | 6.0661 us |

注意:'IteratedVectorAvoidNew' 是由于讨论问题可能出在 Vector3I 的 new 运算符 - 最初,我使用自定义迭代循环并用秒表测量。

另外,我迭代256×256×256区域时的基准:

                 Method |     Mean |     Error |    StdDev |
----------------------- |---------:|----------:|----------:|
            NestedLoops | 18.67 ms | 0.0504 ms | 0.0446 ms |
      IteratedVariables | 18.80 ms | 0.2006 ms | 0.1877 ms |
         IteratedVector | 43.66 ms | 0.4525 ms | 0.4232 ms |
 IteratedVectorAvoidNew | 43.36 ms | 0.5316 ms | 0.4973 ms |

我的环境:

备注:

我当前的任务是重写现有代码以 a) 支持更多功能,b) 更快。此外,我正在处理大量数据 - 这是整个应用程序的当前瓶颈,所以不,这不是过早的优化。

将嵌套循环重写为一个 - 我不打算在那里进行优化。我只是需要多次编写这样的迭代,所以只是想简化代码,仅此而已。但因为它是代码的性能关键部分,所以我正在测量设计中的此类变化。现在,当我看到只需将三个变量存储到一个结构中时,我就会将处理时间加倍......我非常害怕使用这样的结构......

这里涉及内存访问和寄存器访问的区别。

TL;DR:
对于原始变量,一切都可以放入寄存器,而对于结构,一切都必须从堆栈访问,这是一种内存访问。访问寄存器比访问内存快得多。

现在,进入完整解释:

C# 在启动时是 JIT 编译的(这与 JVM 略有不同,但现在这并不重要),因此我们可以看到实际生成的程序集(查看 here 了解如何查看它)。

为此,我只比较 IteratedVariablesIteratedVector,因为您将仅通过这些获得一般要点。首先我们有 IteratedVariables:

                    ; int countX = 64;
in   al, dx  
push edi  
push esi  
push ebx  
                    ; int result = 0;
xor ebx, ebx  
                    ; int x = 0, y = 0, z = 0;
xor edi, edi  
                    ; int x = 0, y = 0, z = 0;
xor ecx, ecx  
xor esi, esi  
                    ; while(true) {
                    ;     result += ((x ^ y) ^ (~z));
LOOP:
    mov eax, edi  
    xor eax, ecx  
    mov edx, esi  
    not edx   
    xor eax, edx  
    add ebx, eax 
                    ; ++z;
    inc esi  
                    ; if(z >= countZ)
    cmp esi, 40h  
    jl  LOOP  
                    ; {
                    ;     z = 0;
    xor esi, esi  
                    ; ++y;
    inc ecx  
                    ; if(y >= countY)
    cmp ecx, 40h  
    jl  LOOP  
                    ; {
                    ;     y = 0;
    xor ecx, ecx  
                    ; ++x;
    inc edi  
                    ; if(x >= countX)
    cmp edi, 40h  
    jl  LOOP  
                    ; {
                    ;     break;
                    ; } } } }
                    ; return result;
mov eax, ebx  
pop ebx  
pop esi  
pop edi  
pop ebp  
ret  

我做了一些清理代码的工作,所有注释(用分号标记的行(;))都来自实际的 C# 代码(这些是为我生成的),我为了简洁起见,对它们进行了一些清理。您在这里应该注意的主要事情是,所有内容都在访问寄存器,没有原始内存访问(原始内存访问可以通过寄存器名称周围的 [] 在某种程度上标识)。

在第二个示例 (IteratedVector) 中,我们将看到一个略有不同的代码片段:

                                    ; int countX = 64;
push ebp  
mov  ebp, esp  
sub  esp, 0Ch  
xor  eax, eax  
mov  dword ptr [ebp-0Ch], eax  
mov  dword ptr [ebp-8],   eax  
mov  dword ptr [ebp-4],   eax  
                                    ; int result = 0;
xor ecx,ecx  
                                    ; Vector3i iter = new Vector3i(0, 0, 0);
mov dword ptr [ebp-0Ch], ecx  
mov dword ptr [ebp-8],   ecx  
mov dword ptr [ebp-4],   ecx  
                                    ; while(true) {
                                    ;     result += ((iter.X ^ iter.Y) ^ (~iter.Z));
LOOP:
    mov eax, dword ptr [ebp-0Ch]  
    xor eax, dword ptr [ebp-8]  
    mov edx, dword ptr [ebp-4]  
    not edx  
    xor eax, edx  
    add ecx, eax  
                                    ; ++iter.Z;
    lea eax, [ebp-4]  
    inc dword ptr [eax]  
                                    ; if(iter.Z >= countZ)
    cmp dword ptr [ebp-4], 40h  
    jl  LOOP  
                                    ; {
                                    ;     iter.Z = 0;
    xor edx, edx  
    mov dword ptr [ebp-4], edx  
                                    ; ++iter.Y;
    lea eax, [ebp-8]  
    inc dword ptr [eax]  
                                    ; if(iter.Y >= countY)
    cmp dword ptr [ebp-8], 40h  
    jl  LOOP  
                                    ; {
                                    ;     iter.Y = 0;
    xor edx, edx  
    mov dword ptr [ebp-8], edx  
                                    ; ++iter.X;
    lea eax, [ebp-0Ch]  
    inc dword ptr [eax]  
                                    ; if(iter.X >= countX)
    cmp dword ptr [ebp-0Ch], 40h  
    jl  LOOP  
                                    ; {
                                    ;     break;
                                    ; } } } }
                                    ; return result;
mov eax, ecx  
mov esp, ebp  
                                    ;  {
                                    ;      break;
                                    ;  } } } }
                                    ;  return result;
pop ebp  
ret  

这里你会明显注意到很多原始内存访问,它们由方括号([])标识,它们也有标签dword ptr,不用太担心什么这意味着,但只需将其视为 Memory Access。你会注意到这里的代码充满了它们。它们无处不在,可以从结构中访问值。

这就是结构代码慢得多的原因,寄存器就在CPU旁边(字面上在里面),但内存很远,即使它在[=43中=]缓存它仍然会比访问寄存器慢得多。