Skip to content

vision, gpu-vision, gpu-gates, serve: EmbeddingGemma 2 image input through the Gemma4 vision tower - #324

Merged
juliankang4 merged 12 commits into
midagedev:mainfrom
juliankang4:dgx-spark-emb2-image
Oct 16, 2026
Merged

juliankang4 merged 12 commits into
midagedev:mainfrom
juliankang4:dgx-spark-emb2-image

Conversation

@juliankang4

@juliankang4 juliankang4 commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Part of #59 (plan and PR order in #190), E7: image input for EmbeddingGemma 2, through the Gemma4 vision tower and the embedding side seat. Builds on #299 and #323, both merged. E7's own twelve commits start after gpu-gates: every gate's file digest is the crate's sha256.

What and why

Release notes: Added: the embeddings endpoint takes images for EmbeddingGemma 2 (--embd-mmproj), next to a chat model.

EmbeddingGemma 2 embeds text and images into one vector space. Mainline llama.cpp runs the image half through mtmd with the model's projector file (mmproj-embeddinggemma-2-*.gguf): a Gemma4 vision tower whose output rows go into the text encoder in place of image placeholder tokens. E6 serves the text half beside a chat model. This PR adds the image half to the same side seat:

  • Preprocessing (crates/vision/src/arch/gemma4v/). The mtmd canvas: the image is resized to a dynamic size the tower's patch grid divides, normalized, and given raster positions. The dynamic size rule is shared with the existing vision models (c563d901) instead of a second copy.
  • The tower (crates/gpu-vision/src/arch/gemma4v.rs). Built from the shared tower kernels: the F32 patch convolution, learned position tables, BF16 attention and FFN blocks, and a 3×3 average pooling with a scale. Residuals, post-GEMM outputs and post norms stay F32, as mainline keeps them (d3f9b94c); the BF16 GEMM bodies are shared with the other towers through one macro. With no activation keys the tower follows mainline's Quick GELU; clip.use_gelu=true selects tanh GELU and SiLU is refused.
  • The seat (crates/gpu-gates/src/side_seat.rs, crates/serve/src/embeddings.rs). An input's image parts become rows of the encoder's single pass, between the delimiter tokens mainline's server writes. Tower and encoder share the side seat's card and thread with streams of their own, and their bytes are counted together before the chat placement.
  • Docs. docs/serving.md gains "EmbeddingGemma 2 images": the flags, the request shape and what is refused.

A projector with Q8_0 vision matrices is refused by tensor name; the vision matrices must be BF16, which is what ggml-org publishes beside the Q8_0 text file.

Request shape and limits

A server started with --embd-model and --embd-mmproj takes one object per input in the input array of /v1/embeddings, with an ordered content list of text and image_url parts. An image URL is a base64 data: URL for PNG or JPEG, as on the chat path; HTTP URLs are refused. A request with no loaded tower, with an unsupported image part or beyond the side context is a 400. --embd-hf <repo>[:<quant>] picks the row's BF16 projector, --embd-no-mmproj turns that off, and --embd-image-min-tokens and --embd-image-max-tokens set the image token budget apart from the chat model's flags. docs/serving.md has the full request, the flags and the refusals.

The chat image path keeps its behavior. One change reaches it: a media rule may now return no chat placeholder, because an embedding image has none. Chat expansion, the per-layer token checks and the decision encoding refuse that case by name, and the existing image models keep their token ids (vision, serve: represent a missing chat image token).

Code shape

  • No second copy. The dynamic size rule and the BF16 GEMM accumulator walk each have one owner (bf16_accumulate, shared with the other towers). The HTTP route and the gates build an input's rows through one function, serve::embeddings::assemble. The tower crates use the common shape and tensor errors.
  • Model code holds only architecture facts. The Gemma4 header, activation and pooling scale are read in crates/vision/src/arch/gemma4v/; the seat has no branch per model.
  • No silent failure. A non-finite weight, a Q8_0 vision matrix, a SiLU activation, a canvas past the token budget and a duplicate side flag each end in a named refusal.
  • No relaxed gate. The tower gate's bands derive from the CPU twin, not from measured distances. The Gemma4 entries are pinned in the PTX tables for both architectures.
  • Comments state what is true now.
  • cargo fmt is clean (under "What I ran").

What I ran

