飞行的蜗牛

vuePress-theme-reco 极客学长    2013 - 2025
飞行的蜗牛 飞行的蜗牛

Choose mode

  • dark
  • auto
  • light
首页
分类
  • 技术杂谈
  • Database
  • Docker
  • PHP
  • 随笔杂谈
  • 前端开发
  • FunnyTools
  • Jekyll
  • 读书笔记
  • Java
  • SpringBoot
  • 区块链技术
  • IPFS
  • C/C++
  • Filecoin
  • Golang
  • Sharding-JDBC
  • 分布式存储
  • Lotus-源码系列
  • Lotus
  • 框架源码系列
  • Spring-源码系列
  • AI
  • ChatGPT
  • Stable Diffusion
  • DeepSeek-R1
  • DeepSeek-V3
标签
时间抽
关于作者
开源项目
GeekAI (opens new window)
author-avatar

极客学长

154

文章

151

标签

首页
分类
  • 技术杂谈
  • Database
  • Docker
  • PHP
  • 随笔杂谈
  • 前端开发
  • FunnyTools
  • Jekyll
  • 读书笔记
  • Java
  • SpringBoot
  • 区块链技术
  • IPFS
  • C/C++
  • Filecoin
  • Golang
  • Sharding-JDBC
  • 分布式存储
  • Lotus-源码系列
  • Lotus
  • 框架源码系列
  • Spring-源码系列
  • AI
  • ChatGPT
  • Stable Diffusion
  • DeepSeek-R1
  • DeepSeek-V3
