Memory Management
At this point, you've created a fully-functional Vulkan renderer which can draw hundreds of thousands of instanced, textured, lit objects at real-time rates.
So it's time to start cleaning up your code's dependence on refsol::
functions, and learning a bit more about the boilerplate required to use Vulkan as we do so.
Currently, by my count, there are 24 refsol::
function calls in the codebase that need to be replaced.
In this step, we're going to go after calls in Helpers.cpp
, which are all (more or less) about managing memory.
The Easy One: Creating a Shader Module
Before we get into more complicated calls, we have one easy case to clean up:
This is straightforward -- we want to create a shader module from some SPIR-V bytecode, so we wrap that pointer in an appropriate CreateInfo and pass it to vkCreateShaderModule
.
The only reason this was even pulled out as a helper function in the first place was (1) we use this when creating pipelines are there was already way too much boilerplate code there; and (2) to support that convenient templated version in the header that automatically determines the array size.
Do a quick compile-and-run -- things should continue to work -- and we're down to 23 reference solution calls, just like that.
Buffers
Now let's get into some memory wrangling. We'll start with buffers, because we don't want to think about format and layout just yet.
The general shape of making a buffer in Vulkan is as follows: first, you create a buffer with your desired parameters, but without any memory associated with it. Then, you get a allocated a block of memory of the correct type to associate with the buffer. Finally, you bind the memory to the buffer to create something you can actually use.
This create-then-allocate-then-bind dance is why our code calls them AllocatedBuffer
objects, by the way.
Creating the Buffer
We'll start with creating the buffer, which goes exactly as you probably expect by now:
Now we need to determine the memory requirements, which we do as follows:
The VkMemoryRequirements
structure is now filled with the requirements for the memory to be bound to the buffer.
The members of this structure are a size
(in bytes), an alignment
(in bytes) -- both of which are what you expect -- and a memoryTypeBits
, which is a bitfield of which memory types from the physical device are supported for the backing memory.
We'll pass all of this information on to the memory allocation function (which, yes, we need to write):
And, to wrap up, we'll tell Vulkan to bind the allocated memory to the already-created buffer:
Compiling now will reveal the missing prototype for the memory allocator.
So let's fill that in (two versions!) along with a free
function for good measure:
Now the code will compile but not link because we need to fill in the allocation functions.
Buffer Destruction
We might as well write the buffer destroy function as well (especially since there's no guarantee that our allocator will behave the same way as the refsol
's, so calling its destroy_buffer
may act strangely).
The buffer gets destroyed, and then we pass the memory allocation to our free function to take care of releasing it as well. (We do the destruction in this order because we don't want to deallocate the memory while the buffer still references it.)
Another refsol call eliminated, and the code should continue to compile (but not link).
A Memory Allocator
Now it's time to fill in our memory allocation functions. As you've probably figured out from context, memory on Vulkan devices is not all the same. Instead, each physical device supports an array of different memory types, which have different properties and are allocated from (potentially) different heaps.
To make this notion a bit more concrete, let's write the version of allocate
that takes a VkMemoryRequirements
:
This version of our allocate
function passes the work of allocating the memory to the other overload of the function, and the work of finding a memory type in the memoryTypeBits
bit set that also has the memory properties in properties
to a function called find_memory_type
.
So that's the next function we need to write. We'll start with the prototype:
The VkPhysicalDeviceMemoryProperties
structure is the description of the physical device's memory types and heaps that our memory-type-finding function will use when enumerating the types.
We'll ask Vulkan for it in Helpers::create
:
The VkPhysicalDeviceMemoryProperties
structure is now filled with information about how many types of memory the device has, what the properties each type has, and what heap it is allocated from.
The structure also tells us how many heaps there are and the approximate size of these heaps.
For your submission for this step you should write some debug code to print all this information out:
Take a screenshot of the output this prints and put it in the check-in thread!
With this information in hand we can write the find_memory_type
function:
As you can see, the function walks through each memory type, attempting to find one that both appears in the type_filter
bit-set and supports all the requested flags
in propertyFlags
.
(Recall that VkMemoryPropertyFlagBits
include things like VK_MEMORY_PROPERTY_HOST_COHERENT_BIT
, which means no special cache control is needed for the host to access the memory; and VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT
, which means the memory is efficient for device access.)
Time to tackle the actual allocation function.
In this tutorial we're going to ask Vulkan for each and every piece of memory we use; but it would generally be substantially more efficient to allocate a large block of memory of each type we might need, and hand that block out in smaller pieces to the individual allocation calls.
To this end, the Allocation
structure includes an offset
member, and if you use it properly (as we did in the buffer creation function above) it should be possible to retrofit a fancier allocator of your choosing into the helper functions here.
Everything seems straightfoward enough, but notice one oddity:
the memory alignment parameter is just ignored!
This is because vkAllocateMemory
is specified to return memory that is aligned enough to meets "any alignment requirement of the implementation."
(Of course, if you were rolling your own slab-based allocator, you'd need to take alignment into account when finding the next free portion of the current slab.)
The last little piece of this is mapping the memory into the host address space if requested.
To do this, we just call vkMapMemory
.
This won't work on all memory types, but for those that it does work on it provides a very nice way to quickly write data into the memory: we can use plain-old memory-writing functions.
Mapped memory provides another reason to replace this code with an allocator that hands out subsets of large pieces -- the number of different memory-mapped ranges one can have active is relatively small on some devices (though the spec doesn't give a number or guarantee a minimum, as far as I can tell). Better to have all of your source buffers in one big chunk and take up only one mapping slot than to have them all taking a mapping slot and potentially run out.
Let's finish things off with a free function:
This function unmaps the memory with vkUnmapMemory
(if it is mapped), and returns the memory to Vulkan with vkFreeMemory
.
It also sets the allocation back to the "empty" state so that it won't complain in its destructor about leaking memory.
At this point, everything should compile and run as before.
Images
We are down to just three reference solution calls in Helpers.cpp
;
and all three of the remaining ones have to do with VkImage
s.
Find Image Format
First up: find_image_format
, a helper that doesn't actually get used from the image creation functions directly, or -- in fact -- by any code we've written so far in the tutorial.
(But we'll need it when we get to creating swapchain-related resources.)
This function is sort of like the find_memory_type
function, but for image formats.
The caller asks for features they want (as a logical or of VkFormatFeatureFlagBits
)
and the function looks for formats (among the candidates the caller requests) that support those features:
The other thing to notice is that features can vary depending on the tiling (arrangement of texels in memory) of the image, something we can specify with a VkImageTiling
value both in this function and when creating an image.
Create Image
Now on to the big one: Helpers::create_image
.
The pattern is the same as for buffers -- create the VkImage
, ask how much memory it needs, create the memory, and bind the memory.
When you want to support mip-maps, non-2D textures (like cubemaps), or texture arrays, you'll need to add another parameter to this function to set the appropriate members of VkImageCreateInfo
;
and you'll probably want to add more members to AllocatedImage
to hold them so that, e.g., transfer_to_image
can do the right thing.
Note, also, that the initial layout is VK_IMAGE_LAYOUT_UNDEFINED
;
if you wanted to specify images directly by writing data to mapped memory (instead of copying from a buffer) you'd instead set this to VK_IMAGE_LAYOUT_PREINITIALIZED
and the tiling to VK_IMAGE_TILING_LINEAR
, which together would guarantee a known image layout.
Destroy Image
Destroying images proceeds much like destroying buffers: destroy the VkImage
handle, then free the allocated memory.
We also take the time to set the metadata we're storing about the AllocatedImage
back to the default values, just to keep things tidy.
Declare Victory!
And that's it!
No more refsol
calls in Helpers.cpp
.
And that means you can finally: