Compare commits

...
4 Commits
Author SHA1 Message Date
dtourolle 08b7d23e86 Release 0.22.1
Benchmarks / CPU and I/O (per commit) (push) Successful in 8m35s
Benchmarks / Frame budget (on demand) (push) Skipped
Traceability / Requirement traces (push) Successful in 1m22s
Build and test / Android (aarch64) (push) Successful in 46m43s
Build and test / android-image (push) Successful in 2s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / Desktop (Linux) (push) Successful in 1h4m43s
Build and test / windows-image (push) Successful in 2s
🐳 Windows image / Build and push (push) Successful in 2s
Build and test / Layer separation (push) Successful in 34s
Build and test / Windows (x86_64, cross) (push) Successful in 29m53s
Build and test / Publish the release (push) Successful in 1m24s
2026-10-04 20:35:01 -04:00
dtourolle a116325991 Declare the DSP's RPC library, so the app can reach the Hexagon
QNN's Hexagon stub loads libcdsprpc.so, a vendor library, and from API 31
an app's linker namespace refuses a vendor library its manifest does not
name. QNN then fails to create its device - QNN_DEVICE_ERROR_INVALID_CONFIG,
before it reaches the DSP - and every model ran on the CPU on 0.22.0: AI
denoise took 30-131 s a photograph on the tablet.

The same engine code ran on the HTP from adb's shell, whose namespace has
no such rule, which is what hid it. With the declaration the app's probe
chose the Hexagon on the tablet and compiled every model for it. Not
required, so a device without the library still installs and runs on the
CPU. A test holds the line in the manifest.
2026-10-04 20:19:04 -04:00
dtourolle f20e481358 Probe the Hexagon strictly, and probe again after falling back to the CPU
0.22.0's first launch on the tablet: QNN could not create its device
(QNN_DEVICE_ERROR_INVALID_CONFIG), the session built anyway with every
node on the CPU behind the provider, and the probe timed that - 28.5 ms
against the CPU's own 19.4 - and rejected the Hexagon. The verdict was
cached under the fingerprint, so every model stayed on the CPU on every
later launch: AI denoise took 30-131 s a photograph instead of seconds.
The same A16W8 detector with the APK's own libraries runs on the HTP in
4.4 ms.

The probe's Hexagon session now sets session.disable_cpu_ep_fallback, so
a device that cannot take the graph fails the probe instead of being timed
as the CPU. Only the probe: shipped graphs may keep nodes on the CPU on
purpose. And a selection that fell back to the CPU after an accelerator
failed or lost is probed again on the next launches, up to three probes
per fingerprint; a cache written by 0.22.0 reads as never retried, so the
tablet probes again once this is installed.
2026-10-04 19:42:15 -04:00
dtourolle 16a5957aa7 Compile dr-ui on sixteen codegen units in release builds
Slint expands the .slint files into ~27 MB of Rust, and at the workspace's
single codegen unit LLVM optimised all of it on one thread: 13.5 minutes
of a release build with the other cores idle. The override applies to
dr-ui alone; the image crates keep one unit, and thin LTO still runs at
link time.
2026-10-04 10:45:43 -04:00
9 changed files with 176 additions and 39 deletions
Generated
+26 -26
View File
@@ -1265,7 +1265,7 @@ checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
[[package]]
name = "darkroom-android"
version = "0.22.0"
version = "0.22.1"
dependencies = [
"android_logger",
"dr-plat",
@@ -1278,7 +1278,7 @@ dependencies = [
[[package]]
name = "darkroom-desktop"
version = "0.22.0"
version = "0.22.1"
dependencies = [
"anyhow",
"dr-plat",
@@ -1454,7 +1454,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
[[package]]
name = "dr-bench"
version = "0.22.0"
version = "0.22.1"
dependencies = [
"anyhow",
"dr-catalog",
@@ -1471,7 +1471,7 @@ dependencies = [
[[package]]
name = "dr-catalog"
version = "0.22.0"
version = "0.22.1"
dependencies = [
"dr-face",
"dr-plat",
@@ -1486,7 +1486,7 @@ dependencies = [
[[package]]
name = "dr-decode"
version = "0.22.0"
version = "0.22.1"
dependencies = [
"dr-types",
"env_logger",
@@ -1500,7 +1500,7 @@ dependencies = [
[[package]]
name = "dr-denoise"
version = "0.22.0"
version = "0.22.1"
dependencies = [
"dr-decode",
"dr-gpu",
@@ -1517,7 +1517,7 @@ dependencies = [
[[package]]
name = "dr-export"
version = "0.22.0"
version = "0.22.1"
dependencies = [
"dr-decode",
"dr-gpu",
@@ -1536,7 +1536,7 @@ dependencies = [
[[package]]
name = "dr-face"
version = "0.22.0"
version = "0.22.1"
dependencies = [
"dr-inference-engine",
"env_logger",
@@ -1549,7 +1549,7 @@ dependencies = [
[[package]]
name = "dr-film"
version = "0.22.0"
version = "0.22.1"
dependencies = [
"log",
"serde",
@@ -1558,7 +1558,7 @@ dependencies = [
[[package]]
name = "dr-gpu"
version = "0.22.0"
version = "0.22.1"
dependencies = [
"bytemuck",
"dr-decode",
@@ -1576,7 +1576,7 @@ dependencies = [
[[package]]
name = "dr-inference-engine"
version = "0.22.0"
version = "0.22.1"
dependencies = [
"env_logger",
"libloading",
@@ -1591,7 +1591,7 @@ dependencies = [
[[package]]
name = "dr-ingest"
version = "0.22.0"
version = "0.22.1"
dependencies = [
"dr-plat",
"dr-types",
@@ -1603,7 +1603,7 @@ dependencies = [
[[package]]
name = "dr-lens"
version = "0.22.0"
version = "0.22.1"
dependencies = [
"lensfun",
"log",
@@ -1611,7 +1611,7 @@ dependencies = [
[[package]]
name = "dr-pano"
version = "0.22.0"
version = "0.22.1"
dependencies = [
"dr-decode",
"dr-inference-engine",
@@ -1625,7 +1625,7 @@ dependencies = [
[[package]]
name = "dr-pipeline"
version = "0.22.0"
version = "0.22.1"
dependencies = [
"dr-types",
"log",
@@ -1634,7 +1634,7 @@ dependencies = [
[[package]]
name = "dr-plat"
version = "0.22.0"
version = "0.22.1"
dependencies = [
"android-native-keyring-store",
"dr-types",
@@ -1650,7 +1650,7 @@ dependencies = [
[[package]]
name = "dr-preset-xmp"
version = "0.22.0"
version = "0.22.1"
dependencies = [
"dr-pipeline",
"log",
@@ -1660,7 +1660,7 @@ dependencies = [
[[package]]
name = "dr-segment"
version = "0.22.0"
version = "0.22.1"
dependencies = [
"dr-inference-engine",
"env_logger",
@@ -1673,7 +1673,7 @@ dependencies = [
[[package]]
name = "dr-sync"
version = "0.22.0"
version = "0.22.1"
dependencies = [
"async-trait",
"dr-plat",
@@ -1687,7 +1687,7 @@ dependencies = [
[[package]]
name = "dr-sync-folder"
version = "0.22.0"
version = "0.22.1"
dependencies = [
"async-trait",
"dr-sync",
@@ -1699,7 +1699,7 @@ dependencies = [
[[package]]
name = "dr-sync-nextcloud"
version = "0.22.0"
version = "0.22.1"
dependencies = [
"async-trait",
"dr-decode",
@@ -1721,7 +1721,7 @@ dependencies = [
[[package]]
name = "dr-thumbs"
version = "0.22.0"
version = "0.22.1"
dependencies = [
"dr-types",
"jpeg-encoder",
@@ -1733,7 +1733,7 @@ dependencies = [
[[package]]
name = "dr-types"
version = "0.22.0"
version = "0.22.1"
dependencies = [
"serde",
"serde_json",
@@ -1742,7 +1742,7 @@ dependencies = [
[[package]]
name = "dr-ui"
version = "0.22.0"
version = "0.22.1"
dependencies = [
"anyhow",
"async-trait",
@@ -1792,7 +1792,7 @@ dependencies = [
[[package]]
name = "dr-xmp"
version = "0.22.0"
version = "0.22.1"
dependencies = [
"dr-types",
"log",
@@ -7126,7 +7126,7 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "traceability"
version = "0.22.0"
version = "0.22.1"
dependencies = [
"anyhow",
"proc-macro2",
+9 -1
View File
@@ -33,7 +33,7 @@ members = [
exclude = ["third_party"]
[workspace.package]
version = "0.22.0"
version = "0.22.1"
edition = "2021"
rust-version = "1.92"
license = "GPL-3.0-or-later"
@@ -278,6 +278,14 @@ opt-level = 0
lto = "thin"
codegen-units = 1
# Except dr-ui. Slint expands the `.slint` files into ~27 MB of Rust
# (`out/app.rs`), and at one codegen unit LLVM optimises all of it on a single
# thread: 13.5 minutes of a release build with the other cores idle. The code
# it holds is UI glue — property bindings and callbacks — not the image work,
# which lives in the crates above that keep the single unit.
[profile.release.package.dr-ui]
codegen-units = 16
# A release build that can say where it panicked: line tables, so a crash
# record's backtrace (`dr_plat::crash`) reads `file.rs:123` rather than bare
# addresses. The macOS build uses it (docs/dev/macos.md) — no one here can
+1 -1
View File
@@ -201,7 +201,7 @@ controls, its place in the chain and its tests.
## Where it stands
**0.22.0**, thirty-six tagged releases in. 193 numbered requirements in
**0.22.1**, thirty-seven tagged releases in. 193 numbered requirements in
scope, 85% of them claimed by code and [traced to it](docs/dev/traceability.md);
the rest are written down rather than merely absent.
@@ -73,6 +73,14 @@
android:requestLegacyExternalStorage="true"
android:supportsRtl="true">
<!-- The DSP's RPC library, which QNN's Hexagon stub loads. From API 31
an app's linker namespace refuses a vendor library the manifest
does not name, and QNN then fails to create its device
(QNN_DEVICE_ERROR_INVALID_CONFIG) before it reaches the DSP:
every model ran on the CPU on 0.22.0. Not required, so a device
without one still installs and stays on the CPU. -->
<uses-native-library android:name="libcdsprpc.so" android:required="false" />
<!-- NativeActivity rather than a Kotlin Activity: android-activity's
glue loads libdarkroom.so and calls android_main. `android.app.lib_name`
is how it learns which library to load, and must match [lib].name.
+13
View File
@@ -538,6 +538,19 @@ mod tests {
Some(value.to_string())
}
/// From API 31 the linker refuses a vendor library the manifest does not
/// name, and QNN cannot create its Hexagon device without the DSP's RPC
/// library: 0.22.0 ran every model on the CPU for want of this line.
#[test]
fn the_npu_can_reach_the_dsp() {
assert!(
manifest().contains(
r#"<uses-native-library android:name="libcdsprpc.so" android:required="false" />"#
),
"libcdsprpc.so must be declared, and not required"
);
}
#[test]
fn a_gallery_can_open_a_photograph_in_this_app() {
let manifest = manifest();
+6
View File
@@ -412,6 +412,12 @@ struct Cache {
/// still reads.
#[serde(default)]
refused: BTreeSet<String>,
/// Probes run under this fingerprint (`probe::run`): a fall-back to the
/// CPU is re-probed until there have been `RETRIES`. Defaulted, so a
/// cache from 0.22.0 or before — which may hold exactly such a verdict —
/// probes again.
#[serde(default)]
attempts: u32,
}
struct State {
+85 -10
View File
@@ -31,6 +31,38 @@ fn ladder(ceiling: Option<Rung>) -> Vec<Rung> {
.collect()
}
/// Probes under one fingerprint that may end on the CPU after an
/// accelerator failed or lost, before that answer is kept.
const RETRIES: u32 = 3;
/// What a cached probe result is good for.
#[derive(Debug, PartialEq)]
enum Reuse {
/// Use it as it is.
Keep,
/// Probe again: it fell back to the CPU after this many probes.
Again(u32),
/// Another device, runtime or model set: probe from the start.
Fresh,
}
/// The CPU because an accelerator failed or lost is asked again on the next
/// launches, a few times: a failure can be a moment's (QNN could not create
/// its device on 0.22.0's first launch after the update), and keeping it for
/// good left the tablet's every model on the CPU. Bounded, so a wedged
/// driver costs a few launches, not all.
fn reuse(cached: &Cache, fingerprint: &str) -> Reuse {
if cached.fingerprint != fingerprint || cached.rung.is_none() {
return Reuse::Fresh;
}
let fell_back = cached.rung == Some(Rung::Cpu) && !cached.failed.is_empty();
if fell_back && cached.attempts < RETRIES {
Reuse::Again(cached.attempts)
} else {
Reuse::Keep
}
}
/// The probe body. Sets the cache and clears `probing` when done; never
/// panics out, because a failed probe is a result (the floor) and not an
/// error.
@@ -38,20 +70,33 @@ pub fn run(runtime: Runtime) {
let cfg = state().lock().unwrap().config.clone();
let fingerprint = fingerprint(&runtime, &cfg);
let mut attempts = 0;
if let Some(cached) = read_cache(&cfg) {
if cached.fingerprint == fingerprint && cached.rung.is_some() {
log::info!(
"inference: cached selection {} ({})",
cached.rung.unwrap().label(),
cached.reason
);
finish(cached);
return;
match reuse(&cached, &fingerprint) {
Reuse::Keep => {
log::info!(
"inference: cached selection {} ({})",
cached.rung.map_or("?", |r| r.label()),
cached.reason
);
finish(cached);
return;
}
Reuse::Again(n) => {
attempts = n;
log::info!(
"inference: probing again after falling back to the CPU ({}), attempt {} of {RETRIES}",
cached.reason,
n + 1
);
}
Reuse::Fresh => {}
}
}
let mut cache = Cache {
fingerprint,
attempts: attempts + 1,
..Cache::default()
};
@@ -212,8 +257,8 @@ fn time_rung(
}
let bytes = std::fs::read(&path).map_err(|e| e.to_string())?;
let started = Instant::now();
let mut session =
crate::session::build(rung, role, &bytes, cfg).map_err(|e| first_line(&e.to_string()))?;
let mut session = crate::session::build_probe(rung, role, &bytes, cfg)
.map_err(|e| first_line(&e.to_string()))?;
log::info!(
"inference: {} session built in {:.1} s",
rung.label(),
@@ -494,4 +539,34 @@ mod tests {
died_inside(&cfg, "probe TensorRT", 2);
assert_eq!(attempt(&cfg, "probe CUDA", || 7), Ok(7));
}
/// The tablet's cache after 0.22.0's first launch, as 0.22.0 wrote it:
/// no `attempts`, the Hexagon "rejected", the CPU selected.
const TABLET: &str = r#"{"fingerprint":"f","rung":"Cpu","reason":"Hexagon NPU 28.5 ms, slower than the CPU's 19.4 ms","compiled":[],"failed":[["Hexagon","28.5 ms, slower than the CPU's 19.4 ms"]]}"#;
#[test]
fn a_fall_back_to_the_cpu_is_probed_again_a_few_times() {
let mut cache: Cache = serde_json::from_str(TABLET).unwrap();
assert_eq!(cache.attempts, 0, "a 0.22.0 cache reads as never retried");
assert_eq!(reuse(&cache, "f"), Reuse::Again(0));
cache.attempts = RETRIES - 1;
assert_eq!(reuse(&cache, "f"), Reuse::Again(RETRIES - 1));
cache.attempts = RETRIES;
assert_eq!(reuse(&cache, "f"), Reuse::Keep, "then it is kept");
}
#[test]
fn an_accelerator_chosen_or_a_cpu_only_device_is_kept() {
let mut cache: Cache = serde_json::from_str(TABLET).unwrap();
cache.rung = Some(Rung::Hexagon);
assert_eq!(reuse(&cache, "f"), Reuse::Keep);
cache.rung = Some(Rung::Cpu);
cache.failed.clear();
assert_eq!(
reuse(&cache, "f"),
Reuse::Keep,
"nothing failed: the only rung"
);
assert_eq!(reuse(&cache, "other"), Reuse::Fresh);
}
}
+27
View File
@@ -13,6 +13,30 @@ use crate::{Config, Role, Rung};
/// its clock instead (§4): a provider that hands real work to the CPU is
/// slower than the CPU floor and rejected by the same measurement.
pub fn build(rung: Rung, role: Role, bytes: &[u8], cfg: &Config) -> ort::Result<Session> {
build_with(rung, role, bytes, cfg, false)
}
/// [`build`] for the probe: on the Hexagon, a session that cannot put the
/// whole graph on the NPU fails instead of running the rest on the CPU.
///
/// The probe times a rung by its session, and a QNN provider that could not
/// create its device still builds one — with every node on the CPU behind
/// it. 0.22.0's first launch on the tablet timed that (28.5 ms against the
/// CPU's own 19.4) and put every model on the CPU. Only the probe is strict:
/// some shipped graphs keep a few nodes on the CPU on purpose
/// (`tools/quantise-models.py`, `float_nodes`), and the probe's detector is
/// not one of them.
pub fn build_probe(rung: Rung, role: Role, bytes: &[u8], cfg: &Config) -> ort::Result<Session> {
build_with(rung, role, bytes, cfg, rung == Rung::Hexagon)
}
fn build_with(
rung: Rung,
role: Role,
bytes: &[u8],
cfg: &Config,
strict: bool,
) -> ort::Result<Session> {
// No optimisation level named. ONNX Runtime's default is already its
// fullest, and on tract any level but "disabled" means `into_optimized`,
// whose optimiser divides by zero inside yolo26n-seg (tract-data
@@ -22,6 +46,9 @@ pub fn build(rung: Rung, role: Role, bytes: &[u8], cfg: &Config) -> ort::Result<
if crate::api::runtime().is_native() {
b = with_runtime_log(b)?;
}
if strict {
b = b.with_config_entry("session.disable_cpu_ep_fallback", "1")?;
}
// A Hexagon session loads the compiled context when there is one and
// compiles it from the model when there is not; the engine thread is
// what makes the second case rare (§6).
+1 -1
View File
@@ -4,7 +4,7 @@
# makes `makepkg -si` in this directory install what you are actually working
# on. Swap `source` for a tagged tarball when there is something to release.
pkgname=darkroom
pkgver=0.22.0
pkgver=0.22.1
# Back to 1 with the version: a new pkgver is a new archive name, so there is
# nothing for makepkg to reuse and nothing for a release number to disambiguate.
pkgrel=1