深入浅出DPDK学习笔记(3)——— Cache和内存

系统架构的演进

在经典计算机系统中一般都有两个标准化的部分:北桥(North Bridge)和南桥(South Bridge)。它们是处理器和内存以及其他外设沟通的渠道。处理器和内存系统通过前端总线(Front Side Bus, FSB)相连,当处理器需要读取或者写回数据时,就通过前端总线和内存控制器通信。图2-1给出了处理器、内存、南北桥以及其他总线之间的关系。
计算机系统中的南北桥示意图
在这种系统中,所有的数据交换都需要通过北桥:

  1. 处理器访问内存需要通过北桥。
  2. 处理器访问所有的外设都需要通过北桥。
  3. 处理器之间的数据交换也需要通过北桥。
  4. 挂在南桥的所有设备访问内存也需要通过北桥。

可以看出,这种系统的瓶颈就在北桥中。当北桥出现拥塞时,所有的设备和处理器都要瘫痪。这种系统设计的另外一个瓶颈体现在对内存的访问上。不管是处理器或者显卡,还是南桥的硬盘、网卡或者光驱,都需要频繁访问内存,当这些设备都争相访问内存时,增大了对北桥带宽的竞争,而且北桥到内存之间也只有一条总线。
为了改善对内存的访问瓶颈,出现了另外一种系统设计,内存控制器并没有被集成在北桥中,而是被单独隔离出来以协调北桥与某个相应的内存之间的交互,如图2-2所示。这样的话,北桥可以和多个内存相连。
更为复杂的南北桥示意图
图2-2所示的这种架构增加了内存的访问带宽,缓解了不同设备对同一内存访问的拥塞问题,但是却没有改进单一北桥芯片的瓶颈的问题。为 了 解 决 这 个 瓶 颈 , 产 生 了 如 图 2-3 所 示 的 NUMA ( Non-Uniform Memory Architecture,非一致性内存架构)系统。
NUMA系统
在这种架构下,在一个配有四核的机器中,不需要一个复杂的北桥就能将内存带宽增加到以前的四倍。当然,这样的架构也存在缺点。该系统中,访问内存所花的时间和处理器相关。之所以和处理器相关是因为该系统每个处理器都有本地内存(Local memory),访问本地内存的时间很短,而访问远程内存(remote memory),即其他处理器的本地内存,需要通过额外的总线!对于某个处理器来说,当其要访问其他的内存时,轻者要经过另外一个处理器,重者要经过2个处理器,才能达到访问非本地内存的目的,因此内存与处理器的“距离”不同,访问的时间也有所差异。

Cache系统简介

在处理器速度不断增加的形势下,处理器处理数据的能力也得到大大提升。但是,数据是存储在内存中的,虽然随着DDR2、DDR3、DDR4的新技术不断推出,内存的吞吐率得到了大大提升,但是相对于处理器来讲,仍然非常慢。一般来讲,处理器要从内存中直接读取数据都要花大概几百个时钟周期,在这几百个时钟周期内,处理器除了等待什么也不能做。在这种环境下,才提出了Cache的概念,其目的就是为了匹配处理器和内存之间存在的巨大的速度鸿沟。

Cache的种类

一般来讲,Cache由三级组成,之所以对Cache进行分级,也是从成本和生产工艺的角度考虑的。一级(L1)最快,但是容量最小;三级(LLC,Last Level Cache)最慢,但是容量最大,在早期计算机系统中,这一级Cache也可以省略。不过在当今时代,大多数处理器都会包含这一级Cache。
Cache是一种SRAM(静态随机访问存储器),在早期计算机系统中,一般一级和二级Cache集成在处理器内部,三级Cache集成在主板上,这样做的主要原因是生产工艺的问题,处理器内部能够集成的晶体管数目有限,而三级Cache又比较大,从而占用的晶体管数量较多。以英特尔最新的Haswell i7-5960X为例,一级Cache有32K,二级有512K,但是三级却有20M,在早期计算机系统中集成如此大的SRAM实在是很难做到。不过随着90nm、45nm、32nm以及22nm工艺的推出,处理器内部能够容纳更多的晶体管,所以三级Cache也慢慢集成到处理器内部了。
一级Cache,一般分为数据Cache和指令Cache,数据Cache用来存储数据,而指令Cache用于存放指令。这种Cache速度最快,一般处理器只需要3~5个指令周期就能访问到数据,因此成本高,容量小,一般都只有几十KB。在多核处理器内部,每个处理器核心都拥有仅属于自己的一级Cache。
二级Cache,和一级Cache分为数据Cache和指令Cache不同,数据和指令都无差别地存放在一起。速度相比一级Cache慢一些,处理器大约需要十几个处理器周期才能访问到数据,容量也相对来说大一些,一般有几百KB到几MB不等。在多核处理器内部,每个处理器核心都拥有仅属于自己的二级Cache。
三级Cache,速度更慢,处理器需要几十个处理器周期才能访问到数据,容量更大,一般都有几MB到几十个MB。在多核处理器内部,三级Cache由所有的核心所共有。这样的共享方式,其实也带来一个问题,有的处理器可能会极大地占用三级Cache,导致其他处理器只能占用极小的容量,从而导致Cache不命中,性能下降。因此,英特尔公司推出了Intel ® CAT技术,确保有一个公平,或者说软件可配置的算法来控制每个核心可以用到的Cache大小。
在这里插入图片描述
对于各级Cache的访问时间,在英特尔的处理器上一直都保持着非常稳定,一级Cache访问是4个指令周期,二级Cache是12个指令周期,三级Cache则是26~31个指令周期。这里所谓的稳定,是指在不同频率、不同型号的英特尔处理器上,处理器访问这三级Cache所花费的指令周期数是相同的。

TLB Cache

