M97 的入口是一份 macOS M1 真机报告:探测认出了 Apple M1(adapters=1 /
hardware=true),日志却写着 `renderer = auto (wgpu -> tiny-skia)`,看起来
像在跑 CPU。读源码发现那行是**常量**:`probe.rs` 按 `ICED_BACKEND` 未设就把
`"auto (wgpu -> tiny-skia)"` 打出来,与实际后端无关。真正的失败被 iced 吞掉:
`iced_renderer-0.14.0/src/fallback.rs` 的 `Compositor::with_backend` 把 wgpu 的
错误装进 `errors` 向量,tiny-skia 一成功就整体丢弃。
## 判据改为单项,不再交给 iced 回退
`ICED_BACKEND` 现在**永远是单项**(`wgpu` / `tiny-skia`):能跑起来就必定是它;
起不来就必然是 `iced::Error::GraphicsCreationFailed` 里的具名错误。判据是纯函数
`renderer::decide(request, probe, gpu_compiled)`,落在 `renderer/decision.rs`,
表驱动测试逐行钉死 1~6 行 + 「除强制 CPU / 未编 wgpu 外必须留兜底」的守门。
判据表(条件 / 选哪个 / 日志该打什么 / 谁能验证)在 `renderer/mod.rs` 顶部。
## 安全网改成换进程
第一版是「打印错误 → 在本进程再 run() 一次」,Windows 实测直接 panic:
`Create event loop: RecreationAttempt`(winit 一个进程只能建一次事件循环)。
改成 `respawn`:子进程只换 `ICED_BACKEND` + `ULL_GPU_FALLBACK`,父进程透传它的
退出码。子进程看到 `ULL_GPU_FALLBACK` 必须跳过探测,否则会无限递归重开。
## 取证通道不许自己降级
带 `--screenshot`(含 `--screen=gallery`)时 wgpu 起不来就**不出图**、退出码 1,
因为换了后端出的图会撒谎;交互启动才允许降级。每张图的日志现在打**实际**后端
(`[shot] … renderer=wgpu`),以前打的是请求值 `auto`——同一个病(在需要结果的
地方打请求)在仓库里出现了两次,这是第二次。
## 可观测性
- `log` 桥(`renderer/log_bridge.rs`)把 `iced_wgpu` 的
`Selected: AdapterInfo{…}` 接到 stderr:这是本仓库第一次能自证「真在用 GPU」。
按 target 分级(`iced_wgpu*` Info、`wgpu*` Warn),否则 Vulkan loader 会刷 90 行噪声。
- `renderer/preflight.rs`(默认关,`ULL_PREFLIGHT=1`):开窗前拿 wgpu 的具名错误。
实测 Windows RX 6600 XT 上 took=717ms,所以不进默认路径。
- `app/backend.rs` 的档位判据跟着改成读单项;**真降级到 CPU 的那一次面板 α 现在会
正确强制 1.0**(以前静默降级后仍按 GPU 档判)。
Windows 实测:auto → wgpu + `Selected: AdapterInfo{…backend: Vulkan}` 出图;
`--renderer=cpu` 逐字不变;`WGPU_BACKEND=metal` 造失败 → 具名错误 → 交互路径降级
不崩(首帧 531ms)/ 取证路径拒绝出图 exit 1。macOS Metal 路径需真机复核,
命令与期望日志行写在 `renderer/mod.rs` 的「真机验收」一节。
245 lines
8.4 KiB
Rust
245 lines
8.4 KiB
Rust
//! wgpu 自检(判据表第 7 行):在把控制权交给 iced **之前**自己跑一遍
|
||
//! 「实例 → 适配器 → 设备」,把 wgpu 的具名错误拿在手里。
|
||
//!
|
||
//! ## 为什么默认关
|
||
//!
|
||
//! 它会**重复** iced 马上要做的活(再建一个实例、再要一个设备),macOS 上一个 Metal 设备
|
||
//! 不是免费的;而且它测不了 `create_surface`(那时还没有窗口),所以它**代替不了**真跑一次。
|
||
//! 它的价值只有一个:让「不上 GPU」这个结论在**开窗之前**就有原因,验收时不用盯着一个闪窗猜。
|
||
//!
|
||
//! 开关:`ULL_PREFLIGHT=1`(判据表里登记过,不是隐藏口子)。
|
||
//!
|
||
//! ## 它镜像的是谁
|
||
//!
|
||
//! 逐字对着 `iced_wgpu-0.14.0/src/window/compositor.rs` 的 `Compositor::request()` 抄的:
|
||
//! `Backends::from_env().unwrap_or(all)`、`PowerPreference::from_env().unwrap_or(HighPerformance)`、
|
||
//! `Limits { max_bind_groups: 2, max_non_sampler_bindings: 2048, ..default }`。
|
||
//! 差一处都会让预检和真实路径说不同的话,那时预检就不是证据而是噪声了。
|
||
|
||
/// 预检开关的环境变量名(`1` / `true` 打开)。
|
||
pub const ENV_SWITCH: &str = "ULL_PREFLIGHT";
|
||
|
||
/// 预检成功:拿到的适配器摘要。
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct Verdict {
|
||
/// 适配器名(`Apple M1` / `AMD Radeon RX 6600 XT` / `Microsoft Basic Render Driver`)。
|
||
pub adapter: String,
|
||
/// `device_type` 的字符串形式。
|
||
pub device_type: &'static str,
|
||
/// 命中哪个 wgpu 后端(`Metal` / `Vulkan` / `Dx12` / `Gl`)。
|
||
pub backend: String,
|
||
}
|
||
|
||
impl Verdict {
|
||
/// 一行日志。
|
||
#[must_use]
|
||
pub fn describe(&self) -> String {
|
||
format!(
|
||
"ok adapter={:?} type={} backend={}",
|
||
self.adapter, self.device_type, self.backend
|
||
)
|
||
}
|
||
}
|
||
|
||
/// 预检失败:卡在哪一步 + wgpu 自己的错误文本。
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct Failure {
|
||
/// 失败阶段(日志里 `stage=` 的取值)。
|
||
pub stage: &'static str,
|
||
/// wgpu 具名错误(`Display`,原样保留,不翻译成「失败」)。
|
||
pub detail: String,
|
||
}
|
||
|
||
impl Failure {
|
||
/// 一行日志(`stage=` 可直接 grep)。
|
||
#[must_use]
|
||
pub fn describe(&self) -> String {
|
||
format!("FAILED stage={} error={}", self.stage, self.detail)
|
||
}
|
||
}
|
||
|
||
/// 用户是否要求预检。
|
||
#[must_use]
|
||
pub fn requested() -> bool {
|
||
std::env::var(ENV_SWITCH).is_ok_and(|value| matches!(value.trim(), "1" | "true" | "yes" | "on"))
|
||
}
|
||
|
||
/// 按 `ULL_PREFLIGHT` 决定跑不跑;跑了就顺手把结论打进 stderr(带耗时,别让开关成本变成传说)。
|
||
///
|
||
/// 返回 `None` = 没要求预检(调用方按判据表 1~6 行原样走)。
|
||
#[must_use]
|
||
pub fn run_if_requested() -> Option<Result<Verdict, Failure>> {
|
||
if !requested() {
|
||
return None;
|
||
}
|
||
|
||
let started = std::time::Instant::now();
|
||
let verdict = run();
|
||
let took = started.elapsed().as_secs_f64() * 1000.0;
|
||
match &verdict {
|
||
Ok(ok) => eprintln!("[gpu-preflight] {} took={took:.0}ms", ok.describe()),
|
||
Err(failure) => {
|
||
eprintln!("[gpu-preflight] {} took={took:.0}ms", failure.describe());
|
||
}
|
||
}
|
||
Some(verdict)
|
||
}
|
||
|
||
#[cfg(feature = "gpu")]
|
||
/// 真跑一遍 wgpu 初始化(不开窗、不建 surface)。
|
||
///
|
||
/// # Errors
|
||
/// 无适配器 / 设备请求被拒时返回 [`Failure`],`detail` 是 wgpu 的原话。
|
||
pub fn run() -> Result<Verdict, Failure> {
|
||
use iced::wgpu::{
|
||
Backends, ExperimentalFeatures, Features, Instance, InstanceDescriptor, Limits,
|
||
MemoryHints, PowerPreference, RequestAdapterOptions, Trace,
|
||
};
|
||
|
||
let backends = Backends::from_env().unwrap_or_else(Backends::all);
|
||
let instance = Instance::new(&InstanceDescriptor {
|
||
backends,
|
||
..Default::default()
|
||
});
|
||
|
||
let options = RequestAdapterOptions {
|
||
power_preference: PowerPreference::from_env().unwrap_or(PowerPreference::HighPerformance),
|
||
// 没有窗口,测不了 surface 兼容性——这一条只能靠真跑一次(判据表第 8 行)。
|
||
compatible_surface: None,
|
||
force_fallback_adapter: false,
|
||
};
|
||
|
||
let adapter = block_on(instance.request_adapter(&options)).map_err(|error| Failure {
|
||
stage: "request-adapter",
|
||
detail: format!("{options:?}: {error}"),
|
||
})?;
|
||
|
||
let info = adapter.get_info();
|
||
let device_type = device_type_name(info.device_type);
|
||
|
||
// iced 的 `request_device` 失败时不打日志、只把错误塞进 `RequestDeviceFailed`,
|
||
// 所以我们在这里先请求一次,把它的话原样抄下来。
|
||
let _ = block_on(adapter.request_device(&iced::wgpu::DeviceDescriptor {
|
||
label: Some("unlucky-launcher gpu preflight"),
|
||
required_features: Features::empty(),
|
||
required_limits: Limits {
|
||
max_bind_groups: 2,
|
||
max_non_sampler_bindings: 2048,
|
||
..Limits::default()
|
||
},
|
||
memory_hints: MemoryHints::MemoryUsage,
|
||
trace: Trace::Off,
|
||
experimental_features: ExperimentalFeatures::disabled(),
|
||
}))
|
||
.map_err(|error| Failure {
|
||
stage: "request-device",
|
||
detail: error.to_string(),
|
||
})?;
|
||
|
||
Ok(Verdict {
|
||
adapter: info.name,
|
||
device_type,
|
||
backend: format!("{:?}", info.backend),
|
||
})
|
||
}
|
||
|
||
#[cfg(not(feature = "gpu"))]
|
||
/// 本构建没编 wgpu:预检必然是「不可用」,但这不是错误——判据表第 4 行已经说明原因。
|
||
///
|
||
/// # Errors
|
||
/// 恒返回 [`Failure`](`stage=not-compiled`)。
|
||
pub fn run() -> Result<Verdict, Failure> {
|
||
Err(Failure {
|
||
stage: "not-compiled",
|
||
detail: "本构建未编入 wgpu(--no-default-features)".to_owned(),
|
||
})
|
||
}
|
||
|
||
#[cfg(feature = "gpu")]
|
||
/// `DeviceType` → 日志里的字符串(探测与预检**共用**这一处,别各写一份 `match`)。
|
||
#[must_use]
|
||
pub fn device_type_name(device_type: iced::wgpu::DeviceType) -> &'static str {
|
||
use iced::wgpu::DeviceType;
|
||
match device_type {
|
||
DeviceType::DiscreteGpu => "DiscreteGpu",
|
||
DeviceType::IntegratedGpu => "IntegratedGpu",
|
||
DeviceType::VirtualGpu => "VirtualGpu",
|
||
DeviceType::Cpu => "Cpu",
|
||
DeviceType::Other => "Other",
|
||
}
|
||
}
|
||
|
||
/// 把 wgpu 的 future 跑到底。
|
||
///
|
||
/// 只用 `std`:`Waker` 由 `std::task::Wake` 提供(安全 API),运行期负责 `park`。
|
||
/// 不引入 futures 执行器——这里只有一个 future,而且它在 wgpu 的回调线程上唤醒。
|
||
#[cfg(feature = "gpu")]
|
||
fn block_on<F: std::future::Future>(future: F) -> F::Output {
|
||
use std::sync::Arc;
|
||
use std::task::{Context, Poll, Wake, Waker};
|
||
|
||
struct Unpark(std::thread::Thread);
|
||
|
||
impl Wake for Unpark {
|
||
fn wake(self: Arc<Self>) {
|
||
self.0.unpark();
|
||
}
|
||
|
||
fn wake_by_ref(self: &Arc<Self>) {
|
||
self.0.unpark();
|
||
}
|
||
}
|
||
|
||
let waker = Waker::from(Arc::new(Unpark(std::thread::current())));
|
||
let mut context = Context::from_waker(&waker);
|
||
let mut future = std::pin::pin!(future);
|
||
|
||
loop {
|
||
match future.as_mut().poll(&mut context) {
|
||
Poll::Ready(output) => return output,
|
||
Poll::Pending => std::thread::park(),
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn failure_line_is_greppable() {
|
||
let failure = Failure {
|
||
stage: "request-device",
|
||
detail: "no device request succeeded".to_owned(),
|
||
};
|
||
let text = failure.describe();
|
||
assert!(
|
||
text.starts_with("FAILED stage=request-device error="),
|
||
"{text}"
|
||
);
|
||
assert!(text.contains("no device request succeeded"), "{text}");
|
||
}
|
||
|
||
#[test]
|
||
fn verdict_line_lists_the_three_facts() {
|
||
let verdict = Verdict {
|
||
adapter: "Apple M1".to_owned(),
|
||
device_type: "IntegratedGpu",
|
||
backend: "Metal".to_owned(),
|
||
};
|
||
let text = verdict.describe();
|
||
assert!(text.contains("adapter=\"Apple M1\""), "{text}");
|
||
assert!(text.contains("type=IntegratedGpu"), "{text}");
|
||
assert!(text.contains("backend=Metal"), "{text}");
|
||
}
|
||
|
||
/// 没编 wgpu 的构建:预检必须说清原因,而不是一句「失败」。
|
||
#[cfg(not(feature = "gpu"))]
|
||
#[test]
|
||
fn unbuilt_gpu_names_the_reason() {
|
||
let failure = run().unwrap_err();
|
||
assert_eq!(failure.stage, "not-compiled");
|
||
assert!(failure.detail.contains("wgpu"), "{}", failure.detail);
|
||
}
|
||
}
|