C程序确定缓存的级别和大小

34

完整重写/更新以提高清晰度(并使您保持理智,因为它有点太长了)... (旧帖子)

为了完成作业,我需要找到每个缓存的级别(L1、L2等)和大小。根据提示和我目前发现的内容:我认为这个想法是创建不同大小的数组并读取它们。计时这些操作:

sizes = [1k, 4k, 256K, ...]
foreach size in sizes 
    create array of `size`

    start timer
    for i = 0 to n // just keep accessing array
        arr[(i * 16) % arr.length]++ // i * 16 supposed to modify every cache line ... see link
    record/print time

更新 (9月28日 下午6:57 UTC+8)

参见 完整源代码

现在,按照@mah的建议,我可能已经解决了信噪比问题......并找到了一种计时代码的方法(wall_clock_time来自实验室示例代码)。

然而,我似乎得到了错误的结果:我使用的是Intel Core i3 2100: [规格]

  • L1: 2 x 32K
  • L2: 2 x 256K
  • L3: 3MB

我得到的结果如图所示:

lengthMod: 1KB to 512K

enter image description here

第一个峰值的基数是32K...合理的...第二个峰值是384K...为什么?我期望是256?

lengthMod: 512k to 4MB

enter image description here

然后为什么这个范围会混乱呢?


我还了解到有关预取或其他应用程序的干扰,因此在运行脚本时尽可能关闭了许多东西,通过多次运行,数据始终如此混乱。


您已添加了多个问题,这使得在SO格式中回答变得困难,因为这不是真正的讨论板。1)arr的大小不是262144,而是1M * sizeof(int) - 数组大小(1024 * 1024)是它所容纳的int数,而不是字节数。2)您是正确的;您正在复制的代码假定每个条目占用16个字节。3)有一个模运算符,但and'ing _much_更快,并且对于2的幂是可靠的。4)您可以声明单个缓冲区(最大大小),但使用不同数量的缓冲区。 - mah
  1. 正确,系统上的其他进程会影响您的结果,因此要么运行多个试验,要么删除其他进程。最后) 由于我所描述的信噪比较低,差别很小。如果在内存访问速度较慢的机器上运行,您将获得更有用的结果。我将在下面发布另一个答案,介绍您可以尝试改善这种情况的方法...
- mah
@mah,你是指我最新的更新吗?我最近进行了更新,将问题简化了很多? - Jiew Meng
我们的更新大约在同一时间完成;在您更新时,我正在发表评论,因此我还没有看到您的缩减:)请查看我的答案更新以解决您的大小/时间相关问题,但更进一步说明:不要改变数组大小,保持在1M(或至少保持大于最大缓存大小)。您测试的大小更改只能针对lengthMod变量;只需确保它始终是2的幂减1位。因此,对于1k,请将其设置为(1 * 1024)-1; 对于4j,将其设置为(4 * 1024)-1; 等等。 - mah
关于墙钟时间,你应该阅读这个线程,了解C语言中可用的不同时钟,并使用更好的时钟:https://dev59.com/q2ct5IYBdhLWcg3wCZMT#12480485 - Douglas B. Staple
6个回答

41

在搜索了英特尔指令手册10分钟,并花费另外10分钟编码后,我得到了如下代码(适用于基于英特尔的处理器):

void i386_cpuid_caches () {
    int i;
    for (i = 0; i < 32; i++) {

        // Variables to hold the contents of the 4 i386 legacy registers
        uint32_t eax, ebx, ecx, edx; 

        eax = 4; // get cache info
        ecx = i; // cache id

        __asm__ (
            "cpuid" // call i386 cpuid instruction
            : "+a" (eax) // contains the cpuid command code, 4 for cache query
            , "=b" (ebx)
            , "+c" (ecx) // contains the cache id
            , "=d" (edx)
        ); // generates output in 4 registers eax, ebx, ecx and edx 

        // See the page 3-191 of the manual.
        int cache_type = eax & 0x1F; 

        if (cache_type == 0) // end of valid cache identifiers
            break;

        char * cache_type_string;
        switch (cache_type) {
            case 1: cache_type_string = "Data Cache"; break;
            case 2: cache_type_string = "Instruction Cache"; break;
            case 3: cache_type_string = "Unified Cache"; break;
            default: cache_type_string = "Unknown Type Cache"; break;
        }

        int cache_level = (eax >>= 5) & 0x7;

        int cache_is_self_initializing = (eax >>= 3) & 0x1; // does not need SW initialization
        int cache_is_fully_associative = (eax >>= 1) & 0x1;

        // See the page 3-192 of the manual.
        // ebx contains 3 integers of 10, 10 and 12 bits respectively
        unsigned int cache_sets = ecx + 1;
        unsigned int cache_coherency_line_size = (ebx & 0xFFF) + 1;
        unsigned int cache_physical_line_partitions = ((ebx >>= 12) & 0x3FF) + 1;
        unsigned int cache_ways_of_associativity = ((ebx >>= 10) & 0x3FF) + 1;

        // Total cache size is the product
        size_t cache_total_size = cache_ways_of_associativity * cache_physical_line_partitions * cache_coherency_line_size * cache_sets;

        printf(
            "Cache ID %d:\n"
            "- Level: %d\n"
            "- Type: %s\n"
            "- Sets: %d\n"
            "- System Coherency Line Size: %d bytes\n"
            "- Physical Line partitions: %d\n"
            "- Ways of associativity: %d\n"
            "- Total Size: %zu bytes (%zu kb)\n"
            "- Is fully associative: %s\n"
            "- Is Self Initializing: %s\n"
            "\n"
            , i
            , cache_level
            , cache_type_string
            , cache_sets
            , cache_coherency_line_size
            , cache_physical_line_partitions
            , cache_ways_of_associativity
            , cache_total_size, cache_total_size >> 10
            , cache_is_fully_associative ? "true" : "false"
            , cache_is_self_initializing ? "true" : "false"
        );
    }
}

