Skip to content

Commit 7c772e1

Browse files
authored
feat: speculative decoding with paired drafter checkpoints (#5241)
1 parent 70801c1 commit 7c772e1

37 files changed

Lines changed: 3083 additions & 76 deletions

doc/source/user_guide/backends.rst

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ Backends
77
Xinference supports multiple backends for different models. After the user specifies the model,
88
xinference will automatically select the appropriate backend.
99

10+
.. _llama_cpp_backend:
11+
1012
llama.cpp
1113
=========
1214

@@ -196,3 +198,120 @@ MLX
196198
`MLX <https://github.com/ml-explore/mlx-examples/tree/main/llms>`_ provides efficient runtime
197199
to run LLM on Apple silicon. It's recommended to use for Mac users when running on Apple silicon
198200
if the model has MLX format support.
201+
202+
203+
.. _speculative_decoding:
204+
205+
Speculative decoding
206+
====================
207+
Some models ship a small paired drafter checkpoint that predicts several tokens
208+
ahead, which the target model then verifies in one pass. Output is unchanged,
209+
decoding gets faster. Gemma 4 calls this multi-token prediction (MTP) and
210+
publishes a ``*-it-assistant`` drafter for every variant.
211+
212+
Pass ``--enable_mtp true`` at launch to download the drafter declared by the
213+
model spec and run it alongside the target model:
214+
215+
.. code-block:: bash
216+
217+
xinference launch --model-name gemma-4 --model-engine vllm \
218+
--model-format pytorch --size-in-billions 12 --quantization none \
219+
--enable_mtp true
220+
221+
In the Web UI the same options live under *Advanced Configuration → Speculative
222+
Decoding*, which only appears for a format/size that actually ships a drafter.
223+
224+
Optional parameters:
225+
226+
* ``--num_speculative_tokens <n>``: how many tokens the drafter proposes per
227+
round, including the bonus token. Left unset, MLX reads it from the drafter,
228+
which runs at the depth it was trained for (``4`` for Gemma 4). Gemma 4 on
229+
llama.cpp, vLLM, and SGLang follows the same model-size recipe: ``2`` for
230+
E2B, ``4`` for E4B and 26B-A4B, and the lower end (``4``) of the recommended
231+
``4-8`` range for 12B and 31B. Other llama.cpp models keep xllamacpp's own
232+
default.
233+
* ``--draft_quantization <quantization>``: which drafter conversion to use, when
234+
the spec declares more than one — the MLX build of Gemma 4 12B publishes
235+
eight. Defaults to the first declared, which is the least quantized one: a
236+
drafter is small and quantizing it costs acceptance rate, so pairing a
237+
quantized target with an unquantized drafter is the recommended setup.
238+
* ``--draft_model_path <path>``: use a local drafter instead of the one declared
239+
by the spec. Implies ``--enable_mtp true``.
240+
241+
The drafter must match the target model family and size — it shares the target's
242+
KV cache, so a mismatched checkpoint is rejected rather than silently degrading.
243+
244+
Engine support:
245+
246+
.. list-table::
247+
:header-rows: 1
248+
:widths: 18 32 50
249+
250+
* - Engine
251+
- Requirement
252+
- Notes
253+
* - :ref:`vLLM <vllm_backend>`
254+
- ``vllm>=0.22.0``, ``transformers>=5.8.0``
255+
- Translated into ``speculative_config`` with ``method: mtp``. An
256+
explicitly provided ``speculative_config`` is left untouched. Older
257+
vLLM treats the drafter as a generic draft model, while older
258+
Transformers does not recognize ``gemma4_assistant``; either case is
259+
rejected before engine initialization. Virtual-environment launches
260+
also synchronize ``flashinfer-cubin`` with ``flashinfer-python`` before
261+
starting vLLM, repairing stale environments that contain mismatched
262+
FlashInfer packages.
263+
* - :ref:`SGLang <sglang_backend>`
264+
- ``sglang==0.5.13.post1``, ``transformers==5.8.1``
265+
- Translated into ``--speculative-algorithm NEXTN`` with the matching
266+
``speculative_num_steps`` / ``speculative_num_draft_tokens``. An
267+
explicitly provided ``speculative_algorithm`` is left untouched.
268+
* - :ref:`MLX <mlx_backend>`
269+
- ``mlx-vlm>=0.5.0`` (``>=0.6.1`` for Gemma 4 12B)
270+
- Served by the MLX vision engine, the one that runs multimodal models
271+
such as Gemma 4. The drafter is validated against the target when the
272+
model loads.
273+
* - :ref:`llama.cpp <llama_cpp_backend>`
274+
- ``xllamacpp>=2026.6.9713``
275+
- Translated into the ``draft-mtp`` speculative implementation. The
276+
drafter is a single gguf published inside the target's own repository,
277+
so its quantizations are its own (``BF16``, ``F16``, ``Q8_0`` for Gemma
278+
4) and independent of the target's. Earlier llama.cpp builds do not
279+
know the ``gemma4-assistant`` architecture and cannot load it.
280+
281+
Not supported by the Transformers engine: it runs its own continuous-batching
282+
loop rather than ``generate()``, so there is nowhere to attach a drafter.
283+
284+
When it pays off
285+
----------------
286+
Speculation is only worth it when a drafting step is cheap *relative to* a
287+
target decoding step. The drafter is a small dense model whose cost does not
288+
change with the target, so the ratio is what decides the outcome — and a
289+
mixture-of-experts target can land on the wrong side of it, because only its
290+
activated slice is read per token:
291+
292+
.. list-table::
293+
:header-rows: 1
294+
:widths: 34 22 22 22
295+
296+
* - gemma-4 MLX, 4bit, M5 Pro
297+
- Without a drafter
298+
- With MTP
299+
- Accepted per round
300+
* - 31B (dense, 18.4 GB read per token)
301+
- 14.9 tok/s
302+
- **31.0 tok/s** (2.1x)
303+
- 2.08 of 4
304+
* - 26B-A4B (MoE, ~2.2 GB read per token)
305+
- 73.2 tok/s
306+
- 65.9 tok/s (0.9x)
307+
- 1.40 of 4
308+
309+
The 0.83 GB drafter costs about 5% of a 31B decoding step but 39% of a
310+
26B-A4B one, so on the MoE the three drafting steps of a round already exceed
311+
one plain decoding step — and the round has to win that back from a lower
312+
acceptance rate. The MoE is still the faster model here in absolute terms; it
313+
simply has no headroom left for speculation.
314+
315+
So measure before leaving it on, and note that the verification step itself is
316+
not the problem: a four-token forward costs only ~40% more than a single-token
317+
one on either model.

frontend/.env.development

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
NEXT_PUBLIC_API_URL=http://127.0.0.1:9997

frontend/.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ yarn-error.log*
3434

3535
# env files (can opt-in for committing if needed)
3636
.env*
37+
# ...except the dev defaults: without NEXT_PUBLIC_API_URL the browser
38+
# talks to the Next dev server, whose rewrite proxy buffers SSE and
39+
# silently kills streaming responses.
40+
!.env.development
3741

3842
# vercel
3943
.vercel

frontend/README.md

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,21 @@ npm run dev
2222

2323
Open http://127.0.0.1:3999.
2424

25-
In dev mode, the frontend proxies API requests to `http://127.0.0.1:9997`. To
26-
use a different backend endpoint:
25+
`.env.development` points the browser straight at `http://127.0.0.1:9997`.
26+
Keep it that way unless you know you want the proxy: with `NEXT_PUBLIC_API_URL`
27+
unset the browser talks to the Next dev server instead, and its rewrite proxy
28+
buffers responses — streaming endpoints (chat completions and friends) then
29+
deliver the whole answer in one go, which looks like the backend lost its
30+
streaming support.
31+
32+
To use a different backend endpoint:
2733

2834
```bash
29-
XINFERENCE_API_URL=http://127.0.0.1:6735 npm run dev
35+
NEXT_PUBLIC_API_URL=http://127.0.0.1:6735 npm run dev
3036
```
3137

32-
For direct browser requests to a non-default backend, use
33-
`NEXT_PUBLIC_API_URL` instead.
38+
`XINFERENCE_API_URL` sets the proxy target instead, for the same-origin setup
39+
where the browser goes through the dev server.
3440

3541
## Build
3642

frontend/src/components/pages/launch-model/launch-dialog/advanced-config.tsx

Lines changed: 92 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,22 @@ import { Settings } from 'lucide-react';
55

66
import { CollapsiblePanel } from '@/components/ui/collapsible';
77
import { FormField } from '@/components/ui/form-field';
8+
import { Input } from '@/components/ui/input';
89
import { RadioGroup } from '@/components/ui/radio-group';
10+
import { Select } from '@/components/ui/select';
11+
import { Switch } from '@/components/ui/switch';
12+
import { InfoTooltip } from '@/components/ui/tooltip';
913
import {
14+
GEMMA_4_SPECULATIVE_TOKENS_BY_SIZE,
1015
KWARGS_OPTIONS_FOR_ENGINES,
1116
QUANTIZATION_OPTIONS,
17+
SPECULATIVE_TOKENS_DEFAULT_BY_ENGINE,
1218
VIRTUAL_ENV_OPTIONS,
1319
} from '@/constants/launch';
1420
import { useI18n } from '@/contexts/i18n-context';
1521
import { useWatch } from '@/hooks/use-form';
1622
import type { FormInstance } from '@/types/form';
23+
import type { Option } from '@/types/common';
1724
import type { RequestModelType } from '../types';
1825
import { toOptionValue } from '../utils';
1926
import CommonFormList from './common-form-list';
@@ -22,13 +29,27 @@ import { ModelType } from '@/constants';
2229
interface AdvancedConfigProps {
2330
form: FormInstance;
2431
modelType: RequestModelType;
32+
modelName?: string;
33+
/** whether the selected spec ships a drafter for speculative decoding */
34+
hasDrafter?: boolean;
35+
/** available drafter conversions, empty when there is nothing to pick */
36+
draftQuantizationOptions?: Option<string>[];
2537
}
2638

2739
interface ConfigSectionProps {
2840
title: ReactNode;
2941
children: ReactNode;
3042
}
3143

44+
function FieldLabel({ label, tip }: { label: string; tip: string }) {
45+
return (
46+
<span className="shrink-0 flex items-center gap-1 text-sm font-medium">
47+
{label}
48+
<InfoTooltip content={tip} />
49+
</span>
50+
);
51+
}
52+
3253
function ConfigSection({ title, children }: ConfigSectionProps) {
3354
return (
3455
<section className="space-y-2">
@@ -40,13 +61,24 @@ function ConfigSection({ title, children }: ConfigSectionProps) {
4061
);
4162
}
4263

43-
const AdvancedConfig: FC<AdvancedConfigProps> = ({ form, modelType }) => {
64+
const AdvancedConfig: FC<AdvancedConfigProps> = ({
65+
form,
66+
modelType,
67+
modelName,
68+
hasDrafter = false,
69+
draftQuantizationOptions = [],
70+
}) => {
4471
const { t } = useI18n();
4572
const modelEngineValue = toOptionValue(useWatch('model_engine', form));
73+
const modelSizeValue = toOptionValue(useWatch('model_size_in_billions', form));
74+
const enableMtpValue = useWatch('enable_mtp', form);
4675

47-
const kwargsOptionsForEngine = modelEngineValue
48-
? KWARGS_OPTIONS_FOR_ENGINES[modelEngineValue.toLowerCase()]
49-
: undefined;
76+
const engineKey = modelEngineValue.toLowerCase();
77+
const kwargsOptionsForEngine = engineKey ? KWARGS_OPTIONS_FOR_ENGINES[engineKey] : undefined;
78+
const speculativeTokensDefault =
79+
['vllm', 'sglang', 'llama.cpp'].includes(engineKey) && modelName === 'gemma-4'
80+
? GEMMA_4_SPECULATIVE_TOKENS_BY_SIZE[modelSizeValue]
81+
: SPECULATIVE_TOKENS_DEFAULT_BY_ENGINE[engineKey];
5082
const showLora = [ModelType.LLM, ModelType.Image, ModelType.Video].includes(modelType);
5183
const showLoraKwargs = [ModelType.Image, ModelType.Video].includes(modelType);
5284
return (
@@ -126,6 +158,62 @@ const AdvancedConfig: FC<AdvancedConfigProps> = ({ form, modelType }) => {
126158
/>
127159
</div>
128160
</ConfigSection>
161+
162+
{hasDrafter && (
163+
<ConfigSection title={t('launchModel.speculativeDecoding')}>
164+
<div className="px-2 py-3.5 flex items-center justify-between">
165+
<FieldLabel label={t('launchModel.enableMtp')} tip={t('launchModel.enableMtpTip')} />
166+
<FormField name="enable_mtp" valuePropName="checked">
167+
<Switch />
168+
</FormField>
169+
</div>
170+
171+
{enableMtpValue && draftQuantizationOptions.length > 0 && (
172+
<div className="px-2 py-3.5 flex items-center justify-between gap-4">
173+
<FieldLabel
174+
label={t('launchModel.draftModelQuantization')}
175+
tip={t('launchModel.draftModelQuantizationTip')}
176+
/>
177+
<FormField
178+
className="w-56"
179+
name="draft_quantization"
180+
placeholder={t('launchModel.draftModelQuantizationPlaceholder')}
181+
>
182+
<Select options={draftQuantizationOptions} />
183+
</FormField>
184+
</div>
185+
)}
186+
187+
{enableMtpValue && (
188+
<div className="px-2 py-3.5 flex items-center justify-between gap-4">
189+
<FieldLabel
190+
label={t('launchModel.numSpeculativeTokens')}
191+
tip={t('launchModel.numSpeculativeTokensTip')}
192+
/>
193+
<FormField
194+
className="w-56"
195+
name="num_speculative_tokens"
196+
placeholder={
197+
speculativeTokensDefault
198+
? t('launchModel.numSpeculativeTokensPlaceholderValue', {
199+
value: speculativeTokensDefault,
200+
})
201+
: t('launchModel.numSpeculativeTokensPlaceholder')
202+
}
203+
rules={[
204+
{
205+
pattern: /^[1-9]\d*$/,
206+
message: t('launchModel.enterIntegerGreaterThanZero'),
207+
},
208+
]}
209+
normalize={(v) => (v === '' ? undefined : Number(v))}
210+
>
211+
<Input type="number" min={1} />
212+
</FormField>
213+
</div>
214+
)}
215+
</ConfigSection>
216+
)}
129217
</CollapsiblePanel>
130218
);
131219
};

frontend/src/components/pages/launch-model/launch-dialog/launch-dialog.tsx

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,51 @@ export default function LaunchDialog({
243243
t,
244244
]);
245245

246+
// The spec currently selected in the form, used to tell whether this
247+
// format/size/quantization ships a drafter for speculative decoding.
248+
const selectedSpec = useMemo(
249+
() =>
250+
model?.modelSpecs?.find(
251+
(spec) =>
252+
spec.model_format === modelFormatValue &&
253+
toOptionValue(spec.model_size_in_billions) === modelSizeInBillionsKey &&
254+
(spec.quantization === quantizationValue ||
255+
(Array.isArray(spec.quantizations) && spec.quantizations.includes(quantizationValue)))
256+
),
257+
[model?.modelSpecs, modelFormatValue, modelSizeInBillionsKey, quantizationValue]
258+
);
259+
// Drafter conversions available for the selected spec, each flagged with
260+
// whether it is already downloaded. Empty when the spec declares a drafter
261+
// without alternatives, in which case there is nothing to pick.
262+
// A drafter needs both a spec that ships one and an engine that can run it:
263+
// gemma-4's pytorch spec has a drafter but Transformers cannot use it, and
264+
// showing the switch there only moves the failure to the launch button.
265+
const engineSupportsDrafter = useMemo(() => {
266+
const entries = modelEngineMap[modelEngineValue];
267+
if (!Array.isArray(entries)) return false;
268+
const matching = entries.filter(
269+
(entry) =>
270+
entry.model_format === modelFormatValue &&
271+
toOptionValue(entry.model_size_in_billions) === modelSizeInBillionsKey
272+
);
273+
return (matching.length ? matching : entries).some((entry) => entry.support_draft_model);
274+
}, [modelEngineMap, modelEngineValue, modelFormatValue, modelSizeInBillionsKey]);
275+
const hasDrafter =
276+
engineSupportsDrafter &&
277+
Boolean(selectedSpec?.draft_model_id || selectedSpec?.draft_model_file_name_template);
278+
const draftQuantizationOptions = useMemo(() => {
279+
const quantizations = (selectedSpec?.draft_quantizations as string[] | undefined) || [];
280+
const cacheStatus = selectedSpec?.draft_cache_status;
281+
282+
return quantizations.map((quantization, index) => ({
283+
label: quantization,
284+
value: quantization,
285+
suffix: (Array.isArray(cacheStatus) ? cacheStatus[index] : cacheStatus)
286+
? t('launchModel.cached')
287+
: undefined,
288+
}));
289+
}, [selectedSpec, t]);
290+
246291
const multimodalProjectorOptions = useMemo(
247292
() =>
248293
Array.from(
@@ -524,7 +569,15 @@ export default function LaunchDialog({
524569
name: 'collapsibleConfig',
525570
type: 'custom',
526571
colSpan: 2,
527-
content: <CollapsibleConfig form={form} modelType={modelType} />,
572+
content: (
573+
<CollapsibleConfig
574+
form={form}
575+
modelType={modelType}
576+
modelName={model?.model_name}
577+
hasDrafter={hasDrafter}
578+
draftQuantizationOptions={draftQuantizationOptions}
579+
/>
580+
),
528581
},
529582
],
530583
[ModelType.Embedding]: [

0 commit comments

Comments
 (0)