RTX 4060: Fragment shader reads the wrong descriptor for any heap index other than 0

VK_EXT_descriptor_heap: fragment shader reads the wrong descriptor for any heap index other than 0

Reading a layout(descriptor_heap) array from a fragment shader at any index other than 0 returns the wrong descriptor on NVIDIA. Index 0 is correct. A literal constant index of 1 is enough to trigger it — there is no push data, no dynamic index and no nonuniformEXT involved.

Reproduced identically on two driver branches: 610.57.04 and 615.71.09.

The same heap, same descriptors and equivalent shader logic read correctly at every index from a compute shader on the same device, so this appears to be specific to the fragment stage.

Mesa RADV 26.2.3 renders every case correctly from the same SPIR-V and the same API calls.

Environment

GPU NVIDIA GeForce RTX 4060
Driver 615.71.09 (driverVersion 615.284.576, device API 1.4.351) — also reproduced on 610.57.04 (driverVersion 610.228.256, device API 1.4.341)
OS Linux (CachyOS, kernel 7.2.6)
Loader 1.4.357
App API version Vulkan 1.4
Extensions VK_EXT_descriptor_heap (spec v1 on both branches), VK_KHR_shader_untyped_pointers
Comparison device AMD Radeon 7700X iGPU, RADV, Mesa 26.2.3 — all cases pass

Minimum Repro

See README.md for further details.

Observed output

Nvidia driver:

./build/descriptor_heap_repro
================================================================
  Part 1 of 2: CONTROL CASES (compute)
  These are expected to PASS on every driver.
================================================================

Device:      NVIDIA GeForce RTX 4060
Driver:      615.284.576
API version: 1.4.351

storage buffer descriptor size: 16 bytes
heap reserved range:            96768 bytes at offset 128

constant index         : PASS
dynamic index (buffer) : PASS
pushdata index = 0      : PASS
pushdata index = 1      : PASS

RESULT: all cases passed.

================================================================
  Part 2 of 2: THE REPRO (fragment stage)
  Expected to FAIL on affected NVIDIA drivers, pass elsewhere.
================================================================

Device:      NVIDIA GeForce RTX 4060
Driver:      615.284.576
API version: 1.4.351

[A] constant heap index 0 (expect red every frame):
  -> 40/40 frames correct

[B] constant heap index 1 (expect green every frame):
frame 0: pushed index 0, expected (0,255,0,255), got (0,0,0,0)
frame 1: pushed index 1, expected (0,255,0,255), got (0,0,0,0)
frame 2: pushed index 0, expected (0,255,0,255), got (0,0,0,0)
frame 3: pushed index 1, expected (0,255,0,255), got (0,0,0,0)
frame 4: pushed index 0, expected (0,255,0,255), got (0,0,0,0)
frame 5: pushed index 1, expected (0,255,0,255), got (0,0,0,0)
... and 34 more
  -> 0/40 frames correct

[C] heap index 0, pushed value selects inside the buffer:
  -> 40/40 frames correct

RESULT: heap slot 0 reads correctly, but a CONSTANT heap index of 1
        reads the wrong descriptor (B failed 40/40 frames).
        No push data or dynamic indexing is involved in B at all.
        Keeping the heap index at 0 and selecting inside the buffer
        works (C passed), which is a viable workaround.

================================================================
  SUMMARY
    control cases (compute) : PASS
    repro (fragment)        : FAIL   <-- the bug
================================================================

The controls all pass, so descriptor heaps work fine in compute at
every index. Only the fragment stage is affected, and only for heap
indices other than 0 -- even a literal constant 1. This is the bug.

AMD driver:

 VK_DRIVER_FILES=/usr/share/vulkan/icd.d/radeon_icd.json ./build/descriptor_heap_repro
================================================================
  Part 1 of 2: CONTROL CASES (compute)
  These are expected to PASS on every driver.
================================================================

Device:      AMD Ryzen 7 7700X 8-Core Processor (RADV RAPHAEL_MENDOCINO)
Driver:      26.2.3
API version: 1.4.354

storage buffer descriptor size: 16 bytes
heap reserved range:            0 bytes at offset 96

