mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-07 12:13:10 +00:00
Build the GPU compositor off the UI thread (#9969)
* Prepare GPU compositor in background * Add compositor startup performance scenarios * Generate scorecard in scratch CI * Fix pipeline build type inference * Address compositor startup review and document measurements * Run startup performance checks on scratch CI * Remove scratch performance job * Count failed GPU attempts in frame timing * Make shader fingerprint test portable * Address review: run first-document scenario as a guarded group; share COMPOSITOR_BUILDING constant * Handle compositor startup benchmark failures * Fix frame timing after upstream merge * Keep GPU startup failures on CPU fallback * Record the failed GPU attempt of a deferred redraw, and bound --reps A redraw deferred after a failed GPU composite carries that attempt's time to its end, where it is recorded once beside the CPU time. Perf::record clears the attempt so a direct CPU refresh doesn't inherit the last one's. perf_scenarios caps --reps. * Refresh cached canvases after compositor build * Expose canvas cache key to GPU completion handler * ci: drop the GPU compositor probe job AGENTS.md: feature and fix PRs don't add or change CI, and app workflows only call the shared craft-common-actions workflows. The probe example stays as a manual tool (cargo run -p photocraft-gpu --example compositor_build_probe). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
FredApps
parent
c441fd350c
commit
5c157e8400
Generated
+1
@@ -5564,6 +5564,7 @@ name = "photocraft-ui-egui"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"chrono",
|
||||
"craft-print",
|
||||
"craft-ui",
|
||||
"craft-update",
|
||||
|
||||
@@ -542,6 +542,9 @@ fn services(inbox: Inbox, place_inbox: Inbox, cursor_pos: photocraft_ui_egui::Cu
|
||||
// Preferences live in the browser's localStorage.
|
||||
load_prefs: Some(Box::new(|| local_storage()?.get_item(PREFS_KEY).ok().flatten())),
|
||||
save_prefs: Some(Box::new(|text: &str| local_storage().ok_or("no localStorage")?.set_item(PREFS_KEY, text).map_err(|e| format!("{e:?}")))),
|
||||
load_gpu_memo: None,
|
||||
save_gpu_memo: None,
|
||||
shader_compiler: None,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -588,6 +588,7 @@ fn main() -> eframe::Result {
|
||||
&& app.session.prefs().performance.effective_rendering_mode() != photocraft_engine::prefs::RenderingMode::Cpu
|
||||
{
|
||||
app.set_wgpu(rs);
|
||||
app.start_compositor(&cc.egui_ctx);
|
||||
} else {
|
||||
// The window still draws with wgpu: record its errors instead of panicking,
|
||||
// and notice when the device is lost (#3438).
|
||||
|
||||
@@ -341,6 +341,41 @@ pub fn prefs_file() -> Option<PathBuf> {
|
||||
config_dir().map(|d| d.join("preferences.json"))
|
||||
}
|
||||
|
||||
fn gpu_memo_file() -> Option<PathBuf> {
|
||||
config_dir().map(|d| d.join("gpu-compositor.json"))
|
||||
}
|
||||
|
||||
fn load_gpu_memo_text() -> Option<String> {
|
||||
use std::io::Read;
|
||||
let file = std::fs::File::open(gpu_memo_file()?).ok()?;
|
||||
let cap = photocraft_ui_egui::gpu_memo::MAX_GPU_MEMO_BYTES;
|
||||
let mut bytes = Vec::new();
|
||||
file.take((cap + 1) as u64).read_to_end(&mut bytes).ok()?;
|
||||
if bytes.len() > cap {
|
||||
log::info!("ignoring oversized GPU compositor memo");
|
||||
return None;
|
||||
}
|
||||
String::from_utf8(bytes).ok()
|
||||
}
|
||||
|
||||
fn shader_compiler_identity() -> String {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let exe = std::env::current_exe().ok();
|
||||
let exe_dir = exe.as_deref().and_then(Path::parent);
|
||||
match crate::gpu_startup::dx12_compiler(std::env::var("WGPU_DX12_COMPILER").ok().as_deref(), exe_dir) {
|
||||
eframe::wgpu::Dx12Compiler::Fxc => "fxc".into(),
|
||||
eframe::wgpu::Dx12Compiler::DynamicDxc { dxc_path } => format!("dxc:{dxc_path}"),
|
||||
eframe::wgpu::Dx12Compiler::StaticDxc => "static-dxc".into(),
|
||||
eframe::wgpu::Dx12Compiler::Auto => "auto".into(),
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// The brush preset store (one file per preset group plus tip bitmaps; see
|
||||
/// `photocraft_engine::preset_store`).
|
||||
pub fn presets_dir() -> Option<PathBuf> {
|
||||
@@ -791,6 +826,14 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
|
||||
load_prefs: Some(Box::new(|| std::fs::read_to_string(prefs_file()?).ok())),
|
||||
photoshop_shortcuts: Some(Box::new(|| crate::photoshop_settings::live_keyboard_shortcuts(&|k| std::env::var_os(k)))),
|
||||
save_prefs: Some(Box::new(|text: &str| write_atomic(&prefs_file().ok_or("no config directory")?, text.as_bytes()))),
|
||||
load_gpu_memo: Some(Box::new(load_gpu_memo_text)),
|
||||
save_gpu_memo: Some(Box::new(|text: &str| {
|
||||
if text.len() > photocraft_ui_egui::gpu_memo::MAX_GPU_MEMO_BYTES {
|
||||
return Err("GPU compositor memo exceeds 64 KiB".into());
|
||||
}
|
||||
write_atomic(&gpu_memo_file().ok_or("no config directory")?, text.as_bytes())
|
||||
})),
|
||||
shader_compiler: Some(shader_compiler_identity()),
|
||||
system_theme: crate::appearance::service(),
|
||||
append_text: Some(Box::new(|path: &str, text: &str| {
|
||||
use std::io::Write;
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
//! Measure compositor pipeline construction and, optionally, device work during a background build.
|
||||
//!
|
||||
//! `cargo run --release -p photocraft-gpu --example compositor_build_probe -- [dx12|vulkan|metal|gl] [fxc|dxc] [rounds=3] [threads=N] [--background] [--json out.json]`
|
||||
//!
|
||||
//! The explicit thread count is passed to the dedicated Rayon build pool.
|
||||
|
||||
use photocraft_gpu::Compositor;
|
||||
use std::fs;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Sample {
|
||||
build_ms: f64,
|
||||
ok: bool,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let backend = args.first().map(String::as_str).unwrap_or("dx12");
|
||||
let compiler = args.get(1).map(String::as_str).unwrap_or("fxc");
|
||||
let rounds = args.iter().find_map(|a| a.strip_prefix("rounds=").and_then(|s| s.parse::<usize>().ok())).unwrap_or(3).max(1);
|
||||
let default_threads = std::thread::available_parallelism().map_or(1, |n| (n.get() / 2).max(1));
|
||||
let threads = args.iter().find_map(|a| a.strip_prefix("threads=").and_then(|s| s.parse::<usize>().ok())).unwrap_or(default_threads).max(1);
|
||||
let background = args.iter().any(|a| a == "--background");
|
||||
let json_path = args.iter().position(|a| a == "--json").and_then(|i| args.get(i + 1));
|
||||
let backends = match backend {
|
||||
"vulkan" => wgpu::Backends::VULKAN,
|
||||
"metal" => wgpu::Backends::METAL,
|
||||
"gl" => wgpu::Backends::GL,
|
||||
_ => wgpu::Backends::DX12,
|
||||
};
|
||||
let shader_compiler = match compiler {
|
||||
"dxc" => wgpu::Dx12Compiler::default_dynamic_dxc(),
|
||||
_ => wgpu::Dx12Compiler::Fxc,
|
||||
};
|
||||
let mut descriptor = wgpu::InstanceDescriptor::new_without_display_handle();
|
||||
descriptor.backends = backends;
|
||||
descriptor.flags = wgpu::InstanceFlags::empty();
|
||||
descriptor.backend_options.dx12.shader_compiler = shader_compiler;
|
||||
let instance = wgpu::Instance::new(descriptor);
|
||||
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { force_fallback_adapter: true, ..Default::default() }))?;
|
||||
let info = adapter.get_info();
|
||||
println!("adapter={} backend={:?} driver={} {} threads={threads}", info.name, info.backend, info.driver, info.driver_info);
|
||||
|
||||
let mut samples = Vec::with_capacity(rounds);
|
||||
let mut stall = Vec::new();
|
||||
let mut cpu_idle_ms = None;
|
||||
let mut cpu_during_ms = None;
|
||||
for round in 0..rounds {
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default()))?;
|
||||
let format = Compositor::preferred_acc_format(&adapter);
|
||||
if background {
|
||||
if round == 0 {
|
||||
let doc = photocraft_doc::Document::with_background(
|
||||
"compositor build probe",
|
||||
photocraft_geom::Size::new(1920, 1080),
|
||||
photocraft_color::ColorMode::Rgb,
|
||||
photocraft_color::SampleType::U8,
|
||||
photocraft_color::Color::WHITE,
|
||||
);
|
||||
let start = Instant::now();
|
||||
let _ = photocraft_compose::render(&doc, doc.bounds());
|
||||
cpu_idle_ms = Some(start.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
let (build_tx, build_rx) = std::sync::mpsc::channel();
|
||||
let start = Instant::now();
|
||||
std::thread::scope(|scope| {
|
||||
scope.spawn(|| {
|
||||
let build_start = Instant::now();
|
||||
let result = Compositor::try_new_with_format_threads(&device, format, threads);
|
||||
let elapsed = build_start.elapsed().as_secs_f64() * 1000.0;
|
||||
let error = result.err().map(|e| e.to_string());
|
||||
let _ = build_tx.send((error, elapsed));
|
||||
});
|
||||
let target = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("compositor probe UI target"),
|
||||
size: wgpu::Extent3d { width: 256, height: 256, depth_or_array_layers: 1 },
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = target.create_view(&Default::default());
|
||||
let mut last_cpu = start;
|
||||
loop {
|
||||
match build_rx.try_recv() {
|
||||
Ok((error, build_ms)) => {
|
||||
samples.push(Sample { build_ms, ok: error.is_none(), error });
|
||||
break;
|
||||
}
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||||
samples.push(Sample { build_ms: start.elapsed().as_secs_f64() * 1000.0, ok: false, error: Some("build thread terminated".into()) });
|
||||
break;
|
||||
}
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => (),
|
||||
}
|
||||
let tick = Instant::now();
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("probe frame"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), store: wgpu::StoreOp::Store },
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
drop(_pass);
|
||||
queue.submit([encoder.finish()]);
|
||||
let _ = device.poll(wgpu::PollType::Poll);
|
||||
stall.push(tick.elapsed().as_secs_f64() * 1000.0);
|
||||
if round == 0 && cpu_during_ms.is_none() && last_cpu.elapsed() >= Duration::from_millis(100) {
|
||||
let doc = photocraft_doc::Document::with_background(
|
||||
"compositor build probe",
|
||||
photocraft_geom::Size::new(1920, 1080),
|
||||
photocraft_color::ColorMode::Rgb,
|
||||
photocraft_color::SampleType::U8,
|
||||
photocraft_color::Color::WHITE,
|
||||
);
|
||||
let cpu_start = Instant::now();
|
||||
let _ = photocraft_compose::render(&doc, doc.bounds());
|
||||
cpu_during_ms = Some(cpu_start.elapsed().as_secs_f64() * 1000.0);
|
||||
last_cpu = Instant::now();
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(16));
|
||||
}
|
||||
});
|
||||
} else {
|
||||
let start = Instant::now();
|
||||
let result = Compositor::try_new_with_format_threads(&device, format, threads);
|
||||
let build_ms = start.elapsed().as_secs_f64() * 1000.0;
|
||||
let error = result.err().map(|e| e.to_string());
|
||||
samples.push(Sample { build_ms, ok: error.is_none(), error });
|
||||
}
|
||||
let s = samples.last().ok_or("missing compositor build sample")?;
|
||||
println!("round={round} build_ms={:.3} ok={} error={}", s.build_ms, s.ok, s.error.as_deref().unwrap_or(""));
|
||||
}
|
||||
|
||||
stall.sort_by(f64::total_cmp);
|
||||
let p95 = stall.get((stall.len() * 95 / 100).min(stall.len().saturating_sub(1))).copied().unwrap_or(0.0);
|
||||
let max = stall.last().copied().unwrap_or(0.0);
|
||||
println!("ui_stall_p95_ms={p95:.3} ui_stall_max_ms={max:.3} cpu_idle_ms={cpu_idle_ms:?} cpu_during_ms={cpu_during_ms:?}");
|
||||
if let Some(path) = json_path {
|
||||
let rounds_json = samples
|
||||
.iter()
|
||||
.map(|s| {
|
||||
format!("{{\"build_ms\":{:.3},\"ok\":{},\"error\":{}}}", s.build_ms, s.ok, s.error.as_deref().map(json_string).unwrap_or_else(|| "null".into()))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let json = format!(
|
||||
"{{\"backend\":{},\"compiler\":{},\"adapter\":{},\"threads\":{},\"rounds\":[{}],\"stall\":{{\"p95_ms\":{p95:.3},\"max_ms\":{max:.3}}},\"cpu_composite_ms\":{{\"idle\":{},\"during_build\":{}}}}}",
|
||||
json_string(backend),
|
||||
json_string(compiler),
|
||||
json_string(&info.name),
|
||||
threads,
|
||||
rounds_json,
|
||||
optional_number(cpu_idle_ms),
|
||||
optional_number(cpu_during_ms)
|
||||
);
|
||||
fs::write(path, json)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn json_string(s: &str) -> String {
|
||||
let mut out = String::from("\"");
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'"' => out.push_str("\\\""),
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
c if c.is_control() => out.push_str(&format!("\\u{:04x}", u32::from(c))),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
out
|
||||
}
|
||||
|
||||
fn optional_number(n: Option<f64>) -> String {
|
||||
n.map(|v| format!("{v:.3}")).unwrap_or_else(|| "null".into())
|
||||
}
|
||||
@@ -141,6 +141,7 @@ mod paging;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod readback;
|
||||
mod setup;
|
||||
pub use setup::{BuildFailure, BuildFailureKind};
|
||||
mod textures;
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -167,5 +168,20 @@ use textures::TexKind;
|
||||
use textures::{CachedPattern, FxEntry, FxKey, ResKey, Resident, Tex};
|
||||
pub use textures::{ChunkOut, Stats};
|
||||
|
||||
/// Stable fingerprint of the compositor WGSL source, for invalidating cached build failures.
|
||||
/// Uses 64-bit FNV-1a over the exact embedded source bytes.
|
||||
pub fn shader_fingerprint() -> u64 {
|
||||
fnv1a(SHADER.as_bytes())
|
||||
}
|
||||
|
||||
fn fnv1a(bytes: &[u8]) -> u64 {
|
||||
let mut hash = 0xcbf29ce484222325_u64;
|
||||
for byte in bytes {
|
||||
hash ^= u64::from(*byte);
|
||||
hash = hash.wrapping_mul(0x00000100000001b3);
|
||||
}
|
||||
hash
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
+89
-5
@@ -7,6 +7,40 @@ use super::{
|
||||
Compositor, DeviceHealth, DocId, Document, Fault, HashMap, Kernel, LayerContent, Plan, Rect, Role, TILE_SIZE, Unsupported, fx, plan, prefix, timing,
|
||||
};
|
||||
|
||||
/// Classification of a synchronous compositor pipeline build failure.
|
||||
///
|
||||
/// Only a recognized shader compiler diagnostic is considered deterministic. Every other
|
||||
/// error, including generic validation, internal, and out-of-memory errors, is conservative
|
||||
/// and must be treated as transient or unknown by callers.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum BuildFailureKind {
|
||||
/// A known compiler diagnostic means the same shader is expected to fail on relaunch.
|
||||
DeterministicShaderCompilation,
|
||||
/// The failure may depend on device state, resource pressure, or driver behavior.
|
||||
TransientOrUnknown,
|
||||
}
|
||||
|
||||
/// A pipeline build error with a conservative retry classification.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct BuildFailure {
|
||||
pub kind: BuildFailureKind,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for BuildFailure {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for BuildFailure {}
|
||||
|
||||
fn classify_build_diagnostic(diagnostic: &str) -> BuildFailureKind {
|
||||
// FXC's X3507 diagnostic is a concrete shader compilation failure observed when
|
||||
// compiling generated compositor shaders. Do not classify generic validation text.
|
||||
if diagnostic.contains("X3507") { BuildFailureKind::DeterministicShaderCompilation } else { BuildFailureKind::TransientOrUnknown }
|
||||
}
|
||||
|
||||
impl Compositor {
|
||||
/// Create a compositor with the default accumulation format ([`ACC_FORMAT`]).
|
||||
pub fn new(device: &wgpu::Device) -> Self {
|
||||
@@ -26,17 +60,53 @@ impl Compositor {
|
||||
/// to the CPU compositor. Where errors only arrive asynchronously (WebGPU), creation is assumed
|
||||
/// to have worked.
|
||||
pub fn try_new_with_format(device: &wgpu::Device, acc_format: wgpu::TextureFormat) -> Result<Self, Unsupported> {
|
||||
match first_error(device, || Self::build(device, acc_format)) {
|
||||
match first_error(device, || Self::build(device, acc_format, false)) {
|
||||
((comp, None), None) => Ok(comp),
|
||||
((_, Some(e)), _) | (_, Some(e)) => Err(Unsupported(format!("couldn't build the compositor's pipelines: {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build pipelines using a dedicated Rayon pool instead of the process-wide pool.
|
||||
///
|
||||
/// This keeps shader compilation from occupying workers needed by CPU compositing.
|
||||
/// If the pool cannot be created, pipeline enumeration runs sequentially on the calling
|
||||
/// thread. The outer and per-pipeline wgpu error scopes are kept on their respective
|
||||
/// build threads.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn try_new_with_format_threads(device: &wgpu::Device, acc_format: wgpu::TextureFormat, threads: usize) -> Result<Self, Unsupported> {
|
||||
Self::try_new_with_format_threads_classified(device, acc_format, threads).map_err(|e| Unsupported(e.message))
|
||||
}
|
||||
|
||||
/// Build the compositor pipelines with an explicit failure classification for callers
|
||||
/// that cache deterministic failures across launches. Only recognized shader compiler
|
||||
/// diagnostics are marked deterministic; all other wgpu errors are transient or unknown.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn try_new_with_format_threads_classified(device: &wgpu::Device, acc_format: wgpu::TextureFormat, threads: usize) -> Result<Self, BuildFailure> {
|
||||
let pool = rayon::ThreadPoolBuilder::new().num_threads(threads.max(1)).thread_name(|index| format!("photocraft-gpu-build-{index}")).build();
|
||||
let built = match pool {
|
||||
Ok(pool) => pool.install(|| first_error(device, || Self::build(device, acc_format, false))),
|
||||
Err(e) => {
|
||||
log::warn!("GPU compositor: couldn't create dedicated build pool; building sequentially: {e}");
|
||||
first_error(device, || Self::build(device, acc_format, true))
|
||||
}
|
||||
};
|
||||
match built {
|
||||
((comp, None), None) => Ok(comp),
|
||||
((_, Some(e)), None) => {
|
||||
let kind = classify_build_diagnostic(&e.to_string());
|
||||
Err(BuildFailure { kind, message: format!("couldn't build the compositor's pipelines: {e}") })
|
||||
}
|
||||
(_, Some(e)) => {
|
||||
Err(BuildFailure { kind: BuildFailureKind::TransientOrUnknown, message: format!("couldn't build the compositor's pipelines: {e}") })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a compositor whose accumulation/render-target format is `acc_format`. Pass
|
||||
/// `Rgba16Float` on adapters that can't render to `Rgba32Float` (e.g. some Intel Vulkan drivers),
|
||||
/// which otherwise panics at render-pipeline creation. See `Compositor::preferred_acc_format`.
|
||||
pub fn new_with_format(device: &wgpu::Device, acc_format: wgpu::TextureFormat) -> Self {
|
||||
let (comp, error) = Self::build(device, acc_format);
|
||||
let (comp, error) = Self::build(device, acc_format, false);
|
||||
if let Some(e) = error {
|
||||
log::error!("GPU compositor: couldn't build its pipelines: {e}");
|
||||
}
|
||||
@@ -54,7 +124,7 @@ impl Compositor {
|
||||
/// while the cache still held it, and dropping the device then aborted the process. So each
|
||||
/// job has its own vertex entry point and fragment shaders ([`build_jobs`]), and builds of
|
||||
/// several compositors take turns.
|
||||
fn build(device: &wgpu::Device, acc_format: wgpu::TextureFormat) -> (Self, Option<wgpu::Error>) {
|
||||
fn build(device: &wgpu::Device, acc_format: wgpu::TextureFormat, sequential: bool) -> (Self, Option<wgpu::Error>) {
|
||||
static BUILDING: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
let _building = BUILDING.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("pc_compose"), source: wgpu::ShaderSource::Wgsl(SHADER.into()) });
|
||||
@@ -98,7 +168,7 @@ impl Compositor {
|
||||
};
|
||||
// Effect maps render to R32Float / R16Float, which some adapters (e.g. GL without float
|
||||
// render targets) can't: then layer effects use the CPU compositor and the rest stays here.
|
||||
let built = fx::par_map(jobs, |job| {
|
||||
let build_one = |job: super::kit::BuildJob| {
|
||||
job.keys
|
||||
.into_iter()
|
||||
.map(|key| {
|
||||
@@ -106,7 +176,8 @@ impl Compositor {
|
||||
(key, built, error)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
};
|
||||
let built: Vec<_> = if sequential { jobs.into_iter().map(build_one).collect() } else { fx::par_map(jobs, build_one) };
|
||||
let built = built.into_iter().flatten();
|
||||
let (mut pipelines, mut maps) = (HashMap::new(), Vec::new());
|
||||
let (mut error, mut map_error) = (None, None);
|
||||
@@ -548,3 +619,16 @@ impl Compositor {
|
||||
self.timer.as_mut().and_then(timing::GpuTimer::take)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod build_failure_tests {
|
||||
use super::{BuildFailureKind, classify_build_diagnostic};
|
||||
|
||||
#[test]
|
||||
fn only_concrete_shader_compiler_diagnostic_is_deterministic() {
|
||||
assert_eq!(classify_build_diagnostic("error X3507: loop control variable must be integer"), BuildFailureKind::DeterministicShaderCompilation);
|
||||
assert_eq!(classify_build_diagnostic("Validation Error: invalid bind group"), BuildFailureKind::TransientOrUnknown);
|
||||
assert_eq!(classify_build_diagnostic("out of memory"), BuildFailureKind::TransientOrUnknown);
|
||||
assert_eq!(classify_build_diagnostic("shader compilation failed"), BuildFailureKind::TransientOrUnknown);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,3 +426,9 @@ fn colour_managed_tiles_match_the_per_pixel_conversion() {
|
||||
fn grid_rounds_out() {
|
||||
assert_eq!(tile_grid(Rect::new(0, 0, 300, 256)), Rect::new(0, 0, 512, 256));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shader_fingerprint_is_fnv1a_of_embedded_source() {
|
||||
assert_eq!(super::fnv1a(b"hello"), 0xa430_d846_80aa_bd0b);
|
||||
assert_eq!(super::shader_fingerprint(), super::fnv1a(super::SHADER.as_bytes()));
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ license.workspace = true
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
chrono = { workspace = true }
|
||||
craft-print = { workspace = true }
|
||||
craft-ui = { workspace = true }
|
||||
photocraft-geom = { workspace = true }
|
||||
|
||||
@@ -322,9 +322,13 @@ impl Bench {
|
||||
|
||||
/// Reports a row. `splits` (one per sample, or none) adds the command / refresh split.
|
||||
fn record(&mut self, name: &str, s: &Session, samples: &[f64], splits: &[Split], result: Res<()>) {
|
||||
self.record_with_gpu_bytes(name, samples, splits, result, self.gpu_bytes(s));
|
||||
}
|
||||
|
||||
fn record_with_gpu_bytes(&mut self, name: &str, samples: &[f64], splits: &[Split], result: Res<()>, gpu_bytes: Option<u64>) {
|
||||
match result {
|
||||
Ok(()) => {
|
||||
let mut r = row(name, samples, self.sampler.peak(), self.gpu_bytes(s));
|
||||
let mut r = row(name, samples, self.sampler.peak(), gpu_bytes);
|
||||
let mut split = String::new();
|
||||
if !splits.is_empty() && splits.len() == samples.len() {
|
||||
let cmd: Vec<f64> = splits.iter().map(|x| x.cmd_ms).collect();
|
||||
@@ -404,8 +408,13 @@ struct Sizes {
|
||||
/// A huge document with one painted layer, refreshed zoomed out (#1015, #9330). The row name
|
||||
/// leaves out the size because `--quick` uses a smaller one.
|
||||
huge_doc: (u32, u32),
|
||||
/// The new document whose compositor builds in the background.
|
||||
first_document: (u32, u32),
|
||||
}
|
||||
|
||||
/// The most `--reps` a run takes: each sample is kept, and a mistyped count must not allocate for them all.
|
||||
const MAX_REPS: usize = 10_000;
|
||||
|
||||
const FULL: Sizes = Sizes {
|
||||
layered: (5472, 3648),
|
||||
big_layer: (4243, 2828),
|
||||
@@ -418,6 +427,7 @@ const FULL: Sizes = Sizes {
|
||||
a4: (2480, 3508),
|
||||
huge_new: (78_740, 39_370),
|
||||
huge_doc: (30_000, 30_000),
|
||||
first_document: (1920, 1080),
|
||||
};
|
||||
|
||||
const QUICK: Sizes = Sizes {
|
||||
@@ -432,6 +442,7 @@ const QUICK: Sizes = Sizes {
|
||||
a4: (1240, 1754),
|
||||
huge_new: (20_000, 10_000),
|
||||
huge_doc: (8_000, 8_000),
|
||||
first_document: (640, 480),
|
||||
};
|
||||
|
||||
/// The layered document of #209: a background, six pixel layers with a drop shadow and a stroke,
|
||||
@@ -498,7 +509,7 @@ fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let quick = args.iter().any(|a| a == "--quick");
|
||||
let sz = if quick { &QUICK } else { &FULL };
|
||||
let reps: usize = arg(&args, "--reps").and_then(|v| v.parse().ok()).unwrap_or(if quick { 7 } else { 15 }).max(1);
|
||||
let reps: usize = arg(&args, "--reps").and_then(|v| v.parse().ok()).unwrap_or(if quick { 7 } else { 15 }).clamp(1, MAX_REPS);
|
||||
let gpu = if args.iter().any(|a| a == "--cpu") { None } else { canvas() };
|
||||
let no_reuse = args.iter().any(|a| a == "--no-composite-reuse");
|
||||
if no_reuse && let Some((canvas, _)) = &gpu {
|
||||
@@ -526,7 +537,7 @@ fn main() {
|
||||
// A panic in one group (a crash the scenario found) is reported against the row that was
|
||||
// running, and the other groups still run.
|
||||
type Group = fn(&mut Bench, &Sizes);
|
||||
let groups: [(&str, Group); 8] = [
|
||||
let groups: [(&str, Group); 9] = [
|
||||
("layered", layered_scenarios),
|
||||
("many layers", many_layer_scenarios),
|
||||
("full-canvas layers", full_canvas_layer_scenarios),
|
||||
@@ -534,6 +545,7 @@ fn main() {
|
||||
("long operations", job_scenarios),
|
||||
("algorithms", algorithm_scenarios),
|
||||
("huge document", huge_document_scenarios),
|
||||
("first document", first_document_scenarios),
|
||||
("redraw area", redraw_area_scenarios),
|
||||
];
|
||||
let use_gpu = b.gpu.is_some();
|
||||
@@ -584,6 +596,79 @@ fn main() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Startup costs when a new canvas builds its compositor in the background, as the app does.
|
||||
fn first_document_scenarios(b: &mut Bench, sz: &Sizes) {
|
||||
let (w, h) = sz.first_document;
|
||||
const FIRST: &str = "first document: UI-thread time to first pixels";
|
||||
const READY: &str = "first document: GPU compositor ready";
|
||||
let Some((_, rs)) = b.gpu.as_ref() else {
|
||||
println!("GPU compositor startup scenarios skipped: no GPU canvas");
|
||||
return;
|
||||
};
|
||||
let rs = rs.clone();
|
||||
if rs.adapter.get_info().device_type == eframe::wgpu::DeviceType::Cpu {
|
||||
println!("GPU compositor startup scenarios skipped: software adapter");
|
||||
return;
|
||||
}
|
||||
for name in [FIRST, READY] {
|
||||
if !b.wanted(name) {
|
||||
continue;
|
||||
}
|
||||
b.current = Some(name.into());
|
||||
b.sampler.reset();
|
||||
let mut samples = Vec::with_capacity(b.reps);
|
||||
let mut failure = None;
|
||||
for _ in 0..b.reps {
|
||||
let canvas = GpuCanvas::new(&rs);
|
||||
let ctx = egui::Context::default();
|
||||
let started = Instant::now();
|
||||
canvas.start_compositor(&ctx);
|
||||
let mut session = Session::new();
|
||||
if let Err(e) = exec(&mut session, "file.new", json!({"width": w, "height": h, "background": "white"})) {
|
||||
failure = Some(e);
|
||||
break;
|
||||
}
|
||||
let first_ms = if name == FIRST {
|
||||
let Some(doc) = session.active().map(|d| &d.doc) else {
|
||||
failure = Some("file.new did not create a document".into());
|
||||
break;
|
||||
};
|
||||
canvas.refresh(doc.id.0, doc, None, None);
|
||||
Some(started.elapsed().as_secs_f64() * 1000.0)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Wait before starting the next sample so builds cannot overlap or replace resources.
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
|
||||
loop {
|
||||
if let Some(result) = canvas.take_compositor_completion() {
|
||||
if let Err((message, _)) = result {
|
||||
failure = Some(format!("GPU compositor build failed: {message}"));
|
||||
}
|
||||
break;
|
||||
}
|
||||
if std::time::Instant::now() >= deadline {
|
||||
failure = Some("timed out waiting for GPU compositor build".into());
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
}
|
||||
if failure.is_some() {
|
||||
break;
|
||||
}
|
||||
samples.push(first_ms.unwrap_or_else(|| started.elapsed().as_secs_f64() * 1000.0));
|
||||
}
|
||||
if let Some(error) = failure {
|
||||
println!("{name:<52} ERROR {error}");
|
||||
b.errors.push((name.into(), error));
|
||||
break;
|
||||
} else {
|
||||
// Each sample owns a separate canvas that has already been dropped.
|
||||
b.record_with_gpu_bytes(name, &samples, &[], Ok(()), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #209 rows on the layered 20 MP document.
|
||||
fn layered_scenarios(b: &mut Bench, sz: &Sizes) {
|
||||
let (w, h) = sz.layered;
|
||||
|
||||
@@ -33,6 +33,80 @@ impl PhotocraftApp {
|
||||
self.prefs_rt.gpu_style = None;
|
||||
}
|
||||
|
||||
/// Begin preparing the compositor after the GPU canvas is initialized.
|
||||
pub fn start_compositor(&mut self, ctx: &egui::Context) {
|
||||
self.start_compositor_inner(ctx, false);
|
||||
}
|
||||
|
||||
pub(crate) fn retry_compositor(&mut self, ctx: &egui::Context) {
|
||||
self.start_compositor_inner(ctx, true);
|
||||
}
|
||||
|
||||
fn start_compositor_inner(&mut self, ctx: &egui::Context, retry: bool) {
|
||||
if self.session.prefs().performance.effective_rendering_mode() == photocraft_engine::prefs::RenderingMode::Cpu {
|
||||
return;
|
||||
}
|
||||
let Some(gpu) = self.gpu.clone() else { return };
|
||||
let compiler = self.services.shader_compiler.as_deref().unwrap_or("");
|
||||
let key = gpu.compositor_memo_key(compiler);
|
||||
let mut memo = self.load_gpu_memo();
|
||||
let bypass = retry || std::env::var("PHOTOCRAFT_GPU_REBUILD").is_ok_and(|v| v == "1");
|
||||
if bypass {
|
||||
if memo.remove(&key) {
|
||||
self.save_gpu_memo(&memo);
|
||||
}
|
||||
} else if let Some(entry) = memo.get(&key) {
|
||||
gpu.remember_compositor_failure(&entry.error);
|
||||
return;
|
||||
}
|
||||
gpu.start_compositor(ctx);
|
||||
}
|
||||
|
||||
fn load_gpu_memo(&mut self) -> crate::gpu_memo::GpuMemo {
|
||||
self.services
|
||||
.load_gpu_memo
|
||||
.as_mut()
|
||||
.and_then(|load| load())
|
||||
.and_then(|text| crate::gpu_memo::GpuMemo::parse(&text).map_err(|e| log::info!("ignoring GPU compositor memo: {e}")).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn save_gpu_memo(&mut self, memo: &crate::gpu_memo::GpuMemo) {
|
||||
if let (Ok(text), Some(save)) = (memo.serialize(), self.services.save_gpu_memo.as_mut())
|
||||
&& let Err(error) = save(&text)
|
||||
{
|
||||
log::info!("could not save GPU compositor memo: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
fn record_compositor_completion(&mut self) {
|
||||
let Some(gpu) = self.gpu.clone() else { return };
|
||||
let Some(result) = gpu.take_compositor_completion() else { return };
|
||||
// A completed CPU redraw can leave a current texture and a temporary build reason.
|
||||
// Recompute those documents on their next repaint, including any hidden documents.
|
||||
for id in self.render_path.compositor_building_docs() {
|
||||
if let Some(doc) = self.session.documents().iter().find(|st| st.doc.id.0 == id).map(|st| st.doc.id) {
|
||||
crate::canvas::background_redraw::cancel(self, doc);
|
||||
self.canvases.remove(&(doc, crate::canvas::GPU_OUTPUT));
|
||||
}
|
||||
}
|
||||
let compiler = self.services.shader_compiler.as_deref().unwrap_or("");
|
||||
let key = gpu.compositor_memo_key(compiler);
|
||||
let mut memo = self.load_gpu_memo();
|
||||
let changed = match result {
|
||||
Ok(()) => memo.remove(&key),
|
||||
Err((error, true)) if gpu.fault().is_none() => {
|
||||
let now: chrono::DateTime<chrono::Utc> = std::time::SystemTime::now().into();
|
||||
memo.remember(key, error, now.to_rfc3339());
|
||||
true
|
||||
}
|
||||
Err(_) => false,
|
||||
};
|
||||
if changed {
|
||||
self.save_gpu_memo(&memo);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the document canvas currently draws on the GPU (false on the CPU path, and after
|
||||
/// the device was lost).
|
||||
pub fn gpu_active(&self) -> bool {
|
||||
@@ -72,5 +146,6 @@ impl PhotocraftApp {
|
||||
/// the CPU canvas after a device loss and runs the started hook.
|
||||
pub fn check_gpu(&mut self, ctx: &egui::Context) {
|
||||
gpu_status::check(self, ctx);
|
||||
self.record_compositor_completion();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,6 @@ pub mod zoom_window_fit;
|
||||
pub(crate) use cursors::eyedropper_ring_colors;
|
||||
pub(crate) use cursors::{alt_picks, alt_samples, composite_color, paint_bucket_cursor, pen_cursor, pencil_cursor_rect, pencil_tip_is_round, pipette_cursor};
|
||||
use cursors::{brush_tip_centre, eyedropper_ring, precise_other_cursor, visible_crosshair};
|
||||
#[cfg(test)]
|
||||
pub(crate) use display::GPU_OUTPUT;
|
||||
use display::texture_key;
|
||||
pub(crate) use display::{cache_key, canvas_display, display_doc, display_image};
|
||||
|
||||
@@ -111,6 +111,9 @@ pub(crate) struct Redraw {
|
||||
stop: Arc<AtomicBool>,
|
||||
started_ms: f64,
|
||||
fallback: Option<crate::render_path::CpuReason>,
|
||||
/// Time spent on the failed GPU composite attempts that led to this redraw (ms), recorded once
|
||||
/// when it ends.
|
||||
gpu_attempt_ms: f64,
|
||||
/// The state the blocks are drawn from, and what the texels depend on besides it.
|
||||
doc: Arc<Document>,
|
||||
display: u64,
|
||||
@@ -286,6 +289,7 @@ pub(crate) fn start(
|
||||
visible: Rect,
|
||||
region: Rect,
|
||||
fallback: Option<crate::render_path::CpuReason>,
|
||||
gpu_attempt_ms: f64,
|
||||
) {
|
||||
let all = blocks(doc.bounds());
|
||||
let display_key = display.as_deref().map_or(0, |d| d.texture_key);
|
||||
@@ -293,15 +297,21 @@ pub(crate) fn start(
|
||||
// Taken again when a running redraw continues: the edit (or Memory Usage) may have changed it.
|
||||
let cap = keep_cap(app, doc.id);
|
||||
app.kept.trim(cap);
|
||||
let (mut pending, since, fallback, mut keep) = match app.redraws.remove(&doc.id) {
|
||||
let (mut pending, since, fallback, gpu_attempt_ms, mut keep) = match app.redraws.remove(&doc.id) {
|
||||
Some(mut old) if old.blocks == all => {
|
||||
// Blocks drawn from another state aren't part of this one's composite.
|
||||
let same = old.display == display_key && old.text_gamma == gamma && (Arc::ptr_eq(&old.doc, &doc) || *old.doc == *doc);
|
||||
let (collected, bytes) =
|
||||
if same && old.collected_bytes <= cap { (std::mem::take(&mut old.collected), old.collected_bytes) } else { (vec![None; all.len()], 0) };
|
||||
(std::mem::take(&mut old.pending), (old.started_ms, old.drawn), fallback.or_else(|| old.fallback.take()), (collected, bytes, cap))
|
||||
(
|
||||
std::mem::take(&mut old.pending),
|
||||
(old.started_ms, old.drawn),
|
||||
fallback.or_else(|| old.fallback.take()),
|
||||
gpu_attempt_ms + old.gpu_attempt_ms,
|
||||
(collected, bytes, cap),
|
||||
)
|
||||
}
|
||||
_ => (vec![false; all.len()], (crate::gpu_canvas::now_ms(), 0), fallback, (vec![None; all.len()], 0, cap)),
|
||||
_ => (vec![false; all.len()], (crate::gpu_canvas::now_ms(), 0), fallback, gpu_attempt_ms, (vec![None; all.len()], 0, cap)),
|
||||
};
|
||||
for (p, b) in pending.iter_mut().zip(&all) {
|
||||
*p |= !b.intersect(®ion).is_empty();
|
||||
@@ -328,14 +338,14 @@ pub(crate) fn start(
|
||||
}
|
||||
keep = (vec![None; all.len()], 0, keep.2);
|
||||
}
|
||||
spawn(app, ctx, Spawn { doc, display, display_key, text_gamma: gamma, format, visible, blocks: all, pending, since, fallback, keep });
|
||||
spawn(app, ctx, Spawn { doc, display, display_key, text_gamma: gamma, format, visible, blocks: all, pending, since, fallback, gpu_attempt_ms, keep });
|
||||
}
|
||||
|
||||
/// After an edit drew its own damage: continue document `doc.id`'s redraw, if one is running, on
|
||||
/// the edited document, so blocks still to come don't bring back what was there before it.
|
||||
pub(crate) fn restart(app: &mut PhotocraftApp, ctx: &egui::Context, gpu: &GpuCanvas, doc: Arc<Document>, display: Option<Arc<CanvasDisplay>>, visible: Rect) {
|
||||
if app.redraws.contains_key(&doc.id) {
|
||||
start(app, ctx, gpu, doc, display, visible, Rect::EMPTY, None);
|
||||
start(app, ctx, gpu, doc, display, visible, Rect::EMPTY, None, 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,6 +394,7 @@ pub(crate) fn poll(app: &mut PhotocraftApp, gpu: &GpuCanvas, doc: DocId) {
|
||||
}
|
||||
let ms = crate::gpu_canvas::now_ms() - r.started_ms;
|
||||
app.perf.record("background", r.drawn, ms, 0.0);
|
||||
app.perf.record_gpu_attempt(r.gpu_attempt_ms);
|
||||
crate::notices::slow_cpu_refresh(app, doc.0, ms, r.fallback.as_ref());
|
||||
}
|
||||
|
||||
@@ -455,6 +466,7 @@ struct Spawn {
|
||||
/// When it started and the pixels uploaded so far.
|
||||
since: (f64, u64),
|
||||
fallback: Option<crate::render_path::CpuReason>,
|
||||
gpu_attempt_ms: f64,
|
||||
/// Blocks collected for [`Kept`], their bytes, and the cap.
|
||||
keep: (Vec<Option<Vec<u8>>>, u64, u64),
|
||||
}
|
||||
@@ -471,6 +483,7 @@ fn spawn(app: &mut PhotocraftApp, ctx: &egui::Context, s: Spawn) {
|
||||
pending,
|
||||
since: (started_ms, drawn),
|
||||
fallback,
|
||||
gpu_attempt_ms,
|
||||
keep: (collected, collected_bytes, cap),
|
||||
} = s;
|
||||
let id = doc.id;
|
||||
@@ -512,6 +525,7 @@ fn spawn(app: &mut PhotocraftApp, ctx: &egui::Context, s: Spawn) {
|
||||
stop,
|
||||
started_ms,
|
||||
fallback,
|
||||
gpu_attempt_ms,
|
||||
doc: kept_doc,
|
||||
display: display_key,
|
||||
text_gamma,
|
||||
|
||||
@@ -599,6 +599,34 @@ fn a_compositor_build_failure_shows_its_reason() {
|
||||
assert!(g.harness.state().perf.gpu_fallback.as_deref().is_some_and(|f| f.contains("X3507")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_compositor_build_refreshes_an_unchanged_cached_document() {
|
||||
let _gpu = photocraft_testkit::gpu_lock();
|
||||
for success in [true, false] {
|
||||
let Some(mut g) = gpu_app() else { return };
|
||||
let gpu = g.harness.state().gpu.as_ref().expect("GPU canvas").clone();
|
||||
gpu.simulate_compositor_build();
|
||||
g.harness.state_mut().run("file.new", json!({"width": 400, "height": 300, "background": "#336699"})).expect("new");
|
||||
g.harness.step();
|
||||
settle(&mut g);
|
||||
let doc = g.harness.state().session.active().expect("document").doc.id;
|
||||
let revision = g.harness.state().session.active().expect("document").revision;
|
||||
assert_eq!(render_path_of(&g).0, Some("compositorBuilding"));
|
||||
assert!(gpu.simulate_compositor_completion(success), "test compositor must build");
|
||||
g.harness.step();
|
||||
settle(&mut g);
|
||||
g.harness.run_steps(2);
|
||||
assert_eq!(g.harness.state().session.active().expect("document").revision, revision);
|
||||
if success {
|
||||
assert!(on_gpu_canvas(&g), "successful build should replace the temporary reason: {:?}", render_path_of(&g));
|
||||
} else {
|
||||
assert_eq!(render_path_of(&g).0, Some("compositorBuild"));
|
||||
assert!(g.harness.state().perf.gpu_fallback.as_deref().is_some_and(|s| s.contains("simulated build failure")));
|
||||
assert_matches_synchronous(&mut g, doc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_canvas_over_the_texture_budget_records_its_reason() {
|
||||
let _gpu = photocraft_testkit::gpu_lock();
|
||||
|
||||
@@ -39,6 +39,7 @@ pub use photocraft_gpu::{DeviceHealth, Fault};
|
||||
|
||||
mod budget;
|
||||
mod callback;
|
||||
mod compositor_build;
|
||||
mod doc_textures;
|
||||
mod pages;
|
||||
mod perf;
|
||||
@@ -53,8 +54,8 @@ use budget::detect_video_memory;
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
use budget::nvidia_video_memory;
|
||||
pub use budget::{
|
||||
DEFER_PX, MIN_GPU_BUDGET, OVER_BUDGET, OVER_CANVAS_BUDGET, REFRESH_DEFERRED, REFRESH_OVER_BUDGET, canvas_budget, canvas_texture_bytes, memory_budget,
|
||||
physical_memory, video_memory, vram_share,
|
||||
COMPOSITOR_BUILDING, DEFER_PX, MIN_GPU_BUDGET, OVER_BUDGET, OVER_CANVAS_BUDGET, REFRESH_DEFERRED, REFRESH_OVER_BUDGET, canvas_budget, canvas_texture_bytes,
|
||||
memory_budget, physical_memory, video_memory, vram_share,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use budget::{NVIDIA_VENDOR, bounded_output, drm_video_memory, parse_nvidia_smi};
|
||||
@@ -171,6 +172,8 @@ pub struct GpuCanvas {
|
||||
/// Camera Raw's preview pass, built the first time the dialog asks (#6419).
|
||||
camera_raw: crate::camera_raw_gpu::Slot,
|
||||
camera_raw_started: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||||
compositor_build: compositor_build::Slot,
|
||||
compositor_completion: compositor_build::Completion,
|
||||
}
|
||||
|
||||
/// Whether `regenerate_mips` builds each level in a scratch texture and copies it back: on
|
||||
@@ -228,7 +231,61 @@ impl GpuCanvas {
|
||||
rs.target_format,
|
||||
vram.map_or_else(|| "unknown".to_owned(), |b| format!("{} MB", b >> 20))
|
||||
);
|
||||
Self { rs: rs.clone(), tile, high, health, vram, canvas_budget: budget, camera_raw: Default::default(), camera_raw_started: Default::default() }
|
||||
Self {
|
||||
rs: rs.clone(),
|
||||
tile,
|
||||
high,
|
||||
health,
|
||||
vram,
|
||||
canvas_budget: budget,
|
||||
camera_raw: Default::default(),
|
||||
camera_raw_started: Default::default(),
|
||||
compositor_build: Default::default(),
|
||||
compositor_completion: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Start compositor compilation off the UI thread on native hardware.
|
||||
pub fn start_compositor(&self, ctx: &egui::Context) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if self.rs.adapter.get_info().device_type != wgpu::DeviceType::Cpu && std::env::var_os("PHOTOCRAFT_CPU_COMPOSE").is_none() {
|
||||
compositor_build::start(
|
||||
&self.rs.device,
|
||||
photocraft_gpu::Compositor::preferred_acc_format(&self.rs.adapter),
|
||||
ctx,
|
||||
&self.compositor_build,
|
||||
&self.compositor_completion,
|
||||
);
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let _ = ctx;
|
||||
}
|
||||
|
||||
/// Key the memo to this device, application and shader compiler.
|
||||
pub fn compositor_memo_key(&self, compiler: &str) -> crate::gpu_memo::MemoKey {
|
||||
let info = self.rs.adapter.get_info();
|
||||
crate::gpu_memo::MemoKey {
|
||||
app_version: env!("CARGO_PKG_VERSION").into(),
|
||||
shader_fingerprint: photocraft_gpu::shader_fingerprint(),
|
||||
adapter_name: info.name,
|
||||
vendor: info.vendor,
|
||||
device: info.device,
|
||||
driver: info.driver,
|
||||
driver_info: info.driver_info,
|
||||
backend: format!("{:?}", info.backend),
|
||||
shader_compiler: compiler.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Use a known deterministic failure without recompiling the same shaders.
|
||||
pub fn remember_compositor_failure(&self, error: &str) {
|
||||
*self.compositor_build.lock().unwrap_or_else(std::sync::PoisonError::into_inner) =
|
||||
compositor_build::BuildState::Failed(photocraft_gpu::Unsupported(format!("remembered from an earlier launch: {error}")));
|
||||
}
|
||||
|
||||
/// Take the background build result once, independently of compositor adoption.
|
||||
pub fn take_compositor_completion(&self) -> Option<Result<(), (String, bool)>> {
|
||||
self.compositor_completion.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take()
|
||||
}
|
||||
|
||||
/// The device's health flag (shared with the wgpu compositor and the paint callback).
|
||||
@@ -402,6 +459,28 @@ impl GpuCanvas {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn simulate_compositor_build(&self) {
|
||||
*self.compositor_build.lock().unwrap() = compositor_build::BuildState::Building;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn simulate_compositor_completion(&self, success: bool) -> bool {
|
||||
let (state, outcome) = if success {
|
||||
let Ok(comp) = photocraft_gpu::Compositor::try_new_with_format(&self.rs.device, photocraft_gpu::Compositor::preferred_acc_format(&self.rs.adapter))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
(compositor_build::BuildState::Ready(Box::new(comp)), Ok(()))
|
||||
} else {
|
||||
let message = "simulated build failure".to_string();
|
||||
(compositor_build::BuildState::Failed(photocraft_gpu::Unsupported(message.clone())), Err((message, false)))
|
||||
};
|
||||
*self.compositor_build.lock().unwrap() = state;
|
||||
*self.compositor_completion.lock().unwrap() = Some(outcome);
|
||||
true
|
||||
}
|
||||
|
||||
/// Why the compositor couldn't be built, once it couldn't (see [`GpuCanvas::composite`]).
|
||||
fn compositor_failed(&self) -> Option<String> {
|
||||
let r = self.rs.renderer.read();
|
||||
@@ -422,6 +501,9 @@ impl GpuCanvas {
|
||||
let (bytes, budget) = self.canvas_fit(doc).unwrap_or_default();
|
||||
return CpuReason::CanvasTooLarge { bytes, budget };
|
||||
}
|
||||
if e.0 == COMPOSITOR_BUILDING {
|
||||
return CpuReason::CompositorBuilding;
|
||||
}
|
||||
if self.compositor_failed().is_some_and(|f| f == e.0) {
|
||||
return CpuReason::CompositorBuild(e.0);
|
||||
}
|
||||
@@ -623,6 +705,22 @@ impl GpuCanvas {
|
||||
if let Some(e) = &res.compositor_failed {
|
||||
return Err(e.clone());
|
||||
}
|
||||
{
|
||||
let mut state = self.compositor_build.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
match std::mem::replace(&mut *state, compositor_build::BuildState::Adopted) {
|
||||
compositor_build::BuildState::Building => {
|
||||
*state = compositor_build::BuildState::Building;
|
||||
return Err(photocraft_gpu::Unsupported(COMPOSITOR_BUILDING.into()));
|
||||
}
|
||||
compositor_build::BuildState::Ready(comp) => res.compositor = Some(*comp),
|
||||
compositor_build::BuildState::Failed(error) => {
|
||||
res.compositor_failed = Some(error.clone());
|
||||
return Err(error);
|
||||
}
|
||||
compositor_build::BuildState::NotStarted => *state = compositor_build::BuildState::NotStarted,
|
||||
compositor_build::BuildState::Adopted => (),
|
||||
}
|
||||
}
|
||||
let mut comp = match res.compositor.take() {
|
||||
Some(c) => c,
|
||||
None => match photocraft_gpu::Compositor::try_new_with_format(device, photocraft_gpu::Compositor::preferred_acc_format(&self.rs.adapter)) {
|
||||
@@ -809,8 +907,12 @@ impl GpuCanvas {
|
||||
out.gpu = stats.gpu_time;
|
||||
return out;
|
||||
}
|
||||
Err(e) => out.fallback = Some(self.reason(doc, e)),
|
||||
Err(e) => {
|
||||
out.gpu_attempt_ms = now_ms() - t0;
|
||||
out.fallback = Some(self.reason(doc, e));
|
||||
}
|
||||
}
|
||||
let t0 = now_ms();
|
||||
let px = region.width() as u64 * region.height() as u64;
|
||||
if defer && px >= DEFER_PX && self.prepare_texture(key, doc).is_some() {
|
||||
out.kind = REFRESH_DEFERRED;
|
||||
|
||||
@@ -8,6 +8,9 @@ pub const MIN_GPU_BUDGET: u64 = 512 << 20;
|
||||
/// CPU compositor.
|
||||
pub const OVER_BUDGET: &str = photocraft_gpu::OVER_BUDGET;
|
||||
|
||||
/// Why a full refresh goes to the CPU compositor while the GPU one is still being built.
|
||||
pub const COMPOSITOR_BUILDING: &str = "GPU compositor is building";
|
||||
|
||||
/// GPU memory the wgpu compositor may hold for layer pages and effect maps.
|
||||
///
|
||||
/// `fixed` (Preferences › Performance › GPU Memory, bytes) sets it when it isn't 0, within
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
//! Builds the compositor without blocking the UI's first document refresh.
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) enum BuildState {
|
||||
#[default]
|
||||
NotStarted,
|
||||
Building,
|
||||
Ready(Box<photocraft_gpu::Compositor>),
|
||||
Failed(photocraft_gpu::Unsupported),
|
||||
Adopted,
|
||||
}
|
||||
|
||||
pub(super) type Slot = Arc<Mutex<BuildState>>;
|
||||
pub(super) type Completion = Arc<Mutex<Option<Result<(), (String, bool)>>>>;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn start(device: &eframe::wgpu::Device, format: eframe::wgpu::TextureFormat, ctx: &egui::Context, slot: &Slot, completion: &Completion) {
|
||||
let mut state = slot.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if !matches!(*state, BuildState::NotStarted) {
|
||||
return;
|
||||
}
|
||||
*state = BuildState::Building;
|
||||
let (device, ctx, out) = (device.clone(), ctx.clone(), slot.clone());
|
||||
let worker_completion = completion.clone();
|
||||
let spawned = std::thread::Builder::new().name("gpu-compositor-build".into()).spawn(move || {
|
||||
let threads = std::thread::available_parallelism().map_or(1, |n| (n.get() / 2).max(1));
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
photocraft_gpu::Compositor::try_new_with_format_threads_classified(&device, format, threads)
|
||||
}));
|
||||
let (result, outcome) = match result {
|
||||
Ok(Ok(comp)) => (BuildState::Ready(Box::new(comp)), Ok(())),
|
||||
Ok(Err(error)) => {
|
||||
let deterministic = error.kind == photocraft_gpu::BuildFailureKind::DeterministicShaderCompilation;
|
||||
(BuildState::Failed(photocraft_gpu::Unsupported(error.message.clone())), Err((error.message, deterministic)))
|
||||
}
|
||||
Err(_) => (
|
||||
BuildState::Failed(photocraft_gpu::Unsupported("building the GPU compositor panicked".into())),
|
||||
Err(("building the GPU compositor panicked".into(), false)),
|
||||
),
|
||||
};
|
||||
*out.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = result;
|
||||
*worker_completion.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(outcome);
|
||||
ctx.request_repaint();
|
||||
});
|
||||
if let Err(error) = spawned {
|
||||
record_spawn_failure(&mut state, completion, &error);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn record_spawn_failure(state: &mut BuildState, completion: &Completion, error: &std::io::Error) {
|
||||
let message = format!("failed to spawn GPU compositor build thread: {error}");
|
||||
*state = BuildState::Failed(photocraft_gpu::Unsupported(message.clone()));
|
||||
*completion.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Err((message, false)));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn spawn_failure_is_reported_as_retryable() {
|
||||
let completion = super::Completion::default();
|
||||
let mut state = super::BuildState::Building;
|
||||
super::record_spawn_failure(&mut state, &completion, &std::io::Error::other("resource exhausted"));
|
||||
assert!(matches!(state, super::BuildState::Failed(_)));
|
||||
let outcome = completion.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take();
|
||||
assert_eq!(outcome, Some(Err(("failed to spawn GPU compositor build thread: resource exhausted".into(), false))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compositor_is_send() {
|
||||
fn assert_send<T: Send>() {}
|
||||
assert_send::<photocraft_gpu::Compositor>();
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,7 @@ fn canvas_mips_preserve_opaque_pixels_and_partial_updates() {
|
||||
}
|
||||
// On the same device (see the top of the file).
|
||||
canvas_textures_the_gpu_refuses_leave_it_working(&gpu);
|
||||
background_compositor_switch_preserves_pixels(&gpu);
|
||||
refused_viewport_page_work_is_caught(&gpu);
|
||||
}
|
||||
|
||||
@@ -202,3 +203,36 @@ fn partial_updates_match_a_full_rebuild_at_every_mip_level() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A CPU-created texture stays valid when a newly built compositor updates only a damage rect.
|
||||
fn background_compositor_switch_preserves_pixels(gpu: &GpuCanvas) {
|
||||
use photocraft_doc::{ColorMode, Document, Layer, LayerContent};
|
||||
let mut doc = Document::new("background build", photocraft_geom::Size::new(64, 64), ColorMode::Rgb, SampleType::U8);
|
||||
let mut layer = Layer::raster("pixels", doc.pixel_format());
|
||||
if let LayerContent::Raster(surface) = &mut layer.content {
|
||||
surface.fill_rect(Rect::new(0, 0, 64, 64), &[0.2, 0.4, 0.6, 1.0]);
|
||||
}
|
||||
doc.layers.push(layer);
|
||||
let key = doc.id.0;
|
||||
*gpu.compositor_build.lock().unwrap() = compositor_build::BuildState::Building;
|
||||
let first = gpu.refresh(key, &doc, None, None);
|
||||
assert_eq!(first.kind, "full");
|
||||
assert_eq!(first.fallback, Some(CpuReason::CompositorBuilding));
|
||||
assert!(gpu.compositor_failed().is_none());
|
||||
let built = photocraft_gpu::Compositor::try_new_with_format(&gpu.rs.device, photocraft_gpu::Compositor::preferred_acc_format(&gpu.rs.adapter));
|
||||
let Ok(comp) = built else { return };
|
||||
*gpu.compositor_build.lock().unwrap() = compositor_build::BuildState::Ready(Box::new(comp));
|
||||
let damage = Rect::new(8, 8, 16, 16);
|
||||
if let LayerContent::Raster(surface) = &mut doc.layers[0].content {
|
||||
surface.fill_rect(damage, &[0.8, 0.2, 0.1, 1.0]);
|
||||
}
|
||||
let second = gpu.refresh(key, &doc, Some(damage), None);
|
||||
assert_eq!(second.kind, "gpu-rect", "{:?}", second.fallback);
|
||||
let actual = read_mips(gpu, key).remove(0);
|
||||
let expected = photocraft_compose::render(&doc, doc.bounds());
|
||||
for (got, want) in actual.iter().zip(expected.px.iter()) {
|
||||
for (g, w) in got.iter().zip(want.iter()) {
|
||||
assert!((g - w).abs() < 0.02, "{got:?} != {want:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ pub struct Perf {
|
||||
/// path the cost of *encoding and submitting* the composite — not of running it, which is
|
||||
/// `gpu_ms`.
|
||||
pub composite_ms: f64,
|
||||
/// Time spent on a failed GPU composite attempt before CPU fallback.
|
||||
pub gpu_attempt_ms: f64,
|
||||
/// Last canvas refresh: conversion + upload (ms).
|
||||
pub upload_ms: f64,
|
||||
/// Last canvas refresh kind: "full", "rect" or "none".
|
||||
@@ -219,6 +221,9 @@ impl Perf {
|
||||
self.last_refresh_px = px;
|
||||
self.composite_ms = composite_ms;
|
||||
self.upload_ms = upload_ms;
|
||||
// A refresh that didn't come through `record_refresh` or `record_gpu_attempt` had no failed
|
||||
// GPU attempt: the last one's time would be read as this refresh's.
|
||||
self.gpu_attempt_ms = 0.0;
|
||||
if !kind.starts_with("gpu") {
|
||||
self.gpu_uploads = 0;
|
||||
// The CPU compositor did this one: there is no GPU time, and the last GPU
|
||||
@@ -231,11 +236,19 @@ impl Perf {
|
||||
/// Record a canvas refresh and any GPU measurement that came back with it.
|
||||
pub fn record_refresh(&mut self, r: &Refresh) {
|
||||
self.record(r.kind, r.px, r.composite_ms, r.upload_ms);
|
||||
self.record_gpu_attempt(r.gpu_attempt_ms);
|
||||
if let Some(t) = r.gpu {
|
||||
self.record_gpu(t);
|
||||
}
|
||||
}
|
||||
|
||||
/// Record the time a failed GPU composite attempt took before the CPU did the refresh just
|
||||
/// recorded, apart from that refresh's own CPU time.
|
||||
pub fn record_gpu_attempt(&mut self, ms: f64) {
|
||||
self.gpu_attempt_ms = ms;
|
||||
self.stage.composite_ms += ms;
|
||||
}
|
||||
|
||||
/// Record a GPU composite measurement that came back (see [`photocraft_gpu::GpuTiming`]).
|
||||
pub fn record_gpu(&mut self, t: photocraft_gpu::GpuTiming) {
|
||||
self.gpu_ms = Some(t.ms);
|
||||
|
||||
@@ -32,6 +32,8 @@ pub struct Refresh {
|
||||
/// Pixels refreshed.
|
||||
pub px: u64,
|
||||
pub composite_ms: f64,
|
||||
/// Time spent on a failed GPU composite attempt before CPU fallback.
|
||||
pub gpu_attempt_ms: f64,
|
||||
pub upload_ms: f64,
|
||||
/// Layer tiles the wgpu compositor uploaded.
|
||||
pub uploads: u64,
|
||||
|
||||
@@ -397,3 +397,30 @@ fn shaders_validate() {
|
||||
.unwrap_or_else(|e| panic!("{name}: {e:?}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_gpu_attempt_is_recorded_separately_and_counted_in_frame_time() {
|
||||
let mut perf = Perf::default();
|
||||
perf.record_refresh(&Refresh { kind: "full", composite_ms: 2.0, upload_ms: 3.0, gpu_attempt_ms: 7.0, ..Default::default() });
|
||||
assert_eq!(perf.composite_ms, 2.0);
|
||||
assert_eq!(perf.gpu_attempt_ms, 7.0);
|
||||
assert_eq!(perf.stage.composite_ms + perf.stage.upload_ms, 12.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refresh_recorded_directly_has_no_gpu_attempt_of_its_own() {
|
||||
let mut perf = Perf::default();
|
||||
perf.record_refresh(&Refresh { kind: "full", composite_ms: 2.0, upload_ms: 3.0, gpu_attempt_ms: 7.0, ..Default::default() });
|
||||
perf.record("rect", 10, 1.0, 1.0);
|
||||
assert_eq!(perf.gpu_attempt_ms, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_deferred_redraws_gpu_attempt_is_counted_once_when_it_ends() {
|
||||
let mut perf = Perf::default();
|
||||
perf.record("background", 10, 2.0, 0.0);
|
||||
perf.record_gpu_attempt(7.0);
|
||||
assert_eq!(perf.composite_ms, 2.0);
|
||||
assert_eq!(perf.gpu_attempt_ms, 7.0);
|
||||
assert_eq!(perf.stage.composite_ms + perf.stage.upload_ms, 9.0);
|
||||
}
|
||||
|
||||
@@ -578,7 +578,7 @@ pub(super) fn ensure_gpu(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usiz
|
||||
if r.kind == crate::gpu_canvas::REFRESH_DEFERRED {
|
||||
// Composited off the UI thread, the visible part first (#4024).
|
||||
let region = if partial { damage.unwrap_or(doc.bounds()) } else { doc.bounds() };
|
||||
crate::background_redraw::start(app, ctx, &gpu, doc.clone(), display.clone(), visible, region, r.fallback.clone());
|
||||
crate::background_redraw::start(app, ctx, &gpu, doc.clone(), display.clone(), visible, region, r.fallback.clone(), r.gpu_attempt_ms);
|
||||
} else if partial {
|
||||
// The edit drew its damage; blocks of a running redraw still to come follow the edit.
|
||||
crate::background_redraw::restart(app, ctx, &gpu, doc.clone(), display.clone(), visible);
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
//! Persistence model for deterministic GPU compositor build failures.
|
||||
//!
|
||||
//! Call [`GpuMemo::remember`] only for deterministic build failures. Transient device or
|
||||
//! allocation errors are deliberately not classified here and are never saved automatically.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Current on-disk schema version.
|
||||
pub const GPU_MEMO_VERSION: u32 = 1;
|
||||
/// Maximum accepted serialized document size.
|
||||
pub const MAX_GPU_MEMO_BYTES: usize = 64 * 1024;
|
||||
/// Number of failure records retained, newest last.
|
||||
pub const MAX_GPU_MEMO_ENTRIES: usize = 8;
|
||||
|
||||
/// Identifies the application, shader set, and graphics stack that produced a failure.
|
||||
#[derive(Clone, Debug, Default, Deserialize, Eq, Hash, PartialEq, Serialize)]
|
||||
pub struct MemoKey {
|
||||
pub app_version: String,
|
||||
pub shader_fingerprint: u64,
|
||||
pub adapter_name: String,
|
||||
pub vendor: u32,
|
||||
pub device: u32,
|
||||
pub driver: String,
|
||||
pub driver_info: String,
|
||||
pub backend: String,
|
||||
pub shader_compiler: String,
|
||||
}
|
||||
|
||||
/// One remembered deterministic build failure.
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct MemoEntry {
|
||||
pub key: MemoKey,
|
||||
pub error: String,
|
||||
/// RFC3339 timestamp supplied by the caller.
|
||||
pub when: String,
|
||||
}
|
||||
|
||||
/// Bounded, versioned compositor failure memo.
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct GpuMemo {
|
||||
version: u32,
|
||||
failures: Vec<MemoEntry>,
|
||||
}
|
||||
|
||||
impl Default for GpuMemo {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl GpuMemo {
|
||||
/// Creates an empty memo using the current schema version.
|
||||
pub fn new() -> Self {
|
||||
Self { version: GPU_MEMO_VERSION, failures: Vec::new() }
|
||||
}
|
||||
|
||||
/// Parse persisted JSON. Rejects oversized, malformed, and unknown-version data.
|
||||
pub fn parse(text: &str) -> Result<Self, String> {
|
||||
if text.len() > MAX_GPU_MEMO_BYTES {
|
||||
return Err("GPU memo exceeds 64 KiB".into());
|
||||
}
|
||||
let mut memo: Self = serde_json::from_str(text).map_err(|e| e.to_string())?;
|
||||
if memo.version != GPU_MEMO_VERSION {
|
||||
return Err(format!("unsupported GPU memo version {}", memo.version));
|
||||
}
|
||||
if memo.failures.len() > MAX_GPU_MEMO_ENTRIES {
|
||||
let excess = memo.failures.len() - MAX_GPU_MEMO_ENTRIES;
|
||||
memo.failures.drain(..excess);
|
||||
}
|
||||
Ok(memo)
|
||||
}
|
||||
|
||||
/// Serialize this memo as JSON.
|
||||
pub fn serialize(&self) -> Result<String, String> {
|
||||
let text = serde_json::to_string(self).map_err(|e| e.to_string())?;
|
||||
if text.len() > MAX_GPU_MEMO_BYTES {
|
||||
return Err("GPU memo exceeds 64 KiB".into());
|
||||
}
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
/// Returns the remembered failure for `key`, if present.
|
||||
pub fn get(&self, key: &MemoKey) -> Option<&MemoEntry> {
|
||||
self.failures.iter().rev().find(|entry| &entry.key == key)
|
||||
}
|
||||
|
||||
/// Records a deterministic failure. Callers must exclude transient device-loss and OOM errors.
|
||||
/// Existing entries for the key are replaced and the oldest record is dropped at capacity.
|
||||
pub fn remember(&mut self, key: MemoKey, error: String, when: String) {
|
||||
if self.version != GPU_MEMO_VERSION {
|
||||
*self = Self::new();
|
||||
}
|
||||
self.remove(&key);
|
||||
if self.failures.len() == MAX_GPU_MEMO_ENTRIES {
|
||||
self.failures.remove(0);
|
||||
}
|
||||
self.failures.push(MemoEntry { key, error, when });
|
||||
}
|
||||
|
||||
/// Removes a memoized failure for `key`; returns whether one existed.
|
||||
pub fn remove(&mut self, key: &MemoKey) -> bool {
|
||||
let before = self.failures.len();
|
||||
self.failures.retain(|entry| &entry.key != key);
|
||||
before != self.failures.len()
|
||||
}
|
||||
|
||||
/// Number of retained failure records.
|
||||
pub fn len(&self) -> usize {
|
||||
self.failures.len()
|
||||
}
|
||||
|
||||
/// Whether the memo has no records.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.failures.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn key(n: usize) -> MemoKey {
|
||||
MemoKey { app_version: n.to_string(), ..MemoKey::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_and_remove() {
|
||||
let mut memo = GpuMemo::new();
|
||||
memo.remember(key(1), "compiler failed".into(), "2026-10-28T00:00:00Z".into());
|
||||
let parsed = GpuMemo::parse(&memo.serialize().expect("serializes")).expect("parses");
|
||||
assert_eq!(parsed.get(&key(1)).map(|entry| entry.error.as_str()), Some("compiler failed"));
|
||||
let mut parsed = parsed;
|
||||
assert!(parsed.remove(&key(1)));
|
||||
assert!(!parsed.remove(&key(1)));
|
||||
assert!(parsed.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_any_identity_field_invalidates_a_failure() {
|
||||
let original = MemoKey::default();
|
||||
let mut memo = GpuMemo::new();
|
||||
memo.remember(original.clone(), "compiler failed".into(), "now".into());
|
||||
let variants = [
|
||||
MemoKey { app_version: "new".into(), ..original.clone() },
|
||||
MemoKey { shader_fingerprint: 1, ..original.clone() },
|
||||
MemoKey { adapter_name: "new".into(), ..original.clone() },
|
||||
MemoKey { vendor: 1, ..original.clone() },
|
||||
MemoKey { device: 1, ..original.clone() },
|
||||
MemoKey { driver: "new".into(), ..original.clone() },
|
||||
MemoKey { driver_info: "new".into(), ..original.clone() },
|
||||
MemoKey { backend: "new".into(), ..original.clone() },
|
||||
MemoKey { shader_compiler: "new".into(), ..original.clone() },
|
||||
];
|
||||
for variant in variants {
|
||||
assert!(memo.get(&variant).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retains_only_eight_newest_entries() {
|
||||
let mut memo = GpuMemo::new();
|
||||
for n in 0..10 {
|
||||
memo.remember(key(n), "failure".into(), "now".into());
|
||||
}
|
||||
assert_eq!(memo.len(), MAX_GPU_MEMO_ENTRIES);
|
||||
assert!(memo.get(&key(0)).is_none());
|
||||
assert!(memo.get(&key(1)).is_none());
|
||||
assert!(memo.get(&key(2)).is_some());
|
||||
assert!(memo.get(&key(9)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_corrupt_oversized_and_unknown_versions() {
|
||||
assert!(GpuMemo::parse("{").is_err());
|
||||
assert!(GpuMemo::parse(&" ".repeat(MAX_GPU_MEMO_BYTES + 1)).is_err());
|
||||
assert!(GpuMemo::parse(r#"{"version":2,"failures":[]}"#).is_err());
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ pub fn check(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
if fault.is_out_of_memory() && app.gpu_oom_retries < OOM_AUTO_RETRIES && can_retry_now(app) {
|
||||
app.gpu_oom_retries += 1;
|
||||
if retry_now(app) {
|
||||
app.retry_compositor(ctx);
|
||||
log::warn!("{fault}; rebuilt the GPU canvas (automatic retry {} of {OOM_AUTO_RETRIES})", app.gpu_oom_retries);
|
||||
app.ui.gpu_fallback_notice = None;
|
||||
}
|
||||
@@ -292,10 +293,12 @@ pub fn show_fallback(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
});
|
||||
});
|
||||
if let Some(retry) = choice
|
||||
&& let Err(error) = choose_recovery(app, retry)
|
||||
{
|
||||
crate::notices::error(app, error);
|
||||
if let Some(retry) = choice {
|
||||
match choose_recovery(app, retry) {
|
||||
Ok(()) if retry => app.retry_compositor(ctx),
|
||||
Ok(()) => (),
|
||||
Err(error) => crate::notices::error(app, error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4247,6 +4247,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded
|
||||
Font size presets Předvolby velikosti písma
|
||||
Leading presets Předvolby prokladu
|
||||
Tracking presets Předvolby prostrkání
|
||||
|
||||
The GPU compositor is still being prepared The GPU compositor is still being prepared
|
||||
Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Neplatná číselná hodnota. Je vyžadováno celé číslo mezi {min}% a {max}%. Byla vložena předchozí hodnota.
|
||||
Color Aware Podle barev
|
||||
Decontaminate Colors Dekontaminovat barvy
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3529,6 +3529,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded
|
||||
Font size presets Schriftgrößen-Vorgaben
|
||||
Leading presets Zeilenabstand-Vorgaben
|
||||
Tracking presets Laufweiten-Vorgaben
|
||||
|
||||
The GPU compositor is still being prepared The GPU compositor is still being prepared
|
||||
Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Ungültige Zahleneingabe. Es ist eine ganze Zahl zwischen {min}% und {max}% erforderlich. Der vorherige Wert wurde eingesetzt.
|
||||
Clear Selection Auswahl löschen
|
||||
Color Aware Farbbezogen
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -4200,6 +4200,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded
|
||||
Font size presets Προεπιλογές μεγέθους γραμματοσειράς
|
||||
Leading presets Προεπιλογές διάστιχου
|
||||
Tracking presets Προεπιλογές διαστήματος χαρακτήρων
|
||||
|
||||
The GPU compositor is still being prepared The GPU compositor is still being prepared
|
||||
Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Μη έγκυρη αριθμητική τιμή. Απαιτείται ακέραιος αριθμός μεταξύ {min}% και {max}%. Εισήχθη η προηγούμενη τιμή.
|
||||
Color Aware Με βάση το χρώμα
|
||||
Decontaminate Colors Απολύμανση χρωμάτων
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 47 and column 53.
|
@@ -4411,6 +4411,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded
|
||||
Font size presets Valores predefinidos de tamaño de fuente
|
||||
Leading presets Valores predefinidos de interlineado
|
||||
Tracking presets Valores predefinidos de tracking
|
||||
|
||||
The GPU compositor is still being prepared The GPU compositor is still being prepared
|
||||
Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Entrada numérica no válida. Se requiere un número entero entre {min}% y {max}%. Se ha insertado el valor anterior.
|
||||
Color Aware Según color
|
||||
Decontaminate Colors Descontaminar colores
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -4537,6 +4537,8 @@ shape Appearance Apparence
|
||||
Font size presets Tailles de police prédéfinies
|
||||
Leading presets Interlignages prédéfinis
|
||||
Tracking presets Approches prédéfinies
|
||||
|
||||
The GPU compositor is still being prepared The GPU compositor is still being prepared
|
||||
Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Entrée numérique non valide. Un entier compris entre {min}% et {max}% est requis. La valeur précédente a été insérée.
|
||||
Color Aware Selon la couleur
|
||||
Decontaminate Colors Décontaminer les couleurs
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -567,6 +567,8 @@ cameraRaw Yellow पीला
|
||||
Target लक्ष्य
|
||||
Units इकाइयाँ
|
||||
Demosaic डिमोज़ेक
|
||||
|
||||
The GPU compositor is still being prepared The GPU compositor is still being prepared
|
||||
Clear Selection चयन साफ़ करें
|
||||
Color Aware रंग-आधारित
|
||||
Decontaminate Colors रंग शुद्ध करें
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -4420,6 +4420,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded
|
||||
Font size presets Betűméret-előbeállítások
|
||||
Leading presets Sorköz-előbeállítások
|
||||
Tracking presets Betűköz-előbeállítások
|
||||
|
||||
The GPU compositor is still being prepared The GPU compositor is still being prepared
|
||||
Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Érvénytelen számbevitel. {min}% és {max}% közötti egész szám szükséges. Az előző érték lett beillesztve.
|
||||
Color Aware Színalapú
|
||||
Decontaminate Colors Színek fertőtlenítése
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3519,6 +3519,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded
|
||||
Font size presets Preset ukuran font
|
||||
Leading presets Preset leading
|
||||
Tracking presets Preset tracking
|
||||
|
||||
The GPU compositor is still being prepared The GPU compositor is still being prepared
|
||||
Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Entri numerik tidak valid. Diperlukan bilangan bulat antara {min}% dan {max}%. Nilai sebelumnya telah disisipkan.
|
||||
Clear Selection Hapus Seleksi
|
||||
Color Aware Berbasis Warna
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -4613,6 +4613,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded
|
||||
Font size presets Predefiniti dimensione font
|
||||
Leading presets Predefiniti interlinea
|
||||
Tracking presets Predefiniti spaziatura
|
||||
|
||||
The GPU compositor is still being prepared The GPU compositor is still being prepared
|
||||
Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Valore numerico non valido. È richiesto un numero intero compreso tra {min}% e {max}%. È stato inserito il valore precedente.
|
||||
Color Aware In base al colore
|
||||
Decontaminate Colors Decontamina colori
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 79 and column 53.
|
@@ -4175,6 +4175,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded
|
||||
Font size presets フォントサイズのプリセット
|
||||
Leading presets 行送りのプリセット
|
||||
Tracking presets トラッキングのプリセット
|
||||
|
||||
The GPU compositor is still being prepared The GPU compositor is still being prepared
|
||||
Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. 無効な数値です。{min}% から {max}% までの整数が必要です。以前の値が挿入されました。
|
||||
Clear Selection 選択範囲をクリア
|
||||
Color Aware カラー認識
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3718,6 +3718,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded
|
||||
Font size presets 글꼴 크기 사전 설정
|
||||
Leading presets 행간 사전 설정
|
||||
Tracking presets 자간 사전 설정
|
||||
|
||||
The GPU compositor is still being prepared The GPU compositor is still being prepared
|
||||
Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. 잘못된 숫자 입력입니다. {min}%에서 {max}% 사이의 정수가 필요합니다. 이전 값이 입력되었습니다.
|
||||
Clear Selection 선택 영역 지우기
|
||||
Color Aware 색상 인식
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 63 and column 53.
|
@@ -4257,6 +4257,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded
|
||||
Font size presets Voorinstellingen lettergrootte
|
||||
Leading presets Voorinstellingen interlinie
|
||||
Tracking presets Voorinstellingen spatiëring
|
||||
|
||||
The GPU compositor is still being prepared The GPU compositor is still being prepared
|
||||
Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Ongeldige numerieke invoer. Er is een geheel getal tussen {min}% en {max}% vereist. De vorige waarde is ingevoegd.
|
||||
Color Aware Kleurbewust
|
||||
Decontaminate Colors Kleuren zuiveren
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 34.
|
@@ -4117,6 +4117,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded
|
||||
Font size presets Ustawienia wstępne rozmiaru czcionki
|
||||
Leading presets Ustawienia wstępne interlinii
|
||||
Tracking presets Ustawienia wstępne światła
|
||||
|
||||
The GPU compositor is still being prepared The GPU compositor is still being prepared
|
||||
Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Nieprawidłowa wartość liczbowa. Wymagana jest liczba całkowita od {min}% do {max}%. Wstawiono poprzednią wartość.
|
||||
Clear Selection Wyczyść zaznaczenie
|
||||
Color Aware Według kolorów
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -4427,6 +4427,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded
|
||||
Font size presets Predefinições de tamanho da fonte
|
||||
Leading presets Predefinições de entrelinha
|
||||
Tracking presets Predefinições de tracking
|
||||
|
||||
The GPU compositor is still being prepared The GPU compositor is still being prepared
|
||||
Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Entrada numérica inválida. É necessário um número inteiro entre {min}% e {max}%. O valor anterior foi inserido.
|
||||
Color Aware Com reconhecimento de cor
|
||||
Decontaminate Colors Descontaminar cores
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -4698,6 +4698,8 @@ shape Appearance Оформление
|
||||
Font size presets Наборы размеров шрифта
|
||||
Leading presets Наборы интерлиньяжа
|
||||
Tracking presets Наборы трекинга
|
||||
|
||||
The GPU compositor is still being prepared The GPU compositor is still being prepared
|
||||
Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Недопустимое числовое значение. Требуется целое число от {min}% до {max}%. Вставлено предыдущее значение.
|
||||
Color Aware С учетом цвета
|
||||
Decontaminate Colors Очистить цвета
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -4578,6 +4578,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded
|
||||
Font size presets Unapred podešene veličine fonta
|
||||
Leading presets Unapred podešeni proredi
|
||||
Tracking presets Unapred podešeni razmaci slova
|
||||
|
||||
The GPU compositor is still being prepared The GPU compositor is still being prepared
|
||||
Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Neispravan numerički unos. Potreban je ceo broj između {min}% i {max}%. Umetnuta je prethodna vrednost.
|
||||
Color Aware Prema boji
|
||||
Decontaminate Colors Dekontaminiraj boje
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -4229,6 +4229,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded
|
||||
Font size presets Yazı tipi boyutu hazır ayarları
|
||||
Leading presets Satır aralığı hazır ayarları
|
||||
Tracking presets İzleme hazır ayarları
|
||||
|
||||
The GPU compositor is still being prepared The GPU compositor is still being prepared
|
||||
Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Geçersiz sayısal giriş. {min}% ile {max}% arasında bir tam sayı gerekiyor. Önceki değer eklendi.
|
||||
Color Aware Renge Duyarlı
|
||||
Decontaminate Colors Renkleri Arındır
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 34.
|
@@ -4210,6 +4210,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded
|
||||
Font size presets Набори розмірів шрифту
|
||||
Leading presets Набори інтерліньяжу
|
||||
Tracking presets Набори трекінгу
|
||||
|
||||
The GPU compositor is still being prepared The GPU compositor is still being prepared
|
||||
Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Неприпустиме числове значення. Потрібне ціле число від {min}% до {max}%. Вставлено попереднє значення.
|
||||
Clear Selection Очистити виділення
|
||||
Color Aware З урахуванням кольору
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 152 and column 53.
|
@@ -4006,6 +4006,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded
|
||||
Font size presets Cỡ chữ đặt sẵn
|
||||
Leading presets Giãn dòng đặt sẵn
|
||||
Tracking presets Khoảng cách chữ đặt sẵn
|
||||
|
||||
The GPU compositor is still being prepared The GPU compositor is still being prepared
|
||||
Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Giá trị số không hợp lệ. Cần một số nguyên từ {min}% đến {max}%. Đã chèn giá trị trước đó.
|
||||
Clear Selection Xóa vùng chọn
|
||||
Color Aware Nhận biết màu
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3182,6 +3182,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded
|
||||
Font size presets 字号预设
|
||||
Leading presets 行距预设
|
||||
Tracking presets 字距调整预设
|
||||
|
||||
The GPU compositor is still being prepared The GPU compositor is still being prepared
|
||||
Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. 数值输入无效。需要介于 {min}% 和 {max}% 之间的整数。已插入先前的值。
|
||||
Clear Selection 清除选区
|
||||
Color Aware 颜色识别
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 63 and column 53.
|
@@ -3138,6 +3138,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded
|
||||
Font size presets 字體大小預設
|
||||
Leading presets 行距預設
|
||||
Tracking presets 字距調整預設
|
||||
|
||||
The GPU compositor is still being prepared The GPU compositor is still being prepared
|
||||
Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. 數值輸入無效。需要介於 {min}% 和 {max}% 之間的整數。已插入先前的值。
|
||||
Clear Selection 清除選取範圍
|
||||
Color Aware 顏色感知
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -33,6 +33,7 @@ pub(crate) mod edit_session;
|
||||
pub mod file_dialog;
|
||||
pub mod file_open;
|
||||
pub mod frame_stages;
|
||||
pub mod gpu_memo;
|
||||
pub mod gpu_status;
|
||||
pub mod home_recent_ui;
|
||||
pub mod i18n;
|
||||
|
||||
@@ -133,7 +133,11 @@ pub fn clipboard_unreadable(app: &mut PhotocraftApp) {
|
||||
/// `ms`. The canvas doesn't respond while it runs, so once per document say why it was slow and
|
||||
/// which edits will do it again (#1015).
|
||||
pub fn slow_cpu_refresh(app: &mut PhotocraftApp, doc: u64, ms: f64, reason: Option<&crate::render_path::CpuReason>) {
|
||||
if ms.is_nan() || ms < SLOW_CPU_REFRESH_MS || app.ui.slow_refresh_noticed.contains(&doc) {
|
||||
if matches!(reason, Some(crate::render_path::CpuReason::CompositorBuilding))
|
||||
|| ms.is_nan()
|
||||
|| ms < SLOW_CPU_REFRESH_MS
|
||||
|| app.ui.slow_refresh_noticed.contains(&doc)
|
||||
{
|
||||
return;
|
||||
}
|
||||
app.ui.slow_refresh_noticed.push(doc);
|
||||
@@ -415,6 +419,14 @@ mod tests {
|
||||
assert!(app.ui.notices.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preparing_compositor_does_not_warn_about_large_documents() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
slow_cpu_refresh(&mut app, 1, 60_000.0, Some(&crate::render_path::CpuReason::CompositorBuilding));
|
||||
assert!(app.ui.notices.is_empty());
|
||||
assert!(app.ui.slow_refresh_noticed.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_slow_cpu_refresh_is_explained_once_per_document() {
|
||||
let mut app = PhotocraftApp::new(Session::new(), Services::default());
|
||||
|
||||
@@ -32,6 +32,8 @@ pub enum CpuReason {
|
||||
CanvasTooLarge { bytes: u64, budget: u64 },
|
||||
/// The driver couldn't build the compositor's pipelines (such as DX12 FXC X3507, #8733).
|
||||
CompositorBuild(String),
|
||||
/// The GPU compositor is being built in the background.
|
||||
CompositorBuilding,
|
||||
/// The device was lost (the fault's text).
|
||||
DeviceLost(String),
|
||||
/// An uncaptured GPU error (the fault's text).
|
||||
@@ -61,6 +63,7 @@ impl CpuReason {
|
||||
CpuReason::OverBudget { .. } => "overBudget",
|
||||
CpuReason::CanvasTooLarge { .. } => "canvasTooLarge",
|
||||
CpuReason::CompositorBuild(_) => "compositorBuild",
|
||||
CpuReason::CompositorBuilding => "compositorBuilding",
|
||||
CpuReason::DeviceLost(_) => "deviceLost",
|
||||
CpuReason::GpuError(_) => "gpuError",
|
||||
CpuReason::Unsupported(_) => "unsupported",
|
||||
@@ -87,6 +90,7 @@ impl fmt::Display for CpuReason {
|
||||
CpuReason::CanvasTooLarge { bytes, budget } => {
|
||||
write!(f, "the canvas needs {} of GPU textures, budget {}; drawing it on the CPU canvas", fmt_bytes(*bytes), fmt_bytes(*budget))
|
||||
}
|
||||
CpuReason::CompositorBuilding => f.write_str(tl!("The GPU compositor is still being prepared")),
|
||||
CpuReason::CompositorBuild(e) => write!(f, "the GPU compositor couldn't be built: {e}"),
|
||||
CpuReason::DeviceLost(e) | CpuReason::GpuError(e) | CpuReason::Unsupported(e) => f.write_str(e),
|
||||
CpuReason::AdjustPreview(why) => write!(f, "the adjustment preview uses the CPU proxy: {why}"),
|
||||
@@ -137,6 +141,9 @@ pub struct RenderPath {
|
||||
}
|
||||
|
||||
impl RenderPath {
|
||||
pub(crate) fn compositor_building_docs(&self) -> Vec<u64> {
|
||||
self.docs.iter().filter_map(|(&doc, reason)| matches!(reason, CpuReason::CompositorBuilding).then_some(doc)).collect()
|
||||
}
|
||||
/// Why document `doc` is drawn on the CPU now, or None on the GPU: the window's reason, else
|
||||
/// the view's, else the canvas's, else the adjustment preview's. A software adapter still runs
|
||||
/// the GPU canvas, so a document that left even that names its own reason first.
|
||||
|
||||
@@ -475,6 +475,11 @@ pub struct Services {
|
||||
/// the web (see `prefs_ui`).
|
||||
pub load_prefs: Option<LoadTextFn>,
|
||||
pub save_prefs: Option<SaveTextFn>,
|
||||
/// Load/save the bounded deterministic GPU compositor failure memo (native only).
|
||||
pub load_gpu_memo: Option<LoadTextFn>,
|
||||
pub save_gpu_memo: Option<SaveTextFn>,
|
||||
/// Compiler identity included in GPU memo keys (for example `fxc` or `dxc:<path>`).
|
||||
pub shader_compiler: Option<String>,
|
||||
/// Photoshop's live keyboard shortcut set, imported once at first launch (`kys_import`).
|
||||
pub photoshop_shortcuts: Option<PhotoshopShortcutsFn>,
|
||||
pub system_theme: Option<SystemThemeFn>,
|
||||
|
||||
+3
-1
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
# Hardware parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (Camera Raw's preview develops its per-pixel stages on the GPU) · **Target:** Adobe Photoshop 2026
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (GPU compositor startup builds in the background, measurements pending; Camera Raw's preview develops its per-pixel stages on the GPU) · **Target:** Adobe Photoshop 2026
|
||||
|
||||
Each hardware feature Photoshop 2026 (27.11.0) uses, per platform, against PhotoCraft. Photoshop
|
||||
side: its bundle (Metal and OpenCL paths, "Precise Color Management for HDR Display" and EDR
|
||||
@@ -19,6 +19,7 @@ HDR display to verify.
|
||||
|---|---|---|---|
|
||||
| GPU canvas, zoom/pan, rotate view | Metal (macOS), DirectX/OpenCL (Windows) | wgpu 30: Metal, Vulkan, DX12, GL; backend selectable | done |
|
||||
| GPU compositing of layers, blend modes, effects | partial (Photoshop composites mostly on CPU) | wgpu compositor, ≤ 1/255 vs CPU oracle; Multichannel and > texture-limit regions fall back to CPU | done (ahead in places) |
|
||||
| GPU compositor startup | available | Pipeline preparation runs in the background on native hardware; the CPU composites until ready. Windows WARP probe: 0.329 ms p95, 6.860 ms maximum device-work stall during two FXC builds; hardware measurements remain pending (#9157, #9364, #6419) | partial |
|
||||
| GPU filters (Blur Gallery, Liquify, Smart Sharpen, Camera Raw) | yes | CPU (rayon) for filters; GPU for compose and effects. Camera Raw's preview runs its per-pixel stages (white balance to grain) in one wgpu pass, about 2-3 ms vs 11-28 ms on the CPU for the 858×572 proxy, depending on the machine; spatial stages, Calibration and OK still develop on the CPU (#6916) | partial |
|
||||
| Documents larger than the GPU texture limit | yes (tiled) | CPU fallback; tiling in progress (#49) | partial |
|
||||
| CPU-only fallback when no GPU | yes, reduced | `cpu` canvas preference; fails to start without an adapter on some Linux systems (#2519, #2412) | partial |
|
||||
@@ -62,6 +63,7 @@ HDR display to verify.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-28 | minor | Build GPU compositor pipelines in the background; benchmark baseline pending (#9157) |
|
||||
| 2026-10-10 | major | First version |
|
||||
| 2026-10-17 | minor | GPU filters: Camera Raw's preview, per-pixel stages (#6916 step 1) |
|
||||
| 2026-10-19 | minor | Graphics Adapter choice on Linux (#1885) |
|
||||
|
||||
+3
-1
@@ -6,7 +6,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
|
||||
|
||||
| Area | Done | Partial | Missing | Key numbers |
|
||||
|---|---:|---:|---:|---|
|
||||
| [Performance](#performance) | 3 | 22 | 49 | 83 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 45 not run, 4 not measurable yet (baseline 2026-10-05) |
|
||||
| [Performance](#performance) | 3 | 22 | 51 | 85 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 47 not run, 4 not measurable yet (baseline 2026-10-05) |
|
||||
| [File compatibility](#file-compatibility) | 11 | 14 | 2 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 190/258, round trip ≥ 258/258 |
|
||||
| [Tools](#tools) | 36 | 9 | 0 | |
|
||||
| [Workspace and UI](#workspace-and-ui) | 15 | 6 | 2 | settings that do nothing: 10 of 189 (target 0) |
|
||||
@@ -98,6 +98,8 @@ Baseline: 2026-10-05, 1 run, commit `f5d2bcb54a`, Apple M4 Pro (48 GB), GPU Appl
|
||||
| P77 | Toggle an adjustment layer's visibility | ≤ 10 ms p95 | – | – | – | – | – | not run | [#6535](https://github.com/storytold/photocraft/issues/6535) |
|
||||
| P78 | Hide the top adjustment layer among 150 full-canvas layers, 6 MP, GPU budget forced to 512 MB (CPU fallback) | ≤ 30 ms p95 | – | – | – | – | – | not run | [#6533](https://github.com/storytold/photocraft/issues/6533) |
|
||||
| P79 | Zoomed-out reduced composite of a 30000x30000 document with one painted layer (#9330 target: 60000x60000) | track only | – | – | – | – | – | not run | [#1015](https://github.com/storytold/photocraft/issues/1015) |
|
||||
| B2a | First document: UI-thread time to first pixels | ≤ 100 ms p95 | – | – | – | – | – | not run | [#9157](https://github.com/storytold/photocraft/issues/9157) |
|
||||
| B2b | First document: GPU compositor ready | ≤ 2000 ms p95 | – | – | – | – | – | not run | [#9157](https://github.com/storytold/photocraft/issues/9157) |
|
||||
| P80 | App frame: full CPU refresh of a 24 MP document | ≤ 50 ms p95 | – | – | – | – | – | not run | [#9328](https://github.com/storytold/photocraft/issues/9328) |
|
||||
| P81 | Undo a brush dab on a full-canvas 16-bit 15000 x 10000 layer + refresh | track only | – | – | – | – | – | not run | [#9329](https://github.com/storytold/photocraft/issues/9329) |
|
||||
| P82 | Fill a 2 % selection on a full-canvas 24 MP layer + refresh | track only | – | – | – | – | – | not run | [#9329](https://github.com/storytold/photocraft/issues/9329) |
|
||||
|
||||
@@ -767,6 +767,28 @@ bench = "perf_scenarios"
|
||||
row = "zoomed-out reduced composite of the huge document with one painted layer"
|
||||
enforce = false # tracked only
|
||||
|
||||
# ---- #9157: background compositor startup ------------------------------------------------------
|
||||
|
||||
[[scenario]]
|
||||
id = "B2a"
|
||||
title = "First document: UI-thread time to first pixels"
|
||||
issue = 9157
|
||||
bench = "perf_scenarios"
|
||||
row = "first document: UI-thread time to first pixels"
|
||||
metric = "p95"
|
||||
budget_ms = 100
|
||||
enforce = false # nightly Metal baseline not recorded yet
|
||||
|
||||
[[scenario]]
|
||||
id = "B2b"
|
||||
title = "First document: GPU compositor ready"
|
||||
issue = 9157
|
||||
bench = "perf_scenarios"
|
||||
row = "first document: GPU compositor ready"
|
||||
metric = "p95"
|
||||
budget_ms = 2000
|
||||
enforce = false # informational until a nightly Metal baseline exists
|
||||
|
||||
[[scenario]]
|
||||
id = "P80"
|
||||
title = "App frame: full CPU refresh of a 24 MP document"
|
||||
|
||||
Reference in New Issue
Block a user