The head is 55156aa2, a rebase of the previous head bda0b1c9 onto main b9808a65 with no conflicts. The range-diff shows all twelve of our commits as unchanged (=), which is why the contrib-check result below, run on the earlier head 83eab52b, is carried over. The card runs and host checks used DGX Sparks in the project's dev container (bloomery-dev:cuda13.3.1, image sha256:853c20d59e68), CUDA 13.3.1, nvidia-smi. Each row names the head its result is from:

After the review of 84551417, image media spans go through gpu::media's span checker, one helper owns the tap conversion, and the unused coordinate arrays are gone. At head 710838ce, /v1/embeddings checks each image's span while it assembles the input and stops at the first image past the context, so a request with many images is refused before they are all prepared; both image gate recipes select the gemma-embedding2 model. At that head cargo test -p bloomery-serve embeddings passed (7 tests), and the new refusal test fails on the previous eager path.

After the review of 710838ce, each memory access in vis_clamp_f32, vis_add_f32, vis_pool_order_f32 and vis_rms_norm_f32 (each scalar read too) is its own unsafe block with its own SAFETY line, and the gpu-vision count in tools/unsafe-ratchet.txt is recounted (116 to 127) with a dated PIN reason. The PTX of the tower gate is unchanged on sm_121 and sm_86.

NVIDIA GB10, 595.84, 12.1
Check On this head On the base (main a688accb)
cargo fmt --all -- --check rc 0 (1d71fd07) not run
host tests of bloomery-serve, bloomery-vision and bloomery-refset rc 0 (1d71fd07) not run
just contrib-check --tier t0 85 checks, 0 red, carried from 83eab52b (range-diff above) not run
lint step of ./tools/mac-static.sh (Mac) 59 warnings, above the ratchet's 41 (1d71fd07) the same 59
check-recipes step of ./tools/mac-static.sh (Mac) red (1d71fd07) the same red
side seat tests of bloomery-gpu-gates 2 passed (8cb20c3f) not run
just check-no-vision builds (8cb20c3f) not run
just gate-ptx-spill (cc 12.1), the Gemma4 tower 23 pinned entries, PASS (8cb20c3f) 16 pinned entries, PASS
just gate-ptx-spill (cc 12.1), generate_ds41 and gate_e2e 11 unpinned entries in each, red (8cb20c3f) the same 11 in each
PTX pin check: remove the vision pins, then restore them exactly seven added failures, then the base failure again (8cb20c3f) not applicable
FAIL-first, CPU side: three edits, one to each guarding test each edit fails its test (rc 101), the restored file passes (rc 0) (8cb20c3f) not applicable
just gate-gpu-gemma4v-tower rc 0 on 55156aa2: 133/133 numerical rows, 14/14 bit checks no such gate
just gate-gpu-gemma-embedding2-e2e, image inputs still running on the card when this was posted; the result goes into this description when it ends no such inputs
just gate-gpu-gemma-embedding2-serve, image requests rc 0 on 55156aa2: 188/188 checks, 181 vector comparisons no such requests
FAIL-first, the three image gates against edits that break what they hold still running on the card when this was posted; the result goes into this description when it ends not applicable
the gates just affected selects still running on the card when this was posted; the result goes into this description when it ends not applicable

The Mac's check-recipes is red with FAIL cold run: the base side runs in a temp archive, away from this tree's git and with the ptx-compare self-test, which fails 1 of 33 cases. Main a688accb gives the same two lines on the same Mac, and the 59 lint warnings come from the same source files on both sides. I did not look for the causes of these reds.

The three CPU-side edits were: f.model = model; replaced by f.model = source::Flags::default(); in crates/gpu-gates/src/side_seat.rs; image.at = span.start; replaced by image.at = 0; in crates/serve/src/embeddings.rs; and Kind::ServerCpu, replaced by Kind::Server, in crates/refset/src/arch/gemma_embedding2/mod.rs.

Comparison with the reference

The reference is mainline llama.cpp 6868a52cb337450658006ad2f9281c2eb63f1666. The comparison runs the same Q8_0 model, BF16 projector and seven fixed images through the card and the CPU twin, with the gates and bands unchanged.

