深入浅出DPDK学习笔记(4)——— 并行计算

深入浅出DPDK学习笔记(4)——— 并行计算

  • 多核性能和可扩展性
    • 追求性能水平扩展
    • 多核处理器
    • 亲和性
      • Linux内核对亲和性的支持
      • 为什么应该使用亲和性
      • 线程独占
    • DPDK的多线程
      • EAL中的lcore
      • lcore的亲和性
      • 对用户pthread的支持
      • 有效地管理计算资源
  • 指令并发与数据并行
    • 指令并发
    • 单指令多数据
      • SIMD简介
      • 实战DPDK

处理器性能提升主要有两个途径,一个是提高IPC(每个时钟周期内可以执行的指令条数),另一个是提高处理器主频率。每一代微架构的调整可以伴随着对IPC的提高,从而提高处理器性能,只是幅度有限。而提高处理器主频率对于性能的提升作用是明显而直接的。但一味地提高频率很快会触及频率墙,因为处理器的功耗正比于主频的三次方。
所以,最终要取得性能提升的进一步突破,还是要回到提高IPC这个因素。经过处理器厂商的不懈努力,我们发现可以通过提高指令执行的并行度来提高IPC。而提高并行度主要有两种方法,一种是提高微架构的指令并行度,另一种是采用多核并发。这一章主要就分享这两种方法在DPDK中的实践,并在指令并行方法中上进一步引入数据并发的介绍。

多核性能和可扩展性

追求性能水平扩展

多核处理器是指在一个处理器中集成两个或者多个完整的内核(及计算引擎)。
Amdahl定律告诉我们,假设一个任务的工作量不变,多核并行计算理论时延加速上限取决于那些不能并行处理部分的比例。换句话说,多核并行计算下时延不能随着核数增加而趋于无限小。该定律明确告诉我们,利用多核处理器提升固定工作量性能的关键在于降低那些不得不串行部分占整个任务执行的比例。更多信息可以参考[Ref3-1]。
对于DPDK的主要应用领域——数据包处理,多数场景并不是完成一个固定工作量的任务,更主要关注单位时间内的吞吐量。Gustafson定律对于在固定工作时间下的推导给予我们更多的指导意义。它指出,多核并行计算的吞吐率随核数增加而线性扩展,可并行处理部分占整个任务比重越高,则增长的斜率越大。带着这个观点来读DPDK,很多实现的初衷就豁然开朗。资源局部化、避免跨核共享、减少临界区碰撞、加快临界区完成速率(后两者涉及多核同步控制,将在下一章中介绍)等,都不同程度地降低了不可并行部分和并发干扰部分的占比。

多核处理器

通过单核结构(见图3-1),我们先认识一下CPU物理核中主要的基本组件。为简化理解,将主要组件简化为:CPU寄存器集合、中断逻辑(Local APIC)、执行单元和Cache。一个完整的物理核需要拥有这样的整套资源,提供一个指令执行线程。
深入浅出DPDK学习笔记(4)——— 并行计算_第1张图片
多处理器结构指的是多颗单独封装的CPU通过外部总线连接,构成的统一计算平台,如图3-2所示。每个CPU都需要独立的电路支持,有自己的Cache,而它们之间的通信通过主板上的总线。在此架构上,若一个多线程的程序运行在不同CPU的某个核上,跨CPU的线程间协作都要走总线,而共享的数据还会付出因Cache一致性产生的开销。从内存子系统的角度,多处理器结构进一步衍生出了非一致内存访问(NUMA),这一点在第2章就有介绍。在DPDK中,对于多处理器的NUMA结构,使用Socket Node来标示,跨NUMA的内存访问是性能调优时最需要避免的。
深入浅出DPDK学习笔记(4)——— 并行计算_第2张图片
如图3-3所示,超线程(Hyper-Threading)在一个处理器中提供两个逻辑执行线程,逻辑线程共享流水线、执行单元和缓存。该技术的本质是复用单处理器中的超标量流水线的多路执行单元,降低多路执行单元中因指令依赖造成的执行单元闲置。对于每个逻辑线程,拥有完整独立的寄存器集合和本地中断逻辑,从软件的角度,与单线程物理核并没有差异。例如,8核心的处理器使用超线程技术之后,可以得到16个逻辑线程。采用超线程,在单核上可以同时进行多线程处理,使整体性能得到一定程度提升。但由于其毕竟是共享执行单元的,对IPC(每周期执行指令数)越高的应用,带来的帮助越有限。DPDK是一种I/O集中的负载,对于这类负载,IPC相对不是特别高,所以超线程技术会有一定程度的帮助。更多信息可以参考[Ref3-2]。
深入浅出DPDK学习笔记(4)——— 并行计算_第3张图片
如果说超线程还是站在一个核内部以资源切分的方式构成多个执行线程,多核体系结构(见图3-4)则是在一个CPU封装里放入了多个对等的物理核,每个物理核可以独立构成一个执行线程,当然也可以进一步分割成多个执行线程(采用超线程技术)。多核之间的通信使用芯片内部总线来完成,共享更低一级缓存(LLC,三级缓存)和内存。随着CPU制造工艺的提升,每个CPU封装中放入的物理核数也在不断提高。
深入浅出DPDK学习笔记(4)——— 并行计算_第4张图片
各种架构在总线占用、Cache、寄存器以及执行单元的区别大致可以归纳为表3-1。
深入浅出DPDK学习笔记(4)——— 并行计算_第5张图片

亲和性

简单地说,CPU亲和性(Core affinity)就是一个特定的任务要在某个给定的CPU上尽量长时间地运行而不被迁移到其他处理器上的倾向性。这意味着线程可以不在处理器之间频繁迁移。这种状态正是我们所希望的,因为线程迁移的频率小就意味着产生的负载小。
Linux内核包含了一种机制,它让开发人员可以编程实现CPU亲和性。这意味着应用程序可以显式地指定线程在哪个(或哪些)处理器上运行。

Linux内核对亲和性的支持

在Linux内核中,所有的线程都有一个相关的数据结构,称为task_struct。这个结构非常重要,原因有很多;其中与亲和性相关度最高的是cpus_allowed位掩码。这个位掩码由n位组成,与系统中的n个逻辑处理器一一对应。具有4个物理CPU的系统可以有4位。如果这些CPU都启用了超线程,那么这个系统就有一个8位的位掩码。
如果针对某个线程设置了指定的位,那么这个线程就可以在相关的CPU上运行。因此,如果一个线程可以在任何CPU上运行,并且能够根据需要在处理器之间进行迁移,那么位掩码就全是1。实际上,在Linux中,这就是线程的默认状态。
Linux内核API提供了一些方法,让用户可以修改位掩码或查看当前的位掩码:

  • sched_set_affinity()(用来修改位掩码)
  • sched_get_affinity()(用来查看当前的位掩码)

注意,cpu_affinity会被传递给子线程,因此应该适当地调用sched_set_affinity。

为什么应该使用亲和性

将线程与CPU绑定,最直观的好处就是提高了CPU Cache的命中率,从而减少内存访问损耗,提高程序的速度。
在多核体系CPU上,提高外设以及程序工作效率最直观的办法就是让各个物理核各自负责专门的事情。每个物理核各自也会有缓存,缓存着执行线程使用的信息,而线程可能会被内核调度到其他物理核上,这样L1/L2的Cache命中率会降低,当绑定物理核后,程序就会一直在指定核上跑,不会由操作系统调度到其他核上,省却了来回反复调度的性能消耗,线程之间互不干扰地完成工作。
在NUMA架构下,这个操作对系统运行速度的提升有更大的意义,跨NUMA节点的任务切换,将导致大量三级Cache的丢失。从这个角度来看,NUMA使用CPU绑定时,每个核心可以更专注地处理一件事情,资源体系被充分使用,减少了同步的损耗。

线程独占

DPDK通过把线程绑定到逻辑核的方法来避免跨核任务中的切换开销,但对于绑定运行的当前逻辑核,仍然可能会有线程切换的发生,若希望进一步减少其他任务对于某个特定任务的影响,在亲和的基础上更进一步,可以采取把逻辑核从内核调度系统剥离的方法。
Linux内核提供了启动参数isolcpus。对于有4个CPU的服务器,在启动的时候加入启动参数isolcpus=2,3。那么系统启动后将不使用CPU3和CPU4。注意,这里说的不使用不是绝对地不使用,系统启动后仍然可以通过taskset命令指定哪些程序在这些核心中运行。步骤如下所示。

