使用 C 测量内存写入带宽

Jer*_*rry 2 c memory parallel-processing memset memory-bandwidth

我正在尝试测量内存的写入带宽,我创建了一个 8G 字符数组,并使用 128 个线程在其上调用 memset。下面是代码片段。

#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <string.h>
#include <pthread.h>
int64_t char_num = 8000000000;
int threads = 128;
int res_num = 62500000;

uint8_t* arr;

static inline double timespec_to_sec(struct timespec t)
{
    return t.tv_sec * 1.0 + t.tv_nsec / 1000000000.0;
}

void* multithread_memset(void* val) {
    int thread_id = *(int*)val;
    memset(arr + (res_num * thread_id), 1, res_num);
    return NULL;
}

void start_parallel()
{
    int* thread_id = malloc(sizeof(int) * threads);
    for (int i = 0; i < threads; i++) {
        thread_id[i] = i;
    }
    pthread_t* thread_array = malloc(sizeof(pthread_t) * threads);
    for (int i = 0; i < threads; i++) {
        pthread_create(&thread_array[i], NULL, multithread_memset, &thread_id[i]);
    }
    for (int i = 0; i < threads; i++) {
        pthread_join(thread_array[i], NULL);
    }
}

int main(int argc, char *argv[])
{
    struct timespec before;
    struct timespec after;
    float time = 0;
    arr = malloc(char_num);

    clock_gettime(CLOCK_MONOTONIC, &before);
    start_parallel();
    clock_gettime(CLOCK_MONOTONIC, &after);
    double before_time = timespec_to_sec(before);
    double after_time = timespec_to_sec(after);
    time = after_time - before_time;
    printf("sequential = %10.8f\n", time);
    return 0;
}
Run Code Online (Sandbox Code Playgroud)

根据输出,完成所有 memset 需要 0.6 秒,据我了解,这意味着 8G/0.6 = 13G 内存写入带宽。但是,我有一个 2667 MHz DDR4,其带宽应为 21.3 GB/s。我的代码或计算有问题吗?谢谢你的帮助!!

Jér*_*ard 7

TL;DR:测量内存带宽并不容易。在您的情况下,性能问题可能来自页面错误

如果您想测量内存写入带宽,您需要注意多个事项:

  • 在 Intel/AMD x86 平台上,内存写入未在缓存中获取的位置会导致写入分配:丢失写入位置的数据被加载到缓存中。请参阅此页面了解更多信息。该策略使处理器能够填充缓存行中未写入的部分,以保证CPU缓存的一致性。然而,这也意味着一半的内存吞吐量被“浪费”了。实际上,情况甚至更糟,因为交错存储器读写通常会带来额外的开销。解决这个问题的一种解决方案是使用非临时写入指令。在SSE中,您可以使用_mm_stream_*内在函数(通常_mm_stream_si128)。在AVX中,这是_mm256_stream_*内在函数(通常是_mm256_stream_si256)。请注意,只有当数据块不适合缓存或此后不会很快重用时,才可以使用此类指令。一个好的 libc 实现应该memsetmemcpy大块使用这样的指令。

  • 大多数操作系统实际上并不在分配时将分配的页面映射到物理页面。内存只是虚拟分配的,而不是物理分配的。第一次接触分配的内存页面会导致页面错误,这是相当昂贵的。此时通常会物理映射整页,并且出于安全原因在大多数系统上将其重置为零。为了测量内存吞吐量,您不需要在基准测试中包含这样的开销,只需预先分配内存并提前写入内存块(如果可能的话使用随机值)。

  • CPU 缓存可能非常大,写入的内存缓冲区应该比它们大得多,以免测量缓存本身的吞吐量(通常是由于缓存关联性)。

  • 一个线程通常不足以使主存的带宽饱和。通常需要很少的线程才能达到最佳吞吐量(这非常依赖于平台,服务器处理器(如英特尔至强处理器)上通常需要许多线程)。如果线程太多,可能会出现一些复杂的影响(例如争用),从而降低总体吞吐量。

  • NUMA 系统上,如果核心访问自己的内存,内存访问通常会更快。这意味着线程应该固定到核心,并且应该读/写到专用于线程的缓冲区中,以实现最佳吞吐量。例如,在 AMD Ryzen 台式机/服务器处理器或双路服务器系统上尤其如此。

  • 现代处理器通常使用可变频率(请参阅频率缩放)。此外,线程的创建和实际启动可能需要一些时间。因此,使用具有同步屏障的在同一缓冲区上多次迭代的循环对于最小化由此引入的偏差非常重要。这对于检查每个线程所花费的时间是否大致相同也很重要(否则,这意味着会发生像 NUMA 那样的不良影响)。

  • 内存使用量不宜太大,因为某些操作系统使用内存压缩策略(例如z-swap)来避免内存使用太大。在最坏的情况下,可以使用交换存储设备。

请注意,您可以使用OpenMP更轻松地编写并行代码(生成的代码将更小且更易于阅读)。OpenMP 还使您能够控制线程固定并根据目标架构分配适量的线程。大多数编译器都支持 OpenMP,包括 GCC、Clang、ICC、MSVC(目前仅支持 MSVC 2.0 版本)。