标签
时间抽
关于作者
开源项目
GeekAI (opens new window)
  • Lotus 源码研究 05 - PC1 SDR 多核加速优化实现

    • 1. 关于 CPU 缓存
      • 2. PC1计算的SDR多核加速
        • 3. PC1 L3 核心分组和绑定
          • 4. 原语云核心分组的优化
            • 5. 测试优化结果

            Lotus 源码研究 05 - PC1 SDR 多核加速优化实现

            vuePress-theme-reco 极客学长    2013 - 2025

            Lotus 源码研究 05 - PC1 SDR 多核加速优化实现


            极客学长 2021-11-03 0 Lotus PC1 优化 CPU L3 缓存加速

            尼采

            生命中最艰难的阶段不是没有人懂你,而是你自己不懂你自己。

            转载声明

            本文转载自原语云公众号 Lotus PC1多核计算的L3缓存加速的实现分析和优化 (opens new window)。欢迎订阅,第一时间获取技术干货。

            本文属于原语云的计算加速系列,这篇将重点介绍原语云的 PC1 的 CPU L3 计算核组自定义绑定的设计和实现。

            # 1. 关于 CPU 缓存

            目前的计算机体系程序计算的中间数据都是存储在 RAM 设备里面,也就是我们通常说的内存中,例如一个运行的程序中的常量,变量,函数等编译的二进制或者字节码等都是存储在内存中。 当 CPU 需要读取某个数据的时候,先从 CPU 的缓存中查找,如果找到了就立即读取发送给 CPU,否则就会从相对速度比较慢的内存中去读取。 CPU 对内存的读取是以块为单位,也就是说 CPU 一次会读取这个内存地址所在的整个内存块的数据,后续的计算中对这块内存任何数据的访问都可以直接在 CPU 缓存中读取。 这种读取机制会让 CPU 命中 cache 的概率非常高,大多数 CPU 可以在缓存中找到90%的数据,也就是只有10%的读取会穿透缓存,需要访问内存,这种机制会极大的减少对内存的访问,从而加速计算的读取。

            比较新的CPU都有四级缓存,一级/二级/三级/四级,通常称为:L1d/L1i/L2/L3 缓存,读取数据的时候也是依照上面的顺序依次检测,找到了就立即读取发送给CPU,没找到就穿透到下一级缓存。 缓存空间一般也是逐步增大,2012 年前的 CPU 的 L3 缓存都是外置芯片,2012 年才内置到CPU中,也正是 L3 缓存的出现让 CPU 对内存的访问降低到了5%,95%的访问都可以命中 CPU 缓存, 例如 AMD 5900X 的第一个核的缓存空间分配情况如下:

            {
              "l0_size": 32768,
              "l1_size": 32768,
              "l1_id": 0,
              "core": 0,
              "l2_id": 0,
              "online": 1,
              "l3_size": 33554432,
              "l3_id": 0,
              "l2_size": 524288,
              "l0_id": 0
            }
            

            下面我们通过一个简单的二维数组遍历的例子来验证下 CPU 缓存对数据访问的加速效果:

            
            #include <stdio.h>
            #include <stdlib.h>
            #include <string.h>
            #include <time.h>
            
            int main(int argc, char *argv[])
            {
                int i, j, t, r;
                clock_t s,e;
                char *v, key[64] = {'\0'}, val[255] = {'\0'};
            
                // 1, parse the rows arguments and default it to 10240
                r = 10240;
                for (i = 0; i < argc; i++) {
                    v = argv[i];
                    if (strlen(v) > 2 && strncmp(v, "--", 2) == 0 && strchr(v, '=') != NULL) {
                        j = sscanf(v, "--%63[^=]=%254s", key, val);
                        if (j != 2) {
                            printf("Invalid arguments pair: %s", v);
                            return 1;
                        }
            
                        if (strcmp(key, "rows") == 0) {
                            r = atoi(val);
                        }
                    }
                }
            
            
                // 2, do the memory alloc
                int **arr = (int **) calloc(r, sizeof(int *));
                if (arr == NULL) {
                    printf("Failed to alloc\n");
                    return 1;
                }
            
            
                // 3, initialize the array
                for (i = 0; i < r; i++) {
                    arr[i] = (int *) calloc(r, sizeof(int));
                    if (arr[i] == NULL) {
                        printf("Failed to alloc col %d\n", i);
                        return 1;
                    }
            
                    for (j = 0; j < r; j++) {
                        arr[i][j] = j;
                    }
                }
            
            
                // 4, Sequential access
                s = clock();
                for (i = 0; i < r; i++) {
                    for (j = 0; j < r; j++) {
                        // printf("arr[%d][%d]=%d\n", i, j, arr[i][j]);
                        t = arr[i][j];
                    }
                }
                e = clock();
                printf("Done, t=%d, cost: %.3f secs\n", t, (double)(e-s)/CLOCKS_PER_SEC);
            
            
                // 5, Jump access
                s = clock();
                for (j = 0; j < r; j++) {
                    for (i = 0; i < r; i++) {
                        // printf("arr[%d][%d]=%d\n", i, j, arr[i][j]);
                        t = arr[i][j];
                    }
                }
                e = clock();
            
                printf("Done, t=%d, cost: %.3f secs\n", t, (double)(e-s)/CLOCKS_PER_SEC);
                return 0;
            }
            

            拷贝上述代码到 loop.c 文件中,然后编译,运行:

            gcc -g -Wall ./loop.c
            /a.out --rows=10240
            

            上述 C 程序定义了一个int型的二维数组,依据 rows 参数动态申请内存进行初始化,之后依次通过顺序循环和跳跃循环来遍历访问二维数组,再记载打印两个循环的耗时,实验数据如下:

            // 数组长度 1024 个整数
            ➜  ~ ./a.out --rows=1024 
            Done, t=1023, cost: 0.002 secs
            Done, t=1023, cost: 0.003 secs
            
            // 数组长度 10240 个整数
            ➜  ~ ./a.out --rows=10240
            Done, t=10239, cost: 0.093 secs
            Done, t=10239, cost: 0.453 secs
            
            // 数组长度 20480 个整数
            ➜  ~ ./a.out --rows=20480 
            Done, t=20479, cost: 0.379 secs
            Done, t=20479, cost: 1.855 secs
            

            上述数据显示随着二维数组的长度的不断增大,两种操作的耗时差越大,这种耗时差距的本质就是 CPU 缓存的命中率的不同导致的。 第一种循环的大部分数据访问的内存寻址都是集中的,也就是在连续的内存块上,CPU 的块内存读取方式会让这种数据访问极大命中缓存, 而第二种访问方式内存寻址跳跃性很大,导致 CPU 缓存命中率降低了很多,而且 rows 越大这两个时间差距越大,rows 到了比较大的值。例如 r=102400 时,第二个跳跃性的访问过程可能需要等上几分钟。

            # 2. PC1计算的SDR多核加速

            最早太空竞赛的时候默认的 Lotus 代码没有多核计算加速,那会类似 7F32 这种主频很高,但是核数少的 AMD CPU 对于 PC1 计算确实有投入产出的优势。 当官方实现了 SDR 的多核加速之后,给了计算频率虽然低但是核数多的 CPU(如7742,7542) 发挥空间,毕竟芯片的发展之路也是由原来的更快演变成了并行的方案了。

            # SDR 多核加速原理:

            1. 首先,多个线程用 parent node 里面的的数据预先填充 buffer, 并完成部分 sha2.compress256 hash 计算,主要实现代码在 multi.create_label_runner 函数中。
            2. 然后,在 multi.create_layer_labels 函数的主线程空间里面 进行主要的 sha2.compress256 hash 计算。
            3. 最后,在 multi.create_label_encoding 里面的主线程把计算后的数据写入到磁盘。

            回到上述讲到的 CPU 缓存加速的效果,如果这些计算线程都集中在共享 CPU 缓存的核心上面,整个计算过程会极大的命中数据读取缓存, 从而加速整个计算过程, 当然缓存空间最大的是 L3 缓存,通常我们也以 L3 缓存的共享核心作为一组来分别绑定到不同的计算线程。

            你可以通过导出如下环境变量来启用 SDR 多核加速:

            export FIL_PROOFS_USE_MULTICORE_SDR=1
            

            同时,你可以通过如下环境变量来设置生成 label 的并行数,也就是上述加速原理第一步中的加速的线程数:

            export FIL_PROOFS_MULTICORE_SDR_PRODUCERS=3
            

            注意:

            经常有人在这里踩坑,SDR_PRODUCERS 默认值是3,官方也解释过了这个值是在 AMD 3970x CPU上实践过的最优值,很多工程师直接就用的这个值来跑全部的芯片, 这肯定会导致问题的,例如:7302/7402这种芯片的 SDR_PRODUCERS 设置为3会导致 PC1 计算偏慢很多,甚至跑到十几个小时,错误的配置可能会导致计算速率还不如单核计算来的快。

            怎么确定 SDR_PRODUCERS 的值呢?

            这个值等于 CPU 共享某组 L3 缓存的核心数减去一,例如: AMD 5900x 上共享第一组L3缓存的 CPU Processor 如下:

            lscpu -e | grep ":0 "
            ## 输出内容
              0    0      0    0 0:0:0:0          yes 4950.1948 2200.0000
              1    0      0    1 1:1:1:0          yes 4950.1948 2200.0000
              2    0      0    2 2:2:2:0          yes 4950.1948 2200.0000
              3    0      0    3 3:3:3:0          yes 4950.1948 2200.0000
              4    0      0    4 4:4:4:0          yes 4950.1948 2200.0000
              5    0      0    5 5:5:5:0          yes 4950.1948 2200.0000
             12    0      0    0 0:0:0:0          yes 4950.1948 2200.0000
             13    0      0    1 1:1:1:0          yes 4950.1948 2200.0000
             14    0      0    2 2:2:2:0          yes 4950.1948 2200.0000
             15    0      0    3 3:3:3:0          yes 4950.1948 2200.0000
             16    0      0    4 4:4:4:0          yes 4950.1948 2200.0000
             17    0      0    5 5:5:5:0          yes 4950.1948 2200.0000
            

            可以看到,一共有 12 个 Processor 共享一组 L3 缓存,但是由于我开启了超线程,所以实际上是有 6 个核心共享第一组 L3 缓存。 所以此时你可以把 SDR_PRODUCERS 设置为 6-1=5,另外一个核心分配给主线程,通常我们建议这个值不超过3,因为过多反而会变慢。

            原语云管理终端是会自动的通过一个叫做 auto_lotus_sdr_producers 的 ark 底层函数进行计算,lotus-p1-worker 应用保持默认配置就好,最大也是默认设置的不超过3:

            # 3. PC1 L3 核心分组和绑定

            首先我们要明确的一件事情是:计算线程和指定 Processor 的绑定本身是通过 Linux 内核提供的设置进程和线程的 CPU 亲和力 (CPU affinity) 的接口来实现的, 将特定的进程调度到指定的 CPU Processor 集合上去运行,这是系统内核的工作,并不是 lotus 做的事情。

            Lotus 默认的 CPU L3 分组以及 SDR 线程绑定的实现如下:

            # 3.1 初始化 CPU 加速核组

            vanilla.cores.rs 里面有一块 lazy_static 的代码,会调用一个函数把全部的 core (不是Processor) 按照 L3 共享来分类,得到一个分组的集合,后续计算会直接从这个缓存的vec集合中获取核组信息,核心代码如下(注意看代码注释):

            
            // 通过hwloc获取 core的depth
            let core_depth = match topo.depth_or_below_for_type(&ObjectType::Core) {
                Ok(depth) => depth,
                Err(_) => return None,
            };
            
            // 获取全部的 Core (物理核,不包括超线程出来的 Processor )
            let all_cores = topo
                .objects_with_type(&ObjectType::Core)
                .expect("objects_with_type failed");
            let core_count = all_cores.len();
            
            let mut cache_depth = core_depth;
            let mut cache_count = 1;
            
            // 通过 core_depth 来计算 cache_count
            // cache_count 表示 CPU 一起多少个 Core深度的 Cache 个数
            // 这里得到的就是 CPU 的 L3 缓存个数
            while cache_depth > 0 {
                let objs = topo.objects_at_depth(cache_depth);
                let obj_count = objs.len();
                if obj_count < core_count {
                    cache_count = obj_count;
                    break;
                }
            
                cache_depth -= 1;
            }
            
            // 得到最终的分组数量和每组的核心数
            // 全部核数除以 L3 缓存个数 = 缓存组数
            let mut group_size = core_count / cache_count;
            let mut group_count = cache_count;
            info!("core_depth: {:?}, core_count: {:?}, group_size: {:?}, group_count: {:?}",
                core_depth, core_count, group_size, group_count);
            

            例如在我的 AMD 5900X 的机器上运行上述逻辑的输出如下:

            core_depth: 3, core_count: 12, group_size: 6, group_count: 2
            

            5900X 是12 核 24 线程的,结合上述代码理解如下:

            一共有12个core,即 core_count=12,得到的 cache_count = 2,也就是5900X CPU 只有两个 L3 缓存

            故 group_size = core_count / cache_count = 6

            进而得到,group_count = cache_count = 2

            这个分组会缓存到一个叫做 CORE_GROUPS 的静态集合里面,以 AMD 7542 为例,这款 CPU 有 8 个缓存对象 (cache_count),每 4 (group_size) 个核共享一组 L3 缓存,初始化后,CORE_GROUPS 的空间结构如下:

            # 3.2 核心的选择和绑定

            例如我们在 7542 的CPU上通常会用1T内存并行 14 PC1 计算,rust层是通过调用一个 checkout_core_group 的函数来选择一个核组,接下来的 PC1 计算线程只会绑定到这个这个选定的核组中的 cpu cores。

            1. 核组的选择逻辑:

              遍历上一步提到的 CORE_GROUPS 静态全局变量,找到第一组没有被互斥锁锁定的核组 (group.try_lock成功),找到了就返回并且把这一组核心锁定,核心代码实现如下:

              match &*CORE_GROUPS {
              	Some(groups) => {
              		for (i, group) in groups.iter().enumerate() {
              			// info!("{}: {:?}", i, group);
              			match group.try_lock() {
              				Ok(guard) => {
              					info!("checked out core group {}", i);
              					return Some(guard);
              				}
              				Err(_) => debug!("core group {} locked, could not checkout", i),
              			}
              		}
              		None
              	}
              	None => None,
              }
              
            2. 核心的绑定逻辑:

              checkout 得到的核组,会伴随整个 PC1 的 labels encoding 计算过程,具体绑定逻辑是:这个核组的第一个核心绑定到主计算线程上面,剩下的核心依次绑定给 labels_runner 线程 ( SDR_PRODUCERS 定义的数量), 绑定的具体实现是通通过一个叫做 bind_core 的函数,本质上是线程 CPU 亲和力的系统调用。计算过程结束后,锁定的核组会被释放,供给下一个任务计算需要,核心代码如下(注意看代码):

              // 主线程绑定
              let _cleanup_handle = (*core_group).as_ref().map(|group| {
              		// This could fail, but we will ignore the error if so.
              		// It will be logged as a warning by `bind_core`.
              		info!("binding core in main thread");
              		group.get(0).map(|core_index| bind_core(*core_index))
              });
              
              // label runner 线程绑定
              // 下面的变量 i 为 producer的序号 <= SDR_PRODUCERS 数
              // 不同的 producer 绑定到不同的核心进行计算
              let core_index = if let Some(cg) = &*core_group {
              		cg.get(i + 1)
              } else {
              		None
              };
              
              runners.push(s.spawn(move |_| {
              		// This could fail, but we will ignore the error if so.
              		// It will be logged as a warning by `bind_core`.
              		info!("binding core in producer thread {}", i);
              		// When `_cleanup_handle` is dropped, the previous binding of thread will be restored.
              		let _cleanup_handle = core_index.map(|c| bind_core(*c));
              		create_label_runner(...)
              }));
              

            # 4. 原语云核心分组的优化

            了解了上述L3分组和绑定的实现后,回到一个现实骨感的问题,例如,AMD 7542 的芯片,我们通常会在1T的内存机器上并行至少14个 PC1 计算,但是如上图描述的7542的机器只有8组加速核心, 也就是前面的8个扇区计算都可以单独绑定核组计算,但是后面的 6 个并行计算没办法绑定会怎么样呢?

            1. 如果 PC1 worker 进程没有设置 CPU 的亲和力,那么剩下的没有绑定核组的6个 PC1 并行就会使用全部的 CPU。

            2. 如果 PC1 worker 进程设置了CPU亲和力,那么剩下的6个计算都会集中在这些设置的核心上运行。

            无论是上述任何一种方式都无疑会干扰前面绑定了核组的计算的CPU 缓存命中率导致计算效率不可控制的下降,自然计算时间会拉长并且不稳定。 为了解决这个问题,提高PC1计算的速度和稳定性(这两者通常共存),我们需要重新设计这个核组的分配,然后让计算的CPU资源完全按照我们的规划来分配,思路是考虑一组共享L3的加速核心并行两个任务,也就是8组核心可以并行16个任务, 通常情况下我们会拿出7组核心用于并行14个PC1任务,剩下的一组核心单独绑定进行PC2计算,有如下两种方式:

            1. 每个核组的核心数不变,把核心的超线程额外加分一组:

              默认 CPU 是打开超线程的,AMD 7542 有32核64线程,也就是我们可以把例如,CPU 0,1,2,3对应的超线程 15,16,17,18也分一组,虽然是同一组 core,但是超线程本身也有一定的加速效果, 主要是这样CPU的资源分配不会乱窜,这样可以得到16组加速核心。

            2. 每个核组的核心减半,PC1 的 SDR 并行核心数减少:

              如果机器关闭了超线程或者不想使用超线程,也可以把分组的核心数减半,例如:7542每组加速核心有4个 core,可以分成两组2个 core 的,再设置 SDR_PRODUCERS=1 绑定给两个 PC1 并行计算, 这样做到用主 core 在一组L3缓存上并行两个计算,也可以保证CPU的资源分配的有序不乱,超线程可以用于单独的 PC1 进程或者 PC2 进程的计算。

            无论是上述哪种思路,都需要修改核组的初始化代码,还需要修改 bind_core 函数允许绑定任意的 Processor (例如超线程) 而不是只能是 core,原语云并并没有把这些拆分逻辑写到rust层, 而是通过引入一个叫做 FIL_PROOFS_SDR_CACHE_GROUPS 的环境变量允许外部完全自定义核组的分配,核心代码如下(注意看注释):

            // 自定义核组的解析
            let group_list: Vec<&str> = sdr_groups.as_str().split("/").collect();
            for g in group_list {
                let mut cpu_set: Vec<CoreIndex> = vec![];
                let cpu_list: Vec<&str> = g.split(",").collect();
                for core in cpu_list {
                    cpu_set.push(CoreIndex(core.parse::<usize>().unwrap()));
                }
                // push the current cpu set to the global groups
                core_groups.push(cpu_set);
            }
            
            info!("core_groups: {:?}", core_groups);
            
            // bind_core的修改
            // 允许绑定 Processor 
            let bind_to = hwloc::CpuSet::from(core_index.0 as u32);
            // Thread binding before explicit set.
            let before = locked_topo.get_cpubind_for_thread(tid, CPUBIND_THREAD);
            info!("core_index: {:?}, binding to {:?}", core_index, bind_to);
            

            例如:我们把 7542 的核心通过逗号隔开,不同的核组通过/隔开传递给到 rust 去初始化全局的 CORE_GROUPS ,这样可以完全在外面控制 PC1 任务的核心绑定。

            在原语云中这个核心的分配也是通过一个叫 auto_cpu_l3_share_cores 的 ark 底层函数自动实现的(有兴趣的可以参考原语云的使用文档 (opens new window)):

            默认的7542的核心分组如下:

            
            root@worker01:~# qark-client --rsc cpu.l3_cache_cores
            [
             [0,1,2,3,32,33,34,35],      // 第 1 组核心
             [4,5,6,7,36,37,38,39],      // 第 2 组核心
             [8,9,10,11,40,41,42,43],
             [12,13,14,15,44,45,46,47],
             [16,17,18,19,48,49,50,51],
             [20,21,22,23,52,53,54,55],
             [24,25,26,27,56,57,58,59],
             [28,29,30,31,60,61,62,63]   // 第 8 组核心
            ]
            

            除非 7h12 这类本身就带 16 组 L3 缓存的,否则类似 7542 这种芯片 L3 缓存组数不够 12 组的话,原语云默认会对核组进行二次拆分,也就是把超线程也作为一组,得到 16 组加速核心,拆开分组后如下:

            root@worker01:~# qark-client --rsc cpu.l3_cache_cores --part=2
            [
             [0,1,2,3],    // 第 1 组核心
             [4,5,6,7],    // 第 2 组核心
             [8,9,10,11],
             [12,13,14,15],
             [16,17,18,19],
             [20,21,22,23],
             [24,25,26,27],
             [28,29,30,31],
             [32,33,34,35],
             [36,37,38,39],
             [40,41,42,43],
             [44,45,46,47],
             [48,49,50,51],
             [52,53,54,55],
             [56,57,58,59],
             [60,61,62,63]  // 第 16 组核心
            ]
            

            # 5. 测试优化结果

            通过这种方式会把7542的全部的 Processor 都用满,每台 worker 并行14个PC1 计算可以稳定每天完成 2.7T 左右的 PC1/PC2 计算,htop 截图如下:

            Tips:

            近期原语云将在深圳的办公室举行 lotus 技术线下交流,探讨和分享 lotus PC1/PC2/C2 计算加速的优化和思考,欢迎有兴趣的小伙伴公众号留言或者加本人微信报名,仅限 8 个人参与。

            本文首发于【原语云】公微信服务号。原语云是专注智能运维的SaaS云平台。提供海量服务器的可视化智能运维、Filecoin的集群方案/代码优化/可视化管理/应用生态等一站式云解决方案!

            本站博文如非注明转载则均属作者原创文章,引用或转载无需申请版权或者注明出处,如需联系作者请加微信: geekmaster01

            Lotus 源码研究 04 - 小试牛刀 历史总在重演,科技永远向前