#66221

Windows: a minimized GPUI window keeps redrawing on every vsync (~3% of a core)

OpenElitelordopened 3d ago

Reproduction steps

  1. Run any GPUI app on Windows. I used gpui-kit's hello_world example (a static window) and a task-manager UI built on gpui-kit, both release builds on GPUI 0.3.10 from crates.io (gpui-pre, a snapshot of this repo). Current main has the same code (link below).
  2. Leave it idle, then minimize it.
  3. Compare the process's CPU time over 20 s while visible and while minimized.

Current vs. Expected behavior

Current: minimizing doesn't lower CPU at all. Per-thread sampling shows the main thread keeps running at the display rate while the window is minimized:

App (release, 60 Hz display)VisibleMinimized
gpui-kit hello_world (static)2.08%3.44%
task-manager UI3.91%3.36%

Medians of 3 runs, % of one core over 20 s, Ryzen AI 9 HX 370, Windows 11 26200. For comparison, an egui build of the same task-manager UI drops from 1.98% to 0.42% when minimized.

Expected: a minimized window stops drawing, as on macOS, where window_did_change_occlusion_state stops the display link when the window isn't visible (gpui_macos/src/window.rs#L2985-L2997). Its comment says AppKit posts that for "covering, minimizing, hiding, Space switches, and display sleep alike".

Cause

