RUST.md: iris's bindless texture array does not survive real Android GPUs

Iris asked whether the 'unknown number of images' approach even works on
mobile. It does not, measured with a new rig (rigs/gpu-probe, no APK
needed) and sourced rather than recalled: the emulator's software Vulkan
refuses iris's descriptor-indexing request outright, and on real hardware
the current Android Vulkan Profile baseline (80.1% of active devices)
does not require VK_EXT_descriptor_indexing either -- Arm's own docs say
only Valhall/5th-Gen Mali (2019+) support it.

iris already solved the identical problem for text in I1 (the glyph
atlas). The recommendation is to generalize it to images rather than
widen the binding array further; not yet implemented, since it changes
iris's render core.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Sonnet 5 committed 2026-09-04 21:17:18 -04:00
1 parent c70a670356
commit 79b9cd789a
6 files changed
+1592 -12

No files matched your search

+123 -12
View File
@@ -37,19 +37,36 @@ session spending an afternoon on them again.
## Where things stand (2026-09-04) ## Where things stand (2026-09-04)
- **Done**: E0 (toolchain), E1 (Masonry on android-view, which found the - **Done**: E0 (toolchain), E1 (Masonry on android-view, which found the
keyboard gap), I0a, I0b (iris builds on a pinned nightly and runs), I1 keyboard gap — now explained, see below), I0a, I0b (iris builds on a
(parley + glyph atlas). pinned nightly and runs), I1 (parley + glyph atlas).
- **Next**: **I2** — iris on android-view. E1 says the first thing to - **E1's keyboard gap is Masonry's `as_input_connection` returning `None`
answer there is why the editor gets keystrokes but no autocorrect or (a TODO), not android-view or `EditorInfo`.** android-view's own demo
suggestions, since that is the constraint the framework decision turns implements the `InputConnection` trait over a parley editor and gets
on. **E2** (a transcript in Masonry) can go in parallel in another real Gboard suggestions on this emulator — screenshotted 2026-09-04.
session. android-view's `accesskit_android` adapter also has a reproducible abort
(a client detaching, not attaching, is the trigger) — see E1 below for
both, with the mitigation iris/I4 needs to carry.
- **Blocking, found 2026-09-04, before I2 can be called done**: iris's
texture pipeline asks every device, unconditionally, for
`VK_EXT_descriptor_indexing` ("bindless" binding arrays) — and a
sourced check says a real share of Android hardware does not have it,
not just the emulator's software renderer. See "iris's binding array
does not survive real Android hardware" under the iris track for the
measurement, the sources, and the recommended fix (generalize the
glyph atlas to images, the same way I1 already did for text). Not yet
implemented; a design decision to confirm before iris's render core
changes.
- **Next**: decide on the atlas-based image fix above, then **I2**
iris on android-view. **E2** (a transcript in Masonry) can go in
parallel in another session.
- **Not started**: `client-core`, which is item 1 of the recommendation - **Not started**: `client-core`, which is item 1 of the recommendation
below and does not depend on the framework choice. Nothing has been below and does not depend on the framework choice. Nothing has been
built for it, and it is not one of the numbered boxes — worth picking up built for it, and it is not one of the numbered boxes — worth picking up
in a session that wants work independent of the emulator. in a session that wants work independent of the emulator.
- **The app itself is untouched.** Everything so far is in `iris/` and in - **The app itself is untouched.** Everything so far is in `iris/`, in
the rigs; nothing under `app/` or `server/` has changed. `rigs/gpu-probe` (a headless wgpu/Vulkan feature probe, pushable to a
device with no APK — see the binding-array section), and in the other
rigs; nothing under `app/` or `server/` has changed.
- **Changed outside this repo**, both in `emulator-tools` and both pushed: - **Changed outside this repo**, both in `emulator-tools` and both pushed:
`avd_serial` now validates its cache by asking the device its AVD name `avd_serial` now validates its cache by asking the device its AVD name
rather than by checking the serial is still attached (a recycled port rather than by checking the serial is still attached (a recycled port
@@ -851,6 +868,97 @@ step measured.
nightly gates — `portable_simd` (the old glyph compositing) and nightly gates — `portable_simd` (the old glyph compositing) and
`gen_blocks` (the deleted line iterator). **Eleven left.** `gen_blocks` (the deleted line iterator). **Eleven left.**
### iris's binding array does not survive real Android hardware (found 2026-09-04)
Iris asked, of the "unknown number of images" case — a transcript with an
unbounded number of attached screenshots — whether iris's approach even
works on a phone, since her recollection was that mobile does not support
it. Checked rather than assumed, and the recollection is right, with
sources rather than a guess.
**What iris does today.** Every texture — every `Image` widget
(`src/widget/image.rs`) and every glyph atlas page — gets its own
permanent slot in one array via `Textures::add`
(`core/src/primitive/texture.rs:65`), and both the `TEXTURE` and `GLYPH`
primitives sample it by `view_idx` into `binding_array<texture_2d<f32>>`
at `core/src/render/shader.wgsl:56`, sized by `UiLimits::default` — 100,000
textures, 1,000 samplers (`core/src/render/mod.rs:347`). That needs three
wgpu features: `TEXTURE_BINDING_ARRAY`,
`SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING`,
`PARTIALLY_BOUND_BINDING_ARRAY` — Vulkan's `VK_EXT_descriptor_indexing`
("bindless"), promoted to core in 1.2. So a transcript with an unbounded
number of images is exactly the case that grows this array without bound,
one permanent slot per image.
**Measured first on the emulator, and it fails outright.** A rig
(`rigs/gpu-probe`, a plain executable with no window, pushed with `adb
push` and run from `/data/local/tmp` — no APK needed to ask a device what
it supports) asks `wgpu::Adapter::request_device` for exactly iris's
features and limits. Against the emulator's guest Vulkan — both
SwiftShader (`vk_swiftshader_icd.json`) and lavapipe (`lvp_icd.json`,
cold-booted) — `request_device` **fails**: `Unsupported features were
requested: TEXTURE_BINDING_ARRAY |
SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING |
PARTIALLY_BOUND_BINDING_ARRAY`. A second, raw query through `ash`
(`rigs/gpu-probe/src/vk.rs`, bypassing wgpu) shows lavapipe's
`vkGetPhysicalDeviceFeatures2` actually reporting all seven descriptor-
indexing sub-features as `true` at device api version 1.3 — so on this
software renderer wgpu-hal's own feature detection is being more
conservative than the driver, for a reason not chased further (a likely
instance-version negotiation gap, since `VK_EXT_descriptor_indexing` was
only promoted to core at 1.2 and wgpu-hal's own `Instance::init` may be
requesting less). That part is an emulator/wgpu-hal question and not the
finding that matters.
**The finding that matters is about real phones, not the emulator, and it
is sourced rather than recalled.** The **Android Vulkan Profile 2025**
Google and Khronos's current baseline, covering **80.1% of active
Vulkan-capable Android devices** as of October 2025
([developer.android.com/ndk/guides/graphics/android-vulkan-profile](https://developer.android.com/ndk/guides/graphics/android-vulkan-profile))
— does **not** require `VK_EXT_descriptor_indexing` or any descriptor-
indexing feature. It requires `shaderSampledImageArrayDynamicIndexing`
(indexing an array of samplers by a value uniform across the invocation —
Vulkan 1.0 baseline, unrelated to bindless) and stops there; the same is
true of the 2021 and 2022 profiles. On the hardware side, Arm's own
developer documentation states **"`VK_EXT_descriptor_indexing` is
supported on all Valhall and 5th Gen GPUs"**
([developer.arm.com/mobile-graphics-and-gaming/vulkan-api-best-practices-on-arm-gpus](https://developer.arm.com/mobile-graphics-and-gaming/vulkan-api-best-practices-on-arm-gpus)) —
Mali generations from roughly 2019 (Mali-G77) onward, named affirmatively
with no claim made for Bifrost, Midgard or Utgard, which are still common
in budget and older Android phones still in use. So this is not a
software-renderer artifact: a real, currently-shipping share of the
Android fleet lacks the feature iris's texture pipeline asks for
unconditionally, and the newest official baseline does not promise it
either. (A crates.io/search-engine claim of "1% support on Android" for
this extension was checked against its cited source, an Arm blog post,
and was not actually there — that number does not appear anywhere primary
and should not be repeated; the 80.1%-baseline-excludes-it finding above
is the one with an attributable source.)
**Recommendation, not yet implemented.** iris already solved the
identical problem for text in I1: the glyph atlas
(`core/src/render/atlas.rs`) packs many small rasters into a handful of
shared 1024×1024 pages and samples them by UV offset, so **text needs
none of the three features above** — only ordinary single-texture
sampling. The same technique generalizes to images: route an `Image`
widget through a shared atlas when it is small enough to pack (thumbnails,
downscaled attachment previews, avatars, icons), and fall back to one
ordinary, non-array texture bind group — selected per batched draw call
the way every immediate-mode 2D renderer already does — for anything too
large to atlas well (a photo opened at full resolution). Either path is
plain Vulkan 1.0 / GLES texture sampling, so it removes the descriptor-
indexing requirement from iris's device request entirely, which is also
what would make the emulator work regardless of the wgpu-hal question
above: a device that never asks for the feature cannot be refused for
lacking it. This is a change to iris's rendering core — the shader's
binding group layout, `Textures`, the texture and glyph primitives, and
`ui/painter.rs` — so it is written here as a recommendation rather than
started, per the project's rule to confirm a load-bearing design change
before making it. **It should be resolved before I2 is called done**,
since I2's pass condition is the phone, not just the emulator, and this
is exactly the kind of thing that passes on a desktop GPU and fails
silently on real hardware.
- [ ] **I2 — iris on android-view.** An `android-view` surface as a second - [ ] **I2 — iris on android-view.** An `android-view` surface as a second
backend beside winit: `wgpu` on the view's surface (GLES here, see backend beside winit: `wgpu` on the view's surface (GLES here, see
the Vulkan section; Vulkan on the phone), touch as pointer events, the Vulkan section; Vulkan on the phone), touch as pointer events,
@@ -895,9 +1003,12 @@ re-derived:
5. Run the existing rigs rather than inventing new ones: `ui-sandbox.sh` 5. Run the existing rigs rather than inventing new ones: `ui-sandbox.sh`
for a server with fixtures, `transcript-bench.sh` for the scroll for a server with fixtures, `transcript-bench.sh` for the scroll
baseline, `ui-trace` for anything positional, `emu up` for the baseline, `ui-trace` for anything positional, `emu up` for the
emulator, and `iris/run-headless.sh EXAMPLE --shot PNG` for an iris emulator, `iris/run-headless.sh EXAMPLE --shot PNG` for an iris
example on this displayless machine. The Vulkan section below says how example on this displayless machine, and `rigs/gpu-probe` to ask a
to get a Vulkan path in the emulator when a `wgpu` backend needs one. device (this VM, the emulator, or a real phone over `adb push`) what
`wgpu` features and limits it actually has before building anything on
the assumption it does. The Vulkan section below says how to get a
Vulkan path in the emulator when a `wgpu` backend needs one.
6. **Bound anything heavy at the moment you start it.** An emulator or a 6. **Bound anything heavy at the moment you start it.** An emulator or a
long build gets a deadline — `timeout`, or a watchdog scoped to the pid long build gets a deadline — `timeout`, or a watchdog scoped to the pid
you just started — rather than a plan to stop it later. Scope it to you just started — rather than a plan to stop it later. Scope it to
+1
View File
@@ -0,0 +1 @@
target
+1188
View File
File diff suppressed because it is too large. Load diff
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "gpu-probe"
version = "0.1.0"
edition = "2024"
# Deliberately its own crate rather than a member of iris's workspace: the
# vendored `iris/` tree is meant to stay reconcilable with the iris/iris
# repository, and this is a rig belonging to ai-app.
[dependencies]
# Pinned to what iris asks for, so the answer is about iris rather than
# about a different wgpu.
wgpu = "28.0.0"
pollster = "0.4.0"
# Queried directly, because wgpu and `cmd gpu vkjson` disagreed about
# descriptor indexing in the emulator and only the raw call says which is
# right.
ash = "0.38"
+154
View File
@@ -0,0 +1,154 @@
//! Ask a device whether it can give iris the GPU it asks for.
//!
//! iris's renderer binds every texture it has drawn as one binding array and
//! indexes it non-uniformly from the shader, which needs descriptor indexing
//! and a very large per-stage binding-array limit (101,000 elements: 100,000
//! textures and 1,000 samplers, `UiLimits::default`). Those are ordinary on a
//! desktop and not obviously available on a phone, so this reports what the
//! adapter offers before anything is built on the assumption.
//!
//! It runs as a plain executable with no window and no APK, because
//! `request_adapter` needs no surface -- so it can be pushed to a device with
//! `adb push` and run from `/data/local/tmp`, which is far cheaper than an
//! app. What it therefore cannot answer is anything about presenting to a
//! surface; that is the Android backend's own problem.
mod vk;
use wgpu::*;
/// What `iris/src/default/render.rs` asks `request_device` for.
fn iris_features() -> Features {
Features::TEXTURE_BINDING_ARRAY
| Features::PARTIALLY_BOUND_BINDING_ARRAY
| Features::SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING
}
/// `UiLimits::default()`: 100,000 textures + 1,000 samplers.
const IRIS_MAX_BINDING_ARRAY: u32 = 101_000;
const IRIS_MAX_BINDING_ARRAY_SAMPLERS: u32 = 1_000;
fn main() {
vk::report();
let instance = Instance::new(&InstanceDescriptor {
backends: Backends::from_env().unwrap_or(Backends::PRIMARY),
..Default::default()
});
let adapters = pollster::block_on(instance.enumerate_adapters(Backends::all()));
println!("adapters: {}", adapters.len());
for adapter in &adapters {
let info = adapter.get_info();
println!(
" {:?} {} ({:?})",
info.backend, info.name, info.device_type
);
}
let Some(adapter) = pollster::block_on(instance.request_adapter(&RequestAdapterOptions {
power_preference: PowerPreference::default(),
compatible_surface: None,
force_fallback_adapter: false,
}))
.ok() else {
println!("\nNO ADAPTER");
std::process::exit(1);
};
let info = adapter.get_info();
println!(
"\nchosen: {:?} {} ({:?})",
info.backend, info.name, info.device_type
);
println!("driver: {} {}", info.driver, info.driver_info);
let have = adapter.features();
println!("\nfeatures iris requires:");
let mut missing = Features::empty();
for f in iris_features().iter() {
let ok = have.contains(f);
println!(
" {:60} {}",
format!("{f:?}"),
if ok { "yes" } else { "NO" }
);
if !ok {
missing |= f;
}
}
let limits = adapter.limits();
println!("\nlimits iris requires:");
for (name, want, got) in [
(
"max_binding_array_elements_per_shader_stage",
IRIS_MAX_BINDING_ARRAY,
limits.max_binding_array_elements_per_shader_stage,
),
(
"max_binding_array_sampler_elements_per_shader_stage",
IRIS_MAX_BINDING_ARRAY_SAMPLERS,
limits.max_binding_array_sampler_elements_per_shader_stage,
),
] {
println!(
" {name:52} want {want:>7} have {got:>7} {}",
if got >= want { "ok" } else { "TOO SMALL" }
);
}
println!(
" {:52} want {:>7} have {:>7}",
"max_buffer_size (iris asks 1<<30)",
1u64 << 30,
limits.max_buffer_size
);
// The question that actually matters: does the device iris builds come
// back, or does wgpu refuse it?
let mut wanted = Limits {
max_binding_array_elements_per_shader_stage: IRIS_MAX_BINDING_ARRAY,
max_binding_array_sampler_elements_per_shader_stage: IRIS_MAX_BINDING_ARRAY_SAMPLERS,
max_buffer_size: 1 << 30,
..Default::default()
};
match pollster::block_on(adapter.request_device(&DeviceDescriptor {
required_features: iris_features(),
required_limits: wanted.clone(),
..Default::default()
})) {
Ok(_) => println!("\nIRIS DEVICE: ok"),
Err(e) => println!("\nIRIS DEVICE: FAILED -- {e}"),
}
// If it failed, say how far down it has to be turned before it works, so
// the report names a number to design against rather than just "no".
if missing.is_empty() {
for cap in [
limits.max_binding_array_elements_per_shader_stage,
1024,
128,
16,
] {
if cap >= IRIS_MAX_BINDING_ARRAY {
continue;
}
wanted.max_binding_array_elements_per_shader_stage = cap;
wanted.max_binding_array_sampler_elements_per_shader_stage =
cap.min(IRIS_MAX_BINDING_ARRAY_SAMPLERS);
let ok = pollster::block_on(adapter.request_device(&DeviceDescriptor {
required_features: iris_features(),
required_limits: wanted.clone(),
..Default::default()
}))
.is_ok();
println!(
" binding array capped at {cap:>7}: {}",
if ok { "ok" } else { "no" }
);
if ok {
break;
}
}
}
}
+108
View File
@@ -0,0 +1,108 @@
//! The raw Vulkan half of the probe.
//!
//! wgpu reports a feature only after a chain of its own decisions -- which
//! physical device, which API version, which extension list -- so "wgpu says
//! no" and "the driver says no" are different claims. This asks
//! `vkGetPhysicalDeviceFeatures2` itself and prints the inputs to that chain,
//! so a disagreement can be attributed rather than guessed at.
use ash::{Entry, vk};
use std::ffi::CStr;
pub fn report() {
let entry = match unsafe { Entry::load() } {
Ok(e) => e,
Err(e) => {
println!("\nraw vulkan: cannot load loader -- {e}");
return;
}
};
let instance_version = match unsafe { entry.try_enumerate_instance_version() } {
Ok(Some(v)) => v,
Ok(None) => vk::API_VERSION_1_0,
Err(e) => {
println!("\nraw vulkan: enumerate_instance_version failed -- {e}");
return;
}
};
println!("\nraw vulkan:");
println!(" loader instance version: {}", ver(instance_version));
// Ask for the highest instance version the loader admits to: wgpu clamps
// the device version by the instance's, so an instance created at 1.0
// makes a 1.3 device look like 1.0.
let app_info = vk::ApplicationInfo::default().api_version(instance_version);
let create = vk::InstanceCreateInfo::default().application_info(&app_info);
let instance = match unsafe { entry.create_instance(&create, None) } {
Ok(i) => i,
Err(e) => {
println!(" create_instance failed -- {e}");
return;
}
};
let devices = unsafe { instance.enumerate_physical_devices() }.unwrap_or_default();
for phd in devices {
let props = unsafe { instance.get_physical_device_properties(phd) };
let name = unsafe { CStr::from_ptr(props.device_name.as_ptr()) };
println!(" device: {}", name.to_string_lossy());
println!(" device api version: {}", ver(props.api_version));
let exts =
unsafe { instance.enumerate_device_extension_properties(phd) }.unwrap_or_default();
let has_ext = exts.iter().any(|e| {
(unsafe { CStr::from_ptr(e.extension_name.as_ptr()) }) == c"VK_EXT_descriptor_indexing"
});
println!(" VK_EXT_descriptor_indexing advertised: {has_ext}");
println!(" device extensions: {}", exts.len());
let mut indexing = vk::PhysicalDeviceDescriptorIndexingFeatures::default();
let mut features2 = vk::PhysicalDeviceFeatures2::default().push_next(&mut indexing);
unsafe { instance.get_physical_device_features2(phd, &mut features2) };
for (name, v) in [
(
"shaderSampledImageArrayNonUniformIndexing",
indexing.shader_sampled_image_array_non_uniform_indexing,
),
(
"descriptorBindingSampledImageUpdateAfterBind",
indexing.descriptor_binding_sampled_image_update_after_bind,
),
(
"shaderStorageImageArrayNonUniformIndexing",
indexing.shader_storage_image_array_non_uniform_indexing,
),
(
"descriptorBindingStorageImageUpdateAfterBind",
indexing.descriptor_binding_storage_image_update_after_bind,
),
(
"shaderStorageBufferArrayNonUniformIndexing",
indexing.shader_storage_buffer_array_non_uniform_indexing,
),
(
"descriptorBindingStorageBufferUpdateAfterBind",
indexing.descriptor_binding_storage_buffer_update_after_bind,
),
(
"descriptorBindingPartiallyBound",
indexing.descriptor_binding_partially_bound,
),
] {
println!(" {name:48} {}", if v != 0 { "yes" } else { "NO" });
}
}
unsafe { instance.destroy_instance(None) };
}
fn ver(v: u32) -> String {
format!(
"{}.{}.{}",
vk::api_version_major(v),
vk::api_version_minor(v),
vk::api_version_patch(v)
)
}