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:
Khawar Zamman Wani
2026-11-05 22:49:12 +01:00
committed by GitHub
co-authored by Claude Opus 5.5 FredApps
parent c441fd350c
commit 5c157e8400
53 changed files with 1105 additions and 27 deletions
Generated
+1
View File
@@ -5564,6 +5564,7 @@ name = "photocraft-ui-egui"
version = "0.20.1"
dependencies = [
"base64 0.23.1",
"chrono",
"craft-print",
"craft-ui",
"craft-update",
+3
View File
@@ -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()
}
}
+1
View File
@@ -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).
+43
View File
@@ -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())
}
+16
View File
@@ -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
View File
@@ -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);
}
}
+6
View File
@@ -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()));
}
+1
View File
@@ -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 }
+88 -3
View File
@@ -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;
+75
View File
@@ -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();
}
}
-1
View File
@@ -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};
+19 -5
View File
@@ -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(&region).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();
+106 -4
View File
@@ -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);
}
+1 -1
View File
@@ -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);
+178
View File
@@ -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());
}
}
+7 -4
View File
@@ -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),
}
}
}
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+1
View File
@@ -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;
+13 -1
View File
@@ -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());
+7
View File
@@ -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.
+5
View File
@@ -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
View File
File diff suppressed because one or more lines are too long
+3 -1
View File
@@ -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
View File
@@ -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) |
+22
View File
@@ -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"