Skip to content

Wake a laptop's discrete card when an iPad looks for PCs - #256

Merged
whoisaldo merged 3 commits into
release/0.3.5-candidatefrom
perf/amd-idle-wake
Nov 8, 2026
Merged

whoisaldo merged 3 commits into
release/0.3.5-candidatefrom
perf/amd-idle-wake

Conversation

@whoisaldo

@whoisaldo whoisaldo commented Nov 8, 2026 •

Copy link
Copy Markdown
Member

Why

On a hybrid AMD laptop, the first session after about 20-40 s idle had its first picture about 700 ms late. The discrete card had powered off entirely (D3cold):

Idle since the card's last use dGPU and the PCIe ports above it (dGPU, downstream, upstream, root) D3D11 device on the dGPU
about 4 s D0 about 160 ms
8-16 s the dGPU in D3, then each port above it, about 5 s apart 160-163 ms
24 s and more all in D3, root port included: power removed 780-830 ms

Whatever touches the card first pays for the wake. With a D3D11 device created first, the AMF open that follows took 74-145 ms; without one, the AMF open itself took about 830 ms.

The iPad looks for PCs over mDNS on its connect screen, seconds before it sends HELLO. That is early enough to start the wake.

What

  • discovery::watch_browsers: reads mDNS on the shared port alongside the daemon, and sends nothing. A PTR or ANY question for _eternaldisplay._udp.local from another device calls on_browse.
    • The daemon's own Respond events can't be used: it answers its own probes at each registration and at the 60 s re-advertisement.
    • Queries from the PC's own addresses and loopback are ignored.
    • It reads up to mDNS's 9000 bytes per datagram and never stops on a receive error. Errors that pass (an oversized datagram, a reset, an interruption) go on at once, a few in a row; other or repeated errors wait longer each time, up to a minute.
  • gpu_wake: on a browse, creates a D3D11 device on the encoder's DXGI adapter on a thread of its own and drops it at once.
    • Only the card of the encoder that streams. Each encoder open, fallbacks included, publishes it in SharedControl::wake_adapter: the pipeline GPU's adapter for a hardware encoder of its family, None for the CPU or another card's encoder (NVENC or AMF failing over to Quick Sync). Adopting a late GPU detection publishes it too, until the new pipeline's encoder opens. The browse callback reads only that.
    • Never while a viewer is connected, and never more than one at a time.
    • The wake thread checks again, just before it creates the device, that no session has started since the browse (the session count), and drops the wake if one has.
    • Holding the device for 10 s made no measurable difference to the first picture, so it isn't held.
  • WakeGate: at most one wake every 15 s. Each wake that no session followed doubles the wait for the next, up to 10 min, so a device that browses and never connects causes a few wakes an hour. A session since the last wake restores the 15 s wait before the elapsed-time check, so a viewer after a long backoff doesn't wait it out (Session::sessions_started).

Tests

  • discovery: a browse for the service is told from other mDNS traffic (other service types, A questions, responses, case, a second question through a compression pointer). Malformed, truncated and self-pointing packets are not browses.
  • discovery, receive errors: an oversized datagram, a reset and an interruption go on at once, then back off if they keep coming. Other errors back off from the first, up to a minute, and the watch never stops.
  • gpu_wake:
    • At most one wake per interval, and none in a session; one that paid off keeps the short interval.
    • A session after a ten-minute backoff restores the short interval at once. This failed before the fix: "the session's browse waited out the old ten-minute wait".
    • Only the card of the encoder that streams is woken: not for the CPU, nor for another card's encoder.
    • encoder: an open publishes the card to wake, and a Quick Sync fallback from NVENC clears it.
    • supervisor: an adopted late detection becomes the card to wake, after startup encoded on the CPU.
    • A wake is dropped when a session started first.
    • A device that browses every 20 s and never connects gets increasing gaps, settling at one wake every 10 min.
  • Locally (macOS): cargo test -p eternal-host --lib passes (1231). cargo clippy --all-targets -D warnings and cargo fmt --check are clean.
  • Windows: cargo clippy -p eternal-host --all-targets -D warnings is clean.

