【问题标题】:[Optimize This]: Slow LINQ to Objects Query【优化】:LINQ to Objects 查询慢
【发布时间】:2011-01-14 09:41:37
【问题描述】:

我有一个困扰我的问题;它被封装为一个新的查询运算符,我做了两个版本,试图看看哪个版本更好。两者的表现都很糟糕。

第一次尝试;声明式风格

public static IEnumerable<IEnumerable<α>> Section<α>(this IEnumerable<α> source, int length)
{
    return source.Any()
        ? source.Take(length).Cons(source.Skip(length).Section(length))
        : Enumerable.Empty<IEnumerable<α>>();
}

第二次尝试:命令式“收益回报”风格

public static IEnumerable<IEnumerable<α>> Section<α>(this IEnumerable<α> source, int length)
{
    var fst = source.Take(length);
    var rst = source.Skip(length);

    yield return fst;

    if (rst.Any())
        foreach (var section in rst.Section(length))
            yield return section;
}

事实上,第二次尝试更糟糕,无论是在可读性、组合性还是速度方面。

关于如何优化它的任何线索?

【问题讨论】:

  • 我认为第二个更易读恕我直言
  • 您能否简要介绍一下您使用 Section 功能的目标是什么?看起来您尝试使用 IEnumerable 并对其进行枚举,每次都会获得一个带有长度项的新 IEnumerable,对吗?
  • Enumerate 是一种扩展方法,它只对自身产生收益,它是一种将值投影到 IEnumerable 中的简单方法;所以假设 x 是一个 int,然后 x.Enumerate() 创建一个 IEnumerable 类型的值,它只有一个值。
  • 目标是创建一个排序矩阵,所以如果你有这个 [1, 2, 3, 4] 那么 xs.Section(2) 将产生 [[1, 2], [3 , 4]]
  • Enumerate + Concat 实际上是“Cons”,例如x.Enumerate().Concat(xs) => x.Cons(xs)

标签: c# linq performance optimization linq-to-objects


【解决方案1】:

只要有可能,我会尝试只在操作符内迭代一次源。如果源类似于Reverse() 运算符的结果,调用AnyTakeSkip 可能会导致很多令人讨厌的性能。

目前尚不完全清楚您的操作员要做什么,但如果您可以在不多次阅读源代码的情况下做到这一点,那可能会有所帮助 - 尽管这在很大程度上取决于输入的内容。

【讨论】:

  • 嗨,乔恩,我知道像 Reverse 和 Sum 这样的运算符在评估整个序列时很危险,但这并不是我正在做的。实际上 Reverse().Any() 应该很快,因为它应该可以在不实际反转集合的情况下实现它 - 然而不幸的是,这可能不是 LINQ to Objects 的工作方式。
  • Reverse().Any(..) 将是一种优化,它表示“我正在寻找的值是已知(或假设)更接近结束而不是开始,所以从最后看更快”,前提是 Reverse().Any() 实际上可以优化,在一般情况下,它不是。
  • 看起来很像来自 MoreLinq 的 Batch() 运算符。
  • 完全不是 Lasse,它会检查是否只有一个值(不关心在哪里或有多少),它会在未反转的枚举上执行此操作。
【解决方案2】:

如果我正确理解您的问题,您正在尝试构建一个惰性实现的枚举器,它将较大的项目集合拆分为较小的可枚举项目集合。

例如,一百万个数字的序列可以分成“部分”,每个部分只产生 100 个,而您希望这一切都懒惰地完成,即。在生成它们之前不要将 100 个项目收集到一个列表中。

首先,您的尝试将多次重复该集合,这很糟糕,因此会出现性能问题。

如果您尝试构建纯惰性实现,则应考虑以下问题:

  • 您只想遍历基础集合一次
  • 您应该返回可重用底层枚举器的枚举
  • 您需要处理您返回的部分未完全枚举(例如,调用代码只需要这 100 项中的前 50 项)。

编辑:在我进入我的简单解决方案之前,这里有一些关于它的警告:

  • 您不能保存每个部分以供以后使用,即。你不能这样做:collection.Sequence(10).ToArray() 来获取一组部分。
  • 您不能对每个部分进行多次枚举,因为这样做会更改隐藏的底层数据结构。

基本上:我的解决方案不是通用的。如果你需要这个,你应该使用@LBushkin关于MoreLinq Batch 的评论,我会犹豫将我的代码放入类库中,它必须在需要它的地方,或者重命名为清楚的东西警告您有关它的问题。


这是一个简单的实现,我很确定这里有错误,所以你可能想看看为边缘情况实现大量的单元测试:

using System;
using System.Collections.Generic;
using System.Linq;

namespace ConsoleApplication20
{
    class SectionEnumerable<T> : IEnumerable<T>
    {
        private readonly IEnumerator<T> _Enumerator;

        public SectionEnumerable(IEnumerator<T> enumerator, int sectionSize)
        {
            _Enumerator = enumerator;
            Left = sectionSize;
        }

        public IEnumerator<T> GetEnumerator()
        {
            while (Left > 0)
            {
                Left--;
                yield return _Enumerator.Current;
                if (Left > 0)
                    if (!_Enumerator.MoveNext())
                        break;
            }
        }

        System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator()
        {
            return GetEnumerator();
        }

        public int Left { get; private set; }
    }

    static class SequenceExtensions
    {
        public static IEnumerable<IEnumerable<T>> Section<T>(this IEnumerable<T> collection, int sectionSize)
        {
            if (collection == null)
                throw new ArgumentNullException("collection");
            if (sectionSize < 1)
                throw new ArgumentOutOfRangeException("sectionSize");

            using (IEnumerator<T> enumerator = collection.GetEnumerator())
            {
                while (enumerator.MoveNext())
                {
                    SectionEnumerable<T> enumerable = new SectionEnumerable<T>(enumerator, sectionSize);
                    yield return enumerable;
                    for (int index = 0; index < enumerable.Left; index++)
                        if (!enumerator.MoveNext())
                            yield break;
                }
            }
        }
    }

    class Program
    {
        static void Main(string[] args)
        {
            var sequence = Enumerable.Range(0, 100);
            var sections = sequence.Section(10);
            foreach (var section in sections)
            {
                Console.WriteLine(
                    String.Join(", ",
                    section.Take(5).ToArray().Select(i => i.ToString()).ToArray()));
            }
            Console.ReadLine();
        }
    }
}

输出:

0, 1, 2, 3, 4
10, 11, 12, 13, 14
20, 21, 22, 23, 24
30, 31, 32, 33, 34
40, 41, 42, 43, 44
50, 51, 52, 53, 54
60, 61, 62, 63, 64
70, 71, 72, 73, 74
80, 81, 82, 83, 84
90, 91, 92, 93, 94

你应该单元测试的东西:

  • 空输入集合不会产生任何部分
  • 元素数量恰到好处的集合,只产生一个部分
  • 包含多个节大小元素(即 10、20、30 等,节大小为 5 或 10 的元素)的集合在所有预期的
  • 那其实是懒惰的,如果你枚举了前10个元素的section,但是只枚举了第二个section的前5个,那么就只枚举了底层集合的前15个元素

【讨论】:

  • 嗯,我猜默认的标准查询运算符实现并不像我想的那么聪明。那时我可能不得不建立一些记忆。
  • 这看起来很像 MoreLinq 中的 Batch() 运算符;见:code.google.com/p/morelinq/source/browse/trunk/MoreLinq/…
  • @LBushkin,它很相似,只是我的实现非常懒惰,它在生成它之前不会收集完整的批次/部分,因此内存使用量很少,并且由于周期性“没有延迟”在继续之前收集一批/部分”。
  • 这被深深地破坏了。当你走出测试用例并做任何不同的事情时,你会得到疯狂的结果。例如:“var section = sequence.Section(10).ToArray(); foreach (var num in section[5]) Console.WriteLine(num);” -- 你希望得到 {50...59}。相反,您会得到 {99}。
  • 嗨,Eric,是的,我知道,正如我在回答中提到的,这只是基本的一次性本地使用。正如@LBushkin 所指出的,MoreLinq 中的批处理实现是正确的方法,但并不那么懒惰。这取决于他想要什么。
