Skip to content

fix(models): suppress gemma4_unified multimodal placeholder tokens - #351

Merged
inureyes merged 3 commits into
mainfrom
fix/350-gemma4-suppress-placeholder-tokens
Jun 18, 2026
Merged

inureyes merged 3 commits into
mainfrom
fix/350-gemma4-suppress-placeholder-tokens

Conversation

@inureyes

Copy link
Copy Markdown
Member

Summary

gemma4_unified (e.g. gemma-4-12b-it-4bit) reserves multimodal placeholder ids (audio/image/video span markers) for INPUT alignment only, but mlxcel did not mask them in the output logits. At a near-tie greedy decode step one could become the argmax and leak into generated text (a stray <audio|> mid-sentence on the "hash table" prompt). Through /v1/chat/completions the server strips special tokens, so a leak-heavy generation returned EMPTY content with finish_reason=length. This masks those ids to -inf at every decode step via the existing per-step token-bias mechanism, with a generic helper so other multimodal families can opt in later.

What changed

  • LanguageModel::output_suppressed_token_ids() (new trait method, default empty): the per-model "never generate these" set, mirroring eos_token_ids(). Non-multimodal models inherit the empty default and pay zero cost.
  • TokenBiasMap::suppress_tokens(ids): force-masks each id to f32::NEG_INFINITY (suppression always wins over any prior finite bias); empty slice is a no-op that preserves the bit-exact baseline.
  • models::MultimodalPlaceholderTokens: a family-agnostic collector that, given the config *_token_id / *_token_index fields, returns the deduplicated, non-negative id set. Gemma4UnifiedConfig::placeholder_tokens() builds it from the parsed config, and the Gemma4UnifiedModel LanguageModel impl returns suppressed_ids() from its stored ids. LoadedModel delegates the new trait method.
  • Wiring (scoped to gemma4_unified via the trait override): CLI run_generation_mode merges the set into the token-bias map before any generator is built; server BatchScheduler resolves it once in with_config and force-applies it on every sequence's sampling config in enqueue_request.

Where suppression is applied (both decode paths)

  • CLI generate: run_generation_mode merges model.output_suppressed_token_ids() into the token-bias map at the top, so it reaches the standard, VLM-embedding, and speculative sub-paths (all consume that map via with_token_bias).
  • Server decode: enqueue_request applies the resolved set to each sequence's SamplingConfig.token_bias after the lang-bias merge and unconditionally, so it covers classic decode and the MTP burst (both sample through sample_token_optimized, which applies config.token_bias). Suppression is added last, so a per-request bias can never displace it.

Derivation and safety

The suppressed set is derived from config (audio_token_id, image_token_id, video_token_id, boa_token_id, boi_token_id, eoa_token_id/eoa_token_index, eoi_token_id), never hardcoded as raw integers in the wiring. The real EOS ids (eos_token_id [1, 106, 50]) are not part of the placeholder struct, so end-of-sequence detection and normal text generation are unaffected. Non-multimodal models return an empty set, so apply_token_bias keeps its zero-overhead short-circuit.

Test plan

  • cargo test --release -p mlxcel-core sampling::tests::suppress (2 tests: -inf + zero softmax probability, empty no-op)
  • cargo test --release -p mlxcel --lib -- multimodal_placeholders gemma4_unified_config (14 tests incl. output_suppression_masks_placeholders_but_not_eos: placeholder ids masked to -inf, EOS [1,106,50] untouched)
  • cargo clippy -p mlxcel-core --lib --tests -- -D warnings and cargo clippy -p mlxcel --lib --tests -- -D warnings (clean)
  • cargo fmt --check (clean)
  • Real-model validation (orchestrator): "hash table" prompt coherent and non-empty via mlxcel generate -t 0 AND /v1/chat/completions; no regression on clean prompts ("list three primary colors", "write one sentence about the moon").

Closes #350

gemma4_unified (e.g. gemma-4-12b-it-4bit) reserves audio/image/video span placeholder ids (audio_token_id 258881, image_token_id 258880, boi_token_id 255999, boa_token_id 256000, eoi_token_id 258882, eoa 258883, video_token_id 258884) purely for INPUT alignment. mlxcel did not mask them in the output logits, so at a near-tie greedy step one could become the argmax and leak into generated text (a stray <audio|> mid-sentence). Through /v1/chat/completions the server strips special tokens, so a leak-heavy generation returned EMPTY content with finish_reason=length.

