前言:本文整理下之前的学习笔记,基于DPDK17.11版本源码分析。主要分析一下内存管理部分代码。
一、概述
内存管理是数据面开发套件(DPDK)的一个核心部分,以此为基础,DPDK的其他部分和用户应用得以发挥其最佳性能。本系列文章将详细介绍DPDK提供的各种内存管理的功能。
但在此之前,有必要先谈一谈为何DPDK中内存管理要以现有的方式运作,它背后又有怎样的原理,再进一步探讨DPDK具体能够提供哪些与内存相关的功能。本文将先介绍DPDK内存的基本原理,并解释它们是如何帮助DPDK实现高性能的。
请注意,虽然DPDK支持FreeBSD,而且也会有正在运行的Windows端口,但目前大多数与内存相关的功能仅适用于Linux。
先看一下下面的图片,其中左边部分为DPDK内存层级结构,下面三层在rte_eal_init初始化时完成,上面三层由用户调用API生成。右边为每层内存结构提供的API,供上层或者APP使用。
下图为内存管理相关的数据结构,其中rte_config->mem_config指向共享内存文件 /var/run/.rte_config,对应的结构体为struct rte_mem_config,其中mem_cfg_addr保存了映射文件/var/run/.rte_config后的虚拟地址,从进程使用此值进行映射,保证主从进程可以使用相同的虚拟地址访问结构体rte_mem_config。
此共享内存struct rte_mem_config还保存了如下几个重要的结构体:
a. memseg: 将同时满足下面四个条件的大页放在一个memseg中
1. 同socket 2. hugepage 大小相同 3. 物理地址连续 4. 虚拟地址连续
b. malloc_heap: 将相同socket的memseg挂在同一个malloc_heap上,对外提供API的最底层实现
c. memzone: 用来申请整块内存
d. tailq_head: 共享队列实现,主从进程可以同时访问。
内存初始化流程如下,后面会详细看每个函数的实现。
rte_eal_init //internal_config为本进程全局变量,用来保存参数等信息 eal_reset_internal_config(&internal_config); //解析参数,保存到 internal_config eal_parse_args(argc, argv); //收集系统上可用的大页内存,保存到 internal_config->hugepage_info[3] eal_hugepage_info_init(); //初始化全局变量rte_config,并将 rte_config->mem_config 进行映射 rte_config_init(); //大页内存映射,并保存到 rte_config->mem_config->memseg[] rte_eal_memory_init(); //将 memseg 插入 rte_config->mem_config->malloc_heaps[] rte_eal_memzone_init(); //主进程初始化完成 rte_eal_mcfg_complete(); /* ALL shared mem_config related INIT DONE */ //主进程完成了所有共享内存的初始化,设置RTE_MAGIC。 //从进程在等待magic变成RTE_MAGIC后才能继续下去。 if (rte_config.process_type == RTE_PROC_PRIMARY) rte_config.mem_config->magic = RTE_MAGIC;
二、标准大页
现代CPU架构中,内存管理并不以单个字节进行,而是以页为单位,即虚拟和物理连续的内存块。这些内存块通常(但不是必须) 存储在RAM中。在英特尔®64和IA-32架构上,标准系统的页面大小为4KB。
基于安全性和通用性的考虑,软件的应用程序访问的内存位置使用的是操作系统分配的虚拟地址。运行代码时,该虚拟地址需要被转换为硬件使用的物理地址。这种转换是操作系统通过页表转换来完成的,页表在分页粒度级别上(即4KB一个粒度)将虚拟地址映射到物理地址。为了提高性能,最近一次使用的若干页面地址被保存在一个称为转换检测缓冲区(TLB)的高速缓存中。每一分页都占有TLB的一个条目。如果用户的代码访问(或最近访问过)16 KB的内存,即4页,这些页面很有可能会在TLB缓存中。
如果其中一个页面不在TLB缓存中,尝试访问该页面中包含的地址将导致TLB查询失败;也就是说,操作系统写入TLB的页地址必须是在它的全局页表中进行查询操作获取的。因此,TLB查询失败的代价也相对较高(某些情况下代价会非常高),所以最好将当前活动的所有页面都置于TLB中以尽可能减少TLB查询失败。
然而,TLB的大小有限,而且实际上非常小,和DPDK通常处理的数据量(有时高达几十GB)比起来,在任一给定的时刻,4KB 标准页面大小的TLB所覆盖的内存量(几MB)微不足道。这意味着,如果DPDK采用常规内存,使用DPDK的应用会因为TLB频繁的查询失败在性能上大打折扣。
还不熟悉的朋友,这里可以先领取一份dpdk新手学习资料包(入坑不亏):
为解决这个问题,DPDK依赖于标准大页。从名字中很容易猜到,标准大页类似于普通的页面,只是会更大。有多大呢?在英特尔®64和1A-32架构上,目前可用的两种大页大小为2MB和1GB。也就是说,单个页面可以覆盖2 MB或1 GB大小的整个物理和虚拟连续的存储区域。
TLB内存覆盖量比较
这两种页面大小DPDK都可以支持。有了这样的页面大小,就可以更容易覆盖大内存区域,也同时避免(同样多的)TLB查询失败。反过来,在处理大内存区域时,更少的TLB查询失败也会使性能得到提升,DPDK的用例通常如此。
2.1eal_hugepage_info_init 收集可用大页内存
遍历/sys/kernel/mm/hugepages下面的目录收集可用的大页。
/* * when we initialize the hugepage info, everything goes * to socket 0 by default. it will later get sorted by memory * initialization procedure. */ int eal_hugepage_info_init(void) { const char dirent_start_text[] = "hugepages-"; const size_t dirent_start_len = sizeof(dirent_start_text) - 1; unsigned i, num_sizes = 0; DIR *dir; struct dirent *dirent; //打开目录 /sys/kernel/mm/hugepages dir = opendir(sys_dir_path); for (dirent = readdir(dir); dirent != NULL; dirent = readdir(dir)) { struct hugepage_info *hpi; if (strncmp(dirent->d_name, dirent_start_text, dirent_start_len) != 0) continue; if (num_sizes >= MAX_HUGEPAGE_SIZES) break; hpi = &internal_config.hugepage_info[num_sizes]; hpi->hugepage_sz = rte_str_to_size(&dirent->d_name[dirent_start_len]); //打开文件 /proc/mounts 遍历所有挂载点,找到 hugetlbfs //相关的挂载点,再找到和hpi->hugepage_sz相等的挂载 //点,返回其挂载目录。 hpi->hugedir = get_hugepage_dir(hpi->hugepage_sz); //如果对应hugepage_sz的挂载点,则跳过此种类型大页 /* first, check if we have a mountpoint */ if (hpi->hugedir == NULL) { uint32_t num_pages; num_pages = get_num_hugepages(dirent->d_name); if (num_pages > 0) RTE_LOG(NOTICE, EAL, "%" PRIu32 " hugepages of size " "%" PRIu64 " reserved, but no mounted " "hugetlbfs found for that size\n", num_pages, hpi->hugepage_sz); continue; } /* try to obtain a writelock */ hpi->lock_descriptor = open(hpi->hugedir, O_RDONLY); /* if blocking lock failed */ flock(hpi->lock_descriptor, LOCK_EX); //删除挂载点目录下以"map_"开头的文件 /* clear out the hugepages dir from unused pages */ clear_hugedir(hpi->hugedir); //获取空闲的大页数量,free_hugepages减去 //resv_hugepages。此时还不知道大页位于哪个socket, //所以先将大页数量统一放到socket0上 /* for now, put all pages into socket 0, * later they will be sorted */ hpi->num_pages[0] = get_num_hugepages(dirent->d_name); //大页种类加一 num_sizes++; } closedir(dir); //保存大页种类: 2M, 1G等 internal_config.num_hugepage_sizes = num_sizes; //按照大页大小排序 hugepage_info,从大到小 /* sort the page directory entries by size, largest to smallest */ qsort(&internal_config.hugepage_info[0], num_sizes, sizeof(internal_config.hugepage_info[0]), compare_hpi); //如果有可用的大页,返回0,否则返回-1 /* now we have all info, check we have at least one valid size */ for (i = 0; i < num_sizes; i++) if (internal_config.hugepage_info[i].hugedir != NULL && internal_config.hugepage_info[i].num_pages[0] > 0) return 0; /* no valid hugepage mounts available, return error */ return -1; }
rte_config_init
映射文件 /var/run/.rte_config,用来保存结构体 rte_config->mem_config 的内容。主进程映射此文件,将映射后的虚拟地址保存到 rte_config->mem_config->mem_cfg_addr,从进程需要映射两次此文件,第一次是为了获取主进程使用的虚拟地址mem_cfg_addr,然后再使用此虚拟地址进行映射,这样保存主从进程可以使用相同的虚拟地方访问 rte_config->mem_config指向的共享内存。
/* Sets up rte_config structure with the pointer to shared memory config.*/ static void rte_config_init(void) { rte_config.process_type = internal_config.process_type; switch (rte_config.process_type){ case RTE_PROC_PRIMARY: //主进程初始化 rte_eal_config_create(); break; case RTE_PROC_SECONDARY: //从进程attach rte_eal_config_attach(); //等待主进程初始化完成 rte_eal_mcfg_wait_complete(rte_config.mem_config); //再次attach rte_eal_config_reattach(); break; case RTE_PROC_AUTO: case RTE_PROC_INVALID: rte_panic("Invalid process type\n"); } }
主进程映射文件 /var/run/.rte_config
/* create memory configuration in shared/mmap memory. Take out * a write lock on the memsegs, so we can auto-detect primary/secondary. * This means we never close the file while running (auto-close on exit). * We also don't lock the whole file, so that in future we can use read-locks * on other parts, e.g. memzones, to detect if there are running secondary * processes. */ static void rte_eal_config_create(void) { void *rte_mem_cfg_addr; int retval; //获取config所在路径 /var/run/.rte_config const char *pathname = eal_runtime_config_path(); if (internal_config.no_shconf) return; /* map the config before hugepage address so that we don't waste a page */ if (internal_config.base_virtaddr != 0) rte_mem_cfg_addr = (void *) RTE_ALIGN_FLOOR(internal_config.base_virtaddr - sizeof(struct rte_mem_config), sysconf(_SC_PAGE_SIZE)); else rte_mem_cfg_addr = NULL; //打开config文件路径 /var/run/.rte_config if (mem_cfg_fd < 0){ mem_cfg_fd = open(pathname, O_RDWR | O_CREAT, 0660); if (mem_cfg_fd < 0) rte_panic("Cannot open '%s' for rte_mem_config\n", pathname); } //将文件大小修改为 mem_config 的长度 retval = ftruncate(mem_cfg_fd, sizeof(*rte_config.mem_config)); if (retval < 0){ close(mem_cfg_fd); rte_panic("Cannot resize '%s' for rte_mem_config\n", pathname); } //给文件mem_cfg_fd加上锁,可以调用eal_proc_type_detect检测是主进程还是从进程。 //只有主进程能lock成功。 static struct flock wr_lock = { .l_type = F_WRLCK, .l_whence = SEEK_SET, .l_start = offsetof(struct rte_mem_config, memseg), .l_len = sizeof(early_mem_config.memseg), }; fcntl(mem_cfg_fd, F_SETLK, &wr_lock); //映射config文件 rte_mem_cfg_addr = mmap(rte_mem_cfg_addr, sizeof(*rte_config.mem_config), PROT_READ | PROT_WRITE, MAP_SHARED, mem_cfg_fd, 0); //将early_mem_config内容保存到共享内存 rte_mem_cfg_addr memcpy(rte_mem_cfg_addr, &early_mem_config, sizeof(early_mem_config)); //将映射后的虚拟地址保存到 rte_config.mem_config rte_config.mem_config = rte_mem_cfg_addr; //最后将映射后的虚拟地址 rte_mem_cfg_addr 保存到共享内存 mem_config->mem_cfg_addr, //以便从进程获取后映射相同的虚拟地址。 /* store address of the config in the config itself so that secondary * processes could later map the config into this exact location */ rte_config.mem_config->mem_cfg_addr = (uintptr_t) rte_mem_cfg_addr; }
因为只有主进程能lock,所以可以使用函数eal_proc_type_detect检查当前进程是主进程还是从进程。
/* Detect if we are a primary or a secondary process */ enum rte_proc_type_t eal_proc_type_detect(void) { enum rte_proc_type_t ptype = RTE_PROC_PRIMARY; const char *pathname = eal_runtime_config_path(); /* if we can open the file but not get a write-lock we are a secondary * process. NOTE: if we get a file handle back, we keep that open * and don't close it to prevent a race condition between multiple opens */ if (((mem_cfg_fd = open(pathname, O_RDWR)) >= 0) && (fcntl(mem_cfg_fd, F_SETLK, &wr_lock) < 0)) ptype = RTE_PROC_SECONDARY; RTE_LOG(INFO, EAL, "Auto-detected process type: %s\n", ptype == RTE_PROC_PRIMARY ? "PRIMARY" : "SECONDARY"); return ptype; }
从进程第一此映射/var/run/.rte_config,可能在主进程之前就映射了,映射后主从进程都可以操作这块内存,但主要目的还是为了获取主进程的虚拟地址 mem_cfg_addr。
/* attach to an existing shared memory config */ static void rte_eal_config_attach(void) { struct rte_mem_config *mem_config; //获取config所在路径 /var/run/.rte_config const char *pathname = eal_runtime_config_path(); if (internal_config.no_shconf) return; //打开文件 /var/run/.rte_config if (mem_cfg_fd < 0){ mem_cfg_fd = open(pathname, O_RDWR); if (mem_cfg_fd < 0) rte_panic("Cannot open '%s' for rte_mem_config\n", pathname); } //映射config文件,只读模式。而且第一个参数为NULL,即不知道虚拟地址 /* map it as read-only first */ mem_config = (struct rte_mem_config *) mmap(NULL, sizeof(*mem_config), PROT_READ, MAP_SHARED, mem_cfg_fd, 0); if (mem_config == MAP_FAILED) rte_panic("Cannot mmap memory for rte_config! error %i (%s)\n", errno, strerror(errno)); rte_config.mem_config = mem_config; }
等待主进程初始化完成。主进程初始化完成后会调用rte_eal_mcfg_complete将mcfg->magic设置为 RTE_MAGIC。
inline static void rte_eal_mcfg_wait_complete(struct rte_mem_config* mcfg) { /* wait until shared mem_config finish initialising */ while(mcfg->magic != RTE_MAGIC) rte_pause(); }
主进程初始化成功后,从进程就可以使用主进程映射的虚拟地址 mem_cfg_addr进行第二次映射
/* reattach the shared config at exact memory location primary process has it */ static void rte_eal_config_reattach(void) { struct rte_mem_config *mem_config; void *rte_mem_cfg_addr; if (internal_config.no_shconf) return; //从共享配置文件获取主进程映射的虚拟地址 mem_cfg_addr /* save the address primary process has mapped shared config to */ rte_mem_cfg_addr = (void *) (uintptr_t) rte_config.mem_config->mem_cfg_addr; //去除从进程之前的对mem_config的映射 /* unmap original config */ munmap(rte_config.mem_config, sizeof(struct rte_mem_config)); //指定主进程映射的虚拟地址 mem_cfg_addr重新映射,获取和主进程一样的虚拟地址 /* remap the config at proper address */ mem_config = (struct rte_mem_config *) mmap(rte_mem_cfg_addr, sizeof(*mem_config), PROT_READ | PROT_WRITE, MAP_SHARED, mem_cfg_fd, 0); if (mem_config == MAP_FAILED || mem_config != rte_mem_cfg_addr) { if (mem_config != MAP_FAILED) /* errno is stale, don't use */ rte_panic("Cannot mmap memory for rte_config at [%p], got [%p]" " - please use '--base-virtaddr' option\n", rte_mem_cfg_addr, mem_config); else rte_panic("Cannot mmap memory for rte_config! error %i (%s)\n", errno, strerror(errno)); } close(mem_cfg_fd); //将映射后的mem_config保存到从进程本地变量 rte_config.mem_config rte_config.mem_config = mem_config; }
这样主从进程都能使用相同的虚拟地址访问共享配置 rte_config.mem_config。