Behaviour before and after

Measured at f271721. RX 7600S hybrid laptop, h264_amf, headless synthetic still desktop. The first session after 45 s idle, 6 trials each, client time from HELLO_ACK to the first decoded frame. The client browsed for the service over mDNS (loopback only, in a local test copy) the given time before HELLO.

trials median
before, no browse 883, 946, 844, 944, 871, 891 ms about 885 ms
after, no browse 869, 865, 851, 887, 956, 844 ms about 865 ms
after, browse 1.5 s before HELLO 377, 340, 354, 802, 379, 364 ms about 365 ms
after, browse 0.3 s before HELLO 1900, 585, 599, 628, 782, 620 ms about 625 ms
  • A warm session (3-5 s gaps) takes about 130 ms. After a wake the AMF reopen still took about 280 ms, against about 60 ms warm.
  • Cost of a wake that no session follows: the card stayed in D0 for about 13.5 s and was back in D3cold 28.6 s after the wake. The laptop was on AC power, so battery drain was not measured.

A laptop powers its discrete card off entirely (D3cold) about 20 s after
its last use: on an AMD hybrid laptop the card and every PCIe port above
it, up to the root port, went to D3. The next D3D11 device or AMF open on
it then took about 800 ms instead of 160, and an AMF session's first
picture after 40 s idle took about 870 ms instead of 130.

An iPad looks for PCs over mDNS on its connect screen, seconds before it
sends HELLO. The host now reads mDNS queries alongside its daemon, and a
PTR or ANY question for the service from another device (not the PC's own
daemon, which probes at each registration and re-advertisement) creates a
D3D11 device on the encoder's adapter on a thread of its own and drops it.

Nothing is kept open, and nothing wakes the card on a timer: a wake comes
at most every 15 s, never while a viewer is connected, and each one no
session followed doubles the wait for the next, up to 10 min.
@whoisaldo whoisaldo added the 0.3.5 After 0.3.4 label Nov 8, 2026
- The browse watch read into a 1500-byte buffer and stopped at its first
  receive error: one oversized mDNS datagram (WSAEMSGSIZE) ended warming
  until the app restarted. It now reads up to mDNS's 9000 bytes, goes on
  at once after errors that pass, and waits longer after repeated or
  other errors, up to a minute, without stopping.
- A HELLO could start a session between the browse's check and the wake's
  device. The wake thread now checks again, just before it creates the
  device, that no session has started since (the session count), and
  drops the wake if one has.
- After a long backoff a session didn't restore the short wait until the
  old ten-minute one had passed: the gate checked the wait before
  noticing the session. It now resets the wait first, keeping the 15 s
  minimum.
- Whether to wake now follows the encoder that will stream: the override
  in Settings, and no wake after a fallback to the CPU, nor for an
  encoder on another card.
…d it

The browse callback judged a snapshot of startup: the detected encoder,
the Settings override and the CPU-fallback flag. A hardware fallback
(NVENC or AMF failing over to Quick Sync, which clears that flag) still
woke the card no one used, and a late GPU detection the supervisor
adopted never became wakeable: startup's libx264 stayed in the callback.

SharedControl::wake_adapter now holds the card to wake. Each encoder open,
fallbacks included, sets it from the encoder that opened and the
pipeline's GPU: that GPU's adapter for a hardware encoder of its family,
None for the CPU or another card's encoder. Adopting a late detection
sets it too, until the new pipeline's encoder opens. The callback reads
only that.
@whoisaldo
whoisaldo marked this pull request as ready for review November 8, 2026 22:20
@whoisaldo
whoisaldo merged commit 51584b5 into release/0.3.5-candidate Nov 8, 2026
12 of 16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

0.3.5 After 0.3.4

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants