Vulkan 内存管理

众所周知,Vulkan 编程的代码量相对于 OpenGL 多了一个数量级(不用害怕,后面Vulkan封装一下,用起来也会非常简洁),本文避免一上去就讲一大堆代码,奉行概念先行。

概念掌握的差不多了,再去看代码,  这样思路不容易卡住,大致就可以把握住整体代码逻辑,知道这一块代码是干嘛的,那一块是什么目的。

Vulkan 内存管理

Vulkan 将内存管理的工作交给了开发者自己负责,如何分配释放内存,怎样制定内存策略都由开发者自己决定,当然出了问题也是由开发者自己负责。

Vulkan 将内存划分为两大类:主机内存 Host Memory 和 设备内存 Device Memory

在移动设备上,主机内存就是 CPU 内存,设备内存就是 GPU 内存,显存。在此基础上,每种内存类型还可以单独按照属性进一步划分。

Vulkan提供了一种透明的机制来显示内部内存的细节以及相关属性。这样的做法在OpenGL中是完全不可能的,后者不允许应用程序显示地控制内存区域和布局。

Vulkan 内存管理

Vulkan 系统中的内存有四种类型(并不是所有设备都支持这四种类型):

  • Host Local Memory,只对 Host 可见的内存,通常称之为普通内存
  • Device Local Memory,只对 Device 可见的内存,通常称之为显存
  • Host Local Device Memory,由 Host 管理的,对 Device 可见的内存
  • Device Local Host Memory,由 Device 管理的,对 Host 可见的内存(不常见)

对比这两种内存类型的话,主机内存比设备内存更慢,但是宿主机内存的容量通常更大。

设备内存,它对于物理设备是直接可见的。物理设备可以直接读取其中的内存区块。设备内存与物理设备之间的关系非常紧密,因此它的性能比宿主机内存更高。

图像对象(VkImage) 、缓存对象(VkBuffer),以及一致变量的缓存对象(Uniform Buffer)都是在设备内存端分配的。

单一的物理设备可能有多种类型的内存;根据它们的堆类型以及属性的不同还可能进一步细分。

函数 vkGetPhysicalDeviceMemoryProperties() 负责查询物理设备上可用的内存堆和内存属性。

VkPhysicalDeviceMemoryProperties

typedef struct VkPhysicalDeviceMemoryProperties {
    uint32_t        memoryTypeCount;
    VkMemoryType    memoryTypes[VK_MAX_MEMORY_TYPES];
    uint32_t        memoryHeapCount;
    VkMemoryHeap    memoryHeaps[VK_MAX_MEMORY_HEAPS];
} VkPhysicalDeviceMemoryProperties;
  • memoryTypeCount 支持的内存类型数量。
  • memoryTypes 有效元素个数为 memoryTypeCount 的内存类型信息数组。
  • memoryHeapCount 支持的内存堆数量。
  • memoryHeaps 有效元素个数为 memoryHeapCount 的内存堆信息数组。

其中 memoryHeaps 中就是用于获取具体内存堆是哪一种。其中 VkMemoryHeap 定义如下:

VkMemoryHeap

typedef struct VkMemoryHeap {
    VkDeviceSize         size;
    VkMemoryHeapFlags    flags;
} VkMemoryHeap;
  • size 该堆大小。单位为字节。
  • flags 该堆类型标志位。

其中 flags 就是用于指示该堆的类型。其有效值定义于 VkMemoryHeapFlagBits 中,如下:

VkMemoryHeapFlagBits

typedef enum VkMemoryHeapFlagBits {
    VK_MEMORY_HEAP_DEVICE_LOCAL_BIT = 0x00000001,
    VK_MEMORY_HEAP_MULTI_INSTANCE_BIT = 0x00000002,
    VK_MEMORY_HEAP_MULTI_INSTANCE_BIT_KHR = VK_MEMORY_HEAP_MULTI_INSTANCE_BIT,
    VK_MEMORY_HEAP_FLAG_BITS_MAX_ENUM = 0x7FFFFFFF
} VkMemoryHeapFlagBits;

枚举值解释:

  • VK_MEMORY_HEAP_DEVICE_LOCAL_BIT (0x00000001): 表示内存堆是设备本地的。这种内存通常是最快的,因为它与 GPU 紧密集成,适合存储需要频繁访问的数据。
  • VK_MEMORY_HEAP_MULTI_INSTANCE_BIT (0x00000002): 用于多 GPU 配置,表示内存堆在多个物理设备实例中是独立的。
  • VK_MEMORY_HEAP_MULTI_INSTANCE_BIT_KHR: 这是 VK_MEMORY_HEAP_MULTI_INSTANCE_BIT 的一个别名,为了兼容性而定义。KHR 后缀表示这是一个 Khronos 扩展(Khronos 是 Vulkan 标准的管理机构)。
  • VK_MEMORY_HEAP_FLAG_BITS_MAX_ENUM (0x7FFFFFFF): 用于强制枚举类型为 32 位整数。这个值不实际使用,仅作为枚举类型的大小限制。