Check against llama.cpp Passed Largest difference CPU twin Band Result
Gemma4 tower, relative distance 133/133 numerical rows, 14/14 bit checks 4.882324453e-2 3.700747081e-2 9.251867702e-2 PASS
Embedding seat, cosine distance 188/188 checks, 181 vector comparisons 7.898842e-4 2.137216e-2 PASS

All tower rows stayed within 2.5 times the CPU/card twin distance, and all 181 serving vector comparisons stayed within their unchanged derived cosine and absolute-error bands.

Hardware (if the change is for a machine)

DGX Spark: NVIDIA GB10, compute capability 12.1, driver 595.84, dev container nvidia/cuda:13.3.1-devel-ubuntu24.04. RTX 5090: NVIDIA GeForce RTX 5090, sm_120.

What I ran into

None of this blocks the PR.

  • Three reds are the same on main a688accb. They are the Mac's lint count, the Mac's check-recipes step and the 11 unpinned PTX entries of just gate-ptx-spill. Each gives the same result with and without this PR, so I left them alone.
  • passage-tokens failed in upstream CI once and did not reproduce. On the previous PR head 84551417, check-recipes in contrib-check failed with FAIL passage-tokens: no 'B2 passage 120 tokens' in the happy run's output. I ran the same self-test (tools/ref/q38-3090-server.sh --self-test) ten times in a row on Linux aarch64 at 55156aa2, and it passed every time. The script is byte-identical on that PR head, this head and upstream main. I have not found the cause and have not run it on x86_64.

AI help

AI 코딩 에이전트가 코드를 작성한 후 설명을 썼고, 테스트를 마쳤습니다. diff 리뷰와 PR 초안 리뷰는 제가 직접 했습니다.

@github-actions github-actions Bot added dgx-spark The DGX Spark (aarch64, GB10) port documentation Improvements or additions to documentation gpu The card engine: crates/gpu*, crates/app host Host crates: qdot, threads, engram, engram-lab, runtime, sampler, levers, decision, vision, refset needs-gpu Changes code a card must check, and carries no gate run on a card serve The HTTP server: crates/serve tools Tools, recipes, CI and the toolchain: tools/, justfile, .github/, Cargo labels Oct 16, 2026
@github-actions github-actions Bot removed the dgx-spark The DGX Spark (aarch64, GB10) port label Oct 16, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: MUST_FIX: The change repeats shared validation and conversion logic and allocates coordinate buffers with no consumer.

Must fix

1. Reuse the media span checker

crates/gpu-gemma-embedding2/src/pass.rs:420 repeats the shape/order contract already owned by crates/gpu/src/media.rs:191 (R14).
Use the existing checker and widener so media refusals keep one owner. Replace lines 419–452 with:

bloomery_gpu::media::check_spans(PASS, spans, n, self.hp.n_embd)?;
let mut inp_scaled = self.token_rows(ids)?;
for span in spans {
    let dst = &mut inp_scaled[
        span.at.start * self.hp.n_embd..span.at.end * self.hp.n_embd
    ];
    bloomery_gpu::media::widen_bf16(span.rows, dst);
    if dst.iter().any(|v| !v.is_finite()) {
        return Err(GpuError::shape(PASS, "image row contains a non-finite value"));
    }
}

2. Own the tap conversion once

crates/gpu-gates/src/bin/shared/vision_taps.rs:151 and crates/gpu-gates/src/bin/gate_vision_encoder.rs:454 add identical F32-to-BF16 adapters (R14).
Give the existing TapSink::take_f32 method this default and delete both adapters. This removes two definitions and prevents conversion fixes from diverging:

fn take_f32(&mut self, name: &str, cols: usize, values: Vec<f32>) {
    self.take(name, cols, values.into_iter().map(crate::f32_bf16).collect());
}

3. Remove the unused coordinate arrays

crates/vision/src/arch/gemma4v/media.rs:48 computes two coordinate vectors and immediately drops them. The device computes its own coordinates.
This adds two allocations and 8 bytes per patch per image [derived]. Remove positions, its re-export and its dedicated assertions now, before callers depend on this unused API. Replace preprocess with:

