mlxrunner: unify the MTP decode paths

Greedy is a special case of sampled decoding — at temperature 0 the sampler
yields a point mass, so rejection-sampling acceptance reduces to argmax-match
— so collapse the separate greedy, sampled, and serial paths into one. MTP
now honors any temperature, penalty, and top-k/p/min-p setting; logprobs
remain the only gated feature.
This commit is contained in:
Jesse Gross 2026-06-02 12:53:47 -07:00
parent fc58544422
commit 2e9d68dc38
3 changed files with 465 additions and 339 deletions

View file

@ -34,8 +34,6 @@ type mtpStats struct {
accepted int
mismatches int
allAccepted int
batched int
serial int
maxDraft int
targetDuration time.Duration
draftDuration time.Duration
@ -46,7 +44,6 @@ type mtpOptions struct {
initialDraftTokens int
maxDraftTokens int
draftSchedule mtpDraftSchedule
serialValidate bool
}
func (r *Runner) mtpDefaults(sample bool) base.MTPDefaults {
@ -84,9 +81,6 @@ func (r *Runner) loadMTPOptions(sample bool) mtpOptions {
if opts.initialDraftTokens > opts.maxDraftTokens {
opts.initialDraftTokens = opts.maxDraftTokens
}
if b, err := strconv.ParseBool(os.Getenv("OLLAMA_MLX_MTP_SERIAL_VALIDATE")); err == nil {
opts.serialValidate = b
}
switch schedule := strings.ToLower(strings.TrimSpace(os.Getenv("OLLAMA_MLX_MTP_DRAFT_SCHEDULE"))); schedule {
case "", string(mtpDraftScheduleConstant):
opts.draftSchedule = mtpDraftScheduleConstant
@ -111,7 +105,9 @@ func positiveEnvInt(key string) int {
return v
}
func (r *Runner) useGreedyMTP(opts sampler.Options) bool {
// useMTP reports whether the request can run through the MTP speculative
// decode path. Logprobs are not yet supported.
func (r *Runner) useMTP(opts sampler.Options) bool {
if r.Draft == nil {
return false
}
@ -121,190 +117,22 @@ func (r *Runner) useGreedyMTP(opts sampler.Options) bool {
if _, ok := r.Model.(base.MTPEmbeddingModel); !ok {
return false
}
if !r.mtpDefaults(false).Enabled {
if !r.mtpDefaults(opts.Temperature != 0).Enabled {
return false
}
if opts.Logprobs || opts.TopLogprobs > 0 {
return false
}
if opts.Temperature != 0 {
return false
}
repeatPenaltyNeutral := opts.RepeatPenalty <= 0 || opts.RepeatPenalty == 1
topPNeutral := opts.TopP <= 0 || opts.TopP >= 1
topKNeutral := opts.TopK <= 0
return repeatPenaltyNeutral && opts.PresencePenalty == 0 && opts.FrequencyPenalty == 0 && topPNeutral && topKNeutral && opts.MinP == 0
return true
}
func (r *Runner) useSampleMTP(opts sampler.Options) bool {
if serial, err := strconv.ParseBool(os.Getenv("OLLAMA_MLX_MTP_SERIAL_VALIDATE")); err == nil && serial {
return false
}
if r.Draft == nil {
return false
}
if _, ok := r.Draft.(base.MTPDraftModel); !ok {
return false
}
if _, ok := r.Model.(base.MTPEmbeddingModel); !ok {
return false
}
if !r.mtpDefaults(true).Enabled {
return false
}
if opts.Logprobs || opts.TopLogprobs > 0 {
return false
}
return opts.Temperature != 0
}
func (r *Runner) runGreedyMTPDecode(ctx context.Context, request Request, session *cacheSession, caches []cache.Cache, seed []int32, position *int, started time.Time) error {
func (r *Runner) runMTPDecode(ctx context.Context, request Request, session *cacheSession, caches []cache.Cache, seed []int32, position *int, started time.Time) error {
targetEmbeddings := r.Model.(base.MTPEmbeddingModel)
draft := r.Draft.(base.MTPDraftModel)
mtpOpts := r.loadMTPOptions(false)
mtpOpts := r.loadMTPOptions(request.SamplerOpts.Temperature != 0)
stats := mtpStats{maxDraft: mtpOpts.initialDraftTokens}
draftLimit := mtpOpts.initialDraftTokens
slog.Info("MTP greedy decode enabled", "initial_draft_tokens", mtpOpts.initialDraftTokens, "max_draft_tokens", mtpOpts.maxDraftTokens, "draft_schedule", mtpOpts.draftSchedule, "serial_validate", mtpOpts.serialValidate)
targetForward := func(token *mlx.Array) *mlx.Array {
fwd := r.Model.Forward(&batch.Batch{
InputIDs: token,
SeqOffsets: []int32{int32(*position)},
SeqQueryLens: []int32{int32(token.Dim(1))},
}, caches)
*position += token.Dim(1)
return fwd
}
hidden := targetForward(mlx.FromValues(seed, 1, len(seed)))
current := sampler.Result{Token: greedyTokenFromLogits(r.lastLogits(hidden))}
mlx.Pin(current.Arrays()...)
mlx.Sweep()
mlx.AsyncEval(current.Arrays()...)
defer func() {
mlx.Unpin(current.Arrays()...)
}()
dec := decoder{tokenizer: r.Tokenizer}
final := CompletionResponse{Done: true, PromptEvalCount: len(request.Tokens), DoneReason: 1}
now := started
generated := 0
for generated < request.Options.NumPredict {
if err := ctx.Err(); err != nil {
return err
}
t0 := time.Now()
hidden = targetForward(current.Token.ExpandDims(-1))
baseLogits := r.lastLogits(hidden)
stats.targetDuration += time.Since(t0)
if generated == 0 {
mlx.Eval(current.Arrays()...)
final.PromptEvalDuration = time.Since(now)
now = time.Now()
}
done, err := r.emitTokens(ctx, request, session, &dec, []sampler.Result{current}, &final, &generated)
if err != nil {
return err
}
if done {
break
}
stats.iterations++
maxDraft := min(draftLimit, request.Options.NumPredict-generated)
t0 = time.Now()
draftTokens := r.generateMTPDrafts(draft, targetEmbeddings, current.Token, hidden, caches, int32(*position-1), maxDraft)
draftCount := 0
if draftTokens != nil {
draftCount = draftTokens.Dim(1)
mlx.Pin(baseLogits, draftTokens)
mlx.Eval(draftTokens)
mlx.Sweep()
}
stats.draftDuration += time.Since(t0)
stats.drafted += draftCount
var next sampler.Result
if draftCount == 0 {
next = sampler.Result{Token: greedyTokenFromLogits(baseLogits)}
} else {
var accepted int
t0 = time.Now()
next, accepted, done, err = r.acceptMTPDrafts(ctx, request, session, &dec, caches, position, baseLogits, draftTokens, &final, &generated, &stats, mtpOpts)
stats.validateDuration += time.Since(t0)
mlx.Unpin(baseLogits, draftTokens)
if err != nil {
return err
}
stats.accepted += accepted
switch {
case mtpOpts.draftSchedule == mtpDraftScheduleConstant:
case accepted == draftCount:
stats.allAccepted++
draftLimit = min(mtpOpts.maxDraftTokens, draftLimit+2)
default:
stats.mismatches++
draftLimit = max(1, draftLimit-1)
}
if mtpOpts.draftSchedule == mtpDraftScheduleConstant {
if accepted == draftCount {
stats.allAccepted++
} else {
stats.mismatches++
}
}
stats.maxDraft = max(stats.maxDraft, draftLimit)
if next.Token == nil {
mlx.Sweep()
}
if done || generated >= request.Options.NumPredict {
break
}
}
mlx.Pin(next.Arrays()...)
old := current
current = next
mlx.Unpin(old.Arrays()...)
mlx.Sweep()
mlx.AsyncEval(current.Arrays()...)
if generated%256 == 0 {
mlx.ClearCache()
}
}
final.EvalCount = generated
final.EvalDuration = time.Since(now)
acceptance := 0.0
if stats.drafted > 0 {
acceptance = float64(stats.accepted) / float64(stats.drafted)
}
avgDraft := 0.0
avgAccepted := 0.0
if stats.iterations > 0 {
avgDraft = float64(stats.drafted) / float64(stats.iterations)
avgAccepted = float64(stats.accepted) / float64(stats.iterations)
}
slog.Info("MTP decode stats", "generated", generated, "drafted", stats.drafted, "accepted", stats.accepted, "acceptance", acceptance, "iterations", stats.iterations, "avg_draft", avgDraft, "avg_accepted", avgAccepted, "batched", stats.batched, "serial", stats.serial, "mismatches", stats.mismatches, "all_accepted", stats.allAccepted, "max_draft", stats.maxDraft, "draft_schedule", mtpOpts.draftSchedule, "target_duration", stats.targetDuration, "draft_duration", stats.draftDuration, "validate_duration", stats.validateDuration)
select {
case <-ctx.Done():
return ctx.Err()
case request.Responses <- final:
return nil
}
}
func (r *Runner) runSampleMTPDecode(ctx context.Context, request Request, session *cacheSession, caches []cache.Cache, seed []int32, position *int, started time.Time) error {
targetEmbeddings := r.Model.(base.MTPEmbeddingModel)
draft := r.Draft.(base.MTPDraftModel)
mtpOpts := r.loadMTPOptions(true)
stats := mtpStats{maxDraft: mtpOpts.initialDraftTokens}
draftLimit := mtpOpts.initialDraftTokens
slog.Info("MTP sample decode enabled", "initial_draft_tokens", mtpOpts.initialDraftTokens, "max_draft_tokens", mtpOpts.maxDraftTokens, "draft_schedule", mtpOpts.draftSchedule, "serial_validate", mtpOpts.serialValidate)
slog.Info("MTP decode enabled", "initial_draft_tokens", mtpOpts.initialDraftTokens, "max_draft_tokens", mtpOpts.maxDraftTokens, "draft_schedule", mtpOpts.draftSchedule)
targetForward := func(token *mlx.Array) *mlx.Array {
fwd := r.Model.Forward(&batch.Batch{
@ -368,14 +196,13 @@ func (r *Runner) runSampleMTPDecode(ctx context.Context, request Request, sessio
}
stats.draftDuration += time.Since(t0)
stats.drafted += draftCount
var next sampler.Result
if draftCount == 0 {
next = r.Sampler.Sample([]int{pipelineSlot}, baseLogits)
} else {
var accepted int
t0 = time.Now()
next, accepted, done, err = r.acceptSampleMTPDrafts(ctx, request, session, &dec, caches, position, baseLogits, candidates, &final, &generated, &stats)
next, accepted, done, err = r.acceptMTPDrafts(ctx, request, session, &dec, caches, position, baseLogits, candidates, &final, &generated)
stats.validateDuration += time.Since(t0)
mlx.Unpin(candidateArrays...)
if err != nil {
@ -431,7 +258,7 @@ func (r *Runner) runSampleMTPDecode(ctx context.Context, request Request, sessio
avgDraft = float64(stats.drafted) / float64(stats.iterations)
avgAccepted = float64(stats.accepted) / float64(stats.iterations)
}
slog.Info("MTP decode stats", "mode", "sample", "generated", generated, "drafted", stats.drafted, "accepted", stats.accepted, "acceptance", acceptance, "iterations", stats.iterations, "avg_draft", avgDraft, "avg_accepted", avgAccepted, "batched", stats.batched, "serial", stats.serial, "mismatches", stats.mismatches, "all_accepted", stats.allAccepted, "max_draft", stats.maxDraft, "draft_schedule", mtpOpts.draftSchedule, "target_duration", stats.targetDuration, "draft_duration", stats.draftDuration, "validate_duration", stats.validateDuration)
slog.Info("MTP decode stats", "generated", generated, "drafted", stats.drafted, "accepted", stats.accepted, "acceptance", acceptance, "iterations", stats.iterations, "avg_draft", avgDraft, "avg_accepted", avgAccepted, "mismatches", stats.mismatches, "all_accepted", stats.allAccepted, "max_draft", stats.maxDraft, "draft_schedule", mtpOpts.draftSchedule, "target_duration", stats.targetDuration, "draft_duration", stats.draftDuration, "validate_duration", stats.validateDuration)
select {
case <-ctx.Done():
return ctx.Err()
@ -453,35 +280,6 @@ func (c *mtpDraftCandidates) Arrays() []*mlx.Array {
return append([]*mlx.Array{c.tokens}, c.dist.Arrays()...)
}
func (r *Runner) generateMTPDrafts(draft base.MTPDraftModel, target base.MTPEmbeddingModel, token *mlx.Array, hidden *mlx.Array, caches []cache.Cache, position int32, maxDraft int) *mlx.Array {
if maxDraft <= 0 {
return nil
}
lastToken := token.ExpandDims(-1)
lastHidden := hidden
draftTokens := make([]*mlx.Array, 0, maxDraft)
// Gemma4 assistant MTP is trained as "single-position" drafting:
// keep the RoPE/cache position anchored at the last target-seen token
// while the proposed token and projected hidden state advance.
for range maxDraft {
tokenEmbedding := target.TokenEmbeddings(lastToken)
inputs := tokenEmbedding.Concatenate(-1, lastHidden)
logits, projected := draft.Draft(inputs, position, caches)
stepLogits := r.lastLogitsFromLogits(logits)
nextToken := greedyTokenFromLogits(stepLogits)
lastToken = nextToken.ExpandDims(-1)
lastHidden = projected
draftTokens = append(draftTokens, lastToken)
}
if len(draftTokens) == 0 {
return nil
}
return mlx.Concatenate(draftTokens, 1)
}
func (r *Runner) generateMTPDraftCandidates(draft base.MTPDraftModel, target base.MTPEmbeddingModel, token *mlx.Array, hidden *mlx.Array, caches []cache.Cache, position int32, maxDraft int) *mtpDraftCandidates {
if maxDraft <= 0 {
return nil
@ -523,16 +321,6 @@ func (r *Runner) generateMTPDraftCandidates(draft base.MTPDraftModel, target bas
}
}
func (r *Runner) acceptMTPDrafts(ctx context.Context, request Request, session *cacheSession, dec *decoder, caches []cache.Cache, position *int, baseLogits *mlx.Array, draftTokens *mlx.Array, final *CompletionResponse, generated *int, stats *mtpStats, opts mtpOptions) (sampler.Result, int, bool, error) {
if opts.serialValidate {
stats.serial++
return r.acceptMTPDraftsSerial(ctx, request, session, dec, caches, position, baseLogits, draftTokens, final, generated)
}
stats.batched++
return r.acceptMTPDraftsBatched(ctx, request, session, dec, caches, position, baseLogits, draftTokens, final, generated)
}
// scheduleSpeculation schedules per-token snapshots at offsets
// [before, before+draftCount) on every cache, so the speculative forward
// captures a rollback point before each draft token's write.
@ -586,64 +374,10 @@ func commitSpeculation(caches []cache.Cache, accepted, draftCount, before int) {
}
}
func (r *Runner) acceptMTPDraftsBatched(ctx context.Context, request Request, session *cacheSession, dec *decoder, caches []cache.Cache, position *int, baseLogits *mlx.Array, draftTokens *mlx.Array, final *CompletionResponse, generated *int) (sampler.Result, int, bool, error) {
before := *position
draftCount := draftTokens.Dim(1)
scheduleSpeculation(caches, before, draftCount)
hiddenSeq := r.Model.Forward(&batch.Batch{
InputIDs: draftTokens,
SeqOffsets: []int32{int32(before)},
SeqQueryLens: []int32{int32(draftCount)},
}, caches)
accepted := 0
var next sampler.Result
done := false
selectedTokens := r.mtpValidationTokens(baseLogits, hiddenSeq)
mlx.Eval(draftTokens, selectedTokens)
draftIDs := draftTokens.Ints()
selectedIDs := selectedTokens.Ints()
if len(selectedIDs) < draftCount+1 {
// Drain the scheduled snapshots and roll the speculative forward back
// out of the live caches before bailing, so the abandoned drafts don't
// reach the trie via session.close().
commitSpeculation(caches, 0, draftCount, before)
return sampler.Result{}, accepted, false, fmt.Errorf("mtp validation produced %d tokens for %d draft tokens", len(selectedIDs), draftCount)
}
for i, id := range draftIDs {
if selectedIDs[i] != id {
next = sampler.Result{Token: mtpTokenAt(selectedTokens, i)}
break
}
accepted++
if r.Tokenizer.IsEOS(int32(id)) {
done = true
break
}
}
commitSpeculation(caches, accepted, draftCount, before)
*position = before + accepted
emitted, err := r.emitTokens(ctx, request, session, dec, draftResults(draftIDs[:accepted]), final, generated)
if err != nil {
return sampler.Result{}, accepted, emitted || done, err
}
if emitted || done {
return sampler.Result{}, accepted, true, nil
}
if next.Token == nil {
next = sampler.Result{Token: mtpTokenAt(selectedTokens, draftCount)}
}
return next, accepted, false, nil
}
func (r *Runner) acceptSampleMTPDrafts(ctx context.Context, request Request, session *cacheSession, dec *decoder, caches []cache.Cache, position *int, baseLogits *mlx.Array, candidates *mtpDraftCandidates, final *CompletionResponse, generated *int, stats *mtpStats) (sampler.Result, int, bool, error) {
stats.batched++
// acceptMTPDrafts accepts the longest draft prefix that survives rejection
// sampling against the target model. At temperature 0 the distributions are
// point masses, so acceptance reduces to argmax-match.
func (r *Runner) acceptMTPDrafts(ctx context.Context, request Request, session *cacheSession, dec *decoder, caches []cache.Cache, position *int, baseLogits *mlx.Array, candidates *mtpDraftCandidates, final *CompletionResponse, generated *int) (sampler.Result, int, bool, error) {
before := *position
draftCount := candidates.tokens.Dim(1)
scheduleSpeculation(caches, before, draftCount)
@ -753,42 +487,6 @@ func mtpTokenVector(token *mlx.Array) *mlx.Array {
}
}
func (r *Runner) acceptMTPDraftsSerial(ctx context.Context, request Request, session *cacheSession, dec *decoder, caches []cache.Cache, position *int, baseLogits *mlx.Array, draftTokens *mlx.Array, final *CompletionResponse, generated *int) (sampler.Result, int, bool, error) {
logits := baseLogits
accepted := 0
draftIDs := draftTokens.Ints()
for _, id := range draftIDs {
selected := greedyTokenFromLogits(logits)
mlx.Eval(selected)
selectedID := tokenID(selected)
if selectedID != id {
return sampler.Result{Token: mlx.FromValues([]int32{int32(selectedID)}, 1)}, accepted, false, nil
}
hidden := r.Model.Forward(&batch.Batch{
InputIDs: mlx.FromValues([]int32{int32(id)}, 1, 1),
SeqOffsets: []int32{int32(*position)},
SeqQueryLens: []int32{1},
}, caches)
(*position)++
accepted++
res := sampler.Result{Token: mlx.FromValues([]int32{int32(id)}, 1)}
done, err := r.emitTokens(ctx, request, session, dec, []sampler.Result{res}, final, generated)
if err != nil {
return sampler.Result{}, accepted, done, err
}
if done {
return sampler.Result{}, accepted, true, nil
}
logits = r.lastLogits(hidden)
}
return sampler.Result{Token: greedyTokenFromLogits(logits)}, accepted, false, nil
}
// emitTokens records a run of generated tokens to session.outputs, then streams
// them. A trailing EOS stops generation and is recorded but not streamed.
// Returns whether to stop and any cancellation error.
@ -841,36 +539,21 @@ func (r *Runner) lastLogits(hidden *mlx.Array) *mlx.Array {
return r.lastLogitsFromLogits(logits)
}
func (r *Runner) mtpValidationTokens(baseLogits, hiddenSeq *mlx.Array) *mlx.Array {
return greedyTokenFromLogits(r.mtpValidationLogits(baseLogits, hiddenSeq))
}
func (r *Runner) mtpValidationLogits(baseLogits, hiddenSeq *mlx.Array) *mlx.Array {
seqLogits := r.Model.Unembed(hiddenSeq)
return baseLogits.ExpandDims(1).Concatenate(1, seqLogits)
}
func mtpTokenAt(tokens *mlx.Array, index int) *mlx.Array {
return tokens.Slice(mlx.Slice(), mlx.Slice(index)).Squeeze(0)
}
func (r *Runner) lastLogitsFromLogits(logits *mlx.Array) *mlx.Array {
return logits.Slice(mlx.Slice(), mlx.Slice(logits.Dim(1)-1), mlx.Slice()).Squeeze(1)
}
func greedyTokenFromLogits(logits *mlx.Array) *mlx.Array {
return logits.Argmax(-1, false).AsType(mlx.DTypeInt32)
}
// tokenID reads a single-token array as its host id. It goes through the
// item accessor, which evaluates the array first: raw data reads on a lazy
// array race its evaluation and return garbage.
func tokenID(token *mlx.Array) int {
if token == nil {
return -1
}
if token.DType() == mlx.DTypeInt32 {
ids := token.Ints()
if len(ids) > 0 {
return ids[0]
}
}
return token.Int()
}

446
x/mlxrunner/mtp_test.go Normal file
View file

@ -0,0 +1,446 @@
package mlxrunner
import (
"context"
"encoding/json"
"fmt"
"slices"
"strings"
"testing"
"time"
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/x/mlxrunner/batch"
"github.com/ollama/ollama/x/mlxrunner/cache"
"github.com/ollama/ollama/x/mlxrunner/mlx"
"github.com/ollama/ollama/x/mlxrunner/model/base"
sampler "github.com/ollama/ollama/x/mlxrunner/sample"
"github.com/ollama/ollama/x/tokenizer"
)
// skipIfNoMLX skips a test that exercises native MLX when the dynamic library
// is unavailable, as on CI runners without an MLX build.
func skipIfNoMLX(t *testing.T) {
t.Helper()
if err := mlx.CheckInit(); err != nil {
t.Skipf("MLX not available: %v", err)
}
}
// The MTP fakes make hidden state and logits the same tensor (Forward returns
// one-hot logits, Unembed is the identity), so tests fully script target and
// draft predictions.
const mtpTestVocab = 8
// oneHotLogits builds logits with a large value on each listed token id.
func oneHotLogits(tokens []int32) *mlx.Array {
data := make([]float32, len(tokens)*mtpTestVocab)
for i, tok := range tokens {
data[i*mtpTestVocab+int(tok)] = 30
}
return mlx.FromValues(data, 1, len(tokens), mtpTestVocab)
}
// fakeMTPModel is a target whose next-token prediction is a fixed function of
// the input token; Forward also feeds ids to the caches so offsets advance.
type fakeMTPModel struct {
predict map[int32]int32
tok *tokenizer.Tokenizer
// forwards records each Forward call so tests can assert contiguous writes.
forwards []forwardCall
}
type forwardCall struct {
offset int32
n int32
}
func (m *fakeMTPModel) Forward(b *batch.Batch, caches []cache.Cache) *mlx.Array {
mlx.Eval(b.InputIDs)
ids := b.InputIDs.Ints()
m.forwards = append(m.forwards, forwardCall{offset: b.SeqOffsets[0], n: int32(len(ids))})
for _, c := range caches {
if rc, ok := c.(*fakeRewindableCache); ok {
seg := make([]int32, len(ids))
for i, id := range ids {
seg[i] = int32(id)
}
rc.feed(seg)
}
}
preds := make([]int32, len(ids))
for i, id := range ids {
preds[i] = m.predict[int32(id)]
}
return oneHotLogits(preds)
}
func (m *fakeMTPModel) Unembed(x *mlx.Array) *mlx.Array { return x }
func (m *fakeMTPModel) NumLayers() int { return 1 }
func (m *fakeMTPModel) Tokenizer() *tokenizer.Tokenizer { return m.tok }
func (m *fakeMTPModel) MaxContextLength() int { return 4096 }
func (m *fakeMTPModel) LoadWeights(map[string]*mlx.Array) error {
return nil
}
// TokenEmbeddings returns a width-1 embedding holding the token id as a float,
// so a draft can recover which token it is extending from inputs[...,0].
func (m *fakeMTPModel) TokenEmbeddings(inputIDs *mlx.Array) *mlx.Array {
mlx.Eval(inputIDs)
ids := inputIDs.Ints()
data := make([]float32, len(ids))
for i, id := range ids {
data[i] = float32(id)
}
return mlx.FromValues(data, inputIDs.Dim(0), inputIDs.Dim(1), 1)
}
var (
_ base.Model = (*fakeMTPModel)(nil)
_ base.MTPEmbeddingModel = (*fakeMTPModel)(nil)
)
// fakeMTPDraft extends the token in inputEmbeds through predict; a map (not a
// step counter) keeps drafting consistent regardless of batching.
type fakeMTPDraft struct {
predict map[int32]int32
// calls records each Draft call so tests can assert the position convention.
calls []draftCall
}
type draftCall struct {
position int32
from int32
}
func (d *fakeMTPDraft) LoadWeights(map[string]*mlx.Array) error { return nil }
func (d *fakeMTPDraft) Draft(inputEmbeds *mlx.Array, position int32, caches []cache.Cache) (logits, hidden *mlx.Array) {
mlx.Eval(inputEmbeds)
prev := int32(inputEmbeds.Floats()[0])
d.calls = append(d.calls, draftCall{position: position, from: prev})
return oneHotLogits([]int32{d.predict[prev]}), mlx.Zeros(mlx.DTypeFloat32, 1, 1, mtpTestVocab)
}
var (
_ base.DraftModel = (*fakeMTPDraft)(nil)
_ base.MTPDraftModel = (*fakeMTPDraft)(nil)
)
// newTestTokenizer builds a byte-level BPE tokenizer over single-character
// tokens "0".."7" with the given EOS ids, so Decode(id) yields that digit and
// IsEOS reports membership.
func newTestTokenizer(t *testing.T, eos []int32) *tokenizer.Tokenizer {
t.Helper()
vocab := make(map[string]int32, mtpTestVocab)
for i := range mtpTestVocab {
vocab[fmt.Sprintf("%d", i)] = int32(i)
}
model := map[string]any{
"type": "BPE",
"vocab": vocab,
"merges": []string{},
}
data, err := json.Marshal(map[string]any{"model": model})
if err != nil {
t.Fatalf("marshal tokenizer: %v", err)
}
genConfig, err := json.Marshal(map[string]any{"eos_token_id": eos})
if err != nil {
t.Fatalf("marshal generation config: %v", err)
}
tok, err := tokenizer.LoadFromBytesWithConfig(data, &tokenizer.TokenizerConfig{GenerationConfigJSON: genConfig})
if err != nil {
t.Fatalf("load tokenizer: %v", err)
}
return tok
}
// mtpTestRunner wires a Runner with the MTP fakes and a real sampler
// registered with opts.
func mtpTestRunner(t *testing.T, predict map[int32]int32, eos []int32, opts sampler.Options) *Runner {
t.Helper()
tok := newTestTokenizer(t, eos)
r := &Runner{
Model: &fakeMTPModel{predict: predict, tok: tok},
Tokenizer: tok,
Sampler: sampler.New(4096),
}
r.Sampler.Add(pipelineSlot, opts, nil)
t.Cleanup(func() { r.Sampler.Remove(pipelineSlot) })
return r
}
// collectResponses drains a buffered response channel into the concatenated
// streamed content and the captured terminal response.
func collectResponses(ch chan CompletionResponse) (content string, final CompletionResponse) {
var b strings.Builder
for {
select {
case resp := <-ch:
if resp.Done {
return b.String(), resp
}
b.WriteString(resp.Content)
default:
return b.String(), final
}
}
}
func TestAcceptMTPDraftsGreedyAcceptAll(t *testing.T) {
skipIfNoMLX(t)
// Target predicts 1->2->3->4 along the accepted chain; the draft proposed
// exactly that, so every draft token is accepted and the bonus token is the
// target's prediction after the last accepted token.
predict := map[int32]int32{1: 2, 2: 3, 3: 4, 4: 5}
r := mtpTestRunner(t, predict, nil, sampler.Options{})
caches, _ := newMTPTestCaches(1)
candidates := scriptedCandidates(r, []int32{2, 3, 4})
baseLogits := oneHotLogits([]int32{2}).Squeeze(1) // target prediction after the seed token 1
session, ch := newMTPTestSession(caches)
position := caches[0].Offset()
final := CompletionResponse{Done: true}
generated := 0
req := Request{Responses: ch, CompletionRequest: CompletionRequest{Options: api.Options{NumPredict: 100}}}
next, accepted, done, err := r.acceptMTPDrafts(context.Background(), req, session, &decoder{tokenizer: r.Tokenizer}, caches, &position, baseLogits, candidates, &final, &generated)
if err != nil {
t.Fatalf("acceptMTPDrafts: %v", err)
}
if accepted != 3 {
t.Fatalf("accepted = %d, want 3", accepted)
}
if done {
t.Fatalf("done = true, want false")
}
if got := tokenID(next.Token); got != 5 {
t.Fatalf("bonus token = %d, want 5", got)
}
if position != 3 {
t.Fatalf("position = %d, want 3", position)
}
if got := caches[0].Offset(); got != 3 {
t.Fatalf("cache offset = %d, want 3 (all drafts kept)", got)
}
if generated != 3 {
t.Fatalf("generated = %d, want 3", generated)
}
}
func TestAcceptMTPDraftsGreedyMismatch(t *testing.T) {
skipIfNoMLX(t)
// Target predicts 1->2->9 but the draft proposed 2 then 7: the second draft
// token mismatches, so only the first is accepted and the bonus is the
// target's own prediction (3) at the rejection point.
predict := map[int32]int32{1: 2, 2: 3, 7: 0}
r := mtpTestRunner(t, predict, nil, sampler.Options{})
caches, _ := newMTPTestCaches(1)
candidates := scriptedCandidates(r, []int32{2, 7})
baseLogits := oneHotLogits([]int32{2}).Squeeze(1)
session, ch := newMTPTestSession(caches)
position := caches[0].Offset()
final := CompletionResponse{Done: true}
generated := 0
req := Request{Responses: ch, CompletionRequest: CompletionRequest{Options: api.Options{NumPredict: 100}}}
next, accepted, done, err := r.acceptMTPDrafts(context.Background(), req, session, &decoder{tokenizer: r.Tokenizer}, caches, &position, baseLogits, candidates, &final, &generated)
if err != nil {
t.Fatalf("acceptMTPDrafts: %v", err)
}
if accepted != 1 {
t.Fatalf("accepted = %d, want 1", accepted)
}
if done {
t.Fatalf("done = true, want false")
}
if got := tokenID(next.Token); got != 3 {
t.Fatalf("bonus token = %d, want 3 (target prediction at rejection)", got)
}
if position != 1 {
t.Fatalf("position = %d, want 1", position)
}
if got := caches[0].Offset(); got != 1 {
t.Fatalf("cache offset = %d, want 1 (rolled back to accepted)", got)
}
}
func TestAcceptMTPDraftsGreedyEOS(t *testing.T) {
skipIfNoMLX(t)
// The second accepted draft token is EOS: it is recorded but stops
// generation, no bonus token is produced, and the cache keeps both tokens.
const eos int32 = 6
predict := map[int32]int32{1: 2, 2: eos, eos: 0}
r := mtpTestRunner(t, predict, []int32{eos}, sampler.Options{})
caches, _ := newMTPTestCaches(1)
candidates := scriptedCandidates(r, []int32{2, eos})
baseLogits := oneHotLogits([]int32{2}).Squeeze(1)
session, ch := newMTPTestSession(caches)
position := caches[0].Offset()
final := CompletionResponse{Done: true, DoneReason: 1}
generated := 0
req := Request{Responses: ch, CompletionRequest: CompletionRequest{Options: api.Options{NumPredict: 100}}}
next, accepted, done, err := r.acceptMTPDrafts(context.Background(), req, session, &decoder{tokenizer: r.Tokenizer}, caches, &position, baseLogits, candidates, &final, &generated)
if err != nil {
t.Fatalf("acceptMTPDrafts: %v", err)
}
if accepted != 2 {
t.Fatalf("accepted = %d, want 2 (token + EOS)", accepted)
}
if !done {
t.Fatalf("done = false, want true")
}
if next.Token != nil {
t.Fatalf("bonus token = %d, want none after EOS", tokenID(next.Token))
}
if final.DoneReason != 0 {
t.Fatalf("DoneReason = %d, want 0 (EOS)", final.DoneReason)
}
if position != 2 {
t.Fatalf("position = %d, want 2", position)
}
}
func TestRunMTPDecodeGreedy(t *testing.T) {
skipIfNoMLX(t)
// The seed token 1 is the last prefill token; its prediction (2) is the
// first generated token. The decode then walks 2->3->4->EOS. The draft
// proposes the correct chain so steps are accepted in a single forward.
const eos int32 = 7
predict := map[int32]int32{1: 2, 2: 3, 3: 4, 4: eos, eos: 0}
r := mtpTestRunner(t, predict, []int32{eos}, sampler.Options{})
// The draft mirrors the target chain, so every drafted token is accepted.
draft := &fakeMTPDraft{predict: predict}
r.Draft = draft
caches, _ := newMTPTestCaches(1)
session, ch := newMTPTestSession(caches)
position := 1 // one prefill token already processed
req := Request{
Responses: ch,
Tokens: []int32{0},
CompletionRequest: CompletionRequest{Options: api.Options{NumPredict: 20}},
SamplerOpts: sampler.Options{},
}
if err := r.runMTPDecode(context.Background(), req, session, caches, []int32{1}, &position, time.Now()); err != nil {
t.Fatalf("runMTPDecode: %v", err)
}
content, final := collectResponses(ch)
if content != "234" {
t.Fatalf("content = %q, want %q", content, "234")
}
if !final.Done {
t.Fatalf("final response not marked Done")
}
if final.DoneReason != 0 {
t.Fatalf("DoneReason = %d, want 0 (EOS)", final.DoneReason)
}
if got := []int32{2, 3, 4, eos}; !slices.Equal(session.outputs, got) {
t.Fatalf("session outputs = %v, want %v", session.outputs, got)
}
// The target writes the caches contiguously: the seed token at offset 1,
// the round's current token at offset 2, then the 4-token validation
// forward at offset 3.
wantForwards := []forwardCall{{offset: 1, n: 1}, {offset: 2, n: 1}, {offset: 3, n: 4}}
model := r.Model.(*fakeMTPModel)
if !slices.Equal(model.forwards, wantForwards) {
t.Fatalf("target forwards = %v, want %v", model.forwards, wantForwards)
}
// Single-position drafting anchors every Draft call in a round at the
// last target-seen position (the current token, offset 2), while the
// extended token advances along the proposed chain.
wantDraft := []draftCall{{2, 2}, {2, 3}, {2, 4}, {2, eos}}
if !slices.Equal(draft.calls, wantDraft) {
t.Fatalf("draft calls = %v, want %v", draft.calls, wantDraft)
}
}
func TestRunMTPDecodeSampled(t *testing.T) {
skipIfNoMLX(t)
// The same chain at temperature 1: because oneHotLogits uses a large gap,
// the proposal and target distributions are effectively point masses, so the
// rejection-sampling accept path that the sampled and greedy paths now share
// accepts the mirrored draft chain deterministically.
const eos int32 = 7
predict := map[int32]int32{1: 2, 2: 3, 3: 4, 4: eos, eos: 0}
r := mtpTestRunner(t, predict, []int32{eos}, sampler.Options{Temperature: 1, Seed: 42, UseSeed: true})
r.Draft = &fakeMTPDraft{predict: predict}
if !r.useMTP(sampler.Options{Temperature: 1}) {
t.Fatalf("useMTP rejected a sampled request")
}
caches, _ := newMTPTestCaches(1)
session, ch := newMTPTestSession(caches)
position := 1
req := Request{
Responses: ch,
Tokens: []int32{0},
CompletionRequest: CompletionRequest{Options: api.Options{NumPredict: 20}},
SamplerOpts: sampler.Options{Temperature: 1, Seed: 42, UseSeed: true},
}
if err := r.runMTPDecode(context.Background(), req, session, caches, []int32{1}, &position, time.Now()); err != nil {
t.Fatalf("runMTPDecode: %v", err)
}
content, final := collectResponses(ch)
if content != "234" {
t.Fatalf("content = %q, want %q", content, "234")
}
if final.DoneReason != 0 {
t.Fatalf("DoneReason = %d, want 0 (EOS)", final.DoneReason)
}
if got := []int32{2, 3, 4, eos}; !slices.Equal(session.outputs, got) {
t.Fatalf("session outputs = %v, want %v", session.outputs, got)
}
}
// newMTPTestCaches returns n rewindable fake caches sharing one snapshot
// tracker, matching the cache.Cache the speculation helpers drive.
func newMTPTestCaches(n int) ([]cache.Cache, *snapshotTracker) {
tr := &snapshotTracker{}
caches := make([]cache.Cache, n)
for i := range caches {
caches[i] = &fakeRewindableCache{tracker: tr}
}
return caches, tr
}
// newMTPTestSession wraps caches in a cacheSession with a buffered response
// channel large enough to hold a short decode run without a reader.
func newMTPTestSession(caches []cache.Cache) (*cacheSession, chan CompletionResponse) {
ch := make(chan CompletionResponse, 256)
return &cacheSession{caches: caches}, ch
}
// scriptedCandidates builds draft candidates by running the real generator
// against a draft whose prediction chain, starting from seed token 0, yields
// exactly the requested tokens. Using the real generator means the proposal
// distributions match what acceptMTPDrafts expects.
func scriptedCandidates(r *Runner, tokens []int32) *mtpDraftCandidates {
chain := map[int32]int32{}
prev := int32(0)
for _, tok := range tokens {
chain[prev] = tok
prev = tok
}
draft := &fakeMTPDraft{predict: chain}
target := r.Model.(base.MTPEmbeddingModel)
seed := mlx.FromValues([]int32{0}, 1, 1)
hidden := mlx.Zeros(mlx.DTypeFloat32, 1, 1, mtpTestVocab)
return r.generateMTPDraftCandidates(draft, target, seed, hidden, nil, 0, len(tokens))
}

View file

@ -137,11 +137,8 @@ func (r *Runner) TextGenerationPipeline(ctx context.Context, request Request) er
// Register the sampler after prefill completes.
r.Sampler.Add(pipelineSlot, request.SamplerOpts, inputs)
if r.useGreedyMTP(request.SamplerOpts) {
return r.runGreedyMTPDecode(ctx, request, session, caches, tokens[processed:], &position, now)
}
if r.useSampleMTP(request.SamplerOpts) {
return r.runSampleMTPDecode(ctx, request, session, caches, tokens[processed:], &position, now)
if r.useMTP(request.SamplerOpts) {
return r.runMTPDecode(ctx, request, session, caches, tokens[processed:], &position, now)
}
step := func(token *mlx.Array) sampler.Result {