【问题标题】:C# foreach loop comically slower than for loop on a RaspberryPiC# foreach 循环比 Raspberry Pi 上的 for 循环慢得多
【发布时间】:2022-01-04 09:57:09
【问题描述】:

我在 RaspberryPi 上测试 .NET 应用程序,而该程序的每次迭代在 Windows 笔记本电脑上需要 500 毫秒,而在 RaspberryPi 上同样需要 5 秒。经过一些调试,我发现大部分时间都花在了foreach 循环连接字符串上。

编辑 1: 澄清一下,我提到的 500 毫秒和 5 秒时间是整个循环的时间。我在循环之前放置了一个计时器,并在循环完成后停止计时器。而且,两者的迭代次数相同,都是 1000。

编辑 2:为了计时循环,我使用了提到的答案here

private static string ComposeRegs(List<list_of_bytes> registers)
{
    string ret = string.Empty;
    foreach (list_of_bytes register in registers)
    {
        ret += Convert.ToString(register.RegisterValue) + ",";
    }
    return ret;
}

出乎意料的是,我用for 循环替换了foreach,突然之间它开始花费几乎与笔记本电脑相同的时间。 500 到 600 毫秒。

private static string ComposeRegs(List<list_of_bytes> registers)
{
    string ret = string.Empty;
    for (UInt16 i = 0; i < 1000; i++)
    {
        ret += Convert.ToString(registers[i].RegisterValue) + ",";
    }
    return ret;
}

我是否应该始终使用for 循环而不是foreach?或者这只是for 循环比foreach 循环快得多的场景?

【问题讨论】:

  • spent in a foreach loop concatenating strings. 是你的问题,而不是 forforeach。字符串是不可变的。修改或连接字符串会创建一个新字符串。您的循环创建了 2000 个需要进行垃圾回收的临时对象。这个过程是昂贵的。改用 StringBuilder,最好使用 capacity 大致等于预期字符串的大小
  • 至于为什么会有这样的差异,你确定有吗?你实际测量了什么?您确定 GC 在测试期间没有运行吗?要获得有意义的数字,请使用 BenchmarkDotNet 将每个代码运行足够多次,以便获得稳定的结果考虑 GC 和分配
  • 两种方法的另一个明显区别是,第二个在 1000 个项目后中止,无论列表中有多少个,如果少于 1000 个则爆炸。第一个总是处理整个列表,因此根据列表中有多少项目,他们可能做的工作量可能大不相同。
  • 那么测试是错误的,完全容易受到 GC 延迟的影响。你不是在衡量你认为你是什么。使用 BenchmarkDotNet 获得有意义的结果
  • 同时为StringBuilder添加另一个测试。我怀疑你会感到惊讶。仅 1000 个项目的 500 毫秒 极其缓慢,令人难以置信的慢!!!!!!!一个树莓派有一个1+GHz的核心,怎么格式化1000个项目要花这么多时间?这些数据太少了,它甚至应该放入 RPi 的 CPU 缓存中!没关系 Windows 机器。

标签: c# .net raspberry-pi raspberry-pi4


【解决方案1】:

实际问题是连接字符串,forforeach 之间没有区别。即使在 Raspberry Pi 上,报告的时间也极其缓慢。 1000 项数据太少了,它可以容纳在任何一台机器的 CPU 缓存中。一个 RPi 有一个 1+ GHZ CPU,这意味着每个连接至少需要 1000 个周期。

问题在于串联。字符串是不可变的。修改或连接字符串会创建一个新字符串。您的循环创建了 2000 个需要进行垃圾回收的临时对象。这个过程是昂贵的。请改用 StringBuilder,最好使用大致等于预期字符串大小的 capacity

    [Benchmark]
    public string StringBuilder()
    {
        var sb = new StringBuilder(registers.Count * 3);
        foreach (list_of_bytes register in registers)
        {
            sb.AppendFormat("{0}",register.RegisterValue);
        }
        return sb.ToString();
    }

仅测量一次执行,甚至平均 10 次执行,都不会产生有效数字。 GC 很有可能在其中一次测试期间收集这 2000 个对象。也很可能其中一项测试因 JIT 编译或任何其他原因而延迟。测试应该运行足够长的时间以产生稳定的数字。

.NET 基准测试的事实标准是BenchmarkDotNet。该库将运行每个基准测试足够长的时间,以消除启动和冷却效应并考虑内存分配和 GC 收集。您不仅会看到每个测试需要多少时间,还会看到使用了多少 RAM 以及导致了多少 GC