其中每个堆自身可以包含一到多个类型的内存,堆上的内存类型信息被定义在 memoryTypes 中,其 VkMemoryType 定义如下:

VkMemoryType

typedef struct VkMemoryType {
VkMemoryPropertyFlags    propertyFlags;
uint32_t                 heapIndex;
} VkMemoryType;
  • propertyFlags 内存类型标志位。
  • heapIndex 对应的 memoryHeaps 堆索引。

其中 propertyFlags 有效值被定义在了 VkMemoryPropertyFlagBits 枚举中,其定义如下:

VkMemoryPropertyFlagBits(重点关注)

typedef enum VkMemoryPropertyFlagBits {
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT = 0x00000001,
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT = 0x00000002,
VK_MEMORY_PROPERTY_HOST_COHERENT_BIT = 0x00000004,
VK_MEMORY_PROPERTY_HOST_CACHED_BIT = 0x00000008,
VK_MEMORY_PROPERTY_LAZILY_ALLOCATED_BIT = 0x00000010,
} VkMemoryPropertyFlagBits;
  • VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT 表示在此内存类型上分配的内存可被物理设备高效访问。只有对应的堆为 VK_MEMORY_HEAP_DEVICE_LOCAL_BIT 才会有该内存类型。
  • VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT 表示在此内存类型上分配的内存可被 Host 端通过 vkMapMemory() 函数进行映射,进而进行访问。
  • VK_MEMORY_PROPERTY_HOST_COHERENT_BIT 表示在此内存类型上分配的内存将会自动进行同步,不需要手动调用 vkFlushMappedMemoryRanges() 和 vkInvalidateMappedMemoryRanges() 来进行内存同步。
  • VK_MEMORY_PROPERTY_HOST_CACHED_BIT 表示在此内存类型上分配的内存为 缓存 (高速缓存)内存, Host 端访问 非缓存 内存要比访问 缓存 内存慢。但是 非缓存 内存总是 同步内存 ( VK_MEMORY_PROPERTY_HOST_COHERENT_BIT )。
  • VK_MEMORY_PROPERTY_LAZILY_ALLOCATED_BIT 表示在此内存类型上分配的内存只有物理设备可访问。内存类型不能同时为 VK_MEMORY_PROPERTY_LAZILY_ALLOCATED_BIT 和 VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT 。此外其底层内存将会用于 惰性内存 。

内存分配

使用vkAllocateMemory函数分配的设备内存只能在设备端进行访问,它对于宿主机来说是不可见的。

宿主机只能访问那些支持映射的设备内存类型,即,内存属性包含了VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT标识量的内存对象。

通过之前 vkGetPhysicalDeviceMemoryProperties() 函数我们可以获取到设备的内存信息,现在我们就可以通过这些信息进行内存分配了。

为此 Vulkan 为我们提供了 vkAllocateMemory() 函数进行内存分配。该函数定义如下:

vkAllocateMemory

VkResult vkAllocateMemory(
    VkDevice                                    device,
    const VkMemoryAllocateInfo*                 pAllocateInfo,
    const VkAllocationCallbacks*                pAllocator,
    VkDeviceMemory*                             pMemory);
  • device 分配内存的目标设备。
  • pAllocateInfo 内存分配信息。
  • pAllocator 句柄内存分配器。
  • pMemory 分配的内存句柄。

其中 pAllocateInfo 用于指定内存的分配信息, pAllocator 用于指定创建 pMemory 内存句柄时的分配器。

其中主要的内存分配信息被定义在了 pAllocateInfo ,对应的 VkMemoryAllocateInfo 定义如下:

VkMemoryAllocateInfo

typedef struct VkMemoryAllocateInfo {
    VkStructureType    sType;
    const void*        pNext;
    VkDeviceSize       allocationSize;
    uint32_t           memoryTypeIndex;
} VkMemoryAllocateInfo;
  • sType 是该结构体的类型枚举值, 必须 是 VkStructureType::VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO 。
  • pNext 要么是 NULL 要么指向其他结构体来扩展该结构体。
  • allocationSize 要分配的内存大小。单位为 字节 。
  • memoryTypeIndex 分配内存的目标内存类型索引。

其中 memoryTypeIndex 尤为重要,用于指定在 memoryTypes[memoryTypeIndex] 对应的内存类型上进行内存分配,对应分配的堆为 memoryHeaps[memoryTypes[memoryTypeIndex].heapIndex] 。

