LINQ 内部结构:速度优化

LINQ 内部结构:速度优化

编码文章call10242025-06-18 14:58:321A+A-


LINQ 优雅的外观背后隐藏着一个精心设计的核心,其设计不仅仅是为了简单。 本文深入探讨 LINQ 的技术复杂性,重点关注提高其执行效率的“速度优化”。 这些优化也有很多缺点。

LINQ

LINQ 是“语言集成查询”的缩写。 它的名字来自于直接用 C# 编写查询的能力:

var query =
    from person in people
    where person.Age > 20
    select person.Name;

优点是所有查询组件都可以在编译时由编译器验证,而不是让应用程序在运行时失败。 还支持自动完成。

SharpLab 是一个很好的工具,可以查看编译器对代码的实际操作。 您可以看到查询实际上已转换为与以下内容等效的内容:

var query = 
  Enumerable.Select(Enumerable.Where(people, person => person.Age > 20), person => person.Name);

Enumerable 是在 System.Linq 命名空间中声明的静态类。 这意味着它的所有方法都是静态的,包括此查询中使用的 Select() 和Where() 方法。

注意:Enumerable 是一个包含数百个方法的庞大类。 这实际上是使用partial 关键字的一个很好的例子。 这允许类被分成多个文件。 请随时在
https://github.com/dotnet/dotnet/tree/main/src/runtime/src/libraries/System.Linq/src/System/Linq 检查源代码

Enumerable 中的所有方法实际上都被声明为 IEnumerable<T> 的扩展方法。 这意味着查询可以重写如下:

var query = people
    .Where(person => person.Age > 20)
    .Select(person => person.Name);

这样更容易理解。 people 是一个实现 IEnumerable<Person> 的集合,然后Where() 应用谓词函数并返回实现 IEnumerable<T> 的对象的实例,然后 Select() 应用选择器函数并返回实现 IEnumerable<T> 的另一个对象的实例 IEnumerable<字符串>。 这意味着查询将保留对 Select() 返回的 IEnumerable<string> 的引用。

这里重要的是 Enumerable 类中声明的方法支持 LINQ。 LINQ 的性能是由这些方法的性能定义的。 在本文中,我将尝试解释 LINQ 中用于提高其性能的许多技巧。

IQueryable<T>

LINQ 实际上提供了另一种查询机制,该机制由 IQueryable<T> 接口支持,该接口也在 System.Linq 命名空间中定义。 在这种情况下,查询被委托给提供者,该提供者在运行时将查询转换为表达式树,然后生成可由其他引擎解释的查询。

您可能会发现多个“LINQ to SQL”提供程序将查询转换为可由数据库执行的 SQL 查询。 您还可能会找到其他提供程序,例如 LinqToExcel、LinqToTwitter、LinqToCsv、LINQ-to-BigQuery、
LINQ-to-GameObject-for-Unity、ElasticLINQ、GpuLinq 等等。 它们可以在非常不同的引擎中执行相同的查询。

使用提供程序时,查询在其他地方执行,性能取决于许多因素。 因此,这种类型的查询执行超出了本文的讨论范围。

隐式枚举器崩溃

LINQ 操作是可组合的,这意味着一个操作的输出可以是另一个操作的输入。 我们已经在上面看到 Select() 可以在Where()之后使用。

在没有任何优化的情况下,Where() 和 Select() 的实现类似于以下内容:

public static IEnumerable<TSource> Where<TSource>(
    this IEnumerable<TSource> source, 
    Func<TSource, bool> predicate)
{
    foreach(var item in source)
    {
        if(predicate(item))
            yield return item;
    }
}


public static IEnumerable<TResult> Select<TSource, TResult>(
    this IEnumerable<TSource> source, 
    Func<TSource, TResult> selector)
{
    foreach(var item in source)
    {
        yield return selector(item);
    }
}     

