diff --git a/Cargo.lock b/Cargo.lock index 4affb469..914a7aa3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1275,14 +1275,18 @@ name = "omni-omnijev-native" version = "0.1.0" dependencies = [ "anyhow", + "axum", "base64 0.22.1", + "half", "image", "libm", "omni-qwen3-5-native", + "omni-runtime", "safetensors 0.8.0", "serde_json", "sha2 0.10.9", "tokenizers 0.22.2", + "tokio", ] [[package]] diff --git a/README.md b/README.md index a1b55e87..5d8ec42c 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,7 @@ runs as a Python worker or as a native worker on CUDA. Open-Jev-27B-v1.1 and Open-Jev-9B run on the same native Rust/CUDA worker. Cua-S1 also has a Python screenshot worker, Valen-Preview-0923 has a Python reference worker for text and one-image state, CLM has a stub-encoder contract recipe, and OmniJev-4B -v1.1 has its checkpoint export and CPU contract, with no worker yet. D3 +v1.1 runs on a native Rust/CUDA worker. D3 (Decision 3.0) has a Python reference worker that runs the checkpoint's own pinned runtime for text, image and video state: @@ -179,7 +179,7 @@ pinned runtime for text, image and video state: | autotrust/JEV-27B-VL | [Experimental Rust/CUDA worker](recipe/jev_vl/README.md); single-question text, online inline PNG/JPEG and offline-preencoded image inputs; [validation scope and limits](recipe/jev_vl/validation.md) | | CLM-v0.1-8B | [External worker with a CPU stub encoder](recipe/clm/README.md); contract checks only, real Qwen3-8B decisions unverified by this recipe | | LFM2.5-350M | [Transformers worker](recipe/lfm2/README.md); text state and independent choice questions | -| OmniJev-4B v1.1 | [Checkpoint export and CPU contract](recipe/omnijev/README.md) only; the native worker is in progress ([#114](https://github.com/ThinkFlowLab/system1-omni/issues/114)) | +| OmniJev-4B v1.1 | [Native Rust/CUDA worker](recipe/omnijev/native.md), eager: one image, Choice, Noul and Score; [reference comparison on sm_89](recipe/omnijev/native.md#comparison-with-the-reference) | | D3 (Decision 3.0) | [Python reference worker](recipe/d3/reference.md) on the checkpoint's pinned runtime; `choice`/`noul`/`score` over text, image and video state; author-run on A100 for text and media serving (see the integration and media PRs) | [Supported models and hardware](docs/supported-models.md) lists the devices diff --git a/docs/supported-models.md b/docs/supported-models.md index 4afd2514..681d5cc0 100644 --- a/docs/supported-models.md +++ b/docs/supported-models.md @@ -25,7 +25,7 @@ Models that are being added are also tracked in issues labeled [new model](https | CLM-v0.1-8B | [External `clm-serve` recipe](../recipe/clm/README.md) with a CPU stub embeddings server | **Stub-encoder contract checks only** ([#23](https://github.com/ThinkFlowLab/system1-omni/pull/23)); not real Qwen3-8B decisions | Real encoder unverified by the merged recipe | Unverified | Python, upstream CLM and head checkpoint; a real encoder requires a separate embeddings server | | LFM2.5-350M | [Transformers choice worker](../recipe/lfm2/README.md) | Tiny-model regression tests; full checkpoint unverified | L40S FP16 [recorded execution checks](../recipe/lfm2/RESULTS.md) | Unverified | Python 3.12, [pinned dependencies](../src/models/lfm2/requirements.lock.txt), explicit candidate batch size | | CLM-v0.1-8B | [Native Rust engine](../src/models/clm/README.md), `clm-run`, in front of a Qwen3-8B `/v1/embeddings` server | Engine-only agreement **4.5e-06** on identical vectors ([#29](https://github.com/ThinkFlowLab/system1-omni/pull/29), [validation](../recipe/clm/native/VALIDATION.md)); contract checks documented ([#23](https://github.com/ThinkFlowLab/system1-omni/pull/23)) | **Validated on compute capability 8.9** ([#29](https://github.com/ThinkFlowLab/system1-omni/pull/29), [validation](../recipe/clm/native/VALIDATION.md)) | Unverified | Rust to build and a separate Qwen3-8B `/v1/embeddings` server for the encoder; the engine itself has no device-specific code | -| OmniJev-4B v1.1 | None yet: [checkpoint export and CPU contract](../recipe/omnijev/README.md); the native worker is in progress ([#114](https://github.com/ThinkFlowLab/system1-omni/issues/114)) | Request contract, preparation, heads and finishing checked against the reference ([#120](https://github.com/ThinkFlowLab/system1-omni/pull/120)); no inference | Not yet | Not supported | Python 3.10 reference environment for the export and fixtures | +| OmniJev-4B v1.1 | [Native Rust/CUDA worker](../recipe/omnijev/native.md) on the shared Qwen3.5 vision and language path, eager | Contract, preparation, pixels, heads and finishing checked against the reference; no CPU inference | Compared with the reference on compute capability 8.9 for [140 answers](../recipe/omnijev/native.md#comparison-with-the-reference) | Not supported | Compute capability 8.0 or newer, CUDA toolkit to build, the export from `export.py` | | D3 (Decision 3.0) | [Python reference worker](../recipe/d3/reference.md) on the checkpoint's pinned runtime — text, image and video state over `choice`/`noul`/`score`, with the deliberate transport differences documented in the recipe; the native worker is in progress ([#136](https://github.com/ThinkFlowLab/system1-omni/issues/136)) | Unverified | Author-run on NVIDIA A100 (sm_80): serving smoke and reference parity for text and media (exact decisions, max delta 0.0); evidence in the integration and media PRs. Owner: @MrDongsls. | Unverified | Python 3.12, the [pinned runtime requirements](../recipe/d3/requirements.txt), the [pinned package](https://huggingface.co/vllm-sr/d3) (~52 GB); [media budgets and the 64 MiB total request cap](../recipe/d3/reference.md) | - **Validated:** covered by the recipe on `main` or by the checks in the linked merged pull request. diff --git a/mkdocs.yml b/mkdocs.yml index ec30436b..f67b6a7b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -78,6 +78,7 @@ nav: - JEV-27B-VL experimental worker: recipe/jev_vl/README.md - JEV-27B-VL validation status: recipe/jev_vl/validation.md - OmniJev preparation: recipe/omnijev/README.md + - OmniJev native worker: recipe/omnijev/native.md - CLM stub-encoder contract: recipe/clm/README.md - LFM2.5-350M choice worker: recipe/lfm2/README.md - D3 reference worker: recipe/d3/reference.md diff --git a/recipe/README.md b/recipe/README.md index 27778139..f1d30b0c 100644 --- a/recipe/README.md +++ b/recipe/README.md @@ -31,7 +31,8 @@ For a first real decision, follow the [complete CPU walkthrough](../docs/getting - [JEV-27B-VL experimental worker](jev_vl/README.md): native text and preencoded image decisions; bounded H800 validation with explicit deployment limits. - [OmniJev-4B v1.1 preparation](omnijev/README.md): export the checkpoint and - regenerate the CPU fixtures; the native worker is not available yet. + regenerate the CPU fixtures; [native worker](omnijev/native.md): serve it on CUDA + and compare it with the reference. - [CLM behind the frontend](clm/README.md): run CLM's own server behind the frontend on CPU with a stub encoder, and what the response comparison has to allow for. - [D3 reference worker](d3/reference.md): download the pinned vllm-sr/d3 package, diff --git a/recipe/omnijev/README.md b/recipe/omnijev/README.md index 02f49391..0ec8aa2d 100644 --- a/recipe/omnijev/README.md +++ b/recipe/omnijev/README.md @@ -1,10 +1,10 @@ # OmniJev-4B v1.1 preparation This recipe prepares OmniJev-4B v1.1 ([#114](https://github.com/ThinkFlowLab/system1-omni/issues/114)) -for the native worker: it exports the checkpoint and regenerates the CPU fixtures -that the contract and preparation tests use. The native worker itself is not -available yet; [the model README](../../src/models/omnijev/README.md) describes what -is implemented. Run the commands from the repository root. +for [the native worker](native.md): it exports the checkpoint and regenerates the CPU +fixtures that the contract, preparation and pixel tests use. [The model +README](../../src/models/omnijev/README.md) describes what is implemented. Run the +commands from the repository root. ## Reference environment @@ -65,8 +65,8 @@ OMNIJEV_CHECKPOINT=$PWD/omnijev-v1.1 OMNIJEV_EXPORT=$PWD/omnijev-export \ cargo test --locked -p omni-omnijev-native -- --include-ignored ``` -The generator runs the reference's preparation, layout, rotary positions, heads and -finishing without loading the backbone. The ordinary tests need only the committed +The generator runs the reference's image loading and processor, preparation, layout, +rotary positions, heads and finishing without loading the backbone. The ordinary tests need only the committed fixtures; the opt-in ones compare token ids with the checkpoint's tokenizer and the heads with the export. Cargo runs the tests from the crate's directory, so the two paths must be absolute. diff --git a/recipe/omnijev/gate_questions.json b/recipe/omnijev/gate_questions.json new file mode 100644 index 00000000..22e76b08 --- /dev/null +++ b/recipe/omnijev/gate_questions.json @@ -0,0 +1,242 @@ +{ + "one_token": { + "type": "choice", + "instructions": "Which button should be clicked next?", + "options": [ + { + "key": "click OK", + "text": "click OK" + }, + { + "key": "click Cancel", + "text": "click Cancel" + }, + { + "key": "click Close", + "text": "click Close" + }, + { + "key": "click Help", + "text": "click Help" + }, + { + "abstain": true + } + ] + }, + "long_start": { + "type": "choice", + "instructions": "What should the user do next?", + "options": [ + { + "key": "first", + "text": "open the settings menu at the top of the screen and then select the first item" + }, + { + "key": "second", + "text": "open the settings menu at the top of the screen and then select the second item" + }, + { + "key": "third", + "text": "open the settings menu at the top of the screen and then select the third item" + }, + { + "abstain": true + } + ] + }, + "no_fit": { + "type": "choice", + "instructions": "Which animal is shown in the picture?", + "options": [ + { + "key": "a giraffe", + "text": "a giraffe" + }, + { + "key": "a whale", + "text": "a whale" + }, + { + "key": "a penguin", + "text": "a penguin" + }, + { + "abstain": true + } + ] + }, + "score10": { + "type": "score", + "instructions": "How bright is the image overall?", + "levels": [ + "pitch black", + "very dark", + "dark", + "dim", + "slightly dim", + "neutral", + "slightly bright", + "bright", + "very bright", + "blinding" + ] + }, + "choice32": { + "type": "choice", + "instructions": "Which word best describes the image?", + "options": [ + { + "key": "game", + "text": "game" + }, + { + "key": "chart", + "text": "chart" + }, + { + "key": "map", + "text": "map" + }, + { + "key": "menu", + "text": "menu" + }, + { + "key": "photo", + "text": "photo" + }, + { + "key": "diagram", + "text": "diagram" + }, + { + "key": "table", + "text": "table" + }, + { + "key": "form", + "text": "form" + }, + { + "key": "chat", + "text": "chat" + }, + { + "key": "email", + "text": "email" + }, + { + "key": "video", + "text": "video" + }, + { + "key": "code", + "text": "code" + }, + { + "key": "terminal", + "text": "terminal" + }, + { + "key": "calendar", + "text": "calendar" + }, + { + "key": "document", + "text": "document" + }, + { + "key": "spreadsheet", + "text": "spreadsheet" + }, + { + "key": "slide", + "text": "slide" + }, + { + "key": "browser", + "text": "browser" + }, + { + "key": "robot", + "text": "robot" + }, + { + "key": "board", + "text": "board" + }, + { + "key": "phone", + "text": "phone" + }, + { + "key": "street", + "text": "street" + }, + { + "key": "kitchen", + "text": "kitchen" + }, + { + "key": "office", + "text": "office" + }, + { + "key": "forest", + "text": "forest" + }, + { + "key": "beach", + "text": "beach" + }, + { + "key": "city", + "text": "city" + }, + { + "key": "animal", + "text": "animal" + }, + { + "key": "person", + "text": "person" + }, + { + "key": "car", + "text": "car" + }, + { + "key": "screen", + "text": "screen" + }, + { + "key": "error", + "text": "error" + }, + { + "abstain": true + } + ] + }, + "noul_day": { + "type": "noul", + "instructions": "The image was taken during the day." + }, + "noul_people": { + "type": "noul", + "instructions": "There are more than three people in the image." + }, + "noul_region": { + "type": "noul", + "instructions": "There is text in this region.", + "region": { + "box": [ + 100, + 100, + 700, + 400 + ] + } + } +} \ No newline at end of file diff --git a/recipe/omnijev/native.md b/recipe/omnijev/native.md new file mode 100644 index 00000000..b6467c99 --- /dev/null +++ b/recipe/omnijev/native.md @@ -0,0 +1,123 @@ +# OmniJev-4B v1.1 native worker + +The native worker ([`src/models/omnijev/native/`](../../src/models/omnijev/native/)) +serves OmniJev-4B v1.1 ([#114](https://github.com/ThinkFlowLab/system1-omni/issues/114)) +on `/v1/systemone`: one PNG or JPEG image and any mix of Choice, Noul and Score +questions, with the vision tower and the Qwen3.5-4B language model on the CUDA kernels +in [`src/backends/cuda/qwen3_5/`](../../src/backends/cuda/qwen3_5/) and no Python or +PyTorch. It needs an NVIDIA GPU with compute capability 8.0 or newer and the export from +[the preparation recipe](README.md). [The model README](../../src/models/omnijev/README.md) +describes the contract and what the worker computes. + +Run the commands from the repository root. Pass your GPU's compute capability to +`build.sh`; the worker finds `libqwen3_5_cuda.so` next to its executable, or at +`OMNIJEV_CUDA_LIB`: + +```sh +src/backends/cuda/qwen3_5/build.sh target/release 89 # needs nvcc and cuBLASLt +cargo build --release --locked -p omni-omnijev-native +OMNIJEV_MODEL=$PWD/omnijev-export target/release/omni-omnijev-native +``` + +`OMNIJEV_HOST` and `OMNIJEV_PORT` default to `127.0.0.1` and `8000`. At start the +worker checks every file of the export against its manifest, loads the weights and +answers one request with all three question types before it listens, so `/health` +answers only once a request has run. Put the frontend in front of it with +`OMNI_JEV_BACKEND_URL` (see [the frontend README](../../src/frontend/README.md)), or +send requests to the worker directly: + +```sh +IMAGE=$(base64 < screenshot.png | tr -d '\n') +curl -s http://127.0.0.1:8000/v1/systemone -H 'Content-Type: application/json' -d @- < 1 else 1.0 + flips.append((name, qid, ra[decision], na[decision], margin)) + if decision is None and (ra["noul"] >= 0.5) != (na["noul"] >= 0.5): + flips.append((name, qid, ra["noul"], na["noul"], abs(ra["noul"] - 0.5))) + for p in problems: + print("not compared:", p) + if not diffs: + sys.exit("nothing to compare") + diffs.sort() + q = lambda f: diffs[min(len(diffs) - 1, int(f * len(diffs)))] + print(f"{total} answers on {len(ref['images'])} images; input tokens differ on {tokens} images") + print(f"largest difference per answer: median {statistics.median(diffs):.4f}, p90 {q(0.9):.4f}, " + f"p99 {q(0.99):.4f}, max {diffs[-1]:.4f}; within 0.01: {sum(d <= 0.01 for d in diffs)}") + print(f"decision changes: {len(flips)}") + for f in flips: + print(" {} {}: reference {} native {} (reference margin {:.4f})".format(*f)) + + +def main(): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + sub = ap.add_subparsers(dest="command", required=True) + r = sub.add_parser("reference") + r.add_argument("--reference", required=True, help="OmniJev checkout @ 14dbec4") + r.add_argument("--checkpoint", required=True) + r.add_argument("--base", required=True) + r.add_argument("--images", required=True) + r.add_argument("--questions") + r.add_argument("--merge", action="store_true", help="merge the LoRA into the BF16 weights first") + r.add_argument("--fp32", action="store_true", help="load the model in float32, TF32 off") + r.add_argument("--out", required=True) + n = sub.add_parser("native") + n.add_argument("--url", required=True) + n.add_argument("--images", required=True) + n.add_argument("--against", required=True, help="the reference output, for its questions and image hashes") + n.add_argument("--out", required=True) + c = sub.add_parser("compare") + c.add_argument("reference_json") + c.add_argument("native_json") + a = ap.parse_args() + {"reference": reference, "native": native, "compare": compare}[a.command](a) + + +if __name__ == "__main__": + main() diff --git a/src/backends/cuda/qwen3_5/qwen3_5.backend.json b/src/backends/cuda/qwen3_5/qwen3_5.backend.json index e49e1457..325e1757 100644 --- a/src/backends/cuda/qwen3_5/qwen3_5.backend.json +++ b/src/backends/cuda/qwen3_5/qwen3_5.backend.json @@ -28,7 +28,8 @@ "src/models/open_jev/native/", "src/models/jev_vl/native/", "src/models/decider/native/", - "src/models/jemm/native/" + "src/models/jemm/native/", + "src/models/omnijev/native/" ], "build": { "script": "build.sh", diff --git a/src/models/cua_s1/native/src/multimodal.rs b/src/models/cua_s1/native/src/multimodal.rs index 555eda83..a9f352c3 100644 --- a/src/models/cua_s1/native/src/multimodal.rs +++ b/src/models/cua_s1/native/src/multimodal.rs @@ -31,47 +31,6 @@ pub fn chat_image(question: &Question, image_tokens: usize) -> Result { )) } -pub fn image_positions(ids: &[u32], image_token: u32, grid: [usize; 3]) -> Result<[Vec; 3]> { - let [t, h, w] = grid; - ensure!( - t == 1 && h > 0 && w > 0 && h.is_multiple_of(2) && w.is_multiple_of(2), - "expected one image with even, nonzero spatial grid" - ); - ensure!( - !ids.is_empty() && ids.len() <= MAX_TOKENS, - "processed prompt exceeds {MAX_TOKENS} tokens or is empty" - ); - let count = (h / 2) - .checked_mul(w / 2) - .ok_or_else(|| anyhow::anyhow!("grid overflow"))?; - let start = ids - .iter() - .position(|&id| id == image_token) - .ok_or_else(|| anyhow::anyhow!("missing image placeholders"))?; - let end = start - .checked_add(count) - .ok_or_else(|| anyhow::anyhow!("grid overflow"))?; - ensure!( - end <= ids.len() - && ids[start..end].iter().all(|&id| id == image_token) - && ids[end..].iter().all(|&id| id != image_token), - "image placeholders must be one contiguous span matching the grid" - ); - let mut positions: [Vec; 3] = std::array::from_fn(|_| (0..start as i64).collect()); - for y in 0..h / 2 { - for x in 0..w / 2 { - positions[0].push(start as i64); - positions[1].push((start + y) as i64); - positions[2].push((start + x) as i64); - } - } - let next = start + h.max(w) / 2; - for axis in &mut positions { - axis.extend((next..next + ids.len() - end).map(|p| p as i64)); - } - Ok(positions) -} - /// A prepared single-image prompt. Every prompt in a request is prepared before /// running the shared image encoder. pub struct ImagePrompt { @@ -94,7 +53,11 @@ pub fn prepare_prompt( .encode(chat_image(question, count)?, false) .map_err(anyhow::Error::msg)?; let token_ids = encoded.get_ids().to_vec(); - let position_ids = image_positions(&token_ids, image_token, grid)?; + ensure!( + !token_ids.is_empty() && token_ids.len() <= MAX_TOKENS, + "processed prompt exceeds {MAX_TOKENS} tokens or is empty" + ); + let position_ids = crate::inputs::image_positions(&token_ids, image_token, grid)?; let image_token_indices = token_ids .iter() .enumerate() diff --git a/src/models/omnijev/README.md b/src/models/omnijev/README.md index 05cd376b..e614f21b 100644 --- a/src/models/omnijev/README.md +++ b/src/models/omnijev/README.md @@ -3,11 +3,12 @@ OmniJev-4B v1.1 ([#114](https://github.com/ThinkFlowLab/system1-omni/issues/114)) answers Choice, Noul and Score questions about one image with a Qwen3.5-4B backbone, a merged LoRA adapter, two option-marker tokens and trained FP32 heads. This directory -holds the native implementation's CPU side so far: the request contract, preparation -and the branch layout, the heads, and response finishing, each checked against the -pinned reference. Vision and language execution, the worker and frontend integration -come next. The [recipe](../../../recipe/omnijev/README.md) exports the checkpoint and -regenerates the fixtures. +holds the native worker: the request contract, image decoding and preparation, the +branch layout, execution on the shared Qwen3.5 vision and language CUDA path, the +heads, and response finishing, each checked against the pinned reference. The +[preparation recipe](../../../recipe/omnijev/README.md) exports the checkpoint and +regenerates the fixtures; [the worker recipe](../../../recipe/omnijev/native.md) builds, +runs and validates the worker. ## Pinned artifacts @@ -48,10 +49,13 @@ linear-attention state of every row from zero, and 5.2 to 5.4 fail in it. Option text is cut to 200 characters, and region coordinates are rounded half to even, both as the reference does. Answers keep the reference's fields: `noul`; `choice`, `probabilities`, `abstain`, `valid` and `confidence`; or `score`, `probabilities` and -`confidence`; each with `latency_s`, and with `latency_total_s` once the worker adds -it, both from the native timing. Probabilities are rounded to four decimals. The -layout also gives the reference's input-token count, the shared prefix once plus every -row, for the worker's `usage`. +`confidence`; each with `latency_s` and `latency_total_s`, which time the request's +preparation, its vision and language passes and its heads, as the reference times `ask_branch` +from encoding to the heads. Probabilities are +rounded to four decimals. The response is +`{"model": "tinnel123/OmniJev", "answers": {id: answer}, "usage": {"input_tokens": n, +"output_tokens": 0}}`, with the reference's input-token count: the shared prefix once +plus every row. Deliberate differences from the reference: @@ -60,12 +64,15 @@ Deliberate differences from the reference: - Text containing `<|opt|>`, `<|/opt|>`, `<|image_pad|>`, `<|video_pad|>`, `<|vision_start|>` or `<|vision_end|>` is refused. The reference would read it as option or image markers and misplace its readouts. -- Images arrive as data URLs instead of file paths. +- Images arrive as data URLs instead of file paths. PNG files decode to the same RGB + values as Pillow's `convert("RGB")`, including its 16-bit conversions; JPEG files use + the `image` crate's decoder, whose values can differ from Pillow's libjpeg-turbo by + rounding (by at most 3 in the committed sample). - Explicit limits: a 12 MiB body; an 8 MiB image of at most 4096 pixels per side and an aspect ratio of at most 200, which the processor also requires; 64 questions with ids of at most 256 characters; 1 to 255 options per question and 1,024 in all; - 8,192 characters per text field; 8,192 tokens per question; and 65,536 processed - tokens per request. + 8,192 characters per text field; 8,192 tokens per question; 65,536 processed + tokens per request; and 16,384 option-text tokens whose log-probabilities are read. - Fields the reference ignores (Noul `criteria`, `criteria` next to Choice `options` or Score `levels`, unknown fields), and values it would turn into text (numbers as levels, for example), are refused. @@ -84,18 +91,36 @@ chat template does: ``` The template trims the user content, so trailing whitespace in the instructions goes, -by Python's definition of whitespace. The image placeholder expands to one token per -2×2 merged patch of the processor's resize, with `MSO1`'s 602,112-pixel budget (it -overrides the processor config's 401,408) and the 65,536-pixel minimum. +by Python's definition of whitespace. The image is decoded and preprocessed by the +shared Qwen crate (`image_decode` and `image_preprocess`, the port of the reference's +`Qwen2VLImageProcessor`) with `MSO1`'s 602,112-pixel budget (it overrides the processor +config's 401,408) and the 65,536-pixel minimum, and its placeholder expands to one token +per 2×2 merged patch. The layout follows `MSO1.ask_branch`. The shared prefix is the questions' common token prefix, cut at least one token before any question's first `<|opt|>`. Each row is the rest of its question plus one option block. A row's readouts are `u` at its closing marker, `zq` at the token before its option marker, and, for the LM features, each option-text token with the position that predicts it. Rotary positions are Qwen3.5's -T/H/W positions of the single-question sequence; after the prefix, rows continue from -its largest position plus one. Since every row equals a plain forward over prefix plus -row up to rounding, execution may choose its own split points. +T/H/W positions (the shared `inputs::image_positions`); after the prefix, rows continue +from its largest position plus one. Since every row equals a plain forward over prefix +plus row up to rounding, execution may choose its own split points. + +## Execution + +[`executor.rs`](native/src/executor.rs) runs the vision tower once per request, then the +request's prefix up to its last multiple of 64 tokens once, each question's text up to the +last multiple of 64 before its first option once, and each option's block with the +tokens that remain before it, each as a continuation of the state before it +(`Model::readout_window`). The passes use the fixed-algorithm GEMMs and split at +multiples of 64, so every answer equals that of one plain pass per (question, option) +row over the prefix and that row, which `Executor::execute_rows` keeps for the checks. +The passes read the final-norm hidden states at +`zq` and `u`, which is what the reference's `hidden_states[-1]` holds, and the +option-text tokens' log-probabilities: BF16 logits from the tied embedding over the +248,079-entry vocabulary, converted to float32, with a float64 sum in the log-softmax. Score rows skip the +log-probabilities, which its ordinal head does not use. The heads run on the CPU; the +question state `zq` is the first row's, as in the reference. ## Heads and finishing @@ -103,9 +128,8 @@ row up to rounding, execution may choose its own split points. and Noul use `OptionScorer`: gated option and question MLPs, the LM features, a per-type temperature, and for Choice a softmax that includes the abstain logit; Noul is a sigmoid. Score uses only the cumulative-link ordinal head. The six LM -features of an option come from its text tokens' log-probabilities over the full -248,079-entry vocabulary, which execution computes. A plain Noul option has no text, -so its features are zero. +features of an option come from its text tokens' log-probabilities. A plain Noul +option has no text, so its features are zero. `contract::answer` follows `MSO1._finish`: Noul's log-odds bias before its temperature, the saved temperatures for each type, Choice's abstain and validity, @@ -114,27 +138,32 @@ confidence, and four-decimal rounding. The float32 sum of more than four head ou can add in a different order than PyTorch's, which moves the fourth decimal by one at most; the reference's own sum on a GPU has the same freedom. -[`processing::image_grid`](native/src/processing.rs) and `processing::image_positions` -follow Transformers 5.17.0's `Qwen2VLImageProcessor` resize and Qwen3.5 rotary -positions (Apache-2.0, Copyright 2025 The Qwen team, Alibaba Group and the HuggingFace -Inc. team), as Cua-S1's copies do. Both move to the shared Qwen crate once its image -preprocessing is shared. - ## Validation [`generate_fixtures.py`](../../../tests/omnijev/generate_fixtures.py) runs the -reference's own preparation, layout, rotary positions, heads and finishing on the CPU -without loading the backbone. The tests in [`tests/omnijev/`](../../../tests/omnijev/) -compare against those fixtures: prompts, answer keys and option text, marker -positions, image grids, rotary positions, the prefix, rows, readouts and token counts -for seven requests, exactly; the LM features within 1e-6; and finished answers for -eighteen head outputs, exactly up to four options and within one unit of the fourth -decimal beyond. Two checks are opt-in: token ids against the checkpoint's tokenizer, -exactly, and the heads against an export, within 1e-5 (relative for values above one). Contract tests cover -refused requests, with the reason, and the limits. - -No GPU work runs in this crate, and nothing here establishes parity of the model's -outputs; that comes with execution. +reference's own image loading and processor, preparation, layout, rotary positions, +heads and finishing on the CPU without loading the backbone. The tests in +[`tests/omnijev/`](../../../tests/omnijev/) compare against those fixtures: + +- prompts, answer keys and option text, marker positions, image grids, rotary + positions, the prefix, rows, readouts and token counts for seven requests, exactly; +- decoded RGB values and pixel values for nine images (patterns at four sizes, RGBA, + grayscale, palette, 16-bit grayscale and JPEG), exactly for PNG, and for JPEG the + decoder within 3 of Pillow and the processor exactly on Pillow's values; +- the LM features within 1e-6, and finished answers for eighteen head outputs, exactly + up to four options and within one unit of the fourth decimal beyond; +- refused requests, with the reason, the limits, and the export manifest checks. + +Opt-in checks need the checkpoint or an export: token ids against the checkpoint's +tokenizer, exactly; the heads against an export, within 1e-5 (relative for values above +one); and on a GPU, the readout pass (`tests/qwen3_5/readout.rs`: equal to +`forward_fixed` for text, independent of the other rows and targets read, +log-probabilities against the CPU, and windows continuing captured prefixes equal to one +pass) and the worker (repeatable answers, independent of question order, and the reused +prefix equal to the per-row passes bit for bit for questions that split three ways +against the 64-token chunks). The two prefix states the executor keeps take about +50 MiB each plus 32 KiB per token of their longest prefix. [`validate.py`](../../../recipe/omnijev/validate.py) compares the +worker's answers with the reference's. The Rust code adapts OmniJev's Apache-2.0 code; its copyright and license are retained in [`native/LICENSE.omnijev`](native/LICENSE.omnijev). No model weights are diff --git a/src/models/omnijev/native/Cargo.toml b/src/models/omnijev/native/Cargo.toml index 2d19a8d2..644b3f1f 100644 --- a/src/models/omnijev/native/Cargo.toml +++ b/src/models/omnijev/native/Cargo.toml @@ -3,18 +3,28 @@ name = "omni-omnijev-native" version = "0.1.0" edition = "2024" publish = false -description = "OmniJev-4B v1.1 request contract, preparation, heads and finishing" +description = "Native /v1/systemone worker for OmniJev-4B v1.1" + +[[bin]] +name = "omni-omnijev-native" +path = "src/main.rs" [dependencies] anyhow = "1.0.100" +axum = "0.8.8" base64 = "0.22.1" +half = "2.7.1" image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } libm = "0.2" +# the CUDA kernels live in libqwen3_5_cuda.so, loaded at run time omni-qwen3-5-native = { path = "../../qwen3_5/native" } +omni-runtime = { path = "../../../runtime" } safetensors = "0.8.0" serde_json = { version = "1.0.149", features = ["float_roundtrip", "preserve_order"] } +sha2 = "0.10" # the onig regex backend, as in the Python tokenizers wheel tokenizers = { version = "=0.22.2", default-features = false, features = ["onig"] } +tokio = { version = "1.49.0", features = ["macros", "net", "rt-multi-thread", "sync"] } [[test]] name = "contract" @@ -32,5 +42,14 @@ path = "../../../../tests/omnijev/finish.rs" name = "heads" path = "../../../../tests/omnijev/heads.rs" -[dev-dependencies] -sha2 = "0.10" +[[test]] +name = "export" +path = "../../../../tests/omnijev/export.rs" + +[[test]] +name = "pixels" +path = "../../../../tests/omnijev/pixels.rs" + +[[test]] +name = "worker" +path = "../../../../tests/omnijev/worker.rs" diff --git a/src/models/omnijev/native/src/contract.rs b/src/models/omnijev/native/src/contract.rs index 62fd07af..129c0860 100644 --- a/src/models/omnijev/native/src/contract.rs +++ b/src/models/omnijev/native/src/contract.rs @@ -62,8 +62,8 @@ pub struct Question { pub options: Vec, } -/// The request's image: its encoded bytes and dimensions. Pixel decoding comes -/// with the vision path. +/// The request's image: its encoded bytes and the dimensions from its header. +/// `processing::pixels` decodes it. pub struct Image { pub format: image::ImageFormat, pub width: usize, diff --git a/src/models/omnijev/native/src/executor.rs b/src/models/omnijev/native/src/executor.rs new file mode 100644 index 00000000..170034a5 --- /dev/null +++ b/src/models/omnijev/native/src/executor.rs @@ -0,0 +1,403 @@ +//! Native execution of a prepared request: the vision tower once, then the request's +//! prefix once, each question's text once and each option's block, as continuations +//! with the fixed GEMM algorithms and 64-token boundaries; the heads on the CPU; and +//! `MSO1._finish`. Every answer equals that of one plain pass per (question, option) +//! row, which `execute_rows` keeps for checking. + +use std::ops::Range; +use std::path::Path; +use std::time::Instant; + +use anyhow::{Context, Result, ensure}; +use omni_qwen3_5_native::inputs::{MultimodalInput, image_positions}; +use omni_qwen3_5_native::model::{Model, PrefixState}; +use omni_qwen3_5_native::vision::VisionModel; +use serde_json::{Map, Value, json}; + +use crate::contract::{self, Calibration, Kind, round4}; +use crate::export; +use crate::heads::{self, Heads}; +use crate::processing::{IMAGE_TOKEN, PreparedRequest, Processor, Row}; + +pub struct Executor { + vision: VisionModel, + language: Model, + heads: Heads, + calibration: Calibration, + /// Buffers for the state after the request's prefix and after a question's text. Each + /// request or question that uses one captures it again; only the allocations outlive a + /// request, grown as needed. + request: Option, + question: Option, +} + +/// Rows `[start, end)` of a prompt: their token ids, their image placeholders within the +/// window, the rows of the image features those take, and their positions. +struct Window { + ids: Vec, + images: Vec, + features: Range, + positions: [Vec; 3], +} + +impl Window { + fn new(ids: &[u32], positions: &[Vec; 3], range: Range) -> Self { + let image_start = ids.iter().position(|&id| id == IMAGE_TOKEN).unwrap_or(0); + let images: Vec = range + .clone() + .filter(|&i| ids[i] == IMAGE_TOKEN) + .map(|i| i - range.start) + .collect(); + let first = images.first().map_or(0, |&i| range.start + i - image_start); + Self { + ids: ids[range.clone()].to_vec(), + features: first..first + images.len(), + images, + positions: positions + .each_ref() + .map(|axis| axis[range.clone()].to_vec()), + } + } + + fn input<'a>(&'a self, features: &'a [half::bf16], hidden: usize) -> MultimodalInput<'a> { + MultimodalInput { + token_ids: &self.ids, + image_token_indices: &self.images, + image_embeddings: &features[self.features.start * hidden..self.features.end * hidden], + position_ids: [&self.positions[0], &self.positions[1], &self.positions[2]], + } + } +} + +/// `slot`'s state, reallocated when it holds fewer than `len` tokens. +fn state<'s>( + model: &Model, + slot: &'s mut Option, + len: usize, +) -> Result<&'s mut PrefixState> { + if slot.as_ref().is_none_or(|state| state.capacity() < len) { + *slot = None; + let max = model.cfg.max_positions - model.cfg.max_positions % 64; + *slot = Some(model.alloc_prefix(len.next_multiple_of(1024).min(max).max(len))?); + } + Ok(slot.as_mut().unwrap()) +} + +/// What one row's pass reads: `zq` and `u`, and the option-text tokens' +/// log-probabilities, the first one's from `zq`. +struct RowReadout { + zq: Vec, + u: Vec, + picked: Vec, + first: Option, +} + +impl Executor { + /// Check the export at `dir`, then load the processor, the vision tower, the + /// language model and the heads. + pub fn load(dir: &Path, library: &Path) -> Result<(Self, Processor)> { + let export = export::load(dir)?; + let processor = Processor::load(dir)?; + let language = Model::load(dir, library)?; + ensure!( + language.cfg.image_token_id == Some(IMAGE_TOKEN) + && language.cfg.hidden == heads::HIDDEN, + "export config does not match OmniJev-4B" + ); + let vision = VisionModel::load(dir, library)?; + let heads = Heads::load(&dir.join("heads.safetensors"))?; + Ok(( + Self { + vision, + language, + heads, + calibration: export.calibration, + request: None, + question: None, + }, + processor, + )) + } + + /// The answers in question order, each with the reference's `latency_s` and + /// `latency_total_s`: the request's preparation, its vision and language passes and + /// its heads, over its questions and in all. + pub fn execute(&mut self, prepared: &PreparedRequest) -> Result> { + self.execute_with(prepared, true) + } + + /// `execute` with one plain pass per (question, option) row over the prefix and that + /// row, which `execute`'s answers equal bit for bit, timing aside. + pub fn execute_rows(&mut self, prepared: &PreparedRequest) -> Result> { + self.execute_with(prepared, false) + } + + fn execute_with(&mut self, prepared: &PreparedRequest, reuse: bool) -> Result> { + let start = Instant::now(); + let outputs = self.head_outputs(prepared, reuse)?; + let elapsed = prepared.preparation_seconds + start.elapsed().as_secs_f64(); + let latency = elapsed / prepared.questions.len() as f64; + prepared + .questions + .iter() + .zip(outputs) + .map(|(question, mu)| { + let mut answer = contract::answer(question, &mu, &self.calibration, latency)?; + answer + .as_object_mut() + .context("answers are objects")? + .insert("latency_total_s".into(), json!(round4(elapsed))); + Ok(answer) + }) + .collect() + } + + /// Every question's head outputs before finishing, with the prefix reused or with + /// one plain pass per row; for checking one against the other. + pub fn head_outputs( + &mut self, + prepared: &PreparedRequest, + reuse: bool, + ) -> Result>> { + let result = self.run(prepared, reuse); + // Also finish queued work on an error before releasing admission. + let vision = self.vision.synchronize(); + let language = self.language.synchronize(); + let outputs = result?; + vision?; + language?; + Ok(outputs) + } + + fn run(&mut self, prepared: &PreparedRequest, reuse: bool) -> Result>> { + let features = self.vision.forward(&prepared.pixels)?; + let readouts = if reuse { + let (mut request, mut question) = (self.request.take(), self.question.take()); + let readouts = self.shared(prepared, &features, &mut request, &mut question); + (self.request, self.question) = (request, question); + readouts? + } else { + let prefix_len = prepared.layout.prefix; + let prefix = &prepared.inputs.first().context("no questions")?.token_ids[..prefix_len]; + let mut readouts = Vec::with_capacity(prepared.questions.len()); + for (question, input) in prepared.questions.iter().zip(&prepared.inputs) { + // Score's ordinal head takes no LM features. + let lm = question.kind != Kind::Score; + let rows = input + .rows + .iter() + .map(|row| self.row(prefix, row, prepared.grid, &features, lm)) + .collect::>>()?; + readouts.push(rows); + } + readouts + }; + prepared + .questions + .iter() + .zip(readouts) + .map(|(question, rows)| { + let zq = &rows[0].zq; + let u: Vec> = rows.iter().map(|r| r.u.clone()).collect(); + if question.kind == Kind::Score { + self.heads.ordinal(zq, &u) + } else { + let picked: Vec> = rows.iter().map(|r| r.picked.clone()).collect(); + let first: Vec> = rows.iter().map(|r| r.first).collect(); + let features = heads::lm_features(&picked, &first); + self.heads + .option_probabilities(&u, zq, question.kind.type_id(), &features) + } + }) + .collect() + } + + /// The readouts of every row with the prefix computed once: the request's prefix up + /// to its last multiple of 64 tokens, then per question its text up to the last + /// multiple of 64 before its first option, captured when it reaches one, then each + /// option's block with what remains before it. `zq` is read with the question's text + /// when that capture ends right after it, and otherwise in every row. + fn shared( + &mut self, + prepared: &PreparedRequest, + features: &[half::bf16], + request: &mut Option, + question_state: &mut Option, + ) -> Result>> { + let hidden = self.language.cfg.hidden; + let l = prepared.layout.prefix; + let p = l - l % 64; + let prefix = &prepared.inputs.first().context("no questions")?.token_ids[..l]; + let request = if p > 0 { + let positions = image_positions(prefix, IMAGE_TOKEN, prepared.grid)?; + let window = Window::new(prefix, &positions, 0..p); + let state = state(&self.language, request, p)?; + self.language.readout_window( + &window.input(features, hidden), + None, + Some(&mut *state), + &[], + &[], + )?; + Some(&*state) + } else { + None + }; + let mut readouts = Vec::with_capacity(prepared.questions.len()); + for (question, input) in prepared.questions.iter().zip(&prepared.inputs) { + // Score's ordinal head takes no LM features. + let lm = question.kind != Kind::Score; + let rows = &input.rows; + let row0 = rows.first().context("a question without options")?; + // the first option's marker, and the question's text up to the boundary before it + let open = l + row0.zq + 1; + let q = open - open % 64; + let zq_at = open - 1; + let mut shared_zq = None; + // each option's first text token, read from zq with the question's text + let mut shared_first: Vec> = Vec::new(); + let base = if q > p { + let ids: Vec = prefix.iter().chain(&row0.tokens).copied().collect(); + let positions = image_positions(&ids, IMAGE_TOKEN, prepared.grid)?; + let window = Window::new(&ids, &positions, p..q); + // the capture ends right after zq when the marker starts a new chunk + let read: &[usize] = if zq_at < q { &[zq_at] } else { &[] }; + let targets: Vec<(usize, u32)> = if lm && zq_at < q { + rows.iter() + .filter_map(|r| r.first.map(|token| (zq_at, token))) + .collect() + } else { + Vec::new() + }; + let state = state(&self.language, question_state, q)?; + let readout = self.language.readout_window( + &window.input(features, hidden), + request, + Some(&mut *state), + read, + &targets, + )?; + shared_zq = readout.hidden.into_iter().next(); + let mut logprobs = readout.logprobs.into_iter(); + shared_first = rows + .iter() + .map(|r| r.first.and_then(|_| logprobs.next())) + .collect(); + Some(&*state) + } else { + request + }; + let mut question_rows = Vec::with_capacity(rows.len()); + for (i, row) in rows.iter().enumerate() { + let ids: Vec = prefix.iter().chain(&row.tokens).copied().collect(); + let positions = image_positions(&ids, IMAGE_TOKEN, prepared.grid)?; + let window = Window::new(&ids, &positions, q..ids.len()); + let mut read = Vec::with_capacity(2); + let mut targets = Vec::new(); + if shared_zq.is_none() { + read.push(zq_at); + if lm { + targets.extend(row.first.map(|token| (zq_at, token))); + } + } + read.push(l + row.u); + if lm { + targets.extend(row.targets.iter().map(|&(p, token)| (l + p, token))); + } + let readout = self.language.readout_window( + &window.input(features, hidden), + base, + None, + &read, + &targets, + )?; + let mut hidden_rows = readout.hidden.into_iter(); + let zq = match &shared_zq { + Some(zq) => zq.clone(), + None => hidden_rows.next().unwrap(), + }; + let u = hidden_rows.next().unwrap(); + let mut logprobs = readout.logprobs.into_iter(); + let first = match row.first { + Some(_) if lm => { + if shared_zq.is_some() { + shared_first[i] + } else { + logprobs.next() + } + } + _ => None, + }; + question_rows.push(RowReadout { + zq, + u, + picked: logprobs.collect(), + first, + }); + } + readouts.push(question_rows); + } + Ok(readouts) + } + + /// One pass over the prefix and `row`, reading `zq`, `u` and, with `lm`, the + /// option-text log-probabilities. + fn row( + &mut self, + prefix: &[u32], + row: &Row, + grid: [usize; 3], + features: &[half::bf16], + lm: bool, + ) -> Result { + let at = prefix.len(); + let ids: Vec = prefix.iter().chain(&row.tokens).copied().collect(); + let positions = image_positions(&ids, IMAGE_TOKEN, grid)?; + let image_rows: Vec = (0..at).filter(|&i| ids[i] == IMAGE_TOKEN).collect(); + let mut targets = Vec::new(); + if lm { + targets.extend(row.first.map(|token| (at + row.zq, token))); + targets.extend(row.targets.iter().map(|&(p, token)| (at + p, token))); + } + let readout = self.language.forward_multimodal_readout( + &MultimodalInput { + token_ids: &ids, + image_token_indices: &image_rows, + image_embeddings: features, + position_ids: [&positions[0], &positions[1], &positions[2]], + }, + &[at + row.zq, at + row.u], + &targets, + )?; + let mut hidden = readout.hidden.into_iter(); + let (zq, u) = (hidden.next().unwrap(), hidden.next().unwrap()); + let mut logprobs = readout.logprobs.into_iter(); + let first = if lm && row.first.is_some() { + logprobs.next() + } else { + None + }; + Ok(RowReadout { + zq, + u, + picked: logprobs.collect(), + first, + }) + } +} + +/// The `/v1/systemone` response: the answers by question id, and the reference's +/// input-token count. +pub fn response(prepared: &PreparedRequest, answers: Vec) -> Value { + let answers: Map = prepared + .questions + .iter() + .map(|q| q.id.clone()) + .zip(answers) + .collect(); + json!({ + "model": contract::MODEL_ID, + "answers": answers, + "usage": {"input_tokens": prepared.layout.processed_tokens, "output_tokens": 0}, + }) +} diff --git a/src/models/omnijev/native/src/export.rs b/src/models/omnijev/native/src/export.rs new file mode 100644 index 00000000..fb57a361 --- /dev/null +++ b/src/models/omnijev/native/src/export.rs @@ -0,0 +1,150 @@ +//! The export `recipe/omnijev/export.py` writes: its manifest, the hashes of its files +//! and the calibration saved with the heads. + +use std::fs::{self, File}; +use std::io::Read; +use std::path::Path; + +use anyhow::{Context, Result, ensure}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; + +use crate::contract::{Calibration, MODEL_ID}; +use crate::processing::{MAX_PIXELS, OPTION_CLOSE, OPTION_OPEN}; + +pub const FORMAT: &str = "omnijev-merged/1"; +pub const MANIFEST: &str = "omnijev_export.json"; +pub const CHECKPOINT_REVISION: &str = "ffe5f436eaf22e20e2f041f8e74e121fd057a6cb"; +pub const BASE_REVISION: &str = "851bf6e806efd8d0a36b00ddf55e13ccb7b8cd0a"; +/// The tokenizer's length, to which the export resizes the tied embedding. +pub const VOCABULARY: usize = 248_079; +/// Files the worker reads besides the language weights; the manifest must list and +/// hash each. +const REQUIRED: [&str; 5] = [ + "config.json", + "heads.safetensors", + "model.safetensors.index.json", + "tokenizer.json", + "vision.safetensors", +]; + +/// A checked export directory. +pub struct Export { + pub calibration: Calibration, +} + +/// Check `dir`'s manifest against the pinned release and every file it lists against +/// its SHA-256, then read the calibration. +pub fn load(dir: &Path) -> Result { + let path = dir.join(MANIFEST); + let manifest: Value = + serde_json::from_slice(&fs::read(&path).with_context(|| { + format!("reading {}; run recipe/omnijev/export.py", path.display()) + })?) + .context("export manifest")?; + let expected = [ + ("format", json!(FORMAT)), + ("model_id", json!(MODEL_ID)), + ("checkpoint_revision", json!(CHECKPOINT_REVISION)), + ("base_revision", json!(BASE_REVISION)), + ("vocabulary", json!(VOCABULARY)), + ( + "option_tokens", + json!({"<|opt|>": OPTION_OPEN, "<|/opt|>": OPTION_CLOSE}), + ), + ("max_pixels", json!(MAX_PIXELS)), + ( + "heads", + json!({"norm": "softmax", "lm_features": true, "ordinal": true}), + ), + ]; + for (key, value) in expected { + ensure!( + manifest[key] == value, + "export manifest {key} is {}, expected {value}", + manifest[key] + ); + } + let outputs = manifest["outputs"] + .as_object() + .context("export manifest lists no outputs")?; + for name in REQUIRED { + ensure!( + outputs.contains_key(name), + "export manifest does not list {name}" + ); + } + for (name, digest) in outputs { + ensure!( + !name.is_empty() && !name.contains(['/', '\\']) && name != ".." && name != MANIFEST, + "export manifest lists an invalid file name {name:?}" + ); + let digest = digest + .as_str() + .context("export manifest hashes must be strings")?; + ensure!( + sha256(&dir.join(name))? == digest, + "{name} does not match the export manifest" + ); + } + // the language weights the shared loader reads through the index, all hashed above + let index: Value = serde_json::from_slice(&fs::read(dir.join("model.safetensors.index.json"))?) + .context("model.safetensors.index.json")?; + let shards = index["weight_map"] + .as_object() + .context("model.safetensors.index.json has no weight_map")?; + for shard in shards.values() { + let shard = shard + .as_str() + .context("weight_map values must be file names")?; + ensure!( + outputs.contains_key(shard), + "export manifest does not list {shard}" + ); + } + ensure!( + !dir.join("model.safetensors").exists() || outputs.contains_key("model.safetensors"), + "export has an unlisted model.safetensors" + ); + let config: Value = + serde_json::from_slice(&fs::read(dir.join("config.json"))?).context("config.json")?; + ensure!( + config["text_config"]["vocab_size"] == json!(VOCABULARY), + "config.json vocabulary is {}, expected {VOCABULARY}", + config["text_config"]["vocab_size"] + ); + Ok(Export { + calibration: calibration(&manifest["calibration"])?, + }) +} + +/// The manifest's calibration: positive, finite temperatures and a finite Noul bias. +pub fn calibration(value: &Value) -> Result { + let number = |v: &Value| v.as_f64().filter(|x| x.is_finite()); + let t = &value["temperatures"]; + let temperatures = ["noul", "choice", "score"].map(|k| number(&t[k]).filter(|&x| x > 0.0)); + ensure!( + t.as_object().is_some_and(|o| o.len() == 3) && temperatures.iter().all(Option::is_some), + "export calibration needs positive noul, choice and score temperatures" + ); + let noul_bias = + number(&value["noul_bias"]).context("export calibration needs a finite noul_bias")?; + Ok(Calibration { + temperatures: temperatures.map(Option::unwrap), + noul_bias, + }) +} + +fn sha256(path: &Path) -> Result { + let mut file = File::open(path).with_context(|| format!("opening {}", path.display()))?; + let mut hash = Sha256::new(); + let mut buffer = vec![0u8; 1 << 22]; + loop { + let n = file.read(&mut buffer)?; + if n == 0 { + break; + } + hash.update(&buffer[..n]); + } + Ok(format!("{:x}", hash.finalize())) +} diff --git a/src/models/omnijev/native/src/lib.rs b/src/models/omnijev/native/src/lib.rs index 1be2f939..03ea08e7 100644 --- a/src/models/omnijev/native/src/lib.rs +++ b/src/models/omnijev/native/src/lib.rs @@ -1,5 +1,7 @@ //! OmniJev-4B v1.1 decisions on one image: request contract, preparation and the -//! branch layout, trained heads and response finishing. +//! branch layout, the export, native execution, trained heads and response finishing. pub mod contract; +pub mod executor; +pub mod export; pub mod heads; pub mod processing; diff --git a/src/models/omnijev/native/src/main.rs b/src/models/omnijev/native/src/main.rs new file mode 100644 index 00000000..6cf4882d --- /dev/null +++ b/src/models/omnijev/native/src/main.rs @@ -0,0 +1,189 @@ +//! OmniJev-4B v1.1 `/v1/systemone` worker: CPU preparation, then native vision and +//! language passes behind serial admission. +use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +use anyhow::{Context, Result}; +use axum::{ + Json, Router, + body::Bytes, + extract::{DefaultBodyLimit, State, rejection::BytesRejection}, + http::{StatusCode, header}, + response::{IntoResponse, Response}, + routing::{get, post}, +}; +use omni_omnijev_native::{ + contract::{MAX_BODY, MODEL_ID}, + executor::{self, Executor}, + processing::Processor, +}; +use omni_qwen3_5_native::{cuda, json}; +use serde_json::{Value, json}; +use tokio::sync::Semaphore; + +/// Requests prepared at once; each decodes an image of up to 4096 × 4096. +const PREPARING: usize = 2; +/// Requests in the worker at once, preparing, waiting or running; more get 503. +const ADMITTED: usize = 16; + +#[derive(Clone)] +struct Shared { + processor: Arc, + executor: Arc>, + scheduler: omni_runtime::SerialScheduler, + preparing: Arc, + admitted: Arc, +} + +/// One admitted request, released on drop. It moves into the preparation and then +/// into the scheduled work, so a request whose client went away still counts until its +/// work ends. +struct Admission(Arc); + +impl Drop for Admission { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::SeqCst); + } +} + +fn reply(status: StatusCode, value: Value) -> Response { + (status, Json(value)).into_response() +} + +async fn decide(State(shared): State, body: Result) -> Response { + let raw = match body { + Ok(b) => b, + Err(e) => return reply(e.status(), json!({"detail": e.body_text()})), + }; + // The strict parser the contract uses: duplicate keys and out-of-range numbers are + // malformed too. + if let Err(e) = json::parse(&raw) { + return reply(StatusCode::BAD_REQUEST, json!({"detail": e})); + } + if shared.admitted.fetch_add(1, Ordering::SeqCst) >= ADMITTED { + shared.admitted.fetch_sub(1, Ordering::SeqCst); + return ( + StatusCode::SERVICE_UNAVAILABLE, + [(header::RETRY_AFTER, "1")], + Json(json!({"detail": "the worker is busy"})), + ) + .into_response(); + } + let admission = Admission(shared.admitted.clone()); + // Decoding and preparation run before the scheduler and without the model lock. + let Ok(permit) = shared.preparing.clone().acquire_owned().await else { + return reply( + StatusCode::INTERNAL_SERVER_ERROR, + json!({"detail": "inference failed"}), + ); + }; + let processor = shared.processor.clone(); + let (prepared, admission) = match tokio::task::spawn_blocking(move || { + let _permit = permit; + (processor.prepare(&raw), admission) + }) + .await + { + Ok((Ok(prepared), admission)) => (prepared, admission), + Ok((Err(e), _)) => { + return reply( + StatusCode::UNPROCESSABLE_ENTITY, + json!({"detail": format!("{e:#}")}), + ); + } + Err(e) => { + eprintln!("preparation failed: {e}"); + return reply( + StatusCode::INTERNAL_SERVER_ERROR, + json!({"detail": "inference failed"}), + ); + } + }; + let executor = shared.executor.clone(); + let result = shared + .scheduler + .run(move || { + let _admission = admission; + let answers = executor + .lock() + .map_err(|_| anyhow::anyhow!("poisoned executor"))? + .execute(&prepared)?; + Ok(executor::response(&prepared, answers)) + }) + .await; + match result { + Ok(body) => reply(StatusCode::OK, body), + Err(e) => { + eprintln!("inference failed: {e:#}"); + reply( + StatusCode::INTERNAL_SERVER_ERROR, + json!({"detail": "inference failed"}), + ) + } + } +} + +/// A request with every question type, an option text for the vocabulary pass and a +/// region, run once before the worker reports ready. +fn warmup_request() -> Vec { + let image = image::RgbImage::from_fn(96, 64, |x, y| image::Rgb([x as u8, y as u8, 128])); + let mut png = Vec::new(); + image + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .expect("encoding the warmup image"); + let url = format!( + "data:image/png;base64,{}", + base64::Engine::encode(&base64::engine::general_purpose::STANDARD, png) + ); + serde_json::to_vec(&json!({ + "model": MODEL_ID, + "state": {"images": [url]}, + "questions": { + "noul": {"type": "noul", "instructions": "The image is mostly blue.", + "region": {"box": [0, 0, 48, 32]}}, + "choice": {"type": "choice", "instructions": "Which colour dominates?", + "criteria": {"red": null, "green": "Mostly green", "blue": null}}, + "score": {"type": "score", "instructions": "How bright is the image?", + "levels": ["dark", "medium", "bright"]}, + }, + })) + .expect("serializing the warmup request") +} + +#[tokio::main] +async fn main() -> Result<()> { + let dir = std::env::var_os("OMNIJEV_MODEL") + .map(PathBuf::from) + .context("set OMNIJEV_MODEL to the export directory")?; + let library = std::env::var_os("OMNIJEV_CUDA_LIB") + .map(PathBuf::from) + .map_or_else(cuda::default_library, Ok)?; + let shared = tokio::task::spawn_blocking(move || -> Result { + let (mut executor, processor) = Executor::load(&dir, &library)?; + let warmup = processor.prepare(&warmup_request())?; + executor.execute(&warmup).context("warmup request")?; + Ok(Shared { + processor: Arc::new(processor), + executor: Arc::new(Mutex::new(executor)), + scheduler: omni_runtime::SerialScheduler::default(), + preparing: Arc::new(Semaphore::new(PREPARING)), + admitted: Arc::new(AtomicUsize::new(0)), + }) + }) + .await??; + let host = std::env::var("OMNIJEV_HOST").unwrap_or_else(|_| "127.0.0.1".into()); + let port: u16 = std::env::var("OMNIJEV_PORT").map_or(Ok(8000), |p| p.parse())?; + let app = Router::new() + .route( + "/health", + get(|| async { Json(json!({"status": "ready", "model": MODEL_ID})) }), + ) + .route("/v1/systemone", post(decide)) + .layer(DefaultBodyLimit::max(MAX_BODY)) + .with_state(shared); + let listener = tokio::net::TcpListener::bind((host.as_str(), port)).await?; + println!("OmniJev worker listening on {host}:{port}"); + axum::serve(listener, app).await?; + Ok(()) +} diff --git a/src/models/omnijev/native/src/processing.rs b/src/models/omnijev/native/src/processing.rs index b08e0ce0..9af33bce 100644 --- a/src/models/omnijev/native/src/processing.rs +++ b/src/models/omnijev/native/src/processing.rs @@ -4,6 +4,11 @@ use std::path::Path; use anyhow::{Context, Result, ensure}; +use omni_qwen3_5_native::image_decode::{DecodeLimits, decode_rgb8}; +use omni_qwen3_5_native::image_preprocess::{ + ImageLimits, ProcessedImage, preprocess_rgb8_with_limits, +}; +use omni_qwen3_5_native::inputs::image_positions; use tokenizers::Tokenizer; use crate::contract::{self, Image, Question, Request}; @@ -14,30 +19,47 @@ pub const OPTION_CLOSE: u32 = 248_078; /// `MSO1`'s default image budget, which overrides the processor config's 401,408. pub const MAX_PIXELS: usize = 768 * 28 * 28; pub const MIN_PIXELS: usize = 65_536; -const FACTOR: usize = 32; // patch 16 × spatial merge 2 /// A single question's tokens: prompt, image and every option. pub const MAX_SEQUENCE: usize = 8192; /// Tokens the reference processes for a request: its prefix once, then every row. pub const MAX_PROCESSED: usize = 65_536; +/// Option-text tokens whose log-probabilities a request reads; each takes a log-softmax +/// over the vocabulary on the CPU. +pub const MAX_TARGETS: usize = 16_384; const PAD: &str = "<|image_pad|>"; -/// `[1, height / 16, width / 16]` after the processor's resize: Transformers' -/// `Qwen2VLImageProcessor` smart resize (Apache-2.0; see ../../README.md), as Cua-S1's. -pub fn image_grid(width: usize, height: usize) -> [usize; 3] { - // Python's round uses ties-to-even. - let mut w = (width as f64 / FACTOR as f64).round_ties_even() as usize * FACTOR; - let mut h = (height as f64 / FACTOR as f64).round_ties_even() as usize * FACTOR; - if w * h > MAX_PIXELS { - let beta = ((width * height) as f64 / MAX_PIXELS as f64).sqrt(); - w = ((width as f64 / beta / FACTOR as f64).floor() as usize * FACTOR).max(FACTOR); - h = ((height as f64 / beta / FACTOR as f64).floor() as usize * FACTOR).max(FACTOR); - } else if w * h < MIN_PIXELS { - let beta = (MIN_PIXELS as f64 / (width * height) as f64).sqrt(); - w = (width as f64 * beta / FACTOR as f64).ceil() as usize * FACTOR; - h = (height as f64 * beta / FACTOR as f64).ceil() as usize * FACTOR; - } - [1, h / 16, w / 16] +/// The processor's resize and patches for `MSO1`'s budget; the source limits are the +/// contract's. +const IMAGE_LIMITS: ImageLimits = ImageLimits { + max_source_side: contract::MAX_IMAGE_SIDE, + max_source_pixels: contract::MAX_IMAGE_SIDE * contract::MAX_IMAGE_SIDE, + min_pixels: MIN_PIXELS, + max_pixels: MAX_PIXELS, + max_patches: MAX_PIXELS / 256, +}; + +/// Decode the request's image to RGB as Pillow's `convert("RGB")` does, then +/// `preprocess` it. +pub fn pixels(image: &Image) -> Result { + let decoded = decode_rgb8( + &image.bytes, + image.format, + &DecodeLimits { + max_side: contract::MAX_IMAGE_SIDE, + max_pixels: contract::MAX_IMAGE_SIDE * contract::MAX_IMAGE_SIDE, + max_aspect: 200, + // a 16-bit RGBA image at the largest size + max_alloc: (contract::MAX_IMAGE_SIDE * contract::MAX_IMAGE_SIDE * 8) as u64, + }, + )?; + preprocess(decoded.width, decoded.height, &decoded.rgb) +} + +/// Resize, normalize and pack interleaved RGB8 as the reference's +/// `Qwen2VLImageProcessor` does with `MSO1`'s budget. +pub fn preprocess(width: usize, height: usize, rgb: &[u8]) -> Result { + preprocess_rgb8_with_limits(width, height, rgb, &IMAGE_LIMITS) } /// Image tokens after the 2×2 spatial merge. @@ -87,8 +109,6 @@ pub struct Row { pub struct PreparedQuestion { /// The single-question sequence: prompt, image and every option block. pub token_ids: Vec, - /// `[3, sequence]` T/H/W rotary positions of `token_ids`. - pub positions: [Vec; 3], pub rows: Vec, } @@ -157,7 +177,7 @@ pub fn layout(sequences: &[Vec], grid: [usize; 3]) -> Result<(Layout, Vec(); rows.push(question_rows); } - let positions = image_positions(&sequences[0], grid)?; + let positions = image_positions(&sequences[0], IMAGE_TOKEN, grid)?; let next_position = positions .iter() .map(|axis| axis[prefix - 1]) @@ -174,56 +194,20 @@ pub fn layout(sequences: &[Vec], grid: [usize; 3]) -> Result<(Layout, Vec Result<[Vec; 3]> { - let start = ids - .iter() - .position(|&id| id == IMAGE_TOKEN) - .context("missing image placeholders")?; - let count = ids[start..] - .iter() - .take_while(|&&id| id == IMAGE_TOKEN) - .count(); - let end = start + count; - ensure!( - !ids[end..].contains(&IMAGE_TOKEN), - "image placeholders must be one contiguous span" - ); - let [t, h, w] = grid; - ensure!( - t == 1 && h > 0 && w > 0 && h % 2 == 0 && w % 2 == 0 && h / 2 * (w / 2) == count, - "image span does not match its grid" - ); - let mut positions: [Vec; 3] = std::array::from_fn(|_| (0..start as i64).collect()); - for y in 0..h / 2 { - for x in 0..w / 2 { - positions[0].push(start as i64); - positions[1].push((start + y) as i64); - positions[2].push((start + x) as i64); - } - } - let next = start + h.max(w) / 2; - for axis in &mut positions { - axis.extend((next..next + ids.len() - end).map(|p| p as i64)); - } - Ok(positions) -} - pub struct Processor { tokenizer: Tokenizer, } /// A request ready for execution; `questions` keeps identity and order for finishing. pub struct PreparedRequest { - pub image: Image, + pub pixels: ProcessedImage, pub grid: [usize; 3], pub questions: Vec, pub inputs: Vec, pub layout: Layout, + /// Time spent decoding, preprocessing and tokenizing, which the reference's latency + /// includes. + pub preparation_seconds: f64, } impl Processor { @@ -264,8 +248,10 @@ impl Processor { /// Validate and lay out the whole request before any device work. pub fn prepare(&self, raw: &[u8]) -> Result { + let start = std::time::Instant::now(); let Request { image, questions } = contract::compile(raw)?; - let grid = image_grid(image.width, image.height); + let pixels = pixels(&image)?; + let grid = pixels.image_grid_thw; let sequences = questions .iter() .map(|q| { @@ -285,23 +271,30 @@ impl Processor { "request needs {} tokens, above {MAX_PROCESSED}", layout.processed_tokens ); + // Score's ordinal head reads no log-probabilities. + let targets: usize = questions + .iter() + .zip(&rows) + .filter(|(q, _)| q.kind != contract::Kind::Score) + .flat_map(|(_, rows)| rows) + .map(|r| r.targets.len() + usize::from(r.first.is_some())) + .sum(); + ensure!( + targets <= MAX_TARGETS, + "request reads {targets} option-text tokens, above {MAX_TARGETS}" + ); let inputs = sequences .into_iter() .zip(rows) - .map(|(token_ids, rows)| { - Ok(PreparedQuestion { - positions: image_positions(&token_ids, grid)?, - token_ids, - rows, - }) - }) - .collect::>>()?; + .map(|(token_ids, rows)| PreparedQuestion { token_ids, rows }) + .collect(); Ok(PreparedRequest { - image, + pixels, grid, questions, inputs, layout, + preparation_seconds: start.elapsed().as_secs_f64(), }) } } diff --git a/src/models/qwen3_5/native/Cargo.toml b/src/models/qwen3_5/native/Cargo.toml index 6a328e25..5a3a79c8 100644 --- a/src/models/qwen3_5/native/Cargo.toml +++ b/src/models/qwen3_5/native/Cargo.toml @@ -36,6 +36,10 @@ path = "../../../../tests/qwen3_5/image_decode.rs" name = "prefix" path = "../../../../tests/qwen3_5/prefix.rs" +[[test]] +name = "readout" +path = "../../../../tests/qwen3_5/readout.rs" + [[test]] name = "graph_loading" path = "../../../../tests/qwen3_5/graph_loading.rs" diff --git a/src/models/qwen3_5/native/src/image_decode.rs b/src/models/qwen3_5/native/src/image_decode.rs index 536a0c98..c7ac76bd 100644 --- a/src/models/qwen3_5/native/src/image_decode.rs +++ b/src/models/qwen3_5/native/src/image_decode.rs @@ -1,10 +1,35 @@ -//! Bounded single-frame PNG/JPEG data URL decoding shared by native workers. +//! Bounded single-frame PNG/JPEG decoding to the interleaved RGB8 that Pillow's +//! `Image.open(...).convert("RGB")` gives, shared by native workers. +//! +//! APNG and MPO containers are refused rather than decoded to their first picture. +//! Sixteen-bit samples follow Pillow's conversions. use anyhow::{Context, Result, ensure}; use base64::Engine; +use image::{DynamicImage, ImageFormat}; use std::io::Cursor; pub const MAX_IMAGE_BYTES: usize = 4 * 1024 * 1024; +/// The source limits of `decode_data_url`. +const DATA_URL_LIMITS: DecodeLimits = DecodeLimits { + max_side: 2048, + max_pixels: 1_048_576, + max_aspect: 200, + max_alloc: 32 * 1024 * 1024, +}; + +/// Source limits checked from the header before decoding. +#[derive(Clone, Copy, Debug)] +pub struct DecodeLimits { + pub max_side: usize, + pub max_pixels: usize, + /// Longest side over shortest side. + pub max_aspect: usize, + /// Largest decoder allocation in bytes. + pub max_alloc: u64, +} + +/// An interleaved RGB8 image. pub struct DecodedImage { pub width: usize, pub height: usize, @@ -14,8 +39,8 @@ pub struct DecodedImage { pub fn decode_data_url(url: &str) -> Result { let (prefix, encoded) = url.split_once(',').context("invalid image data URL")?; let format = match prefix { - "data:image/png;base64" => image::ImageFormat::Png, - "data:image/jpeg;base64" => image::ImageFormat::Jpeg, + "data:image/png;base64" => ImageFormat::Png, + "data:image/jpeg;base64" => ImageFormat::Jpeg, _ => anyhow::bail!("only inline PNG/JPEG images are supported"), }; ensure!( @@ -26,40 +51,56 @@ pub fn decode_data_url(url: &str) -> Result { .decode(encoded) .context("invalid base64 image")?; ensure!(raw.len() <= MAX_IMAGE_BYTES, "image exceeds 4 MiB"); + decode_rgb8(&raw, format, &DATA_URL_LIMITS) +} + +/// Decode `raw`, whose bytes must be a `format` image, within `limits`. +pub fn decode_rgb8(raw: &[u8], format: ImageFormat, limits: &DecodeLimits) -> Result { + ensure!( + matches!(format, ImageFormat::Png | ImageFormat::Jpeg), + "only PNG and JPEG images are supported" + ); ensure!( - image::guess_format(&raw)? == format, + image::guess_format(raw)? == format, "image format does not match MIME type" ); - if format == image::ImageFormat::Jpeg { - ensure_single_jpeg(&raw)?; + if format == ImageFormat::Jpeg { + ensure_single_jpeg(raw)?; } - let mut limits = image::Limits::default(); - limits.max_image_width = Some(2048); - limits.max_image_height = Some(2048); - limits.max_alloc = Some(32 * 1024 * 1024); - let mut header = image::ImageReader::with_format(Cursor::new(&raw), format); - header.limits(limits.clone()); + let mut decoder_limits = image::Limits::default(); + decoder_limits.max_image_width = Some(limits.max_side as u32); + decoder_limits.max_image_height = Some(limits.max_side as u32); + decoder_limits.max_alloc = Some(limits.max_alloc); + let mut header = image::ImageReader::with_format(Cursor::new(raw), format); + header.limits(decoder_limits.clone()); let (width, height) = header.into_dimensions()?; let (w, h) = (width as usize, height as usize); ensure!( - w > 0 && h > 0 && w <= 2048 && h <= 2048 && w * h <= 1048576 && w.max(h) <= 200 * w.min(h), + w > 0 + && h > 0 + && w <= limits.max_side + && h <= limits.max_side + && w * h <= limits.max_pixels + && w.max(h) <= limits.max_aspect * w.min(h), "image dimensions exceed supported limits" ); - let decoded = if format == image::ImageFormat::Png { - let decoder = image::codecs::png::PngDecoder::with_limits(Cursor::new(&raw), limits)?; + let decoded = if format == ImageFormat::Png { + let decoder = + image::codecs::png::PngDecoder::with_limits(Cursor::new(raw), decoder_limits)?; ensure!(!decoder.is_apng()?, "image must be single-frame"); - image::DynamicImage::from_decoder(decoder)? + DynamicImage::from_decoder(decoder)? } else { - let mut reader = image::ImageReader::with_format(Cursor::new(&raw), format); - reader.limits(limits); + let mut reader = image::ImageReader::with_format(Cursor::new(raw), format); + reader.limits(decoder_limits); reader.decode()? }; Ok(DecodedImage { width: w, height: h, + // byte 25 of a PNG is its IHDR color type; 0 is grayscale without alpha rgb: pillow_rgb( decoded, - format == image::ImageFormat::Png && raw.get(25) == Some(&0), + format == ImageFormat::Png && raw.get(25) == Some(&0), ), }) } @@ -99,8 +140,8 @@ fn ensure_single_jpeg(raw: &[u8]) -> Result<()> { Ok(()) } -fn pillow_rgb(image: image::DynamicImage, png_grayscale: bool) -> Vec { - use image::DynamicImage::*; +fn pillow_rgb(image: DynamicImage, png_grayscale: bool) -> Vec { + use DynamicImage::*; match image { ImageLuma16(p) => p.pixels().flat_map(|v| [v[0].min(255) as u8; 3]).collect(), ImageLumaA16(p) if png_grayscale => { diff --git a/src/models/qwen3_5/native/src/inputs.rs b/src/models/qwen3_5/native/src/inputs.rs index f43b8380..a6bc918e 100644 --- a/src/models/qwen3_5/native/src/inputs.rs +++ b/src/models/qwen3_5/native/src/inputs.rs @@ -89,6 +89,49 @@ impl MultimodalInput<'_> { } } +/// Qwen3.5's T/H/W rotary positions (Transformers 5.17.0 `get_rope_index`, Apache-2.0; +/// see `../../../cua_s1/native/THIRD_PARTY_NOTICES.md`) for a sequence +/// with one contiguous span of `image_token` matching `grid` (`[1, h, w]` patches, +/// merged 2×2): text before the span counts up, each merged patch takes the span's +/// start plus its row (H) and column (W), and text after the span continues from the +/// image's largest position plus one. +pub fn image_positions(ids: &[u32], image_token: u32, grid: [usize; 3]) -> Result<[Vec; 3]> { + let [t, h, w] = grid; + ensure!( + t == 1 && h > 0 && w > 0 && h.is_multiple_of(2) && w.is_multiple_of(2), + "expected one image with even, nonzero spatial grid" + ); + let count = (h / 2) + .checked_mul(w / 2) + .ok_or_else(|| anyhow::anyhow!("grid overflow"))?; + let start = ids + .iter() + .position(|&id| id == image_token) + .ok_or_else(|| anyhow::anyhow!("missing image placeholders"))?; + let end = start + .checked_add(count) + .ok_or_else(|| anyhow::anyhow!("grid overflow"))?; + ensure!( + end <= ids.len() + && ids[start..end].iter().all(|&id| id == image_token) + && ids[end..].iter().all(|&id| id != image_token), + "image placeholders must be one contiguous span matching the grid" + ); + let mut positions: [Vec; 3] = std::array::from_fn(|_| (0..start as i64).collect()); + for y in 0..h / 2 { + for x in 0..w / 2 { + positions[0].push(start as i64); + positions[1].push((start + y) as i64); + positions[2].push((start + x) as i64); + } + } + let next = start + h.max(w) / 2; + for axis in &mut positions { + axis.extend((next..next + ids.len() - end).map(|p| p as i64)); + } + Ok(positions) +} + /// The same prompt/position boundary with borrowed, contiguous device BF16 rows. /// Vision completion precedes consumption; language execution synchronizes before /// returning, including errors, so the producer borrow can then be released. diff --git a/src/models/qwen3_5/native/src/lib.rs b/src/models/qwen3_5/native/src/lib.rs index 2e47adb9..c0d20575 100644 --- a/src/models/qwen3_5/native/src/lib.rs +++ b/src/models/qwen3_5/native/src/lib.rs @@ -1,5 +1,5 @@ -//! Qwen3.5/3.8 prefill, runtime-loaded CUDA operations, and request JSON helpers -//! shared by the Cua-S1 and Open-Jev native workers. +//! Qwen3.5/3.8 prefill, runtime-loaded CUDA operations, image decoding and +//! preprocessing, vision, and request JSON helpers shared by the native workers. pub mod cuda; pub mod json; pub mod model; diff --git a/src/models/qwen3_5/native/src/model.rs b/src/models/qwen3_5/native/src/model.rs index 279bca89..65deffc7 100644 --- a/src/models/qwen3_5/native/src/model.rs +++ b/src/models/qwen3_5/native/src/model.rs @@ -633,6 +633,33 @@ impl PrefixState { pub fn bytes(&self) -> usize { self.bytes } + + /// Tokens the buffers hold; a capture by `Model::readout_window` may cover fewer. + pub fn capacity(&self) -> usize { + self.cap + } +} + +/// Rows of logits computed at once by `forward_multimodal_readout`; with the fixed GEMM +/// algorithms, a row's logits do not depend on how many share the call. +const VOCAB_ROWS: usize = 64; + +/// What `Model::forward_multimodal_readout` reads from one prefill. +#[derive(Debug, Clone)] +pub struct Readout { + /// Final-norm hidden states at the requested rows, in order. + pub hidden: Vec>, + /// For each requested `(row, token)`, the log-probability of `token` after `row`. + pub logprobs: Vec, +} + +/// log-softmax of one row of bfloat16 logits over its first `vocab` entries, at `token`: +/// the logits as float32, their maximum, and a float64 sum of the exponentials. +pub(crate) fn token_logprob(logits: &[u8], vocab: usize, token: u32) -> f32 { + let value = |i: usize| half::bf16::from_le_bytes([logits[2 * i], logits[2 * i + 1]]).to_f32(); + let max = (0..vocab).map(value).fold(f32::NEG_INFINITY, f32::max) as f64; + let sum: f64 = (0..vocab).map(|i| (value(i) as f64 - max).exp()).sum(); + (value(token as usize) as f64 - max - sum.ln()) as f32 } /// Gated DeltaNet chunk length. `forward_shared` ends prefixes at its multiples, where @@ -709,6 +736,9 @@ pub struct Model { scratch: Option, /// The request and group prefix states of `forward_shared`; grow as needed. shared: [Option; 2], + /// Hidden rows and logits for `forward_multimodal_readout`'s vocabulary pass, and + /// the host copy of the logits; allocated on first use. + vocab_buffers: Option<(DeviceBuffer, DeviceBuffer, Vec)>, /// Opt-in replay with at most 64 captures per mode, keyed by sequence lengths. graph_enabled: bool, graph_stats: GraphStats, @@ -873,6 +903,7 @@ impl Model { fp32_gdn: None, scratch: None, shared: [None, None], + vocab_buffers: None, graph_enabled: graph, graph_stats: GraphStats { requested: graph, @@ -1259,6 +1290,185 @@ impl Model { Ok(()) } + /// `forward_multimodal` with the fixed-algorithm GEMMs of `forward_fixed`, reading the + /// final-norm hidden state at each of `rows` and, for each `(row, token)` in `targets`, + /// the log-probability of `token` after `row` under the tied output embedding: + /// bfloat16 logits over the vocabulary, converted to float32, with a float64 sum in + /// the log-softmax. Each result is the same however many rows or targets the call + /// reads. `readout_window` with neither a prefix nor a capture. + pub fn forward_multimodal_readout( + &mut self, + input: &MultimodalInput<'_>, + rows: &[usize], + targets: &[(usize, u32)], + ) -> Result { + self.readout_window(input, None, None, rows, targets) + } + + /// One window of a prompt with the fixed-algorithm GEMMs, continuing `prefix` (the + /// prompt's rows `[0, prefix.len)`, or nothing) and optionally capturing the state + /// after it into `capture`, which must hold the prompt so far. `window` holds the + /// window's own rows: their token ids, the indices of their image placeholders + /// within the window, those rows' image embeddings and the rows' rotary positions. + /// `rows` and `targets` index the whole prompt and must lie in the window; they are + /// read as by `forward_multimodal_readout`. When the prefix was itself captured here, + /// and every boundary is a multiple of 64 tokens, every result equals that of one + /// `forward_multimodal_readout` pass over the whole prompt. + /// + /// Work is queued on the model's stream; `capture` is ready for continuations queued + /// after this call on the same model. + pub fn readout_window( + &mut self, + window: &MultimodalInput<'_>, + prefix: Option<&PrefixState>, + capture: Option<&mut PrefixState>, + rows: &[usize], + targets: &[(usize, u32)], + ) -> Result { + self.ensure_fixed_profile()?; + let result = self.run_readout_window(window, prefix, capture, rows, targets); + if result.is_err() { + // Work already queued may still use the buffers. + let _ = cuda::synchronize(self.stream); + } + result + } + + fn run_readout_window( + &mut self, + window: &MultimodalInput<'_>, + prefix: Option<&PrefixState>, + mut capture: Option<&mut PrefixState>, + rows: &[usize], + targets: &[(usize, u32)], + ) -> Result { + let image_token = self + .cfg + .image_token_id + .context("checkpoint has no image_token_id")?; + let vocab = self.embed.shape[0]; + window.validate(self.cfg.hidden, vocab, image_token, self.cfg.max_positions)?; + if let Some(pre) = prefix { + ensure!( + Arc::ptr_eq(&self.prefix_owner, &pre.owner), + "prefix state belongs to a different model instance" + ); + ensure!(pre.initialized, "prefix state has not completed capture"); + } + let qb = prefix.map_or(0, |pre| pre.len); + let tend = qb + window.token_ids.len(); + ensure!( + tend <= self.cfg.max_positions, + "prompt exceeds the configured maximum length" + ); + if let Some(cap) = capture.as_deref_mut() { + ensure!( + Arc::ptr_eq(&self.prefix_owner, &cap.owner), + "prefix state belongs to a different model instance" + ); + ensure!( + tend.is_multiple_of(64) && tend <= cap.cap, + "a capture must end on a multiple of 64 within its state" + ); + } + ensure!( + rows.iter().all(|&r| (qb..tend).contains(&r)) + && targets + .iter() + .all(|&(r, token)| (qb..tend).contains(&r) && (token as usize) < vocab), + "readout row or token outside the window" + ); + self.prepare_scratch(tend)?; + let gemm = self.fixed_gemm()?; + let s = self.scratch.as_ref().unwrap(); + self.upload_positions(s, window.position_ids)?; + self.embed_tokens_at(s, window.token_ids, qb)?; + self.overwrite_image_rows(s, window, qb)?; + if let Some(cap) = capture.as_deref_mut() { + cap.initialized = false; + cap.len = tend; + } + self.run_window(s, qb, tend, true, capture.as_deref_mut(), prefix, gemm)?; + if let Some(cap) = capture { + cap.initialized = true; + } + let hidden = rows + .iter() + .map(|&r| self.hidden_row(s, r)) + .collect::>>()?; + let logprobs = self.vocabulary_logprobs(targets, gemm)?; + Ok(Readout { hidden, logprobs }) + } + + /// The log-probabilities of `targets` from the final-norm hidden states in the + /// scratch, in chunks of `VOCAB_ROWS` rows. + fn vocabulary_logprobs( + &mut self, + targets: &[(usize, u32)], + gemm: *mut c_void, + ) -> Result> { + let mut logprobs = Vec::with_capacity(targets.len()); + if targets.is_empty() { + return Ok(logprobs); + } + let vocab = self.embed.shape[0]; + let hb = self.cfg.hidden * BF16; + // padded so that each logits row starts on a 16-byte boundary + let ld = vocab.next_multiple_of(8); + let row = ld * BF16; + if self.vocab_buffers.is_none() { + self.vocab_buffers = Some(( + DeviceBuffer::new(VOCAB_ROWS * hb)?, + DeviceBuffer::new(VOCAB_ROWS * row)?, + vec![0u8; VOCAB_ROWS * row], + )); + } + let s = self.scratch.as_ref().unwrap(); + let (picked, logits, host) = self.vocab_buffers.as_mut().unwrap(); + for chunk in targets.chunks(VOCAB_ROWS) { + let m = chunk.len(); + // SAFETY: rows lie inside the scratch's final hidden states; picked holds + // VOCAB_ROWS >= m rows, logits VOCAB_ROWS rows of ld, and the tied embedding + // is [vocab, hidden]. + unsafe { + // one copy per run of consecutive rows + let mut i = 0; + while i < m { + let mut j = i + 1; + while j < m && chunk[j].0 == chunk[j - 1].0 + 1 { + j += 1; + } + cuda::copy_dd( + picked.at(i * hb), + s.at(s.x + chunk[i].0 * hb), + (j - i) * hb, + self.stream, + )?; + i = j; + } + check( + (cuda::api().cs1_gemm)( + gemm, + picked.at(0), + self.embed.ptr, + logits.at(0), + m as i32, + vocab as i32, + self.cfg.hidden as i32, + ld as i32, + self.stream, + ), + "vocabulary gemm", + )?; + cuda::download(&mut host[..m * row], logits.at(0), self.stream)?; + } + logprobs.extend(chunk.iter().enumerate().map(|(i, &(_, token))| { + token_logprob(&host[i * row..(i + 1) * row], vocab, token) + })); + } + Ok(logprobs) + } + /// Eager multimodal execution using borrowed device-resident image rows. /// The producer must finish writes before entry. All queued consumer work is /// completed on every exit path, before the external allocation can be reused. @@ -1653,8 +1863,9 @@ impl Model { Ok(Some(state)) } - /// `forward_fixed` and `forward_shared` cover merged weights with the default - /// numerics. Refuse the other profiles before any allocation or CUDA call. + /// `forward_fixed`, `forward_shared` and `readout_window` cover merged weights with + /// the default numerics. Refuse the other profiles before any allocation or CUDA + /// call. fn ensure_fixed_profile(&self) -> Result<()> { ensure!( self.lora.is_none() && self.reference.is_none() && self.fp32_gdn.is_none(), @@ -1700,12 +1911,17 @@ impl Model { } fn last_hidden(&self, s: &Scratch, t: usize) -> Result> { + self.hidden_row(s, t - 1) + } + + /// The final-norm hidden state at `row`. + fn hidden_row(&self, s: &Scratch, row: usize) -> Result> { let mut last = vec![0u8; self.cfg.hidden * BF16]; - // SAFETY: x holds at least t rows of the hidden size. + // SAFETY: x holds more than `row` rows of the hidden size. unsafe { cuda::download( &mut last, - s.at(s.x + (t - 1) * self.cfg.hidden * BF16), + s.at(s.x + row * self.cfg.hidden * BF16), self.stream, )?; } diff --git a/src/models/qwen3_5/native/src/vision/mod.rs b/src/models/qwen3_5/native/src/vision/mod.rs index 063ce2a4..63640aa5 100644 --- a/src/models/qwen3_5/native/src/vision/mod.rs +++ b/src/models/qwen3_5/native/src/vision/mod.rs @@ -61,7 +61,8 @@ impl VisionConfig { || sizes == [27, 1152, 4304, 16, 2304, 5120, 3, 16, 2, 2]) && self.hidden_act == "gelu_pytorch_tanh" && self.deepstack_visual_indexes.is_empty() - && self.model_type == "qwen3_5", + // the Hub configs say qwen3_5; Transformers writes its class's qwen3_5_vision + && ["qwen3_5", "qwen3_5_vision"].contains(&self.model_type.as_str()), "unsupported Qwen vision layout" ); Ok(()) diff --git a/tests/omnijev/data/pixels.json b/tests/omnijev/data/pixels.json new file mode 100644 index 00000000..6b8a04d9 --- /dev/null +++ b/tests/omnijev/data/pixels.json @@ -0,0 +1 @@ +{"provenance":{"python":"3.10.12","reference_commit":"14dbec4f71e194852c8d7b88ab36ef639493f400","checkpoint_revision":"ffe5f436eaf22e20e2f041f8e74e121fd057a6cb","base_revision":"851bf6e806efd8d0a36b00ddf55e13ccb7b8cd0a","max_pixels":602112,"torch":"2.14.0+cu130","transformers":"5.17.0","command":"CUDA_VISIBLE_DEVICES= python tests/omnijev/generate_fixtures.py"},"cases":[{"name":"rgb_upscale","kind":"pattern","format":"png","size":[64,48],"rgb_sha256":"ecf96c43e5f74ee72682d3551395aa97b406fb6f1101c6eecd9c07ed493d0dbe","image_grid_thw":[1,14,20],"pixel_values_shape":[280,1536],"pixel_values_sha256":"e531f3b0823f349873b32f1ae1b6e56e2f69d194ac144e02b4e77e2eb17d1614"},{"name":"rgb_odd","kind":"pattern","format":"png","size":[333,217],"rgb_sha256":"30db34466d92d643f8cdc06fc010db9ad25fc5552da56a2e66ebf3f223901d3f","image_grid_thw":[1,14,20],"pixel_values_shape":[280,1536],"pixel_values_sha256":"2bc0dd030b70175f05d99fe049820022d55cdb94fee75db9030ea44f2a404cae"},{"name":"rgb_downscale","kind":"pattern","format":"png","size":[1920,1080],"rgb_sha256":"395ca04ef06b6e6beaa54cb1ca1b33500a72b47271db863719be1bbe1ef03f6f","image_grid_thw":[1,36,64],"pixel_values_shape":[2304,1536],"pixel_values_sha256":"272d9e20ed42ca9d9960394ce95d39ac83b38b38b9f6037e1c972c65d7672320"},{"name":"rgb_wide","kind":"pattern","format":"png","size":[2000,20],"rgb_sha256":"352b9f5a5d644ded6762b9aa61553365bb79cd9e649f762bef83c1052bf4c193","image_grid_thw":[1,2,160],"pixel_values_shape":[320,1536],"pixel_values_sha256":"bdc6743e56eba4588e702cbb644867fa7084560eca128a12a8ac1cc25e6cc2e1"},{"name":"rgba","kind":"pattern_rgba","format":"png","size":[300,200],"rgb_sha256":"1b0abd0140c0eb47b6e4315e7cf31b10137cf94a010e372ced063b47f0ee730f","image_grid_thw":[1,14,20],"pixel_values_shape":[280,1536],"pixel_values_sha256":"8cd9c34970558ff36ae7e971b4052a23133ccf65507b10127794e4df1e3ae306"},{"name":"gray","kind":"pattern_gray","format":"png","size":[200,150],"rgb_sha256":"d33aeda54e3f9e3ed33c603b2f466b0415f9a2ba56da0ba1d57a9fac3d96bbfb","image_grid_thw":[1,14,20],"pixel_values_shape":[280,1536],"pixel_values_sha256":"1786c4b758e0bd5311447a4c2c5e585bca5d92b7e041313bbdf4eed761567cb5"},{"name":"palette","kind":"stored","format":"png","size":[180,120],"rgb_sha256":"975c502ad4f01137147d0717f1d65caf41aac1734d24046f856bae8d4c350888","image_grid_thw":[1,14,20],"pixel_values_shape":[280,1536],"pixel_values_sha256":"3038e2f98fac4a6109e8e38a22608831a19fc3f44c4df3bf3273bcd1fd0bbe69","bytes":"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"},{"name":"gray16","kind":"stored","format":"png","size":[160,100],"rgb_sha256":"3ef16f98c953a34aa2812e9c76834486f90201e7232410ccb4b056077103743e","image_grid_thw":[1,14,22],"pixel_values_shape":[308,1536],"pixel_values_sha256":"8a9255240fb519412f464fa1551912ff58f5b4cf96896a6bba07e04992b02217","bytes":"iVBORw0KGgoAAAANSUhEUgAAAKAAAABkEAAAAAB0TbgQAAABw0lEQVR4nO3RS2oDQQwAUU8dKSf1kbOIQ/zpmSy7CoTxoudJUKDjdjvu3J//V6/xD799/f6Ox//1y/i140lpOp6UpuNJaTqelKbjSWk6npSmcz26Xhz/e7H/hm3Hk9J0PClNx5PSdDwpTceT0nQ8KU3nanS9OP78Yv8N244npel4UpqOJ6XpeFKajiel6XhSms756Hpx/PXF/hu2HU9K0/GkNB1PStPxpDQdT0rT8aQ0nbPR9eL4+4v9N2w7npSm40lpOp6UpuNJaTqelKbjSWk669H14vjni/03bDuelKbjSWk6npSm40lpOp6UpuNJaTqr0fXi+OrF/hu2HU9K0/GkNB1PStPxpDQdT0rT8aQ0nc/R9eL4+sX+G7YdT0rT8aQ0HU9K0/GkNB1PStPxpDSd99H14viZs/+GbceT0nQ8KU3Hk9J0PClNx5PSdDwpTed1dL04fu7sv2Hb8aQ0HU9K0/GkNB1PStPxpDQdT0rTecb14viVs/+GbceT0nQ8KU3Hk9J0PClNx5PSdDwpTef4Z3H82tl/w7bjSWk6npSm40lpOp6UpuNJaTqelKZzvXicLo7/fGH/DduOJ6XpeFKa/g0PHX22E5hqhgAAAABJRU5ErkJggg=="},{"name":"jpeg","kind":"stored","format":"jpeg","size":[96,64],"rgb_sha256":"ace0ae21a59ac01de5e3f91372b8dff533e63ba3cabd92192c40be05816a5c2b","image_grid_thw":[1,14,20],"pixel_values_shape":[280,1536],"pixel_values_sha256":"c5e8e68275cf83e5ead02de5094ebf5d86523613dafc9ebfc7da4ced3a6f5a91","bytes":"/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/wAARCABAAGADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD88/DOifaNny5z+leyeGfhz9o2fus59ulY3wv0T7R5Py5zj8K+sPhd8OftHk/us5x26V8nxDnf+r/Nr+nLb773v8vy74Y/6j1tY43wz8DvtGz/AEfOfbpXsnhn9mT7Rs/0TOf9npXS+Ifivofw71G50DQdL/4SLxFb/u5ZCwWztZMN8rMPmkdGCbkXA5I3qykDESDx54zg1HU9Y1nUI9PNrL5umaZI1vbeRhmaLylIDjDFcuWYjAZjivRxOZ5nhY+1lNU4S2ct16Ld3v1t/l+m5Fi80rUPrmIqKhRSvzS3a3uorW1tdbX6XOI+Ovwk8LaNr1j4V1XWdI0iaGNbyWDULyKCRi2QhCvg4A3cgkHcR1WuL0bwJ8JoLyW1l16KSSKRo2aDT7mVMg4JV0iKuPRlJBHIJFXdQ8I+DPCOt3U/ifUYn1ubfO2mWcbXE4IVWWPaM+XlWQJ5jKCMc4BI6Dw/qv2jzofCvgLOdn2a+1iXp90vvgjH+8BiX0J7rX9MU6tfJcLGEa1SSt8Xu04p9WnLm5k79LtW8tP5YxGdY3inNq2avEVZQm/dl7tKPInaNufmvaNr8t7u711Oe0bwx8OvF15LDF4b8QtHZyMi3EFjC0c4JwHUPKjAHbkblB55APFdLD8PPCws4W0TwDrGpXbSANFqAitEVMHJDoZSTnHG0cEnPGDcF34m8JXj3eueKdI8LK8ccq6Tb2EEkioSIw0cTpJO4LAkn5sfN0A47XSEtfF1nNYWmteNPEkZtGBRLicWk5kDL5MvnOjAHaNxEbjD9yCK/kXFY2vxdaaqOWZPtKdsRbe7tTo0nSpR6S9/yZ/QXD8MTSUKiqSdLTaU2l3TlaCvd9JfOxFpvwujsdMub6b4SM8NtC8zhNUy21VJOM2/XivnuzsvA3ibVbjULvQPDT3F1IZG/wCKtgIXPRRmInAGABngACvVvjbp8PhTw3oehx+FPDnhW91WYzPJ4g1VXWSCLGVWMrE2S7Id4bjYRg7uPMLadDEBp93Yw3djI0b2vhvQWmN0dyrxJch4mC4JDK6ggnBb5a+myhVMsoyxDm37R2TUp2gk2tXGdS6k/S1tf7vwPiNnFXNc0pZPQqS5KOrtOpy88u7jUqKyVknfRuSdrMuXPgvSdDvEY23gs2l1JIbeK1t7nUpIkBGBI9vIRnDDkqm7BIHBA9G8M+CtEuNn+g+GTn/qXdU4/WrmoW99Ho+l314+uWW248lGvrm10CA7lJIMkBPmP8gwrDgbyCOQdpb+4H2bSoNXxDdW7C7huNUv9bgmjf5dhNuFReA4ZGLEhhwB1/acVjq+BppKbbd7tNWjbys5O6t00f4HDscRiKUEpyvrdxkmtPKzltbpv1tqfMHwD0T7T9m+XOcfhX1J8TPFl58O/CulaDoFz9l8Ra1kvNFgy2tqBhnX5tyO7EKr4PCy4IZQR5P+yzon2j7H8uc4/Cut8O2dx8SfjBrWpSzxX1pFdvZWL28hkhFtExSMxncwwwG87Tgs7MAM1+XZ3Uhg8yqTrawprmaeyfRdb33+VvT0sHgKeLzeVTEK9OguZp7N7RT+evnaxe+FPw00vwtoMmta1LFYaXZxiSe5mHyoOABgcsSSAFAJJIABJArB+NHxW8U+N9O03wx4Pjn8JaNeMXuGglX7dPGkitGzuF3QglV4jYcrIpZ1OB3vie1l+Inju38K2MOND8PXDRMUMg+0XWAsjOpAH7sho14P8ZDEOAPBviF4z1O9+IeoeGfBc1rJGIza3Wr2/mGa1IfaIYc4EbhBlmGSGlYDy3Q1/POW05UsYsTXSnUtz2l8NJfZbWt27311i0kkmm19Xx3mc8FkapSs8Vi9lL4adLRyk1rdtNRd02udJJSVx2lad4T8PzwabaWH9va9bZD2djKjRQNvCMk8vPluNr/IFZsr8wUEE79rceJLSzt73xT4qi8HadLIkkFlpCtbSEqFRhH5e64lGZAzLuYAtkgADHP6Fb6f4I+0eGPCdn/aHiuPbDJfeUr2tg5yHHOfMlXA+XG0E/MSUZD6RDZ2+vaoxup5fFvjiON0RTILi3s3MpHlzEMvlBW8wiGMcYIIjDBq/YMZVXF0uaV5ZrLrK3NiF3SdqGHjSpR+J6z85WPyTIMCoTjKacov7UknO2/MlK8YRbeknr6qx13gjQLOOc2fgrwb/aO3Cx6pqoMcRYSYJEC/MyMgBBLowLcqNuD3+lat52owCXxXqeozRXB22HgyLyooUkAfiZCiSooCqN0jspOOu81zN1pf/ExSz8WXFzr+sR7ZoPDGmR+XbWpYSNEX/gTAbbubfNsdThgQap/FzXpvB/hhrC71WXw7e3ciw2vhvwphr4ISCxmk3Bh+5mGdzIrfKypur9mqQdCvGmnzSns9W13d2pOSaerjGEVrr2/X6VWhlmAq5nifeUIOXdy06Skm3zaK8Ywjd/FY8U8SyiPxNfXkmm+HvCls9uEtorxpL2+SMu0cc0MQ2kjoceUwDByeMgdZpEl/BEuq/afEMsdpItu95Ndx6Fp/k7mCy5j6lmZQN8asQwBIIC1yXheL/hE7qOyji0zwtcpiaPTrC0/tLUfliB8qQspjTeXVlLpH/D82AxPQwqllKz3METapbRvaJ/a9y2q6ttKmS32Q5xAcEtiQtHukXLYOW+hxCWVxVNe8raeSXndt32vGcku2h/NWAp184xk8ZifenVbk3q78zu7NtytfS8ak1tdaaxeHdMs4/wBxoVjbI8d+UCaBo5up1jG8Yaa5xBKn3cvGAWO0qNpNei6bqV7p95fTjUpbLUTdi0j2axJIYihx+90+yUeWdqEMAwCueWY/e5+4tZTdWcWtw4Se4gnhg18vLuXymTeunWgxFyrqJQSpw2fmfjp9MnvNMniklW50PFubi2t2uhp8sLSyFVxaWoea4iUquCzlsB+VwzH+aMbU+oav3ubz1Xkpfbu+jWnVX0X9OZDgfbcra5m13V35KX2ldaxav3TehxX7N8tx4b8BatrlpFFLd6bplxewpOpMZeOJnUMAQSCVGcEH3Fd/8MDJ8M/hNfa5a+UmqGNLawEjqp8+QhVZQwIcoC0mzByIznAyRxnw5sbiD9nnXZbWWWCR47WIvC5Q7HuYkdcjsysykdCCQeDXo11pP2j/AIQHwrBPnO/U7u08rp0jgfeR/wBfIwD7kfdr+h80qRwOIrKWsZVPe8o048zT3upc1ulm/u48rwEK+KrKaXLKr73nGnHnafk+a3q/M4T4kay3gL4EwabpY+weKfE2/S7FlLF4YGQfabhSMYKKVUEEENMjDO0ivBdM03/hCNHsvCfhi58rxXebPtctumXsLZlJ4fPySvldvBIUlvlJjY9T8ZPEtrqXxn8XeIZ4p7jRfBUI0S0tZl2bp4XKOg2q2BJdO6hyG4Kk4UALLpEt7r1nNdF4ovHHiKRisaSyNJZwShlM0eDuQR7BHECwxgEbhEwr8HwFKXEuDUprmzaq+eV9sRJq6T+GnSjh6CTkvtv+87H5NxZmUs54gnKPvUKdo94ua953ve8YczcraOy1aZa8M6J/YWzwX4LXOsn9zqWrQf8ALl2aGJh/y17Mw/1fQfPkx9Zo1tb+HbOW08K30WkeH9NjYan4lRQTKSNhjtzgnGWwHT53fb5eAAXxdMsbfw7Z3vhW0mi0jw/psarr2sBwTLkDNshXJxlgjKPnd8x4ABD9PYR297Z6dqOo6fLBoUUhXQvDaELNezAY82XGRnDfM3KxK21dzN+8/I8XVeWttvmUtdbXvbd7ptp3jH4acfelrt+g8P4FzalK8oyaetm5Nq6bT0vbWMX7tOPvS1Om0+yTQtCuJNHEvh/wlbyRpFqVsjR6lqMq5ISHkFA5AwvDbUJdlUuq+UfFTXIB4nt7HTVvPC+j2CpaRaXpsqvq18xmJM+PlMCjy9p2sFPlsDJJuVR7Bqutjw1Br+qnyL/xlbwiH7dMiDTNIwG/dKGbkxfeK9Bl2dmZXU/NPh64TS1hmiur6yiu5HhXWpw1zqmsBIQoWFXUtCOwkZgRmHLRE7T+55PReAnPES97t5O12+vM2tna+7jGKXMvN8Ssyaw9DIaLvKbVSp1bSXuX3vzPmkpW5vdTjGEfePQtH06806C202xsP7HEmFTQ9AlDX7sESRTc3PDQxFtuCiqzLKflkUEnH8JM9hFNHonlC0SMO9v4fdILe2iRll3y6kQWz5bctD8p8shlTJqlo1i+iSppB0uWwtFtEmudCsLpYYYItv7xr28KhgHjeRvL2ldqpuUZDt6/bW9vdxC1vb6KS28xo7W5ZQunq4ZRvs7RAzXQTz3XzGB42ktJH97sxVX/AIiddP8Ac1qOv/Pzn9p1esXGEOTR3m/e92U5u0vnOHsF9UtFpTU7P1t1esm3bZqXM7e5OS0WZosVvpl5YJpqxadpaXaxKdPvhpVqGBmwpvXG+eTaBwgETgB1wVrW1vxBb/C3wPPqsdw2nyFk/suO1t2062lm8oIDEqn7bcKskiNJFxxvYMoVTWtp1rFo32i/SHdrkFuZfN1YxzXsajZNtKYaCyiHmSH59y+WxZFBG2vIfjJ4vvZ5V0qxliT7PIZLvVxLKgeYK3kRG7kzNcyRxxKQLbYsqSsA3AUfm2GpOniYwtzQfxXeyWt7635r2Ut73TX8v7Bm2ZrIcirY6LtKVoQu9ZTlezXS6V5KW7tKMlc6DwbY3Hi74MeG4oZZdPjbWraKdI3LLOhhmcKwGMgNGjYOeVB6gV13jbxjJ8JNV8aeLb6SCR/DulWtto0FzDujNxJAjQRMqFWdWuJyWOchWbkBeOc+GMtv/wAIF4A0RopWu73U/tsbhR5apDEUcE5zkm4THHZskYGcf4269b+OW1DwtpNzYm21HxTJLKVcTNPEZnnWSHGMhdkOXBZSLgA4O01t7H/XrMJUaivmMpS5unt4865nf3aVL2VGm4/3/VHRxFmMeFsrxWLlopOUIaa3nNJtPuowm/SL6Kz5Twr4Tlj07wZ4Ks22/bcajqESrIrGNTtgBI+VkZxKxU5IaJDxgZ7LQNVu/wB/4ss08rWNXnSw0C2uEDm1t1/5aGJnONqZZtu5POlGVw5FUru1vJf+EqlENzDNeX6eFrNMidIY03JMPmA2JIkVw2FBKtNxzl66HT3uNMvNQvbeGWFtIk/4R3RIHQ4F5lhLMVPmJkMsj5IUOkEatg1/XOKrPAJuVpOW6fRtptPdNScoxfaMH8v5X4ewkqyUpJNvdPq21Jp9LOTjFv8AlhLWxU0eLS9CiXRpLmW08JaPIsF9DHBsm1HUFZv3CAnMgUqMLhV3K7klUV17/SNJ1S6lVZNUig8W3cavfXMa7IdE08KzbEctiA4II6sFLyEhmVzz+mW8GgQRWcNnusNAnNhYRLHK632qshH71PlASMpIueu1ZSrHzFWtfUYYvCuj6pc3kE+o2umSteavFaSwx/2zqDkSCBQMDEbb2ZexVz5f7lSf5mxVR4CVou7l3tp69+a6ur78sXZRdv6nyTCwjH2knbq20tVvd+ujkr7uMXaMJHgnxn8QSjWbDwdo0f2iyi50/QYC6RXXzfu7y8BVWKfKHVTjlgigMkkgg029uLm8vtXWaL7J5Ys5NZtYyZC5OUtdMXdgAAOA/OdzyBl2mZqWuahqHifxFqF94i1GJkuNserS6fuZt5LbNJtUY/KiqMMoY5ywdhtmLacuoeV3+wfZP9E/0H5/7O3f8udp08y7fb+8l42bT9zZ+5+HjTWS4enRSu2tX2el+nvczt07N3bjGP4Bi8fU4pzmrmMo3537vW0NFFbatpRb0d2+aScpRiui8PxpoukxJcW9itlaRyfaNLsrhxZWIWQ7DeFcm4nEixqIQFJKfKI2Yb/RtMh/sbfObm5F55E00d9cti6u7YZGU3JtsLcO8rhuCAuUCsrIeS8K3FvpdmbG7aKwW0u44raJLcS2WlzOCm0BVL3VyiqTgqVQtIflCvHXT6TZ2+mxfZ4Z7FbK4uw8trqEglUPGzu7Xjo3mTXLlXItkY4KBiCyKZPbx0v7NlLm1u/klv53bT1/O/LI/X8jy7nl7y6+dmnr99nrvfvzck5ehedbaf4b1gwW7RaBbQzva2toYzDcCPyji2i4N+QQPnk2xh8OP3LHPx9qWr/2n4pu9X1O5vo7wXEkVyxuvLl3iXzhCLjaTDtTfstrUOVKSRnIwB6t8b9bvdP8O/2FcpPHLeWTyagt46StHbmZWhW5IG6BJC+5LW35bDpkrKuM7TXuPNnvZIZbOxtY4pjYshimt4pVw6qw82O1QjywqRhriRlhbdiTNfc5PR/4iTQliJfuK9Ju2vtOZTst/d5IJx3974oyu6jfN+d+IeZ3zSlk9F80KOsrO16klG2ut3yuPvLrLm0dz//Z","rgb":"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"}]} \ No newline at end of file diff --git a/tests/omnijev/export.rs b/tests/omnijev/export.rs new file mode 100644 index 00000000..684c5e31 --- /dev/null +++ b/tests/omnijev/export.rs @@ -0,0 +1,180 @@ +//! The export manifest checks, on a stand-in export directory. +use std::fs; +use std::path::PathBuf; + +use omni_omnijev_native::export; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; + +/// A directory with small stand-in files and a manifest that lists their hashes. +struct Dir(PathBuf); + +impl Dir { + fn new(name: &str) -> Self { + let dir = + std::env::temp_dir().join(format!("omnijev-export-{name}-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + let mut outputs = serde_json::Map::new(); + let shard = "model-00001-of-00001.safetensors"; + let files = [ + ( + "config.json", + json!({"text_config": {"vocab_size": 248079}}).to_string(), + ), + ( + "model.safetensors.index.json", + json!({"weight_map": {"embed_tokens.weight": shard}}).to_string(), + ), + (shard, "stand-in weights".to_string()), + ("heads.safetensors", "stand-in heads".to_string()), + ("tokenizer.json", "stand-in tokenizer".to_string()), + ("vision.safetensors", "stand-in vision".to_string()), + ]; + for (file, bytes) in files { + fs::write(dir.join(file), &bytes).unwrap(); + outputs.insert( + file.into(), + json!(format!("{:x}", Sha256::digest(bytes.as_bytes()))), + ); + } + let manifest = json!({ + "format": "omnijev-merged/1", + "model_id": "tinnel123/OmniJev", + "checkpoint_revision": "ffe5f436eaf22e20e2f041f8e74e121fd057a6cb", + "base_model_id": "Qwen/Qwen3.5-4B", + "base_revision": "851bf6e806efd8d0a36b00ddf55e13ccb7b8cd0a", + "lora": {"rank": 32, "alpha": 64, "merged": "bfloat16"}, + "vocabulary": 248079, + "option_tokens": {"<|opt|>": 248077, "<|/opt|>": 248078}, + "max_pixels": 602112, + "heads": {"norm": "softmax", "lm_features": true, "ordinal": true}, + "calibration": {"temperatures": {"noul": 1.1991, "choice": 1.1199, "score": 1.1909}, + "noul_bias": 0.05310856608843584}, + "inputs": {}, + "outputs": outputs, + }); + let dir = Self(dir); + dir.write(&manifest); + dir + } + + fn manifest(&self) -> Value { + serde_json::from_slice(&fs::read(self.0.join("omnijev_export.json")).unwrap()).unwrap() + } + + fn write(&self, manifest: &Value) { + fs::write( + self.0.join("omnijev_export.json"), + serde_json::to_vec(manifest).unwrap(), + ) + .unwrap(); + } + + fn error(&self) -> String { + format!( + "{:#}", + export::load(&self.0) + .err() + .expect("the export must be refused") + ) + } +} + +impl Drop for Dir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +#[test] +fn reads_a_matching_export_and_its_calibration() { + let dir = Dir::new("ok"); + let export = export::load(&dir.0).unwrap(); + assert_eq!(export.calibration.temperatures, [1.1991, 1.1199, 1.1909]); + assert_eq!(export.calibration.noul_bias, 0.05310856608843584); +} + +#[test] +fn refuses_changed_files_and_other_releases() { + let dir = Dir::new("changed"); + fs::write(dir.0.join("vision.safetensors"), "changed").unwrap(); + assert!( + dir.error().contains("vision.safetensors does not match"), + "{}", + dir.error() + ); + + let dir = Dir::new("missing"); + fs::remove_file(dir.0.join("heads.safetensors")).unwrap(); + assert!(dir.error().contains("heads.safetensors"), "{}", dir.error()); + + let edits: [(&str, Value, &str); 6] = [ + ("checkpoint_revision", json!("main"), "checkpoint_revision"), + ("max_pixels", json!(401408), "max_pixels"), + ("vocabulary", json!(248320), "vocabulary"), + ( + "heads", + json!({"norm": "sigmoid", "lm_features": true, "ordinal": true}), + "heads", + ), + ( + "calibration", + json!({"temperatures": {"noul": 0.0, "choice": 1.0, "score": 1.0}, "noul_bias": 0.0}), + "temperatures", + ), + ( + "calibration", + json!({"temperatures": {"noul": 1.0, "choice": 1.0, "score": 1.0}}), + "noul_bias", + ), + ]; + for (key, value, reason) in edits { + let dir = Dir::new(key); + let mut manifest = dir.manifest(); + manifest[key] = value; + dir.write(&manifest); + assert!(dir.error().contains(reason), "{key}: {}", dir.error()); + } + + for (name, reason) in [ + ("tokenizer.json", "does not list tokenizer.json"), + ("../config.json", "invalid file name"), + ] { + let dir = Dir::new("listing"); + let mut manifest = dir.manifest(); + let outputs = manifest["outputs"].as_object_mut().unwrap(); + let digest = outputs.remove("tokenizer.json").unwrap(); + if name != "tokenizer.json" { + outputs.insert("tokenizer.json".into(), digest); + outputs.insert(name.into(), json!("00")); + } + dir.write(&manifest); + assert!(dir.error().contains(reason), "{name}: {}", dir.error()); + } + // the shard the index names, and the vocabulary + let dir = Dir::new("shard"); + let mut manifest = dir.manifest(); + manifest["outputs"] + .as_object_mut() + .unwrap() + .remove("model-00001-of-00001.safetensors"); + dir.write(&manifest); + assert!( + dir.error() + .contains("does not list model-00001-of-00001.safetensors"), + "{}", + dir.error() + ); + let dir = Dir::new("vocab"); + let config = serde_json::json!({"text_config": {"vocab_size": 248320}}).to_string(); + fs::write(dir.0.join("config.json"), &config).unwrap(); + let mut manifest = dir.manifest(); + manifest["outputs"]["config.json"] = json!(format!("{:x}", Sha256::digest(config.as_bytes()))); + dir.write(&manifest); + assert!( + dir.error().contains("vocabulary is 248320"), + "{}", + dir.error() + ); +} diff --git a/tests/omnijev/generate_fixtures.py b/tests/omnijev/generate_fixtures.py index 66431901..f0e7b043 100644 --- a/tests/omnijev/generate_fixtures.py +++ b/tests/omnijev/generate_fixtures.py @@ -1,8 +1,9 @@ #!/usr/bin/env python3 """Generate the OmniJev CPU fixtures from the pinned reference. -Runs the reference's own preparation, branch split, rotary positions, heads and -response finishing without loading the backbone, so it needs no GPU: +Runs the reference's own image loading and processor, preparation, branch split, +rotary positions, heads and response finishing without loading the backbone, so it +needs no GPU: CUDA_VISIBLE_DEVICES= python tests/omnijev/generate_fixtures.py \ --reference \ @@ -301,6 +302,59 @@ def finish_cases(reference, checkpoint): return {"temperatures": stub.temps, "biases": stub.biases, "cases": cases} +def pattern(width, height): + """The test pattern tests/omnijev/pixels.rs draws too: channel values from x and y.""" + import numpy as np + x = np.arange(width, dtype=np.int64)[None, :] + y = np.arange(height, dtype=np.int64)[:, None] + return np.stack([(x * 7 + y * 3) % 256, (x * y + 13) % 256, ((x ^ y) * 5) % 256], -1).astype(np.uint8) + + +def pixel_cases(obj): + """Images as the reference reads them, `Image.open(path).convert("RGB")`, and the + processor's pixel values for them. Pattern PNGs are redrawn by the test; files whose + encoding matters (palette, 16-bit, JPEG) are stored.""" + import base64 + import numpy as np + from PIL import Image + + def encode(image, fmt, **kw): + buf = io.BytesIO() + image.save(buf, format=fmt, **kw) + return buf.getvalue() + + rgba = np.concatenate([pattern(300, 200), ((np.arange(300)[None, :] + np.arange(200)[:, None]) % 256) + .astype(np.uint8)[..., None]], -1) + gray16 = ((np.arange(160)[None, :] * 400 + np.arange(100)[:, None] * 50) % 65536).astype(np.uint16) + files = [ + ("rgb_upscale", "pattern", encode(Image.fromarray(pattern(64, 48)), "PNG")), + ("rgb_odd", "pattern", encode(Image.fromarray(pattern(333, 217)), "PNG")), + ("rgb_downscale", "pattern", encode(Image.fromarray(pattern(1920, 1080)), "PNG")), + ("rgb_wide", "pattern", encode(Image.fromarray(pattern(2000, 20)), "PNG")), + ("rgba", "pattern_rgba", encode(Image.fromarray(rgba, "RGBA"), "PNG")), + ("gray", "pattern_gray", encode(Image.fromarray(pattern(200, 150)[..., 0], "L"), "PNG")), + ("palette", "stored", encode(Image.fromarray(pattern(180, 120)).quantize(64), "PNG")), + ("gray16", "stored", encode(Image.fromarray(gray16, "I;16"), "PNG")), + ("jpeg", "stored", encode(Image.fromarray(pattern(96, 64)), "JPEG", quality=90)), + ] + cases = [] + for name, kind, raw in files: + im = Image.open(io.BytesIO(raw)).convert("RGB") + rgb = np.asarray(im, dtype=np.uint8) + enc = obj.proc(text=["<|vision_start|><|image_pad|><|vision_end|>"], images=[im], return_tensors="pt") + pixels = enc["pixel_values"].to(torch.float32).contiguous() + case = {"name": name, "kind": kind, "format": "jpeg" if raw[:2] == b"\xff\xd8" else "png", + "size": [im.width, im.height], "rgb_sha256": hashlib.sha256(rgb.tobytes()).hexdigest(), + "image_grid_thw": enc["image_grid_thw"][0].tolist(), "pixel_values_shape": list(pixels.shape), + "pixel_values_sha256": hashlib.sha256(pixels.numpy().tobytes()).hexdigest()} + if kind == "stored": + case["bytes"] = base64.b64encode(raw).decode() + if case["format"] == "jpeg": + case["rgb"] = base64.b64encode(rgb.tobytes()).decode() + cases.append(case) + return cases + + def main(): ap = argparse.ArgumentParser() ap.add_argument("--reference", required=True) @@ -321,6 +375,7 @@ def main(): "command": "CUDA_VISIBLE_DEVICES= python tests/omnijev/generate_fixtures.py"} os.makedirs(a.out, exist_ok=True) for fname, payload in (("processing.json", {"provenance": provenance, "cases": cases}), + ("pixels.json", {"provenance": provenance, "cases": pixel_cases(obj)}), ("heads.json", {"provenance": provenance, "cases": head_cases(a.checkpoint)}), ("features.json", {"provenance": provenance, **feature_cases()}), ("finish.json", {"provenance": provenance, **finish_cases(a.reference, a.checkpoint)})): diff --git a/tests/omnijev/pixels.rs b/tests/omnijev/pixels.rs new file mode 100644 index 00000000..3267f9d5 --- /dev/null +++ b/tests/omnijev/pixels.rs @@ -0,0 +1,135 @@ +//! Image decoding and pixel values against the reference's `Image.open(...).convert("RGB")` +//! and processor (tests/omnijev/data/pixels.json). +mod common; + +use base64::Engine; +use omni_omnijev_native::{contract::Image, processing}; +use omni_qwen3_5_native::image_decode::{DecodeLimits, decode_rgb8}; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +/// The generator's pattern: channel values from x and y. +fn pattern(width: u32, height: u32, x: u32, y: u32) -> [u8; 3] { + let _ = (width, height); + let (x, y) = (x as u64, y as u64); + [ + ((x * 7 + y * 3) % 256) as u8, + ((x * y + 13) % 256) as u8, + (((x ^ y) * 5) % 256) as u8, + ] +} + +/// The case's file: redrawn for pattern cases, stored otherwise. +fn file(case: &Value) -> Vec { + let [w, h] = [0, 1].map(|i| case["size"][i].as_u64().unwrap() as u32); + let mut bytes = Vec::new(); + let mut out = std::io::Cursor::new(&mut bytes); + match case["kind"].as_str().unwrap() { + "pattern" => image::RgbImage::from_fn(w, h, |x, y| image::Rgb(pattern(w, h, x, y))) + .write_to(&mut out, image::ImageFormat::Png) + .unwrap(), + "pattern_rgba" => image::RgbaImage::from_fn(w, h, |x, y| { + let [r, g, b] = pattern(w, h, x, y); + image::Rgba([r, g, b, ((x + y) % 256) as u8]) + }) + .write_to(&mut out, image::ImageFormat::Png) + .unwrap(), + "pattern_gray" => { + image::GrayImage::from_fn(w, h, |x, y| image::Luma([pattern(w, h, x, y)[0]])) + .write_to(&mut out, image::ImageFormat::Png) + .unwrap() + } + _ => { + return base64::engine::general_purpose::STANDARD + .decode(case["bytes"].as_str().unwrap()) + .unwrap(); + } + } + bytes +} + +fn sha256(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +fn f32_sha256(values: &[f32]) -> String { + let bytes: Vec = values.iter().flat_map(|v| v.to_le_bytes()).collect(); + sha256(&bytes) +} + +#[test] +fn decoding_and_pixel_values_match_reference() { + let fixture = common::fixture("pixels.json"); + let limits = DecodeLimits { + max_side: 4096, + max_pixels: 4096 * 4096, + max_aspect: 200, + max_alloc: 4096 * 4096 * 8, + }; + for case in fixture["cases"].as_array().unwrap() { + let name = case["name"].as_str().unwrap(); + let format = match case["format"].as_str().unwrap() { + "jpeg" => image::ImageFormat::Jpeg, + _ => image::ImageFormat::Png, + }; + let bytes = file(case); + let decoded = decode_rgb8(&bytes, format, &limits).unwrap(); + let size = [decoded.width, decoded.height]; + assert_eq!(serde_json::json!(size), case["size"], "{name}"); + let image = Image { + format, + width: decoded.width, + height: decoded.height, + bytes, + }; + let pixels = processing::pixels(&image).unwrap(); + if format == image::ImageFormat::Png { + assert_eq!( + sha256(&decoded.rgb), + case["rgb_sha256"].as_str().unwrap(), + "{name}" + ); + assert_eq!( + serde_json::json!(pixels.image_grid_thw), + case["image_grid_thw"], + "{name}" + ); + assert_eq!( + f32_sha256(&pixels.pixel_values), + case["pixel_values_sha256"].as_str().unwrap(), + "{name}" + ); + } else { + // JPEG decoders may differ by rounding: the bytes against Pillow's, and the + // processor alone on Pillow's pixels. + let reference = base64::engine::general_purpose::STANDARD + .decode(case["rgb"].as_str().unwrap()) + .unwrap(); + let worst = decoded + .rgb + .iter() + .zip(&reference) + .map(|(a, b)| a.abs_diff(*b)) + .max() + .unwrap(); + let changed = decoded + .rgb + .iter() + .zip(&reference) + .filter(|(a, b)| a != b) + .count(); + eprintln!( + "{name}: {changed} of {} values differ from Pillow's, by at most {worst}", + reference.len() + ); + assert!(worst <= 3, "{name}: {worst}"); + let from_reference = + processing::preprocess(decoded.width, decoded.height, &reference).unwrap(); + assert_eq!( + f32_sha256(&from_reference.pixel_values), + case["pixel_values_sha256"].as_str().unwrap(), + "{name}" + ); + } + } +} diff --git a/tests/omnijev/processing.rs b/tests/omnijev/processing.rs index 46d37e97..f1ad09dd 100644 --- a/tests/omnijev/processing.rs +++ b/tests/omnijev/processing.rs @@ -3,6 +3,7 @@ mod common; use omni_omnijev_native::contract; use omni_omnijev_native::processing::{self, Processor}; +use omni_qwen3_5_native::inputs::image_positions; use sha2::{Digest, Sha256}; fn positions_sha256(positions: &[Vec; 3]) -> String { @@ -21,7 +22,7 @@ fn questions_prompts_and_layout_match_reference() { for case in fixture["cases"].as_array().unwrap() { let name = case["name"].as_str().unwrap(); let request = contract::compile(&common::request(case)).unwrap(); - let grid = processing::image_grid(request.image.width, request.image.height); + let grid = processing::pixels(&request.image).unwrap().image_grid_thw; assert_eq!(serde_json::json!(grid), case["image_grid_thw"], "{name}"); let expected = case["questions"].as_array().unwrap(); assert_eq!(request.questions.len(), expected.len(), "{name}"); @@ -45,7 +46,7 @@ fn questions_prompts_and_layout_match_reference() { let (opens, closes) = processing::spans(&ids); assert_eq!(serde_json::json!(opens), e["opens"], "{name} {}", q.id); assert_eq!(serde_json::json!(closes), e["closes"], "{name} {}", q.id); - let positions = processing::image_positions(&ids, grid).unwrap(); + let positions = image_positions(&ids, processing::IMAGE_TOKEN, grid).unwrap(); assert_eq!( positions_sha256(&positions), e["positions_sha256"].as_str().unwrap(), @@ -82,7 +83,7 @@ fn questions_prompts_and_layout_match_reference() { } } // Rows continue the prefix: the first row position is the prefix's largest plus one. - let positions = processing::image_positions(&sequences[0], grid).unwrap(); + let positions = image_positions(&sequences[0], processing::IMAGE_TOKEN, grid).unwrap(); assert_eq!(layout.next_position, positions[0][layout.prefix], "{name}"); } } diff --git a/tests/omnijev/worker.rs b/tests/omnijev/worker.rs new file mode 100644 index 00000000..9cab96a9 --- /dev/null +++ b/tests/omnijev/worker.rs @@ -0,0 +1,182 @@ +//! GPU checks of the native execution on an export. They need a GPU, OMNIJEV_EXPORT +//! pointing at an export from recipe/omnijev/export.py and OMNIJEV_CUDA_LIB at +//! libqwen3_5_cuda.so, both absolute: +//! +//! OMNIJEV_EXPORT= OMNIJEV_CUDA_LIB=$PWD/target/release/libqwen3_5_cuda.so \ +//! cargo test --release -p omni-omnijev-native --test worker -- --ignored --test-threads=1 +mod common; + +use std::path::PathBuf; + +use omni_omnijev_native::executor::{self, Executor}; +use serde_json::{Value, json}; + +fn executor() -> (Executor, omni_omnijev_native::processing::Processor) { + let var = + |name: &str| PathBuf::from(std::env::var_os(name).unwrap_or_else(|| panic!("set {name}"))); + Executor::load(&var("OMNIJEV_EXPORT"), &var("OMNIJEV_CUDA_LIB")).unwrap() +} + +fn request(questions: Value) -> Vec { + serde_json::to_vec(&json!({ + "model": "tinnel123/OmniJev", + "state": {"images": [common::png_url(320, 200, [200, 40, 40])]}, + "questions": questions, + })) + .unwrap() +} + +/// The answers without the timing fields. +fn decisions(response: &Value) -> Value { + let mut answers = response["answers"].clone(); + for answer in answers.as_object_mut().unwrap().values_mut() { + let answer = answer.as_object_mut().unwrap(); + answer.remove("latency_s"); + answer.remove("latency_total_s"); + } + answers +} + +#[test] +#[ignore = "needs a GPU, OMNIJEV_EXPORT and OMNIJEV_CUDA_LIB"] +fn answers_are_repeatable_and_independent_of_question_order() { + let (mut executor, processor) = executor(); + let questions = json!({ + "red": {"type": "noul", "instructions": "The image is mostly red."}, + "where": {"type": "noul", "instructions": "There is something in this region.", + "region": {"box": [0, 0, 160, 100]}}, + "colour": {"type": "choice", "instructions": "Which colour dominates the image?", + "options": [{"key": "red", "text": "red"}, {"key": "green", "text": "green"}, + {"key": "blue", "text": "blue"}, {"text": "none", "abstain": true}]}, + "warm": {"type": "score", "instructions": "How warm is the colour?", + "levels": ["cold", "neutral", "warm", "hot"]}, + }); + let run = |executor: &mut Executor, questions: &Value| { + let prepared = processor.prepare(&request(questions.clone())).unwrap(); + let answers = executor.execute(&prepared).unwrap(); + executor::response(&prepared, answers) + }; + let first = run(&mut executor, &questions); + assert_eq!(first["model"], "tinnel123/OmniJev"); + assert!(first["usage"]["input_tokens"].as_u64().unwrap() > 0); + let answers = first["answers"].as_object().unwrap(); + assert_eq!( + answers.keys().collect::>(), + ["red", "where", "colour", "warm"] + ); + for (id, field) in [ + ("red", "noul"), + ("where", "noul"), + ("colour", "choice"), + ("warm", "score"), + ] { + assert!(answers[id].get(field).is_some(), "{id}: {}", answers[id]); + assert!(answers[id]["latency_total_s"].is_number(), "{id}"); + } + assert_eq!( + decisions(&run(&mut executor, &questions)), + decisions(&first) + ); + // a question's rows continue only the request's prefix and its own text, so the + // order of the questions changes nothing + let reversed: serde_json::Map = questions + .as_object() + .unwrap() + .iter() + .rev() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + let mut back = decisions(&run(&mut executor, &Value::Object(reversed))); + let mut want = decisions(&first); + back.as_object_mut().unwrap().sort_keys(); + want.as_object_mut().unwrap().sort_keys(); + assert_eq!(back, want); +} + +/// Questions on `image` whose first option marker falls at different places against the +/// 64-token chunks: among them one right on a boundary (`zq` read with the question's +/// text), one whose text crosses a boundary, the speed_bench-like and gate questions. +fn reuse_request(processor: &omni_omnijev_native::processing::Processor, image: &str) -> Vec { + let gates: Value = serde_json::from_str( + &std::fs::read_to_string(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../../recipe/omnijev/gate_questions.json" + )) + .unwrap(), + ) + .unwrap(); + let build = |padding: usize| { + let mut questions = gates.clone(); + let q = questions.as_object_mut().unwrap(); + q.insert( + "boundary".into(), + json!({"type": "choice", "instructions": format!("Count the windows{}.", " again".repeat(padding)), + "options": [{"key": "one", "text": "one"}, {"key": "many", "text": "many windows"}]}), + ); + // text longer than a chunk past any prefix: zq is read in rows that continue it + q.insert( + "long".into(), + json!({"type": "choice", "instructions": "Look at every part of the picture, from the top left corner to the bottom right corner, including any text, icons, people, vehicles, buildings, plants, animals and the sky, then look again at the middle of the picture and at its edges, compare what you see there with what you would expect in an ordinary photograph or screenshot, and then say how much is happening in the whole scene overall.", + "options": [{"key": "nothing", "text": "nothing at all"}, {"key": "little", "text": "a little"}, {"key": "lot", "text": "a lot"}]}), + ); + q.insert( + "red".into(), + json!({"type": "noul", "instructions": "The image is mostly red."}), + ); + serde_json::to_vec(&json!({"model": "tinnel123/OmniJev", "state": {"images": [image]}, "questions": questions})).unwrap() + }; + // the padding that puts the boundary question's first option on a multiple of 64 + for padding in 0..80 { + let raw = build(padding); + let prepared = processor.prepare(&raw).unwrap(); + let i = prepared + .questions + .iter() + .position(|q| q.id == "boundary") + .unwrap(); + let open = prepared.layout.prefix + prepared.inputs[i].rows[0].zq + 1; + if open.is_multiple_of(64) { + return raw; + } + } + panic!("no padding puts the first option on a boundary"); +} + +#[test] +#[ignore = "needs a GPU, OMNIJEV_EXPORT and OMNIJEV_CUDA_LIB"] +fn prefix_reuse_matches_plain_passes_bit_for_bit() { + let (mut executor, processor) = executor(); + for (w, h) in [(64, 48), (320, 200), (1600, 900)] { + let raw = reuse_request(&processor, &common::png_url(w, h, [30, 120, 200])); + let prepared = processor.prepare(&raw).unwrap(); + // the three ways a question's rows split against the 64-token chunks, each with + // LM features read + let l = prepared.layout.prefix; + let p = l - l % 64; + let cases: Vec<(bool, bool)> = prepared + .questions + .iter() + .zip(&prepared.inputs) + .filter(|(q, _)| q.kind != omni_omnijev_native::contract::Kind::Score) + .map(|(_, input)| { + let open = l + input.rows[0].zq + 1; + (open - open % 64 > p, open.is_multiple_of(64)) + }) + .collect(); + for case in [(false, false), (true, false), (true, true)] { + assert!( + cases.contains(&case), + "{w}x{h}: no question splits as {case:?}" + ); + } + let shared = executor.head_outputs(&prepared, true).unwrap(); + let plain = executor.head_outputs(&prepared, false).unwrap(); + let bits = |outputs: &[Vec]| -> Vec { + outputs.iter().flatten().map(|v| v.to_bits()).collect() + }; + assert_eq!(bits(&shared), bits(&plain), "{w}x{h}"); + let reused = executor::response(&prepared, executor.execute(&prepared).unwrap()); + let rows = executor::response(&prepared, executor.execute_rows(&prepared).unwrap()); + assert_eq!(decisions(&reused), decisions(&rows), "{w}x{h}"); + } +} diff --git a/tests/qwen3_5/model.rs b/tests/qwen3_5/model.rs index 6e66fd1c..e82c1958 100644 --- a/tests/qwen3_5/model.rs +++ b/tests/qwen3_5/model.rs @@ -41,6 +41,40 @@ fn runs_keep_every_prompt_and_end_prefixes_on_chunks() { } } +#[test] +fn token_logprob_is_a_log_softmax_over_the_vocabulary_only() { + let vocab = 1001; + // logits in [-20, 20) and, past the vocabulary, padding that must not count + let mut x = 7u64; + let mut values: Vec = (0..vocab) + .map(|_| { + x = x + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + ((x >> 40) as f32 / (1u64 << 24) as f32) * 40.0 - 20.0 + }) + .collect(); + values.extend([1e4f32; 7]); + let bytes: Vec = values + .iter() + .flat_map(|&v| half::bf16::from_f32(v).to_le_bytes()) + .collect(); + let rounded: Vec = values[..vocab] + .iter() + .map(|&v| half::bf16::from_f32(v).to_f64()) + .collect(); + let lse = rounded.iter().map(|v| v.exp()).sum::().ln(); + for token in [0u32, 1, 500, 1000] { + let want = rounded[token as usize] - lse; + let got = token_logprob(&bytes, vocab, token) as f64; + assert!((got - want).abs() <= 1e-5, "token {token}: {got} vs {want}"); + } + let total: f64 = (0..vocab as u32) + .map(|t| (token_logprob(&bytes, vocab, t) as f64).exp()) + .sum(); + assert!((total - 1.0).abs() <= 1e-5, "{total}"); +} + /// A two-layer checkpoint with hidden size 128, the smallest whose stacked weights stay /// contiguous in the arena, and an FP32 adapter on layer 0's down projection. #[cfg(unix)] @@ -233,11 +267,22 @@ fn cpu_fixed_and_shared_refuse_other_profiles_before_device_work() { branches: vec![&[4], &[5, 6]], }], }; + let positions = [0, 1, 2]; + let input = MultimodalInput { + token_ids: &[1, 2, 3], + image_token_indices: &[], + image_embeddings: &[], + position_ids: [&positions; 3], + }; let refuse = |model: &mut Model, profile: &str| { let before = unsafe { calls() }; for error in [ model.forward_fixed(&[1, 2, 3]).unwrap_err(), model.forward_shared(&prompts).unwrap_err(), + model + .forward_multimodal_readout(&input, &[2], &[(2, 4)]) + .map(drop) + .unwrap_err(), ] { assert!( error.to_string().contains("only merged weights"), @@ -247,6 +292,7 @@ fn cpu_fixed_and_shared_refuse_other_profiles_before_device_work() { assert_eq!(unsafe { calls() }, before, "{profile}: CUDA calls"); assert!( model.scratch.is_none() + && model.vocab_buffers.is_none() && model.fixed_gemm.is_null() && model.shared.iter().all(Option::is_none), "{profile}: buffers" diff --git a/tests/qwen3_5/readout.rs b/tests/qwen3_5/readout.rs new file mode 100644 index 00000000..d0ccdf37 --- /dev/null +++ b/tests/qwen3_5/readout.rs @@ -0,0 +1,260 @@ +//! GPU checks of `Model::forward_multimodal_readout`. They need a GPU, CUA_S1_CUDA_LIB +//! pointing at libqwen3_5_cuda.so and QWEN3_5_CHECKPOINT pointing at a merged +//! multimodal Qwen3.5 export with `image_token_id`, such as an OmniJev export: +//! +//! CUA_S1_CUDA_LIB=$PWD/target/release/libqwen3_5_cuda.so QWEN3_5_CHECKPOINT= \ +//! cargo test --release -p omni-qwen3-5-native --test readout -- --ignored --test-threads=1 + +use std::fs::File; +use std::path::PathBuf; + +use half::bf16; +use omni_qwen3_5_native::inputs::{MultimodalInput, image_positions}; +use omni_qwen3_5_native::model::Model; +use safetensors::SafeTensors; +use serde_json::Value; + +fn checkpoint() -> PathBuf { + PathBuf::from(std::env::var_os("QWEN3_5_CHECKPOINT").expect("set QWEN3_5_CHECKPOINT")) +} + +fn model() -> Model { + let library = PathBuf::from(std::env::var_os("CUA_S1_CUDA_LIB").expect("set CUA_S1_CUDA_LIB")); + Model::load(&checkpoint(), &library).unwrap() +} + +/// Token ids in [1000, 100000) from a fixed seed. +fn tokens(n: usize, seed: u64) -> Vec { + let mut x = seed.wrapping_mul(0x9e37_79b9_7f4a_7c15) | 1; + (0..n) + .map(|_| { + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + 1000 + (x >> 33) as u32 % 99_000 + }) + .collect() +} + +fn bits(rows: &[Vec]) -> Vec { + rows.iter().flatten().map(|v| v.to_bits()).collect() +} + +/// A prompt of `before` text tokens, an 8×12 grid of image tokens and `after` text tokens, +/// with random image rows. +struct Prompt { + ids: Vec, + images: Vec, + features: Vec, + positions: [Vec; 3], +} + +impl Prompt { + fn new(model: &Model, before: usize, after: usize) -> Self { + let image = model.cfg.image_token_id.expect("a multimodal checkpoint"); + let grid = [1, 8, 12]; + let count = 4 * 6; + let mut ids = tokens(before, 1); + ids.extend(std::iter::repeat_n(image, count)); + ids.extend(tokens(after, 2)); + let positions = image_positions(&ids, image, grid).unwrap(); + let mut x = 3u64; + let features = (0..count * model.cfg.hidden) + .map(|_| { + x = x + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + bf16::from_f32(((x >> 40) as f32 / (1u64 << 24) as f32 - 0.5) * 0.2) + }) + .collect(); + Self { + images: (before..before + count).collect(), + ids, + features, + positions, + } + } + + fn input(&self) -> MultimodalInput<'_> { + MultimodalInput { + token_ids: &self.ids, + image_token_indices: &self.images, + image_embeddings: &self.features, + position_ids: [&self.positions[0], &self.positions[1], &self.positions[2]], + } + } +} + +/// log p(token) after the final-norm `hidden` state on the CPU: float64 dot products with +/// the tied embedding read from the checkpoint, rounded to bfloat16 as the GPU's logits, +/// then a float64 log-softmax. Also the token's logit. +fn cpu_logprob(hidden: &[f32], token: u32) -> (f64, f64) { + let dir = checkpoint(); + let index: Value = + serde_json::from_slice(&std::fs::read(dir.join("model.safetensors.index.json")).unwrap()) + .unwrap(); + let name = [ + "embed_tokens.weight", + "model.embed_tokens.weight", + "model.language_model.embed_tokens.weight", + ] + .into_iter() + .find(|n| index["weight_map"][n].is_string()) + .unwrap(); + let file = File::open(dir.join(index["weight_map"][name].as_str().unwrap())).unwrap(); + // SAFETY: the checkpoint is not modified while the test runs. + let map = unsafe { memmap2::Mmap::map(&file).unwrap() }; + let tensors = SafeTensors::deserialize(&map).unwrap(); + let tensor = tensors.tensor(name).unwrap(); + let width = hidden.len(); + let logits: Vec = tensor + .data() + .chunks_exact(width * 2) + .map(|row| { + let dot: f64 = row + .as_chunks::<2>() + .0 + .iter() + .zip(hidden) + .map(|(b, &h)| bf16::from_le_bytes(*b).to_f64() * h as f64) + .sum(); + bf16::from_f64(dot).to_f64() + }) + .collect(); + let max = logits.iter().copied().fold(f64::NEG_INFINITY, f64::max); + let lse = max + logits.iter().map(|l| (l - max).exp()).sum::().ln(); + (logits[token as usize] - lse, logits[token as usize]) +} + +#[test] +#[ignore = "needs a GPU, CUA_S1_CUDA_LIB and a multimodal QWEN3_5_CHECKPOINT"] +fn text_readout_equals_forward_fixed() { + let mut model = model(); + for n in [1usize, 63, 64, 65, 300] { + let ids = tokens(n, n as u64); + let positions: Vec = (0..n as i64).collect(); + let readout = model + .forward_multimodal_readout( + &MultimodalInput { + token_ids: &ids, + image_token_indices: &[], + image_embeddings: &[], + position_ids: [&positions; 3], + }, + &[n - 1], + &[], + ) + .unwrap(); + let fixed = model.forward_fixed(&ids).unwrap(); + assert_eq!(bits(&readout.hidden), bits(&[fixed]), "{n} tokens"); + } +} + +#[test] +#[ignore = "needs a GPU, CUA_S1_CUDA_LIB and a multimodal QWEN3_5_CHECKPOINT"] +fn readouts_do_not_depend_on_what_else_is_read() { + let mut model = model(); + let prompt = Prompt::new(&model, 10, 140); + let t = prompt.ids.len(); + let vocab_ids = [0u32, 13, 2000, 151_000, 248_000]; + // 130 targets: more than one 64-row chunk of logits + let targets: Vec<(usize, u32)> = (0..130) + .map(|i| (t - 130 + i, vocab_ids[i % vocab_ids.len()])) + .collect(); + let all = model + .forward_multimodal_readout(&prompt.input(), &[0, 40, t - 1], &targets) + .unwrap(); + assert!(all.logprobs.iter().all(|&p| p.is_finite() && p <= 0.0)); + let few = model + .forward_multimodal_readout(&prompt.input(), &[t - 1, 40], &targets[65..67]) + .unwrap(); + assert_eq!( + bits(&few.hidden), + bits(&[all.hidden[2].clone(), all.hidden[1].clone()]) + ); + assert_eq!(few.logprobs, all.logprobs[65..67]); + // against the CPU, from the downloaded hidden state: the GPU may round a logit to the + // neighbouring bfloat16 value, which moves the log-probability by one unit of it + let (row, token) = targets[129]; + assert_eq!(row, t - 1); + let (want, logit) = cpu_logprob(&all.hidden[2], token); + let ulp = bf16::from_f64(logit.abs()).to_f64() * 2f64.powi(-7); + assert!( + (all.logprobs[129] as f64 - want).abs() <= ulp + 1e-3, + "{} vs {want}", + all.logprobs[129] + ); +} + +/// Rows `range` of `prompt` as a window: their ids, image placeholders and image rows, +/// and positions. +fn window<'a>( + prompt: &'a Prompt, + range: std::ops::Range, + hidden: usize, +) -> (Vec, MultimodalInput<'a>) { + let images: Vec = prompt + .images + .iter() + .filter(|&&i| range.contains(&i)) + .map(|&i| i - range.start) + .collect(); + let first = prompt + .images + .iter() + .position(|&i| range.contains(&i)) + .unwrap_or(0); + let features = &prompt.features[first * hidden..(first + images.len()) * hidden]; + let positions = [0, 1, 2].map(|a| &prompt.positions[a][range.clone()]); + let input = MultimodalInput { + token_ids: &prompt.ids[range.clone()], + image_token_indices: &[], + image_embeddings: features, + position_ids: positions, + }; + (images, input) +} + +#[test] +#[ignore = "needs a GPU, CUA_S1_CUDA_LIB and a multimodal QWEN3_5_CHECKPOINT"] +fn windows_with_captures_equal_one_pass() { + let mut model = model(); + let hidden = model.cfg.hidden; + // the image placeholders cross the first 64-token boundary + let prompt = Prompt::new(&model, 50, 260); + let t = prompt.ids.len(); + let targets: Vec<(usize, u32)> = (t - 40..t).map(|r| (r, prompt.ids[r] % 1000 + 1)).collect(); + let mid = [100usize, 127]; + let whole = model + .forward_multimodal_readout(&prompt.input(), &[mid[0], mid[1], t - 1], &targets) + .unwrap(); + let mut first = model.alloc_prefix(64).unwrap(); + let mut second = model.alloc_prefix(128).unwrap(); + let (images, mut input) = window(&prompt, 0..64, hidden); + input.image_token_indices = &images; + model + .readout_window(&input, None, Some(&mut first), &[], &[]) + .unwrap(); + let (images, mut input) = window(&prompt, 64..128, hidden); + input.image_token_indices = &images; + let captured = model + .readout_window(&input, Some(&first), Some(&mut second), &mid, &[]) + .unwrap(); + let (images, mut input) = window(&prompt, 128..t, hidden); + input.image_token_indices = &images; + let rest = model + .readout_window(&input, Some(&second), None, &[t - 1], &targets) + .unwrap(); + assert_eq!(bits(&captured.hidden), bits(&whole.hidden[..2])); + assert_eq!(bits(&rest.hidden), bits(&whole.hidden[2..])); + assert_eq!(rest.logprobs, whole.logprobs); + // a capture that does not end on a multiple of 64 is refused before any work + let (images, mut input) = window(&prompt, 128..130, hidden); + input.image_token_indices = &images; + let mut third = model.alloc_prefix(192).unwrap(); + assert!( + model + .readout_window(&input, Some(&second), Some(&mut third), &[], &[]) + .is_err() + ); +}