要实际测量您的代码,请尝试使用 BenchmarkDotNet 使用此基准测试:

[MemoryDiagnoser]
[MarkdownExporterAttribute.StackOverflow]
public class ConcatTest
{

    private readonly List<list_of_bytes> registers;


    public ConcatTest()
    {
        registers = Enumerable.Range(0,1000).Select(i=>new list_of_bytes(i)).ToList();
    }

    [Benchmark]
    public string StringBuilder()
    {
        var sb = new StringBuilder(registers.Count*3);
        foreach (var register in registers)
        {
            sb.AppendFormat("{0}",register.RegisterValue);
        }
        return sb.ToString();
    }

    [Benchmark]
    public string ForEach()
    {
        string ret = string.Empty;
        foreach (list_of_bytes register in registers)
        {
            ret += Convert.ToString(register.RegisterValue) + ",";
        }
        return ret;
    }

    [Benchmark]
    public string For()
    {
        string ret = string.Empty;
        for (UInt16 i = 0; i < registers.Count; i++)
        {
            ret += Convert.ToString(registers[i].RegisterValue) + ",";
        }
        return ret;
    }

}

通过调用 BenchmarkRunner.Run&lt;ConcatTest&gt;() 运行测试

using System.Text;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Linq;

public class Program
{
    public static void Main(string[] args)
    {
        var summary = BenchmarkRunner.Run<ConcatTest>();
        Console.WriteLine(summary);
    }
}

结果

在 Macbook 上运行它会产生以下结果。请注意,BenchmarkDotNet 生成的结果可以在 StackOverflow 中使用,并且运行时信息包含在结果中:

BenchmarkDotNet=v0.13.1, OS=macOS Big Sur 11.5.2 (20G95) [Darwin 20.6.0]
Intel Core i7-8750H CPU 2.20GHz (Coffee Lake), 1 CPU, 12 logical and 6 physical cores
.NET SDK=6.0.100
  [Host]     : .NET 6.0.0 (6.0.21.52210), X64 RyuJIT
  DefaultJob : .NET 6.0.0 (6.0.21.52210), X64 RyuJIT


        Method |      Mean |    Error |   StdDev |    Gen 0 |   Gen 1 | Allocated |
-------------- |----------:|---------:|---------:|---------:|--------:|----------:|
 StringBuilder |  34.56 μs | 0.682 μs | 0.729 μs |   7.5684 |  0.3052 |     35 KB |
       ForEach | 278.36 μs | 5.509 μs | 5.894 μs | 818.8477 | 24.4141 |  3,763 KB |
           For | 268.72 μs | 3.611 μs | 3.015 μs | 818.8477 | 24.4141 |  3,763 KB |

ForForEach 占用的内存几乎是 StringBuilder 的 10 倍,并使用了 100 倍的 RAM

【讨论】:

    【解决方案2】:

    如果字符串可以像您的示例中那样更改,那么使用 StringBuilder 是更好的选择,并且可以帮助您处理问题。

    修改任何字符串对象都会导致创建一个新的字符串对象。这使得字符串的使用成本很高。所以当用户需要对字符串进行重复操作时,StringBuilder 的需求就应运而生了。它提供了处理重复和多个字符串操作操作的优化方法。它代表一个可变的字符串。可变的意思是可以改变的字符串。所以 String 对象是不可变的,但 StringBuilder 是可变的字符串类型。它不会创建当前字符串对象的新修改实例,而是在现有字符串对象中进行修改。

    因此,不要创建许多需要进行垃圾收集并意味着占用大量内存的临时对象,只需使用StringBuilder

    更多关于StringBuilder - https://docs.microsoft.com/en-us/dotnet/api/system.text.stringbuilder?view=net-6.0

    【讨论】:

    • 这并不能解释差异。两个循环都同样慢。测量代码很可能是测量错误的东西
    • “但在现有字符串对象中进行修改” - 不正确。没有现有的字符串对象。
    • "StringBuilder 是可变字符串类型" - 这也不是真的。
    • “创建许多需要垃圾收集的临时对象” - 这是问题还是首先创建临时对象是问题?换句话说,如果没有GC,还会需要很长时间吗?
    猜你喜欢
    • 1970-01-01
    • 2014-01-21
    • 2013-06-02
    • 2017-01-29
    • 1970-01-01
    • 2021-01-10
    • 2019-09-29
    • 1970-01-01
    相关资源
    最近更新 更多