在早期计算机系统中,程序员都是直接访问物理地址进行编程,当程序出现错误时,整个系统都瘫痪掉;或者在多进程系统中,当一个进程出现问题,对属于另外一个进程的数据或者指令区域进行写操作,会导致另外一个进程崩溃。因此,随着计算机技术的进步,虚拟地址和分段分页技术被提出来用来保护脆弱的软件系统。软件使用虚拟地址访问内存,而处理器负责虚拟地址到物理地址的映射工作。为了完成映射工作,处理器采用多级页表来进行多次查找最终找到真正的物理地址。当处理器发现页表中找不到真正对应的物理地址时,就会发出一个异常,挂起寻址错误的进程,但是其他进程仍然可以正常工作。
页表也存储在内存中,处理器虽然可以利用三级Cache系统来缓存页表内容,但是基于两点原因不能这样做。一种原因下面的段落会讲到,我们先讲另外一个原因。处理器每当进行寻址操作都要进行一次映射工作,这使得处理器访问页表的频率非常得高,有可能一秒钟需要访问几万次。因此,即使Cache的命中率能够达到99%以上,也就是说不命中率有1%,那么不命中的概率每秒也有几百次,这会导致处理器在单位时间内访问内存(因为Cache没有命中,只能访问内存)的次数增多,降低了系统的性能。
因 此 , TLB ( Translation Look-aside Buffer ) Cache 应 运 而生,专门用于缓存内存中的页表项。TLB一般都采用相连存储器或者按内容访问存储器(CAM,Content Addressable Memory)。相连存储器使用虚拟地址进行搜索,直接返回对应的物理地址,相对于内存中的多级页表需要多次访问才能得到最终的物理地址,TLB查找无疑大大减少了处理器的开销,这也是上文提到的第二个原因。如果需要的地址在TLB Cache中,相连存储器迅速返回结果,然后处理器用该物理地址访问内存,这样的查找操作也称为TLB命中;如果需要的地址不在TLB Cache中,也就是不命中,处理器就需要到内存中访问多级页表,才能最终得到物理地址。

Cache地址映射和变换

Cache的容量一般都很小,即使是最大的三级Cache(L3)也只有20MB~30MB。而当今内存的容量都是以GB作为单位,在一些服务器平台上,则都是以TB(1TB=1024GB)作为单位。在这种情况下,如何把内存中的内容存放到Cache中去呢?这就需要一个映射算法和一个分块机制。
分块机制就是说,Cache和内存以块为单位进行数据交换,块的大小通常以在内存的一个存储周期中能够访问到的数据长度为限。当今主流块的大小都是64字节,因此一个Cache line就是指64个字节大小的数据块。
而映射算法是指把内存地址空间映射到Cache地址空间。具体来说,就是把存放在内存中的内容按照某种规则装入到Cache中,并建立内存地址与Cache地址之间的对应关系。当内容已经装入到Cache之后,在实际运行过程中,当处理器需要访问这个数据块内容时,则需要把内存地址转换成Cache地址,从而在Cache中找到该数据块,最终返回给处理器。
根据Cache和内存之间的映射关系的不同,Cache可以分为三类:第一类是全关联型Cache(full associative cache),第二类是直接关联型Cache(direct mapped cache),第三类是组关联型Cache(N-ways associative cache)。

全关联型Cache

全关联型Cache是指主存中的任何一块内存都可以映射到Cache中的任意一块位置上。在Cache中,需要建立一个目录表,目录表的每个表项都有三部分组成:内存地址、Cache块号和一个有效位。当处理器需要访问某个内存地址时,首先通过该目录表查询是否该内容缓存在Cache中,具体过程如图2-5所示。
全关联Cache查找过程
首先,用内存的块地址A在Cache的目录表中进行查询,如果找到等值的内存块地址,检查有效位是否有效,只有有效的情况下,才能通过Cache块号在Cache中找到缓存的内存,并且加上块内地址B,找到相应数据,这时则称为Cache命中,处理器拿到数据返回;否则称为不命中,处理器则需要在内存中读取相应的数据。
可以看出,使用全关联型Cache,块的冲突最小(没有冲突),Cache的利用率也高,但是需要一个访问速度很快的相联存储器。随着Cache容量的增加,其电路设计变得十分复杂,因此只有容量很小的 Cache 才 会 设 计 成 全 关 联 型 的 ( 如 一 些 英 特 尔 处 理 器 中 的 TLB Cache)。

直接关联型Cache

直接关联型Cache是指主存中的一块内存只能映射到Cache的一个特定的块中。假设一个Cache中总共存在N个Cache line,那么内
存被分成N等分,其中每一等分对应一个Cache line。举个简单的例子,假设Cache的大小是2K,而一个Cache line的大小是64B,那么就一共有2K/64B=32个Cache line,那么对应我们的内存,第1块( 地 址 0~63 ) , 第 33 块 ( 地 址 6432~6433-1 ) , 以 及 第(N32+1)块(地址64(N-1)~64N-1)都被映射到Cache第一块中;同理,第2块,第34块,以及第(N32+2)块都被映射到Cache第二块中;可以依次类推其他内存块。
直接关联型Cache的目录表只有两部分组成:区号和有效位。其查找过程如图2-6所示。首先,内存地址被分成三部分:区号A、块号B和块内地址C。根据区号A在目录表中找到完全相等的区号,并且在有效位有效的情况下,说明该数据在Cache中,然后通过内存地址的块号B获得在Cache中的块地址,加上块内地址C,最终找到数据。如果在目录表中找不到相等的区号,或者有效位无效的情况下,则说明该内容不在Cache中,需要到内存中读取。
直接相联Cache查找过程
可以看出,直接关联是一种很“死”的映射方法,当映射到同一个Cache块的多个内存块同时需要缓存在Cache中时,只有一个内存块能够缓存,其他块需要被“淘汰”掉。因此,直接关联型命中率是最低的,但是其实现方式最为简单,匹配速度也最快。

组关联型Cache

组关联型Cache是目前Cache中用的比较广泛的一种方式,是前两种Cache的折中形式。在这种方式下,内存被分为很多组,一个组的大小为多个Cache line的大小,一个组映射到对应的多个连续的Cache line,也就是一个Cache组,并且该组内的任意一块可以映射到对应Cache组的任意一个。可以看出,在组外,其采用直接关联型Cache的映射方式,而在组内,则采用全关联型Cache的映射方式。
假设有一个4路组关联型Cache,其大小为1M,一个Cache line的大小为64B,那么总共有16K个Cache line,但是在4路组关联的情况下,我们并不是简简单单拥有16K个Cache line,而是拥有了4K个组,每个组有4个Cache line。一个内存单元可以缓存到它所对应的组中的任意一个Cache line中去。
图2-7以4路组关联型Cache为例介绍其在Cache中的查找过程。目录表由三部分组成,分别是“区号+块号”、Cache块号和有效位。当收到一个内存地址时,该地址被分成四部分:区号A、组号B、块号C和块内地址D。首先,根据组号B按地址查找到一组目录表项,在4路组关联中,则有四个表项,每个表项都有可能存放该内存块;然后,根据区号A和块号C在该组表项中进行关联查找(即并行查找,为了提高效率),如果匹配且有效位有效,则表明该数据块缓存在Cache中 , 得 到 Cache 块 号 , 加 上 块 内 地 址 D , 可 以 得 到 该 内 存 地 址 在Cache中映射的地址,得到数据;如果没有找到匹配项或者有效位无效,则表示该内存块不在Cache中,需要处理器到内存中读取。
4路组关联型Cache查找过程
实 际 上 , 直 接 关 联 型 Cache 和 全 关 联 型 Cache 只 是 组 关 联 型Cache的特殊情况,当组内Cache Line数目为1时,即为直接关联型Cache 。 而 当 组 内 Cache Line 数 目 和 Cache 大 小 相 等 时 , 即 整 个Cache只有一个组,这成为全关联型Cache。

Cache的写策略

内存的数据被加载到Cache后,在某个时刻其要被写回内存,对于这个时刻的选取,有如下几个不同的策略。

  • 直写(write-through):所谓直写,就是指在处理器对Cache写入的同时,将数据写入到内存中。这种策略保证了在任何时刻,内存的数据和Cache中的数据都是同步的,这种方式简单、可靠。但由于处理器每次对Cache更新时都要对内存进行写操作,因此总线工作繁忙,内存的带宽被大大占用,因此运行速度会受到影响。假设一段程序在频繁地修改一个局部变量,尽管这个局部变量的生命周期很短,而且其他进程/线程也用不到它,CPU依然会频繁地在Cache和内存之间交换数据,造成不必要的带宽损失。
  • 回写(write-back):回写相对于直写而言是一种高效的方法。直写不仅浪费时间,而且有时是不必要的,比如上文提到的局部变量的例子。回写系统通过将Cache line的标志位字段添加一个Dirty标志位,当处理器在改写了某个Cache line后,并不是马上把其写回内存,而是将该Cache
    line的Dirty标志设置为1。当处理器再次修改该Cache line并且写回到Cache中,查表发现该Dirty位已经为1,则先将Cache line内容写回到内存中相应的位置,再将新数据写到Cache中。其实,回写策略在多核系统中会引起Cache一致性的问题。设想有两个处理器核心都需要对某个内存块进行读写,其中一个核心已经修改了该数据块,并且写回到Cache中,设置了Dirty位;这时另外一个核心也完成了该内存块的修改,并且准备写入到Cache中,这时才发现该Cache line是“脏”的,在这种情况下,Cache如何处理呢?之后的章节我们会继续这个话题。

除 了 上 述 这 两 种 写 策 略 , 还 有 WC ( write-combining ) 和UC(uncacheable)。这两种策略都是针对特殊的地址空间来使用的。

  • write-combining策略是针对于具体设备内存(如显卡的RAM)的一种优化处理策略。对于这些设备来说,数据从Cache到内存转移的开销比直接访问相应的内存的开销还要高得多,所以应该尽量避免过多的数据转移。试想,如果一个Cache
    line里的字被改写了,处理器将其写回内存,紧接着又一个字被改写了,处理器又将该Cacheline写回内存,这样就显得低效,符合这种情况的一个例子就是显示屏上水平相连的像素点数据。write-combining策略的引入就是为了解决这种问题,顾名思义,这种策略就是当一个Cache
    line里的数据一个字一个字地都被改写完了之后,才将该Cache line写回到内存中。
  • uncacheable内存是一部分特殊的内存,比如PCI设备的I/O空间通过MMIO方式被映射成内存来访问。这种内存是不能缓存在Cache中的,因为设备驱动在修改这种内存时,总是期望这种改变能够尽快通过总线写回到设备内部,从而驱动设备做出相应的动作。如果放在Cache中,硬件就无法收到指令。

Cache预取

Cache的预取原理

Cache之所以能够提高系统性能,主要是程序执行存在局部性现象,即时间局部性和空间局部性。

  • 时间局部性:是指程序即将用到的指令/数据可能就是目前正在使用的指令/数据。因此,当前用到的指令/数据在使用完毕之后可以暂时存放在Cache中,可以在将来的时候再被处理器用到。一个简单的例子就是一个循环语句的指令,当循环终止的条件满足之前,处理器需要反复执行循环语句中的指令。
  • 空间局部性:是指程序即将用到的指令/数据可能与目前正在使用的指令/数据在空间上相邻或者相近。因此,在处理器处理当前指令/数据时,可以从内存中把相邻区域的指令/数据读取到Cache中,这样,当处理器需要处理相邻内存区域的指令/数据时,可以直接从Cache中读取,节省访问内存的时间。一个简单的例子就是一个需要顺序处理的数组。

所谓的Cache预取,也就是预测数据并取入到Cache中,是根据空间局部性和时间局部性,以及当前执行状态、历史执行过程、软件提示等信息,然后以一定的合理方法,在数据/指令被使用前取入Cache。这样,当数据/指令需要被使用时,就能快速从Cache中加载到处理器内部进行运算和执行。

软件预取

软件预取指令

预取指令使软件开发者在性能相关区域,把即将用到的数据从内存中加载到Cache,这样当前数据处理完毕后,即将用到的数据已经在Cache中,大大减小了从内存直接读取的开销,也减少了处理器等待的时间,从而提高了性能。增加预取指令并不是让软件开发者需要时时考虑到Cache的存在,让软件自己来管理Cache,而是在某些热点区域,或者性能相关区域能够通过显示地加载数据到Cache,提高程序执行的效率。不过,不正确地使用预取指令,造成Cache中负载过重或者无用数据的比例增加,反而还会造成程序性能下降,也有可能造成其他程序执行效率降低(比如某程序大量加载数据到三级Cache,影响到其他程序)。因此,软件开发者需要仔细衡量利弊,充分进行测试,才能够正确地优化程序。需要指出的是,预取指令只对数据有效,对指令预取是无效的。下图是预取指令列表。
预取指令列表
预取指令是汇编指令,对于很多软件开发者来说,直接插入汇编指令不是很方便,一些程序库也提供了相应的软件版本。比如“mmintrin.h”提供了如下的函数原型:

void _mm_prefetch(char *p, int i);

p是需要预取的内存地址,i对应相应的预取指令,如表2-2所示。
软件库中的预取函数

DPDK中的预取

在讨论之前,我们需要了解另外一个和性能相关的话题。DPDK一个处理器核每秒钟大概能够处理33M个报文,大概每30纳秒需要处理一个报文,假设处理器的主频是2.7GHz,那么大概每80个处理器时钟周期就需要处理一个报文。那么,处理报文需要做一些什么事情呢?以下是一个基本过程。

  1. 写接收描述符到内存,填充数据缓冲区指针,网卡收到报文后就会根据这个地址把报文内容填充进去。
  2. 从内存中读取接收描述符(当收到报文时,网卡会更新该结构)(内存读),从而确认是否收到报文。
  3. 从接收描述符确认收到报文时,从内存中读取控制结构体的指针(内存读),再从内存中读取控制结构体(内存读),把从接收描述符读取的信息填充到该控制结构体。
  4. 更新接收队列寄存器,表示软件接收到了新的报文。
  5. 内存中读取报文头部(内存读),决定转发端口。
  6. 从控制结构体把报文信息填入到发送队列发送描述符,更新发送队列寄存器。
  7. 从内存中读取发送描述符(内存读),检查是否有包被硬件传送出去。
  8. 如果有的话,从内存中读取相应控制结构体(内存读),释放数据缓冲区。

可以看出,处理一个报文的过程,需要6次读取内存(见上“内存读”)。而之前我们讨论过,处理器从一级Cache读取数据需要3~5个时钟周期,二级是十几个时钟周期,三级是几十个时钟周期,而内存则需要几百个时钟周期。从性能数据来说,每80个时钟周期就要处理一个报文。
因此,DPDK必须保证所有需要读取的数据都在Cache中,否则一旦出现Cache不命中,性能将会严重下降。为了保证这点,DPDK采用了多种技术来进行优化,预取只是其中的一种。
而从上面的介绍可以看出,控制结构体和数据缓冲区的读取都没有遵循硬件预取的原则,因此DPDK必须用一些预取指令来提前加载相应数据。以下就是部分接收报文的代码。

while (nb_rx < nb_pkts) {
        rxdp = &rx_ring[rx_id]; // 读取接收描述符
        staterr = rxdp->wb.upper.status_error;
        // 检查是否有报文收到
        if (!(staterr & rte_cpu_to_le_32(IXGBE_RXDADV_STAT_DD)))
                break;
       rxd = *rxdp;
       // 分配数据缓冲区
       nmb = rte_rxmbuf_alloc(rxq->mb_pool);
       nb_hold++;
       // 读取控制结构体
       rxe = &sw_ring[rx_id];
       ......
       rx_id++;
       if (rx_id == rxq->nb_rx_desc)
       rx_id = 0;
       // 预取下一个控制结构体 mbuf
       rte_ixgbe_prefetch(sw_ring[rx_id].mbuf);
       // 预取接收描述符和控制结构体指针
       if ((rx_id & 0x3) == 0) {
              rte_ixgbe_prefetch(&rx_ring[rx_id]);
              rte_ixgbe_prefetch(&sw_ring[rx_id]);
       }
       ......
       // 预取报文
       rte_packet_prefetch((char *)rxm->buf_addr + rxm->data_off);
       // 把接收描述符读取的信息存储在控制结构体 mbuf 中
       rxm->nb_segs = 1;
       rxm->next = NULL;
       rxm->pkt_len = pkt_len;
       rxm->data_len = pkt_len;
       rxm->port = rxq->port_id;
       ......
       rx_pkts[nb_rx++] = rxm;
}

Cache一致性

我们知道,Cache是按照Cache Line作为基本单位来组织内容的 , 其 大 小 是 32 ( 较 早 的 ARM 、 1990 年 ~2000 年 早 期 的 x86 和PowerPC)、64(较新的ARM和x86)或128(较新的Power ISA机器)字节。当我们定义了一个数据结构或者分配了一段数据缓冲区之后,在内存中就有一个地址和其相对应,然后程序就可以对它进行读写。对于读,首先是从内存加载到Cache,最后送到处理器内部的寄存器;对于写,则是从寄存器送到Cache,最后通过内部总线写回到内存。这两个过程其实引出了两个问题:

  1. 该数据结构或者数据缓冲区的起始地址是Cache Line对齐的吗?如果不是,即使该数据区域的大小小于Cache Line,那么也需要占用两个Cache entry;并且,假设第一个Cache Line前半部属于另外一个数据结构并且另外一个处理器核正在处理它,那么当两个核都修改了该Cache Line从而写回各自的一级Cache,准备送到内存时,如何同步数据?毕竟每个核都只修改了该Cache Line的一部分。
  2. 假设该数据结构或者数据缓冲区的起始地址是Cache Line对齐的,但是有多个核同时对该段内存进行读写,当同时对内存进行写回操作时,如何解决冲突?
    接下来,我们先回答第一个问题,然后再回答第二个问题。

Cache Line对齐

对于第一个问题,其实有多种方法来解决。比如,用解决第二个问题的方法去解决它,从本质来讲,第一个问题和第二个问题都是因为多个核同时操作一个Cache Line进行写操作造成的。另外一个简单的方法就是定义该数据结构或者数据缓冲区时就申明对齐,DPDK对很多结构体定义的时候就是如此操作的。见下例:

struct rte_ring_debug_stats {
        uint64_t enq_success_bulk;
        uint64_t enq_success_objs;
        uint64_t enq_quota_bulk;
        uint64_t enq_quota_objs;
        uint64_t enq_fail_bulk;
        uint64_t enq_fail_objs;
        uint64_t deq_success_bulk;
        uint64_t deq_success_objs;
        uint64_t deq_fail_bulk;
        uint64_t deq_fail_objs;
} __rte_cache_aligned;

__rte_cache_aligned的定义如下所示:

#define RTE_CACHE_LINE_SIZE 64
#define __rte_cache_aligned
__attribute__((__aligned__(RTE_CACHE_LINE_SIZE)))

其实现在编译器很多时候也比较智能,会在编译的时候尽量做到Cache Line对齐。

Cache一致性问题的由来