vim    /boot/grub2.cfg

在Linux kernel启动参数里面加入isolcpus参数,isolcpu=2,3。

cat    /proc/cmdline

等 待 系 统 重 新 启 动 之 后 查 看 启 动 参 数

BOOT_IMAGE=/vmlinuz-3.10.0-514.el7.x86_64 root=/dev/mapper/cl-root ro crashkernel=auto rd.lvm.lv=cl/root rd.lvm.lv=cl/swap rhgb quiet isolcpus=2,3

DPDK的多线程

DPDK的线程基于pthread接口创建,属于抢占式线程模型,受内核调度支配。DPDK通过在多核设备上创建多个线程,每个线程绑定到单独的核上,减少线程调度的开销,以提高性能。DPDK的线程可以作为控制线程,也可以作为数据线程。
在DPDK的一些示例中,控制线程一般绑定到MASTER核上,接受用户配置,并传递配置参数给数据线程等;数据线程分布在不同核上处理数据包。

EAL中的lcore

DPDK 的 lcore 指 的 是 EAL 线 程 , 本 质 是 基 于pthread(Linux/FreeBSD)封装实现。Lcore(EAL pthread)由remote_launch函数指定的任务创建并管理。在每个EAL pthread中,有一个TLS(Thread Local Storage)称为_lcore_id。当使用DPDK的EAL‘-c’参数指定coremask时,EAL pthread生成相应个数lcore并默认是1:1亲和到coremask对应的CPU逻辑核,_lcore_id和CPU ID是一致的。
下面简单介绍DPDK中lcore的初始化及执行任务的注册。

  • 初始化
  1. rte_eal_cpu_init ( ) 函 数 中 , 通 过 读取/sys/devices/system/cpu/cpuX/下的相关信息,确定当前系统有哪些CPU核,以及每个核属于哪个CPU Socket。
  2. eal_parse_args()函数,解析-c参数,确认哪些CPU核是可以使用的,以及设置第一个核为MASTER。
  3. 为 每 一 个 SLAVE 核 创 建 线 程 , 并 调 用eal_thread_set_affinity ( ) 绑 定 CPU 。 线 程 的 执 行 体 是eal_thread_loop()。eal_thread_loop()的主体是一个while死循环,调用不同模块注册到lcore_config[lcore_id].f的回调函数。
RTE_LCORE_FOREACH_SLAVE(i) {
        /*
        * create communication pipes between master thread
        * and children
        */if (pipe(lcore_config[i].pipe_master2slave) < 0)
        rte_panic("Cannot create pipe\n");
        if (pipe(lcore_config[i].pipe_slave2master) < 0)
                rte_panic("Cannot create pipe\n");
        lcore_config[i].state = WAIT;
        /* create a thread for each lcore */
        ret = pthread_create(&lcore_config[i].thread_id, NULL,
        eal_thread_loop, NULL);
        if (ret != 0)
                rte_panic("Cannot create thread\n");
}
  • 注册
    不同的模块需要调用rte_eal_mp_remote_launch(),将自己的回调处理函数注册到lcore_config[].f中。以l2fwd为例,注册的回调处理函数是l2fwd_launch_on_lcore()。
rte_eal_mp_remote_launch(l2fwd_launch_one_lcore, NULL, CALL_MASTER);

DPDK 每 个 核 上 的 线 程 最 终 会 调 用 eal_thread_loop ( ) —>l2fwd_launch_on_lcore(),调用到自己实现的处理函数。
最后,总结整个lcore启动过程和执行任务分发,可以归纳为如图3-5所示。
深入浅出DPDK学习笔记(4)——— 并行计算_第6张图片

lcore的亲和性

默认情况下,lcore是与逻辑核一一亲和绑定的。带来性能提升的同时,也牺牲了一定的灵活性和能效。在现网中,往往有流量潮汐现象的发生,在网络流量空闲时,没有必要使用与流量繁忙时相同的核数。按需分配和灵活的扩展伸缩能力,代表了一种很有说服力的能效需求。于是,EAL pthread和逻辑核之间进而允许打破1:1的绑定关系,使得_lcore_id本身和CPU ID可以不严格一致。EAL定义了长选项“–lcores”来指定lcore的CPU亲和性。对一个特定的lcore ID或者lcore ID组,这个长选项允许为EAL pthread设置CPU集。
格式如下:

--lcores=<lcore_set>[@cpu_set][,<lcore_set>[@cpu_set],...]

其中,‘lcore_set’和‘cpu_set’可以是一个数字、范围或者一个组 。 数 字 值 是 “digit ( [0-9]+ ) ”; 范 围 是 "-”;group是“([,,…])”。如果不指定‘@cpu_set’的值,那么默认就使用‘lcore_set’的值。这个选项与corelist的选项‘-l’是兼容的。

例如, "--lcores='1,2@(5-7),(3-5)@(0,2),(0,6),7-8'" 表示
start 9 EAL thread;
lcore 0 runs on cpuset 0x41 (cpu 0,6);
lcore 1 runs on cpuset 0x2 (cpu 1);
lcore 2 runs on cpuset 0xe0 (cpu 5,6,7);
lcore 3,4,5 runs on cpuset 0x5 (cpu 0,2);
lcore 6 runs on cpuset 0x41 (cpu 0,6);
lcore 7 runs on cpuset 0x80 (cpu 7);
lcore 8 runs on cpuset 0x100 (cpu 8).

这个选项以及对应的一组API(rte_thread_set/get_affinity())为lcore提供了亲和的灵活性。lcore可以亲和到一个CPU或者一个CPU集合,使得在运行时调整具体某个CPU承载lcore成为可能。
而另一个方面,多个lcore也可能亲和到同一个核。这里要注意的是,同一个核上多个可抢占式的任务调度涉及非抢占式的库时,会有一定限制。这里以非抢占式无锁rte_ring为例:

  1. 单生产者/单消费者模式,不受影响,可正常使用。
  2. 多 生 产 者 / 多 消 费 者 模 式 且 pthread 调 度 策 略 都 是SCHED_OTHER时,可以使用,性能会有所影响。
  3. 多生产者/多消费者模式且pthread调度策略有SCHED_FIFO或者SCHED_RR时,建议不使用,会产生死锁。

对用户pthread的支持

除了使用DPDK提供的逻辑核之外,用户也可以将DPDK的执行上下文运行在任何用户自己创建的pthread中。在普通用户自定义的pthread 中 , lcore id 的 值 总 是 LCORE_ID_ANY , 以 此 确 定 这 个thread 是 一 个 有 效 的 普 通 用 户 所 创 建 的 pthread 。 用 户 创 建 的pthread可以支持绝大多数DPDK库,没有任何影响。但少数DPDK库可能无法完全支持用户自创建的pthread,如timer和Mempool。以Mempool为例,在用户自创建的pthread中,将不会启用每个核的缓存队列(Mempool cache),这个会对最佳性能造成一定影响。更多影响可以参见开发者手册的多线程章节。

有效地管理计算资源

我们知道,如果网络吞吐很大,超过一个核的处理能力,可以加入更多的核来均衡流量提高整体计算能力。但是,如果网络吞吐比较小,不能耗尽哪怕是一个核的计算能力,如何能够释放计算资源给其他任务呢?通过前面的介绍,我们了解到了DPDK的线程其实就是普通的zthread。使用cgroup能把CPU的配额灵活地配置在不同的线程上。
cgroup是control group的缩写,是Linux内核提供的一种可以限制、记录、隔离进程组所使用的物理资源(如:CPU、内存、I/O等)的机制。DPDK可以借助cgroup实现计算资源配额对于线程的灵活配置,可以有效改善I/O核的闲置利用率。

指令并发与数据并行

指令并发