Add a generic, opt-in suppression mechanism and wire it for gemma4_unified only:

- LanguageModel gains output_suppressed_token_ids() (default empty), the per-model "never generate these" set, mirroring eos_token_ids(). Non-multimodal models return empty and pay zero cost.
- TokenBiasMap::suppress_tokens(ids) force-masks each id to f32::NEG_INFINITY (suppression always wins over any prior finite bias). Empty slice is a no-op, preserving the bit-exact baseline.
- models::MultimodalPlaceholderTokens is a family-agnostic collector: given the config *_token_id / *_token_index fields it returns the deduplicated, non-negative id set. Gemma4UnifiedConfig::placeholder_tokens() builds it from the parsed config, and the Gemma4UnifiedModel LanguageModel impl returns suppressed_ids() from its stored ids.

The set is derived from config, never hardcoded, and excludes the real EOS ids (eos_token_id [1,106,50]), so end-of-sequence detection and normal text are unaffected.

Applied at generation time on both decode paths: the CLI generate path merges it into the token-bias map at the top of run_generation_mode (covering the standard, VLM-embedding, and speculative sub-paths), and the server scheduler resolves it once in with_config and force-applies it on every sequence's sampling config in enqueue_request (covering classic decode and the MTP burst, which both sample through sample_token_optimized). Both apply after any lang-bias merge so suppression cannot be displaced by a per-request bias.

Tests: TokenBiasMap::suppress_tokens (-inf + zero softmax probability, empty no-op); MultimodalPlaceholderTokens derivation (dedup, negative-id drop, unset-field skip, the seven gemma-4-12b ids); and a gemma4_unified config acceptance test asserting the placeholder ids are masked to -inf while EOS ids [1,106,50] stay untouched.
@inureyes inureyes added type:bug Bug fixes, error corrections, or issue resolutions priority:medium Medium priority area:models Model architectures, weights, loading, metadata area:inference Generation, sampling, decoding (incl. speculative, DRY) status:review Under review labels Jun 18, 2026
@inureyes

Copy link
Copy Markdown
Member Author

Implementation Review Summary

Intent

