(C/C++) 效率黑科技-Duff’s Device

情景

对一个序列进行循环遍历操作是再平常不过的操作。

此处列举这么一个场景,需要对对两个字符串进行拷贝。

在C语言中void* memcpy( void *dest, const void *src, size_t count );可以帮我们完成。

#include <stdio.h>
#include <string.h>

int main() {
    
    
    char src[128] = "Hello World!";
    char dest[128] = "";

    memcpy(dest, src, sizeof(src));

    printf("dest = %s\n", dest);
    printf("src = %s\n", src);

    return 0;
}

现在我们要自己实现一个这样的函数,我们定义如下的接口。

#include <stdio.h>
#include <string.h>

/**
 * @brief 
 * 实现类 void* memcpy( void *dest, const void *src, size_t count );
 * 的字符串拷贝功能
 * @param dest 目标串的首地址
 * @param src  被复制串的首地址
 * @param len  复制长度
 */
void my_memcpy(char* dest, char* src, int len) ;

int main() {
    
    
    char src[128] = "Hello World!";
    char dest[128] = "";

    my_memcpy(dest, src, strlen(src));

    printf("dest = %s\n", dest);
    printf("src = %s\n", src);

    return 0;
}

实现

暴力复制

最直接的办法便是直接遍历赋值。

但是对于这个循环,循环体内的操作非常简单,但是循环判断条件确一次也不能少。

这种情况通常for中的判断开销比循环体内更大。

void my_memcpy(char* dest, char* src, int len) {
    
    
    for (int i = 0; i < len; i += 1) {
    
    
        *dest++ = *src++;
    }
}

这里对不熟悉C语言的朋友做简单解释。

*dest++ = *src++;++运算级在*之前。

具体的,是在指针进行递增操作的同时进行解引用操作(取值操作),然后进行赋值。

这样在下一轮操作中,dest和src就能到下一个位置。保证两者的操作相对位置一致。

可以近似的理解为等效于下方的操作:

for (int i = 0; i < len; i += 1) {
     
     
    dest[i] = src[i];
}

增加运算次数,减少判断次数

尝试以BASE=8次操作为一轮进行计算。

分别对余量进行操作,这里的余量只可能是[0, 8]闭区间的数量。

然后对每一组进行8次相同的操作。

从而达到在代码中直接增加操作次数,且循环判断次数以8倍的速率降低。

具体的,我们可以写出如下形式的代码。

code1

void my_memcpy(char* dest, char* src, int len) {
    
    
    const int BASE = 8;
	
    // 处理余量
    int remainder = len % BASE;
    while (remainder--) {
    
    
        *dest++ = *src++;
    }
    
    // 以base为一组进行计算
    int cnt = len / BASE;
    while (cnt--) {
    
    
        *dest++ = *src++;
        *dest++ = *src++;
        *dest++ = *src++;
        *dest++ = *src++;
        *dest++ = *src++;
        *dest++ = *src++;
        *dest++ = *src++;
        *dest++ = *src++;
    }
}

code2

void my_memcpy(char* dest, char* src, int len) {
    
    
    const int BASE = 8;

    // 处理余量
    switch (len % BASE) {
    
    
    case 7: *dest++ = *src++;
    case 6: *dest++ = *src++;
    case 5: *dest++ = *src++;
    case 4: *dest++ = *src++;
    case 3: *dest++ = *src++;
    case 2: *dest++ = *src++;
    case 1: *dest++ = *src++;
    }

    // 以base为一组进行计算
    int cnt = len / BASE;
    while (cnt--) {
    
    
        *dest++ = *src++;
        *dest++ = *src++;
        *dest++ = *src++;
        *dest++ = *src++;
        *dest++ = *src++;
        *dest++ = *src++;
        *dest++ = *src++;
        *dest++ = *src++;
    }
}

Duff’s Device

上方的代码已经达到了我们的基本目的。但是一眼望去是两部分的代码块,并不是非常的美观。

下面进行本文的重点描述,Duff’s Device

首先我们需要对C/C++中的switch操作有扎实的理解。

在switch语句中,最常出现的就是case /**/ : 和 default :

这两者的本质是跳转的标签,就像goto语法中的标签一样,从switch的判断中直接跳转过来。

而这些标签本身不能构成域,因此上下都能添加各种语句(块)。

这样我们可以把case的标签对应到while的8条操作语句中。为了方便,这里使用do{}while(bool);的形式。

while一次执行8条是固定的,但是这个余量该如何处理呢?答案很简单,将case的编号顺序逆向放置即可。

如此时len % BASE == 3那程序会直接跳转到case 3:处,由于没有break,continue等操作,后面的case 2: case 1:后的操作也会执行。这样就完美的执行了3次操作。

而随之带来的一个小细节是,我们需要将len/BASE的组数进行上取整,且while中的判断需要用while(--cnt)的操作。

void my_memcpy(char* dest, char* src, int len) {
    
    
    const int BASE = 8;

    // 取上整
    int cnt = (len + BASE - 1) / BASE;
    // 余数正常计算
    switch (len % BASE) {
    
    
        do {
    
    
        case 0: *dest++ = *src++;
        case 7: *dest++ = *src++;
        case 6: *dest++ = *src++;
        case 5: *dest++ = *src++;
        case 4: *dest++ = *src++;
        case 3: *dest++ = *src++;
        case 2: *dest++ = *src++;
        case 1: *dest++ = *src++;
        } 
        // 注意cnt的运算顺序
        while (--cnt);
    }
}

可惜的是,这种语法操作在部分语言中并不支持,如java。

性能测试

下面我们增大数据量,并用定时器进行简单的性能测试。

框架进行测试如下。

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

#define STR_LENGTH 1000000
#define TEST_COUNT 5
#define RUN_COUNT 1000

void my_memcpy(char* dest, char* src, int len);

int main() {
    
    
    int test_count = TEST_COUNT;
    char src[STR_LENGTH];
    char dest[STR_LENGTH];

    while (test_count--) {
    
    
        int run_count = RUN_COUNT;

        clock_t start = clock();
        while (run_count--) {
    
    
            my_memcpy(dest, src, STR_LENGTH);
        }
        clock_t stop = clock();

        printf("run time = %ld\n", (stop - start));
    }
    
    return 0;
}

增加运算次数,减少判断次数

的代码不进行测试。

普通for循环

run time = 2177
run time = 2067
run time = 1991
run time = 2009
run time = 1996

Duff’s Device

令人惊讶的事情出现了,Duff’s Device的操作居然没有普通for循环暴力快。

甚至还比其更慢了一点。可见现代编译器对基础循环做了很大的优化。

run time = 2266
run time = 2139
run time = 2232
run time = 2239
run time = 2421

void* memcpy( void *, const void *, size_t);

再尝试一下,内部的memcpy()。直接快的飞起。

像``memcpy() memset()`这类函数,在现代多数编译器中会进一步的优化。

具体如何操作,那就要看具体的编译器是怎么实现的了,一些资料显示这类函数会专门调用一些特定的汇编指令,极大的增加了运算的速度。

#include <string.h>
void my_memcpy(char* dest, char* src, int len) {
    
    
    memcpy(dest, src, len);
}

run time = 80
run time = 69
run time = 53
run time = 56
run time = 55

END

参考资料:How does Duff’s Device work?

猜你喜欢

转载自blog.csdn.net/CUBE_lotus/article/details/131237873