现代多核处理器几乎都采用了超标量的体系结构来提高指令的并发度,并进一步地允许对无依赖关系的指令乱序执行。这种用空间换时间的方法,极大提高了IPC,使得一个时钟周期完成多条指令成为可能。
图3-6中Haswell微架构流水线是Haswell微架构的流水线参考,从中可以看到Scheduler下挂了8个Port,这表示每个core每个时钟周期最多可以派发8条微指令操作。具体到指令的类型,比如Fast LEA,它可以同时在Port 1和Port 5上派发。换句话说,该指令具有被多发的能力。可以简单地理解为,该指令先后操作两个没有依赖关系的数据时,两条指令有可能被处理器同时派发到执行单元执行,由此该指令实际执行的吞吐率就提升了一倍。
深入浅出DPDK学习笔记(4)——— 并行计算_第7张图片

单指令多数据

在进入到什么是“单指令多数据”之前,先简单认识一下它的意义。“单指令多数据”给了我们这样一种可能,即使某条指令本身不再能被并(多)发,我们依旧可以从数据位宽的维度上提升并行度,从而得到整体性能提升。

SIMD简介

SIMD是Single-Instruction Multiple-Data(单指令多数据)的缩写,从字面的意思就能理解大致的含义。多数据指以特定宽度为一个数据单元,多单元数据独立操作。而单指令指对于这样的多单元数据集,一个指令操作作用到每个数据单元。可以把SIMD理解为向量化的 操 作 方 式 。 典 型 SIMD 操 作 如 图 3-7 所 示 , 两 组 各 4 个 数 据 单 元(X1,X2,X3,X4和Y1,Y2,Y3,Y4)并行操作,相同操作作用在相应的数据单元对上(X1和Y1,X2和Y2,X3和Y3,X4和Y4),4对计算结果组成最后的4数据单元数。
深入浅出DPDK学习笔记(4)——— 并行计算_第8张图片
SIMD指令操作的寄存器相对于通用寄存器(general-purpose register,RPRS)更宽,128bit的XMM寄存器或者256bit的YMM寄存器,有2倍甚至4倍于通用寄存器的宽度(在64bit架构上)。所以,用SIMD指令的一个直接好处是最大化地利用一级缓存访存的带宽,以表3-3所示Haswell微架构中第一级Cache参数为例,每时钟周期峰值带宽为64B(load)(注:每周期支持两个load微指令,每个微指令获取最多32B数据)+32B(store)。可见,该微架构单时钟周期可以访存的最大数据宽度为32B即256bit,只有YMM寄存器宽度的单指令load或者store,可以用尽最大带宽。
深入浅出DPDK学习笔记(4)——— 并行计算_第9张图片
对于I/O密集的负载,如DPDK,最大化地利用访存带宽,减少处理器流水线后端因I/O访问造成的CPU失速,会对性能提升有显著的效果。所以,DPDK在多个基础库中都有利用SIMD做向量化的优化操作。然而,也并不是所有场景都适合使用SIMD,由于数据位较宽,对繁复的窄位宽数据操作副作用比较明显,有时数据格式调整的开销可能更大,所以选择使用SIMD时要仔细评估好负载的特征。

实战DPDK

DPDK中的memcpy就利用到了SSE/AVX的特点。比较典型的就是rte_memcpy内存拷贝函数。内存拷贝是一个非常简单的操作,算法上并无难度,关键在于很好地利用处理器的各种并行特性。当前Intel的处理器(例如Haswell、Sandy Bridge等)一个指令周期内可以 执 行 两 条 Load 指 令 和 一 条 Store 指 令 , 并 且 支 持 SIMD 指 令(SSE/AVX)来在一条指令中处理多个数据,其Cache的带宽也对SIMD指令进行了很好的支持。因此,在rte_memcpy中,我们使用了平 台 所 支 持 的 最 大 宽 度 的 Load 和 Store 指 令 ( Sandy Bridge 为128bit,Haswell为256bit)。此外,由于非对齐的存取操作往往需要花费更多的时钟周期,rte_memcpy优先保证Store指令存储的地址对齐,利用处理器每个时钟周期可以执行两条Load这个超标量特性来弥补一部分非对齐Load所带来的性能损失。

你可能感兴趣的:(dpdk,dpdk)