I E

Sunstone dev log 20: fixing memory bandwidth

After the fragment overdraw improvements didn’t fix my framerate, I was stumped. The experiment was useful, in that it ruled out a possible cause, but it didn’t leave me with any clues for where to look next. Why did enabling MSAA tank my performance so badly? After a few cursory web searches failed to help, I turned to Claude Opus 5.5 in “mentor mode”.

“Mentor mode” instructs the LLM to to priortise my learning and development over simply solving the problem. It’s seeded with pedagogical research such as tacit knowledge extraction, because I suspect that a lot of what the model has learned about programming, debugging, etc. via reinforcment learning is tough to put into language. When I start a session, I describe my problem, what I’ve tried, what worked and what didn’t. Importantly, I omit all code. This forces the LLM to generate a search strategy, which is what I want to learn. If I included code, then the model may identify the issue by reading the code and generate leading questions.

Once I’ve set the context, the LLM generates thought-provoking questions about what I should do next. So in the case of my framerate issue, the first question was something like, “Apart from the fragment shader, what are some other things that happen per-frame which scale with MSAA?”. Reads and writes to and from the render pass attachments were the first thing that came to mind. In response, the LLM prompted me to estimate the overhead of the extra attachment memory traffic due to MSAA. A 4K colour attachment uses 3840*2160*4 = 33177600B = 33.1776MB, and when 4x MSAA is used that increase 4-fold to 132.7104MB. Assuming my RTX2070 has a memory bandwidth of 448GB/s. So I estimated that a full colour attachment write would take an extra (132.7104MB - 33.1776MB) / 448GB/s = 0.00022s under MSAA. That doesn’t seem anywhere near enough overhead to cause the ~100ms increase per frame that I observed.

After some more back and forth of me being stumped (I feel like some of the thought-provoking questions the LLM generates do not provoke useful thought), it escalated with a hint (paraphrased): “Your estimate of the memory traffic overhead used 448GB/s, which assumes traffic to device-local memory. Are you sure that you allocated device-local memory for the attachments?” Oh. I wouldn’t be surprised if I forgot to do that. I checked the code, and sure enough, I hadn’t requested device-local memory for the attachments. They were being stored in the default “host-local” heap, so the GPU had to interact with the attachments over PCIe. The best-case bandwidth for that is ~16GB/s. My earlier overhead calculation becomes (132.7104MB - 33.1776MB) / 16GB/s = 0.006s; several milliseconds extra for a full multisampled colour attachment write. I moved the attachment memory to device-local, and Bistro was suddenly rendering at 60FPS :)

This is the kind of problem that I’d usually pose to a forum, Reddit, or StackOverflow. Overall I think I got a better brain workout from consulting “mentor mode”, and certainly a better turnaround time. But I have mixed feelings about the process as a whole, because it’s a lost opportunity for collective sense-making.

Now that I was aware of the influence of memory traffic on my program’s performance, I wanted to figure out how to measure it.

NVIDIA performance counters in RenderDoc

I found this Vulkan performance sample which visualises memory throughput. I couldn’t find anywhere to measure that in RenderDoc, so I read through the sample’s source code. I found that used vendor-specific performance counters, so I decided to install NVIDIA’s profiling SDK to get more performance insights from RenderDoc.

It was easy to get working; RenderDoc gave me instructions on how to do it. I had to sign up for NVIDIA’s “developer program” to download the SDK, though, which felt yucky. The last step was to set boot.kernelParams = [ "nvidia.NVreg_RestrictProfilingToAdminUsers=0" ]; in my NixOS config (see also). This gave me access to, amongst many others, the dram__bytes_{read,write} and pcie__{read,write}_bytes performance counters in RenderDoc.

Here are the stats before my device-local fix:

DRAM read (MB) DRAM write (MB) PCIe read (MB) PCIe write (MB) DRAM total (MB) PCIe total (MB)
442.4234 46.252672 298.443748 442.4234 488.676072 740.867148

That gives memory traffic costs of approximately:

After moving attachments to device-local memory, this became:

DRAM read (MB) DRAM write (MB) PCIe read (MB) PCIe write (MB) DRAM total (MB) PCIe total (MB)
562.532356 207.3233 161.655296 22.861312 769.855656 184.516608

Memory traffic costs:

I found another Vulkan performance guide about proper use of render pass attachments. I followed its advice and set VK_IMAGE_USAGE_TRANSIENT_ATTACHMENT_BIT and VK_ATTACHMENT_STORE_OP_DONT_CARE for the attachments that aren’t read by other passes. This didn’t make too much of a difference.

With VK_ATTACHMENT_STORE_OP_DONT_CARE:

DRAM read (MB) DRAM write (MB) PCIe read (MB) PCIe write (MB) DRAM total (MB) PCIe total (MB)
550.554384 211.130480 161.987584 22.938112 761.684864 184.925696

With VK_ATTACHMENT_STORE_OP_DONT_CARE and VK_IMAGE_USAGE_TRANSIENT_ATTACHMENT_BIT:

DRAM read (MB) DRAM write (MB) PCIe read (MB) PCIe write (MB) DRAM total (MB) PCIe total (MB)
544.762408 197.100320 162.926592 22.834688 741.862728 185.76128

But since I saw a modest saving, I left these changes in.

The remaining 100MB+ of PCIe traffic came from index and vertex buffers, which I also hadn’t requested to be device-local. After moving those over, here are the final numbers:

DRAM read (MB) DRAM write (MB) PCIe read (MB) PCIe write (MB) DRAM total (MB) PCIe total (MB)
667.041784 192.052648 0.881152 0.785408 859.094432 1.66656

Memory traffic costs:

Nice. I don’t understand why PCIe write went down, though, because the index and vertex buffers are for reading only.

What I built

What I learned

Next steps

← Sunstone dev log 19: less overdraw Newest