feat(vdisplay): stall attribution v1 — the driver testifies which leg lost the frames
Every IDD-push capture stall now carries a VERDICT instead of a hypothesis list. The shared ring header grows a v2 telemetry tail (drain-loop heartbeat QPC, last-acquire QPC, full-width offered counter) the driver stamps on every drain pass; the host samples it between fresh frames and attributes each stall: worker-stalled (our thread starved) / compose-silence (DWM composed nothing — the disturbance is below capture) / delivery-leg (frames existed, our publish/ring/consume lost them). The metronomic WARN prints the running tally, so one pasted log line settles the Branch-1/Branch-2 fork of the vdisplay-disturbance-immunity program per session, per box. Both directions stay version-safe: the tail is gated on the HOST-stamped header version (a v2 driver never writes past a v1 host's 64-byte layout — it maps the whole section instead of a fixed 88 bytes), and a v2 host reads a zero heartbeat as pre-telemetry driver, no verdict. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -53,6 +53,7 @@ windows = { version = "0.62", features = [
|
||||
"Win32_System_LibraryLoader",
|
||||
"Win32_System_StationsAndDesktops",
|
||||
"Win32_System_Memory",
|
||||
"Win32_System_Performance",
|
||||
"Win32_System_Threading",
|
||||
"Win32_UI_HiDpi",
|
||||
"Win32_UI_Input_KeyboardAndMouse",
|
||||
|
||||
@@ -54,6 +54,8 @@ use windows::Win32::Security::Authorization::{
|
||||
ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1,
|
||||
};
|
||||
use windows::Win32::Security::{PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES};
|
||||
use windows::Win32::System::Performance::{QueryPerformanceCounter, QueryPerformanceFrequency};
|
||||
|
||||
use windows::Win32::System::Memory::{
|
||||
CreateFileMappingW, MapViewOfFile, UnmapViewOfFile, FILE_MAP_ALL_ACCESS,
|
||||
MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE,
|
||||
@@ -324,7 +326,7 @@ mod descriptor;
|
||||
mod stall;
|
||||
use channel::ChannelBroker;
|
||||
use descriptor::{DescriptorPoller, DisplayDescriptor};
|
||||
use stall::StallWatch;
|
||||
use stall::{StallEvidence, StallWatch};
|
||||
|
||||
pub struct IddPushCapturer {
|
||||
device: ID3D11Device,
|
||||
@@ -482,6 +484,11 @@ pub struct IddPushCapturer {
|
||||
/// during active flow and warns when they turn metronomic — the sole-virtual-display
|
||||
/// periodic-stutter diagnostic.
|
||||
stall_watch: StallWatch,
|
||||
/// v2 driver-telemetry trackers feeding [`stall::StallEvidence`]: the header's
|
||||
/// `offered_total` as of the last fresh frame, and the stalest the driver's drain heartbeat
|
||||
/// ever read (µs) while the host starved since then. Rolled at every fresh frame.
|
||||
offered_at_fresh: u64,
|
||||
max_hb_age_us: u64,
|
||||
/// Host-owned ROTATING output ring NVENC encodes (one YUV texture per slot). Rotating it per frame
|
||||
/// is the precondition for pipelining the encode loop: while NVENC encodes frame N's texture on the
|
||||
/// ASIC, frame N+1's convert writes a DIFFERENT texture — the two overlap. Format = `out_format()`:
|
||||
@@ -532,6 +539,47 @@ impl IddPushCapturer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Age of a driver-stamped QPC value in microseconds (QPC is system-wide, so cross-process
|
||||
/// comparison is sound); 0 when the stamp reads ahead of us (a benign race with the writer).
|
||||
fn qpc_age_us(stamp: u64) -> u64 {
|
||||
static FREQ: std::sync::OnceLock<u64> = std::sync::OnceLock::new();
|
||||
let freq = *FREQ.get_or_init(|| {
|
||||
let mut f = 0i64;
|
||||
// SAFETY: plain FFI; `f` is a valid local out-param. The frequency is fixed at boot and
|
||||
// cannot fail on any OS we run on; 0 would only mean the call failed — guarded below.
|
||||
let _ = unsafe { QueryPerformanceFrequency(&mut f) };
|
||||
f.max(0) as u64
|
||||
});
|
||||
if freq == 0 {
|
||||
return 0;
|
||||
}
|
||||
let mut now = 0i64;
|
||||
// SAFETY: plain FFI; `now` is a valid local out-param.
|
||||
if unsafe { QueryPerformanceCounter(&mut now) }.is_err() {
|
||||
return 0;
|
||||
}
|
||||
(now as u64).saturating_sub(stamp).saturating_mul(1_000_000) / freq
|
||||
}
|
||||
|
||||
/// The header's v2 telemetry tail — `(drain_heartbeat_qpc, offered_total)`; `None` until a
|
||||
/// telemetry-capable driver writes its first heartbeat (the host always creates the v2 layout,
|
||||
/// so a zero heartbeat means the attached driver predates it).
|
||||
#[inline]
|
||||
fn telemetry(&self) -> Option<(u64, u64)> {
|
||||
// SAFETY: like `latest` — the header stays mapped for the capturer's lifetime, both fields
|
||||
// are 8-aligned `u64`s within the v2 layout the host itself created, and Relaxed suffices
|
||||
// for best-effort diagnostics (the same contract the driver writes them under).
|
||||
let (hb, offered) = unsafe {
|
||||
(
|
||||
(*(std::ptr::addr_of!((*self.header).drain_heartbeat_qpc) as *const AtomicU64))
|
||||
.load(Ordering::Relaxed),
|
||||
(*(std::ptr::addr_of!((*self.header).offered_total) as *const AtomicU64))
|
||||
.load(Ordering::Relaxed),
|
||||
)
|
||||
};
|
||||
(hb != 0).then_some((hb, offered))
|
||||
}
|
||||
|
||||
/// Log the driver's status once it first reports (the only driver-visibility channel we have).
|
||||
fn log_driver_status_once(&mut self) {
|
||||
if self.status_logged {
|
||||
@@ -1380,6 +1428,14 @@ impl IddPushCapturer {
|
||||
);
|
||||
}
|
||||
}
|
||||
// Stall-attribution evidence (v2 telemetry): record the STALEST the driver's drain
|
||||
// heartbeat ever reads between fresh frames. A heartbeat that goes quiet for the hole
|
||||
// convicts our worker (starved/dead WUDFHost); one that stays fresh through it acquits the
|
||||
// driver and indicts the compose/present path. Two Relaxed loads + a QPC read per consume
|
||||
// tick; rolled at every fresh frame below.
|
||||
if let Some((hb, _)) = self.telemetry() {
|
||||
self.max_hb_age_us = self.max_hb_age_us.max(Self::qpc_age_us(hb));
|
||||
}
|
||||
let latest = self.latest();
|
||||
// `latest` is the proto publish token `(generation << 40) | (seq << 8) | slot`. Reject any publish
|
||||
// whose generation isn't our CURRENT ring (a stale old-ring publish racing a recreate, or the 0
|
||||
@@ -1529,10 +1585,29 @@ impl IddPushCapturer {
|
||||
// the running correlated/total tally — lives on `StallWatch` (sweep Phase 5.4). It was
|
||||
// ~65 lines of log prose inside `try_consume`, which is the hot loop, and its two
|
||||
// counters were capturer fields that nothing else touched.
|
||||
self.stall_watch.report(&stall, now);
|
||||
let evidence = StallEvidence {
|
||||
// A publisher re-attach restarts `offered_total` near zero; a ring recreate resets
|
||||
// the stall watch before that can matter, but guard the delta anyway (a restarted
|
||||
// counter reads as "frames offered since the restart", never as a u64 underflow).
|
||||
offered_delta: self.telemetry().map(|(_, offered)| {
|
||||
if offered >= self.offered_at_fresh {
|
||||
offered - self.offered_at_fresh
|
||||
} else {
|
||||
offered
|
||||
}
|
||||
}),
|
||||
max_heartbeat_age_ms: self.max_hb_age_us / 1_000,
|
||||
};
|
||||
self.stall_watch.report(&stall, now, &evidence);
|
||||
}
|
||||
if !regen {
|
||||
self.last_fresh = now; // feeds the driver-death watch
|
||||
// A fresh driver frame: feed the driver-death watch and roll the stall-evidence
|
||||
// trackers (a regen re-encodes OLD content — it is not evidence of driver progress).
|
||||
self.last_fresh = now;
|
||||
if let Some((_, offered)) = self.telemetry() {
|
||||
self.offered_at_fresh = offered;
|
||||
}
|
||||
self.max_hb_age_us = 0;
|
||||
}
|
||||
// Build the frame. For PyroWave the encode input is the Y plane
|
||||
// (`texture`) + the CbCr plane & fence in `pyro`; signal the shared fence
|
||||
@@ -2037,4 +2112,35 @@ mod tests {
|
||||
"detection re-armed after the reset"
|
||||
);
|
||||
}
|
||||
|
||||
/// [`stall::attribute`]'s verdict table — the Branch-1/Branch-2 fork, per evidence shape.
|
||||
#[test]
|
||||
fn stall_attribution_verdicts() {
|
||||
use super::stall::{attribute, StallVerdict};
|
||||
let verdict = |gap_ms: u64, offered: Option<u64>, hb_age_ms: u64| {
|
||||
attribute(
|
||||
Duration::from_millis(gap_ms),
|
||||
&StallEvidence {
|
||||
offered_delta: offered,
|
||||
max_heartbeat_age_ms: hb_age_ms,
|
||||
},
|
||||
)
|
||||
};
|
||||
// Pre-telemetry driver: no verdict, whatever the heartbeat tracker read.
|
||||
assert_eq!(verdict(300, None, 500), StallVerdict::NoTelemetry);
|
||||
// Heartbeat silent for most of the hole → the worker starved, wherever the frames were.
|
||||
assert_eq!(verdict(600, Some(0), 400), StallVerdict::WorkerStalled);
|
||||
assert_eq!(verdict(600, Some(50), 300), StallVerdict::WorkerStalled);
|
||||
// A scheduled worker heartbeats every ≤16 ms — 200 ms of silence on a 300 ms gap is under
|
||||
// the max(gap/2, 250 ms) bar, so the verdict falls through to the offered-frames fork.
|
||||
assert_eq!(verdict(300, Some(1), 200), StallVerdict::ComposeSilence);
|
||||
// The stall-ending frame (+ a small resume burst) does not acquit DWM...
|
||||
assert_eq!(verdict(300, Some(3), 20), StallVerdict::ComposeSilence);
|
||||
// ...but sustained composition through the hole does: the frames existed, WE lost them.
|
||||
assert_eq!(verdict(300, Some(8), 20), StallVerdict::DeliveryLeg);
|
||||
assert_eq!(verdict(2_000, Some(120), 30), StallVerdict::DeliveryLeg);
|
||||
// Long holes scale the worker-stalled bar: 900 ms of silence on a 3 s gap is not half.
|
||||
assert_eq!(verdict(3_000, Some(2), 900), StallVerdict::ComposeSilence);
|
||||
assert_eq!(verdict(3_000, Some(2), 1_600), StallVerdict::WorkerStalled);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -633,6 +633,8 @@ impl IddPushCapturer {
|
||||
last_liveness: Instant::now(),
|
||||
last_kick: Instant::now(),
|
||||
stall_watch: StallWatch::new(),
|
||||
offered_at_fresh: 0,
|
||||
max_hb_age_us: 0,
|
||||
out_ring: Vec::new(),
|
||||
out_idx: 0,
|
||||
video_conv: None,
|
||||
|
||||
@@ -16,6 +16,69 @@ pub(super) struct Stall {
|
||||
pub(super) metronomic: Option<Duration>,
|
||||
}
|
||||
|
||||
/// Driver-telemetry evidence for one stall window (the v2 header tail — see
|
||||
/// `pf_driver_proto::frame::SharedHeader`), sampled by the capturer between the last pre-gap
|
||||
/// frame and the frame that ended the stall.
|
||||
pub(super) struct StallEvidence {
|
||||
/// Surfaces the driver OFFERED to the ring publisher during the window (delta of
|
||||
/// `offered_total`); `None` = pre-telemetry driver (it never wrote the tail).
|
||||
pub(super) offered_delta: Option<u64>,
|
||||
/// The STALEST the driver's drain heartbeat ever read while the host starved (max of
|
||||
/// now − heartbeat over the window), in milliseconds.
|
||||
pub(super) max_heartbeat_age_ms: u64,
|
||||
}
|
||||
|
||||
/// The attribution a stall's evidence supports — the Branch-1/Branch-2 fork of the
|
||||
/// vdisplay-disturbance-immunity program, computed per stall instead of argued per field report.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(super) enum StallVerdict {
|
||||
/// Pre-telemetry driver: no verdict, the log says only what the host observed.
|
||||
NoTelemetry,
|
||||
/// The drain worker's heartbeat went silent for a large share of the hole — the swap-chain
|
||||
/// thread starved (CPU/MMCSS) or the WUDFHost died. Ours, host/driver side.
|
||||
WorkerStalled,
|
||||
/// The worker drained E_PENDING throughout: DWM composed NOTHING for the hole. The
|
||||
/// disturbance is below capture (adapter servicing / DDC lock / present clock) — the
|
||||
/// micro-probe + ETW phases discriminate further.
|
||||
ComposeSilence,
|
||||
/// DWM composed frames all through the hole and the driver offered them, but none became a
|
||||
/// consumable ring slot — OUR publish/ring/consume leg lost them. Fully killable.
|
||||
DeliveryLeg,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for StallVerdict {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::NoTelemetry => "pre-telemetry driver (no verdict)",
|
||||
Self::WorkerStalled => "driver-worker-stalled (heartbeat silent) — host CPU/MMCSS or a dead WUDFHost, NOT the display path",
|
||||
Self::ComposeSilence => "compose-silence (driver drained E_PENDING) — DWM composed nothing; the disturbance is below capture",
|
||||
Self::DeliveryLeg => "delivery-leg (frames were composed + offered but never consumable) — OUR ring/publish/consume leg",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn one stall window's evidence into a [`StallVerdict`]. Pure — unit-tested beside the
|
||||
/// [`StallWatch`] tests.
|
||||
///
|
||||
/// Thresholds: a heartbeat that was ever `max(gap/2, 250 ms)` stale convicts the worker (its
|
||||
/// scheduled cadence is ≤16 ms, so 250 ms of silence is real starvation, and gap/2 scales the bar
|
||||
/// for long holes); `offered_delta ≥ 8` acquits DWM (8 composed frames during the "hole" mirrors
|
||||
/// [`StallWatch::RECENT`]'s definition of sustained flow — the stall-ending frame plus a resume
|
||||
/// burst stay well under it).
|
||||
pub(super) fn attribute(gap: Duration, evidence: &StallEvidence) -> StallVerdict {
|
||||
let Some(offered) = evidence.offered_delta else {
|
||||
return StallVerdict::NoTelemetry;
|
||||
};
|
||||
let gap_ms = gap.as_millis() as u64;
|
||||
if evidence.max_heartbeat_age_ms >= (gap_ms / 2).max(250) {
|
||||
StallVerdict::WorkerStalled
|
||||
} else if offered >= 8 {
|
||||
StallVerdict::DeliveryLeg
|
||||
} else {
|
||||
StallVerdict::ComposeSilence
|
||||
}
|
||||
}
|
||||
|
||||
/// Capture-stall watch — the "sole virtual display" stutter diagnostic (field reports: Exclusive
|
||||
/// topology = periodic double-jolt, Extend = smooth, i.e. the disturbance lives in the display/present
|
||||
/// path BELOW capture and only while no physical output is active).
|
||||
@@ -35,6 +98,10 @@ pub(super) struct StallWatch {
|
||||
/// [`Self::report`] uses. They were capturer fields that nothing outside the report touched.
|
||||
seen: u32,
|
||||
with_os_events: u32,
|
||||
/// Running per-verdict tally (worker-stalled / compose-silence / delivery-leg / no-telemetry),
|
||||
/// in [`StallVerdict`] order — the metronomic WARN prints it, so one pasted line attributes the
|
||||
/// whole session's beat, not just the stall that tripped the metronome.
|
||||
verdicts: [u32; 4],
|
||||
}
|
||||
|
||||
impl StallWatch {
|
||||
@@ -54,6 +121,7 @@ impl StallWatch {
|
||||
cadence: pf_frame::metronome::Metronome::new(),
|
||||
seen: 0,
|
||||
with_os_events: 0,
|
||||
verdicts: [0; 4],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +160,10 @@ impl StallWatch {
|
||||
/// a running tally, all of it about stalls and none of it about consuming a frame, in a function
|
||||
/// that runs per frame. `now` is the instant of the frame that ENDED the stall — the same one
|
||||
/// passed to [`Self::note_fresh`] — which is what bounds the event-correlation window.
|
||||
pub(super) fn report(&mut self, stall: &Stall, now: Instant) {
|
||||
/// `evidence` is the capturer's driver-telemetry sample for the window; its [`attribute`]
|
||||
/// verdict rides every stall line, so a field log names which leg lost the frames instead of
|
||||
/// leaving it to hypothesis.
|
||||
pub(super) fn report(&mut self, stall: &Stall, now: Instant, evidence: &StallEvidence) {
|
||||
// OS display events inside the gap (plus a lead-in margin: the event that CAUSED the
|
||||
// hole lands just before DWM stops delivering) — the attribution that turns "DWM
|
||||
// stopped composing" into "…because Windows re-enumerated SAMSUNG on HDMI".
|
||||
@@ -105,14 +176,24 @@ impl StallWatch {
|
||||
if !events.is_empty() {
|
||||
self.with_os_events = self.with_os_events.saturating_add(1);
|
||||
}
|
||||
let verdict = attribute(stall.gap, evidence);
|
||||
self.verdicts[match verdict {
|
||||
StallVerdict::NoTelemetry => 0,
|
||||
StallVerdict::WorkerStalled => 1,
|
||||
StallVerdict::ComposeSilence => 2,
|
||||
StallVerdict::DeliveryLeg => 3,
|
||||
}] += 1;
|
||||
// debug (not warn): a single hole also happens when content legitimately pauses;
|
||||
// the reportable signal is the metronomic cycle below. Mounjay-class triage runs
|
||||
// at debug level, and the web-console debug ring captures these.
|
||||
tracing::debug!(
|
||||
gap_ms = stall.gap.as_millis() as u64,
|
||||
os_display_events = %pf_win_display::display_events::summarize(&events),
|
||||
"IDD-push capture stall — the desktop was composing at speed, then DWM \
|
||||
delivered no frame for the gap; the present path stalled below capture"
|
||||
verdict = %verdict,
|
||||
offered_during_gap = evidence.offered_delta,
|
||||
max_heartbeat_age_ms = evidence.max_heartbeat_age_ms,
|
||||
"IDD-push capture stall — the desktop was composing at speed, then the ring \
|
||||
delivered no frame for the gap; the verdict names the leg that lost them"
|
||||
);
|
||||
if let Some(period) = stall.metronomic {
|
||||
let suspects = pf_win_display::display_events::connected_inactive_physicals();
|
||||
@@ -122,6 +203,11 @@ impl StallWatch {
|
||||
suspects.join(", ")
|
||||
};
|
||||
let correlated = format!("{}/{}", self.with_os_events, self.seen);
|
||||
// The session's attribution in one token: which leg the evidence convicted, per stall.
|
||||
let verdict_tally = format!(
|
||||
"worker-stalled {}, compose-silence {}, delivery-leg {}, no-telemetry {}",
|
||||
self.verdicts[1], self.verdicts[2], self.verdicts[3], self.verdicts[0]
|
||||
);
|
||||
// Half-or-more of the stalls carrying a coinciding OS event = the reaction
|
||||
// cascade is OS-visible; otherwise the disturbance never surfaces above the
|
||||
// driver. Different classes, different cures — say which one this box has.
|
||||
@@ -130,6 +216,7 @@ impl StallWatch {
|
||||
period_s = format!("{:.2}", period.as_secs_f64()),
|
||||
os_correlated = correlated,
|
||||
connected_inactive = %suspects,
|
||||
verdicts = %verdict_tally,
|
||||
"capture stalls are METRONOMIC and coincide with Windows monitor \
|
||||
hot-plug/re-enumeration events — a connected display (or its \
|
||||
cable/switch/AVR) re-probes the link on a timer and Windows re-reacts \
|
||||
@@ -145,6 +232,7 @@ impl StallWatch {
|
||||
period_s = format!("{:.2}", period.as_secs_f64()),
|
||||
os_correlated = correlated,
|
||||
connected_inactive = %suspects,
|
||||
verdicts = %verdict_tally,
|
||||
"capture stalls are METRONOMIC with NO coinciding OS display event — \
|
||||
the disturbance is BELOW Windows: the GPU driver servicing a \
|
||||
connected-but-asleep sink (standby HPD/DDC/link probing), \
|
||||
|
||||
@@ -490,8 +490,17 @@ pub mod frame {
|
||||
/// Header magic (`"PFVD"` LE). The host stamps it LAST (after the ring textures exist) so the driver
|
||||
/// only attaches to a fully-published ring.
|
||||
pub const MAGIC: u32 = 0x4456_4650;
|
||||
/// Frame-plane version (independent bump of the header layout).
|
||||
pub const VERSION: u32 = 1;
|
||||
/// Frame-plane version (independent bump of the header layout). v2 appended the stall-attribution
|
||||
/// telemetry tail (`drain_heartbeat_qpc`/`last_acquire_qpc`/`offered_total`); see
|
||||
/// [`VERSION_TELEMETRY`] for the compatibility contract.
|
||||
pub const VERSION: u32 = 2;
|
||||
/// The header version that grew the telemetry tail. Compatibility is gated on the HOST-stamped
|
||||
/// `version` field, not on mapping sizes: a v2 driver writes the tail only when
|
||||
/// `version >= VERSION_TELEMETRY` (a v1 host created a 64-byte layout — never write past it),
|
||||
/// and a v2 host reads `drain_heartbeat_qpc == 0` as "pre-telemetry driver, no verdict" (the
|
||||
/// same zero-means-absent convention as [`OPENED_DETAIL_LIVE`]). Neither side rejects the
|
||||
/// other's version — the tail is diagnostics, not frame-plane semantics.
|
||||
pub const VERSION_TELEMETRY: u32 = 2;
|
||||
/// Ring slots. Headroom so the driver's 0 ms-timeout publish always finds a free slot while the host
|
||||
/// holds one across the convert/copy + the pipelined encode. MUST be identical on both sides — it is,
|
||||
/// because both read this one constant.
|
||||
@@ -587,6 +596,22 @@ pub mod frame {
|
||||
/// token blocks file writes, so this header is how the driver reports state).
|
||||
pub driver_status: u32,
|
||||
pub driver_status_detail: u32,
|
||||
/// v2 telemetry tail (stall attribution, Phase A.1 of the vdisplay-disturbance-immunity
|
||||
/// program): driver-written on every drain-loop pass, host-read when a capture stall ends.
|
||||
/// All three are best-effort Relaxed atomic stores (the `driver_status` visibility
|
||||
/// contract); `0` means a pre-v2 driver never wrote them. QPC of the swap-chain worker's
|
||||
/// most recent drain-loop iteration — E_PENDING passes included, so a fresh heartbeat over
|
||||
/// a stale [`Self::last_acquire_qpc`] reads "the worker is running and DWM is composing
|
||||
/// nothing", while a stale heartbeat reads "our worker starved".
|
||||
pub drain_heartbeat_qpc: u64,
|
||||
/// QPC of the most recent SUCCESSFUL swap-chain acquire — the last instant DWM actually
|
||||
/// composed this display. The Branch-1/Branch-2 fork in one field.
|
||||
pub last_acquire_qpc: u64,
|
||||
/// Wrapping count of surfaces offered to the ring publisher — the full-width sibling of
|
||||
/// the packed 15-bit [`OPENED_DETAIL_LIVE`] counter (which saturates and cannot be
|
||||
/// delta'd over a stall window). The host snapshots it per consumed frame; the delta
|
||||
/// across a stall says whether frames existed that never reached the ring.
|
||||
pub offered_total: u64,
|
||||
}
|
||||
|
||||
/// Why the driver's publisher must NOT attach a delivered channel to its monitor's ring — the
|
||||
@@ -663,7 +688,7 @@ pub mod frame {
|
||||
const _: () = {
|
||||
use core::mem::{offset_of, size_of};
|
||||
|
||||
assert!(size_of::<SharedHeader>() == 64);
|
||||
assert!(size_of::<SharedHeader>() == 88);
|
||||
assert!(offset_of!(SharedHeader, magic) == 0);
|
||||
assert!(offset_of!(SharedHeader, version) == 4);
|
||||
assert!(offset_of!(SharedHeader, generation) == 8);
|
||||
@@ -678,6 +703,9 @@ pub mod frame {
|
||||
assert!(offset_of!(SharedHeader, driver_render_luid_high) == 52);
|
||||
assert!(offset_of!(SharedHeader, driver_status) == 56);
|
||||
assert!(offset_of!(SharedHeader, driver_status_detail) == 60);
|
||||
assert!(offset_of!(SharedHeader, drain_heartbeat_qpc) == 64);
|
||||
assert!(offset_of!(SharedHeader, last_acquire_qpc) == 72);
|
||||
assert!(offset_of!(SharedHeader, offered_total) == 80);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1426,14 +1454,15 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_header_is_pod_and_64_bytes() {
|
||||
fn shared_header_is_pod_and_88_bytes() {
|
||||
let mut h = frame::SharedHeader::zeroed();
|
||||
h.magic = frame::MAGIC;
|
||||
h.width = 5120;
|
||||
h.height = 1440;
|
||||
h.target_id = 262;
|
||||
h.drain_heartbeat_qpc = 0x1234_5678_9abc_def0;
|
||||
let bytes = bytemuck::bytes_of(&h);
|
||||
assert_eq!(bytes.len(), 64);
|
||||
assert_eq!(bytes.len(), 88);
|
||||
let back: frame::SharedHeader = *bytemuck::from_bytes(bytes);
|
||||
assert_eq!(back.magic, frame::MAGIC);
|
||||
assert_eq!(back.width, 5120);
|
||||
@@ -1441,6 +1470,11 @@ mod tests {
|
||||
// v3: the monitor binding occupies the old `_pad` slot at offset 28 — byte-compatible (a v2
|
||||
// host left it zero there).
|
||||
assert_eq!(bytes[28..32], 262u32.to_le_bytes());
|
||||
// Header v2: the telemetry tail is appended — the first 64 bytes ARE the v1 layout, so a
|
||||
// pre-telemetry driver reading its 64-byte view sees exactly what it always saw.
|
||||
assert_eq!(bytes[64..72], 0x1234_5678_9abc_def0u64.to_le_bytes());
|
||||
assert_eq!(back.last_acquire_qpc, 0);
|
||||
assert_eq!(back.offered_total, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -35,6 +35,7 @@ features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Security",
|
||||
"Win32_System_Memory",
|
||||
"Win32_System_Performance",
|
||||
"Win32_System_Threading",
|
||||
"Win32_Graphics_Direct3D",
|
||||
"Win32_Graphics_Direct3D11",
|
||||
|
||||
@@ -31,7 +31,8 @@ use std::time::Instant;
|
||||
use pf_driver_proto::control::SetFrameChannelRequest;
|
||||
use pf_driver_proto::frame::{
|
||||
AttachReject, DRV_STATUS_BIND_FAIL, DRV_STATUS_NO_DEVICE1, DRV_STATUS_OPENED,
|
||||
DRV_STATUS_TEX_FAIL, FrameToken, RING_LEN, SharedHeader, check_attach, pack_opened_detail,
|
||||
DRV_STATUS_TEX_FAIL, FrameToken, RING_LEN, SharedHeader, VERSION_TELEMETRY, check_attach,
|
||||
pack_opened_detail,
|
||||
};
|
||||
use windows::Win32::Foundation::{CloseHandle, HANDLE};
|
||||
use windows::Win32::Graphics::Direct3D11::{
|
||||
@@ -43,6 +44,7 @@ use windows::Win32::Graphics::Dxgi::IDXGIKeyedMutex;
|
||||
use windows::Win32::System::Memory::{
|
||||
FILE_MAP_READ, FILE_MAP_WRITE, MEMORY_MAPPED_VIEW_ADDRESS, MapViewOfFile, UnmapViewOfFile,
|
||||
};
|
||||
use windows::Win32::System::Performance::QueryPerformanceCounter;
|
||||
use windows::Win32::System::Threading::SetEvent;
|
||||
use windows::core::Interface;
|
||||
|
||||
@@ -292,6 +294,13 @@ pub struct FramePublisher {
|
||||
/// "DWM never composed" from "every compose mismatched the ring".
|
||||
offered: u32,
|
||||
mismatch_drops: u32,
|
||||
/// Whether the HOST created a telemetry-capable (v2, 88-byte) header — stamped `version >=
|
||||
/// VERSION_TELEMETRY` at attach. Gates every write to the telemetry tail: a v1 host's header
|
||||
/// is 64 bytes, and the tail fields would land past the layout it reads.
|
||||
telemetry: bool,
|
||||
/// Full-width wrapping sibling of `offered` (which packs to 15 saturating bits) — mirrored
|
||||
/// into `SharedHeader::offered_total` so the host can delta it across a stall window.
|
||||
offered_total: u64,
|
||||
/// The slot of the most recent successful publish + when it happened — what [`Self::harvest_into`]
|
||||
/// reads when this publisher is superseded. `None` until the first publish.
|
||||
last_published: Option<(u32, Instant)>,
|
||||
@@ -327,20 +336,16 @@ impl FramePublisher {
|
||||
|
||||
// 1. Map the header from the duplicated section handle (ours from here on).
|
||||
let map = FrameChannel::take(&mut channel.header);
|
||||
// SAFETY: `map` is the live section handle the host duplicated into this process; mapping
|
||||
// size_of::<SharedHeader>() bytes of it (the host created the mapping at >= that size). The null
|
||||
// `view.Value` is checked below.
|
||||
// SAFETY: `map` is the live section handle the host duplicated into this process; a byte
|
||||
// count of 0 maps the WHOLE section — a v1 host created a 64-byte (pre-telemetry) header,
|
||||
// so requesting size_of::<SharedHeader>() (the v2 88 bytes) could exceed what that host
|
||||
// declared, while 0 always fits and always covers the layout the host actually built (the
|
||||
// `telemetry` version gate keeps our writes inside it). The null `view.Value` is checked below.
|
||||
let view = unsafe {
|
||||
// Read/write only — the host now duplicates the header handle with least access
|
||||
// (`SECTION_MAP_READ | SECTION_MAP_WRITE`), so `FILE_MAP_ALL_ACCESS` would exceed the
|
||||
// granted rights and fail. We read the layout + write status/publish-token fields; RW covers it.
|
||||
MapViewOfFile(
|
||||
map,
|
||||
FILE_MAP_READ | FILE_MAP_WRITE,
|
||||
0,
|
||||
0,
|
||||
core::mem::size_of::<SharedHeader>(),
|
||||
)
|
||||
MapViewOfFile(map, FILE_MAP_READ | FILE_MAP_WRITE, 0, 0, 0)
|
||||
};
|
||||
if view.Value.is_null() {
|
||||
let err = windows::core::Error::from_win32();
|
||||
@@ -370,15 +375,16 @@ impl FramePublisher {
|
||||
// into another client's ring. The shared `check_attach` (unit-tested in pf-driver-proto)
|
||||
// owns the precedence: staleness first, binding second.
|
||||
// SAFETY: `header` is the mapped host header; `magic`/`generation` live within it and are read
|
||||
// atomically (Acquire) to pair with the host's Release publishes; `target_id` is a plain
|
||||
// in-bounds u32 read, stamped before the magic the Acquire load ordered us behind.
|
||||
let (magic, header_gen, header_target) = unsafe {
|
||||
// atomically (Acquire) to pair with the host's Release publishes; `target_id`/`version` are
|
||||
// plain in-bounds u32 reads, stamped before the magic the Acquire load ordered us behind.
|
||||
let (magic, header_gen, header_target, header_version) = unsafe {
|
||||
(
|
||||
(*(core::ptr::addr_of!((*header).magic) as *const AtomicU32))
|
||||
.load(Ordering::Acquire),
|
||||
(*(core::ptr::addr_of!((*header).generation) as *const AtomicU32))
|
||||
.load(Ordering::Acquire),
|
||||
(*header).target_id,
|
||||
(*header).version,
|
||||
)
|
||||
};
|
||||
match check_attach(
|
||||
@@ -518,14 +524,46 @@ impl FramePublisher {
|
||||
mismatch_logged: false,
|
||||
offered: 0,
|
||||
mismatch_drops: 0,
|
||||
telemetry: header_version >= VERSION_TELEMETRY,
|
||||
offered_total: 0,
|
||||
last_published: None,
|
||||
render_luid_low,
|
||||
render_luid_high,
|
||||
})
|
||||
}
|
||||
|
||||
/// v2 telemetry tail, drain side (stall attribution): stamp the heartbeat on EVERY drain-loop
|
||||
/// pass — and the last-acquire on a pass that actually acquired a composed frame — so the host
|
||||
/// can split a capture stall into "our worker starved" (heartbeat went stale) vs "the worker
|
||||
/// drained E_PENDING the whole hole — DWM composed nothing" (heartbeat fresh, last-acquire
|
||||
/// stale). Gated on the host's stamped header version (see the `telemetry` field docs);
|
||||
/// best-effort Relaxed stores, the `driver_status` visibility contract.
|
||||
pub fn note_drain(&self, acquired: bool) {
|
||||
if !self.telemetry {
|
||||
return;
|
||||
}
|
||||
let mut qpc = 0i64;
|
||||
// SAFETY: plain FFI; `qpc` is a valid local out-param. QPC cannot fail on any OS we load on.
|
||||
if unsafe { QueryPerformanceCounter(&mut qpc) }.is_err() {
|
||||
return;
|
||||
}
|
||||
// SAFETY: `self.header` stays mapped for the publisher's lifetime and the version gate above
|
||||
// proves the host built the v2 (88-byte) layout; both fields are naturally-aligned u64s
|
||||
// within it, valid for `AtomicU64` views (the same pattern as `latest_cell`).
|
||||
unsafe {
|
||||
(*(core::ptr::addr_of!((*self.header).drain_heartbeat_qpc) as *const AtomicU64))
|
||||
.store(qpc as u64, Ordering::Relaxed);
|
||||
if acquired {
|
||||
(*(core::ptr::addr_of!((*self.header).last_acquire_qpc) as *const AtomicU64))
|
||||
.store(qpc as u64, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirror the live diagnostic counters into the header's detail word (proto
|
||||
/// `pack_opened_detail`) — read by the host's first-frame timeout to name a no-frames failure.
|
||||
/// `pack_opened_detail`) — read by the host's first-frame timeout to name a no-frames failure —
|
||||
/// plus, on a telemetry-capable (v2) header, the full-width `offered_total` the host deltas
|
||||
/// across a stall window (the packed 15-bit counter saturates, so it can't be delta'd).
|
||||
#[inline]
|
||||
fn write_opened_detail(&self) {
|
||||
// SAFETY: `self.header` stays mapped for the publisher's lifetime (unmapped only in Drop);
|
||||
@@ -534,6 +572,14 @@ impl FramePublisher {
|
||||
(*self.header).driver_status_detail =
|
||||
pack_opened_detail(self.offered, self.mismatch_drops);
|
||||
}
|
||||
if self.telemetry {
|
||||
// SAFETY: the version gate proves the host built the v2 (88-byte) layout;
|
||||
// `offered_total` is a naturally-aligned u64 within it (the `latest_cell` pattern).
|
||||
unsafe {
|
||||
(*(core::ptr::addr_of!((*self.header).offered_total) as *const AtomicU64))
|
||||
.store(self.offered_total, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
@@ -624,6 +670,7 @@ impl FramePublisher {
|
||||
// header's detail word — what lets the host's first-frame timeout tell "DWM never composed"
|
||||
// from "every compose mismatched the ring". Written once per call, after the outcome is known.
|
||||
self.offered = self.offered.saturating_add(1);
|
||||
self.offered_total = self.offered_total.wrapping_add(1);
|
||||
if desc.Format.0 as u32 != self.ring_format || desc.Width != rw || desc.Height != rh {
|
||||
self.mismatch_drops = self.mismatch_drops.saturating_add(1);
|
||||
self.write_opened_detail();
|
||||
|
||||
@@ -485,20 +485,22 @@ pub fn set_cursor_channel(
|
||||
/// genuine teardown, not a flap: the entry was already removed) so the caller drops it, closing the ring
|
||||
/// handles. Replacing an already-stashed publisher (should not happen — one worker exits at a time)
|
||||
/// drops the old one, so it can never accumulate. Returning the publisher in the `Err` makes the
|
||||
/// `Result` itself `#[must_use]`, so a caller can't silently drop the not-preserved publisher.
|
||||
/// `Result` itself `#[must_use]`, so a caller can't silently drop the not-preserved publisher; it
|
||||
/// rides boxed (the struct outgrew clippy's 128-byte `result_large_err` bar with the v2 telemetry
|
||||
/// fields, and this path runs once per worker exit — the allocation is free).
|
||||
pub fn preserve_publisher(
|
||||
target_id: u32,
|
||||
publisher: crate::frame_transport::FramePublisher,
|
||||
) -> Result<(), crate::frame_transport::FramePublisher> {
|
||||
) -> Result<(), Box<crate::frame_transport::FramePublisher>> {
|
||||
if target_id == 0 {
|
||||
return Err(publisher);
|
||||
return Err(Box::new(publisher));
|
||||
}
|
||||
let mut lock = lock_monitors();
|
||||
if let Some(m) = lock.iter_mut().find(|m| m.target_id == target_id) {
|
||||
m.preserved_publisher = Some(publisher);
|
||||
Ok(())
|
||||
} else {
|
||||
Err(publisher)
|
||||
Err(Box::new(publisher))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -408,6 +408,15 @@ impl SwapChainProcessor {
|
||||
)
|
||||
};
|
||||
|
||||
// v2 telemetry (stall attribution): stamp the drain heartbeat — plus the last-acquire
|
||||
// on a pass that got a composed frame — into the shared header EVERY pass, E_PENDING
|
||||
// included (the wait below is ≤16 ms, so the heartbeat cadence bounds how stale it can
|
||||
// read while this thread is scheduled). What lets the host split a capture stall into
|
||||
// worker-starved / DWM-composed-nothing / our-delivery-leg.
|
||||
if let Some(p) = publisher.as_ref() {
|
||||
p.note_drain(hr_success(hr));
|
||||
}
|
||||
|
||||
if (hr as u32) == E_PENDING {
|
||||
if !logged_pending {
|
||||
dbglog!(
|
||||
|
||||
Reference in New Issue
Block a user