Skip to content

Studio: launch llama-server with --backend-sampling on CUDA when it does not speculate - #14409

Open
danielhanchen wants to merge 7 commits into
mainfrom
backend-sampling-studio
Open

danielhanchen wants to merge 7 commits into
mainfrom
backend-sampling-studio

Conversation

@danielhanchen

Copy link
Copy Markdown
Member

Summary

Launch llama-server with --backend-sampling (llama.cpp's GPU sampler) on CUDA GPU launches that do not speculate. With it the per-slot sampler chain runs inside the decode graph, so a decode step no longer copies every slot's full-vocabulary logits to the host and walks them on the CPU.

Why

A Colab L4 profile of a 4-slot server (-c 8192 -np 4 --kv-unified) showed the GPU idle for about 2.1 ms of a 7.4 ms decode step, about 1.3 ms of it CPU sampling. On the same L4, with Studio's own sampler chains, 4 slots, interleaved rounds:

CPU sampling --backend-sampling
Qwen3.5-0.8B, 4-stream ITL 20.3 ms 7.0 ms
Qwen3.5-0.8B, 1-stream ITL 8.1 ms 4.9 ms
Qwen3.5-0.8B, flood 0.5 req/s 154 tok/s 341 tok/s
Qwen3-0.6B, 4-stream ITL 7.25 ms 6.05 ms
TTFT per new request baseline +16 to 27 ms (llama.cpp re-reserves its scheduler per request)

A/A noise on ITL was 0.1 ms (Qwen3) and 0.9 ms (Qwen3.5).

Correctness

  • llama.cpp runs logit_bias, top_k, top_p, min_p, temperature, greedy and dist on the GPU, and penalties when each step emits one token per slot. Anything else (DRY, XTC, mirostat, grammar, a reasoning budget, pre-sampling logprobs) falls back to the CPU per request, without an error.
  • Same logits and seed give the same token: 32,400 of 32,400 seeded draws identical across six Studio chains and two models (L4 and B200); the reported filtered distribution matches a float64 re-implementation of the CPU chain within 1e-6; greedy output identical for every chain.

When the flag is added

All of: the binary's --help advertises it (fails closed, so older builds launch exactly as before); a GPU is detected; the build has the CUDA backend (ROCm, SYCL and Vulkan are not measured yet); not macOS; the model is not placed on the CPU; no speculative decoding (penalties cannot run on the GPU when a step verifies several tokens, and there was no gain beside drafts); the user has not passed the flag or LLAMA_ARG_BACKEND_SAMPLING; UNSLOTH_LLAMA_BACKEND_SAMPLING=0 is not set.

Studio's Auto mode adds --spec-default for a plain GGUF, so today this engages when speculation is off or stands down.

Testing

  • studio/backend/tests/test_backend_sampling_launch.py: 47 tests (capability probe, every gate, speculation detection, end-to-end launch argv). On main the file does not import; 8 behaviour tests fail against main's launch.
  • 18 related launch and capability test files (before the CUDA-only gate, which only narrows it): 1812 passed; the 9 failures are the same test IDs that fail on main (test_capability_detection.py).

llama.cpp can run the per-slot sampler chain inside the decode graph
(--backend-sampling). Without it every decode step copies each slot's
full-vocabulary logits to the host and walks them on the CPU.

Colab L4, -c 8192 -np 4 --kv-unified, Studio's own sampler chains:
- Qwen3.5-0.8B (presence 1.5): 20.3 -> 7.0 ms per token at 4 streams,
  8.1 -> 4.9 ms at 1 stream, flood throughput 154 -> 341 tok/s
- Qwen3-0.6B: 7.3 -> 6.1 ms at 4 streams, 4.4 -> 4.1 ms at 1 stream
- cost: ~20-25 ms more TTFT per request (llama.cpp re-reserves its
  scheduler for each new sampler)
With identical logits and seed it draws the same tokens as the CPU chain.

The flag is per request in llama-server and steps aside on its own for
grammar / tools, a reasoning budget and pre-sampling logprobs. Studio
adds it only when the binary advertises it (new probe key
supports_backend_sampling, fails closed), a GPU holds the output layer,
the backend is not Vulkan or macOS, and no speculative decoding runs
(penalties cannot run on the GPU when a step verifies several tokens per
slot, and there it measured no gain). A user's own flag and
UNSLOTH_LLAMA_BACKEND_SAMPLING=0 win.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ⚠️ Failed 2026-10-20T21:09:21.139863Z
We couldn’t complete this review. Comment @codex review to try again.
5eefbf9 PR opened
🔒 Security Review ⚠️ Failed 2026-10-20T21:09:22.317553Z
We couldn’t complete this review. Comment @codex security review to try again.
5eefbf9 PR opened
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

llama.cpp's GPU min_p keeps logit - (max_logit + log p) > 0, so at min_p 1
(or within float rounding of it) it drops even the top token and draws
from NaN probabilities, where the CPU chain keeps the argmax.

Requests whose min_p is past 0.999 now carry backend_sampling: false, only
when the spawned llama-server enables the GPU sampler (or the client asked
for it per request), so older binaries never see the field. Applied at
_open_stream, the GGUF TTS POST, _build_passthrough_payload (OpenAI,
Responses and both Anthropic passthroughs) and the raw /v1/completions
proxy. A launch whose default --min-p / LLAMA_ARG_MIN_P is past 0.999 does
not get --backend-sampling at all.

[skip ci]
llama.cpp's backend sampler aborts the whole server on a top_k past its
candidates (ggml_top_k asserts, and every backend sampler is probed on a
128K-candidate graph), so a /v1/completions body with top_k 1000000, top_k
140000 on a 151936-token vocabulary, or a custom sampler order with
temperature 0 ahead of top_k is a remote crash. It also samples another
distribution than the CPU chain for min_keep > 1, dynatemp, duplicate
logit_bias tokens, and a fixed seed without top_p (top_k's GPU order is not
stable, so the seed stops reproducing).

guard_gpu_min_p becomes guard_gpu_sampler, one check over every field the GPU
part of the chain reads, on the same call sites (_open_stream, the GGUF TTS
POST, the passthrough builder, raw /v1/completions). top_k is held to the
GGUF's token count and the 128K probe (256 when the vocabulary is unknown);
an omitted field takes the launch's own flag or LLAMA_ARG_* default, a launch
default outside the envelope keeps the flag off, and an unparseable value is
outside.
…ff the GPU prefix

Launch flags the GPU sampler envelope parses are now matched through _flag_name, as
common/arg.cpp folds a long option's underscores: --sampler_seq tk enabled backend sampling
and recorded no custom order, so temperature 0 with top_k 20 aborted llama-server. Every
--spec-type is appended (a later none no longer hides an earlier type), and the argv value
wins over the env twin.

XTC, DRY, typical-p, top-n-sigma and mirostat have no backend implementation, so an active one
split the chain into a GPU prefix and a CPU suffix that saw the candidates top-p / min-p had
masked to -inf; XTC at threshold 0 then kept an excluded token. A request with any of them
active, from its own fields or a launch default, now samples on the CPU, and a launch default
that activates one drops --backend-sampling. A NaN sampling value is outside the envelope.
…in /props

llama.cpp also takes sampling defaults, and backend-sampling itself, from a
config.ini (/etc/llama.cpp, ${XDG_CONFIG_HOME:-~/.config}/llama.cpp, %PROGRAMDATA% /
%APPDATA% on Windows) that no argv or env shows, so [default] sampler-seq = tk
plus a temperature 0, top_k 20 request still aborted the server.

Once the server is up, and before it is published, read
default_generation_settings.params from /props (the same params every request
copies its defaults from) and guard omitted fields with those; the argv reading
only fills logit_bias, which /props leaves out, and stays the pre-launch gate.
A GPU-sampling server whose /props is unreadable or lacks a field, or one with
no readable /props and a config.ini present, pins every request to the CPU.
Every spawn re-reads it, so a respawn or reload refreshes the defaults.
…ampler

/props leaves logit_bias out, and llama.cpp appends the biases of every
config section it applies, which the GPU sampler does not sum.
danielhanchen added a commit that referenced this pull request Oct 21, 2026
…mpile packing test off leaked env (#14425)

## Problem

Two tests on main fail in CI for reasons in the tests, not the product.
They are red on unrelated PRs, for example #14409 and #14365.

**1. `tests/python/test_v100_fullft_precision.py` (10 tests, `Repo tests
(CPU, studio)`)**

```
tests/python/test_v100_fullft_precision.py:80: in _decide
    exec(CODE, {"torch": torch, "os": os}, {"args": args, "model": model})
E   NameError: name 'args' is not defined
```

The test runs the `mixed_precision` template from `unsloth/models/rl.py`
with separate globals and locals dicts. #14394 added
`_unsloth_precision_requested = tuple(getattr(args, k, False) for k in
(...))` to that template. In the generated trainer this runs inside a
function, where the generator sees `args`, so the product is fine. A
generator inside `exec` with a separate locals dict cannot see those
locals, so the test raises `NameError`.

**2.
`tests/test_legacy_block_compile_install.py::test_packed_calls_keep_the_unsloth_layer_forward`
(`Repo tests (CPU, rest)`)**

```
tests/test_legacy_block_compile_install.py:105: in test_packed_calls_keep_the_unsloth_layer_forward
E   assert []
```

The test (from #14398) expects an unpacked call to go through the
compiled block. `_block_compile._wrap` keeps every call on the stock
forward when `UNSLOTH_COMPILE_DISABLE` is `1`, `UNSLOTH_BLOCK_COMPILE`
is `0`, or `UNSLOTH_FORCE_FLOAT32` is `1`. Eight test modules run
`os.environ.setdefault("UNSLOTH_COMPILE_DISABLE", "1")` at import time,
so the result depends on which modules a worker collected. The test
passes alone and fails with any of those variables set.

## Fix

Test only:

- `test_v100_fullft_precision.py`: `exec` the template in one namespace,
as in the trainer's function scope. Results are still read from `args`
and the environment.
- `test_legacy_block_compile_install.py`: the packing test clears the
three variables with `monkeypatch.delenv` before wrapping.

## Verification

- `test_v100_fullft_precision.py`: on main 10 failed (the `NameError`
above); with this change 10 passed.
- `test_legacy_block_compile_install.py` on main:
- with any one of `UNSLOTH_COMPILE_DISABLE=1`, `UNSLOTH_BLOCK_COMPILE=0`
or `UNSLOTH_FORCE_FLOAT32=1` set: 1 failed (the packing test), 5 passed;
- with this change: 6 passed in each of those three cases and with none
set.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant