gShare: A centralized GPU memory management framework to enable GPU memory sharing for containers
摘要
- 现有的容器软件并不关心每个容器如何分配 GPU 内存。
- 因此,如果某个容器消耗了大部分 GPU 内存,其他容器可能因为内存不足而无法运行其工作负载。
- 本文介绍了 gShare,它是一个集中式的 GPU 内存管理框架,可以实现容器的 GPU 内存共享。
- 与现代操作系统一样,gShare 在框架内部分配整个 GPU 内存,并使用复杂的内存分配器管理内存。
- 然后,gShare 能够通过中介内存分配调用来强制每个容器的 GPU 内存限制。
- 为了实现其目标,gShare 引入了 API 远程处理组件、中介和三级内存分配器,它们支持轻量级和高效的 GPU 内存管理。
- 我们的原型实现在流行的深度学习和 HPC 工作负载中实现了近乎本机的性能,具有安全隔离和少量内存浪费。
引言
容器在它们之间共享 GPU 内存方面有一个弱点,这对在多租户环境中利用容器提出了挑战。
- Docker 可以限制每个容器的最大系统内存大小,这允许容器在给定的数量内使用主机的内存[18]。
- 然而,NVIDIA Docker 不能强制每个容器的 GPU 内存限制。
- 因此,当某个容器积极地消耗大部分 GPU 内存时,其他租户的容器可能会遇到内存不足的问题,无法运行它们的工作负载。
最近的研究[GaiaGPU, KubeShare], 利用 API 远程处理方法限制了每个容器的 GPU 内存使用。
- 安装在每个容器中的 GPU 包装器库拦截与内存相关的 API,并且只允许在总内存使用不超过给定内存容量的情况下执行调用。
- 然而,这种方法的主要问题是,
- 恶意或错误的容器可能会通过直接访问用户空间中的内存映射 I/O 区域来绕过包装器库,或者在容器中放弃具有根特权的库。
- 然后容器就能够超出限制分配 GPU 内存。
- 最基本的造成这个问题的原因是 GPU 内存分别由每个容器管理和分配,没有一个集中的系统参与。
gShare: 这是一个 GPU 内存管理框架,支持容器的 GPU 内存共享
- GShare 的主要目标是构建一个集中的、高效的 GPU 内存管理系统。
- 与现代操作系统一样,gShare 在框架内部分配整个 GPU 内存,并使用复杂的内存分配器管理内存。
- GShare 代理每个容器的内存分配调用,并且仅在容器的内存限制范围内提供对 GPU 内存的访问。
- 来自容器的内存分配调用对应于对 gShare 的系统调用。
- 为了实现这种体系结构,我们在 gShare 中开发了三个部分,包括 API 远程组件、中介和三级内存分配器。
- GShare 的三个部分在用户空间中运行,以便与 NVIDIA CUDA 等 GPU 运行时 API 兼容
- GShare 的主要目标是构建一个集中的、高效的 GPU 内存管理系统。
背景
CUDA 跨进程通信API
CUDA 行程间通讯(IPC) API 是从 CUDA 4.1版本引入的,以支持图形处理器内存的行程间通讯。
- 以前关于消息传递接口(MPI)框架的研究利用 API 直接复制由不同进程分配的 GPU 内存缓冲区,而不涉及主机内存。
- 为了实现这一点
- 一个进程通过执行 cudalpcGetMemHandle 函数在其 GPU 内存缓冲区上获得一个 IPC 句柄。
- 参与通信的另一个进程接收来自前一个进程的句柄,并执行 cudalpcOpenMemHandle,以便将导出的 GPU 缓冲区映射到其虚拟设备地址空间。
- 然后,在 GPU 内存缓冲区之间执行数据的直接复制。
- AMD GPU 还支持与 hipIpcGetMemHandle 和 hipIpcOpenMemHandle 相同的功能。
GShare 利用 CUDA IPC API 在 gShare 和容器中的进程之间实现 GPU 内存分配,而不是在进程之间复制 GPU 数据。由于 NVIDIA 没有显示驱动程序的实现,gShare 无法直接访问 GPU 内存分配容器中进程的 GPU 页表。为了克服这个限制,gShare 使用了 cudalpcOpenMemHandle,它可以将 gShare 以前分配的 GPU 内存映射到容器的进程中。该函数允许 gShare 间接操作进程的 GPU 页表。
设计
设计目标
- 轻量框架
- GShare 的目标是提供一个轻量级框架,就像 gShare 在容器环境中运行一样,在使用 GPU 时实现接近本机的性能。
- GShare 是一个额外的软件层,用于以集中方式管理 GPU 内存,与本地容器环境相比,这不会带来额外的开销。
- 为了实现这个目标,gShare 实现了一个无陷阱的通信系统,可以在 gShare 和每个容器之间快速传递命令。
- gshare只劫持少量和内存分配有关的API, 其他的研究都是劫持所有的API
- 高效内存管理
- 在设计内存管理系统时,有几个方面需要考虑,包括外部和内部碎片、内存分割和合并以及膨胀
- 为了防止内存碎片,gShare 实现了一个三级内存分配器
- GPU共享内存分配器
- 伙伴分配器
- slab分配器
- GShare 还支持内存分割和合并,以满足大型连续内存请求
- gShare 支持GPU内存膨胀,当容器运行过程中需要超出预定的内存时
- 安全内存隔离
- GShare 需要支持多个租户,因此必须确保容器之间的数据隔离。
- 此外,由于应用程序可能损坏同一容器中另一个应用程序的数据,因此应确保同一容器中的进程之间的数据隔离。
- GShare 通过为容器中的每个进程分配一个专用内存块来确保容器间和容器内的安全隔离。
- GShare 需要支持多个租户,因此必须确保容器之间的数据隔离。
架构
API远程组件
通过 API 远程处理,容器的 GPU 内存分配函数可以通过 gShare 进行中介和处理。
- 包装器库:
- 安装在容器中的插入器包装器库拦截在用户进程中执行的 GPU 内存分配或释放函数,并将它们传递给容器中的前端驱动程序。
- 为了最小化干扰开销,库拦截最小必要的 CUDA 调用,包括 cuInit、 cuMemGetInfo、 cuDeviceTotalMem、 cudaSetDevice、 cuMemAlloc、 cuMemFree 及其派生函数; 其他函数直接发送到原始 GPU 库。
- 由于包装器库可以包含在 NVIDIA Docker 映像中,并在运行时动态链接到进程,因此容器的租户可以不知道 gShare。
- 前端和后端:
- 前端驱动程序将接收到的 GPU 调用及其参数打包成一个可传输的消息。
- 消息通过提供 TCP/IP 或共享内存的通信器发送到主机操作系统中运行的后端,用于前端和后端之间的通信。
- 在主机操作系统中,后端将接收到的消息转换为原始 GPU 调用及其参数。
- 后端将把这个调用传递给调停器的核心引擎,以便进一步处理。
- 通信器:
- 通信器是连接 gShare 前端和后端的重要模块。
- TCP/IP 和共享内存都可以用作通信方法,但是当容器中的工作负载执行频繁的内存分配时,它们会产生显著的开销。
- 例如,TensorFlow 的内部内存分配器称为最佳拟合结合(BFC)[35]通常一次分配大量内存(例如1、2或4GB) ,但同时,它分配一个小的内存大小(例如8字节)连续管理张量的空间。
- 这种情况使得通信器频繁地陷入操作系统内核,以执行通信所需的系统调用,因此导致了巨大的开销[36,37]。
- 当我们测试 TCP/IP 实现时,通信器在训练工作负载的第一个阶段的100个步骤之前发生了大约7分钟的额外执行时间。
- TCP/IP 和共享内存都可以用作通信方法,但是当容器中的工作负载执行频繁的内存分配时,它们会产生显著的开销。
- 为了解决这个问题,我们采用了在我们以前的工作中开发的无陷阱通信器 FairGV [37] ,它是为使用 GPU 的 VM 环境设计的。通信器实现以下三种机制:
- 首先,它在每个容器中的每个进程的用户空间中建立专用的容器间共享内存。我们在 gShare 和容器中的进程之间提供了一个 POSIX 共享内存对象。
- 接下来,它在共享内存中为两个方向设置了两个有界的无锁队列[38] ,如图1所示。有界的无锁队列使生产者和使用者可以并发访问队列而无需锁定,因此通信器不需要管理缓冲区同步锁的系统调用。
- 最后,在检查队列中的新消息时采用轮询机制,避免了操作系统内核支持的中断或信号。
- 通信器是连接 gShare 前端和后端的重要模块。
调停器
- 核心引擎:
- 核心引擎负责初始化 gShare 并处理每个容器的 GPU 内存请求。
- 在初始化时,引擎分配整个 GPU 内存,以便以集中的方式管理内存。
- 引擎还从后端接收内存分配请求,并使用三级内存分配器处理这些请求。
- 然后将流程结果返回到后端。
- 容器管理器:
- 容器管理器创建、读取、更新和删除每个容器的信息。
- 容器信息是一种 C 语言数据结构,包括容器 ID、进程列表、最大 GPU 内存大小、当前内存利用率和 GPU 可用内存列表。
- 在进程列表中,每个进程结构都有进程 ID、父容器 ID 和 GPU 内存分配列表。
- 当容器以其 GPU 内存需求开始时,容器管理器动态创建容器结构并将其插入到容器列表中,该列表是一个全局变量。
- 因此,给定容器 ID,容器管理器可以通过迭代容器列表来获得所需的信息。
三级内存分配
- GSM 分配器
- GShare 将整个物理 GPU 内存分割成固定大小的块,以防止外部碎片,就像现代操作系统中的分页一样[39]。
- 因此,我们将GPU 内存作为一个固定大小的片段数组。
- 每个内存块称为 GPU 共享内存(GSM)。
- GSM 分配器是管理此阵列的顶级内存分配器,并维护一个系统范围的免费 GSM 列表,以保持未分配的 GSM。
- 为了安全隔离,我们将默认 GSM 大小配置为比默认页面大小(例如256MB)更大(例如4KB) ,如3.4节所述。
- 伙伴分配器
- 好友分配器是管理每个 GSM 的第二级分配器。
- 由于 GSM 的大小相对大于默认的页面大小,因此需要额外的算法来减少 GSM 的内部碎片。
- 伙伴算法递归地将 GSM 分成两半,以尽可能适当地满足容器的内存请求[40]。
- 与分页相比,好友算法在提供大型连续内存块方面具有优势[39]。
- 在各种伙伴算法中,我们实现了基于链表的伙伴算法[41]。
- Slab分配器
- slab分配器是处理相同大小内存的频繁分配的底层分配器[42]。
- 工作负载可以依次分配相同大小的内存,这可能导致内部碎片和内存耗尽。
- 例如,当工作负载分配频繁的2049字节时,好友分配器将为每个请求提供两个2048字节的块,浪费了第二个块的大部分。
- 当板块分配器检测到来自容器的大小相同的请求时,它从好友分配器分配一个大块,如图2所示。
- 然后把它分成相等大小的块,并把每块板送到容器中。
GPU内存管理
gShare初始化
- 在初始化阶段
- gShare 的核心引擎在最大 GPU 内存大小范围内使用大量 CUDA 内存分配调用(即 cuMemAlloc)迭代地分配 GSM (即固定大小的内存块)。默认的 GSM 大小(例如,256MB)成为内存分配函数的参数值。
- 每个 GSM 的虚拟设备地址注册到 GSM 分配器的系统范围的空闲列表中。
- GSM 分配器然后能够管理整个 GPU 内存作为一个数组或固定大小的内存块列表。
内存分配
让我们假设启动了一个容器,然后容器中的一个进程首先通过执行 cuMemAlloc 或 cudaMalloc 从 gShare 请求一个内存片。当一个容器启动时,GSM 分配器将所需的 GSM 数量(相当于容器的最大 GPU 大小)从系统范围的空闲列表移动到容器的空闲列表。
现在启动容器中的一个进程,该进程请求大量的 GPU 内存。
- 请求通过 API 远程处理组件传递到 gShare 的核心引擎。
- 核心引擎首先检查容器是否已经超过最大限制,引用容器结构中的当前内存利用率。
- 在内存限制内,内存分配进程取得进展。因为进程结构的分配列表中还没有元素,所以核心引擎允许 GSM 分配器将一个 GSM 从容器的空闲列表移动到进程的分配列表中。
分配的 GSM 将首先映射到容器中进程的虚拟设备地址空间,通过2.1节中解释的 CUDA IPC 功能。
- 在这个操作过程中,gShare 需要一个握手过程,以便为目标 GSM 的内存映射交换必要的信息。
- 如图3所示,核心引擎通过 cudalpcGetMemHandle 调用获得 GSM 的 IPC 内存句柄,然后将句柄的副本传输到容器的包装器库。
- 容器中的进程然后通过调用 cadalpcOpenMemHandle 将 GSM 与其设备地址空间映射,这将打开 IPC 句柄并返回 GPU 的设备指针。
- 原始 GSM 和映射的 GSM 目前属于不同的虚拟地址空间,但是它们映射到相同的物理地址。
- 然后,映射的 GSM 的虚拟地址返回给 gShare,并用作好友分配器中的基地址。
- 在这个操作过程中,gShare 需要一个握手过程,以便为目标 GSM 的内存映射交换必要的信息。
然后伙伴分配器就可以处理进程的请求了。
- 分配器将分配的 GSM 分成两半,直到满足请求大小为止。
- 当找到目标半数时,分配器将目标半数的偏移量添加到从容器接收到的基地址。和是最终的虚拟设备指针,然后传递给容器中的进程。容器结构中的当前内存利用率也会更新。
- 当来自同一进程的新请求时,如果之前映射的 GSM 能够为请求提供足够的空间,好友分配器就可以立即返回最终地址,而不需要进一步的握手。如果 GSM 不能提供足够的空间,GSM 分配器将一个空闲的 GSM 移动到进程的分配列表中,并重复握手过程。
当进程通过执行 cuMemFree 或 cudaFree 释放分配的内存时,释放的目标地址被发送到gshare。
- 然后,核心引擎通过调查每个分配的 GSM 和目标地址的基地址之间的映射信息,在进程的分配列表中找到相应的 GSM。
- 当找到 GSM 时,偏移量被发送到好友分配器,以便分配器将空间标记为空闲。
- 容器的当前内存利用率也会更新。
- 当容器中的进程终止时,将删除所有剩余的分配信息,并将进程拥有的 GSM 返回给容器。
内存合并
由于 GSM 大小是预先确定的,gShare 很难分配比默认 GSM 大小更大的内存。让我们假设默认的 GSM 大小为256MB,TensorFlow 进程请求连续的2GB GPU 内存。这种情况偶尔会发生,因为 TensorFlow 的 BFC 分配器[35]请求 GPU 内存大小,乘以2,比如1、2和4 GB。即使中介返回8个2GB 的 GSM,这些 GSM 也将在过程中通过8个各自的 udalpcOpenMemHandle 调用映射到非连续的虚拟设备地址。然后容器的内存分配请求失败。
为了解决这个问题,gShare 在分配大于默认 GSM 大小的内存时会合并空闲 GSM。当核心引擎接收到这样的请求时,GSM 分配程序将查找所需的空闲 GSM 数量。如果没有足够的空闲内存,gShare 将返回一个内存不足错误。如果有足够多的 GSM,核心引擎将通过分别调用 cuMemFree 来释放 GPU 上的每个选定的 GSM。然后,它分配一个大的 GSM,其大小与释放的 GSM 的总和相同。然后,这个大型 GSM 的手柄被送到容器中的进程中进行握手。当进程释放分配的大内存时,执行拆分。释放一个大的 GSM,并用默认的 GSM 大小重新分配。
内存膨胀
容器在运行时可能需要更多的 GPU 内存。
- 例如,它可以动态增加用于训练的批量大小,这会消耗更多的 GPU 内存,或者启动一个新线程来提供推理服务。
- 在这种情况下,需要重新启动容器以改变允许的 GPU 内存大小,从而导致服务中断[43]。
为了解决这个问题
- gShare 支持动态 GPU 内存膨胀。当某个容器需要超出限制的内存分配时,GSM 分配器从系统范围的空闲列表中选择空闲 GSM,或者从不断显示 GPU 内存利用率低的某个容器的空闲 GSM 中选择空闲 GSM。
- 如果找到候选 GSM,它们将在运行时传输到容器的空闲列表。这允许容器在没有服务中断的情况下增加 GPU 内存。可用于容器膨胀的 GSM 的数量由管理员配置。
安全隔离
容器内和跨容器的隔离机制
由于 gShare 可以由多个租户使用,因此容器之间的内存隔离对于实现数据安全至关重要。
- 从这个意义上说,在容器中运行的应用程序不应该为了安全而访问另一个容器的 GPU 内存。
- 同时,确保同一容器中的进程之间的数据隔离对于防止故障也很重要。
- 虽然同一个容器属于同一个租户,如果某个进程修改了另一个进程的 GPU 数据,后一个进程将得到不准确的计算结果。
- 因此,确保容器间和容器内的隔离非常重要。
GShare 使用相同的机制确保 GPU 内存容器间和容器内的安全隔离。
- 对于这两种类型的隔离,gShare 都利用了3.3.2节中解释的 IPC 功能。
- IPC 功能允许进程打开从 gShare 接收的句柄,并将导出的 GSM 映射到进程的虚拟设备地址空间。
- 由于 gShare 将一个专用的 GSM 及其对应的句柄导出到进程,因此进程可以专门映射 GSM 并使用自己的虚拟设备地址访问它。
如果容器中的恶意进程能够预测另一个容器中进程的句柄值,那么该恶意进程可以映射受害进程的相同区域,并破坏 gShare 的安全隔离。
- 在这种情况下,受害进程和恶意进程具有不同的虚拟地址空间,但是它们在 GPU 中映射相同的物理内存区域,如图4所示。幸运的是,预测另一个进程句柄的内容是困难的,因为句柄由一个大的64字节值组成。
- 然而,不同的手柄值,这是连续生成的,表现出一定的模式,如图5所示。
- 在连续的值中,虽然大部分内容保持不变,但有些数字会递增1或2。
- 这套制服很容易受到攻击。我们希望 IPC 功能是固定的,以生成一个随机的句柄值,从而在多租户环境中获得更好的安全性。
跨容器隔离的思考
目前 gShare 的设计允许容器中的每个进程拥有自己的 GSM。
- 但是,如果容器中的几个进程没有充分利用 GSM,GPU 内存将被浪费。
- 因此,在开发早期,我们计划为一个容器提供一个单一的大型全球移动通信系统,其大小相当于集装箱的内存限制,并为集装箱的每个进程分配一个大型全球移动通信系统的中间区域。
- 然而,这种设计很难实现,因为 IPC 功能不支持大型全球移动通信系统的中间区域映射。
- CudalpcGetMemHandle 函数只接收以前分配的 GPU 内存的起始地址。
- 因此,如果 gShare 为一个容器提供一个单一的大型 GSM,容器中的所有进程将映射整个 GSM,并自由访问另一个进程的 GPU 数据,打破容器内隔离。
- 为了克服单个大型 GSM 的这种局限性,我们考虑采用 G-net [44]中的 isoPointer,它可以检查每个 GPU 内核的指针访问是否在合法内存空间的范围内。IsoPointer 是一个 C + + 类,它实现了指针运算符重载。这个解决方案可以解决这个问题,但是它需要修改深度学习或 HPC 库的源代码,这会加重 gShare 部署的负担。此外,该方法还可能导致性能下降,因为在运行时期间经常执行内存访问检查
- 但是,如果容器中的几个进程没有充分利用 GSM,GPU 内存将被浪费。
因此,在当前的设计中,我们为每个容器提供几个较小的 GSM (例如,256 MB) ,以便每个进程可以根据需要拥有专用的 GSM。
- 如果 GSM 的大小太大(例如,4 GB) ,只有少数进程可以利用 GPU。
- 例如,当给容器提供12GB 的 GPU 内存时,只有三个进程可以访问 GPU。
- 由于最近的深度学习或 HPC 框架利用单个进程中的多线程,因此这可能不是一个重要问题。
- 尽管如此,容器在使用 GPU 执行更多进程以执行额外工作负载方面仍有局限性。
- 此外,如果每个进程未充分利用内存,则会发生内部碎片。
- 相反,当块大小太小(例如1 MB)时,gShare 很难处理大的连续内存分配(例如4 GB)。
- 然后,gShare 需要将几个块合并到一个块中,这会导致3.3.3节中解释的重新配置开销
- 如果 GSM 的大小太大(例如,4 GB) ,只有少数进程可以利用 GPU。
评估
讨论
相关工作
虚拟环境中的 GPU 内存共享研究可以分为两类: 集成 CPUGPU 处理器如 Intel GPU 的研究[43,49,50]和离散 GPU 如 NVIDIA GPU 的研究[19,20,51-53]。集成芯片通常打开设备驱动程序实现。因此,前人的研究对 GPU 页表进行了修改,以实现内存共享。然而,由于商业原因,离散的 GPU 往往不会显示驱动程序,在这些 GPU 中实现内存共享具有挑战性。
集成: gVirt [49]是一个基于 Xen 的[54] GPU 虚拟化解决方案,用于 Intel 片上 GPU。GVirt Mediator 将图形内存划分为几个固定大小的区域,并通过修改 GPU 页表将每个区域分配给某个 VM。用于 KVM 的 gVirt [55]的移植版本现在是主线 Linux 内核的一部分。
- GScale [50,56]解决了 gVirt 的可伸缩性限制,即 gVirt 在 Intel HD Graphics p4700中不能启用超过4个虚拟机。GScale 不需要将内存划分为一个大的区域并将每个部分分配给 VM,而是允许 VM 通过为每个 VM 维护一个私有的影子 GPU 表来映射 GPU 内存中的任何区域。GScale 在 Linux 上最多可以运行15个 VM。
- GBallon [43]在 Intel GPU 中支持 GPU 内存膨胀。该解决方案可以在运行时调整每个 VM 的 GPU 内存大小。当 gBallon 检测到由于内存不足而导致的性能下降时,它会通过修改 GPU 页面表来动态增加 GPU 内存,而不会造成服务中断。
离散: ConVGPU [51]指出,NVIDIA Docker 不关心每个容器如何使用图形处理器内存,这可能导致程序失败,因为有限的图形处理器内存。ConVGPU 的 GPU 包装器库拦截容器的 GPU 内存分配请求。然后,ConVGPU 的调度程序根据当前可用的 GPU 内存决定是否接受请求。鉴于这项研究是在一个内存有限的 NVIDIA 图形处理器上进行的,它拥有5GB 的内存,这种节流请求的方法是合理的。然而,由于最近的 GPU 配备了24-80GB 的大容量内存,因此需要一个更高级的框架来支持 GPU 内存共享。
- GaiaGPU [19]通过将一个物理 GPU 划分为多个虚拟 GPU,并将每个虚拟 GPU 分配给一个容器,从而在容器之间共享 GPU。GaiaGPU 拦截与内存相关的函数调用,并在容器超过允许的内存限制时限制容器的内存请求。
- KubeShare [20]支持 GPU 共享,包括多个容器之间的 GPU 内存共享。它还拦截 CUDA 库中所有与内存相关的 API,如果容器超过其最大使用需求,则拒绝容器的进一步内存请求。
一些研究侧重于高 GPU 利用率的内存共享、虚拟加速器栈和特定于应用程序的加速器。
- Salus [52]支持在容器中运行的多个深度学习应用程序之间共享 GPU 内存。它的目标是通过在同一个 GPU 上打包更多的深度学习作业来提高内存利用率,而不是在多个容器之间提供隔离。
- Ava [53]使用自动构造生成 hypervisor 管理的虚拟加速器栈。在每个加速器堆栈中,Ava 跟踪分配给每个 VM 用于共享内存的设备内存。所提出的方法是有效的,因为系统管理程序阻止每个 VM 访问 GPU 内存,并将 VM 的内存请求传递给 Ava。但是,在容器环境中,每个容器都可以单独访问 GPU 内存。因此,很难将同样的方法应用于容器
现场可编程门阵列(FPGA) ,应用程序特定的加速器,通常打开他们的设备驱动程序实现与集成 CPU-GPU 处理器。因此,FPGA 虚拟化的研究可以修改 GPU 页表以实现内存共享[57]。GShare 侧重于为不打开设备驱动程序实现的离散 GPU 启用内存共享。