一、介绍
如果一条通信链路要想达到最优的效果,一定是整体上每个环节都要有最佳的节奏协调控制而不一定是每个环节都是最优。这个在计算机的数据处理上就更是明显。一般来说,IO的速度是最低的,至少在可见的时光里要想超越CPU和内存还是很难的。
以网卡通信为例,网卡本身就IO传输,然后通过总线传输进入内存,内存进缓存,缓存到CPU,然后再原路一个反馈。学过计算机的一般都知道,这里面涉及到的几个部分,数据的处理速度那是有着量层级上的差异的。所以在处理问题上有三条思路:
一是把短板补齐,也就是那个慢把哪个速度提起来。这其中就包含着近些年来,不断提高的IO处理速度和总线处理速度(CPU的速度提高反而不太明显,主要是横向扩展);二是把不必要的中间环节去除,也就是本来是ABCD,是不是有可能把BC去除,直接让AD通信;三是通过软件优化,最大化的利用硬件资源(并行等)。
一般来说,这就是目前优化的三个主要的思路,在DPDK中,基本也是延着这两条思路前进的。不过,前者可能不是DPDK所能控制的,其更重要的是朝着后二种前进。
二、PCIe总线和DMA及缓存
PCI Express,Peripheral Component Interconnect Express(PCIe),是一种高速串联通信标准。目前的最新版本是7.0,但主流的标准应该3.0和4.0居多。PCIe主要用来连接显卡、固态硬盘以及采集卡和无线网卡等外设,用来为高速外设进行数据传输。
直接内存访问(DMA,Direct Memory Access),主要是绕过CPU,直接与IO进行数据通信。
缓存就更好理解了,Cache一般使用SRAM静态存储器,用来做为主存与CPU速度不匹配的一种缓冲存储(缓存的速度已经很接近CPU的处理速度),其重要指标就是命中率。
三、数据传输
这里不展开具体的PCIe及相关的DMA等的数据传输细节。一般来说,支持的PCIe的版本越高,其数据传输的速度越快,即使扣除相关的协议开销和控制指令等的开销后,其对数据传输的瓶颈解决越明显。但光其一个最优不行,中间的其它传输也要共同优化,节奏相同。
DMA是通过环形队列与CPU交互(利用DDIO技术减少对主存的访问次数),环形队列一般学过算法的都清楚怎么回事。而为了提高数据访问的速度,环形队列缓冲区的大小必须是网卡支持最大Cache line(128B)的整数倍,这样做的目的当然是为了方便内存对齐,即提高访问效率,效率高了自然速度就上来了。
正常的网卡驱动通信一般是以下几步:
1、将缓冲地址写到地址描述符
2、移动队列尾指针到指定位置
3、判断描述符中的状态位是否完成,如果是接收还要申请新的缓冲区;发送则需要释放已发送缓冲区;这些其实都是一些头尾的动作
4、一些处理操作,如描述符的内容更新和控制头的解析等
四、数据转发
刚刚提到了,某个局部最优并不一定是整体的最优解,所以要处理好数据传输,一个重要的问题是要处理好数据在各个环节的转发的问题。在网卡通信中,最重的是将CPU中的缓存与IO的通信整体协调好。
那么,首先要理顺一下,在这个通信过程中需要哪几个环节协调。首先,CPU面对的主存和外部寄存器。而对DMA来说就是主存和Cache。而它们之间的数据需要PCIe来传输,所以优化的手段就明晰了:
1、减少对外部寄存器MMIO的读取
2、提高PCIe的效率,即在固定的带宽下,如何最大程度的利用满
3、减少Cache的部分写
这都比较好理解,先从最后一个说起,Cache的内存未对齐,也就是部分写,会导致对缓存的二次操作,效率至少降低一半。而余下二者其实类似于批处理,把单次的操作改成多次合成,减少相关的控制和协议处理,自然可以提高数据的传输速度。
在DPDK中,使用Mbuf来处理网络数据帧。而在网络帧的处理上有两种情况,一种是将元数据和数据同时存放;另外一个是将二者分开存放。这样说有点不直白,其实就是空间利用率和时间利用率(效率)的二者的权衡。前者又更好的利用内存空间而后者则能够在浪费一部分内存的情况下更好的提高数据转发效率。DPDK当然选择了后者。
而为了更好的利用内存,将Mbuf形成一个内存池(双环形缓冲区),DPDK为应对多核的情况,允许每个CPU缓存一部分缓冲区。然后在写时进行CAS操作处理。当然,这样做的缺点和上面一样,也会浪费一部分缓存。
五、源码分析
上面把相关的内容理顺了一下,这样再和源码匹配就好理解了。前面的队列和Mbuf等进行过分析,这里只讲一些整体上的源码流程:
//dpdk-stable-19.11.14\lib\librte_eal\linux\eal\eal_vfio.c
static int
vfio_type1_dma_mem_map(int vfio_container_fd, uint64_t vaddr, uint64_t iova,
uint64_t len, int do_map)
{
struct vfio_iommu_type1_dma_map dma_map;
struct vfio_iommu_type1_dma_unmap dma_unmap;
int ret;
if (do_map != 0) {
memset(&dma_map, 0, sizeof(dma_map));
dma_map.argsz = sizeof(struct vfio_iommu_type1_dma_map);
dma_map.vaddr = vaddr;
dma_map.size = len;
dma_map.iova = iova;
dma_map.flags = VFIO_DMA_MAP_FLAG_READ |
VFIO_DMA_MAP_FLAG_WRITE;
ret = ioctl(vfio_container_fd, VFIO_IOMMU_MAP_DMA, &dma_map);
if (ret) {
/**
* In case the mapping was already done EEXIST will be
* returned from kernel.
*/
if (errno == EEXIST) {
RTE_LOG(DEBUG, EAL,
" Memory segment is already mapped,"
" skipping");
} else {
RTE_LOG(ERR, EAL,
" cannot set up DMA remapping,"
" error %i (%s)\n",
errno, strerror(errno));
return -1;
}
}
} else {
memset(&dma_unmap, 0, sizeof(dma_unmap));
dma_unmap.argsz = sizeof(struct vfio_iommu_type1_dma_unmap);
dma_unmap.size = len;
dma_unmap.iova = iova;
ret = ioctl(vfio_container_fd, VFIO_IOMMU_UNMAP_DMA,
&dma_unmap);
if (ret) {
RTE_LOG(ERR, EAL, " cannot clear DMA remapping, error %i (%s)\n",
errno, strerror(errno));
return -1;
} else if (dma_unmap.size != len) {
RTE_LOG(ERR, EAL, " unexpected size %"PRIu64" of DMA "
"remapping cleared instead of %"PRIu64"\n",
(uint64_t)dma_unmap.size, len);
rte_errno = EIO;
return -1;
}
}
return 0;
}
static int
vfio_type1_dma_map(int vfio_container_fd)
{
if (rte_eal_iova_mode() == RTE_IOVA_VA) {
/* with IOVA as VA mode, we can get away with mapping contiguous
* chunks rather than going page-by-page.
*/
int ret = rte_memseg_contig_walk(type1_map_contig,
&vfio_container_fd);
if (ret)
return ret;
/* we have to continue the walk because we've skipped the
* external segments during the config walk.
*/
}
return rte_memseg_walk(type1_map, &vfio_container_fd);
}
这段代码会在最初始的函数rte_eal_init这个函数中调用,这个函数不陌生吧,在最初的文章里就介绍过。它主要用来在初始时对固定内存的DMA地址映射,而若是对小的临时的地址映射可以使用下面的函数:
//dpdk-stable-19.11.14\lib\librte_eal\common\eal_common_dev.c
int
rte_dev_dma_map(struct rte_device *dev, void *addr, uint64_t iova,
size_t len)
{
if (dev->bus->dma_map == NULL || len == 0) {
rte_errno = ENOTSUP;
return -1;
}
/* Memory must be registered through rte_extmem_* APIs */
if (rte_mem_virt2memseg_list(addr) == NULL) {
rte_errno = EINVAL;
return -1;
}
return dev->bus->dma_map(dev, addr, iova, len);
}
int
rte_dev_dma_unmap(struct rte_device *dev, void *addr, uint64_t iova,
size_t len)
{
if (dev->bus->dma_unmap == NULL || len == 0) {
rte_errno = ENOTSUP;
return -1;
}
/* Memory must be registered through rte_extmem_* APIs */
if (rte_mem_virt2memseg_list(addr) == NULL) {
rte_errno = EINVAL;
return -1;
}
return dev->bus->dma_unmap(dev, addr, iova, len);
}
这里不细节展开在使用虚地址和实地址以及自定义地址时,DMA对其的各自处理的细节,有兴趣可以自己查看源码。DMA中对数据的传输,其实就是对缓冲池和Mbuf的处理。在更高的版本中,DPDK在库单独对dmadev进行了封装。
DMA处理数据的过程将在后面的整体流程源码分析中,进行说明。
再看一下PCIe相关,首先是扫描(对于一些基础的PCIe相关的应用知识需要懂一些,否则代码看不看也没啥意义):
static int
pci_scan_one(const char *dirname, const struct rte_pci_addr *addr)
{
char filename[PATH_MAX];
unsigned long tmp;
struct rte_pci_device *dev;
char driver[PATH_MAX];
int ret;
dev = malloc(sizeof(*dev));
if (dev == NULL)
return -1;
memset(dev, 0, sizeof(*dev));
dev->device.bus = &rte_pci_bus.bus;
dev->addr = *addr;
/* get vendor id */
snprintf(filename, sizeof(filename), "%s/vendor", dirname);
if (eal_parse_sysfs_value(filename, &tmp) < 0) {
free(dev);
return -1;
}
dev->id.vendor_id = (uint16_t)tmp;
/* get device id */
snprintf(filename, sizeof(filename), "%s/device", dirname);
if (eal_parse_sysfs_value(filename, &tmp) < 0) {
free(dev);
return -1;
}
dev->id.device_id = (uint16_t)tmp;
/* get subsystem_vendor id */
snprintf(filename, sizeof(filename), "%s/subsystem_vendor",
dirname);
if (eal_parse_sysfs_value(filename, &tmp) < 0) {
free(dev);
return -1;
}
dev->id.subsystem_vendor_id = (uint16_t)tmp;
/* get subsystem_device id */
snprintf(filename, sizeof(filename), "%s/subsystem_device",
dirname);
if (eal_parse_sysfs_value(filename, &tmp) < 0) {
free(dev);
return -1;
}
dev->id.subsystem_device_id = (uint16_t)tmp;
/* get class_id */
snprintf(filename, sizeof(filename), "%s/class",
dirname);
if (eal_parse_sysfs_value(filename, &tmp) < 0) {
free(dev);
return -1;
}
/* the least 24 bits are valid: class, subclass, program interface */
dev->id.class_id = (uint32_t)tmp & RTE_CLASS_ANY_ID;
/* get max_vfs */
dev->max_vfs = 0;
snprintf(filename, sizeof(filename), "%s/max_vfs", dirname);
if (!access(filename, F_OK) &&
eal_parse_sysfs_value(filename, &tmp) == 0)
dev->max_vfs = (uint16_t)tmp;
else {
/* for non igb_uio driver, need kernel version >= 3.8 */
snprintf(filename, sizeof(filename),
"%s/sriov_numvfs", dirname);
if (!access(filename, F_OK) &&
eal_parse_sysfs_value(filename, &tmp) == 0)
dev->max_vfs = (uint16_t)tmp;
}
/* get numa node, default to 0 if not present */
snprintf(filename, sizeof(filename), "%s/numa_node",
dirname);
if (access(filename, F_OK) != -1) {
if (eal_parse_sysfs_value(filename, &tmp) == 0)
dev->device.numa_node = tmp;
else
dev->device.numa_node = -1;
} else {
dev->device.numa_node = 0;
}
pci_name_set(dev);
/* parse resources */
snprintf(filename, sizeof(filename), "%s/resource", dirname);
if (pci_parse_sysfs_resource(filename, dev) < 0) {
RTE_LOG(ERR, EAL, "%s(): cannot parse resource\n", __func__);
free(dev);
return -1;
}
/* parse driver */
snprintf(filename, sizeof(filename), "%s/driver", dirname);
ret = pci_get_kernel_driver_by_path(filename, driver, sizeof(driver));
if (ret < 0) {
RTE_LOG(ERR, EAL, "Fail to get kernel driver\n");
free(dev);
return -1;
}
if (!ret) {
if (!strcmp(driver, "vfio-pci"))
dev->kdrv = RTE_KDRV_VFIO;
else if (!strcmp(driver, "igb_uio"))
dev->kdrv = RTE_KDRV_IGB_UIO;
else if (!strcmp(driver, "uio_pci_generic"))
dev->kdrv = RTE_KDRV_UIO_GENERIC;
else
dev->kdrv = RTE_KDRV_UNKNOWN;
} else
dev->kdrv = RTE_KDRV_NONE;
/* device is valid, add in list (sorted) */
if (TAILQ_EMPTY(&rte_pci_bus.device_list)) {
rte_pci_add_device(dev);
} else {
struct rte_pci_device *dev2;
int ret;
TAILQ_FOREACH(dev2, &rte_pci_bus.device_list, next) {
ret = rte_pci_addr_cmp(&dev->addr, &dev2->addr);
if (ret > 0)
continue;
if (ret < 0) {
rte_pci_insert_device(dev2, dev);
} else { /* already registered */
if (!rte_dev_is_probed(&dev2->device)) {
dev2->kdrv = dev->kdrv;
dev2->max_vfs = dev->max_vfs;
pci_name_set(dev2);
memmove(dev2->mem_resource,
dev->mem_resource,
sizeof(dev->mem_resource));
} else {
/**
* If device is plugged and driver is
* probed already, (This happens when
* we call rte_dev_probe which will
* scan all device on the bus) we don't
* need to do anything here unless...
**/
if (dev2->kdrv != dev->kdrv ||
dev2->max_vfs != dev->max_vfs)
/*
* This should not happens.
* But it is still possible if
* we unbind a device from
* vfio or uio before hotplug
* remove and rebind it with
* a different configure.
* So we just print out the
* error as an alarm.
*/
RTE_LOG(ERR, EAL, "Unexpected device scan at %s!\n",
filename);
else if (dev2->device.devargs !=
dev->device.devargs) {
rte_devargs_remove(dev2->device.devargs);
pci_name_set(dev2);
}
}
free(dev);
}
return 0;
}
rte_pci_add_device(dev);
}
return 0;
}
扫描并增加相关设备后,就可以将其位于“/sys/bus/pci/devices/0000:xxxx:xx/resouce”的每个PCI设备对应的文件中保存的pci设备bar寄存器的地址映射信息在这个设备启动加载时将映射的物理内存地址信息保存在bar寄存器中同时写入到此文件。而UIO就是通过这个文件来使用mmap对其的物理地址进行映射,然后就可以在应用层对此设备进行访问了。
cache等的代码在前面分析过,这里就不再重复,如果需要可以翻翻前面的文章。
六、总结
其实数据IO的处理,比之内存等内的操作更好理解,因为它更接近于现实世界的处理流程。形象化的描述比之抽象化的东西更容易为人所理解。就如让人理解IO数据流不好理解,但去理解水管阀门调节水流容易理解一样。其实二者本质是相同的,原理是相通的。
把原理吃透,多看手册,对比源码,秘密全无。