【问题标题】:Is my method of measuring running time flawed?我测量运行时间的方法有缺陷吗?
【发布时间】:2011-04-29 10:23:39
【问题描述】:

抱歉,这篇文章很长,但我只是在分析本文时解释我的思路。问题在最后。

我了解测量代码运行时间的内容。它运行多次以获得平均运行时间,以解决每次运行的差异,并获得更好地利用缓存的时间。

为了测量某人的运行时间,我在多次修改后想出了this 代码。

最后我得到了这段代码,它产生了我想要捕获的结果,而没有给出误导性的数字:

// implementation C
static void Test<T>(string testName, Func<T> test, int iterations = 1000000)
{
    Console.WriteLine(testName);
    Console.WriteLine("Iterations: {0}", iterations);
    var results = Enumerable.Repeat(0, iterations).Select(i => new System.Diagnostics.Stopwatch()).ToList();
    var timer = System.Diagnostics.Stopwatch.StartNew();
    for (int i = 0; i < results.Count; i++)
    {
        results[i].Start();
        test();
        results[i].Stop();
    }
    timer.Stop();
    Console.WriteLine("Time(ms): {0,3}/{1,10}/{2,8} ({3,10})", results.Min(t => t.ElapsedMilliseconds), results.Average(t => t.ElapsedMilliseconds), results.Max(t => t.ElapsedMilliseconds), timer.ElapsedMilliseconds);
    Console.WriteLine("Ticks:    {0,3}/{1,10}/{2,8} ({3,10})", results.Min(t => t.ElapsedTicks), results.Average(t => t.ElapsedTicks), results.Max(t => t.ElapsedTicks), timer.ElapsedTicks);
    Console.WriteLine();
}

在我见过的所有衡量运行时间的代码中,它们通常采用以下形式:

// 接近 1 伪代码 启动计时器; 循环N次: 运行测试代码(直接或通过函数); 停止计时器; 报告结果;

我认为这很好,因为有了这些数字,我就有了总运行时间,并且可以轻松计算出平均运行时间,并且具有良好的缓存局部性。

但我认为重要的一组值是最小和最大迭代运行时间。这无法使用上述表格进行计算。所以当我写我的测试代码时,我是这样写的:

// 方法 2 伪代码 循环N次: 启动计时器; 运行测试代码(直接或通过函数); 停止计时器; 存储结果; 报告结果;

这很好,因为我可以找到我感兴趣的最小、最大和平均时间。直到现在我意识到这可能会导致结果出现偏差,因为缓存可能会受到影响,因为循环没有t 非常紧,给我的结果不是最佳。


我编写测试代码的方式(使用 LINQ)增加了我知道但忽略的额外开销,因为我只是测量正在运行的代码,而不是开销。这是我的第一个版本:

// implementation A
static void Test<T>(string testName, Func<T> test, int iterations = 1000000)
{
    Console.WriteLine(testName);
    var results = Enumerable.Repeat(0, iterations).Select(i =>
    {
        var timer = System.Diagnostics.Stopwatch.StartNew();
        test();
        timer.Stop();
        return timer;
    }).ToList();
    Console.WriteLine("Time(ms): {0,3}/{1,10}/{2,8}", results.Min(t => t.ElapsedMilliseconds), results.Average(t => t.ElapsedMilliseconds), results.Max(t => t.ElapsedMilliseconds));
    Console.WriteLine("Ticks:    {0,3}/{1,10}/{2,8}", results.Min(t => t.ElapsedTicks), results.Average(t => t.ElapsedTicks), results.Max(t => t.ElapsedTicks));
    Console.WriteLine();
}

在这里我认为这很好,因为我只是在测量运行测试功能所花费的时间。与 LINQ 相关的开销不包括在运行时间中。为了减少在循环中创建计时器对象的开销,我进行了修改。