constant index         : PASS
dynamic index (buffer) : PASS
pushdata index = 0      : PASS
pushdata index = 1      : PASS

RESULT: all cases passed.

================================================================
  Part 2 of 2: THE REPRO (fragment stage)
  Expected to FAIL on affected NVIDIA drivers, pass elsewhere.
================================================================

Device:      AMD Ryzen 7 7700X 8-Core Processor (RADV RAPHAEL_MENDOCINO)
Driver:      26.2.3
API version: 1.4.354

[A] constant heap index 0 (expect red every frame):
  -> 40/40 frames correct

[B] constant heap index 1 (expect green every frame):
  -> 40/40 frames correct

[C] heap index 0, pushed value selects inside the buffer:
  -> 40/40 frames correct

RESULT: all cases correct.

================================================================
  SUMMARY
    control cases (compute) : PASS
    repro (fragment)        : PASS
================================================================

No failure observed on this driver.

The heap has two slots, both written with vkWriteResourceDescriptorsEXT at init: slot 0 holds a uniform buffer with two colours (red, green), slot 1 holds a uniform buffer with one colour (green).

  • [A] reads slot 0. Correct on both vendors.
  • [B] reads slot 1 via tints[1].color, a literal constant. Expected green; NVIDIA returns something else. This is the bug in its smallest form.
  • [C] reads slot 0 but selects between the two colours inside that buffer using a vkCmdPushDataEXT value. Correct on both vendors, so ordinary array indexing within a buffer is unaffected — it is specifically descriptor-heap indexing that breaks.

The incorrect value returned by [B] varies with heap layout: with this layout it is (0,0,0,0), and with a three-slot layout it was slot 0’s colour instead. That suggests a wrong address computation rather than the index simply being clamped to zero.

What works and what does not

Stage Index kind Index value NVIDIA (610 & 615) RADV
compute constant 0–3 pass pass
compute dynamic, from storage buffer 0–3 pass pass
compute dynamic, from push data 0 and 1 pass pass
fragment constant 0 pass pass
fragment constant 1 fail pass
fragment dynamic, from push data 0 pass pass
fragment dynamic, from push data 1 fail pass

Edit: I’ve reproduced this on RTX PRO 4000 Blackwell too

./build/descriptor_heap_repro
================================================================
  Part 1 of 2: CONTROL CASES (compute)
  These are expected to PASS on every driver.
================================================================

Device:      NVIDIA RTX PRO 4000 Blackwell
Driver:      610.228.256
API version: 1.4.341

storage buffer descriptor size: 16 bytes
heap reserved range:            96768 bytes at offset 96

constant index         : PASS
dynamic index (buffer) : PASS
pushdata index = 0      : PASS
pushdata index = 1      : PASS

RESULT: all cases passed.

================================================================
  Part 2 of 2: THE REPRO (fragment stage)
  Expected to FAIL on affected NVIDIA drivers, pass elsewhere.
================================================================

Device:      NVIDIA RTX PRO 4000 Blackwell
Driver:      610.228.256
API version: 1.4.341

[A] constant heap index 0 (expect red every frame):
  -> 40/40 frames correct

[B] constant heap index 1 (expect green every frame):
frame 0: pushed index 0, expected (0,255,0,255), got (0,0,0,0)
frame 1: pushed index 1, expected (0,255,0,255), got (0,0,0,0)
frame 2: pushed index 0, expected (0,255,0,255), got (0,0,0,0)
frame 3: pushed index 1, expected (0,255,0,255), got (0,0,0,0)
frame 4: pushed index 0, expected (0,255,0,255), got (0,0,0,0)
frame 5: pushed index 1, expected (0,255,0,255), got (0,0,0,0)
... and 34 more
  -> 0/40 frames correct

[C] heap index 0, pushed value selects inside the buffer:
  -> 40/40 frames correct

RESULT: heap slot 0 reads correctly, but a CONSTANT heap index of 1
        reads the wrong descriptor (B failed 40/40 frames).
        No push data or dynamic indexing is involved in B at all.
        Keeping the heap index at 0 and selecting inside the buffer
        works (C passed), which is a viable workaround.

================================================================
  SUMMARY
    control cases (compute) : PASS
    repro (fragment)        : FAIL   <-- the bug
================================================================

The controls all pass, so descriptor heaps work fine in compute at
every index. Only the fragment stage is affected, and only for heap
indices other than 0 -- even a literal constant 1. This is the bug.

Excellent repro, this reproduces on Turing as well.

I built your repro and ran it on an RTX 2060 (TU106) on 615.71.09 (driverVersion 615.284.576, device API 1.4.351):

Device: NVIDIA GeForce RTX 2060
Driver: 615.284.576

Part 1: CONTROL CASES (compute) all pass
Part 2:
[A] heap index 0 → 40/40 correct
[B] heap index 1 → 0/40 correct ← fails
[C] heap index 0 + push → 40/40 correct

SUMMARY: repro (fragment): FAIL

Identical to the RTX 4060 and RTX PRO 4000 results, so this is not Ada/Blackwell-specific — it now covers Turing, Ada and Blackwell.

Environment: CachyOS, kernel 7.2.6-1-cachyos, loader 1.4.357, NVIDIA open kernel modules 615.71.09.

Two things worth adding:

  1. Real-world impact. This is not only a synthetic case. With VKD3D_CONFIG=descriptor_heap in vkd3d-proton, The Last of Us Part II Remastered renders skinned character geometry — hair, jackets, cloth — with vertices teleported to wrong positions, producing severe vertex stretching and spikes. Disabling the heap path removes the corruption completely, so the two look like the same underlying bug. The difficulty on that title is that the legacy path exhausts VRAM on a 6 GB card, so the heap path is otherwise the only way to run it at native resolution.

  2. A gap in the repro. As far as I can tell it covers the compute and fragment stages, but not the vertex stage, which is where the corruption I observe actually originates. Would you be open to adding a vertex-stage case? Happy to test one if you add it. The related matrix bug in this thread’s sibling report is specifically about whole-matrix OpLoad through an untyped pointer, and skinning reads a 4x4 bone matrix through exactly that kind of pointer, so the vertex stage may be the most interesting one to cover.

you write descriptor data with stride descriptorSize_ where descriptorSize_ obtained from vkGetPhysicalDeviceDescriptorSizeEXT

but descriptor size should be obtained as VkPhysicalDeviceDescriptorHeapPropertiesEXT::bufferDescriptorSize via vkGetPhysicalDeviceProperties2

From vkGetPhysicalDeviceDescriptorSizeEXT(3) :: Vulkan Documentation Project

```
The return value of this function will be a VkDeviceSize indicating the size in bytes (N) of a heap descriptor with a type equal to descriptorType. When a descriptor of this type is written by vkWriteResourceDescriptorsEXT or vkWriteSamplerDescriptorsEXT, only the first N bytes are written; the rest will not be accessed and can be safely discarded when copying descriptors around.
```

For example, typical storage buffer descriptor contains ptr + size data, 16 bytes. Uniform buffer descriptor might only store ptr 8 byte, because size is compile-time known. But in shader, both descriptors is read with the same stride, which is equal to VkPhysicalDeviceDescriptorHeapPropertiesEXT::bufferDescriptorSize

Confirmed, the stride mismatch is real, and I can give the numbers on a Turing card (RTX 2060, 615.71.09).

I instrumented the repro to print both values:

vkGetPhysicalDeviceDescriptorSizeEXT(UNIFORM_BUFFER) = 8
VkPhysicalDeviceDescriptorHeapPropertiesEXT::bufferDescriptorSize = 16

So slot 1 is written at byte 8 but read at byte 16, which was never written. That fully explains the 40/40 pass at index 0 and 0/40 at index 1, nothing to do with the driver.

@alexseykutasov you were right. Writing at i * heapProps_.bufferDescriptorSize instead should make all cases pass. Happy to test that if you want the change.

Ah yes, thanks for pointing this out, a silly mistake from me. Looks like this is working as designed. The bug in my code didn’t show up on AMD just because the values were the same on AMD’s driver:

AMD RADV:    uniform buffer write size = 16 bytes, heap slot stride = 16 bytes (equal)
NVIDIA:      uniform buffer write size =  8 bytes, heap slot stride = 16 bytes (half)