为啥在 C# 中的二维数组中按列写入速度很慢

Posted

技术标签:

【中文标题】为啥在 C# 中的二维数组中按列写入速度很慢【英文标题】:Why writing by column is slow in two dimensional array in C#为什么在 C# 中的二维数组中按列写入速度很慢 【发布时间】:2021-04-06 19:32:01 【问题描述】:

当我按列添加值时,我有一个二维数组,它的写入速度非常慢(小于 300 倍):

class Program
    
        static void Main(string[] args)
        
            TwoDimArrayPerfomrance.GetByColumns();
            TwoDimArrayPerfomrance.GetByRows();
        
    

    class TwoDimArrayPerfomrance
    

        public static void GetByRows()
        
            int maxLength = 20000;
            int[,] a = new int[maxLength, maxLength];
            DateTime dt = DateTime.Now;
          
            Console.WriteLine("The current time is: " + dt.ToString());

            //fill value
            for (int i = 0; i < maxLength; i++)
            
                for (int j = 0; j < maxLength; j++)
                

                    a[i, j] = i + j;
                
            


            DateTime end = DateTime.Now;
            Console.WriteLine("Total: " + end.Subtract(dt).TotalSeconds);

        

        public static void GetByColumns()
        
            int maxLength = 20000;
            int[,] a = new int[maxLength, maxLength];
            DateTime dt = DateTime.Now;
            Console.WriteLine("The current time is: " + dt.ToString());
            for (int i = 0; i < maxLength; i++)
            
                for (int j = 0; j < maxLength; j++)
                

                    a[j, i] = j + i;
                
            
            DateTime end = DateTime.Now;
            Console.WriteLine("Total: " + end.Subtract(dt).TotalSeconds);

        

    

立柱虎钳大约需要 4.2 秒 而按行取 1.53

【问题讨论】:

缓存接近度可能...您是否在没有调试器的情况下在发布模式下运行程序? (Visual Studio 中的 CTRL+F5)?尝试做TwoDimArrayPerfomrance.GetByColumns(); TwoDimArrayPerfomrance.GetByRows(); TwoDimArrayPerfomrance.GetByColumns(); TwoDimArrayPerfomrance.GetByRows(); 看看是否是热身问题?什么版本的 .NET Core/.NET Framework 和 32 位或 64 位?微基准测试是一门艺术,一门复杂的艺术。 啊,bidi 阵列就像 90 年代的风格......不再流行。建议使用交错数组(int[][]) 请使用StopWatch而不是DateTime来衡量花费的时间。 @DmitryBychenko 谢谢我现在使用秒表,但仍然在同一时间使用 @xanatos 当我第二次调用它时,第二次迭代需要 3 秒而不是 4. 【参考方案1】:

这是第一条评论中提到的“缓存邻近”问题。有内存缓存,任何数据都必须经过 CPU 才能访问。这些缓存存储内存块,因此如果您首先访问内存 N 然后访问内存 N+1,则缓存不会更改。但是,如果您首先访问内存 N,然后访问内存 N+M(其中 M 足够大),则必须将新的内存块添加到缓存中。当您将新块添加到缓存时,必须删除一些现有块。如果您随后必须访问此已删除的块,那么您的代码效率低下。

【讨论】:

【参考方案2】:

我完全同意@Dialectus 所写的...我只想补充一点,编写微基准测试有一些不好的方法,还有更糟糕的方法。进行微基准测试时有很多事情要做。记住在没有附加调试器的情况下以Release模式运行,记住有一个GC,最好在你想要它运行的时候运行,而不是在你进行基准测试时随便运行,记住有时代码只有在它至少执行一次,因此至少进行一轮全面预热是个好主意……等等……甚至还有一个完整的基准测试库 (https://benchmarkdotnet.org/articles/overview.html),供 Microscot .NET Core 团队使用检查他们编写的代码是否没有速度回归。

class Program

    static void Main(string[] args)
    
        if (Debugger.IsAttached)
        
            Console.WriteLine("Warning, debugger attached!");
        

#if DEBUG
        Console.WriteLine("Warning, Debug version!");
#endif

        Console.WriteLine($"Running at (Environment.Is64BitProcess ? 64 : 32)bits");

        Console.WriteLine(RuntimeInformation.FrameworkDescription);

        Process.GetCurrentProcess().PriorityClass = ProcessPriorityClass.High;

        Console.WriteLine();

        const int MaxLength = 10000;

        for (int i = 0; i < 10; i++)
        
            Console.WriteLine($"Round i + 1:");

            TwoDimArrayPerfomrance.GetByRows(MaxLength);
            GC.Collect();
            GC.WaitForPendingFinalizers();

            TwoDimArrayPerfomrance.GetByColumns(MaxLength);
            GC.Collect();
            GC.WaitForPendingFinalizers();

            Console.WriteLine();
        
    


class TwoDimArrayPerfomrance


    public static void GetByRows(int maxLength)
    
        int[,] a = new int[maxLength, maxLength];

        Stopwatch sw = Stopwatch.StartNew();

        //fill value
        for (int i = 0; i < maxLength; i++)
        
            for (int j = 0; j < maxLength; j++)
            

                a[i, j] = i + j;
            
        

        sw.Stop();

        Console.WriteLine($"By Rows, size maxLength * maxLength, sw.ElapsedMilliseconds / 1000.0:0.00 seconds");

        // So that the assignment isn't optimized out, we do some fake operation on the array
        for (int i = 0; i < maxLength; i++)
        
            for (int j = 0; j < maxLength; j++)
            
                if (a[i, j] == int.MaxValue)
                
                    throw new Exception();
                
            
        
    

    public static void GetByColumns(int maxLength)
    
        int[,] a = new int[maxLength, maxLength];

        Stopwatch sw = Stopwatch.StartNew();

        //fill value
        for (int i = 0; i < maxLength; i++)
        
            for (int j = 0; j < maxLength; j++)
            

                a[j, i] = i + j;
            
        

        sw.Stop();

        Console.WriteLine($"By Columns, size maxLength * maxLength, sw.ElapsedMilliseconds / 1000.0:0.00 seconds");

        // So that the assignment isn't optimized out, we do some fake operation on the array
        for (int i = 0; i < maxLength; i++)
        
            for (int j = 0; j < maxLength; j++)
            
                if (a[i, j] == int.MaxValue)
                
                    throw new Exception();
                
            
        
    

啊... .NET 3.5 的 FooType[,] went the way of the dodo 类型的多维数组,当 LINQ 出现但它不支持它们时。你应该使用锯齿状数组FooType[][]

【讨论】:

【参考方案3】:

如果您尝试将二维数组映射到一维数组,可能会更容易看到发生了什么。

映射给出

var a = int[maxLength * maxLength];

现在查找计算由您决定。

for (int i = 0; i < maxLength; i++)

    for (int j = 0; j < maxLength; j++)
    
        //var rowBased = j + i * MaxLength;
        var colBased = i + j * MaxLength;

        //a[rowBased] = i + j;
        a[colBased] = i + j;
    

所以请注意以下事项

在基于列的查找中,乘法的次数是 20.000 * 20.000 次,因为 j 每个循环都会发生变化 在基于行的查找中,i * MaxLength 经过编译器优化,仅发生 20.000 次。 既然 a 是一个一维数组,那么还可以更轻松地查看内存是如何被访问的。在基于行的索引上,内存是按顺序访问的,而基于列的访问几乎是随机的,并且根据数组的大小,开销会有所不同。

看看 BenchmarkDotNet 产生了什么

BenchmarkDotNet=v0.12.1, OS=Windows 10.0.19042
AMD Ryzen 9 3900X, 1 CPU, 24 logical and 12 physical cores
.NET Core SDK=5.0.101
Method MaxLength Mean Error StdDev
GetByRows 100 23.60 us 0.081 us 0.076 us
GetByColumns 100 23.74 us 0.357 us 0.334 us
GetByRows 1000 2,333.20 us 13.150 us 12.301 us
GetByColumns 1000 2,784.43 us 10.027 us 8.889 us
GetByRows 10000 238,599.37 us 1,592.838 us 1,412.009 us
GetByColumns 10000 516,771.56 us 4,272.849 us 3,787.770 us
GetByRows 50000 5,903,087.26 us 13,822.525 us 12,253.308 us
GetByColumns 50000 19,623,369.45 us 92,325.407 us 86,361.243 us

您会看到,虽然 MaxLength 相当小,但差异几乎可以忽略不计 (100x100) 和 (1000x1000),因为我希望 CPU 可以将分配的二维数组保持在快速访问中内存缓存,差异仅与乘法次数有关。

当矩阵变大时,CPU 无法再将所有分配的内存保留在其内部缓存中,我们将开始看到缓存未命中并从外部内存存储中获取内存,这总是很多慢一点。

开销会随着矩阵大小的增加而增加。

【讨论】:

这可能是解决问题的绝佳方法,但我的问题是为什么需要更多时间?背后的逻辑。不管怎样,我会试试的。 这需要更长的时间,因为 1) 基于列的数组查找必须进行 4 亿次乘法运算,而基于行的运算需要 20.000 次。 2) 基于行的内存访问是顺序的,需要更少的内存读取和 CPU 缓存失效。

以上是关于为啥在 C# 中的二维数组中按列写入速度很慢的主要内容,如果未能解决你的问题,请参考以下文章

C语言数组为啥按行优先存储

如何按列对二维数组(锯齿状)进行排序[重复]

c语言问题:c语言中二维数组在内存中怎样存储?

在 Excel C# 范围内写入一个二维对象数组

Python使用numpy函数hsplit水平(按列)拆分numpy数组(返回拆分后的numpy数组列表)实战:水平(按列)拆分二维numpy数组split函数水平(按列)拆分二维numpy数组

C# CS结构中 2005 如何将二维数组转化成DataSet