每个操作都会返回一个可枚举值。 这意味着要遍历其组合结果,必须创建每个枚举器的实例,必须从枚举器“拉”每个项目,该枚举器从另一个枚举器“拉”该项目,后者从源“拉” 枚举器。 操作组合是使用 LINQ 的一大优势,但也是最大的性能问题之一。 幸运的是,在某些情况下可以避免这种情况。

在Where()之后使用Select()是一种非常常见的使用模式。 为了提高性能,Where() 返回的枚举具有 Select() 的重载,该重载返回一个采用谓词和选择器作为参数的枚举。 这相当于执行以下命令:

public static IEnumerable<TResult> WhereSelect<TSource, TResult>(
    this IEnumerable<TSource> source, 
    Func<TSource, bool> predicate, 
    Func<TSource, TResult> selector)
{
    foreach(var item in source)
    {
        if(predicate(item))
            yield return selector(item);
    }
}

它在一个 foreach 循环中同时应用谓词和选择器。 这意味着减少了一层枚举器来“拉动”项目,从而获得更好的性能。

注意:查看我的另一篇文章“高效数据处理:利用 C# 的 foreach 循环”以了解 foreach 的工作原理。

如果您碰巧连续有两个Where() 或两个Select() 操作,也会发生同样的情况。 这相当于执行以下命令之一:

public static IEnumerable<TSource> Where<TSource>(
    this IEnumerable<TSource> source, 
    Func<TSource, bool> predicate1, 
    Func<TSource, bool> predicate2)
    => source.Where(item => predicate1(item) && predicate2(item));
    
public static IEnumerable<TResult> Select<TSource, TMiddle, TResult>(
    this IEnumerable<TSource> source, 
    Func<TSource, TMiddle> selector1, 
    Func<TMiddle, TResult> selector2)
    => source.Select(item => selector2(selector1(item)));

所有这些枚举器优化都是隐式发生的。 您无需更改代码中的任何内容即可使它们发生。

显式枚举器崩溃

在某些情况下,您必须显式使用某个重载,以便折叠枚举器。 First()、FirstOrDefault()、Single()、SingleOrDefault()、Last()、LastOrDefault()、Any() 和 Count() 等操作就是这种情况。 所有这些操作都有一个以谓词作为参数的重载。 它与使用相同谓词在无参数扩展之前使用Where() 具有完全相同的结果。 这意味着, source.Where(predicate).Count() 的结果与 source.Count(predicate) 相同,但第二个可能具有更好的性能。

以下是 Count() 操作的可能实现(无需其他优化):

public static int Count<TSource>(
    this IEnumerable<TSource> source
{
    var count = 0;
    using(var enumerator = source.GetEnumerator())
    {
        checked
        {
            while(enumerator.MoveNext())
                count++;
        }
    }
    return count;
}

public static int Count<TSource>(
    this IEnumerable<TSource> source, 
    Func<TSource, bool> predicate)
{
    var count = 0;
    foreach(var item in source)
    {
        checked
        {
            if(predicate(item))
                count++;
        }
    }
    return count;
})

第二个重载使用一个 foreach 来进行过滤和求和。 这可以避免使用枚举器来执行谓词。

请注意,隐式折叠对于这些重载不起作用。

源类型

所有 LINQ 方法都是 IEnumerable<T> 的扩展方法。 正如我在之前的几篇文章中已经解释过的,就遍历集合时的性能而言,IEnumerable<T> 是最糟糕的情况。 有多种集合类型可以提供更好的性能。

LINQ 库假定源集合可能已转换为不同的类型。 例如,List<T> 可以转换为 IEnumerable<T>。 例如,通过这样做,就不可能显式调用 Count 属性来查找它包含的项目数。

例如,要查找集合是否为空,我们应该使用 Any() 操作。 没有优化的情况下,其实现如下:

public static bool Any<TSource>(this IEnumerable<TSource> source)
{
    using(var enumerator = source.GetEnumerator())
    {
        return enumerator.MoveNext();
    }
}

它必须创建枚举器的实例,然后调用 MoveNext() 方法。 如果该方法返回 true,则意味着它至少包含一项。

