【问题标题】:How can several comparisons be slower than some calculations?几次比较怎么会比一些计算慢?
【发布时间】:2017-07-26 14:16:26
【问题描述】:

我们正在开发一段代码,用于检查何时不允许用户在一段时间内进入某个扇区,我的一位同事创建了一个函数,在下面的代码中是 isAllowed em> 并包含几个比较,我采用了不同的方法,即函数 isAllowed2 它使用时间段之间的秒数。

一开始我们并没有怀疑他的功能会更快,但实际运行代码并比较速度时并非如此,即使差异是我们可以完全忽略的,我们想知道为什么会这样“应该”更快的那个实际上更慢。

考虑以下代码:

#include <iostream>
#include <chrono>

using namespace std;
using namespace std::chrono;

struct timing {
    short hour;
    short minute;
};

bool isAllowed(timing &from, timing &to, timing &actual) {
    return !(((from.hour > to.hour && (actual.hour >= from.hour || actual.hour <= to.hour)) ||
        (actual.hour >= from.hour && actual.hour <= to.hour)) &&
        !(actual.minute > from.minute && actual.minute < to.minute));
}

long getSecs(short hour, short minutes) {

    return (hour * 3600) + (minutes * 60);

}

bool isAllowed2(timing &from, timing &to, timing &current) {

    long secsFrom = getSecs(from.hour, from.minute);
    long secsTo = getSecs(to.hour, to.minute);
    long secsCurrent = getSecs(current.hour, current.minute);

    if (secsFrom > secsTo) secsTo += 24 * 60 * 60;
    if (secsCurrent > secsFrom && secsCurrent < secsTo) {
        return false;
    }

    return true;
}

int main() {
    //debug messages
    std::string okay = " - ok";
    std::string error = " - er";

    std::string receive = " - allowed";
    std::string notReceive = " - denied";

    //testing times
    int const testDataCount = 5;
    timing from[testDataCount] = {
        { 16, 30 },
        { 8,  30 },
        { 10, 30 },
        { 0, 30 },
        { 0, 0 }
    };
    timing to[testDataCount] = {
        { 8,  30 },
        { 20, 0 },
        { 20, 0 },
        { 6, 0 },
        { 7, 0 }
    };

    for (int i = 0; i < testDataCount; i++) {
        std::cout << i + 1 << ": " << from[i].hour << ":" << from[i].minute << " to " << to[i].hour << ":"
            << to[i].minute << std::endl;
    }

    //test current times
    timing current[5] = {
        { 12, 0 },
        { 23, 0 },
        { 17, 30 },
        { 15, 12 },
        { 0, 20 }
    };

    bool ergValues[][testDataCount] = {
        { true,  false, false, true, true },
        { false, true,  true, true, true },
        { false, false, false, true, true },
        { true,  false, false, true, true },
        { false,  true, true, true, false }
    };

    long totalNs1 = 0;
    long totalNs2 = 0;

    for (int i = 0; i < 4; i++) {
        std::cout << std::endl << i + 1 << ". Test: " << current[i].hour << ":" << current[i].minute << std::endl;
        for (int j = 0; j < testDataCount; j++) {

            high_resolution_clock::time_point t1 = high_resolution_clock::now();
            bool response = isAllowed(from[j], to[j], current[i]);
            high_resolution_clock::time_point t2 = high_resolution_clock::now();

            high_resolution_clock::time_point t3 = high_resolution_clock::now();
            bool response2 = isAllowed2(from[j], to[j], current[i]);
            high_resolution_clock::time_point t4 = high_resolution_clock::now();

            long ns1 = duration_cast<std::chrono::nanoseconds>(t2 - t1).count();
            totalNs1 += ns1;
            long ns2 = duration_cast<std::chrono::nanoseconds>(t4 - t3).count();
            totalNs2 += ns2;

            std::cout << j + 1 << "\t\t:1:" << ns1 << "ns: " << response << (response == ergValues[i][j] ? okay : error) << "\t\t:2:" << ns2 << "ms: " << response2 << (response2 == ergValues[i][j] ? okay : error) << "\t\t"
                << (ergValues[i][j] ? receive : notReceive) << std::endl;
        }
    }

    std::cout << "\r\ntotalNs1 = " << totalNs1 << "\r\ntotalNs2 = " << totalNs2 << "\r\n\r\n";

    return 0;
}

结果显然总是不同的,但无论如何totalNs2总是小于totalNs1。

例如:

totalNs1 = 38796
totalNs2 = 25913

