Exporting a real 20 MP CR2 failed every time with "readback did not complete", while the copy itself was perfectly healthy. The bound was 100,000 non-blocking polls. That sounds generous and is not: a `Poll` that finds nothing returns immediately, so the loop spent its entire budget in a few milliseconds. Small transfers — the histogram's 4 KB, a viewport-sized frame — happened to land inside it. An 80 MB frame never could. It is a deadline now, thirty seconds, which is the only thing the bound was ever for: catching a lost device that will never deliver the callback. A one-millisecond pause after the first sixty-four spins stops the loop saturating a core for the length of the copy, while keeping a small transfer as immediate as it was. Found by developing /home/dtourolle/Downloads/_MG_8596.CR2 through the export example: 5472×3648 renders in 127 ms and writes all five formats. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
95 lines
4.3 KiB
Rust
95 lines
4.3 KiB
Rust
//! Waiting for a buffer mapping without parking the interface.
|
||
//!
|
||
//! Shared by every transfer off the device — the display bridge, an export, and
|
||
//! the histogram's 4 KB of bin counts. It was written once inside `AdjustPass`
|
||
//! and the reasoning below is the whole of why it is shaped this way; copying
|
||
//! thirty lines of that reasoning into a second caller would have left two
|
||
//! copies to keep true of each other.
|
||
|
||
use crate::{GpuContext, GpuError};
|
||
|
||
/// How many non-blocking polls a readback gets before it is called failed.
|
||
///
|
||
/// A bound rather than a spin forever: if the device is lost the map callback
|
||
/// never arrives, and an unbounded loop would hang the interface rather than
|
||
/// surfacing the error. Set far above any plausible completion — the copies
|
||
/// this waits on are milliseconds — so it is reached only when something is
|
||
/// wrong.
|
||
/// How long a readback may take before it is called failed.
|
||
///
|
||
/// **A deadline, not an iteration count, and the difference was a real bug.**
|
||
/// This was 100,000 non-blocking polls, which sounds generous and is not: a
|
||
/// `Poll` that finds nothing returns immediately, so the loop burned through
|
||
/// the whole budget in a few milliseconds. Small transfers — the histogram's
|
||
/// 4 KB, a viewport-sized frame — happened to complete inside it. A
|
||
/// full-resolution export did not: 5472×3648 is 80 MB, and every attempt
|
||
/// failed with "readback did not complete" while the copy was still perfectly
|
||
/// healthy.
|
||
///
|
||
/// Generous, because the legitimate worst case is a large export on a slow
|
||
/// integrated GPU, and the only thing this bound exists to catch is a lost
|
||
/// device that will never deliver the callback at all.
|
||
const READBACK_DEADLINE: std::time::Duration = std::time::Duration::from_secs(30);
|
||
|
||
/// How long to wait between polls once the first few have found nothing.
|
||
///
|
||
/// Without it this is a busy spin that saturates a core for the duration of
|
||
/// the copy. A millisecond is far below the transfer times involved and keeps
|
||
/// the thread available to the scheduler.
|
||
const POLL_PAUSE: std::time::Duration = std::time::Duration::from_millis(1);
|
||
|
||
/// Drive the device until a `map_async` callback lands.
|
||
///
|
||
/// **Polled without blocking, then checked.**
|
||
///
|
||
/// `PollType::Wait` parks the calling thread until the GPU has finished, and
|
||
/// these transfers are called from the UI thread — so that park was a frozen
|
||
/// interface for the duration of the copy (~7 ms at 4K for a whole frame).
|
||
/// `Poll` drives the same callbacks without sleeping, so the loop below stays
|
||
/// interruptible and the mapping still completes.
|
||
///
|
||
/// The bounded spin matters: a lost device would otherwise never deliver the
|
||
/// callback and this would hang the app instead of reporting an error.
|
||
pub(crate) fn await_mapping(
|
||
ctx: &GpuContext,
|
||
rx: &std::sync::mpsc::Receiver<Result<(), wgpu::BufferAsyncError>>,
|
||
) -> Result<(), GpuError> {
|
||
let mut mapped = None;
|
||
let deadline = std::time::Instant::now() + READBACK_DEADLINE;
|
||
let mut spins = 0u32;
|
||
while std::time::Instant::now() < deadline {
|
||
// A poll error is a lost device, which is exactly the case the bounded
|
||
// spin exists to escape — returning here reports it immediately rather
|
||
// than spinning out the full limit first.
|
||
ctx.device
|
||
.poll(wgpu::PollType::Poll)
|
||
.map_err(|e| GpuError::Readback(e.to_string()))?;
|
||
match rx.try_recv() {
|
||
Ok(r) => {
|
||
mapped = Some(r);
|
||
break;
|
||
}
|
||
Err(std::sync::mpsc::TryRecvError::Empty) => {
|
||
// The first handful of polls run flat out, so a small
|
||
// transfer — the histogram's, most of all — still returns
|
||
// without ever sleeping. Only a copy that is genuinely going
|
||
// to take a while pays the pause.
|
||
spins += 1;
|
||
if spins > 64 {
|
||
std::thread::sleep(POLL_PAUSE);
|
||
}
|
||
continue;
|
||
}
|
||
Err(e) => return Err(GpuError::Readback(e.to_string())),
|
||
}
|
||
}
|
||
mapped
|
||
.ok_or_else(|| {
|
||
GpuError::Readback(format!(
|
||
"readback did not complete within {}s",
|
||
READBACK_DEADLINE.as_secs()
|
||
))
|
||
})?
|
||
.map_err(|e| GpuError::Readback(e.to_string()))
|
||
}
|