【问题标题】:Why is this LINQ query incredibly slow?为什么这个 LINQ 查询非常慢?
【发布时间】:2019-11-20 16:47:28
【问题描述】:

我编写了以下代码来将字节数组 data 转换为字符串数组 hex,每个条目包含 32 个字节作为十六进制字符串,以将它们写入文件。

byte[] data = new byte[4*1024*1024];
string[] hex = data.Select((b) => b.ToString("X2")).ToArray();
hex = Enumerable.Range(0, data.Length / 32).Select((r) => String.Join(" ", hex.Skip(r * 32).Take(32))).ToArray();  // <= This line takes forever

问题是尽管生成的文件小于 20MB,但它需要几分钟(!)才能完成。于是我尝试对其进行优化,得出以下结论:

byte[] data = new byte[4*1024*1024];
string[] hex = new string[4*1024*1024/32];
for (var i = 0; i <= hex.Length - 1; i++)
{
    var sb = new System.Text.StringBuilder();
    sb.Append(data[i * 32].ToString("X2"));
    for (var k = 1; k <= 32 - 1; k++)
    {
        sb.Append(' ');
        sb.Append(data[i * 32 + k].ToString("X2"));
    }
    hex[i] = sb.ToString();
}

此版本的功能相同,但速度要快几个数量级(133 毫秒对 8 分钟)。 我的问题是我真的不明白为什么原来的版本这么慢。我查看了String.Join()source,它看起来与我的改进版本非常相似。 我喜欢将 LINQ 用于这些想法,因为你可以很容易地解决各种问题,而且我认为它在大多数情况下都很有效,因为它是惰性求值。所以我想知道我在这里缺少什么来改进我未来对 LINQ 的使用。

另一方面,我不知道它可能会写得更快,但这真的不是重点,因为第二个版本对于仅用于调试目的的函数来说已经足够快了。

【问题讨论】:

  • 您不能运行性能查看器来查看占用时间的地方吗?乍一看,这可能是字符串和字符串生成器之间的区别,我认为在第一个示例中,您还有更多循环,但如果不进行深入分析,则很难说
  • 由于嵌入了 Skip().Take(),Linq 查询将一遍又一遍地迭代 data。另外我认为您打算改用hex。另一种方法是使用Select,其中包括索引和索引上的组除以 32 以获得每个 32 块,然后遍历这些结果并使用字符串生成器。
  • @MarkDavies 目前我怀疑我对 LINQ 的理解,并正在寻找某种一般性的答案。除此之外,我确实进行了一些测试。我很确定大部分时间都花在了String.Join(),但我不知道为什么在这种情况下它会这么慢。这对我来说很重要,因为我经常使用String.Join(),并且想知道何时避免使用它。
  • 我的猜测是字符串是不可变的,string.join() 创建了新的字符串实例。时间浪费在创建新对象上,甚至可能正在清理它。看看在这两种情况下创建了多少对象会很有趣。
  • 啊,对;您在第二个示例中使用了 StringBuilder。

标签: c# .net performance linq


【解决方案1】:

我的问题是我真的不明白为什么原来的版本这么慢。

就是这个部分:

hex.Skip(r * 32)

.Skip() 必须遍历序列。它不会直接跳转到正确的索引。换句话说,对于数组中的每 32 个字节,您从头开始重新遍历整个数组,直到到达当前块的开头。这是Shlemiel the Painter 的情况。

您还可以通过使用ArraySegment 类型、Array.Copy()Span&lt;string&gt; 使原始代码更快。您还可以编写自己的类似 linq 的 "Chunk()" 运算符,以从原始 IEnumerable 返回 32 字节序列,或者使用这个非常简单的 Segment() 方法:

public static IEnumerable<T> Segment<T>(this T[] original, int start, int length)
{
    length = start + length;
    while (start < length) 
        yield return original[start++];
}

这会将原始代码更改为如下所示:

byte[] data = new byte[4*1024*1024];
string[] hex = data.Select((b) => b.ToString("X2")).ToArray();
hex = Enumerable.Range(0, data.Length / 32).Select((r) => String.Join(" ", hex.Segment(r * 32,32))).ToArray();

为了好玩,使用我之前链接的Chunk() 实现:

byte[] data = new byte[4*1024*1024];
var hex = data.Select(b => b.ToString("X2"))
              .Chunk(32)
              .Select(c => string.Join(" ", c))
              .ToArray(); //only call ToArray() if you *really* need the array. Often the enumerable is enough.

另一个使用String.Create()的有趣选项

byte[] data = new byte[4*1024*1024];
char[] hexChars = { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
                    'A', 'B', 'C', 'D', 'E', 'F' };
var hex = data.Chunk(32)
      .Select(c => string.Create(95, c, (r, d) => {
          int i = 0;
          foreach(byte b in d)
          {
             r[i*3] = hexChars[((b & 0xf0) >> 4)];
             r[(i*3) + 1] = hexChars[(b & 0x0f)];
             if (i*3 < 92) r[(i*3) + 2] = ' ';
             i++;
         }
      }))
      .ToArray();

您还应该看看这个BitConverter.ToString() 重载。

我很想看看这些基准如何。

【讨论】:

  • 你是对的!我一直认为 LINQ 更聪明一点,并且以不同的方式处理数组。但显然它并没有像对待任何其他 Enumerable&lt;T&gt; 一样对待它。
  • 在某些情况下,它可以区别对待数组。但不是这个。
  • 遗憾的是它无论如何都不会这样做。我刚刚查看了源代码,SkipIterator 是最愚蠢的......
  • Skip() 是愚蠢的。有些人更聪明。
  • 我最终使用了ArraySegment&lt;T&gt;。这样我就可以使用几乎没有任何变化的 LINQ 版本,性能甚至比我的第二个版本好一点。
【解决方案2】:

.NET FrameworkTake 实现不包括对IList 类型的源的任何优化,因此在重复调用大型列表或数组时会变得非常慢。 .NET Core includes these optimizations 的相应实现,所以它的性能相当不错(与手动编码的循环相当)。

【讨论】:

  • 就我而言,Skip() 是问题所在。在 .NET Core 中,Skip() 也有 this optimization。在 .NET Framework 中则没有。
  • @Karsten 你是对的。我经常将SkipTake 混淆。 :-)
猜你喜欢
  • 2011-10-19
  • 2013-07-09
  • 2011-03-11
  • 1970-01-01
  • 1970-01-01
  • 2020-03-19
  • 2014-03-12
  • 2011-02-28
  • 1970-01-01
相关资源
最近更新 更多