pub fn preprocess(image: &Rgb8, rule: &SizeRule) -> Result<Patches, VisionError> {
    image.check()?;
    let (best_w, best_h) = rule.aligned(image.width, image.height)?;
    let (nx, ny) = rule.grid(best_w, best_h);
    if nx.checked_mul(ny).is_none_or(|n| n > rule.max_tokens()) {
        return Err(VisionError::Limits {
            detail: "gemma4v aligned canvas exceeds the image token budget".into(),
        });
    }
    let plan = GridPlan {
        n_llm_h: ny,
        n_llm_w: nx,
        best_h,
        best_w,
    };
    let resized = crate::resample::pad_ceil(image, best_w, best_h, [0; 3])?;
    let patches = crate::preprocess::patchify(
        &resized,
        plan,
        crate::preprocess::PatchLayout::row_major(rule.patch()),
    )?;
    if patches.n_vit_h > 10240 || patches.n_vit_w > 10240 {
        return Err(VisionError::Limits {
            detail: "gemma4v canvas exceeds the learned position table".into(),
        });
    }
    Ok(patches)
}

Non-blocking

⚠ Exercise HTTP image execution: crates/gpu-gates/src/side_seat/gemma_embedding2.rs:127 is bypassed by both numerical image gates; the mock test stops after parsing.
Replacing this method with an error leaves those checks green. Add an image request to the serving gate, assert its vector, and show FAIL-first for that mutant. This protects the endpoint wiring.

⚠ Supply the shared GEMM move proof: crates/gpu-vision/src/gemm_bf16.rs:95 moves the accumulator used by existing towers into a macro.
The body lists spill checks without an identical-PTX comparison. Attach just ptx-compare <base> gate_vision_encoder vision and the owning gates' results to distinguish shared-kernel regressions from new-tower failures.

Checked

Duplicates: 122 new Rust/script items checked; findings above. All 6 dryer pairs read: short preparation glue, distinct module loaders and error constructors; no extraction with fewer elements.
Complexity: new Gemma4 load/Scratch::new/encode_tapped, tower run, e2e images, pass_media, GEMM enqueue_f32, pointwise enqueue/positions and ScratchLens::new are load/validation/execution pipelines. Flags::take/read add option/projector branches; arch::open, e2e run and GLM clauses add dispatch/clause arms.
Silent failure: 10 sites followed; optional defaults are defined, malformed values reach named errors, and device accesses have checked extents.
Tests: static mutation audit covers span offsets, activation defaults, pixel/channel order, reference-device identity and scan architecture assertions; image-splice removal reaches numerical comparisons. HTTP execution gap noted above.
Gates: no numerical band relaxed; PTX and unsafe ratchet additions have dated pins.
Escape catalog: C1–C4 checked; none matched.

Notes

No build or GPU gate was run during this review. The body reports earlier heads; image e2e, GPU FAIL-first and affected-gate results remain pending.

Card check: gpu-evidence. This pull request changes crates/gpu-gates/Cargo.toml, crates/gpu-gates/src/arch/embedding.rs, crates/gpu-gates/src/bin/bloomery_serve.rs … and carries a gate run on the author's card, which counts as verified.

🔨 Shingler, bloomery's review bot: approves a clean pull request and requests changes on a must-fix (how it works)

@github-actions github-actions Bot added gpu-evidence Verified on the author's card: the pull request carries the gate run and its output and removed needs-gpu Changes code a card must check, and carries no gate run on a card labels Oct 16, 2026
Vision and the text encoders share the named tensor and shape error contracts.
Extract the existing tensor-core staging and accumulation into one macro. The BF16 entry keeps its epilogue and launch contract; no F32 entry or launcher is added here.
A media rule may return no chat placeholder. Chat expansion, per-layer token checks and decision encoding refuse that case by name; the existing image models keep their token ids.
Read the Gemma4 header, activation and pooling scale, plan its card bytes and prepare raster patches on the shared dynamic size rule. Embedding images use text delimiters and have no chat placeholder.
Keep the convolution, learned positions, residual stream and post norms in F32, with BF16 transformer linears and raster pooling. Add the F32 GEMM store and explicit F32 tap handlers; row operations expose only the modes their launcher emits.
Budget the image tower before chat placement and run its rows with ordered text parts in one embedding pass. The HTTP route and gates share serve::embeddings::assemble. Build the encoder explicitly for serving instead of enabling it for every DeepSeek gate, and refuse duplicate side flags by name.
Register the tower and server CPU families, collect image-only and text-first vectors, and keep CPU server logs apart from card logs. The collector also compares a running HTTP server with saved llama-server vectors, printing cosine and maximum absolute difference.
Check F32 convolution pixels, raster pooling, every layer and projected rows. Image-only and mix_free-448x448 inputs use the serving assembler and compare raw and normalized vectors with both server sets. Reuse the family opener and the shared CPU-twin multiplier.

