From 9697ea8952ddb6af20c6092babb2919b8b218afd Mon Sep 17 00:00:00 2001 From: Jeongkyu Shin Date: Sat, 22 Aug 2026 10:23:21 +0900 Subject: [PATCH 1/2] feat(xla): derive image context floors for LLaVA and Qwen2-VL `xla_image_context_floor` only had a formula for Molmo2, so LLaVA and Qwen2-VL fell through the startup guard silently and still hit the failure it exists to prevent: run the whole vision tower, then reject at admission. Both derivations mirror what this codebase's preprocessors actually emit rather than upstream behavior, because the guard has to predict the token count the runtime will produce. LLaVA's block carries no markers, separator or per-row token, so one image contributes exactly the projector's count and there is no shape to maximize over. That count is `mm_tokens_per_image` when stated, otherwise `(image_size / patch_size)^2`, matching `load_llava_host_preprocessor`. No anyres grid is applied, because that loader applies none either, including for `llava_next` checkpoints which route to the same path. Qwen2-VL has no fixed grid. `smart_resize` rounds both edges to `patch_size * spatial_merge_size` and caps the area, and the token count is `(h / merge) * (w / merge)`, so both reduce to `pixels / (patch * merge)^2` and the maximum is that quotient at the pixel cap. The cap comes from the processor constructor, not `preprocessor_config.json`: `qwen_vl_processor` calls `Qwen2VLProcessor::new`, which never reads that file, so a `max_pixels` key in a checkpoint does not affect what this path admits. The bounds are now named constants shared with the derivation so the two cannot drift. The guard's message is rewritten because the Qwen2-VL floor exposed a flaw in it. Real expansion on that family spans 64 tokens for a 224x224 image to 16384 for one at the pixel cap, a 256x range, and the old text told the operator to set the capacity to the maximum "to serve images". That is advice nobody can follow at 16384, and it is also wrong: a smaller capacity serves every image that fits it. The message now asks for the largest expansion the operator intends to serve and states what a smaller value does, including that oversized images are still rejected only after their vision tower has run. Verified on five real checkpoints: llava-1.5-7b (576), llava-interleave-0.5b (729), llava-next-mistral-7b (576), and qwen2-vl-2b with its 4bit variant (16384). The Qwen2-VL check runs the real processor over extreme shapes rather than re-evaluating the same formula, and asserts the worst observed count equals the floor, so the bound is shown to be tight and not merely safe. --- src/multimodal/host_preprocessor.rs | 86 ++++++++- src/multimodal/host_preprocessor_tests.rs | 206 ++++++++++++++++++++++ src/vision/processors/qwen2_vl.rs | 42 ++++- 3 files changed, 321 insertions(+), 13 deletions(-) diff --git a/src/multimodal/host_preprocessor.rs b/src/multimodal/host_preprocessor.rs index e1e0ffe73..d3db6114c 100644 --- a/src/multimodal/host_preprocessor.rs +++ b/src/multimodal/host_preprocessor.rs @@ -290,13 +290,79 @@ pub fn load_xla_image_preprocessor( pub fn xla_image_context_floor(model_path: &Path) -> Option { match crate::models::get_model_type(model_path) { Ok(crate::models::ModelType::Molmo2VLM) => molmo2_image_context_floor(model_path), - // Other qualified families expand images too, but their worst case is - // not derived here yet. Reporting `None` keeps this guard silent for - // them instead of guarding with a Molmo2-shaped guess. + Ok(crate::models::ModelType::LlavaVLM) => llava_image_context_floor(model_path), + Ok(crate::models::ModelType::Qwen2VL) => qwen2_vl_image_context_floor(model_path), + // A family without a formula here reports `None` rather than being + // guarded with another family's shape. _ => None, } } +/// Read `config.json` without loading anything else. +fn read_config_json(model_path: &Path) -> Option { + serde_json::from_str(&std::fs::read_to_string(model_path.join("config.json")).ok()?).ok() +} + +/// Worst-case LLaVA image expansion. +/// +/// LLaVA's block carries no begin- or end-of-image markers, no separator and no +/// per-row token (`llava_token_block_info` sets `use_boi_eoi: false` with empty +/// prefix and suffix lists), so one image contributes exactly the projector's +/// token count and there is no shape to maximize over. +/// +/// That count is what `load_llava_host_preprocessor` computes: an explicit +/// `mm_tokens_per_image` when the checkpoint states one, otherwise +/// `(image_size / patch_size)^2` from the vision config. This mirrors that +/// expression rather than the upstream HF behavior, because the guard has to +/// predict what this codebase's preprocessor will actually emit. In particular +/// no anyres or multi-patch grid is applied here, since this loader applies +/// none either. +fn llava_image_context_floor(model_path: &Path) -> Option { + let config = read_config_json(model_path)?; + if let Some(explicit) = config.get("mm_tokens_per_image").and_then(|v| v.as_u64()) { + return usize::try_from(explicit).ok().filter(|count| *count > 0); + } + let vision = config.get("vision_config")?; + let image_size = usize::try_from(vision.get("image_size")?.as_u64()?).ok()?; + let patch_size = usize::try_from(vision.get("patch_size")?.as_u64()?).ok()?; + if patch_size == 0 { + return None; + } + let per_side = image_size / patch_size; + per_side.checked_mul(per_side).filter(|count| *count > 0) +} + +/// Worst-case Qwen2-VL image expansion. +/// +/// Unlike Molmo2 and LLaVA the bound does not come from a fixed grid. The +/// processor's `smart_resize` rounds both edges to `patch_size * +/// spatial_merge_size` and then caps the area, and +/// `insert_qwen_vl_image_tokens` emits `(h / merge) * (w / merge)` tokens for a +/// single-frame image, so the count is `pixels / (patch_size * merge)^2` and +/// its maximum is that quotient at the pixel cap. +/// +/// The cap is deliberately read from +/// [`crate::vision::processors::qwen2_vl::DEFAULT_MAX_PIXELS`] rather than from +/// `preprocessor_config.json`. `qwen_vl_processor` builds the processor with +/// `Qwen2VLProcessor::new`, which never consults that file, so a `max_pixels` +/// key in the checkpoint does not affect what this path admits and reading one +/// would produce a floor the runtime does not honor. +fn qwen2_vl_image_context_floor(model_path: &Path) -> Option { + let config = read_config_json(model_path)?; + let vision = config.get("vision_config")?; + let patch_size = usize::try_from(vision.get("patch_size")?.as_u64()?).ok()?; + let merge_size = usize::try_from(vision.get("spatial_merge_size")?.as_u64()?).ok()?; + if patch_size == 0 || merge_size == 0 { + return None; + } + crate::vision::processors::qwen2_vl::max_image_tokens( + patch_size, + merge_size, + crate::vision::processors::qwen2_vl::DEFAULT_MAX_PIXELS, + ) + .filter(|count| *count > 0) +} + /// Name of the variable that selects the static OpenXLA context shape. /// /// Duplicated from `mlxcel_xla` so this guard, and its tests, still compile in @@ -333,12 +399,14 @@ pub fn ensure_xla_image_context_capacity( return Ok(()); } Err(HostPreprocessorError::InvalidConfig(format!( - "this checkpoint expands one image into up to {floor} tokens, which the default OpenXLA \ - context capacity of {context_capacity} cannot admit, so every image request would run \ - its vision tower and then fail. Set {env}={floor} or higher to serve images (the \ - capacity is also the length every decode step attends over, so a larger graph costs \ - throughput), or set {env}={context_capacity} explicitly to keep this graph for \ - text-only serving.", + "this checkpoint can expand a single image into up to {floor} tokens, and the default \ + OpenXLA context capacity of {context_capacity} admits none of them. Set {env} to the \ + largest image expansion you intend to serve: {floor} accepts any image, and a smaller \ + value accepts every image that fits it while rejecting the rest at admission, after \ + their vision tower has already run. The capacity is also the sequence length every \ + decode step attends over, so a larger graph costs throughput on every request, \ + including text-only ones. To keep this graph exactly as it is, set \ + {env}={context_capacity} explicitly.", env = CONTEXT_CAPACITY_ENV, ))) } diff --git a/src/multimodal/host_preprocessor_tests.rs b/src/multimodal/host_preprocessor_tests.rs index 968353e9c..4d7c1c02b 100644 --- a/src/multimodal/host_preprocessor_tests.rs +++ b/src/multimodal/host_preprocessor_tests.rs @@ -430,6 +430,14 @@ fn the_default_capacity_is_rejected_with_the_derived_requirement() { message.contains("256"), "the message must show the capacity that was rejected: {message}" ); + assert!( + message.contains("accepts every image that fits it"), + "the message must say a smaller capacity still serves images: {message}" + ); + assert!( + !message.contains('#'), + "operator-facing text must not carry issue numbers: {message}" + ); } #[test] @@ -455,3 +463,201 @@ fn the_capacity_variable_name_matches_the_xla_crate() { // catches a rename on either side. assert_eq!(CONTEXT_CAPACITY_ENV, mlxcel_xla::CONTEXT_CAPACITY_ENV); } + +/// A LLaVA checkpoint's geometry. 336px images over 14px patches is the +/// llava-1.5 vision tower, and 384px is the llava-interleave one. +fn llava_checkpoint_dir( + image_size: u32, + patch_size: u32, + explicit: Option, +) -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + let explicit = explicit + .map(|count| format!(r#","mm_tokens_per_image":{count}"#)) + .unwrap_or_default(); + std::fs::write( + dir.path().join("config.json"), + format!( + r#"{{"model_type":"llava","image_token_index":32000,"text_config":{{"model_type":"llama"}},"vision_config":{{"image_size":{image_size},"patch_size":{patch_size}}}{explicit}}}"# + ), + ) + .unwrap(); + dir +} + +fn qwen2_vl_checkpoint_dir(patch_size: u32, merge_size: u32) -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("config.json"), + format!( + r#"{{"model_type":"qwen2_vl","vision_config":{{"patch_size":{patch_size},"spatial_merge_size":{merge_size}}}}}"# + ), + ) + .unwrap(); + dir +} + +#[test] +fn llava_image_floor_matches_the_loader_expression() { + // `load_llava_host_preprocessor` computes `(image_size / patch_size)^2` + // unless the checkpoint states `mm_tokens_per_image`. These are the two + // real geometries: llava-1.5 at 336/14 and llava-interleave at 384/14. + let one_five = llava_checkpoint_dir(336, 14, None); + assert_eq!(xla_image_context_floor(one_five.path()), Some(576)); + let interleave = llava_checkpoint_dir(384, 14, None); + assert_eq!(xla_image_context_floor(interleave.path()), Some(729)); + + // 384 / 14 truncates to 27, so the floor is 729 rather than the 753 an + // exact division would suggest. The loader truncates the same way. + assert_eq!(27usize * 27, 729); + + // An explicit count wins, because the loader prefers it too. + let explicit = llava_checkpoint_dir(336, 14, Some(1024)); + assert_eq!(xla_image_context_floor(explicit.path()), Some(1024)); +} + +#[test] +fn qwen2_vl_image_floor_bounds_every_admissible_image() { + let dir = qwen2_vl_checkpoint_dir(14, 2); + let floor = xla_image_context_floor(dir.path()).expect("qwen2_vl must derive a floor"); + assert_eq!(floor, 16384); + + // Validation against the processor rather than against the same formula: + // run the real `smart_resize` and token count over extreme shapes and + // confirm none exceeds the floor. A floor that is too low reinstates the + // late-admission failure the guard exists to prevent, so this is the + // property that matters. + let processor = crate::vision::processors::qwen2_vl::Qwen2VLProcessor::new(14, 2, 2); + let mut worst = 0usize; + for (h, w) in [ + (224u32, 224u32), + (768, 1024), + (1512, 378), + (378, 1512), + (4000, 4000), + (8000, 8000), + (20000, 300), + (300, 20000), + (10000, 12000), + ] { + let image = DynamicImage::new_rgb8(w, h); + let grids = processor.compute_grid_thw(std::slice::from_ref(&image)); + let (t, gh, gw) = grids[0]; + let tokens = (t as usize) * (gh as usize / 2) * (gw as usize / 2); + assert!( + tokens <= floor, + "{h}x{w} expands to {tokens} tokens, above the derived floor {floor}" + ); + worst = worst.max(tokens); + } + // The bound is tight, not merely safe: a square image at the pixel cap + // reaches it exactly, so the floor is not an arbitrary overestimate. + assert_eq!(worst, floor); +} + +#[test] +fn the_new_families_report_no_floor_for_an_unreadable_config() { + let empty = tempfile::tempdir().unwrap(); + assert_eq!(xla_image_context_floor(empty.path()), None); + + // A LLaVA config without the geometry keys must not fall back to a guess. + let partial = tempfile::tempdir().unwrap(); + std::fs::write( + partial.path().join("config.json"), + r#"{"model_type":"llava","image_token_index":32000,"text_config":{"model_type":"llama"},"vision_config":{}}"#, + ) + .unwrap(); + assert_eq!(xla_image_context_floor(partial.path()), None); + + // A zero patch size must report no floor rather than divide. + let degenerate = qwen2_vl_checkpoint_dir(0, 2); + assert_eq!(xla_image_context_floor(degenerate.path()), None); +} + +#[test] +fn the_capacity_guard_covers_the_new_families_at_the_boundary() { + let llava = llava_checkpoint_dir(336, 14, None); + assert!(ensure_xla_image_context_capacity(llava.path(), 576, false).is_ok()); + assert!(ensure_xla_image_context_capacity(llava.path(), 575, false).is_err()); + assert!(ensure_xla_image_context_capacity(llava.path(), 575, true).is_ok()); + + let qwen = qwen2_vl_checkpoint_dir(14, 2); + assert!(ensure_xla_image_context_capacity(qwen.path(), 16384, false).is_ok()); + assert!(ensure_xla_image_context_capacity(qwen.path(), 16383, false).is_err()); + assert!(ensure_xla_image_context_capacity(qwen.path(), 16383, true).is_ok()); +} + +/// Close the loop on a real checkpoint instead of a synthetic config. +/// +/// The other tests build the config themselves, so they can only prove the +/// derivation is self-consistent. This one reads a checkpoint from +/// `MLXCEL_FLOOR_MODEL`, derives the floor from its actual `config.json`, and +/// for Qwen2-VL runs the real processor over extreme shapes to confirm nothing +/// exceeds it. No weights are loaded, so it stays fast, but the geometry is the +/// checkpoint's own. +#[test] +#[ignore = "requires a real LLaVA or Qwen2-VL checkpoint in MLXCEL_FLOOR_MODEL"] +fn a_real_checkpoint_never_exceeds_its_derived_floor() { + let Ok(path) = std::env::var("MLXCEL_FLOOR_MODEL") else { + panic!("MLXCEL_FLOOR_MODEL is required"); + }; + let model = std::path::PathBuf::from(path); + let floor = xla_image_context_floor(&model).expect("this checkpoint must derive a floor"); + let model_type = crate::models::get_model_type(&model).expect("model type"); + println!("{}: floor={floor} ({model_type:?})", model.display()); + + match model_type { + crate::models::ModelType::Qwen2VL => { + let config: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(model.join("config.json")).unwrap()) + .unwrap(); + let vision = &config["vision_config"]; + let patch = vision["patch_size"].as_u64().unwrap() as usize; + let merge = vision["spatial_merge_size"].as_u64().unwrap() as usize; + let temporal = vision["temporal_patch_size"].as_u64().unwrap_or(2) as usize; + let processor = + crate::vision::processors::qwen2_vl::Qwen2VLProcessor::new(patch, temporal, merge); + let mut worst = 0usize; + for (h, w) in [ + (224u32, 224u32), + (768, 1024), + (4000, 4000), + (8000, 8000), + (20000, 300), + (300, 20000), + ] { + let image = DynamicImage::new_rgb8(w, h); + let grids = processor.compute_grid_thw(std::slice::from_ref(&image)); + let (t, gh, gw) = grids[0]; + let tokens = (t as usize) * (gh as usize / merge) * (gw as usize / merge); + println!(" {h}x{w} -> {tokens} tokens"); + assert!( + tokens <= floor, + "{h}x{w} produced {tokens} above floor {floor}" + ); + worst = worst.max(tokens); + } + println!(" worst={worst} floor={floor}"); + } + crate::models::ModelType::LlavaVLM => { + // LLaVA's block is a fixed count with no shape dependence, so the + // check is that the floor equals what the loader would compute from + // this checkpoint's own vision config. + let config: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(model.join("config.json")).unwrap()) + .unwrap(); + let expected = config + .get("mm_tokens_per_image") + .and_then(|v| v.as_u64()) + .map(|v| v as usize) + .unwrap_or_else(|| { + let vision = &config["vision_config"]; + let side = vision["image_size"].as_u64().unwrap() as usize + / vision["patch_size"].as_u64().unwrap() as usize; + side * side + }); + assert_eq!(floor, expected); + } + other => panic!("unexpected family for this test: {other:?}"), + } +} diff --git a/src/vision/processors/qwen2_vl.rs b/src/vision/processors/qwen2_vl.rs index 6924ccbb7..ca0a9683e 100644 --- a/src/vision/processors/qwen2_vl.rs +++ b/src/vision/processors/qwen2_vl.rs @@ -36,6 +36,40 @@ pub struct Qwen2VLProcessor { pub std: [f32; 3], } +/// Default lower pixel bound the Qwen2-VL processors are constructed with. +pub const DEFAULT_MIN_PIXELS: usize = 4 * 28 * 28; + +/// Default upper pixel bound the Qwen2-VL processors are constructed with. +/// +/// `smart_resize` caps the resized area at this value, so it is also what +/// bounds the visual token count for any input image. Shared with +/// [`max_image_tokens`] so a capacity floor derived from it cannot drift away +/// from what the processor actually admits. +pub const DEFAULT_MAX_PIXELS: usize = 16384 * 28 * 28; + +/// Largest visual token count one image can expand into under these bounds. +/// +/// `smart_resize` rounds both edges to `patch_size * spatial_merge_size` and +/// caps the area at `max_pixels`; `insert_qwen_vl_image_tokens` then emits +/// `(h / merge) * (w / merge)` tokens for a single-frame image. Both reduce to +/// `pixels / (patch_size * spatial_merge_size)^2`, so the maximum is that +/// quotient evaluated at the pixel cap. +/// +/// Returns `None` for a degenerate geometry rather than dividing by zero. +#[must_use] +pub fn max_image_tokens( + patch_size: usize, + spatial_merge_size: usize, + max_pixels: usize, +) -> Option { + let factor = patch_size.checked_mul(spatial_merge_size)?; + let divisor = factor.checked_mul(factor)?; + if divisor == 0 { + return None; + } + Some(max_pixels / divisor) +} + impl Qwen2VLProcessor { /// Used by: Qwen2-VL, Qwen2.5-VL (CLIP normalization) pub fn new(patch_size: usize, temporal_patch_size: usize, spatial_merge_size: usize) -> Self { @@ -43,8 +77,8 @@ impl Qwen2VLProcessor { patch_size, temporal_patch_size, spatial_merge_size, - min_pixels: 4 * 28 * 28, // 3136 - max_pixels: 16384 * 28 * 28, // large limit + min_pixels: DEFAULT_MIN_PIXELS, + max_pixels: DEFAULT_MAX_PIXELS, mean: [0.48145466, 0.4578275, 0.40821073], std: [0.26862954, 0.261_302_6, 0.275_777_1], } @@ -62,8 +96,8 @@ impl Qwen2VLProcessor { patch_size, temporal_patch_size, spatial_merge_size, - min_pixels: 4 * 28 * 28, - max_pixels: 16384 * 28 * 28, + min_pixels: DEFAULT_MIN_PIXELS, + max_pixels: DEFAULT_MAX_PIXELS, mean, std, } From 5330475edf4826f67cf8218777a9b8690ac4d48a Mon Sep 17 00:00:00 2001 From: Jeongkyu Shin Date: Sat, 22 Aug 2026 10:25:22 +0900 Subject: [PATCH 2/2] docs(reports): add the technical report for #1280 Recorded before the merge, per the TECHNICAL_REPORTS/.keep-reports contract, so the report lands inside the squash merge rather than trailing it. --- ...ision-family-context-floors-20260822.en.md | 82 +++++++++++++++++++ ...ision-family-context-floors-20260822.ko.md | 82 +++++++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 TECHNICAL_REPORTS/1280-vision-family-context-floors-20260822.en.md create mode 100644 TECHNICAL_REPORTS/1280-vision-family-context-floors-20260822.ko.md diff --git a/TECHNICAL_REPORTS/1280-vision-family-context-floors-20260822.en.md b/TECHNICAL_REPORTS/1280-vision-family-context-floors-20260822.en.md new file mode 100644 index 000000000..17d6bba96 --- /dev/null +++ b/TECHNICAL_REPORTS/1280-vision-family-context-floors-20260822.en.md @@ -0,0 +1,82 @@ +# Technical Report: PR #1280 - Image context floors for LLaVA and Qwen2-VL + +## Executive Summary + +`xla_image_context_floor` predicts, from config alone, the largest number of prompt tokens one image can expand into, so the OpenXLA startup guard can reject a graph shape that could never admit an image. Only Molmo2 had a formula, leaving LLaVA and Qwen2-VL unguarded. + +Both families now derive one. The interesting result is not the arithmetic but what Qwen2-VL's number exposed: real expansion on that family spans 64 to 16384 tokens depending on image shape, a 256x range, and the guard's existing message was giving operators advice they could not follow. + +## 1. Problem Statement + +`xla_image_context_floor` matched `Molmo2VLM` and returned `None` for everything else. `LlavaVLM` and `Qwen2VL` are both qualified for the OpenXLA image path, so they fell through and still hit the late-admission failure mode: the vision tower runs, then admission rejects the request for exceeding the static graph capacity. + +## 2. Technical Decisions + +### 2.1 Match the implementation, not the upstream family + +The issue described LLaVA as needing an anyres grid walk for `llava_next` variants, and Qwen2-VL as reading `max_pixels` from `preprocessor_config.json`. Neither matches this codebase. + +`load_llava_host_preprocessor` computes `mm_tokens_per_image.unwrap_or((image_size / patch_size)^2)` and applies no anyres grid, including for `llava_next` checkpoints, which `get_model_type` routes to `LlavaVLM` unless the text backbone is Granite. `llava_token_block_info` sets `use_boi_eoi: false` with empty prefix and suffix lists, so there are no framing tokens either. + +`qwen_vl_processor` builds the processor with `Qwen2VLProcessor::new`, which sets `min_pixels` and `max_pixels` from constructor defaults and never reads `preprocessor_config.json`. A `max_pixels` key in a checkpoint therefore has no effect on what this path admits, and a floor derived from one would be a number the runtime does not honor. + +The guard has to predict what the runtime will emit, so both derivations follow the code. This is recorded because the divergence is invisible from the issue text and will look like a bug to anyone comparing against HF behavior. + +### 2.2 Share the pixel bounds rather than restate them + +The Qwen2-VL floor depends on `max_pixels`, which lived as a literal in two constructors. Restating it in the derivation would create two numbers that must agree with no mechanism to keep them agreeing. `DEFAULT_MIN_PIXELS`, `DEFAULT_MAX_PIXELS` and `max_image_tokens` now live in the processor module and both call sites use them. + +### 2.3 Rewrite the guard message, which the Qwen2-VL floor proved wrong + +The message told the operator to set the capacity to the floor "to serve images". At a Molmo2 floor of 1834 that is followable. At a Qwen2-VL floor of 16384 it is not, given that capacity is the sequence length every decode step attends over and measured decode already falls from 3.18 to 1.41 tok/s between 256 and 2048. + +It was also wrong on its own terms. A capacity below the floor serves every image whose expansion fits it. On this family that is most real images: a 768x1024 photograph expands to 999 tokens, so a 2048 capacity serves it comfortably. The old text implied such a configuration could serve no images at all. + +The message now asks for the largest expansion the operator intends to serve, states that a smaller value accepts everything that fits and rejects the rest at admission after the vision tower has run, and keeps the explicit-pin escape. It carries no issue numbers: a GitHub reference is not useful to someone reading a server startup failure. + +This edits `ensure_xla_image_context_capacity`, which the issue placed out of scope. The scope line was written before the 256x range was known, and shipping a correct number attached to impossible advice would have been worse than the gap it closes. + +## 3. Change Summary + +| File | Change | +| --- | --- | +| `src/multimodal/host_preprocessor.rs` | `llava_image_context_floor`, `qwen2_vl_image_context_floor`, `read_config_json`, new arms in `xla_image_context_floor`, rewritten guard message | +| `src/vision/processors/qwen2_vl.rs` | `DEFAULT_MIN_PIXELS`, `DEFAULT_MAX_PIXELS`, `max_image_tokens`, constructors use them | +| `src/multimodal/host_preprocessor_tests.rs` | Per-family pinned floors, tight-bound check against the real processor, missing and degenerate config cases, boundary tests, an ignored real-checkpoint test | + +## 4. Review Findings + +The Qwen2-VL magnitude was raised before the PR was opened rather than shipped quietly, because it changes what the guard does to a family that previously started. Three options were considered: ship the true worst case unchanged, ship it with a corrected message, or decline to derive a floor for Qwen2-VL on the grounds that an unreachable one is worse than none. The second was chosen: the number is correct and becomes the image bucket size once capacity bucketing lands, so the defect was in what the guard said about it, not in the number. + +## 5. Validation + +Unit tests: 26 passed in `host_preprocessor`, with the existing Qwen2-VL processor test still green. + +Real checkpoints, floors derived from each checkpoint's own `config.json`: + +| checkpoint | family | floor | +| --- | --- | --- | +| llava-1.5-7b-4bit | LlavaVLM | 576 | +| llava-interleave-qwen-0.5b-bf16 | LlavaVLM | 729 | +| llava-next-mistral-7b-4bit | LlavaVLM | 576 | +| qwen2-vl-2b | Qwen2VL | 16384 | +| qwen2-vl-2b-4bit | Qwen2VL | 16384 | + +The Qwen2-VL validation runs the real `smart_resize` and `compute_grid_thw` over extreme shapes rather than re-evaluating the derivation, which would only have proved the formula agrees with itself: + +```text +224x224 -> 64 tokens +768x1024 -> 999 tokens +4000x4000 -> 16384 tokens +8000x8000 -> 16384 tokens +20000x300 -> 7854 tokens +worst = 16384, floor = 16384 +``` + +The worst observed count equals the floor exactly, so the bound is tight rather than a safe overestimate. That direction matters: a floor above the true maximum only costs a larger graph, while one below reinstates the failure the guard exists to prevent. + +## 6. Related Work + +Issue #1272 is closed by this PR, and issue #916 introduced the guard being extended. The Qwen2-VL range is the strongest argument yet for capacity bucketing, tracked separately: with one static shape the floor is a throughput tax on every text-only request, and with buckets it becomes the size of the image bucket and costs text requests nothing. + +One forward-looking caveat is recorded in the code: if the LLaVA host preprocessor ever implements an anyres grid, the LLaVA floor stops being a fixed per-image count and has to be revisited. The derivation is correct for the loader as it exists, not for the family in general. diff --git a/TECHNICAL_REPORTS/1280-vision-family-context-floors-20260822.ko.md b/TECHNICAL_REPORTS/1280-vision-family-context-floors-20260822.ko.md new file mode 100644 index 000000000..8144ea8d7 --- /dev/null +++ b/TECHNICAL_REPORTS/1280-vision-family-context-floors-20260822.ko.md @@ -0,0 +1,82 @@ +# 기술 보고서: PR #1280 - LLaVA와 Qwen2-VL의 이미지 context floor + +## 요약 + +`xla_image_context_floor`는 설정만으로 이미지 하나가 확장될 수 있는 최대 프롬프트 토큰 수를 예측한다. OpenXLA 기동 가드가 이미지를 결코 받을 수 없는 그래프 shape을 거부하기 위해서다. Molmo2에만 공식이 있어 LLaVA와 Qwen2-VL은 가드 밖에 있었다. + +이제 두 패밀리 모두 유도한다. 흥미로운 결과는 산술이 아니라 Qwen2-VL의 숫자가 드러낸 것이다. 이 패밀리의 실제 확장은 이미지 형태에 따라 64에서 16384토큰까지 **256배** 벌어지고, 기존 가드 메시지는 운영자가 따를 수 없는 조언을 하고 있었다. + +## 1. 문제 + +`xla_image_context_floor`는 `Molmo2VLM`만 매칭하고 나머지는 `None`을 반환했다. `LlavaVLM`과 `Qwen2VL`은 둘 다 OpenXLA 이미지 경로 자격이 있으므로, 가드를 그대로 통과하고 가드가 막으려던 지연 admission 실패를 그대로 겪었다. 비전 타워가 돌고, 그 다음 admission이 정적 그래프 capacity 초과로 요청을 거부한다. + +## 2. 기술적 판단 + +### 2.1 상위 패밀리가 아니라 이 구현에 맞춘다 + +이슈는 LLaVA가 `llava_next` 변형을 위해 anyres 그리드 순회를 필요로 하고, Qwen2-VL은 `preprocessor_config.json`에서 `max_pixels`를 읽는다고 서술했다. **둘 다 이 코드베이스와 다르다.** + +`load_llava_host_preprocessor`는 `mm_tokens_per_image.unwrap_or((image_size / patch_size)^2)`를 계산하고 anyres 그리드를 적용하지 않는다. `llava_next` 체크포인트도 마찬가지이며, `get_model_type`은 텍스트 백본이 Granite가 아닌 한 이를 `LlavaVLM`으로 라우팅한다. `llava_token_block_info`는 `use_boi_eoi: false`에 접두·접미 목록이 비어 있으므로 프레이밍 토큰도 없다. + +`qwen_vl_processor`는 `Qwen2VLProcessor::new`로 프로세서를 만드는데, 이 생성자는 `min_pixels`와 `max_pixels`를 기본값으로 설정하고 `preprocessor_config.json`을 전혀 읽지 않는다. 따라서 체크포인트의 `max_pixels` 키는 이 경로가 받아들이는 것에 아무 영향이 없고, 그 값에서 유도한 floor는 런타임이 지키지 않는 숫자가 된다. + +가드는 런타임이 실제로 내놓을 값을 예측해야 하므로 두 유도 모두 코드를 따랐다. 이 차이는 이슈 텍스트만 봐서는 보이지 않고 HF 동작과 비교하는 사람에게는 버그로 보일 것이므로 기록한다. + +### 2.2 픽셀 경계는 다시 적지 않고 공유한다 + +Qwen2-VL floor는 `max_pixels`에 의존하는데, 이 값은 두 생성자에 리터럴로 있었다. 유도 쪽에 다시 적으면 서로 일치해야 하지만 일치를 강제할 장치가 없는 숫자가 둘이 된다. `DEFAULT_MIN_PIXELS`, `DEFAULT_MAX_PIXELS`, `max_image_tokens`를 프로세서 모듈에 두고 양쪽이 쓰게 했다. + +### 2.3 Qwen2-VL floor가 틀렸음을 증명한 가드 메시지를 다시 쓴다 + +기존 메시지는 "이미지를 서빙하려면" capacity를 floor로 설정하라고 했다. Molmo2의 1834에서는 따를 수 있다. Qwen2-VL의 16384에서는 불가능하다. capacity는 매 디코드 스텝이 어텐션하는 길이이고, 실측 디코드는 256에서 2048 사이에 이미 3.18에서 1.41 tok/s로 떨어진다. + +메시지는 그 자체로도 틀렸다. floor보다 작은 capacity도 거기 들어가는 모든 이미지를 서빙한다. 이 패밀리에서는 그게 대부분의 실제 이미지다. 768x1024 사진은 999토큰으로 확장되므로 2048 capacity면 여유롭게 처리된다. 기존 문구는 그런 설정이 이미지를 하나도 못 서빙하는 것처럼 암시했다. + +새 메시지는 운영자가 서빙하려는 최대 확장 크기를 묻고, 더 작은 값이 무엇을 하는지 밝힌다. 들어가는 것은 모두 받고 나머지는 비전 타워가 이미 돈 뒤에 admission에서 거부된다는 사실까지 포함한다. 명시 지정 탈출구도 유지한다. 이슈 번호는 넣지 않았다. 서버 기동 실패를 읽는 사람에게 GitHub 참조는 쓸모가 없다. + +이 변경은 이슈가 out of scope로 둔 `ensure_xla_image_context_capacity`를 건드린다. 그 범위 선언은 256배 범위를 알기 전에 쓰인 것이고, 올바른 숫자를 불가능한 조언에 붙여 출하하는 것이 메우려던 공백보다 나빴을 것이다. + +## 3. 변경 요약 + +| 파일 | 변경 | +| --- | --- | +| `src/multimodal/host_preprocessor.rs` | `llava_image_context_floor`, `qwen2_vl_image_context_floor`, `read_config_json`, `xla_image_context_floor`의 새 arm, 가드 메시지 재작성 | +| `src/vision/processors/qwen2_vl.rs` | `DEFAULT_MIN_PIXELS`, `DEFAULT_MAX_PIXELS`, `max_image_tokens`, 생성자가 이를 사용 | +| `src/multimodal/host_preprocessor_tests.rs` | 패밀리별 floor 고정, 실제 프로세서 대상 타이트 경계 확인, 설정 누락·퇴화 사례, 경계 테스트, 무시 표시된 실제 체크포인트 테스트 | + +## 4. 리뷰 지적사항 + +Qwen2-VL의 크기 문제는 PR을 열기 전에 제기했고 조용히 출하하지 않았다. 이전에는 기동되던 패밀리에 대해 가드가 하는 일을 바꾸기 때문이다. 세 가지를 검토했다. 진짜 최악값을 그대로 출하하거나, 메시지를 고쳐서 출하하거나, 도달 불가능한 floor는 없느니만 못하다는 판단으로 Qwen2-VL은 유도하지 않는 것이다. 두 번째를 택했다. 숫자는 정확하고 capacity 버킷이 오면 그대로 이미지 버킷 크기가 되므로, 결함은 숫자가 아니라 가드가 그 숫자에 대해 하는 말에 있었다. + +## 5. 검증 + +단위 테스트: `host_preprocessor`에서 26개 통과, 기존 Qwen2-VL 프로세서 테스트도 유지. + +실제 체크포인트, 각자의 `config.json`에서 floor 유도: + +| 체크포인트 | 패밀리 | floor | +| --- | --- | --- | +| llava-1.5-7b-4bit | LlavaVLM | 576 | +| llava-interleave-qwen-0.5b-bf16 | LlavaVLM | 729 | +| llava-next-mistral-7b-4bit | LlavaVLM | 576 | +| qwen2-vl-2b | Qwen2VL | 16384 | +| qwen2-vl-2b-4bit | Qwen2VL | 16384 | + +Qwen2-VL 검증은 유도식을 다시 계산하지 않고 실제 `smart_resize`와 `compute_grid_thw`를 극단 형태에 돌린다. 유도식을 다시 쓰면 공식이 자기 자신과 일치한다는 것만 증명된다. + +```text +224x224 -> 64 토큰 +768x1024 -> 999 토큰 +4000x4000 -> 16384 토큰 +8000x8000 -> 16384 토큰 +20000x300 -> 7854 토큰 +최악 = 16384, floor = 16384 +``` + +최악 관측값이 floor와 정확히 일치하므로 이 경계는 안전한 과대 추정이 아니라 타이트하다. 방향이 중요하다. 진짜 최대보다 높은 floor는 더 큰 그래프 비용만 물리지만, 낮은 floor는 가드가 막으려던 실패를 되살린다. + +## 6. 관련 작업 + +이슈 #1272가 이 PR로 닫히고, 확장된 가드는 이슈 #916에서 도입됐다. Qwen2-VL의 범위는 capacity 버킷의 가장 강한 근거다. 정적 shape 하나로는 floor가 모든 텍스트 전용 요청에 처리량 세금을 물리지만, 버킷이 있으면 이미지 버킷의 크기가 되어 텍스트 요청에 아무 비용도 물리지 않는다. + +코드에 남긴 전망성 단서가 하나 있다. LLaVA 호스트 전처리기가 언젠가 anyres 그리드를 구현하면 LLaVA floor는 이미지당 고정 수가 아니게 되고 다시 유도해야 한다. 이 유도는 현재 존재하는 로더에 대해 옳은 것이지 패밀리 일반에 대해 옳은 것이 아니다.