本节主要讨论 Vulkan 中内存管理的相关内容,在这个过程中可以加深对 Heap, Memory,Buffer 和 Image 等概念的理解。
1 Vulkan 内存管理
Vulkan 将内存划分为两大类:Host Memory 和 Device Memory。
Host 是运行应用程序的处理器,在 PC 机上就是指 CPU。Device 是执行 Vulkan 命令的处理器,在 PC 机上就是指 GPU。
所以,Host Memory 指的是对 Host 可见的内存,Device Memory 指的是对 Device 可见的内存。
更详细的,Vulkan系统中的内存有四种类型:
- Host Local Memory,只对 Host 可见的内存,通常称之为普通内存
- Device Local Memory,只对 Device 可见的内存,通常称之为显存
- Host Local Device Memory,由 Host 管理的,对 Device 看见的内存
- Device Local Host Memory,由 Device 管理的,对 Host 可见的内存
但并不是所有设备都支持这四种类型。一些嵌入式系统、移动设备甚至是笔记本电脑的 GPU,可能与 CPU 共享内存控制器和内存子系统。这种情况它的内存只有一种类型,我们通常称之为 unified Memory architecture(统一内存架构)。
1.1 Host Memory
Host Memory 是 CPU 可以访问的常规内存,一般是通过调用 malloc 或 new 分配。
Vulkan API 创建的对象通常需要一定数量的 Host Memory,用来储存对象和数据结构。Vulkan 对 Host Memory 的要求就是内存地址是对齐的,这是因为某些高性能 CPU 指令在对齐的内存地址上效果最佳。通过假定存储 CPU 端数据结构的分配是对齐的,Vulkan 可以无条件使用这些高性能指令,从而提供显著的性能优势。
1.2 Device Memory
任何可以由 Device 访问的内存,都被称为 Device Memory(设备内存)。Device Memory 距离 Device 更近,比 Host Memory 更有性能优势。Image object、Buffer object、UBO(uniform Buffer objec)都由 Device Memory 分配。Vulkan 中的所有资源都由 Device Memory 支持。
1.3 Pool
内存是一种昂贵的资源,分配操作通常会有间接的系统代价。Vulkan 中通过资源池来平摊成本,一些创建比较频繁的资源都由资源池统一管理:
- Command Buffer Pool
- Descriptor Pool
- Query Pool
2 Heap 和 Memory
2.1 Heap
在 Vulkan 中,首先有一个叫做 Heap 的概念,可以认为就是一个有着不同属性的 Memory pool,app 显然可以根据它自己对资源的理解,将不同用途的资源申请分配到不同属性的 Heap 中去。但在之前的 API,如 OpenGL,D3D 11 等,资源的属性更多的时候是作为一个 hint,在资源创建的时候传递给 driver,由 driver 来最终决定将资源分配到何处。例如,在 D3D 11 中,创建资源的时候可以指定用途,从 default/dynamic/immutable/staging 中选择一项,然后 driver 负责根据用途来挑选不同的 Memory 类型。
然而,在 Vulkan中,资源对象,例如 Buffer 和 Image,以及资源对象实际使用的 Memory,这两个概念已经剥离开来了。也就是说,在 Vulkan 中创建一个对象,并不会同时为这个对象分配相应的 Memory。 Memory 需要从 Heap 中分配出来,然后和对象来一次 association,对象才真正地有了 Memory 来存放其中的内容。
Heap 本身作为一个硬件支撑的对象,属于 Physical Device 的范畴,所以,可以用 vkGetPhysicalDevice MemoryProperties
来获取硬件可用的 Heap 资源的信息。这个 api 返回的数据结构是一个 VkPhysicalDevice MemoryProperties
的对象,其摘要如下:
1 | typedef struct VkPhysicalDevice MemoryProperties { |
Heap 的信息以一种非常不直观的形式,由这种数据结构展示出来。其中,真正的 Heap 存放在 MemoryHeaps
数组中,这个数组本身的大小是固定的,但是其中有效的 Heap 个数由 MemoryHeapCount
指定。一个 Vk MemoryHeap
只包含两个信息,第一个是 Heap 的大小,第二个则是一个掩码数,目前只有一个选项可选:是否是 Device-Local 的。Vulkan 规定所有的 Heap中,至少有一个必须设置为 Device-Local。
2.2 Memory
我们一般时候说的 Memory,都是指存储空间(不一定是 CPU 的内存,也可能是 GPU 的显存),而在 Vulkan 中, Memory 也特指 Memory 对象,即从一个 Heap 中分配出来的、可以为 Image/Buffer 做容器的对象。当我们谈到 Memory 对象的时候,我们指的是 Vulkan 中的 Memory 概念,其他时候,我们谈 Memory,指的就是存储空间。
有了 Heap 之后,我们就可以从中分配 Memory 对象了,分配的方法很简单,使用 vkAllocateMemory
即可。分配主要需要的信息都在 VkMemoryAllocateInfo
数据结构中体现,其摘要如下:
1 | typedef struct VkMemoryAllocateInfo { |
一个 Memory 对象在生命周期的尽头,由 vkFreeMemory
释放。当释放一个 Memory 对象的时候,app 应当保证此时 Device 不再引用此 Memory 对象上所关联的 Image/Buffer。
3 Buffer 和 Image
内存的作用是给资源做底层支持,不同的资源对内存的要求并不一样。Vulkan 有两种基本资源类型:Buffer 和 Image。
Buffer 是最简单的资源类型,可以用来储存线性的结构化的数据,也可以储存内存中原始字节。
Image 则相对比较复杂,具有特殊的布局(layout)和格式(format),可用来做 flitering,blending,depth 和 stencil testing 等。
3.1 Buffer
从上图中我们可以看到,Vulkan 中使用的 Buffer 类型主要有:
- Indirect Buffer
- Index Buffer
- Vertex Buffer
- Uniform texel Buffer
- Uniform Buffer
- Storage texel Buffer
- Storage Buffer
当使用 vkCreateBuffer
创建一个 Buffer 对象的时候,在创建信息 VkBufferCreateInfo
中,我们就要指定这个 Buffer 对象可能的使用场景。除了上述七种有着一一对应的使用掩码位之外,还有额外的两个,分别表示 Buffer 是否可以作为 Transfer 的 Src 或者 Dst。此外还可以设置是否创建 Sparse 的资源,以及其在 Queue Family 之间共享或者独享等信息。
当我们创建好了一个 Buffer 对象后,我们需要将它和一个 Memory 对象关联起来,这样才可以真正让这个 Buffer 对象拥有 Memory。关联是通过 vkBindBufferMemory
来完成的,然而,直接 binding 可能会失败,因为创建的 Buffer 可能会有一些额外的要求,例如对齐等,所以在此之前,我们需要使用 vkGetBufferMemoryRequirements
来获取这个 Buffer 对象对于 Memory 等要求。要求被填入一个 VkMemoryRequirements
的对象中,这个对象的摘要如下:
1 | typedef struct VkMemoryRequirements { |
对于一个 Texel 的 Buffer,如 Uniform Texel Buffer 或者 Storage Texel Buffer,shader 如果需要使用的话,还需要用 vkCreateBufferView
为其创建一个 Buffer View,才可以绑定到 Descriptor Set 上,创建信息保存在 VkBufferViewCreateInfo
中,主要制定了 View 的 Format,以及 View 在 Buffer 中的 offset 和 range。
Buffer 和 Buffer View 分别使用 vkDestroyBuffer
以及 vkDestroyBufferView
进行销毁。
3.2 Image
从上图中可以看出,image 主要是作为 shader resource 的 sampled image(即传统意义上的 texture),storage image,以及 frame buffer 中的 input attachment, color attachment 和 depth/stencil attachment。大概相当于 D3D 中的 texture, render target 等,表示的是具有 pixel array 和 mipmap 结构的数据。
创建一个 image,需要使用 vkCreateImage
,主要核心在于数据结构 VkImageCreateInfo
,其摘要如下:
1 | typedef struct VkImageCreateInfo { |
首先解释几个比较简单的部分,例如,imageType 决定 image 是 1d/2d/3d,format 决定 image 的格式,extent 决定其大小,mipLevels 和 arrayLayers 则是其 mip 层数和数组维度,samples 给出了采样个数。如同 buffer 创建的时候一样,创建 image 的时候也需要指定共享模式,以及可用的 queue 的 family 索引。
tiling 则决定了 image 本身的 tile 方法,包括 linear 和 optimal 两个选项:
- linear - 其中的图像(Image)数据线性的排列在内存中。
- optimal - 其中的图像(Image)数据以高度优化的模式进行布局,可以有效利用设备的内存子系统。
线性布局(linear layout)适合连续的单行的读写,但是大多数图形操作都涉及到跨行读写纹理元素,如果图像的宽度非常宽,相邻行的访问在线性布局中会有非常大的跳转。这可能会导致性能问题。
优化布局(optimal layout)的好处是内存数据根据不同内存子系统进行优化,比如将所有的纹理像素都优化到一块连续的内存区域中,加快内存处理速度。
如果需要在 shader 中使用 image,必须通过 frame buffer 或者 descriptor set,通过 image view 的方式才可以。创建 image view 是通过 vkCreateImageView
来完成的,如同 buffer 和 memory 的 binding 一样,一个 image 首先需要使用 vkGetImageMemoryRequirements
来获取其对 memory 的要求,然后才可以用 vkBindImageMemory
来将 memory 和 image bind 起来。