由于每个 memoryTypes 都有着不同的属性,所以一般会根据功能需求在某个内存类型上进行分配。

VkDevice device; 
VkPhysicalDevice physicalDevice;
VkDeviceSize size = 1024;
VkDeviceMemory* memory = nullptr;

// 获取物理设备内存属性
vkGetPhysicalDeviceMemoryProperties(physicalDevice, &memoryProperties);

// 查找一个主机可见的内存类型
uint32_t memoryTypeIndex = VK_MAX_MEMORY_TYPES;
for (uint32_t i = 0; i < memoryProperties.memoryTypeCount; i++) {
    if ((memoryProperties.memoryTypes[i].propertyFlags & VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT) &&
        (memoryProperties.memoryTypes[i].propertyFlags & VK_MEMORY_PROPERTY_HOST_COHERENT_BIT)) {
        memoryTypeIndex = i;
        break;
    }
}

if (memoryTypeIndex == VK_MAX_MEMORY_TYPES) {
    fprintf(stderr, "Could not find a suitable memory type!\n");
    exit(EXIT_FAILURE);
}

// 准备内存分配信息
VkMemoryAllocateInfo allocInfo = {};
allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
allocInfo.allocationSize = size;
allocInfo.memoryTypeIndex = memoryTypeIndex;

// 分配内存
VK_CHECK_RESULT(vkAllocateMemory(device, &allocInfo, nullptr, memory));

printf("Memory allocated successfully!\n");

内存映射

我们通过API函数vkMapMemory()来实现宿主机对设备内存的映射访问。这个函数会返回一个虚拟地址的指针,指向映射后的设备内存区域。

原则上所有的设备内存对于 CPU 来说并不像 new/malloc 分配出来的内存那样能够直接进行读写。

为了 CPU 能够读写设备内存,硬件供应商都会提供一部分带有 VkMemoryPropertyFlagBits::VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT 属性的内存用于 CPU 访问。

而在 Vulkan 中分配的内存最终只会对应一个 VkDeviceMemory 句柄,为了能够获得 VkMemoryPropertyFlagBits::VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT 内存类型分配的内存句柄底层的内存地址,可以通过 vkMapMemory() 函数将分配的设备内存底层的虚拟 (说明见下文)地址返回给 CPU (也就是 Host 端)。

vkMapMemory

VkResult vkMapMemory(
    VkDevice                                    device,
    VkDeviceMemory                              memory,
    VkDeviceSize                                offset,
    VkDeviceSize                                size,
    VkMemoryMapFlags                            flags,
    void**                                      ppData);
  • device 内存对应的逻辑设备。
  • memory 要映射的目标内存。
  • offset 内存映射从内存首地址开始的偏移量。从 0 开始。单位为 字节 。
  • size 要映射的内存大小。单位为 字节 。如果指定为 VK_WHOLE_SIZE ,则表明映射范围为从 offset 开始到 memory 结尾。
  • flags 内存映射的额外标志位参数。
  • ppData 内存映射结果。为 void* 的指针。该指针减去 offset 的对齐大小最小 必须 为 VkPhysicalDeviceLimits::minMemoryMapAlignment 。

其中 memory 必须 在 VkMemoryPropertyFlagBits::VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT 类型的内存上分配。

当该函数成功返回后, memory 就被认为在Host 端进行了内存映射 ,并处于映射态。

当内存映射并使用结束后,可进行解除映射,进而释放系统的虚拟内存。可通过 vkUnmapMemory() 函数将映射过的内存进行解映射 。

内存映射代码示例:

// 映射内存
void* data;
VK_CHECK_RESULT(vkMapMemory(device, *memory, 0, size, 0, &data));
printf("Memory mapped successfully!\n");

// 写入数据到内存
int* intData = (int*)data;
for (size_t i = 0; i < size / sizeof(int); i++) {
    intData[i] = i;
}
printf("Data written to memory successfully!\n");

// 解除内存映射
vkUnmapMemory(device, *memory);
printf("Memory unmapped successfully!\n");

内存同步

所谓内存同步是指:虚拟内存中的数据与对应的 VkDeviceMemory 设备内存底层数据保持一致。

当分配的设备内存所对应的内存类型 包含 VkMemoryPropertyFlagBits::VK_MEMORY_PROPERTY_HOST_COHERENT_BIT 时,内存同步将 会自动 进行。其同步规则如下:

  • 当向映射的虚拟内存中写入时,写入虚拟内存中的数据也会同步到对应的 VkDeviceMemory 底层设备内存中。
  • 如果 GPU 向 VkDeviceMemory 底层设备内存中写入数据时,这部分修改的设备内存也会同步到映射的虚拟内存中。