【解决方案3】:

这样更快吗?应该是,因为它只需要对源序列进行一次迭代。

public static IEnumerable<IEnumerable<T>> Section<T>(
    this IEnumerable<T> source, int length)
{
    return source
        .Select((x, i) => new { Value = x, Group = i / length })
        .GroupBy(x => x.Group, y => y.Value);
}

【讨论】:

  • 可能,但仍然比 Lasse 的自定义 IEnumerator 慢几个数量级。
  • 当然可以,但是如果您需要原始性能,那为什么还要使用 LINQ?在我看来,LINQ 是关于可读性的,而不是原始性能。当您需要简洁、易读的声明性代码时,请使用 LINQ。当您需要彻底的速度时,您可能应该完全避免使用 LINQ。
  • 对不起,我想要组合性、可读性和速度,我不想要任何牺牲。因此,即使像 Lasse 那样编写一个自定义的 IEnumerable 也比将 LINQ 交给我更可取。
【解决方案4】:

这是另一种不使用 linq 的方法,它比您的第二种方法快得多:

 public static IEnumerable<IEnumerable<a>> Section<a>(this IEnumerable<a> source, int length)
        {


            var enumerator = source.GetEnumerator();
            var continueLoop = true;
            do
            {
                var list = new List<a>();
                var index = 0;
                for (int i = 0; i < length; i++)
                {
                    if (enumerator.MoveNext())
                    {
                        list.Add(enumerator.Current);
                        index++;
                    }
                    else
                    {
                        continueLoop = false;
                        break;
                    }
                }
                if (list.Count > 0)
                {
                    yield return list;
                }
            } while (continueLoop);


        }

【讨论】:

  • +1 这与我在无法让 GroupBy 足够快地给我分区时编写的非惰性分区方法非常相似。
【解决方案5】:

我怀疑您遇到的问题与枚举最终结果至少是 O(n^2) 操作这一事实有关,可能更糟;我还没有把这一切都想清楚。

这是为什么呢?好吧,假设您有 [1, 2, 3, 4, 5, 6] 并将其拆分为您认为的 { { 1, 2 }, {3, 4}, {5, 6} }

这不是你所做的。实际上,您已将其拆分为 { 取前两个,取前两个并丢弃它们,然后取接下来的两个,取前两个并丢弃,然后取下两个并丢弃它们,然后取第三个两个}

注意沿途的每一步如何重新计算结果?这是因为数组可能在对枚举的调用之间发生变化。LINQ 旨在始终为您提供最新的结果。您编写的查询意味着“跳过前四个并迭代接下来的两个”,这正是您得到的——一个在您枚举时执行该代码的查询。

原始序列是否足够小且足够快,以至于您可以将整个内容读入内存并立即将其全部拆分,而不是懒惰地尝试这样做?或者,序列是否可索引?如果你得到的只是对序列的前向访问,并且一次读入内存太大或太慢,那么你可以在这里做很多事情。但是,如果您拥有这些属性中的一个或两个,那么您至少可以将其设为线性。

【讨论】:

  • 我喜欢为懒惰而设计,以保持灵活性。我也知道来源不会改变 - 不过现在这对我没有帮助。我将尝试反汇编 .Net 代码,并更深入地了解不同运算符和编译代码的实际作用。我想可能会有一些关于运算符组合的聪明才智。
  • 如何设计不变性?当你有一个序列时,你知道绝对不能改变;您不想针对这种将变得越来越普遍的场景优化 LINQ。我不知道该怎么做,也许有办法扩展 C# 编译器以在编译时专门处理 LINQ 查询。 - 或者创建一个自定义 LINQ 提供程序来优化不可变流,尽管会产生“JIT”“LINQ 编译”的开销。
  • @Bent:确实,我们正在研究这个主题。这是一个难题,我不知道我们是否真的会很快在这方面取得任何进展。
  • 是的,我也在想,在迭代器代码中,如果你有无副作用的步骤,那么 Enumerable.Skip 方法也可以跳过计算,完全绕过所有工作。但当然,它需要对迭代器和 LINQ to Objects 进行完全不同的处理。
  • 很好地解释了问题及其背后的原因......像往常一样:)