The VSyncProvider thread calls RedrawWindow(RDW_INVALIDATE) on every window after every vblank (gpui_windows/src/platform.rs#L488-L503). It already checks IsWindowVisible and IsIconic, but only to decide whether to record the frame signal. The invalidate runs either way, so each minimized window still gets a WM_PAINT on every vsync. No GPUI frame runs, because handle_size_msg moves the frame callback aside while minimized and draw_window returns early. But each WM_PAINT goes through the message loop and DefWindowProcW, which costs more than a no-change frame check costs while visible. (Edited after measuring WM_PAINT deliveries against frame-callback calls; see the comment below.)

Possible fix

Skip hidden and minimized windows entirely:

                     for hwnd in all_windows.read().iter() {
                         unsafe {
+                            if !IsWindowVisible(hwnd.as_raw()).as_bool()
+                                || IsIconic(hwnd.as_raw()).as_bool()
+                            {
+                                hwnd.frame_signal.take();
+                                continue;
+                            }
                             if let Some(signal_at) = signal_at {
-                                if IsWindowVisible(hwnd.as_raw()).as_bool()
-                                    && !IsIconic(hwnd.as_raw()).as_bool()
-                                {
-                                    hwnd.frame_signal.record(signal_at, signal_source);
-                                } else {
-                                    // Hidden windows may not consume WM_PAINT until shown again.
-                                    hwnd.frame_signal.take();
-                                }
+                                hwnd.frame_signal.record(signal_at, signal_source);
                             }
                             RedrawWindow(Some(hwnd.as_raw()), None, None, RDW_INVALIDATE)

With this applied to the task-manager UI (same method, 3 runs, alternated with the unpatched build):

VisibleMinimizedMain thread while minimized, per 20 s
Unpatched3.91%3.36%469–594 ms
Patched4.06%0.70%0–31 ms

Restoring the window resumes drawing: Windows sends WM_PAINT on restore, CPU returns to the visible level, and the window shows live data again. What's left while minimized is the VSyncProvider thread itself, which keeps waiting on DwmFlush. Visible idle CPU is unchanged, which is expected, since #63182 covers that.

Notes

  • This is narrower than #63182 (demand-driven frame scheduling), which was closed for a different internal refactor. It only stops work for windows that can't be seen, which macOS already does.
  • I tested minimizing only, not windows hidden with ShowWindow(SW_HIDE) or a window that's fully covered. Skipping covered windows would need occlusion detection, which Windows doesn't report the way AppKit does, so I left that out.
  • Possibly related: #58048 (fans spin up with the screen off on Windows). I haven't reproduced that one.

I'm happy to open a PR with this change if it's wanted.

Zed version and system specs

Not tested with a Zed build. Tested with GPUI 0.3.10 (crates.io), and checked against main at 37faef81f7. OS: Windows 11 Home 26200 Memory: 31 GiB Architecture: x86_64 (Ryzen AI 9 HX 370, Radeon 890M)

Attach Zed log file

Zed.log
undefined

Relevant Zed settings

settings.json
undefined

Relevant Keymap

keymap.json
undefined

(for AI issues) Model provider details

No response

If you are using WSL on Windows, what flavor of Linux are you using?

None

Activity

4
zed-community-bot[bot]3d ago

This issue appears to be a duplicate of:

  • #64388

If this is indeed a duplicate: Please close this issue and subscribe to the linked issue for updates (select "Close as not planned" → "Duplicate")

If this is a different issue: No action needed. A maintainer will review this shortly.

Why were these issues selected?

#64388: Issue #64388 is about the GPUI X11 backend reporting a minimized window as Visible and continuing to request frames — the same root cause as the new issue (GPUI's platform layer not suppressing frame scheduling for minimized windows). The new issue is the Windows equivalent: VSyncProvider calls RedrawWindow unconditionally on minimized windows. Both are about GPUI failing to stop the render loop when a window is minimized.

Shared root cause: GPUI platform backend does not suppress frame requests/redraws for minimized windows, causing the render loop to run at vsync rate even when the window cannot be seen.


This is an automated analysis and might be incorrect.

Elitelord3d ago

This issue appears to be a duplicate of:

If this is indeed a duplicate: Please close this issue and subscribe to the linked issue for updates (select "Close as not planned" → "Duplicate")

If this is a different issue: No action needed. A maintainer will review this shortly.

Why were these issues selected? #64388: Issue #64388 is about the GPUI X11 backend reporting a minimized window as Visible and continuing to request frames — the same root cause as the new issue (GPUI's platform layer not suppressing frame scheduling for minimized windows). The new issue is the Windows equivalent: VSyncProvider calls RedrawWindow unconditionally on minimized windows. Both are about GPUI failing to stop the render loop when a window is minimized.

Shared root cause: GPUI platform backend does not suppress frame requests/redraws for minimized windows, causing the render loop to run at vsync rate even when the window cannot be seen.

This is an automated analysis and might be incorrect.

I think they’re related, but the cause and the fix are in different backends so it's not a duplicate.

In #64388 the X11 backend reports a minimized window as Visible, so the fix is in how X11 detects it. On Windows, detection already works as WindowsWindowInner::visibility() returns Hidden for a minimized window (IsWindowVisible && !IsIconic). The problem is that the VSyncProvider loop in gpui_windows/src/platform.rs calls RedrawWindow on every window regardless, so a window reported as Hidden still gets frame requests. That goes against the WindowVisibility::Hidden docs from zed-industries/zed#64107 (“the platform will not request frames for it until it becomes visible again”). Fixing #64388 wouldn’t change the Windows behavior.

The patch in the issue uses the same condition as visibility(), so frame requests would match what the backend already reports.

yeskunall2d ago

@Elitelord I checked the Windows code at 37faef81f74fc1bb08be9939c4cffc8ede6ced2f. The visibility check only guards frame-signal bookkeeping; RedrawWindow still runs for minimized windows. I agree that this is separate from #64388’s X11 visibility detection.

One distinction remains in the explanation. The minimize handler removes the frame callback, and the paint path can return without invoking it. Repeated invalidation therefore doesn’t by itself establish a rendered frame on every vsync. I haven’t run the native Windows measurements.

Can you distinguish WM_PAINT deliveries from frame-callback invocations in your test, before and after the guard? Your CPU results already show why suppressing the invalidations matters. Those counts would clarify which work still runs while minimized.

Elitelord2d ago

@yeskunall You're right. While the window is minimized the frame callback never runs. I added counters to the Windows backend (GPUI 0.3.10 from crates.io; the vsync loop, handle_size_msg and draw_window match main). They count WM_PAINT deliveries, how many reach the frame callback, how many fall through to DefWindowProcW, and the time spent in each. The guard is switched by an environment variable, so the same build covers both cases. Per second, mean of 3 runs of 10 s each:

GuardWindowWM_PAINTFrame callbacksWM_PAINT → DefWindowProcWTime in DefWindowProcW paintTime in frame callbackMain-thread CPU
offvisible757500 ms8.0 ms18.8 ms
offminimized7507514.2 ms0 ms22.4 ms
onvisible757500 ms8.8 ms15.1 ms
onminimized0000 ms0 ms0.5 ms

(75 is the rate the vsync thread wakes on this machine.)

So while minimized, every WM_PAINT takes the early return in draw_window (request_frame.take()? returns None after handle_size_msg moved the callback into restore_from_minimized), and handle_msg passes it to DefWindowProcW. No GPUI frame runs. The message itself takes about 0.19 ms per DefWindowProcW paint, plus the round trip for each wake. That's more than a frame check costs while visible (about 0.11 ms).

With the guard, there are no invalidations, no WM_PAINT and no main-thread work while minimized. The main thread's CPU time was below the 15.6 ms resolution of the thread times in two of the three runs.

Times are wall-clock around each call (Instant); main-thread CPU is the thread's kernel+user time.

I'll correct the issue text so that it doesn't say each wake runs request_frame.