Skip to content

Studio: apply presence_penalty on the safetensors and MLX inference paths - #6923

Merged
danielhanchen merged 4 commits into
mainfrom
danielhanchen/studio-presence-penalty-parity
Jul 7, 2026
Merged

danielhanchen merged 4 commits into
mainfrom
danielhanchen/studio-presence-penalty-parity

Conversation

@danielhanchen

Copy link
Copy Markdown
Member

Summary

Same-repo re-creation of #6782 (which was opened from a fork and could not merge cross-repo). Recreated onto current main, with #6782's net changes reapplied and conflicts resolved against the refactored inference paths.

The GGUF backend applies presence_penalty (Qwen3.5/3.6 recommend 1.5 in inference_defaults.json), but the transformers safetensors path and the MLX path resolved the same inference config and then dropped the value before generation. The parameter was missing from the orchestrator command, the worker gen_kwargs, and the safetensors/MLX generate paths, and transformers .generate() has no native presence_penalty. So the same model applied the configured value as GGUF and 0 as safetensors/MLX, which shows up as more repetition on prompts like "Create a Flappy Bird game in HTML".

This threads the already-resolved presence_penalty through to the safetensors and MLX generate calls and applies it with a small logits processor. It is backwards compatible: presence_penalty defaults to 0.0 (byte-identical output when unset) and the GGUF path is unchanged.

Changes

  • core/inference/presence_penalty.py (new): apply_presence_penalty and a LogitsProcessor factory (OpenAI/llama.cpp semantics: subtract the penalty once from each distinct completion token, prompt excluded, presence not frequency, zero is a no-op, negatives raise). Dependency-light leaf module so the logic is unit-testable without the full backend.
  • core/inference/inference.py: thread presence_penalty through generate_chat_response, generate_chat_completion_with_tools, the vision path, and generate_stream, and attach the processor at both model.generate() sites (text and vision).
  • core/inference/orchestrator.py and core/inference/worker.py: carry presence_penalty in the generate command and worker gen_kwargs so the resolved request value reaches the subprocess backend.
  • core/inference/mlx_inference.py: matching presence-penalty logits processor for the MLX text and vision paths (on-device scatter-assign, idempotent for duplicate ids).
  • routes/inference.py: forward presence_penalty on the safetensors chat branches (tool, plain), and add presence_penalty plus min_p to the legacy /generate/stream route.
  • models/inference.py: add the missing min_p field to GenerateRequest (the chat route already passed min_p; the stream route dropped it).

Tests

studio/backend/tests/test_presence_penalty.py:

  • Unit tests for apply_presence_penalty and the processor: seen token gets exactly -penalty, unseen unchanged, multiplicity ignored, negatives raise, batch rows isolated, prompt excluded, zero is a no-op, dtype/device preserved, composes with an existing processor.
  • MLX callable test (skipped off an MLX host).
  • A param-propagation regression that fails if any of temperature, top_p, top_k, min_p, repetition_penalty, presence_penalty is dropped across the route -> orchestrator command -> worker gen_kwargs boundary.
PYTHONPATH=studio/backend python -m pytest -q studio/backend/tests/test_presence_penalty.py
14 passed, 1 skipped

Supersedes #6782.

…aths

The safetensors and MLX generate paths resolved the inference config and
then dropped presence_penalty before generation, so the same model applied
the configured value under GGUF and 0 under safetensors/MLX. Thread the
already-resolved presence_penalty through the orchestrator command, worker
gen_kwargs, and the safetensors/MLX generate calls, and apply it with a
small logits processor (subtract once per distinct completion token,
prompt excluded, presence not frequency, zero is a no-op, negatives raise).

Backwards compatible: presence_penalty defaults to 0.0 (byte-identical
output when unset) and the GGUF path is unchanged. Also forward min_p on
the legacy /generate/stream route and add the missing min_p field to
GenerateRequest.

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request introduces presence penalty support for the safetensors and MLX inference paths to ensure parity with GGUF. It adds a new presence_penalty.py module with logits processors for PyTorch, implements a corresponding processor for MLX, propagates the parameter across the orchestrator, worker, and routes, and adds comprehensive unit tests. The review feedback highlights two important robustness improvements: defensively filtering out negative token IDs in PyTorch to prevent incorrect negative indexing, and safely clamping/masking indices in MLX to avoid undefined behavior or crashes from out-of-bounds tokens.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +26 to +27
seen = torch.unique(generated)
seen = seen[seen < vocab_size]

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.

medium

If generated contains negative token IDs (which can sometimes occur for padding or special tokens like -1 or -100), seen will also contain negative values. In PyTorch, indexing with negative values (e.g., scores[b, seen]) will index from the end of the tensor, incorrectly penalizing the last tokens in the vocabulary.

We should defensively filter out negative token IDs by ensuring seen >= 0.