许多集合(例如 List<T>)在内部存储项目数。 对于数组和跨度,通常可以使用 Length 属性来检索,或者在其他情况下使用 Count 属性来检索。 这些集合通常实现提供 Count 属性的接口 IReadOnlyCollection<T> 和 ICollection<T>。 调用这些属性应该比使用枚举器更快。

注意:没有任何 LINQ 操作检查 IReadOnlyCollection<T> 和 IReadOnlyList<T> 类型。 正如我在之前的文章中所解释的,实现这些接口的集合还必须分别实现 ICollection<T> 和 IList<T>,以便 LINQ 可以优化它们的遍历。

如果检查 Any() 的实现,您会发现它调用内部方法 TryGetNonEnumeratedCount() 来查找集合是否提供 Count 属性。 然后,此方法检查源集合是否为 null,以及它是否实现 ICollection()、IIListProvider<T> 或 ICollection。 如果该方法返回 true,则 Any() 使用 Count 属性,否则使用枚举器。 这一切都是在运行时完成的,消耗CPU时间。

注意:Iterator<T>、IIListProvider<T> 和 IPartition<T> 是内部接口,只有内部枚举可以实现,因此只有这些可以利用这些优化。

此初始化时间在 Any() 等操作中或当集合中的项目很少时最为明显。 对于包含大量项目的集合,这种初始化时间是非常值得的,因为在每个项目上节省少量时间将为完全遍历集合节省大量时间。

这些优化带来了很多问题。 首先,并非所有方法都具有这些优化,并且具有这些优化的方法不一致且没有记录。 您必须检查每个操作及其组合的源代码。

值类型枚举器

对于源是 List<T> 的情况,Where() 使用自定义WhereListIterator<T>。 这个“迭代器”保证 List<T> 提供的值类型枚举器不被装箱。

注意:查看我的另一篇文章“C# 中值类型与引用类型枚举器的性能”,了解为什么值类型枚举器很重要。

另一方面,.NET 提供的所有集合确实都有值类型枚举器,但 LINQ 不会优化它们,因为此解决方案不是通用的,它仅适用于 List<T>。

此外,LINQ 不提供任何值类型枚举器,因为所有返回可枚举值的操作都返回 IEnumerable<T>,而不是显式枚举器类型。

通用数学

在 .NET 中实现数学运算一直是一个挑战,因为数学运算符无法在接口中定义。 这导致 LINQ 必须为 .NET 提供的每种数字类型提供 Sum() 和 Average() 的特定实现。

不幸的是,.NET 8 微弱地尝试使用这个新功能。 它仅在内部使用它,并且仍然提供数字类型的所有重载。 这意味着第三方数字类型仍然无法有效地使用 Sum() 和 Average() 方法。

注意:查看我的另一篇文章“.NET 中的通用数学”,了解如何针对您自己的数字类型进行优化。

SIMD

.NET 8 中的 LINQ 最终使用了 SIMD。 不幸的是,它的使用场景非常有限:

Sum() 但仅当项目类型为 int 或 long 时。

Average() 但仅当项目类型为 int 时。

不幸的是,只有这些场景才能保证处理 NaN 和 Infinite 时的向后兼容性。

注意:查看我的另一篇文章“.NET 中的单指令、多数据 (SIMD)”,了解什么是 SIMD 以及如何在自己的代码中使用它。

结论

IEnumerable<T> 提供的抽象使 LINQ 成为一个出色的库,以便可以轻松实现和维护数据处理。 同时,它也是 .NET 应用程序性能不足的最大原因。

.NET 性能的提高主要归功于 JIT 编译器的改进。 该框架还通过新的 API 进行了改进。 然而,这些不能用于保持 LINQ 100% 向后兼容。 很多内部优化与最新进展不一致。

点击这里复制本文地址 以上内容由文彬编程网整理呈现,请务必在转载分享时注明本文地址!如对内容有疑问,请联系我们,谢谢!
qrcode

文彬编程网 © All Rights Reserved.  蜀ICP备2024111239号-4