Suppress gemma4_unified multimodal placeholder ids (audio/image/video/boa/boi/eoa/eoi) in the output logits so they can never leak into generated text (#350).

Findings Addressed

  • No code changes were required. The implementation is correct and complete for the issue's stated scope; no CRITICAL or HIGH findings.

Remaining Items (non-blocking, out of #350 scope)

  • (MEDIUM) CLI pipeline-parallel path bypasses suppression. pipeline_requested (src/commands/generate.rs:1280) routes to generate_pipeline_text instead of run_generation_mode, so the placeholder set is never merged there. The path operates on raw layers with no LoadedModel handle, so wiring it needs a separate config read. Single-node generate and the server are the issue's scope and are covered.
  • (MEDIUM) Distributed-handoff sequence builders do not apply the suppressed set: prefill_text_request_for_handoff (src/server/batch/scheduler.rs:604) and ingest_handoff_as_active (:699) construct a SequenceInfo without suppress_tokens(&self.model_output_suppressed). Both are #[allow(dead_code)] today (not in the live request flow), so there is no current leak, but they should mirror the enqueue_request block when the disaggregated path (End-to-end validation, parity, benchmarks, and docs #126) goes live.
  • (LOW) Gemma4UnifiedConfig::placeholder_tokens() is only exercised by tests; the runtime path rebuilds the set from the model's stored ids in Gemma4UnifiedModel::output_suppressed_token_ids(), so the two 7-field lists can drift over time. Consider having the model reuse the config builder (or store a MultimodalPlaceholderTokens).

Verification

  • All stated requirements implemented: suppressed set is exactly the 7 placeholder ids; real EOS [1,106,50] is excluded by construction (no EOS field in the struct); negative ids dropped; result deduped/sorted.
  • No placeholder/mock code remaining.
  • Integrated into project code flow: CLI standard + VLM-embedding + speculative sub-paths (via with_token_bias, with sampling_config.token_bias empty so the cached map wins); server classic decode + MTP burst (both sample seq.sampling through sample_token_optimized). Both production BatchScheduler construction sites route through with_config.
  • Suppression always wins: suppress_tokens force-inserts NEG_INFINITY; applied last and unconditionally in enqueue_request (after the lang-bias merge) and overwrites any prior finite bias in the shared map.
  • Zero cost when empty: non-multimodal models inherit the empty default; apply_token_bias and the enqueue/with_config paths short-circuit on an empty set. EOS still terminates generation for gemma4_unified.
  • Generic but scoped: MultimodalPlaceholderTokens is config-driven and family-agnostic; only gemma4_unified overrides the trait.
  • Conventions: _tests.rs split, trait method mirrors eos_token_ids(), no AI attribution, no em dashes, all refs are same-repo.
  • Tests pass: 16 targeted tests green (2 in mlxcel-core sampling::, 14 in mlxcel --lib, incl. output_suppression_masks_placeholders_but_not_eos).

Real-model validation (hash-table prompt non-empty/coherent via CLI and /v1/chat/completions, plus clean-prompt no-regression) is left to the orchestrator's full release build.

PR #351 masks gemma4_unified multimodal placeholder ids to -inf via the per-step token bias, but two generation paths bypassed that bias and could still leak a placeholder into generated text (issue #350).

The server MTP speculative-burst verify step derived target tokens with a raw argmax (Gemma4MtpTargetAdapter::verify_forward, the deferred argmax_from_hidden_positions path, and the batched verify_forward_batched), so only the first bonus token (sampled through sample_token_optimized) was suppressed and every later token could leak. The suppression set is already present in the sampler forwarded from enqueue_request through MtpGenerator, so verify now applies apply_token_bias to the logits before the per-position argmax (and before logprob extraction, so the emitted token and its logprob stay consistent). An empty bias short-circuits, keeping non-multimodal models bit-exact.

The interactive chat path (mlxcel generate with no -p, and mlxcel run) built its CxxGenerator without merging the model's suppressed set, so chat replies could leak. run_chat now attaches the suppression bias via with_token_bias, mirroring run_generation_mode.

apply_token_bias is promoted from pub(crate) to pub so the MTP target adapter in the binary crate can reuse the existing masking helper.

Tests: B=1 verify argmax re-routes a leaking position and is a no-op on an empty bias; batched verify argmax re-routes per row. Validated with cargo check (lib + bins, release), targeted unit tests, and clippy on both crates. Real-model MTP/CLI/server validation is pending.
@inureyes

Copy link
Copy Markdown
Member Author

Implementation Review Update (supersedes the Verification section of my earlier comment)

A deeper pass plus the Codex reviewer found two CRITICAL leak paths that my first comment incorrectly marked as covered. Both are now fixed on this branch.

CRITICAL findings (fixed)

  1. MTP speculative-burst verify bypassed suppression. Gemma4MtpTargetAdapter::verify_forward (and the deferred argmax_from_hidden_positions path and the batched verify_forward_batched) derived target tokens with a RAW argmax that never applied the suppression bias. Only the first bonus token (sampled via sample_token_optimized) was masked, so every later token on the server MTP path could still emit a placeholder. This is the exact scenario fix(models): gemma4_unified leaks multimodal placeholder tokens (audio/image/video) into text generation #350 was surfaced in (perf(inference): adaptive MTP enable/decline policy from per-pair profiling #333 / PR perf(server): adaptive B=1 MTP enable/decline policy from profiling #348 MTP validation with gemma-4-12b-it). Fix: apply the suppression bias (already carried in the sampler forwarded from enqueue_request through MtpGenerator) to the verify logits before the per-position argmax and before logprob extraction, so the emitted token and its logprob stay consistent. An empty bias short-circuits, so non-multimodal models stay bit-exact.

  2. Interactive chat bypassed suppression. mlxcel generate with no -p (and mlxcel run) routes through run_generate to run_chat, which built its CxxGenerator without merging the model's suppressed set, so chat replies could leak. Fix: run_chat now attaches the bias via with_token_bias, mirroring run_generation_mode.

apply_token_bias was promoted from pub(crate) to pub so the MTP target adapter (binary crate) can reuse the existing masking helper. Three unit tests added: B=1 verify leak re-route, empty-bias no-op, batched per-row re-route.

Verification

  • cargo check lib + bins (release), cargo test MTP (gemma4_mtp, 35 passed) and core sampling (46 passed), clippy on both crates: all pass.
  • Committed on this branch as ea0a4aa7b (held local, pending push by the orchestrator after the gate below).
  • STILL OWED (orchestrator): real-model validation, specifically an MTP-enabled run of gemma-4-12b-it plus the hash-table prompt via CLI and /v1/chat/completions (no leak, non-empty, coherent) and no regression on clean prompts. The MTP fix only changes the argmax at a step where a placeholder would otherwise have won, so legitimate tokens are unaffected by construction, but a greedy-parity spot check is still worthwhile.

Non-blocking (unchanged)

Add a comment to `argmax_logits_to_array` in the DFlash verify round-loop
noting that the raw argmax carries no token bias. Currently safe because only
gemma4_unified has a non-empty suppressed-id set and it runs on the MTP path,
not DFlash. If a future model adopts DFlash verify with a non-empty
suppressed-id set, the argmax must be preceded by `apply_token_bias` to
prevent reintroducing the #350 leak.

Also reformats a long `assert_eq!` in gemma4_mtp_target_tests.rs to satisfy
`cargo fmt`.
@inureyes

Copy link
Copy Markdown
Member Author

PR Finalization

Tests

No gaps found. The existing 16 suppression tests cover the suppressed-set derivation, EOS exclusion, NEG_INFINITY masking, and the 3 MTP verify-path cases. No new tests were needed.

Documentation

No doc updates warranted. This is a correctness fix with no user-facing surface; there is no existing known-issues or model-support section that applies.

Lint / Format

  • cargo fmt --check: one long assert_eq! in gemma4_mtp_target_tests.rs exceeded the line limit. Reformatted to multi-line. Now clean.
  • cargo clippy -p mlxcel-core --lib --tests -- -D warnings: clean.
  • cargo clippy -p mlxcel --lib --tests -- -D warnings: clean.

Latent-gap comment

Added a comment to argmax_logits_to_array in src/lib/mlxcel-core/src/drafter/dflash/round_loop.rs (the DFlash speculative verify round-loop). The function performs a raw argmax with no token bias. That is safe today because only gemma4_unified has a non-empty output_suppressed_token_ids set, and it runs on the MTP path, not DFlash. The comment notes that if a future model adopts DFlash verify with a non-empty suppressed-id set, this argmax must be preceded by apply_token_bias to prevent reintroducing the #350 leak.

Commit: 67336e0bc

@inureyes

Copy link
Copy Markdown
Member Author

Real-model validation (orchestrator)

Placeholder suppression validated on M1 Ultra:

  • CLI mlxcel generate (run_generation_mode path), 4 prompts including the "What is a hash table?" prompt that previously leaked <audio|> mid-sentence: all coherent, NO placeholder leak.
  • Server /v1/chat/completions with MTP forced on (the verify path the reviewer fixed), one-sentence hash-table prompt, adequate token budget: non-empty, coherent, no leak.

Root-cause note on the earlier "empty content" symptom (it is NOT a placeholder issue): gemma-4-12b-it is a thinking model by design. The server detects its <|channel>thought/<channel|> markers and defaults enable_thinking=true (intentional, upstream PR #1114). With thinking on, the model spends most of a small max_tokens budget inside the thinking block, so max_tokens=50-80 truncates mid-thinking and the answer content is empty (finish_reason=length). With an adequate budget the answer is coherent: the one-sentence prompt finished in 275 tokens (finish=stop). The earlier #333 / initial #350 "empty" results were under-budgeted tests of a thinking model, not a leak.

pr-reviewer fixed two CRITICAL leak paths it found (MTP speculative verify and interactive chat), pr-security-checker confirmed MTP exactness consistency (verify applies the same suppression as classic), and 19 tests pass (16 suppression + 3 MTP verify). The #350 placeholder leak is fixed and validated.

Minor observations (out of #350 scope, not regressions): gemma-4-12b-it thinking is verbose (a three-sentence request did not finish within 800 tokens), and reasoning_content is not populated when thinking occurs. Neither affects answer correctness; worth a separate look if desired.

@inureyes inureyes added status:done Completed and removed status:review Under review labels Jun 18, 2026
@inureyes
inureyes merged commit 6da6480 into main Jun 18, 2026
5 checks passed
@inureyes
inureyes deleted the fix/350-gemma4-suppress-placeholder-tokens branch June 18, 2026 09:17
@inureyes inureyes added this to the 0.3 milestone Jun 21, 2026
@inureyes inureyes self-assigned this Aug 31, 2026
be-free-snowboy pushed a commit to freestylejs/snowbrain-mlxcel that referenced this pull request Oct 27, 2026
…ablup#351)

* fix(models): suppress gemma4_unified multimodal placeholder tokens

gemma4_unified (e.g. gemma-4-12b-it-4bit) reserves audio/image/video span placeholder ids (audio_token_id 258881, image_token_id 258880, boi_token_id 255999, boa_token_id 256000, eoi_token_id 258882, eoa 258883, video_token_id 258884) purely for INPUT alignment. mlxcel did not mask them in the output logits, so at a near-tie greedy step one could become the argmax and leak into generated text (a stray <audio|> mid-sentence). Through /v1/chat/completions the server strips special tokens, so a leak-heavy generation returned EMPTY content with finish_reason=length.

Add a generic, opt-in suppression mechanism and wire it for gemma4_unified only:

- LanguageModel gains output_suppressed_token_ids() (default empty), the per-model "never generate these" set, mirroring eos_token_ids(). Non-multimodal models return empty and pay zero cost.
- TokenBiasMap::suppress_tokens(ids) force-masks each id to f32::NEG_INFINITY (suppression always wins over any prior finite bias). Empty slice is a no-op, preserving the bit-exact baseline.
- models::MultimodalPlaceholderTokens is a family-agnostic collector: given the config *_token_id / *_token_index fields it returns the deduplicated, non-negative id set. Gemma4UnifiedConfig::placeholder_tokens() builds it from the parsed config, and the Gemma4UnifiedModel LanguageModel impl returns suppressed_ids() from its stored ids.

The set is derived from config, never hardcoded, and excludes the real EOS ids (eos_token_id [1,106,50]), so end-of-sequence detection and normal text are unaffected.

Applied at generation time on both decode paths: the CLI generate path merges it into the token-bias map at the top of run_generation_mode (covering the standard, VLM-embedding, and speculative sub-paths), and the server scheduler resolves it once in with_config and force-applies it on every sequence's sampling config in enqueue_request (covering classic decode and the MTP burst, which both sample through sample_token_optimized). Both apply after any lang-bias merge so suppression cannot be displaced by a per-request bias.

Tests: TokenBiasMap::suppress_tokens (-inf + zero softmax probability, empty no-op); MultimodalPlaceholderTokens derivation (dedup, negative-id drop, unset-field skip, the seven gemma-4-12b ids); and a gemma4_unified config acceptance test asserting the placeholder ids are masked to -inf while EOS ids [1,106,50] stay untouched.

* fix(inference): suppress placeholders on MTP verify and chat paths

PR lablup#351 masks gemma4_unified multimodal placeholder ids to -inf via the per-step token bias, but two generation paths bypassed that bias and could still leak a placeholder into generated text (issue lablup#350).

The server MTP speculative-burst verify step derived target tokens with a raw argmax (Gemma4MtpTargetAdapter::verify_forward, the deferred argmax_from_hidden_positions path, and the batched verify_forward_batched), so only the first bonus token (sampled through sample_token_optimized) was suppressed and every later token could leak. The suppression set is already present in the sampler forwarded from enqueue_request through MtpGenerator, so verify now applies apply_token_bias to the logits before the per-position argmax (and before logprob extraction, so the emitted token and its logprob stay consistent). An empty bias short-circuits, keeping non-multimodal models bit-exact.

The interactive chat path (mlxcel generate with no -p, and mlxcel run) built its CxxGenerator without merging the model's suppressed set, so chat replies could leak. run_chat now attaches the suppression bias via with_token_bias, mirroring run_generation_mode.

apply_token_bias is promoted from pub(crate) to pub so the MTP target adapter in the binary crate can reuse the existing masking helper.

Tests: B=1 verify argmax re-routes a leaking position and is a no-op on an empty bias; batched verify argmax re-routes per row. Validated with cargo check (lib + bins, release), targeted unit tests, and clippy on both crates. Real-model MTP/CLI/server validation is pending.

* chore: add lablup#350 latent-gap note to DFlash argmax; fix fmt

Add a comment to `argmax_logits_to_array` in the DFlash verify round-loop
noting that the raw argmax carries no token bias. Currently safe because only
gemma4_unified has a non-empty suppressed-id set and it runs on the MTP path,
not DFlash. If a future model adopts DFlash verify with a non-empty
suppressed-id set, the argmax must be preceded by `apply_token_bias` to
prevent reintroducing the lablup#350 leak.

Also reformats a long `assert_eq!` in gemma4_mtp_target_tests.rs to satisfy
`cargo fmt`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:inference Generation, sampling, decoding (incl. speculative, DRY) area:models Model architectures, weights, loading, metadata priority:medium Medium priority status:done Completed type:bug Bug fixes, error corrections, or issue resolutions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(models): gemma4_unified leaks multimodal placeholder tokens (audio/image/video) into text generation

1 participant