// implementation B
static void Test<T>(string testName, Func<T> test, int iterations = 1000000)
{
    Console.WriteLine(testName);
    Console.WriteLine("Iterations: {0}", iterations);
    var results = Enumerable.Repeat(0, iterations).Select(i => new System.Diagnostics.Stopwatch()).ToList();
    results.ForEach(t =>
    {
        t.Start();
        test();
        t.Stop();
    });
    Console.WriteLine("Time(ms): {0,3}/{1,10}/{2,8} ({3,10})", results.Min(t => t.ElapsedMilliseconds), results.Average(t => t.ElapsedMilliseconds), results.Max(t => t.ElapsedMilliseconds), results.Sum(t => t.ElapsedMilliseconds));
    Console.WriteLine("Ticks:    {0,3}/{1,10}/{2,8} ({3,10})", results.Min(t => t.ElapsedTicks), results.Average(t => t.ElapsedTicks), results.Max(t => t.ElapsedTicks), results.Sum(t => t.ElapsedTicks));
    Console.WriteLine();
}

这改善了总体时间,但造成了一个小问题。我通过添加每次迭代的时间在报告中添加了总运行时间,但由于时间很短并且没有反映实际运行时间(通常要长得多),因此给出了误导性的数字。我现在需要测量整个循环的时间,所以我离开了 LINQ,最终得到了我现在在顶部的代码。这种混合获得了我认为重要的时间,并且开销最小 AFAIK。 (启动和停止计时器只是查询高分辨率计时器)此外,发生的任何上下文切换对我来说都不重要,因为它是正常执行的一部分。

在某一时刻,我强制线程在循环内让步,以确保它在方便的时间的某个时刻有机会(如果测试代码受 CPU 限制并且根本不会阻塞)。我不太担心正在运行的进程可能会使缓存变得更糟,因为无论如何我都会单独运行这些测试。但是,我得出的结论是,对于这种特殊情况,没有必要这样做。虽然如果它总体上证明是有益的,我可能会将它合并到最终的最终版本中。也许作为某些代码的替代算法。


现在我的问题:

  • 我做出了正确的选择吗?一些错误的?
  • 我是否在思考过程中对目标做出了错误的假设?
  • 最小或最大运行时间是否真的是有用的信息,或者它是一个失败的原因?
  • 如果是这样,一般来说哪种方法会更好?循环运行的时间(方法 1)?还是只运行相关代码的时间(方法 2)?
  • 我的混合方法一般可以使用吗?
  • 应该我屈服(原因在上一段中解释)还是对时代的伤害比必要的更大?
  • 有没有我没有提到的更优选的方法?

为了清楚起见,我不是在寻找一个通用的、在任何地方使用的、准确的计时器。我只是想知道一个算法,当我想要一个快速实施、相当准确的计时器来测量代码时,我应该使用一种算法,而库或其他 3rd 方工具不可用。

如果没有异议,我倾向于以这种形式编写我的所有测试代码:

// final implementation
static void Test<T>(string testName, Func<T> test, int iterations = 1000000)
{
    // print header
    var results = Enumerable.Repeat(0, iterations).Select(i => new System.Diagnostics.Stopwatch()).ToList();
    for (int i = 0; i < 100; i++) // warm up the cache
    {
        test();
    }
    var timer = System.Diagnostics.Stopwatch.StartNew(); // time whole process
    for (int i = 0; i < results.Count; i++)
    {
        results[i].Start(); // time individual process
        test();
        results[i].Stop();
    }
    timer.Stop();
    // report results
}

对于赏金,理想情况下,我希望回答上述所有问题。我希望有一个很好的解释,说明我影响代码的想法是否合理(如果不是最理想的,可能还有关于如何改进它的想法),或者如果我的观点有误,请解释为什么它是错误的和/或不必要的,如果适用,提供更好的选择。

总结重要问题和我对做出决定的想法:

  1. 获得每个单独迭代的运行时间通常是一件好事吗?
    通过每个单独迭代的时间,我可以计算其他统计信息,例如最小和最大运行时间以及标准差。所以我可以看看是否有缓存或其他未知因素等因素可能会影响结果。这导致了我的“混合”版本。
  2. 在实际计时开始之前进行小循环运行也不错吗?
    从我对 Sam Saffron's 的回复中想到循环,这是为了增加不断访问内存的可能性将被缓存。这样我只测量所有内容都被缓存的时间,而不是一些没有缓存内存访问的情况。
  3. 循环中的强制Thread.Yield() 是否会帮助或损害受 CPU 限制的测试用例的时间安排?
    如果进程受 CPU 限制,操作系统调度程序会降低此任务的优先级由于 CPU 上的时间不足,可能会增加时间。如果它不受 CPU 限制,我会省略屈服。

根据此处的答案,我将使用最终实现来编写我的测试函数,而无需针对一般情况单独计时。如果我想要其他统计数据,我会将其重新引入测试功能,并应用此处提到的其他内容。

【问题讨论】:

    标签: c# benchmarking


    【解决方案1】:

    方法 2 中的逻辑对我来说感觉“更正确”,但我只是一名 CS 学生。

    我发现了您可能会感兴趣的这个链接: http://www.yoda.arachsys.com/csharp/benchmark.html

    【讨论】:

    • 我也一直这么认为,但我总是以其他开发人员的方法1的形式看到它。所以这要么是一种更好的方法,要么他们不认为尝试方法 2(或 3)。我对尝试这些不同方法的数字的观察使我不清楚。至少我知道我并不孤单。 :) 并感谢您的链接。不是我正在寻找的那种东西,但它绝对值得赞赏。它让我想起了我打算用于标签的词:[基准]。 ;)
    【解决方案2】:

    根据您要测试的代码的运行时间,衡量单个运行情况非常困难。如果您的测试代码的运行时间是几秒钟,那么您为特定运行计时的方法很可能不会成为问题。如果它在毫秒附近,您的结果可能会非常多。如果你例如在错误的时刻进行上下文切换或从交换文件读取,该运行的运行时间将与平均运行时间不成比例。

    【讨论】:

    • 你认为测试代码的最短运行时间的一个好的阈值是多少?我知道测试代码应该至少持续运行几毫秒。我一直的目标是至少 10 个。
    【解决方案3】:

    我认为您的第一个代码示例似乎是最好的方法。

    您的第一个代码示例很小、干净且简单,并且在测试循环期间不使用任何可能引入隐藏开销的主要抽象。

    使用 Stopwatch 类是一件好事,因为它简化了通常必须编写以获得高分辨率计时的代码。

    您可能会考虑的一件事是提供一个选项,在进入计时循环之前对测试进行较少次数的非计时迭代,以预热测试例程的任何缓存、缓冲区、连接、句柄、套接字、线程池线程等可以锻炼。

    HTH。

    【讨论】:

    • 抱歉,我应该标记这些代码,以防万一有人提到它。您是指实现 C(问题中列出的第一个代码示例)还是实现 A(第一次代码尝试)?
    • 我见过一次小的不定时循环,并认为这是一个好主意。我想从现在开始我也会这样做。
    【解决方案4】:

    我有一个类似的question here

    我更喜欢使用单个秒表的概念,尤其是在您进行微型基准测试时。您的代码没有考虑可能影响性能的 GC。

    我认为在运行测试运行之前强制 GC 收集非常重要,我也不确定 100 次预热运行的意义何在。

    【讨论】:

    • 一开始就进行垃圾收集听起来也是个好主意。确保调用有尽可能多的可用内存。我将来可能会这样做,而且我认为没有任何理由不这样做。
    • 计时前短暂预热的动机是增加在您开始计时时不断访问的内存在缓存中的可能性。这样,您就不会过多地计时访问内存的延迟,而是最佳的“一切都在缓存中”。我知道这在编写低级、高性能代码时非常重要,尤其是对于多核/多线程代码。我现在认为自己总是为多核写作。也就是说,如果更了解的人知道这没有必要,我可能会被说服,或者至少提供更好的解释。
    【解决方案5】:

    我倾向于同意@Sam Saffron 关于使用一个秒表而不是每次迭代一个。在您的示例中,您默认执行 1000000 次迭代。我不知道创建一个秒表的成本是多少,但你正在创建 1000000 个。可以想象,这本身可能会影响您的测试结果。我稍微修改了您的“最终实现”,以允许在不创建 1000000 个秒表的情况下测量每次迭代。诚然,因为我保存了每次迭代的结果,所以我分配了 1000000 个 long,但乍一看,这似乎比分配那么多秒表的整体影响要小。我没有将我的版本与您的版本进行比较,看看我的版本是否会产生不同的结果。

    static void Test2<T>(string testName, Func<T> test, int iterations = 1000000)
    {
      long [] results = new long [iterations];
    
      // print header 
      for (int i = 0; i < 100; i++) // warm up the cache 
      {
        test();
      }
    
      var timer = System.Diagnostics.Stopwatch.StartNew(); // time whole process 
    
      long start;
    
      for (int i = 0; i < results.Length; i++)
      {
        start = Stopwatch.GetTimestamp();
        test();
        results[i] = Stopwatch.GetTimestamp() - start;
      }
    
      timer.Stop();
    
      double ticksPerMillisecond = Stopwatch.Frequency / 1000.0;
    
      Console.WriteLine("Time(ms): {0,3}/{1,10}/{2,8} ({3,10})", results.Min(t => t / ticksPerMillisecond), results.Average(t => t / ticksPerMillisecond), results.Max(t => t / ticksPerMillisecond), results.Sum(t => t / ticksPerMillisecond));
      Console.WriteLine("Ticks:    {0,3}/{1,10}/{2,8} ({3,10})", results.Min(), results.Average(), results.Max(), results.Sum());
    
      Console.WriteLine();
    }
    

    我在每次迭代中使用了 Stopwatch 的静态 GetTimestamp 方法两次。之间的增量将是迭代中花费的时间量。使用 Stopwatch.Frequency,我们可以将增量值转换为毫秒。

    使用时间戳和频率来计算性能不一定像直接使用 Stopwatch 实例那样清晰。但是,每次迭代使用不同的秒表可能不如使用单个秒表来测量整个事情那么清楚。

    我不知道我的想法比你的好还是差,但它略有不同;-)

    我也同意预热循环。根据您的测试正在做什么,可能会有一些您不想影响整体结果的固定启动成本。启动循环应该消除这种情况。

    由于保存整个值(或计时器)数组所需的存储成本,可能存在一个点,即保留每个单独的计时结果会适得其反。对于更少的内存,但更多的处理时间,您可以简单地对增量求和,同时计算最小值和最大值。这有可能会影响您的结果,但如果您主要关心基于个人迭代测量生成的统计数据,那么您可以在时间增量检查之外进行最小和最大计算:

    static void Test2<T>(string testName, Func<T> test, int iterations = 1000000)
    {
      //long [] results = new long [iterations];
      long min = long.MaxValue;
      long max = long.MinValue;
    
      // print header 
      for (int i = 0; i < 100; i++) // warm up the cache 
      {
        test();
      }
    
      var timer = System.Diagnostics.Stopwatch.StartNew(); // time whole process 
    
      long start;
      long delta;
      long sum = 0;
    
      for (int i = 0; i < iterations; i++)
      {
        start = Stopwatch.GetTimestamp();
        test();
        delta = Stopwatch.GetTimestamp() - start;
        if (delta < min) min = delta;
        if (delta > max) max = delta;
        sum += delta;
      }
    
      timer.Stop();
    
      double ticksPerMillisecond = Stopwatch.Frequency / 1000.0;
    
      Console.WriteLine("Time(ms): {0,3}/{1,10}/{2,8} ({3,10})", min / ticksPerMillisecond, sum / ticksPerMillisecond / iterations, max / ticksPerMillisecond, sum);
      Console.WriteLine("Ticks:    {0,3}/{1,10}/{2,8} ({3,10})", min, sum / iterations, max, sum);
    
      Console.WriteLine();
    }
    

    没有 Linq 操作看起来很老派,但它仍然可以完成工作。

    【讨论】:

    • 啊,当他提到一个秒表时,我不确定他的意思。你的例子让我更清楚。使用的内存量对我来说不是什么大问题。但是我完全没有想到只是获得一个比保存秒表更容易的时间戳。
    【解决方案6】:

    我的第一个想法是一个简单的循环

    for (int i = 0; i < x; i++)
    {
        timer.Start();
        test();
        timer.Stop();
    }
    

    相比起来有点傻:

    timer.Start();
    for (int i = 0; i < x; i++)
        test();
    timer.Stop();
    

    原因是(1)这种“for”循环的开销非常小,即使 test() 只需要一微秒,也不值得担心,以及 (2) timer.Start()和 timer.Stop() 有自己的开销,这很可能比 for 循环对结果的影响更大。也就是说,我在 Reflector 中查看了 Stopwatch 并注意到 Start() 和 Stop() 相当便宜(考虑到所涉及的数学,调用 Elapsed* 属性可能更昂贵。)

    确保 Stopwatch 的 IsHighResolution 属性为真。如果它是假的,秒表使用 DateTime.UtcNow,我相信它只会每 15-16 毫秒更新一次。

    1.获得每个单独迭代的运行时间通常是一件好事吗?

    通常不需要测量每个单独迭代的运行时间,但有助于了解不同迭代之间的性能差异程度。为此,您可以计算最小/最大(或 k 个异常值)和标准差。只有“中位数”统计数据要求您记录每次迭代。

    如果您发现标准差很大,那么您可能有理由记录每次迭代,以探索时间为何不断变化。

    有些人编写了小型框架来帮助您进行性能基准测试。例如,CodeTimers。如果您正在测试的东西是如此微小和简单以至于基准库的开销很重要,请考虑在基准库调用的 lambda 内的 for 循环中运行该操作。如果操作非常小以至于 for 循环的开销很重要(例如测量乘法的速度),则使用手动循环展开。但如果您使用循环展开,请记住大多数实际应用不使用手动循环展开,因此您的基准测试结果可能夸大了实际性能。

    我为自己编写了一个小类,用于收集最小值、最大值、平均值和标准差,可用于基准测试或其他统计数据:

    // A lightweight class to help you compute the minimum, maximum, average
    // and standard deviation of a set of values. Call Clear(), then Add(each
    // value); you can compute the average and standard deviation at any time by 
    // calling Avg() and StdDeviation().
    class Statistic
    {
        public double Min;
        public double Max;
        public double Count;
        public double SumTotal;
        public double SumOfSquares;
    
        public void Clear()
        {
            SumOfSquares = Min = Max = Count = SumTotal = 0;
        }
        public void Add(double nextValue)
        {
            Debug.Assert(!double.IsNaN(nextValue));
            if (Count > 0)
            {
                if (Min > nextValue)
                    Min = nextValue;
                if (Max < nextValue)
                    Max = nextValue;
                SumTotal += nextValue;
                SumOfSquares += nextValue * nextValue;
                Count++;
            }
            else
            {
                Min = Max = SumTotal = nextValue;
                SumOfSquares = nextValue * nextValue;
                Count = 1;
            }
        }
        public double Avg()
        {
            return SumTotal / Count;
        }
        public double Variance()
        {
            return (SumOfSquares * Count - SumTotal * SumTotal) / (Count * (Count - 1));
        }
        public double StdDeviation()
        {
            return Math.Sqrt(Variance());
        }
        public Statistic Clone()
        {
            return (Statistic)MemberwiseClone();
        }
    };
    

    2。在实际计时开始之前进行小循环是否也不错?

    您测量哪些迭代取决于您最关心的是启动时间、稳态时间还是总运行时间。通常,将一个或多个运行单独记录为“启动”运行可能很有用。您可以期望第一次迭代(有时不止一次)运行得更慢。举个极端的例子,我的GoInterfaces 库始终需要大约 140 毫秒来产生它的第一个输出,然后在大约 15 毫秒内再完成 9 毫秒。

    根据基准测量的内容,您可能会发现,如果您在重新启动后立即运行基准测试,第一次迭代(或前几次迭代)将运行得非常慢。然后,如果您第二次运行基准测试,第一次迭代会更快。

    3.循环中的强制 Thread.Yield() 会帮助还是损害 CPU 绑定测试用例的时间安排?

    我不确定。它可能会清除处理器缓存(L1、L2、TLB),这不仅会降低您的整体基准测试速度,还会降低测量速度。您的结果将更加“人为”,不能很好地反映您在现实世界中会得到什么。也许更好的方法是避免在基准测试的同时运行其他任务。

    【讨论】:

    • 感谢您的回答,并特别指出了重点。
    • p.s.,循环的各个迭代的计时不是为了不计时循环的开销,而是出于统计原因。我同意,这样做确实很愚蠢。 :)
    • 嗯,我有点惊讶,也很感谢能获得代表提升。我并不是性能分析方面的专家,我只是有时会这样做。我希望你能学到任何你想学的东西:)。是的,很抱歉误解了您的代码,因为您在每次迭代中使用了不同的计时器。
    • 对整个重复循环计时的一个危险是跨迭代的编译器优化。如果编译器注意到,它可能会提升一些循环不变的计算,或者下沉一些循环不变的存储。更激进的提前编译器更有可能进行优化,例如在实际代码中有用但无法通过基准测试的优化。另请参阅 BenchmarkDotNet C# 基准框架,它应该有助于避免 Idiomatic way of performance evaluation? 中提到的该问题和其他问题
    • 顺便说一句,是的,一个简单的计数循环的开销小于现代 CPU 上每次迭代的吞吐量成本的时钟周期,它可以发出/重命名每个时钟 4 或 5 个微指令,仅限 dec/jnz采取一个(英特尔)或两个(AMD)。因此,即使 test() 只需要一微秒,相比之下,在多 GHz 机器上的数千个时钟周期仍然很缓慢。如果对单个指令进行微基准测试(尤其是针对吞吐量而不是延迟),您需要展开几次,但您需要一个重复循环,因为 x86 上的最低开销时序 (rdtsc) 比循环迭代。
    【解决方案7】:

    我倾向于最后一个,但我会考虑启动和停止计时器的开销是否会大于循环本身的开销。

    但要考虑的一件事是,CPU 缓存未命中的影响是否实际上是一个公平的尝试?

    利用 CPU 缓存是一种方法可能胜过另一种方法,但在实际情况下,每次调用都可能出现缓存未命中,因此这种优势变得无关紧要。在这种情况下,缓存利用率较低的方法可能会变成实际性能更好的方法。

    一个基于数组或基于单链表的队列就是一个例子;当缓存行在调用之间没有被重新填充时,前者几乎总是具有更高的性能,但在调整大小操作上比后者更受苦。因此后者可以在现实世界中获胜(尤其是因为它们更容易以无锁形式编写),尽管它们几乎总是会在时序测试的快速迭代中失败。

    因此,也值得尝试一些迭代以实际强制刷新缓存。想不出现在最好的方法是什么,所以如果我这样做了,我可能会回来补充。

    【讨论】:

    • StopWatch 简单地查询 HRT(如果可用)(在我的情况下,是的),用于启动和停止。所以那里的开销很小。对于一般情况,我将假设最佳缓存。我对算法感兴趣,而不是我的记忆有多快。如果我对启动时间感兴趣,我会记住这一点。
    • 嗯,算法确实取决于不同的内存速度(所有算法本身都只是数学,1 + 1 不需要任何时间来等于 2)。假设最佳缓存将内存偏差设置为特定方向,假设不良缓存将其设置为另一个方向,但两种偏差都取决于相对内存速度。
    【解决方案8】:

    不管为你的函数计时的机制(这里的答案似乎很好),有一个非常简单的技巧可以消除基准测试代码本身的开销,即循环的开销、计时器读数和方法 -调用:

    首先使用空的Func&lt;T&gt; 调用您的基准测试代码,即

    void EmptyFunc<T>() {}
    

    这将为您提供计时开销的基线,您基本上可以从实际基准函数的后续测量中减去该基线。

    “基本上”我的意思是,由于垃圾收集以及线程和进程调度,在对某些代码进行计时时总是存在变化的空间。务实的方法将例如是对空函数进行基准测试,找到平均开销(总时间除以迭代次数),然后从实际基准函数的每个计时结果中减去该数字,但不要让它低于 0,这是没有意义的。

    当然,您必须稍微重新安排您的基准测试代码。理想情况下,您会希望使用 完全相同的代码 来对空函数和真正的基准函数进行基准测试,因此我建议您将计时循环移到另一个函数中,或者至少保留两个循环 完全一样。总结

    1. 对空函数进行基准测试
    2. 根据结果计算平均开销
    3. 对真正的测试功能进行基准测试
    4. 从这些测试结果中减去平均开销
    5. 大功告成

    通过这样做,实际的计时机制突然变得不那么重要了。

    【讨论】:

    • 我不太关心循环或函数调用本身的开销。但这是取消它的好方法。
    猜你喜欢
    • 1970-01-01
    • 2020-06-06
    • 2011-01-16
    • 1970-01-01
    • 1970-01-01
    • 2021-07-12
    • 2012-12-30
    • 2012-06-16
    • 1970-01-01
    相关资源
    最近更新 更多