Suggested change
seen = torch.unique(generated)
seen = seen[seen < vocab_size]
seen = torch.unique(generated)
seen = seen[(seen >= 0) & (seen < vocab_size)]

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good catch. Fixed in 87ad7b4 with exactly this: seen = seen[(seen >= 0) & (seen < vocab_size)], so a stray negative or out-of-range id is dropped before scores[b, seen] instead of wrapping to the wrong row. Real completion ids are always in range, so zero regression on the normal path.

Comment on lines +61 to +62
# Scatter-assign is idempotent for duplicate ids: presence applies once per token, on-device.
logits[:, generated] = logits[:, generated] - penalty

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.

medium

In MLX, indexing out of bounds is explicitly documented as undefined behavior. If generated contains any out-of-bounds token IDs (either negative or >= vocab_size), indexing logits[:, generated] can cause a crash or memory corruption on Apple Silicon.

Since MLX does not support boolean mask indexing for reading, we can use mx.clip to safely clamp the indices to [0, vocab_size - 1], and then multiply the penalty by a boolean mask cast to the logits' dtype to ensure out-of-bounds tokens are not penalized.

Suggested change
# Scatter-assign is idempotent for duplicate ids: presence applies once per token, on-device.
logits[:, generated] = logits[:, generated] - penalty
import mlx.core as mx
vocab_size = logits.shape[-1]
in_bounds = (generated >= 0) & (generated < vocab_size)
safe_generated = mx.clip(generated, 0, vocab_size - 1)
# Scatter-assign is idempotent for duplicate ids: presence applies once per token, on-device.
logits[:, safe_generated] = logits[:, safe_generated] - penalty * in_bounds.astype(logits.dtype)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 87ad7b4. MLX disallows data-dependent shapes (boolean-mask filtering to a smaller array), so instead of clip+mask I route every out-of-range or negative id to a scratch slot at index vocab via mx.where(valid, generated, vocab), scatter-subtract into a (vocab+1)-wide penalty buffer, then apply buffer[:vocab] to the logits. Only in-range ids are penalized and nothing indexes out of bounds; the shape stays static.

@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a0f0d7b8d2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

return scores
vocab_size = scores.shape[-1]
for b in range(input_ids.shape[0]):
generated = input_ids[b, prompt_len:]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Include prompt tokens in presence penalty history

When presence_penalty > 0 and the prompt/history already contains a token, slicing from prompt_len means the first generated token is not penalized for repeating prompt tokens, and later steps only consider completion tokens. That leaves the safetensors path diverging from the GGUF/OpenAI behavior for prompts that try to discourage terms already present in context; the penalty should be based on the full token history available before sampling, not only tokens emitted after this request starts generating.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Thanks for the careful read. We checked this against the reference this PR targets. llama.cpp applies the presence penalty over a penalty_last_n window (default 64) whose ring buffer is populated by common_sampler_accept on sampled tokens only; llama-server does not seed prompt tokens into it, and our GGUF path passes presence_penalty without setting penalty_last_n. So the reference effectively penalizes generated tokens, not the prompt, which is exactly what the safetensors and MLX paths do via prompt_len. OpenAI's "tokens that appear in the text so far" wording is not defined as prompt+completion, and the stated intent is steering the response toward new topics. Basing the penalty on full history would match neither reference and would penalize every distinct token in a long user prompt, distorting logits in the long-context case. Keeping prompt exclusion, which is documented and consistent across the safetensors, MLX, and GGUF paths. Declining this one.

danielhanchen and others added 2 commits July 7, 2026 04:31
…both paths

The presence-penalty logits processors index by generated token ids. The
torch path filtered only the upper bound (seen < vocab_size), so a negative
id would silently wrap to the wrong row; the MLX path had no bound at all,
and MLX out-of-bounds indexing is documented undefined behavior (crash or
memory corruption on Apple Silicon), unlike torch's harmless negative wrap.

Bound generated ids to [0, vocab) consistently on both paths:
- torch: seen[(seen >= 0) & (seen < vocab_size)] (zero-regression safety net;
  real completion tokens are always in range).
- MLX: route out-of-range/negative ids to a discarded scratch slot via
  mx.where and a (vocab + 1)-wide scatter-assign mask, then subtract. MLX has
  no boolean-mask filtering (data-dependent output shape), so this keeps a
  fixed shape, stays on-device, and preserves once-per-distinct-token
  semantics without any torch/numpy dependency.

Add torch tests for out-of-range and negative ids (only in-range distinct
ids penalized, stray ids ignored, no wrong-index wrap) and a bound-documenting
MLX test that runs on the arm64 macOS CI.
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Jul 7, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Jul 7, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Jul 7, 2026
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🎉

Reviewed commit: 87021ea190

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Jul 7, 2026
@danielhanchen
danielhanchen merged commit 5608081 into main Jul 7, 2026
49 checks passed
@danielhanchen
danielhanchen deleted the danielhanchen/studio-presence-penalty-parity branch July 7, 2026 05:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant