From ca84ea6b76654a2925a845866e6ae08cd0f4f159 Mon Sep 17 00:00:00 2001 From: levius <2114377220@qq.com> Date: Fri, 9 Oct 2026 09:58:26 +0800 Subject: [PATCH 1/3] cua_s1: retain bounded vision scratch across exact geometries --- recipe/cua_s1/native_vision_graph.md | 57 +++++-- src/models/cua_s1/native/src/vision/model.rs | 142 ++++++++++++++-- tests/cua_s1/vision_cache_trace_probe.py | 20 +++ tests/cua_s1/vision_graph.rs | 162 +++++++++++++++++++ 4 files changed, 355 insertions(+), 26 deletions(-) create mode 100644 tests/cua_s1/vision_cache_trace_probe.py diff --git a/recipe/cua_s1/native_vision_graph.md b/recipe/cua_s1/native_vision_graph.md index e6fcc71c..2601f7e4 100644 --- a/recipe/cua_s1/native_vision_graph.md +++ b/recipe/cua_s1/native_vision_graph.md @@ -7,23 +7,43 @@ unset or set it to `0` for eager execution. This switch is independent of `CUA_S1_GRAPH`, which controls language execution; this change does not enable multimodal language Graph replay. -Both modes retain one scratch allocation for the latest exact `[T,H,W]` patch -grid. Equal patch counts with different heights or widths replace the scratch -and Graph. The worker synchronizes, destroys the old executable, and releases -its buffers before allocating the new shape. Pixel values are uploaded on every -request, including replay. Geometry tables remain valid only for that exact grid. -This bounds activation storage to one grid (at most 4608 patches); it does not -bound loaded weights or the GEMM plan cache. - -The first ordinary forward for a grid finishes eagerly, then captures operators +Both modes retain scratch by the exact `[T,H,W]` patch grid. The default entry +limit is 1; set `CUA_S1_VISION_CACHE_ENTRIES=4` to retain four geometries across +size changes. The entry setting is capped at 16; zero disables retention. Equal +patch counts with different heights or widths are distinct keys. The active +scratch is most recently used; a hit restores its existing buffers and Graph, +while a miss synchronizes and evicts least recently used entries before allocating +the new shape. Each executable is destroyed before its referenced buffers. +Pixel values are uploaded on every request, including replay. Geometry tables +remain valid only for that exact grid. + +`CUA_S1_VISION_CACHE_BYTES` bounds total requested bytes in retained scratch, +defaulting to 268435456 (256 MiB). The fixed 4B layout requests 64096 bytes per +patch, including pixels, position/rotary tables, LoRA work, activations and output. +The four tested grids contain 2304 patches in total and retain 147677184 bytes +(140.84 MiB). This budget excludes weights, GEMM workspace/plans, Graph metadata +and allocator/runtime overhead. Trace messages report retained entries/bytes; +measure actual device memory separately. + +A valid geometry larger than the byte budget, or any geometry with retention +disabled, executes eagerly using transient scratch. It preserves existing +cache entries, synchronizes on success or error and releases the transient +buffers after the result is read. The default byte budget therefore prevents +retention even for a single oversized grid (the processor permits up to 4608 +patches); increase it deliberately if that workload needs retention. Temporary +scratch required to answer such a request is additional peak memory, so this is +a retained-activation budget rather than a whole-device memory limit. + +The first ordinary forward for a retained grid finishes eagerly, then captures operators for subsequent requests and returns the completed eager result. Capture time is part of that first request. Uploads, downloads and CPU processing stay outside the Graph. Capture failure always logs to stderr and disables vision Graph for that loaded model; the completed eager result is preserved. Replay failures propagate as inference errors. `forward_with_trace` runs eagerly with all stage -downloads and leaves a matching Graph available for later ordinary forwards. +downloads and preserves an already matching Graph for later ordinary forwards; +it does not capture a new Graph itself. -For optional capture/replay messages, set `CUA_S1_GRAPH_TRACE=1`. Failure messages +For optional cache and capture/replay messages, set `CUA_S1_GRAPH_TRACE=1`. Failure messages are unconditional. Requests execute serially through the existing worker runtime; this feature does not add batching or concurrent forwards. @@ -43,6 +63,8 @@ CUA_S1_BASE=weights/Qwen3.5-4B \ CUA_S1_VISION_ADAPTER=weights/cua-s1-4b-0.2/multimodal \ CUA_S1_MODEL=weights/cua-s1-multimodal-language \ CUA_S1_CUDA_LIB=$PWD/target/release/libqwen3_5_cuda.so \ +CUA_S1_VISION_CACHE_ENTRIES=4 \ +CUA_S1_VISION_CACHE_BYTES=268435456 \ CUA_S1_VISION_GRAPH=1 target/release/omni-cua-s1-vision ``` @@ -57,6 +79,13 @@ Export the same `CUA_S1_BASE`, `CUA_S1_VISION_ADAPTER`, `CUA_S1_MODEL`, and ```sh cargo test --release --locked -p omni-cua-s1-native --lib vision_graph_ \ -- --ignored --test-threads=1 --nocapture +CUA_S1_VISION_CACHE_ENTRIES=4 cargo test --release --locked \ + -p omni-cua-s1-native --lib vision_cache_ \ + -- --ignored --test-threads=1 --nocapture +CUA_S1_VISION_CACHE_ENTRIES=4 CUA_S1_GRAPH_TRACE=1 \ + cargo test --release --locked -p omni-cua-s1-native --lib vision_cache_aba_probe \ + -- --ignored --test-threads=1 --nocapture > /tmp/vision-cache-aba.log 2>&1 +python3 tests/cua_s1/vision_cache_trace_probe.py /tmp/vision-cache-aba.log CUA_S1_VISION_GRAPH=0 cargo run --release --locked -p omni-cua-s1-native \ --example vision_graph_bench -- /tmp/vision-eager.json CUA_S1_VISION_GRAPH=1 cargo run --release --locked -p omni-cua-s1-native \ @@ -74,11 +103,13 @@ g++ -std=c++17 -shared -fPIC -Wall -Wextra tests/cua_s1/fail_graph_capture.cpp \ -Wl,--no-as-needed "$CUA_S1_REAL_CUDA_LIB" -ldl \ -o /tmp/cua-fail-capture.so CUA_S1_CUDA_LIB=/tmp/cua-fail-capture.so CUA_S1_TEST_CAPTURE_FAILURE=1 \ -CUA_S1_GRAPH_TRACE=0 cargo test --release --locked -p omni-cua-s1-native \ +CUA_S1_VISION_CACHE_ENTRIES=4 CUA_S1_GRAPH_TRACE=0 \ + cargo test --release --locked -p omni-cua-s1-native \ --lib vision_capture_failure_ -- --ignored --test-threads=1 --nocapture ``` -The root GPU tests cover changed pixels, full-grid replacement, retirement, +The root GPU tests cover changed pixels, exact-grid identity, A/B/A reuse, +count/byte LRU eviction, transient oversize and zero retention, retirement, trace bypass, invalid input and capture-failure fallback. Ordinary CI compiles these tests but skips device execution. Run them explicitly on a reserved GPU. diff --git a/src/models/cua_s1/native/src/vision/model.rs b/src/models/cua_s1/native/src/vision/model.rs index e2ba8fe3..ab304e4d 100644 --- a/src/models/cua_s1/native/src/vision/model.rs +++ b/src/models/cua_s1/native/src/vision/model.rs @@ -30,6 +30,9 @@ impl Drop for Gemm { pub struct VisionModel { // Field order retires graph/scratch before weights, GEMM workspace and stream. scratch: Option, + // Inactive entries in least-to-most-recently-used order; scratch is MRU. + cached: Vec, + cache_limits: CacheLimits, base: BTreeMap, lora: BTreeMap, gemm: Gemm, @@ -46,6 +49,8 @@ impl VisionModel { ensure!(!gemm.0.is_null(), "cannot create vision cuBLAS handle"); let mut model = Self { scratch: None, + cached: Vec::new(), + cache_limits: CacheLimits::from_env(), graph_enabled: std::env::var("CUA_S1_VISION_GRAPH").as_deref() == Ok("1"), base: BTreeMap::new(), lora: BTreeMap::new(), @@ -241,6 +246,7 @@ impl VisionModel { .scratch .as_ref() .is_some_and(|s| s.grid == image.image_grid_thw) + || self.cached.iter().any(|s| s.grid == image.image_grid_thw) { None } else { @@ -265,36 +271,116 @@ impl VisionModel { .iter() .flat_map(|v| bf16::from_f32(*v).to_bits().to_le_bytes()) .collect(); - if let Some(geo) = geo { + let bytes = Scratch::bytes_for_grid(image.image_grid_thw); + if !self.cache_limits.retains(bytes) { + // A valid large grid still works, but neither buffers nor graph are retained. + // Keep all cached geometries intact while using transient eager scratch. self.synchronize()?; - // Destroy the executable before releasing any address it references. - self.scratch = None; - self.scratch = Some(Scratch::new(self, image.image_grid_thw, geo)?); + let geo = match geo { + Some(geo) => geo, + None => Geometry::new(image.image_grid_thw)?, + }; + let scratch = Scratch::new(self, image.image_grid_thw, geo)?; + self.cache_trace("transient", image.image_grid_thw); + let result = self.run_scratch(&scratch, &pixels, &mut callback, false); + // Also drain an enqueue/download/callback error before releasing buffers. + let drained = self.synchronize(); + drop(scratch); + return result.and_then(|(values, _)| drained.map(|()| values)); + } + if !self + .scratch + .as_ref() + .is_some_and(|s| s.grid == image.image_grid_thw) + { + self.synchronize()?; + if let Some(previous) = self.scratch.take() { + self.cached.push(previous); + } + if let Some(index) = self + .cached + .iter() + .position(|s| s.grid == image.image_grid_thw) + { + self.scratch = Some(self.cached.remove(index)); + self.cache_trace("hit", image.image_grid_thw); + } else { + while self.cache_limits.needs_eviction( + self.cached.len(), + self.retained_bytes(), + bytes, + ) { + // Already synchronized; Scratch drops Graph before its referenced buffers. + let retired = self.cached.remove(0); + let grid = retired.grid; + drop(retired); + self.cache_trace("evicted", grid); + } + self.scratch = Some(Scratch::new(self, image.image_grid_thw, geo.unwrap())?); + self.cache_trace("miss", image.image_grid_thw); + } + } else { + self.cache_trace("hit", image.image_grid_thw); } let scratch = self.scratch.as_ref().unwrap(); + let (result, captured) = + self.run_scratch(scratch, &pixels, &mut callback, self.graph_enabled)?; + if let Some(captured) = captured { + self.finish_capture(captured); + } + Ok(result) + } + fn retained_bytes(&self) -> usize { + self.scratch + .iter() + .chain(&self.cached) + .map(|s| Scratch::bytes_for_grid(s.grid)) + .sum() + } + fn cache_trace(&self, action: &str, grid: [usize; 3]) { + if std::env::var("CUA_S1_GRAPH_TRACE").as_deref() == Ok("1") { + eprintln!( + "Vision cache {action} grid={grid:?} retained_entries={} retained_bytes={} entry_limit={} byte_limit={}", + self.cached.len() + usize::from(self.scratch.is_some()), + self.retained_bytes(), + self.cache_limits.entries, + self.cache_limits.bytes + ); + } + } + fn run_scratch( + &self, + scratch: &Scratch, + pixels: &[u8], + callback: &mut Trace<'_>, + graph_enabled: bool, + ) -> Result<(Vec, Option>)> { + let n = scratch.grid[1] * scratch.grid[2]; // SAFETY: pixels have the validated exact size of the resident allocation. unsafe { - cuda::upload(scratch.pixels.at(0), &pixels, self.stream.0)?; + cuda::upload(scratch.pixels.at(0), pixels, self.stream.0)?; } if callback.is_none() - && self.graph_enabled + && graph_enabled && let Some(graph) = &scratch.graph { graph.launch(self.stream.0)?; graph_trace("replayed", scratch.grid); - return self.read(&scratch.out, n / 4 * 2560); + return Ok((self.read(&scratch.out, n / 4 * 2560)?, None)); } - self.enqueue(scratch, &mut callback)?; + self.enqueue(scratch, callback)?; let result = self.read(&scratch.out, n / 4 * 2560)?; + let mut captured = None; if let Some(callback) = callback.as_mut() { callback("merger.output", &result)?; - } else if self.graph_enabled { + } else if graph_enabled { // The eager read completed warmup. Capture does not execute the operators; // return the already completed result even if recording fails. - let captured = cuda::Graph::capture(self.stream.0, || self.enqueue(scratch, &mut None)); - self.finish_capture(captured); + captured = Some(cuda::Graph::capture(self.stream.0, || { + self.enqueue(scratch, &mut None) + })); } - Ok(result) + Ok((result, captured)) } fn finish_capture(&mut self, captured: Result) { match captured { @@ -428,7 +514,7 @@ fn graph_trace(action: &str, grid: [usize; 3]) { } } struct Scratch { - // Executable is dropped first. One exact grid bounds resident activation memory. + // Executable is dropped first, before every address referenced by its graph. graph: Option, grid: [usize; 3], pixels: DeviceBuffer, @@ -447,7 +533,37 @@ struct Scratch { mlp: DeviceBuffer, out: DeviceBuffer, } +/// Budgets cover every retained Scratch allocation, excluding shared weights/GEMM workspace. +#[derive(Clone, Copy)] +struct CacheLimits { + entries: usize, + bytes: usize, +} +impl CacheLimits { + fn from_env() -> Self { + let value = |name: &str, default| { + std::env::var(name) + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(default) + }; + Self { + entries: value("CUA_S1_VISION_CACHE_ENTRIES", 1).min(16), + bytes: value("CUA_S1_VISION_CACHE_BYTES", 256 << 20), + } + } + fn retains(self, bytes: usize) -> bool { + self.entries > 0 && bytes <= self.bytes + } + fn needs_eviction(self, entries: usize, retained_bytes: usize, incoming_bytes: usize) -> bool { + entries >= self.entries || incoming_bytes > self.bytes.saturating_sub(retained_bytes) + } +} impl Scratch { + fn bytes_for_grid(grid: [usize; 3]) -> usize { + // pixels 3072 + position tables 32 + RoPE 256 + Work 32832 + activations 27904. + grid[1] * grid[2] * 64_096 + } fn new(model: &VisionModel, grid: [usize; 3], geo: Geometry) -> Result { let n = grid[1] * grid[2]; Ok(Self { diff --git a/tests/cua_s1/vision_cache_trace_probe.py b/tests/cua_s1/vision_cache_trace_probe.py new file mode 100644 index 00000000..61857a5a --- /dev/null +++ b/tests/cua_s1/vision_cache_trace_probe.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +"""Check live A/B/A CUDA logs after the Rust feature-parity probe. + +Run vision_cache_aba_probe with CUA_S1_VISION_CACHE_ENTRIES=4 and +CUA_S1_GRAPH_TRACE=1, redirect both stdout and stderr, then pass that log here. +The baseline single-grid implementation must fail this assertion. +""" +import pathlib +import sys + +log = pathlib.Path(sys.argv[1]).read_text() +assert "test result: ok. 1 passed" in log, "the GPU feature-parity probe must pass" +assert log.count("VISION_CACHE_ABA_BEGIN") == log.count("VISION_CACHE_ABA_END") == 1 +window = log.split("VISION_CACHE_ABA_BEGIN")[1].split("VISION_CACHE_ABA_END")[0] +captures = window.count("Vision CUDA Graph captured") +replays = window.count("Vision CUDA Graph replayed") +assert (captures, replays) == (2, 1), ( + f"A/B/A expected 2 captures and 1 replay, observed {captures} captures and {replays} replays" +) +print(f"A/B/A retained graph: {captures} captures, {replays} replay; feature parity passed") diff --git a/tests/cua_s1/vision_graph.rs b/tests/cua_s1/vision_graph.rs index ae3e737a..6a483213 100644 --- a/tests/cua_s1/vision_graph.rs +++ b/tests/cua_s1/vision_graph.rs @@ -131,3 +131,165 @@ fn vision_capture_failure_keeps_eager_result_and_disables_replay() { assert!(model.scratch.as_ref().unwrap().graph.is_none()); assert_eq!(bits(&model.forward(&input).unwrap()), expected); } + +#[test] +#[ignore = "requires CUDA and pinned CUA_S1_BASE/CUA_S1_VISION_ADAPTER/CUA_S1_CUDA_LIB"] +fn vision_cache_aba_probe() { + let mut model = load(); + model.graph_enabled = false; + let inputs = [image([1, 8, 32], 21), image([1, 16, 16], 22)]; + let expected: Vec<_> = inputs + .iter() + .map(|i| bits(&model.forward(i).unwrap())) + .collect(); + model.graph_enabled = true; + eprintln!("VISION_CACHE_ABA_BEGIN"); + for index in [0, 1, 0] { + assert_eq!( + bits(&model.forward(&inputs[index]).unwrap()), + expected[index] + ); + } + eprintln!("VISION_CACHE_ABA_END"); +} + +#[test] +fn vision_cache_budget_policy() { + let patch_bytes = Scratch::bytes_for_grid([1, 2, 2]) / 4; + assert_eq!(patch_bytes, 64_096); + let geometry = Geometry::new([1, 2, 2]).unwrap(); + let geometry_bytes = + (geometry.indices.len() + geometry.weights.len() + geometry.cos.len() + geometry.sin.len()) + * 4; + assert_eq!(geometry_bytes / 4, 288); + assert_eq!(Scratch::bytes_for_grid([1, 32, 72]), 147_677_184); + let limits = CacheLimits { + entries: 2, + bytes: 100, + }; + assert!(limits.retains(100)); + assert!(!limits.retains(101)); + assert!( + !CacheLimits { + entries: 0, + bytes: 100 + } + .retains(1) + ); + assert!(!limits.needs_eviction(1, 60, 40)); + assert!(limits.needs_eviction(2, 0, 1)); + assert!(limits.needs_eviction(1, 61, 40)); +} + +fn clear_scratch(model: &mut VisionModel) { + model.synchronize().unwrap(); + model.scratch = None; + model.cached.clear(); +} + +#[test] +#[ignore = "requires CUDA and pinned CUA_S1_BASE/CUA_S1_VISION_ADAPTER/CUA_S1_CUDA_LIB"] +fn vision_cache_capacity_lru_and_invalid_input() { + let mut model = load(); + model.cache_limits = CacheLimits { + entries: 2, + bytes: 256 << 20, + }; + model.graph_enabled = false; + let inputs = [ + image([1, 8, 32], 30), + image([1, 16, 16], 31), + image([1, 32, 8], 32), + ]; + let expected: Vec<_> = inputs + .iter() + .map(|i| bits(&model.forward(i).unwrap())) + .collect(); + clear_scratch(&mut model); + model.graph_enabled = true; + for index in [0, 1, 0] { + assert_eq!( + bits(&model.forward(&inputs[index]).unwrap()), + expected[index] + ); + } + let a = model.scratch.as_ref().unwrap().pixels.at(0); + assert!(model.scratch.as_ref().unwrap().graph.is_some()); + assert_eq!(model.cached[0].grid, inputs[1].image_grid_thw); + let b = model.cached[0].pixels.at(0); + let mut invalid = image([1, 16, 16], 31); + invalid.pixel_values.pop(); + assert!(model.forward(&invalid).is_err()); + invalid = image([2, 8, 32], 30); + assert!(model.forward(&invalid).is_err()); + assert_eq!(model.scratch.as_ref().unwrap().pixels.at(0), a); + assert_eq!(model.cached[0].pixels.at(0), b); + assert_eq!(bits(&model.forward(&inputs[2]).unwrap()), expected[2]); + assert_eq!(model.cached.len(), 1); + assert_eq!( + model.cached[0].grid, inputs[0].image_grid_thw, + "B must be LRU victim after A/B/A/C" + ); + assert_eq!(model.cached[0].pixels.at(0), a); + assert_eq!(model.retained_bytes(), 512 * 64_096); + assert_eq!(bits(&model.forward(&inputs[0]).unwrap()), expected[0]); + assert_eq!(model.scratch.as_ref().unwrap().pixels.at(0), a); + drop(model); +} + +#[test] +#[ignore = "requires CUDA and pinned CUA_S1_BASE/CUA_S1_VISION_ADAPTER/CUA_S1_CUDA_LIB"] +fn vision_cache_byte_eviction_and_transient_oversize() { + let mut model = load(); + model.cache_limits = CacheLimits { + entries: 4, + bytes: 512 * 64_096, + }; + model.graph_enabled = false; + let inputs = [ + image([1, 8, 32], 40), + image([1, 16, 32], 41), + image([1, 32, 8], 42), + image([1, 32, 32], 43), + ]; + let expected: Vec<_> = inputs + .iter() + .map(|i| bits(&model.forward(i).unwrap())) + .collect(); + clear_scratch(&mut model); + model.graph_enabled = true; + for index in [0, 1, 2, 0] { + assert_eq!( + bits(&model.forward(&inputs[index]).unwrap()), + expected[index] + ); + assert!(model.retained_bytes() <= model.cache_limits.bytes); + } + assert_eq!(model.cached.len(), 1); + assert_eq!(model.cached[0].grid, inputs[2].image_grid_thw); + let a = model.scratch.as_ref().unwrap().pixels.at(0); + let c = model.cached[0].pixels.at(0); + for _ in 0..2 { + assert_eq!(bits(&model.forward(&inputs[3]).unwrap()), expected[3]); + assert_eq!(model.scratch.as_ref().unwrap().pixels.at(0), a); + assert_eq!(model.cached[0].pixels.at(0), c); + assert_eq!(model.retained_bytes(), model.cache_limits.bytes); + assert!(model.graph_enabled); + } + assert!( + model + .forward_with_trace(&inputs[3], |_, _| anyhow::bail!("test trace failure")) + .is_err() + ); + assert_eq!(bits(&model.forward(&inputs[0]).unwrap()), expected[0]); + model.cache_limits.bytes = 1; + assert_eq!(bits(&model.forward(&inputs[0]).unwrap()), expected[0]); + // Internal test-only budget changes may encounter an already resident same grid. + assert_eq!(model.scratch.as_ref().unwrap().pixels.at(0), a); + clear_scratch(&mut model); + model.cache_limits.entries = 0; + assert_eq!(bits(&model.forward(&inputs[0]).unwrap()), expected[0]); + assert_eq!(bits(&model.forward(&inputs[0]).unwrap()), expected[0]); + assert_eq!(model.retained_bytes(), 0); + assert!(model.scratch.is_none() && model.cached.is_empty()); +} From 9dd8db132cf24249c8647dbd65005800ab0d0edc Mon Sep 17 00:00:00 2001 From: levius <2114377220@qq.com> Date: Sat, 10 Oct 2026 22:05:51 +0800 Subject: [PATCH 2/3] fix(cua-s1): warn on invalid vision cache limits --- recipe/cua_s1/native_vision_graph.md | 6 ++ src/models/cua_s1/native/src/vision/model.rs | 16 +++-- tests/cua_s1/vision_graph.rs | 73 ++++++++++++++++++++ 3 files changed, 90 insertions(+), 5 deletions(-) diff --git a/recipe/cua_s1/native_vision_graph.md b/recipe/cua_s1/native_vision_graph.md index 2601f7e4..fdf6aa63 100644 --- a/recipe/cua_s1/native_vision_graph.md +++ b/recipe/cua_s1/native_vision_graph.md @@ -25,6 +25,12 @@ The four tested grids contain 2304 patches in total and retain 147677184 bytes and allocator/runtime overhead. Trace messages report retained entries/bytes; measure actual device memory separately. +Both limits are read once when the model loads. Unset values use their defaults; +an explicitly invalid unsigned integer (including trailing whitespace or a +negative value) logs a warning to stderr and uses that limit's default. The +warning names the setting, supplied value and effective fallback, independently +of `CUA_S1_GRAPH_TRACE`. Zero for either limit disables retention. + A valid geometry larger than the byte budget, or any geometry with retention disabled, executes eagerly using transient scratch. It preserves existing cache entries, synchronizes on success or error and releases the transient diff --git a/src/models/cua_s1/native/src/vision/model.rs b/src/models/cua_s1/native/src/vision/model.rs index ab304e4d..df86bb93 100644 --- a/src/models/cua_s1/native/src/vision/model.rs +++ b/src/models/cua_s1/native/src/vision/model.rs @@ -541,11 +541,17 @@ struct CacheLimits { } impl CacheLimits { fn from_env() -> Self { - let value = |name: &str, default| { - std::env::var(name) - .ok() - .and_then(|v| v.parse::().ok()) - .unwrap_or(default) + let value = |name: &str, default| match std::env::var_os(name) { + None => default, + Some(raw) => match raw.to_str().and_then(|v| v.parse::().ok()) { + Some(value) => value, + None => { + eprintln!( + "Invalid {name}={raw:?}; expected an unsigned integer, using default {default}" + ); + default + } + }, }; Self { entries: value("CUA_S1_VISION_CACHE_ENTRIES", 1).min(16), diff --git a/tests/cua_s1/vision_graph.rs b/tests/cua_s1/vision_graph.rs index 6a483213..3e223e96 100644 --- a/tests/cua_s1/vision_graph.rs +++ b/tests/cua_s1/vision_graph.rs @@ -153,6 +153,79 @@ fn vision_cache_aba_probe() { eprintln!("VISION_CACHE_ABA_END"); } +#[test] +fn vision_cache_limits_from_env() { + // Each probe gets a private environment; parallel tests never mutate global env. + if std::env::var_os("CUA_S1_TEST_CACHE_LIMITS_PROBE").is_some() { + let limits = CacheLimits::from_env(); + eprintln!("CACHE_LIMITS {} {}", limits.entries, limits.bytes); + return; + } + let test = format!( + "{}::vision_cache_limits_from_env", + module_path!().split_once("::").unwrap().1 + ); + let probe = |entries: Option<&str>, + bytes: Option<&str>, + expected: (usize, usize), + warning: Option<&str>| { + let mut command = std::process::Command::new(std::env::current_exe().unwrap()); + command + .args(["--exact", &test, "--nocapture"]) + .env("CUA_S1_TEST_CACHE_LIMITS_PROBE", "1") + .env_remove("CUA_S1_VISION_CACHE_ENTRIES") + .env_remove("CUA_S1_VISION_CACHE_BYTES"); + if let Some(value) = entries { + command.env("CUA_S1_VISION_CACHE_ENTRIES", value); + } + if let Some(value) = bytes { + command.env("CUA_S1_VISION_CACHE_BYTES", value); + } + let output = command.output().unwrap(); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(output.status.success(), "{stderr}"); + assert!( + stderr.contains(&format!("CACHE_LIMITS {} {}", expected.0, expected.1)), + "{stderr}" + ); + assert_eq!( + stderr.matches("Invalid CUA_S1_VISION_CACHE_").count(), + usize::from(warning.is_some()), + "{stderr}" + ); + if let Some(name) = warning { + let default = if name.ends_with("ENTRIES") { + 1 + } else { + 256 << 20 + }; + assert!(stderr.contains(&format!("Invalid {name}=")), "{stderr}"); + assert!( + stderr.contains(&format!("using default {default}")), + "{stderr}" + ); + } + }; + probe(None, None, (1, 256 << 20), None); + probe(Some("0"), Some("0"), (0, 0), None); + probe(Some("4"), Some("1024"), (4, 1024), None); + probe(Some("17"), None, (16, 256 << 20), None); + for invalid in ["4 ", "-1", "four"] { + probe( + Some(invalid), + None, + (1, 256 << 20), + Some("CUA_S1_VISION_CACHE_ENTRIES"), + ); + probe( + None, + Some(invalid), + (1, 256 << 20), + Some("CUA_S1_VISION_CACHE_BYTES"), + ); + } +} + #[test] fn vision_cache_budget_policy() { let patch_bytes = Scratch::bytes_for_grid([1, 2, 2]) / 4; From 3fe232939582da39585f129bc735222a4a1f452d Mon Sep 17 00:00:00 2001 From: levius <2114377220@qq.com> Date: Tue, 13 Oct 2026 13:41:41 +0800 Subject: [PATCH 3/3] test(qwen): keep vision module out of name-specific filters --- src/models/qwen3_5/native/src/vision/model.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/models/qwen3_5/native/src/vision/model.rs b/src/models/qwen3_5/native/src/vision/model.rs index 2b383564..7d301f6c 100644 --- a/src/models/qwen3_5/native/src/vision/model.rs +++ b/src/models/qwen3_5/native/src/vision/model.rs @@ -816,4 +816,4 @@ impl Work { #[cfg(test)] #[path = "../../../../../../tests/qwen3_5/vision_graph.rs"] -mod vision_graph_tests; +mod vision_tests;