上文提到的第二个问题,即多个处理器对某个内存块同时读写,会引起冲突的问题,这也被称为Cache一致性问题。
Cache一致性问题出现的原因是在一个多处理器系统中,每个处理器核心都有独占的Cache系统(比如我们之前提到的一级Cache和二级Cache),而多个处理器核心都能够独立地执行计算机指令,从而有可能同时对某个内存块进行读写操作,并且由于我们之前提到的回写和直写的Cache策略,导致一个内存块同时可能有多个备份,有的已经写回到内存中,有的在不同的处理器核心的一级、二级Cache中。由于Cache缓存的原因,我们不知道数据写入的时序性,因而也不知道哪个备份是最新的。还有另外一个一种可能,假设有两个线程A和B共享一个变量,当线程A处理完一个数据之后,通过这个变量通知线程B,然后线程B对这个数据接着进行处理,如果两个线程运行在不同的处理器核心上,那么运行线程B的处理器就会不停地检查这个变量,而这个变量存储在本地的Cache中,因此就会发现这个值总也不会发生变化。
其实,关于一致性问题的阐述,我们附加了很多限制条件,比如多核,独占Cache,Cache写策略。如果当中有一个或者多个条件不成立时可能就不会引发一致性的问题了。

  1. 假设只是单核处理器,那么只有一个处理器会对内存进行读写,Cache也是只有一份,因而不会出现一致性的问题。
  2. 假设是多核处理器系统,但是Cache是所有处理器共享的,那么当一个处理器对内存进行修改并且缓存在Cache中时,其他处理器
    都能看到这个变化,因而也不会产生一致性的问题。
  3. 假设是多核处理器系统,每个核心也有独占的Cache,但是Cache 只 会 采 用 直 写 , 那 么 当 一 个 处 理 器 对 内 存 进 行 修 改 之 后 ,Cache会马上将数据写入到内存中,也不会有问题吗?考虑之前我们介绍的一个例子,线程A把结果写回到内存中,但是线程B只会从独占的 Cache 中 读 取 这 个 变 量 ( 因 为 没 人 通 知 它 内 存 的 数 据 产 生 了 变化),因此在这种条件下还是会有Cache一致性的问题。

因而,Cache一致性问题的根源是因为存在多个处理器独占的Cache,而不是多个处理器。如果多个处理器共享Cache,也就是说只有一级Cache,所有处理器都共享它,在每个指令周期内,只有一个处理器核心能够通过这个Cache做内存读写操作,那么就不会存在Cache一致性问题。
讲到这里,似乎我们找到了一劳永逸解决Cache一致性问题的办法,只要所有的处理器共享Cache,那么就不会有任何问题。但是,这种解决办法的问题就是太慢了。首先,既然是共享的Cache,势必容量不能小,那么就是说访问速度相比之前提到的一级、二级Cache,速度肯定几倍或者十倍以上;其次,每个处理器每个时钟周期内只有一个处理器才能访问Cache,那么处理器把时间都花在排队上了,这样效率太低了。
因而,我们还是需要针对出现的Cache一致性问题,找出一个解决办法。

一致性协议

解 决 Cache 一 致 性 问 题 的 机 制 有 两 种 : 基 于 目 录 的 协 议( Directory-based protocol ) 和 总 线 窥 探 协 议 ( Bus snoopingprotocol)。其实还有另外一个Snarfing协议,在此不作讨论。

  • 基于目录协议的系统中,需要缓存在Cache的内存块被统一存储在一个目录表中,目录表统一管理所有的数据,协调一致性问题。该目录表类似于一个仲裁者,当处理器需要把一个数据从内存中加载到自己独占的Cache中时,需要向目录表提出申请;当一个内存块被某个处理器改变之后,目录表负责改变其状态,更新其他处理器的Cache中的备份,或者使其他处理器的Cache的备份无效。
  • 总线窥探协议是在1983年被首先提出来,这个协议提出了一个窥探(snooping)的动作,即对于被处理器独占的Cache中的缓存的内容,该处理器负责监听总线,如果该内容被本处理器改变,则需要通过总线广播;反之,如果该内容状态被其他处理器改变,本处理器的Cache从总线收到了通知,则需要相应改变本地备份的状态。

可以看出,这两类协议的主要区别在于基于目录的协议采用全局统一管理不同Cache的状态,而总线窥探协议则使用类似于分布式的系统,每个处理器负责管理自己的Cache的状态,通过共享的总线,同步不同Cache备份的状态。
通 过 之 前 的 描 述 可 以 发 现 , 在 上 面 两 种 协 议 中 , 每 个 CacheBlock都必须有自己的一个状态字段。而维护Cache一致性问题的关键在于维护每个Cache Block的状态域。Cache控制器通常使用一个状态机来维护这些状态域。基于目录的协议的延迟性较大,但是在拥有很多个处理器的系统中,它有更好的可扩展性。而总线窥探协议适用于具有广播能力的总线结构,允许每个处理器能够监听其他处理器对内存的访问,适合小规模的多核系统。
接下来,我们将主要介绍总线窥探协议。最经典的总线窥探协议Write-Once由C.V.Ravishankar和James R.Goodman于1983年提出,继而被x86、ARM和Power等架构广泛采用,衍生出著名的MESI协议,或者称为Illinois Protocol。之所以有这个名字,是因为该协议是由伊利诺伊州立大学研发出来的。

MESI协议

MESI协议是Cache line四种状态的首字母的缩写,分别是修改(Modified)态、独占(Exclusive)态、共享(Shared)态和失效(Invalid)态。Cache中缓存的每个Cache Line都必须是这四种状态中的一种。详见[Ref2-2]。

  • 修改态,如果该Cache
    Line在多个Cache中都有备份,那么只有一个备份能处于这种状态,并且“dirty”标志位被置上。拥有修改态Cache
    Line的Cache需要在某个合适的时候把该Cache Line写回到内存中。但是在写回之前,任何处理器对该Cache
    Line在内存中相对应的内存块都不能进行读操作。Cache Line被写回到内存中之后,其状态就由修改态变为共享态。
  • 独占态,和修改状态一样,如果该Cache
    Line在多个Cache中都有备份,那么只有一个备份能处于这种状态,但是“dirty”标志位没有置上,因为它是和主内存内容保持一致的一份拷贝。如果产生一个读请求,它就可以在任何时候变成共享态。相应地,如果产生了一个写请求,它就可以在任何时候变成修改态。
  • 共享态,意味着该Cache
    Line可能在多个Cache中都有备份,并且是相同的状态,它是和内存内容保持一致的一份拷贝,而且可以在任何时候都变成其他三种状态。
  • 失效态,该Cache Line要么已经不在Cache中,要么它的内容已经过时。一旦某个Cache
    Line被标记为失效,那它就被当作从来没被加载到Cache中。

对于某个内存块,当其在两个(或多个)Cache中都保留了一个备份时,只有部分状态是允许的。如表2-3所示,横轴和竖轴分别表示了两个Cache中某个Cache Line的状态,两个Cache Line都映射到相同的内存块。如果一个Cache Line设置成M态或者E态,那么另外一个Cache Line只能设置成I态;如果一个Cache Line设置成S态,那么另外一个Cache Line可以设置成S态或者I态;如果一个CacheLine设置成I态,那么另外一个Cache Line可以设置成任何状态。
MESI中两个Cache备份的状态矩阵
那么,究竟怎样的操作才会引起Cache Line的状态迁移,从而保持Cache的一致性呢?以下所示表2-4是根据不同读写操作触发的状态迁移表。
MESI状态迁移表

DPDK如何保证Cache一致性

从上面的介绍我们知道,Cache一致性这个问题的最根本原因是处理器内部不止一个核,当两个或多个核访问内存中同一个Cache行的内容时,就会因为多个Cache同时缓存了该内容引起同步的问题。
DPDK与生俱来就是为了网络平台的高性能和高吞吐,并且总是需要部署在多核的环境下。因此,DPDK必须提出好的解决方案,避免由于不必要的Cache一致性开销而造成额外的性能损失。
其实,DPDK的解决方案很简单,首先就是避免多个核访问同一个内存地址或者数据结构。这样,每个核尽量都避免与其他核共享数据,从而减少因为错误的数据共享(cache line false sharing)导致的Cache一致性的开销。
以下是两个DPDK为了避免Cache一致性的例子。
例子1:数据结构定义。DPDK的应用程序很多情况下都需要多个核同时来处理事务,因而,对于某些数据结构,我们给每个核都单独定义一份,这样每个核都只访问属于自己核的备份。如下例所示:

struct lcore_conf {
        uint16_t n_rx_queue;
        struct lcore_rx_queue rx_queue_list[MAX_RX_QUEUE_PER_LCORE];
        uint16_t tx_queue_id[RTE_MAX_ETHPORTS];
        struct mbuf_table tx_mbufs[RTE_MAX_ETHPORTS];
        lookup_struct_t * ipv4_lookup_struct;
        lookup_struct_t * ipv6_lookup_struct;
} __rte_cache_aligned; //Cache 行对齐
struct lcore_conf lcore[RTE_MAX_LCORE] __rte_cache_aligned;

以上的数据结构“struct lcore_conf”总是以Cache行对齐,这样就 不 会 出 现 该 数 据 结 构 横 跨 两 个 Cache 行 的 问 题 。 而 定 义 的 数组“lcore[RTE_MAX_LCORE]”中RTE_MAX_LCORE指一个系统中最大核的数量。DPDK中对每个核都进行编号,这样核n就只需要访问lcore[n],核m只需要访问lcore[m],这样就避免了多个核访问同一个结构体。

例子2:对网络端口的访问。在网络平台中,少不了访问网络设备,比如网卡。多核情况下,有可能多个核访问同一个网卡的接收队列/发送队列,也就是在内存中的一段内存结构。这样,也会引起Cache一致性的问题。那么DPDK是如何解决这个问题的呢?
多核多队列收发示意图
需要指出的是,网卡设备一般都具有多队列的能力,也就是说,一个网卡有多个接收队列和多个访问队列,其他章节会很详细讲到,本节不再赘述。
DPDK中,如果有多个核可能需要同时访问同一个网卡,那么DPDK就会为每个核都准备一个单独的接收队列/发送队列。这样,就避免了竞争,也避免了Cache一致性问题。
图2-9是四个核可能同时访问两个网络端口的图示。其中,网卡1和网卡2都有两个接收队列和四个发送队列;核0到核3每个都有自己的一个接收队列和一个发送队列。核0从网卡1的接收队列0接收数据,可以发送到网卡1的发送队列0或者网卡2的发送队列0;同理,核3从网卡2的接收队列1接收数据,可以发送到网卡1的发送队列3或者网卡2的发送队列3。

TLB和大页

逻辑地址到物理地址的转换

图2-10是x86在32位处理器上进行一次逻辑地址(或线性地址)转换物理地址的示意图。
处理器把一个32位的逻辑地址分成3段,每段都对应一个偏移地址。查表的顺序如下:

  1. 根据位bit[31:22]加上寄存器CR3存放的页目录表的基址,获得页目录表中对应表项的物理地址,读内存,从内存中获得该表项内容,从而获得下一级页表的基址。
  2. 根据位bit[21:12]页表加上上一步获得的页表基址,获得页表中对应表项的物理地址,读内存,从内存中获得该表项内容,从而获得内容页的基址。
  3. 根据为bit[11:0]加上上一步获得的内容页的基址得到准确的物理地址,读内容获得真正的内容。
    页表查找过程
    通过之前的介绍我们知道有Cache的存在,我们也可以把页表缓存在Cache中,但是由于页表项的快速访问性(每次程序内存寻址都需要访问页表)和Cache的“淘汰”机制,有必要提供专门的Cache来保存,也就是TLB。

TLB

相比之前提到的三段查表方式,引入TLB之后,查找过程发生了一些变化。TLB中保存着逻辑地址前20位[31:12]和页框号的对应关系,如果匹配到逻辑地址就可以迅速找到页框号(页框号可以理解为页表项),通过页框号与逻辑地址后12位的偏移组合得到最终的物理地址。
如果没在TLB中匹配到逻辑地址,就出现TLB不命中,从而像我们刚才讨论的那样,进行常规的查找过程。如果TLB足够大,那么这个转换过程就会变得很快速。但是事实是,TLB是非常小的,一般都是几十项到几百项不等,并且为了提高命中率,很多处理器还采用全相连方式。另外,为了减少内存访问的次数,很多都采用回写的策略。在有些处理器架构中,为了提高效率,还将TLB进行分组,以x86架构为例,一般都分成以下四组TLB:
第 一 组 : 缓 存 一 般 页 表 ( 4KB 页 面 ) 的 指 令 页 表 缓 存(Instruction-TLB)。
第二组:缓存一般页表(4KB页面)的数据页表缓存(Data-TLB)。
第三组:缓存大尺寸页表(2MB/4MB页面)的指令页表缓存(Instruction-TLB)。
第四组:缓存大尺寸页表(2MB/4MB页面)的数据页表缓存(Data-TLB)。

使用大页

从上面的逻辑地址到物理地址的转换我们知道,如果采用常规页(4KB)并且使TLB总能命中,那么至少需要在TLB表中存放两个表项,在这种情况下,只要寻址的内容都在该内容页内,那么两个表项就足够了。如果一个程序使用了512个内容页也就是2MB大小,那么需要512个页表表项才能保证不会出现TLB不命中的情况。通过上面的介绍,我们知道TLB大小是很有限的,随着程序的变大或者程序使用内存的增加,那么势必会增加TLB的使用项,最后导致TLB出现不命中的情况。那么,在这种情况下,大页的优势就显现出来了。如果采用2MB作为分页的基本单位,那么只需要一个表项就可以保证不出现TLB不命中的情况;对于消耗内存以GB(2 30 )为单位的大型程序,可以采用1GB为单位作为分页的基本单位,减少TLB不命中的情况。

如何激活大页

我们以Linux系统为例来说明如何激活大页的使用。首先,Linux操作系统采用了基于hugetlbfs的特殊文件系统来加入对2MB或者1GB的大页面支持。这种采用特殊文件系统形式支持大页面的方式,使得应用程序可以根据需要灵活地选择虚存页面大小,而不会被强制使用2MB大页面。
为了使用大页,必须在编译内核的时候激活hugetlbfs。
在激活hugetlbfs之后,还必须在Linux启动之后保留一定数量的内存作为大页来使用。现在有两种方式来预留内存。
第一种是在Linux命令行指定,这样Linux启动之后内存就已经预留;第二种方式是在Linux启动之后,可以动态地预留内存作为大页使
用。以下是2MB大页命令行的参数。

Huagepage=1024

对于其他大小的大页,比如1GB,其大小必须显示地在命令行指定,并且命令行还可以指定默认的大页大小。比如,我们想预留4GB内存作为大页使用,大页的大小为1GB,那么可以用以下的命令行:

default_hugepagesz=1G hugepagesz=1G hugepages=4

需要指出的是,系统能否支持大页,支持大页的大小为多少是由其使用的处理器决定的。以Intel ® 的处理器为例,如果处理器的功能列表有PSE,那么它就支持2MB大小的大页;如果处理器的功能列表有PDPE1GB,那么就支持1GB大小的大页。当然,不同体系架构支持的大页的大小都不尽相同,比如x86处理器架构的2MB和1GB大页,而在IBM Power架构中,大页的大小则为16MB和16GB。
在我们之后会讲到的NUMA系统中,因为存在本地内存的问题,系统会均分地预留大页。假设在有两个处理器的NUMA系统中,以上例预留4GB内存为例,在NODE0和NODE1上会各预留2GB内存。在Linux启动之后,如果想预留大页,则可以使用以下的方法来预留内存。在非NUMA系统中,可以使用以下方法预留2MB大小的大页。

echo 1024 > /sys/kernel/mm/hugepages/hugepages-2048kB/nr_hugepages

该命令预留1024个大小为2MB的大页,也就是预留了2GB内存。
如果是在NUMA系统中,假设有两个NODE的系统中,则可以用以下的命令:

echo       1024      >      /sys/devices/system/node/node0/hugepages/hugepages-2048kB/nr_hugepages
echo       1024      >      /sys/devices/system/node/node1/hugepages/hugepages-2048kB/nr_hugepages

该命令在NODE0和NODE1上各预留1024个大小为2MB的大页,总共预留了4GB大小。
而对于大小为1GB的大页,则必须在Linux命令行的时候就指定,不能动态预留。
在大页预留之后,接下来则涉及使用的问题。我们以DPDK为例来说明如何使用大页。
DPDK也是使用HUGETLBFS来使用大页。首先,它需要把大页mount到某个路径,比如/mnt/huge,以下是命令:

mkdir /mnt/huge
mount -t hugetlbfs nodev /mnt/huge

需要指出的是,在mount之前,要确保之前已经成功预留内存,否则之上命令会失败。该命令只是临时的mount了文件系统,如果想每次开机时省略该步骤,可以修改/etc/fstab文件,加上一行:

nodev /mnt/huge hugetlbfs defaults 0 0

对于1GB大小的大页,则必须用如下的命令:

nodev /mnt/huge_1GB hugetlbfs pagesize=1GB 0 0

接下来,在DPDK运行的时候,会使用mmap()系统调用把大页映射到用户态的虚拟地址空间,然后就可以正常使用了。

DDIO

当一个网络报文送到服务器的网卡时,网卡通过外部总线(比如PCI总线)把数据和报文描述符送到内存。接着,CPU从内存读取数据到Cache进而到寄存器。进行处理之后,再写回到Cache,并最终送到内存中。最后,网卡读取内存数据,经过外部总线送到网卡内部,最终通过网络接口发送出去。
可以看出,对于一个数据报文,CPU和网卡需要多次访问内存。而内存相对CPU来讲是一个非常慢速的部件。CPU需要等待数百个周期才能拿到数据,在这过程中,CPU什么也做不了。
DDIO技术是如何改进的呢?这种技术使外部网卡和CPU通过LLCCache直接交换数据,绕过了内存这个相对慢速的部件。这样,就增加 了 CPU 处 理 网 络 报 文 的 速 度 ( 减 少 了 CPU 和 网 卡 等 待 内 存 的 时间),减小了网络报文在服务器端的处理延迟。这样做也带来了一个问题,因为网络报文直接存储在LLC Cache中,这大大增加了对其容量的需求,因而在英特尔的E5处理器系列产品中,把LLC Cache的容量提高到了20MB。
图2-11是DDIO技术对网络报文的处理流程示意图。
DDIO中报文的处理流程
DDIO功能模块会学习来自I/O设备的读写请求,也就是I/O对内存的读或者写的请求。例如,当网卡需要从服务器端传送一个数据报文到网络上时,它会发起一个I/O读请求(读数据操作),请求把内存中的某个数据块通过外部总线送到网卡上;当网卡从网络中收到一个数据报文时,它会发起一个I/O写请求(写数据操作),请求把某个数据块通过外部总线送到内存中某个地址上。

NUMA系统

