Environment
- GPU: AMD Radeon RX 9070 XT
- Driver: AMD Software: Adrenalin Edition 26.6.4
amdxx64.dll 9.17.11.0088
amdvlk64.dll 9.2.10.395
- OS: Windows 11 Pro 25H2 (build 26200)
- Application: mpv media player (windowed, flip-model presentation), but the issue is not mpv-specific in principle, read up for the details.
Summary
This has been bothering me for a long time. Starting media playback either paused, or with a single Present() call, like a single-frame image, causes the driver to busy-wait on "something". It never stops until you keep presenting more frames to the swapchain.
While debugging this I went down the rabbit hole and tried to reproduce it with different applications, and it turns out one of the triggers is a "custom" game profile in the Adrenalin control panel. What's interesting, it's not related to any specific option, just toggling some single option on/off is enough to trigger it, so the end state is identical to the defaults, but the profile is now "custom". It goes away if you reset the profile to default (not "custom"), even if no value changes in practice. It's a bit hazy, but you will know better, if you look at your driver source code and see what is it waiting for.
The driver spawns a worker thread during the first Present() call which busy-polls at 100% of one CPU core for as long as the application idles between presents. For my case, a video player paused on a frame, this means a full core is burned indefinitely while the process is otherwise completely idle.
Affected APIs
Reproduces with (all windowed, presenting one frame then idling):
- Direct3D 11 (
mpv --gpu-api=d3d11) -> spinning thread entry point amdxx64.dll+0x1798E0
- Vulkan (
mpv --gpu-api=vulkan --gpu-context=winvk) -> spinning thread entry point amdvlk64.dll+0x2F3D44C
- OpenGL via ANGLE (D3D11 backend)
Does not reproduce with native OpenGL (WGL). Whatever the driver is checking, there are distinct code paths for D3D11 and Vulkan. OpenGL is not affected.
The spinning thread (Vulkan case)
mpv --no-config --pause --gpu-api=vulkan --gpu-context=winvk <file>.
ntdll!NtWaitForSingleObject
KERNELBASE!WaitForSingleObjectEx
amdvlk64.dll+0x39BBE5
amdvlk64.dll+0x2853C2
amdvlk64.dll+0x2DF571D
amdvlk64.dll+0x2F3D49B <- thread entry function (start address +0x2F3D44C)
KERNEL32!BaseThreadInitThunk
ntdll!RtlUserThreadStart
Breaking inside the loop shows each iteration is WaitForSingleObjectEx(h, 0, FALSE) returning WAIT_TIMEOUT, on an unnamed manual reset event.
Thread creation
The thread is created inside the first vkQueuePresentKHR call. Captured exactly, by breaking on CreateThread with the start routine equal to the spinner entry:
KERNEL32!CreateThreadStub <- CreateThread(start = amdvlk64.dll+0x2F3D44C)
amdvlk64.dll+0x2F3D5F2 <- create call site, same function region as the thread entry (+0x2F3D44C)
amdvlk64.dll+0x2DF56B2
amdvlk64.dll+0x205832
amdvlk64.dll+0x207367 <- vkQueuePresentKHR
libplacebo!vk_sw_submit_frame <- submits and presents the frame
mpv!flip_page
The spinning thread (D3D11 case)
Same behavior, kept collapsed for reference.
D3D11 stacks (spin loop + thread creation)
Sampling profile of mpv --pause <file> shows the thread alternating between two stacks:
[kernel: KeDelayExecutionThread -> KeYieldExecution, i.e. Sleep(0)]
ntdll!NtDelayExecution
ntdll!RtlDelayExecution
KernelBase!SleepEx
amdxx64.dll+0x179AD7
amdxx64.dll+0x1798EE <- thread entry function (thread start address amdxx64.dll+0x1798E0)
kernel32!BaseThreadInitThunk
ntdll!RtlUserThreadStart
[kernel: ObWaitForSingleObject / KeWaitForSingleObject, returns immediately - zero-timeout poll]
ntdll!ZwWaitForSingleObject
KernelBase!WaitForSingleObjectEx
amdxx64.dll+0x35C8B5
amdxx64.dll+0x179AE2
amdxx64.dll+0x1798EE <- thread entry function
kernel32!BaseThreadInitThunk
ntdll!RtlUserThreadStart
Looks like a while (WaitForSingleObject(h, 0) != 0) Sleep(0); poll loop.
The thread is created inside the first IDXGISwapChain::Present() call. This one is from a sampled profile (the CreateThread itself fell between samples), the creator is somewhere around here:
win32u!NtDxgkSubmitPresentToHwQueue
d3d11!NDXGI::CDevice::SubmitPresentToHwQueueCB
amdxx64.dll+0x4A0603
amdxx64.dll+0x367652
amdxx64.dll+0x601C9
amdxx64.dll+0xC7301
amdxx64.dll+0x183CB9
amdxx64.dll+0x11F3F5
amdxx64.dll+0x18BC22
amdxx64.dll+0x179008 <- same function region as the spinner entry (+0x1798E0)
amdxx64.dll+0x17B2B8
amdxx64.dll+0x183CB9
amdxx64.dll+0xBEDA9
amdxx64.dll+0xBEF12
d3d11!NDXGI::CDevice::PresentImpl
d3d11!NDXGI::CDevice::Present
dxgi!CFlipPresentToDWM<CDXGISwapChainWrapper>::FlipPresentCore
dxgi!CDXGISwapChain::FlipPresentToDWM
dxgi!CDXGISwapChain::PresentImplCore
dxgi!CDXGISwapChain::PresentImpl
dxgi!CDXGISwapChain::Present
Actual behavior
The worker thread spins until the normal rendering flow continues, i.e. more Present() calls. Once the app has presented continuously for a while, the condition is satisfied and the thread goes fully idle, and it stays idle. Pausing again later doesn't bring the spin back.
Expected behavior
The worker should block on its event/fence with a real timeout (or wait for the next present) instead of polling with WaitForSingleObject(h, 0) + Sleep(0). Burning a full CPU core for an unbounded time in an idle application. Media players can sit paused in the background for a long time.
Questions?
If you have any questions and clarifications, feel free to ask. I can provide the full ETW profile and run any test that you need. But I feel like you should be able to either reproduce this or just look up the debug info for these binaries and read the code to figure out whether the busy wait is the best solution for the task at hand.
As for a reproducer: I tried to make a small one, but the custom-profile precondition plus whatever the app-side trigger is made it a blind reverse-engineering exercise. And I spent enough time debugging this already. You can build mpv, it's easy enough to build, and I can help with guidance if needed.
Environment
amdxx64.dll9.17.11.0088amdvlk64.dll9.2.10.395Summary
This has been bothering me for a long time. Starting media playback either paused, or with a single Present() call, like a single-frame image, causes the driver to busy-wait on "something". It never stops until you keep presenting more frames to the swapchain.
While debugging this I went down the rabbit hole and tried to reproduce it with different applications, and it turns out one of the triggers is a "custom" game profile in the Adrenalin control panel. What's interesting, it's not related to any specific option, just toggling some single option on/off is enough to trigger it, so the end state is identical to the defaults, but the profile is now "custom". It goes away if you reset the profile to default (not "custom"), even if no value changes in practice. It's a bit hazy, but you will know better, if you look at your driver source code and see what is it waiting for.
The driver spawns a worker thread during the first
Present()call which busy-polls at 100% of one CPU core for as long as the application idles between presents. For my case, a video player paused on a frame, this means a full core is burned indefinitely while the process is otherwise completely idle.Affected APIs
Reproduces with (all windowed, presenting one frame then idling):
mpv --gpu-api=d3d11) -> spinning thread entry pointamdxx64.dll+0x1798E0mpv --gpu-api=vulkan --gpu-context=winvk) -> spinning thread entry pointamdvlk64.dll+0x2F3D44CDoes not reproduce with native OpenGL (WGL). Whatever the driver is checking, there are distinct code paths for D3D11 and Vulkan. OpenGL is not affected.
The spinning thread (Vulkan case)
mpv --no-config --pause --gpu-api=vulkan --gpu-context=winvk <file>.Breaking inside the loop shows each iteration is
WaitForSingleObjectEx(h, 0, FALSE)returningWAIT_TIMEOUT, on an unnamed manual reset event.Thread creation
The thread is created inside the first
vkQueuePresentKHRcall. Captured exactly, by breaking onCreateThreadwith the start routine equal to the spinner entry:The spinning thread (D3D11 case)
Same behavior, kept collapsed for reference.
D3D11 stacks (spin loop + thread creation)
Sampling profile of
mpv --pause <file>shows the thread alternating between two stacks:Looks like a
while (WaitForSingleObject(h, 0) != 0) Sleep(0);poll loop.The thread is created inside the first
IDXGISwapChain::Present()call. This one is from a sampled profile (theCreateThreaditself fell between samples), the creator is somewhere around here:Actual behavior
The worker thread spins until the normal rendering flow continues, i.e. more
Present()calls. Once the app has presented continuously for a while, the condition is satisfied and the thread goes fully idle, and it stays idle. Pausing again later doesn't bring the spin back.Expected behavior
The worker should block on its event/fence with a real timeout (or wait for the next present) instead of polling with
WaitForSingleObject(h, 0)+Sleep(0). Burning a full CPU core for an unbounded time in an idle application. Media players can sit paused in the background for a long time.Questions?
If you have any questions and clarifications, feel free to ask. I can provide the full ETW profile and run any test that you need. But I feel like you should be able to either reproduce this or just look up the debug info for these binaries and read the code to figure out whether the busy wait is the best solution for the task at hand.
As for a reproducer: I tried to make a small one, but the custom-profile precondition plus whatever the app-side trigger is made it a blind reverse-engineering exercise. And I spent enough time debugging this already. You can build mpv, it's easy enough to build, and I can help with guidance if needed.