【解决方案6】:

我今天有个主意;看看这个

public static IEnumerable<α> Take<α>(this IEnumerator<α> iterator, int count)
{
    for (var i = 0; i < count && iterator.MoveNext(); i++)
        yield return iterator.Current;
}

public static IEnumerable<IEnumerable<α>> Section<α>(this IEnumerator<α> iterator, int length)
{
    var sct = Enumerable.Empty<α>();
    do
    {
        sct = iterator.Take(length).ToArray();
        if (sct.Any())
            yield return sct;
    }
    while (sct.Any());
}

这仍然不是超级优雅,但至少实现非常短且可读。

通过 IEnumerator 研究查询运算符可能会很有趣。

为了方便

public static IEnumerable<IEnumerable<α>> Section<α>(this IEnumerable<α> source, int length)
{
    using (var iterator = source.GetEnumerator())
        foreach (var e in iterator.Section(length))
            yield return e;
}

【讨论】:

  • 其实这里去掉 ToArray 调用是一个很好的加速器。可以在整个 Section 序列上应用 memoization,但这似乎给我测试过的 memoizing 枚举提供了一些相当慢的性能。
【解决方案7】:

随着你的进步,你是否需要保留你的原始来源?如果不是,你为什么不使用递归并使用 hd :: tl 风格来拉头,将 tl 传递给递归调用,然后在任何偶数递归上将你坐在的两个部分合并为一个部分?

随着实验性 Ix 扩展的更新版本,您可以使用 WindowBuffer 运算符来创建 sliding window,这应该可以实现您的目标。

【讨论】:

  • 不确定我是否关注你,也许你可以提供一个例子?无论如何,C# 不喜欢递归。据我所知,它不像 F# 那样应用尾调用优化。不过,这里提供的实现看起来既快速又正确——尽管我相信你可以从中榨出更多的汁液——以优雅为代价。
  • 超时。我确实想到了另一个可能的解决方案,类似于你的:使用索引器重载创建列表,然后压缩它们。
【解决方案8】:

扩展方法怎么样

public static class IEnumerableExtensions
{
    public static IEnumerable<List<T>> InSetsOf<T>(this IEnumerable<T> source, int max)
    {
        List<T> toReturn = new List<T>();
        foreach(var item in source)
        {
                toReturn.Add(item);
                if (toReturn.Count == max)
                {
                        yield return toReturn;
                        toReturn = new List<T>();
                }
        }
        if (toReturn.Any())
        {
                yield return toReturn;
        }
    }
}

一些测试:

[TestFixture]
public class When_asked_to_return_items_in_sets
{
    [Test]
    public void Should_return_the_correct_number_of_sets_if_the_input_contains_a_multiple_of_the_setSize()
    {
        List<string> input = "abcdefghij".Select(x => x.ToString()).ToList();
        var result = input.InSetsOf(5);
        result.Count().ShouldBeEqualTo(2);
        result.First().Count.ShouldBeEqualTo(5);
        result.Last().Count.ShouldBeEqualTo(5);
    }

    [Test]
    public void Should_separate_the_input_into_sets_of_size_requested()
    {
        List<string> input = "abcdefghijklm".Select(x => x.ToString()).ToList();
        var result = input.InSetsOf(5);
        result.Count().ShouldBeEqualTo(3);
        result.First().Count.ShouldBeEqualTo(5);
        result.Last().Count.ShouldBeEqualTo(3);
    }
}        

【讨论】:

  • 这是一个非常好的和简单的想法。要添加的一个小优化是将 toReturn 的容量设置为最大值,即 new List(max);
  • 如果我删除对 ToArray 的调用,它实际上比我的实现慢一点。快速记忆枚举器也很好,但在我的测试中,它们本身就有相当大的开销。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2012-04-04
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多