PIN(2026-10-15): Remove the duplicate embd comparison because projected reads the same output buffer; the projected comparison keeps its band.
Document the image side seat, input parts, reserve and reference gates, with a command for comparing a running HTTP server to saved llama-server vectors.
Allow the architecture spellings whose occupancy limits ptx-scan already defines. This changes no server collection path.
Carry the measured Gemma4 entry rows for the default table and both architecture-specific PTX tables.
Regenerate the gate path table against the embedding seat's current encoder and keep the no-argument e2e recipe in step with its binary. Format the merged reference families and shared header fixture.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: CLEAN: No must-fix defect survived the span, buffer, numeric and error-path checks.

Non-blocking

⚠ Complete the pending GPU evidence: crates/gpu-gates/src/bin/gate_gemma_embedding2_e2e.rs:345.
The body still marks image end-to-end, GPU FAIL-first and affected-gate runs as pending. Add their commands, heads and final output. Recording the results now makes later regressions attributable.

⚠ Show the shared GEMM move proof: crates/gpu-vision/src/gemm_bf16.rs:94.
The extracted accumulator still feeds the existing towers' vis_gemm_bf16, with no launch-count change. The move class requires unchanged PTX for existing entries. Attach a base/head scan or canonical comparison; spill counts alone do not establish equivalence.

Optimization headroom

  • G. Per-launch and per-sync fixed cost: crates/gpu-vision/src/arch/gemma4v.rs:609. Compute the group mean inside the existing pooling gather, preserving its rounding. Removing the intermediate buffer saves its BF16 write and read: 9 × 768 × 2 × 2 = 27,648 bytes per pooled token at width 768, plus one launch per image [derived].

Checked

Duplicates: 120 new declarations Grepped. All six dryer pairs read: short preparation adapters, module loaders and distinct error constructors; no extraction that reduces net elements.
Complexity: new Gemma4 load/encode_tapped/Scratch::new, tower::run, e2e::images, pass_media, ScratchLens::new, GEMM enqueue_f32 and pointwise::{enqueue,positions} contain load, stage or validation branches. Flags::{take,read} add image/context handling. arch::open, e2e::run and GLM clauses add dispatch or stages. No complexity-only finding.
Silent failure: 11 candidate sites traced to consumers; invalid inputs receive named refusals or guards prove the skipped case impossible.
Tests: static mutation review covers lost model flags, zeroed image offsets, eager image preparation, changed activation, wrong CPU family, deleted span checks and restored architecture refusal. Their assertions detect those mutations. GPU FAIL-first execution remains pending in the body.
Gates: dated reasons accompany PTX pins, unsafe-count increases and the scan architecture change. No numerical band relaxed.
Escape catalog: no C1–C4 match in added code.
Adversarial checks: empty and oversized inputs, overlapping spans, non-finite weights, missing chat tokens and partial output windows are refused or bounded. Independent gather/permutation and scratch-byte checks passed.

Notes

Static review only; no Rust build or GPU gate was run here.

Card check: gpu-evidence. This pull request changes crates/gpu-gates/Cargo.toml, crates/gpu-gates/src/arch/embedding.rs, crates/gpu-gates/src/bin/bloomery_serve.rs … and carries a gate run on the author's card, which counts as verified.

🔨 Shingler, bloomery's review bot: approves a clean pull request and requests changes on a must-fix (how it works)

@juliankang4

Copy link
Copy Markdown
Collaborator Author

The review is done and the contribution check passed, so I'm merging this.

@juliankang4
juliankang4 merged commit 6d4113c into midagedev:main Oct 16, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation gpu The card engine: crates/gpu*, crates/app gpu-evidence Verified on the author's card: the pull request carries the gate run and its output host Host crates: qdot, threads, engram, engram-lab, runtime, sampler, levers, decision, vision, refset serve The HTTP server: crates/serve tools Tools, recipes, CI and the toolchain: tools/, justfile, .github/, Cargo

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant