nvwgf2umx.dll allocates 184 bytes in its DllMain DLL_THREAD_ATTACH handler and does not free them in DLL_THREAD_DETACH. Any process with the driver resident leaks 184 bytes per thread created, for as long as the driver stays loaded, regardless of whether the process issues a single Direct3D call.
Minimal reproduction, full logs, and dump analysis:
Environment
OS Windows 10 Pro 22H2 (19045.6466)
GPU GeForce RTX 3070 — single NVIDIA adapter, no second GPU
Driver 595.95 (32.0.15.9595); also reproduced on 580.88 (32.0.15.8088)
Build MSVC (VS2019), x64, /O2 /MD
Reproduction
A single 60-line .cpp with no third-party dependencies. It LoadLibraryExes the DLL named on the command line, then creates and join()s an empty worker thread N times, printing private bytes as it goes. No D3D device, no rendering, no window — the only driver code that runs is its DllMain.
test1_loadlibrary.exe "<driverstore>\nvwgf2umx.dll" 500000 # leaking run
test1_loadlibrary.exe "C:\Windows\System32\version.dll" 500000 # control
The worker returns normally — no TerminateThread — so DLL_THREAD_DETACH does fire. Threads are created and joined one at a time, so at most one extra thread exists at any moment and thread stacks cannot account for the growth.
Result
Leaking run, private bytes over 500,000 threads:
after-load=31899648
iter 100000 private=55721984 delta=+23822336
iter 300000 private=101928960 delta=+70029312
iter 500000 private=148312064 delta=+116412416
Linear across the entire run, no plateau. (116,412,416 − 23,822,336) ÷ 400,000 = 231.48 bytes per thread.
Control run — same binary, same machine, same loop, same instrumentation, only the loaded module differs:
after-load=1081344
iter 3000 private=1155072 delta=+73728
iter 500000 private=1155072 delta=+73728
72 KB of one-time warm-up, then byte-for-byte identical at iteration 3,000 and 500,000 — 497,000 iterations apart. This run drives the identical loader thread-attach/detach path 500,000 times with several DLLs resident. The notifications themselves accumulate nothing.
Side by side after the same 500,000 threads: control holds 1.1 MiB and stopped changing at iteration 3,000; the process with nvwgf2umx.dll loaded holds 141 MiB and is still climbing linearly when the test stops.
Heap analysis
Full dump at end of run, +ust enabled. !ext.heap -stat -h on the process heap:
size #blocks total ( %)
b8 7a125 - 57bd298 (99.20)
0xb8 = 184 bytes. 0x7a125 = 500,005 blocks against 500,000 threads created. 92,000,920 ÷ 500,005 = exactly 184. 99.20% of all busy bytes in the heap. The same LFH bucket holds 17 blocks in the control.
Allocation stack for one of those blocks (!heap -p -a, read bottom-up):
7ffe66e3b49d ntdll!RtlpAllocateHeapInternal+0xa7d
7ffe4556c548 nvwgf2umx!NVDEV_Thunk+0x587228
7ffe45539eef nvwgf2umx!NVDEV_Thunk+0x554bcf
7ffe4553a189 nvwgf2umx!NVDEV_Thunk+0x554e69
7ffe4553a5b2 nvwgf2umx!NVDEV_Thunk+0x555292
7ffe447a457a nvwgf2umx+0xc457a
7ffe66e29a1d ntdll!LdrpCallInitRoutine+0x61
7ffe66e2789f ntdll!LdrpInitializeThread+0x167
7ffe66e86094 ntdll!LdrpInitialize+0x408
7ffe66e85c1e ntdll!LdrInitializeThunk+0xe
A thread starts → the loader initializes it → it calls each module’s entry point → the nvwgf2umx.dll entry point runs → four frames inside the driver → 184-byte allocation, never released on detach. (NVDEV_Thunk+<large offset> is just the nearest exported symbol; the module attribution is unambiguous.)
Not a regression, and not fixed
Identical per-thread growth on both drivers, eight months apart:
| 580.88 (Jul 2025) | 595.95 (Mar 2026) | |
|---|---|---|
+ust on |
231.48 B/thread | 231.48 B/thread |
+ust off |
198.72 B/thread | 198.72 B/thread |
The leak is not an artifact of the forced load
DLL_THREAD_ATTACH is delivered by the loader to every mapped module on every thread creation, regardless of how that module was loaded, so the reproduction above exercises the driver’s entry point the same way an ordinary load does.
It also reproduces with no forced load at all. This was originally found in a process that had the driver resident without any LoadLibrary call from the application — an OpenGL renderer containing no Direct3D code, where nvwgf2umx.dll was loaded by nvldumdx on behalf of a D3D11 device created by something other than application code. Of 60 sampled live 184-byte blocks in that process, 53 were allocated by nvwgf2umx.dll, every one on the same LdrpInitializeThread → DllMain path. All 53 stacks are in the repo.
Whatever the handler is meant to do on attach, the block is not released on detach.