如果分配的设备内存所对应的内存类型 不包含 VkMemoryPropertyFlagBits::VK_MEMORY_PROPERTY_HOST_COHERENT_BIT 的话,内存同步将不会自动进行。需要手动进行内存同步。

换句话说就是,映射的虚拟内存和对应的 VkDeviceMemory 设备内存是两个独立的内存,如果分配的设备内存 包含 VkMemoryPropertyFlagBits::VK_MEMORY_PROPERTY_HOST_COHERENT_BIT 则无论对虚拟内存做修改,还是对设备内存做修改,双方数据将会自动保持一致。否则需要手动进行内存同步。

如此就有两个同步方:

  • 映射的虚拟内存
  • VkDeviceMemory 设备内存

虚拟内存同步到设备内存

当对映射的虚拟内存中的数据修改时,如果设备内存类型 不包含 VkMemoryPropertyFlagBits::VK_MEMORY_PROPERTY_HOST_COHERENT_BIT 的话,则需要通过调用 vkFlushMappedMemoryRanges() 函数手动将虚拟内存中的数据同步(拷贝)到设备内存中。

也就是将虚拟内存中的内容 冲刷 到设备内存中。其定义如下:

vkFlushMappedMemoryRanges
VkResult vkFlushMappedMemoryRanges(
    VkDevice                                    device,
    uint32_t                                    memoryRangeCount,
    const VkMappedMemoryRange*                  pMemoryRanges);
  • device 内存对应的逻辑设备。
  • memoryRangeCount 指定 pMemoryRanges 数组长度。
  • pMemoryRanges 指向 VkMappedMemoryRange 数组。用于配置虚拟内存到设备内存的同步。

设备内存同步到虚拟内存

当对设备内存数据修改时,如果设备内存类型 不包含 VkMemoryPropertyFlagBits::VK_MEMORY_PROPERTY_HOST_COHERENT_BIT 的话,则需要通过调用 vkInvalidateMappedMemoryRanges() 函数手动将设备内存中的数据同步(拷贝)到虚拟内存中。

vkInvalidateMappedMemoryRanges

VkResult vkInvalidateMappedMemoryRanges(
    VkDevice                                    device,
    uint32_t                                    memoryRangeCount,
    const VkMappedMemoryRange*                  pMemoryRanges);
  • device 内存对应的逻辑设备。
  • memoryRangeCount 指定 pMemoryRanges 数组长度。
  • pMemoryRanges 指向 VkMappedMemoryRange 数组。用于配置设备内存到虚拟内存的同步。

其中 VkMappedMemoryRange 定义如下:

VkMappedMemoryRange

typedef struct VkMappedMemoryRange {
    VkStructureType    sType;
    const void*        pNext;
    VkDeviceMemory     memory;
    VkDeviceSize       offset;
    VkDeviceSize       size;
} VkMappedMemoryRange;
  • sType 是该结构体的类型枚举值, 必须 是 VkStructureType::VK_STRUCTURE_TYPE_MAPPED_MEMORY_RANGE 。
  • pNext 要么是 NULL 要么指向其他结构体来扩展该结构体。
  • memory 要同步的目标设备内存。
  • offset 要同步的目标设备内存的偏移。单位为 字节 。
  • size 要同步的目标设备内存的大小。单位为 字节 。如果为 VK_WHOLE_SIZE 则表示同步范围为 [offset, memory 结尾] 。

其中 VkMappedMemoryRange::memory 在手动同步时必须处在映射态 。

也就是 VkMappedMemoryRange::memory 必须已经通过 vkMapMemory() 将设备内存进行映射,并且没有 映射 。当内存同步结束之后,就可以进行解映射 了。

内存释放

当内存成功分配之后,一般会对该内存进行一些列写入和读取操作,当该内存不再被需要时,就可以将该内存通过调用 vkFreeMemory() 进行回收了。其定义如下:

vkFreeMemory

void vkFreeMemory(
VkDevice                                    device,
VkDeviceMemory                              memory,
const VkAllocationCallbacks*                pAllocator);
  • device 要回收 memory 在分配时所对应的逻辑设备。
  • memory 要回收的目标内存。
  • pAllocator 要回收 memory 在分配时所对应的句柄分配器。

参考

  • https://zhuanlan.zhihu.com/p/166387973
  • https://mp.weixin.qq.com/s/wCqRYpOBWbJSQqncqgXhBA
  • 《Vulkan学习指南》 — [新加坡] 帕敏德·辛格(Parminder Singh)

— END —

进技术交流群,扫码添加我的微信:Byte-Flow

字节流动

本文来自作者投稿,版权归原作者所有。如需转载,请注明出处:https://www.nxrte.com/jishu/51009.html

(0)

相关推荐

发表回复

登录后才能评论