我在 Windows 10 和 Debian 8 上的 AMD Phenom II X4 和 Intel i7-3770 上进行了测试,结果非常相似。

那么最后的问题是,为什么函数isAllowed2isAllowed快?

注意:这主要是一个好奇的问题,如果有人认为标题或标签不是最合适的,请告诉我,我会相应地更改它们,请原谅任何最终的语法错误因为英语不是我的母语。


编辑

同时,在了解了微基准测试的不准确程度之后,我一直在根据所有建议和 cmets 进行进一步研究,包括 this 非常详细的答案(非常感谢 Baum mit Augen用于链接 this 惊人的谈话,这有很大帮助)我最终使用 Google microbenchmark library 来获得更“准确”的结果,似乎 isAllowed 函数实际上更快(编译时没有优化) 如库的输出所示。

Run on (8 X 2395 MHz CPU s)
-----------------------------------------------------------------------
Benchmark                                Time           CPU Iterations
-----------------------------------------------------------------------
BM_isAllowed/2/min_time:2.000           22 ns         22 ns  128000000
BM_isAllowed/4/min_time:2.000           22 ns         22 ns  137846154
BM_isAllowed/8/min_time:2.000           22 ns         22 ns  128000000
BM_isAllowed/16/min_time:2.000          22 ns         22 ns  128000000
BM_isAllowed/22/min_time:2.000          22 ns         22 ns  137846154
BM_isAllowed2/2/min_time:2.000          24 ns         24 ns  112000000
BM_isAllowed2/4/min_time:2.000          24 ns         24 ns  119466667
BM_isAllowed2/8/min_time:2.000          24 ns         24 ns  119466667
BM_isAllowed2/16/min_time:2.000         24 ns         24 ns  119466667
BM_isAllowed2/22/min_time:2.000         24 ns         24 ns  119466667

注意:正如Martin Bonner 所指出的,isAllowed 函数似乎存在逻辑缺陷,请勿在生产代码中使用它。

你可以在下面找到我用来做这个基准测试的代码,如果我不熟悉Google library,请告诉我是否有任何缺陷。

重要的是,此代码是使用 Visual Studio 2015 编译的,应该对我们要测试的部分禁用优化。

#include "benchmark/benchmark.h"

using namespace std;
using namespace benchmark;

#pragma optimize( "[optimization-list]", {on | off} )

volatile const long extraDay = 24 * 60 * 60;

#pragma optimize( "", off )

struct timing {
    short hour;
    short minute;
    timing(short hour, short minute) : hour(hour), minute(minute) {}
};

static void BM_isAllowed(benchmark::State& state) {

    while (state.KeepRunning())
    {
        timing from(state.range(0), state.range(0));
        timing to(state.range(0), state.range(0));
        timing current(state.range(0), state.range(0));

        bool b = !(((from.hour > to.hour && (current.hour >= from.hour || current.hour <= to.hour)) ||
            (current.hour >= from.hour && current.hour <= to.hour)) &&
            !(current.minute > from.minute && current.minute < to.minute));
    }
}

static void BM_isAllowed2(benchmark::State& state) {

    while (state.KeepRunning())
    {
        timing from(state.range(0), state.range(0));
        timing to(state.range(0), state.range(0));
        timing current(state.range(0), state.range(0));

        bool b;
        long secsFrom = secsFrom = (from.hour * 3600) + (from.minute * 60);
        long secsTo = (to.hour * 3600) + (to.minute * 60);
        long secsCurrent = (current.hour * 3600) + (current.minute * 60);

        if (secsFrom > secsTo)
            secsTo += extraDay;
        if (secsCurrent > secsFrom && secsCurrent < secsTo)
            b = false;
        else
            b = true;
    }
}
#pragma optimize( "", on ) 

BENCHMARK(BM_isAllowed)->RangeMultiplier(2)->Range(2, 22)->MinTime(2);
BENCHMARK(BM_isAllowed2)->RangeMultiplier(2)->Range(2, 22)->MinTime(2);
BENCHMARK_MAIN();

【问题讨论】:

  • 看看汇编代码 ;) 你用优化标志编译了吗?
  • 这就是为什么您不猜测性能的原因。整数算术很便宜,分支介于字面上免费和昂贵得离谱之间,包括在内。所以至少很难预测这样的事情。
  • 您将有兴趣阅读this answer about branch prediction。这可能是 Stack Overflow 上投票率最高的答案之一。这个问题绝对是票数最高的问题。
  • @Mike 1) std::chrono::nanoseconds 确实返回非零结果。就是所有t1/t2/t3/t4中存储的值都是相等的。 2)正如我已经提到的 - 由于调度程序,所有如此小的时间范围都可以作为随机噪声被注销。增加测试用例的大小,直到总运行时间以秒为单位,然后比较结果。
  • 我完全不相信第一个函数是正确的!如果isAllowed(10:01, 12:59, 11:30) 返回true,而第二个返回false。 (并且第二个的正确性可以通过检查来确定。)

标签: c++ performance c++11 optimization


【解决方案1】:

这没有黄金法则。不幸的是,众所周知,这样的代码的性能很难预测。最重要的一点是

测量一切!

现在看看您的代码中发生了什么:正如其他人正确指出的那样,我们可以观察到 isAllowed 使用分支编译为函数,而 isAllowed2 最终是无分支的。

在谈论性能时,分支机构很有趣:它们介于真正的免费和高得离谱的价格之间,包括在内。这是由于称为分支预测器的 CPU 组件造成的。它试图预测您的控制流将采用哪个分支,并使 CPU 推测性地执行它。如果它猜对了,分支是免费的。如果它猜错了,分支是昂贵的。可以在this answer 中找到对该概念的详细解释,包括一些数字。

所以现在我们需要决定是要分支版本还是无分支版本。 一般来说,两者都不需要比另一个更快!这实际上取决于您的目标 CPU 预测分支的能力,这当然取决于实际输入。 (因此,选择是将函数编译为分支结果还是无分支结果对于编译器来说是一个难题,因为他们不知道二进制文件将在哪些 CPU 上运行,也不知道期望什么样的输入数据。参见例如 @987654322 @.)

所以如果您的基准测试确实正确,我们已经确定在您的 CPU 上,分支太难以预测,无法击败相对便宜的整数运算。这也可能是由于测试用例数量很少,分支预测器无法从如此少的调用中学习模式。但同样,我们不能只称其为一种或另一种方式,我们必须查看具体情况下的实际性能。


正如 cmets 中所述,执行时间有点短,无法进行良好的测量,我在我的机器上发现了巨大的偏差。有关微基准测试的信息,您可以查看this talk,这比人们想象的要难。

此外,正如 Martin Bonner 所提供的 noticed 一样,您的两个函数不会做同样的事情,您当然必须修复它以获得正确的基准。

【讨论】:

  • 这绝对是我正在寻找的答案,我想借此机会添加一个快速而小的相关问题:在优化代码上,最终结果可能因编译器而异,这意味着如果使用某个编译器编译,最终的程序可能会比另一个编译器执行得更好,对吗?
  • @Mike 是的,那可以发生。但是在所有大型编译器上,生成的二进制文件在大多数情况下都同样快,如果您的代码有一些小但对性能非常关键的部分,则大多数情况下会有很大差异。在大型项目中,差异通常会达到 +/- 几个百分点。
  • 分支预测只是在几个到几个时钟后开始提取,提前提取可能会在好日子多时钟(缓存命中)或坏日子(捕捉未命中)数百个时钟。所以分支预测不会解决分支它的成本只是让它稍微不那么痛苦。一般来说,分支很慢,不分支速度更快,但很大程度上也取决于这些指令对内存的访问,无论哪种方式,100 个寄存器只有非分支指令可以比一些基于非分支内存访问的指令快。混乱仍然完全正确,衡量一切,无法预测。
  • @old_timer 缓存命中和分支预测大多不相关(我希望是正确的词,但无论如何,正确或不正确的分支预测通常都意味着缓存未命中)。分支预测器推测性地执行东西,对于现实世界的工作负载,>90% 的命中率并不少见。它真的可以双向进行。所以“一般来说,分支很慢,而不是分支更快”不,尤其是如果它避免像logexp这样的昂贵指令,几个浮点运算或整数除法。像这样的一揽子声明绝对行不通。
  • 点是分支预测器只能看到管道中的内容,管道的大小决定了它理论上可以预测多远。如果管道顺利移动,那么它可以提前多远开始获取(与没有预测器的情况相比),因此它可以开始提前获取少量时钟,仅节省少量时钟,如果管道中的某些指令很慢,内存访问,那么肯定会提前几十或几百,但是管道移动速度很慢,所以你真正保存了什么。
【解决方案2】:

因为你没有测量你想要测量的东西。

事实上,在我的计算机上执行你的两个函数大约需要 40ns,但如果我使用你的测试代码,我会得到大约 500ns 的结果。

您没有执行您想要的测量,因为: 1. 这些函数只执行一次的时间与实际获取时钟的函数的执行时间是同一个量级(甚至更小)。根据经验,要进行测试,请尝试测量大于 10 毫秒的时间。 2. 在这两个滴答之间,编译器已经对你的函数进行了积极的内联和优化版本,因为它知道输入是什么,而这在实际情况中可能不会发生。

如果您将两个函数的定义放在与定义输入的文件不同的文件中:

//is_allowed.cpp
struct timing {
    short hour;
    short minute;
};
bool isAllowed(timing &from, timing &to, timing &actual) {
    return !(((from.hour > to.hour && (actual.hour >= from.hour || actual.hour <= to.hour)) ||
        (actual.hour >= from.hour && actual.hour <= to.hour)) &&
        !(actual.minute > from.minute && actual.minute < to.minute));
}

static long getSecs(short hour, short minutes) {

    return (hour * 3600) + (minutes * 60);

}

bool isAllowed2(timing &from, timing &to, timing &current) {

    long secsFrom = getSecs(from.hour, from.minute);
    long secsTo = getSecs(to.hour, to.minute);
    long secsCurrent = getSecs(current.hour, current.minute);

    if (secsFrom > secsTo) secsTo += 24 * 60 * 60;
    if (secsCurrent > secsFrom && secsCurrent < secsTo) {
        return false;
    }

    return true;
}

然后在“滴答”之间执行一百万次你的函数,你会得到一个更可靠的结果:

int main(){
//...

            high_resolution_clock::time_point t1 = high_resolution_clock::now();
            for (int x=1;x<1000000;++x)
               isAllowed(from[j], to[j], current[i]);
            high_resolution_clock::time_point t2 = high_resolution_clock::now();

            high_resolution_clock::time_point t3 = high_resolution_clock::now();
            for (int x=1;x<1000000;++x)
               isAllowed2(from[j], to[j], current[i]);
            high_resolution_clock::time_point t4 = high_resolution_clock::now();

            long ns1 = duration_cast<std::chrono::nanoseconds>(t2 - t1).count();
            totalNs1 += ns1;
            long ns2 = duration_cast<std::chrono::nanoseconds>(t4 - t3).count();
            totalNs2 += ns2;

//            std::cout << j + 1 << "\t\t:1:" << ns1 << "ns: " << response << (response == ergValues[i][j] ? okay : error) << "\t\t:2:" << ns2 << "ms: " << response2 << (response2 == ergValues[i][j] ? okay : error) << "\t\t"
//                << (ergValues[i][j] ? receive : notReceive) << std::endl;
//...
    }

惊喜,我明白了:

    totalNs1=38085793  //(38ms)
    totalNs2=52182920  //(52ms)

虽然你的代码使用我得到的完全相同的计算机和编译器:

    totalNs1 = 927
    totalNs2 = 587

正如你所料,isAllowed 的第一个版本实际上是赢家!

【讨论】:

  • 我们只需以随机方式重新排序函数调用,就会得到完全相反的结果(在我的机器上,totalNs1 = 507092140;totalNs2 = 394444324)。正如我在回答中提到的那样,他们的基准确实是可疑的,但是修复这个问题比仅仅将函数放在另一个文件中并使用相同的输入更频繁地调用它们更复杂一些,即使我们忽略了这两个函数的事实不要做同样的事情。正确地进行微基准测试并不是微不足道的。
  • @BaummitAugen 我想知道您在问题中给出的测试结果如何给出如此大的时间段?执行此功能之一所需的时间应为 20 ns 量级,绝对不是数百 ns。
  • @BaummitAugen 我刚刚重读了您的评论...您执行了随机排序的调用,以便衡量您必须测量每个调用的两个函数中的每一个所花费的总时间?
  • 您正在使用参数集 A 1M 次,然后使用参数集 B 1M 次等。这为分支预测器产生了一个最大乐观模式,从而产生了对isAllowed 的偏见。我做了和你一样的电话,但顺序是随机的。这对于分支预测器来说是最悲观的,它会经常失败,从而对isAllowed 产生偏见。请注意,我并不是说一个比另一个快,我当然认为isAllowed 最终会更快。我只是说a)我们不能仅仅通过查看代码来判断,b)我们在进行微基准测试时需要小心。
  • 强调一下:我的观点很简单,“不要只是期待,要衡量。” 在您深入了解这个特定案例中的基准测试业务之前,您应该确保您比较的功能做同样的事情。目前,他们没有。
猜你喜欢
  • 1970-01-01
  • 2020-04-12
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2020-09-15
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多