参考资料: Intel® 64和IA-32体系结构开发人员手册:卷2A ,第3-190页,CPUID-CPU识别。

与测量缓存延迟相比,这种方法更可靠,因为在现代处理器上几乎不可能关闭缓存预取。如果您需要针对其他处理器架构获取类似的信息,则必须查阅相应的手册。


嗯...虽然我有编程背景,但我对C语言不是很熟悉。所以如果能得到一些注释就好了。你是在阅读BIOS代码或系统配置吗?这在本次任务中是不允许的 :( - Jiew Meng
4
这是工程师的回答,很棒:)。如果信息已经存在,为什么不直接查找呢? - user1443778
不,这并不以任何方式与BIOS交互,它只使用CPU。此代码直接查询CPU的规格,类似于读取其序列号。我今天稍后会在代码中添加一些注释。 - Sergey L.
如承诺,我已经对我的代码进行了注释,并添加了更多的信息输出。 - Sergey L.

15
调用clock()函数来测量时间的时间(即仅调用clock()函数的时间)比执行“arr [(i * 16)&lengthMod] ++”所需的时间要多得多(多得多...)。 这种极低的信噪比(以及其他可能的陷阱)使您的计划行不通。问题的很大一部分在于,您正在尝试测量循环的单个迭代; 您链接的示例代码尝试测量完整的迭代集(在开始循环之前读取时钟;在从循环中出现后再次读取它; 不要在循环内使用printf())。
如果循环足够大,您可能能够克服信噪比问题。
至于“正在增加哪个元素”; arr是1MB缓冲区的地址; arr [(i * 16)&lengthMod] ++; 导致(i * 16)* lengthMod从该地址生成偏移量; 该偏移量是被递增的int的地址。您正在执行移位(i * 16将变为i << 4),逻辑和,加法,然后根据CPU的不同进行读/添加/写或单个递增。
编辑:如上所述,由于内存访问的相对速度(缓存或无缓存)和调用函数来测量时间的速度较慢,您的代码受到信噪比(信号与噪声比)差的影响。要获得当前的定时,我假定您修改了代码以看起来像:
int main() {
    int steps = 64 * 1024 * 1024;
    int arr[1024 * 1024];
    int lengthMod = (1024 * 1024) - 1;
    int i;
    double timeTaken;
    clock_t start;

    start = clock();
    for (i = 0; i < steps; i++) {
        arr[(i * 16) & lengthMod]++;
    }
    timeTaken = (double)(clock() - start)/CLOCKS_PER_SEC;
    printf("Time for %d: %.12f \n", i, timeTaken);
}

这将在循环外移动测量,这样您就不会测量单个访问(这实际上是不可能的),而是测量steps次访问。

您可以根据需要自由增加 steps 的值,这将直接影响您的计时。由于您收到的时间间隔过于接近,并且在某些情况下甚至反转(您的时间在大小之间波动,这不太可能是由缓存引起的),因此您可以尝试将steps的值更改为256 * 1024 * 1024甚至更大。

注意:您可以使 steps 尽可能大,以适应已签名的整数(应该足够大),因为逻辑和确保您在缓冲区中绕回。


FYI,我已经更新了我的帖子,并附上了完整的源代码(Github Gist),请参见UPDATE(尝试) - Jiew Meng
1
SNR方面是...假设在缓存中访问内存需要25ns,在缓存之外的内存中访问需要200ns。这些时间非常短,而您的时钟分辨率非常小-大约为1ms,这意味着您的测量可能会偏离数百万个内存访问周期。因此,您必须一次性测量多个百万个测试周期。在您的代码中,增加“steps”以执行单个缓存大小测试,并使用lengthMod作为变量来定义要运行的测试大小。您可以使用cin进行操作,但最好自动试验几次。 - mah
嗯...我在想是否有更准确的方法来衡量性能,在最近的实验中,我们只是简单地介绍了prefpapiperf使用起来更容易...但是有没有办法从C程序内部获取计时/统计信息?或者我只能通过命令行来运行它呢?perf stat ./test - Jiew Meng
当我在处理另一个子任务时,为了确定写入策略,我在想是只读取还是读取/写入(增量)更好?“tmp +=(data [(k * 16)&lengthMod]&SIZE); ”,这里我实际上只需要读取速度对吧?如果我写入并且它是写透模式,所有时间都应该是类似的吗?因为它会写入所有缓存级别? - Jiew Meng
我试图获取只读时间,但似乎L1-L2之间的时间差异非常小,在L2内部(32-256K)访问时间似乎在增加,只有在L3中才稳定下来,您知道为什么会这样吗? 来源 - Jiew Meng
显示剩余6条评论

3
我知道这个!(实际上由于预取而变得非常复杂)
 for (times = 0; times < Max; time++) /* many times*/
     for (i=0; i < ArraySize; i = i + Stride)
           dummy = A[i]; /* touch an item in the array */

改变步长可以测试缓存的属性。通过查看图表,您将获得答案。
请看幻灯片35-42:http://www.it.uu.se/edu/course/homepage/avdark/ht11/slides/11_Memory_and_optimization-1.pdf Erik Hagersten是一位非常好的老师(同时也非常有能力,在某个时间点曾是Sun公司的首席架构师),因此请查看他的其他幻灯片以了解更多精彩的解释!

我尝试过这个,但是我注意到随着ArraySize的增加,时间也会增加。可能是因为如果Stride是恒定的,那么内部循环的数量随着ArraySize的增加而增加。另外,我更新了我的问题...请参见UPDATE(一次尝试) - Jiew Meng
第一个属性是缓存行大小(例如64B),如果步幅小于64B,则每隔一次访问就会得到缓存命中。然后我们想要查看L1缓存大小(例如64KB),因此我们需要查看每个内部循环的步幅数量。假设我们有一个1024K的ArraySize和Stride = 128B,那么内部循环的数量为:16384。通过已知的缓存行大小,我们知道这将占用512K的存储空间,无法适应L1缓存。请记住LRU和行大小。 - user1443778
重要的是你需要实际运行外部循环多次(数百万次!!!),并获取每个循环的平均时间。我们关注的是极限性质,而不是启动程序等瞬态过程。 - user1443778

2
回答你关于1MB以上的奇怪数字的问题,其实很简单;这与分支预测有关的缓存逐出策略有关,以及L3缓存是在核心之间共享的事实。
Core i3有一个非常有趣的缓存结构。实际上任何现代处理器都是如此。您应该在维基百科上阅读它们;根据大量因素和个别设计决策,L1-2-3缓存时序可以非常复杂。
除此之外,所有这些决策等(请参见维基百科上关于此主题的文章)必须在两个核心的缓存之间进行同步。同步共享的L3缓存与单独的L1和L2缓存的方法可能很丑陋,可能涉及回溯和重新计算或其他方法。您几乎不可能完全免费地拥有第二个内核,并且没有竞争与L3缓存空间竞争,从而导致同步异常。
通常,如果您正在处理数据,比如卷积内核,您需要确保它适合L1缓存,并围绕此调整算法。 L3缓存并不真正适合您正在进行的数据集的工作方式(尽管它比主存储器更好!)
我发誓如果我是那位不得不实现缓存算法的人,我会疯掉。

1

-1

对于Windows系统,您可以使用GetLogicalProcessorInformation方法。

对于Linux系统,您可以使用sysconf()函数。您可以在/usr/include/unistd.h/usr/include/bits/confname.h中找到sysconf的有效参数。


他们不被允许在这项任务中使用系统配置信息,而且据我上次查看,sysconf 不会给出缓存信息。 - Sergey L.
1
它确实可以。使用 _SC_LEVEL(1_I|1_D|2_|3_|4_)CACHE_(SIZE | ASSOC | LINESIZE) 进行调用。编辑:这就是为什么我包含了定义这些值的 include 文件的原因。 - Mark

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接