之前的章节已经简要介绍过NUMA系统,它是一种多处理器环境下 设 计 的 计 算 机 内 存 结 构 。 NUMA 系 统 是 从 SMP ( SymmetricMultiple Processing,对称多处理器)系统演化而来。
SMP 系 统 最 初 是 在 20 世 纪 90 年 代 由 Unisys 、 ConvexComputer(后来的HP)、Honeywell、IBM等公司开发的一款商用系 统 , 该 系 统 被 广 泛 应 用 于 Unix 类 的 操 作 系 统 , 后 来 又 扩 展 到Windows NT中,该系统有如下特点:

  1. 所有的硬件资源都是共享的。即每个处理器都能访问到任何内存、外设等。
  2. 所有的处理器都是平等的,没有主从关系。
  3. 内存是统一结构、统一寻址的(UMA,Uniform MemoryArchitecture)。
  4. 处理器和内存,处理器和处理器都通过一条总线连接起来。
    其结构如图2-14所示:
    SMP系统示意图
    SMP的问题也很明显,因为所有的处理器都通过一条总线连接起来,因此随着处理器的增加,系统总线成为了系统瓶颈,另外,处理器和内存之间的通信延迟也较大。为了克服以上的缺点,才应运而生了NUMA架构,如图2-15所示。
    NUMA系统示意图
    和SMP系统相比,NUMA系统访问本地内存的带宽更大,延迟更小,但是访问远程的内存成本相对就高多了。因此,我们要充分利用NUMA系统的这个特点,避免远程访问资源。
    以下是DPDK在NUMA系统中的一些实例。
    1)Per-core memory。一个处理器上有多个核(core),per-core memory是指每个核都有属于自己的内存,即对于经常访问的数据结构,每个核都有自己的备份。这样做一方面是为了本地内存的需要,另外一方面也是因为上文提到的Cache一致性的需要,避免多个核访问同一个Cache行。
    2)本地设备本地处理。即用本地的处理器、本地的内存来处理本地的设备上产生的数据。如果有一个PCI设备在node0上,就用node0上的核来处理该设备,处理该设备用到的数据结构和数据缓冲区都从node0上分配。以下是一个分配本地内存的例子:
/* allocate memory for the queue structure */
q    =    rte_zmalloc_socket("fm10k",    sizeof(*q),    RTE_CACHE_LINE_SIZE,     socket_id);

该例试图分配一个结构体,通过传递socket_id,即node id获得本地内存,并且以Cache行对齐。

### 回答1: DPDK(Data Plane Development Kit)是一种高性能数据面开发工具包。深入浅出DPDK是一份详细介绍DPDK的PDF文档,适合初学者了解DPDK的基本概念和使用方法。 该文档从DPDK的介绍开始,介绍了DPDK的发展历史、使用场景和目标。接着详细介绍了DPDK的架构,包括主要组件和数据流向。其中详细介绍了包管理的基本原理和流程、缓存池的管理方式、内存对齐和地址映射等内容。 文档还介绍了如何使用DPDK,包括DPDK的编译和安装、如何使用DPDK进行虚拟网络功能(VNF)处理,以及如何使用DPDK进行数据包嗅探和过滤等。 此外,该文档还介绍了DPDK的性能优化技巧,包括如何优化包接收和处理、如何使用多核心优化性能和如何配置DPDK与硬件交互。 总体而言,深入浅出DPDK是一份非常有价值的文档,它不仅为初学者提供了详细的DPDK介绍和使用指南,还涵盖了DPDK的高级主题和性能优化技巧,为使用DPDK进行高性能数据面开发的人员提供了重要的参考资料。 ### 回答2: DPDK(Data Plane Development Kit)是一个高性能数据平面开发工具包,它使用用户态技术实现了零拷贝的数据处理和高速的数据包转发。作为一款开源工具,DPDK已经被广泛应用于虚拟化、云计算及网络功能虚拟化等领域。 《深入浅出dpdk》是一份由DPDK社区编写的指南,它的目的是帮助开发人员更好地了解DPDK,并使用DPDK构建高性能网络应用程序。该指南提供了详细的DPDK架构、API接口、应用案例和性能调优等方面的介绍,同时也介绍了其他相关技术,例如硬件加速、 NUMA架构、 数据库加速、分布式系统等。 《深入浅出dpdk》PDF版本可以从DPDK社区网站上自由下载,它包含了大量易懂的图表和代码实例,让开发人员更容易理解DPDK的概念和原理。对于想要在高性能数据平面开发方面取得突破的开发人员来说,这份指南是不可或缺的学习资料。 需要注意的是,《深入浅出dpdk》不仅是一份教材,更是一份指导开发人员如何使用DPDK进行数据平面开发的实际指南。开发人员在阅读本指南的同时,也需要具备一定的网络编程基础和C语言编程能力。同时,开发过程中还需要注意DPDK版本的兼容性和网络设备的支持情况。 ### 回答3: DPDK(Data Plane Development Kit)是一种高性能数据面开发工具包,它可以帮助开发人员快速地实现高性能网络应用程序。本篇文章解析的Deep Dive into DPDK的PDF文件是一份帮助初学者深入了解DPDK的指南,其中详细地介绍了DPDK的各种重要概念、特性和使用方法。 首先,这份PDF文件详细介绍了DPDK的架构和工作原理。DPDK采用轮询机制减少了对内核的依赖,从而显著提高了网络数据处理的性能。此外,本文还介绍了DPDK中各种组件的作用和功能,例如EAL(Environment Abstraction Layer)、Mempool、Ring等。这些组件都是DPDK中重要的工具,它们可以帮助开发人员更好地构建高性能应用程序。 其次,本文还介绍了如何使用DPDK来构建网络应用程序。首先,开发人员需要安装DPDK,并正确配置相应环境变量。其次,本文还介绍了如何使用DPDK的API来实现数据包的发送和接收。这些API提供了高效的数据传输方式和内存管理方式,开发人员可以根据自己的需求进行自定义。 此外,本文还介绍了DPDK的一些高级功能,例如NUMA支持、内存优化等。这些功能可以帮助开发人员更好地控制和管理系统资源,从而确保系统的高性能和稳定性。 总之,本文是一份非常实用的DPDK指南,其中介绍了DPDK的各种重要概念、特性和使用方法。初学者可以通过阅读这份文件,快速掌握DPDK的核心知识,为构建高性能应用程序打下牢固的基础。
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

高晓伟_Steven

相逢即是有缘,动力源于金钱。

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值