C 用二进制搜索优化大型if-else分支

C 用二进制搜索优化大型if-else分支,c,optimization,binary-search,linear-search,C,Optimization,Binary Search,Linear Search,因此,在我的程序中有一个if-else分支,包含大约30条if-else语句。这部分每秒运行100次以上,因此我将其视为优化的机会,并使其使用函数指针数组(实际上是一个平衡树映射)进行二进制搜索,而不是执行线性if-else条件检查。但是它比以前的速度慢了70% 我制作了一个简单的基准测试程序来测试这个问题,它也给出了类似的结果,即if-else部分在有编译器优化和没有编译器优化的情况下运行得更快 我还计算了所做比较的数量,正如预期的那样,进行二进制搜索的那一个比简单的if-else分支进行了大

因此,在我的程序中有一个if-else分支,包含大约30条if-else语句。这部分每秒运行100次以上,因此我将其视为优化的机会,并使其使用函数指针数组(实际上是一个平衡树映射)进行二进制搜索,而不是执行线性if-else条件检查。但是它比以前的速度慢了70%

我制作了一个简单的基准测试程序来测试这个问题,它也给出了类似的结果,即if-else部分在有编译器优化和没有编译器优化的情况下运行得更快

我还计算了所做比较的数量,正如预期的那样,进行二进制搜索的那一个比简单的if-else分支进行了大约一半的比较。但它仍然慢了20~30%

我想知道我所有的计算时间都浪费在哪里,为什么线性if-else比对数二进制搜索运行得更快

#include <stdio.h>
#include <stdlib.h>
#include <time.h>

long long ifElseCount = 0;
long long binaryCount = 0;

int ifElseSearch(int i) {
    ++ifElseCount;
    if (i == 0) {
        return 0;
    }
    ++ifElseCount;
    if (i == 1) {
        return 1;
    }
    ++ifElseCount;
    if (i == 2) {
        return 2;
    }
    ++ifElseCount;
    if (i == 3) {
        return 3;
    }
    ++ifElseCount;
    if (i == 4) {
        return 4;
    }
    ++ifElseCount;
    if (i == 5) {
        return 5;
    }
    ++ifElseCount;
    if (i == 6) {
        return 6;
    }
    ++ifElseCount;
    if (i == 7) {
        return 7;
    }
    ++ifElseCount;
    if (i == 8) {
        return 8;
    }
    ++ifElseCount;
    if (i == 9) {
        return 9;
    }
}

int getZero(void) {
    return 0;
}

int getOne(void) {
    return 1;
}

int getTwo(void) {
    return 2;
}

int getThree(void) {
    return 3;
}

int getFour(void) {
    return 4;
}

int getFive(void) {
    return 5;
}

int getSix(void) {
    return 6;
}

int getSeven(void) {
    return 7;
}

int getEight(void) {
    return 8;
}

int getNine(void) {
    return 9;
}

struct pair {
    int n;
    int (*getN)(void);
};

struct pair zeroToNine[10] = {
    {0, getZero},
    {2, getTwo},
    {4, getFour},
    {6, getSix},
    {8, getEight},
    {9, getNine},
    {7, getSeven},
    {5, getFive},
    {3, getThree},
    {1, getOne},
};

int sortCompare(const void *p, const void *p2) {
    if (((struct pair *)p)->n < ((struct pair *)p2)->n) {
        return -1;
    }
    if (((struct pair *)p)->n > ((struct pair *)p2)->n) {
        return 1;
    }
    return 0;
}

int searchCompare(const void *pKey, const void *pElem) {
    ++binaryCount;
    if (*(int *)pKey < ((struct pair *)pElem)->n) {
        return -1;
    }
    if (*(int *)pKey > ((struct pair *)pElem)->n) {
        return 1;
    }
    return 0;
}

int binarySearch(int key) {
    return ((struct pair *)bsearch(&key, zeroToNine, 10, sizeof(struct pair), searchCompare))->getN();
}

struct timer {
    clock_t start;
    clock_t end;
};

void startTimer(struct timer *timer) {
    timer->start = clock();
}

void endTimer(struct timer *timer) {
    timer->end = clock();
}

double getSecondsPassed(struct timer *timer) {
    return (timer->end - timer->start) / (double)CLOCKS_PER_SEC;
}

int main(void) {
    #define nTests 500000000
    struct timer timer;
    int i;

    srand((unsigned)time(NULL));
    printf("%d\n\n", rand());
    for (i = 0; i < 10; ++i) {
        printf("%d ", zeroToNine[i].n);
    }
    printf("\n");
    qsort(zeroToNine, 10, sizeof(struct pair), sortCompare);
    for (i = 0; i < 10; ++i) {
        printf("%d ", zeroToNine[i].n);
    }
    printf("\n\n");

    startTimer(&timer);
    for (i = 0; i < nTests; ++i) {
        ifElseSearch(rand() % 10);
    }
    endTimer(&timer);
    printf("%f\n", getSecondsPassed(&timer));

    startTimer(&timer);
    for (i = 0; i < nTests; ++i) {
        binarySearch(rand() % 10);
    }
    endTimer(&timer);
    printf("%f\n", getSecondsPassed(&timer));
    printf("\n%lli %lli\n", ifElseCount, binaryCount);
    return EXIT_SUCCESS;
}

您应该查看生成的指令以查看(
gcc-S source.c
),但通常可以归结为以下三个方面:

1) N太小了。 如果您只有8个不同的分支,则平均执行4次检查(假设可能性相同,否则可能更快)

如果将其设置为二进制搜索,即log(8)==3检查,但这些检查要复杂得多,导致执行的代码总体上更多

所以,除非你的N是数百,否则这样做可能没有意义。您可以进行一些分析以找到N的实际值

2) 分支预测更难。 在线性搜索的情况下,每个条件在
1/N
情况下都为真,这意味着编译器和分支预测器可以假定没有分支,然后只恢复一次。对于二进制搜索,您可能会在每一层刷新管道一次。对于N<1024的情况,
1/log(N)
预测失误的机会实际上会影响性能

3) 指向函数的指针很慢 当执行指向函数的指针时,必须从内存中获取它,然后必须将函数加载到指令缓存中,然后执行调用指令、函数设置和返回。您不能通过指针内联调用函数,因此需要几个额外的指令,加上内存访问,再加上将内容移入/移出缓存。加起来很快



总而言之,这只适用于较大的N,在应用这些优化之前,您应该始终对其进行分析。

使用switch语句

编译器很聪明。它们将为您的特定值生成最有效的代码。如果认为二进制搜索更有效,他们甚至会进行二进制搜索(使用内联代码)

作为一个巨大的好处,代码是可读的,并且不需要您在六个地方进行更改以添加新案例


很明显,你的代码是一个很好的学习体验。现在您已经学会了,所以不要再这样做:-)

也许因为分支预测,代码越简单,速度就越快?你在编译什么样的优化级别,O2?因为二进制搜索将执行大量的代码?它能调用多少个函数?线性版本更简单,根本不需要内存访问。函数版本需要多个内存访问、间接寻址、bsearch的多个函数调用以及另一个无法内联的函数调用。10次比较确实不足以让日志复杂性补偿更高的每点成本。尝试进行数千次比较,看看会发生什么。事实上,将其作为N的函数计时并绘制曲线,然后看看对数是否能够补偿更高的成本。对于对数与线性时间复杂度的关系,他将不得不编写数百个函数!这是非常好的建议!有时,切换是不可能的(例如在比较两个值或处理非整数时)。在这些情况下,您应该尝试将代码转换为一个开关,并且只有在最终不可能/混乱的情况下,您才应该尝试自己编写一个决策树。这是一个很好的建议,但它不能解决实际问题。我相信,聪明的编译器能够从一堆ifs中识别开关模式,如果它们看起来像这样的话。优化不应用于源代码级别,而是应用于在代码生成方面易于操作的特殊类型的树。只有在惯用的情况下才应该使用Switch(不要争辩这不是事实),否则这只是对缩进和/或范围的浪费。第1点适用,但基准测试实际上有10个测试(不是8个),实际的代码用例是约30个测试,但即使如此,仍然很低,很难克服B搜索所需的工作量。第3点相对较小(至少在基准测试中),因为所有代码都可能保留在缓存中,所以额外的间接性可以忽略不计。
78985494

0 2 4 6 8 9 7 5 3 1 
0 1 2 3 4 5 6 7 8 9 

12.218656
16.496393

2750030239 1449975849