The heuristic schedule grew the draft toward a fixed cap on acceptance alone, maximizing accepted-tokens-per-step rather than throughput, and on a steep-forward target it regressed below no speculation. Replace it with an engine-level controller that drafts the depth maximizing committed-tokens-per-wallclock from live per-position acceptance and persisted per-width forward cost, with no draft-length cap; the heuristic schedule and the OLLAMA_MLX_MTP_* env vars go with it.
526 lines
19 KiB
Go
526 lines
19 KiB
Go
package mlxrunner
|
|
|
|
import (
|
|
"fmt"
|
|
"slices"
|
|
"time"
|
|
|
|
"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"
|
|
)
|
|
|
|
// drafter proposes speculative tokens for the engine to validate, learning
|
|
// the conversation through the committed-stream reports.
|
|
type drafter interface {
|
|
// propose returns up to maxTokens draft tokens with their proposal
|
|
// distributions, or nil to decode this round plainly.
|
|
propose(current *mlx.Array, maxTokens int) *draftCandidates
|
|
|
|
// committed reports a run of tokens committed to the target caches:
|
|
// tokens[i] sits at slot position+i and hiddens row i is the target
|
|
// hidden state at that slot. Runs arrive in slot order — prefill
|
|
// chunks, the decode seed, then each round's validated tokens.
|
|
committed(tokens, hiddens *mlx.Array, position int)
|
|
|
|
// finish reports generation ended with current sampled but never
|
|
// committed, so the drafter can settle state tracking the target caches.
|
|
finish(current *mlx.Array)
|
|
|
|
// flush writes any buffered committed reports through to the draft
|
|
// caches. A drafter without draft caches has nothing to write.
|
|
flush()
|
|
|
|
close()
|
|
}
|
|
|
|
// speculation is the persistent speculative-decoding subsystem of a Runner —
|
|
// one per loaded model, holding everything that outlives a request: the draft
|
|
// model, the target-cache partition, and the depth state learned across
|
|
// requests so each request starts at the proven-out depth. Each request opens a
|
|
// short-lived speculationSession cursor over it; nothing here is rebuilt per request. A
|
|
// nil *speculation means the checkpoint ships no draft head, so every request
|
|
// decodes plainly.
|
|
type speculation struct {
|
|
r *Runner
|
|
draft base.DraftModel
|
|
|
|
// caches is the whole persistent slice, passed to every forward; draftKV
|
|
// are the draft head's own caches and targets are the rest — the caches the
|
|
// target forward writes, which speculation snapshots and rollback cover.
|
|
// Bound the first time the caches exist (the Runner reuses one cache slice
|
|
// for its life) and stable thereafter.
|
|
caches []cache.Cache
|
|
draftKV []cache.Cache
|
|
targets []cache.Cache
|
|
|
|
// depth selects each request's draft length and owns the cost/acceptance
|
|
// models and probe cadence it learns across requests.
|
|
depth *depthController
|
|
}
|
|
|
|
// newSpeculation builds the speculative-decoding subsystem for a loaded model,
|
|
// or nil when the checkpoint ships no draft head.
|
|
func newSpeculation(r *Runner, draft base.DraftModel) *speculation {
|
|
if draft == nil {
|
|
return nil
|
|
}
|
|
return &speculation{r: r, draft: draft, depth: newDepthController()}
|
|
}
|
|
|
|
// bind computes the draft/target cache partition the first time the persistent
|
|
// caches exist; later requests reuse the same slice, so it runs once.
|
|
func (s *speculation) bind(caches []cache.Cache) {
|
|
if s.caches != nil {
|
|
if !slices.Equal(s.caches, caches) {
|
|
panic("speculation: cache slice changed between requests")
|
|
}
|
|
return
|
|
}
|
|
draftKV := s.draft.DraftCaches(caches)
|
|
|
|
// Partition caches into target slots (everything not in draftKV) in one
|
|
// pass. The count check rejects a draft slot that isn't a member of caches.
|
|
targets := make([]cache.Cache, 0, len(caches))
|
|
for _, c := range caches {
|
|
if !slices.Contains(draftKV, c) {
|
|
targets = append(targets, c)
|
|
}
|
|
}
|
|
if len(caches)-len(targets) != len(draftKV) {
|
|
panic("speculation: DraftCaches must select slots of the cache slice")
|
|
}
|
|
|
|
s.caches = caches
|
|
s.draftKV = draftKV
|
|
s.targets = targets
|
|
}
|
|
|
|
// speculationSession is the per-request cursor over the persistent speculation:
|
|
// it owns the drafter and runs the validate rounds. A nil session is a plain
|
|
// decode.
|
|
type speculationSession struct {
|
|
spec *speculation
|
|
drafter drafter
|
|
enabled bool // whether this request drafts; false parks (maintain-only)
|
|
limit int // current draft length
|
|
stats specStats
|
|
|
|
// Cost sampling: each round's wall time (start to next start, spanning the
|
|
// next emit's sync) is attributed to its draft depth only when the depth
|
|
// matches the previous round's, since batch-shape transitions inflate it.
|
|
lastRoundStart time.Time
|
|
prevDrafts int
|
|
roundDrafts int
|
|
}
|
|
|
|
// open returns the speculation cursor for this request or nil when the model ships
|
|
// no draft head (a nil receiver), which decodes plainly.
|
|
func (s *speculation) open(request Request, caches []cache.Cache) *speculationSession {
|
|
if s == nil {
|
|
return nil
|
|
}
|
|
s.bind(caches)
|
|
d := newMTPDrafter(s)
|
|
|
|
// Logprobs are not yet supported, so a logprobs request keeps a speculationSession
|
|
// only to maintain a draft cache in lockstep (permanently parked).
|
|
opts := request.SamplerOpts
|
|
enabled := !opts.Logprobs && opts.TopLogprobs == 0
|
|
|
|
spec := &speculationSession{spec: s, drafter: d, enabled: enabled, prevDrafts: -1, roundDrafts: -1}
|
|
if enabled {
|
|
spec.limit = s.depth.scheduled
|
|
spec.stats.maxDraft = spec.limit
|
|
}
|
|
return spec
|
|
}
|
|
|
|
// beginRound records the previous round's cost sample (its wall time runs to
|
|
// this round's start) and starts timing the new one.
|
|
func (s *speculationSession) beginRound() {
|
|
now := time.Now()
|
|
if !s.lastRoundStart.IsZero() && s.roundDrafts >= 0 {
|
|
s.stats.recordRound(s.roundDrafts)
|
|
if s.roundDrafts == s.prevDrafts {
|
|
s.spec.depth.cost.observe(s.roundDrafts, now.Sub(s.lastRoundStart))
|
|
}
|
|
s.prevDrafts = s.roundDrafts
|
|
}
|
|
s.lastRoundStart = now
|
|
}
|
|
|
|
// endRound records a completed round's draft depth, proposal outcome, and the
|
|
// controller's next draft length. observed is the leading draft positions the
|
|
// acceptance model learns from: the full round, except an accepted EOS holds out
|
|
// positions past it (a terminator, not a target rejection).
|
|
func (s *speculationSession) endRound(drafted, accepted, observed int) {
|
|
s.roundDrafts = drafted
|
|
s.stats.iterations++
|
|
s.stats.drafted += drafted
|
|
s.stats.accepted += accepted
|
|
if s.enabled {
|
|
if observed > 0 {
|
|
s.spec.depth.acc.observe(observed, accepted)
|
|
}
|
|
s.limit = s.spec.depth.next()
|
|
s.stats.maxDraft = max(s.stats.maxDraft, s.limit)
|
|
}
|
|
}
|
|
|
|
func (s *speculationSession) committed(tokens, hiddens *mlx.Array, position int) {
|
|
if s == nil {
|
|
return
|
|
}
|
|
s.drafter.committed(tokens, hiddens, position)
|
|
}
|
|
|
|
// finish reports the end of generation to the drafter: current was sampled
|
|
// after the last committed slot and will never be committed.
|
|
func (s *speculationSession) finish(current *mlx.Array) {
|
|
if s == nil {
|
|
return
|
|
}
|
|
s.drafter.finish(current)
|
|
}
|
|
|
|
// flush writes the drafter's buffered committed reports to the draft caches.
|
|
func (s *speculationSession) flush() {
|
|
if s == nil {
|
|
return
|
|
}
|
|
s.drafter.flush()
|
|
}
|
|
|
|
func (s *speculationSession) close() {
|
|
if s == nil {
|
|
return
|
|
}
|
|
s.drafter.close()
|
|
}
|
|
|
|
// speculativeDecoder decodes one speculative round per call: the engine
|
|
// forwards the current token (emitted by the previous call, so a token that
|
|
// ends generation is never forwarded) fused with the drafter's proposals,
|
|
// returning the round's accepted tokens followed by the next token. The seed
|
|
// primes current and is never returned. While the engine cannot draft (parked
|
|
// at depth zero, or nothing committed to propose from) calls delegate to an
|
|
// inner pipelined decoder at plain decode speed.
|
|
type speculativeDecoder struct {
|
|
s *speculationSession
|
|
position int
|
|
current sampler.Result // emitted (or the seed), not yet forwarded
|
|
inner *pipelinedDecoder // pipelines plain tokens while parked; nil while drafting
|
|
}
|
|
|
|
// decoder returns the decoder for this engine's session. A speculationSession that
|
|
// cannot draft (logprobs) has no depth controller and permanently parks,
|
|
// running the inner pipelined decoder whose reports keep the draft KV level.
|
|
func (s *speculationSession) decoder(seed []int32, position int) decoder {
|
|
current := sampler.Result{Token: mlx.FromValues(seed, len(seed))}
|
|
mlx.Pin(current.Arrays()...)
|
|
return &speculativeDecoder{s: s, position: position, current: current}
|
|
}
|
|
|
|
func (st *speculativeDecoder) next(remaining int) ([]sampler.Result, error) {
|
|
// Route: end a parked stretch by emitting the inner sample, draft on a
|
|
// positive length and a primed drafter, else decode parked.
|
|
var results []sampler.Result
|
|
if s := st.s; st.inner != nil && s.limit > 0 {
|
|
results = st.resume()
|
|
} else {
|
|
s.beginRound()
|
|
var candidates *draftCandidates
|
|
if s.limit > 0 {
|
|
// A round emits the accepted drafts plus one more token (the bonus
|
|
// or residual), so cap the draft one below the remaining budget to
|
|
// land that extra token within it rather than overshooting. At
|
|
// remaining 1 the cap is 0 and the last token decodes plainly.
|
|
candidates = s.drafter.propose(st.current.Token, min(s.limit, remaining-1))
|
|
}
|
|
var accepted, observed int
|
|
var err error
|
|
if candidates == nil {
|
|
results, err = st.park(remaining)
|
|
} else {
|
|
// candidates stays pinned across accept's internal sweep and the
|
|
// draft-count read below; accept pins only its own intermediates.
|
|
mlx.Pin(candidates.tokens)
|
|
defer mlx.Unpin(candidates.tokens)
|
|
results, accepted, observed, err = st.s.accept(&st.position, st.current, candidates)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
drafted := 0
|
|
if candidates != nil {
|
|
drafted = candidates.tokens.Dim(1)
|
|
}
|
|
s.endRound(drafted, accepted, observed)
|
|
}
|
|
|
|
st.advance(results[len(results)-1])
|
|
return results, nil
|
|
}
|
|
|
|
// advance retires the last returned token as the next call's current, pinned
|
|
// across the sweeps the next call runs before reading it. Nothing is forced here.
|
|
func (st *speculativeDecoder) advance(next sampler.Result) {
|
|
mlx.Pin(next.Arrays()...)
|
|
mlx.Unpin(st.current.Arrays()...)
|
|
st.current = next
|
|
}
|
|
|
|
// resume ends a parked stretch: the inner decoder's in-flight sample (sampled
|
|
// but never forwarded) is exactly the current token a drafting round expects,
|
|
// so emit it and let the next call draft from it.
|
|
func (st *speculativeDecoder) resume() []sampler.Result {
|
|
next, position := st.inner.detach()
|
|
st.position = position
|
|
st.inner = nil
|
|
// No round spans this call, so the next beginRound attributes no cost.
|
|
st.s.roundDrafts = -1
|
|
// detach handed over the pin; advance re-pins, no sweep in between.
|
|
mlx.Unpin(next.Arrays()...)
|
|
return []sampler.Result{next}
|
|
}
|
|
|
|
// park decodes one pipelined plain token while the engine cannot draft. Each
|
|
// is a depth-0 round in the controller's accounting, and the inner decoder's
|
|
// reports keep the drafter primed and maintained.
|
|
func (st *speculativeDecoder) park(remaining int) ([]sampler.Result, error) {
|
|
s := st.s
|
|
if st.inner == nil {
|
|
st.inner = s.spec.r.pipelinedDecoder(s, s.spec.caches, st.current.Token.ExpandDims(-1), st.position)
|
|
}
|
|
return st.inner.next(remaining)
|
|
}
|
|
|
|
func (st *speculativeDecoder) close() {
|
|
if st.inner != nil {
|
|
// Ended while parked: the inner decoder's close settles the drafter
|
|
// with its in-flight sample.
|
|
st.inner.close()
|
|
} else {
|
|
// The final token was emitted but never forwarded; its report settles
|
|
// the drafter level with the caches' resting offset.
|
|
st.s.finish(st.current.Token)
|
|
}
|
|
mlx.Unpin(st.current.Arrays()...)
|
|
st.s.logStats()
|
|
}
|
|
|
|
// draftCandidates is one round's draft tokens and the proposal distribution
|
|
// each was sampled from, weighed against the target during acceptance.
|
|
type draftCandidates struct {
|
|
tokens *mlx.Array
|
|
dist sampler.Distribution
|
|
}
|
|
|
|
func (c *draftCandidates) Arrays() []*mlx.Array {
|
|
if c == nil {
|
|
return nil
|
|
}
|
|
return append([]*mlx.Array{c.tokens}, c.dist.Arrays()...)
|
|
}
|
|
|
|
// 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.
|
|
func scheduleSpeculation(caches []cache.Cache, before, draftCount int) {
|
|
offsets := make([]int, draftCount)
|
|
for i := range offsets {
|
|
offsets[i] = before + i
|
|
}
|
|
for _, c := range caches {
|
|
if c != nil {
|
|
c.PrepareSnapshots(offsets)
|
|
}
|
|
}
|
|
}
|
|
|
|
// commitSpeculation rolls every cache back to before+accepted, keeping only
|
|
// the accepted prefix; full acceptance needs no restore. Rollback tries a
|
|
// live rewind first (Restore(nil)) and falls back to the captured snapshot.
|
|
func commitSpeculation(caches []cache.Cache, accepted, draftCount, before int) {
|
|
target := before + accepted
|
|
for _, c := range caches {
|
|
if c == nil {
|
|
continue
|
|
}
|
|
snaps := c.TakeSnapshots()
|
|
if accepted < draftCount {
|
|
// Close the snapshots we won't restore from before restoring: a
|
|
// snapshot restore on a wrapped RotatingKVCache copies out every
|
|
// outstanding lazy snapshot before it rebuilds the buffer, so
|
|
// dropping the unused ones first stops that copy-out from
|
|
// materializing snapshots we are about to discard anyway.
|
|
for i, s := range snaps {
|
|
if s != nil && i != accepted {
|
|
s.Close()
|
|
snaps[i] = nil
|
|
}
|
|
}
|
|
if !c.Restore(nil, target) && !c.Restore(snaps[accepted], target) {
|
|
panic(fmt.Sprintf("speculation: cache restore to %d failed", target))
|
|
}
|
|
}
|
|
for _, s := range snaps {
|
|
if s != nil {
|
|
s.Close()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// accept accepts the longest draft prefix that survives rejection sampling,
|
|
// returning the accepted drafts followed by the target's own next token
|
|
// (residual at a rejection, bonus past a full run), except an accepted EOS
|
|
// ends the run with no continuation. observed is the leading positions the
|
|
// acceptance model learns from, capped at the EOS (a terminator, not a target
|
|
// rejection). NumPredict is the decode loop's to enforce, so a token past the
|
|
// budget is left for decode to drop, not cut here.
|
|
//
|
|
// The caller keeps current and the candidate tokens pinned across the call,
|
|
// since accept sweeps before its eval and reads both afterward; accept pins
|
|
// only the intermediates it produces.
|
|
func (s *speculationSession) accept(position *int, current sampler.Result, candidates *draftCandidates) (results []sampler.Result, accepted, observed int, err error) {
|
|
r := s.spec.r
|
|
before := *position
|
|
draftCount := candidates.tokens.Dim(1)
|
|
scheduleSpeculation(s.spec.targets, before+1, draftCount)
|
|
|
|
// Every exit between schedule and commit must drain the snapshot
|
|
// schedule and roll the speculative writes back out of the live caches:
|
|
// an undrained schedule panics the next PrepareSnapshots, and
|
|
// uncommitted speculative tokens reach the trie through session.close.
|
|
committed := false
|
|
commit := func(keep int) {
|
|
if committed {
|
|
return
|
|
}
|
|
committed = true
|
|
commitSpeculation(s.spec.targets, keep, draftCount, before+1)
|
|
}
|
|
defer commit(0)
|
|
|
|
hiddenSeq := r.Model.Forward(&batch.Batch{
|
|
InputIDs: current.Token.ExpandDims(-1).Concatenate(1, candidates.tokens),
|
|
SeqOffsets: []int32{int32(before)},
|
|
SeqQueryLens: []int32{int32(draftCount + 1)},
|
|
}, s.spec.caches)
|
|
|
|
// Row i of the fused hidden is the state after the token at before+i, so
|
|
// the rows already line up with the drafts: row 0 (current's state)
|
|
// predicts draft 0, and the row after the last accepted draft is the
|
|
// bonus row. No separate base-logits forward exists on this path.
|
|
targetDist := r.Sampler.Distribution(pipelineSlot, r.Model.Unembed(hiddenSeq), candidates.tokens)
|
|
draftDist := candidates.dist
|
|
acceptedMask := r.sampleAcceptedMask(targetDist.SliceRows(0, draftCount), draftDist, candidates.tokens)
|
|
|
|
// The next token is sampled for every possible outcome before anything
|
|
// is evaluated — the residual at each rejection point in one batched
|
|
// draw, plus the bonus row — so a single Eval covers acceptance and the
|
|
// next token instead of a second host round trip after the rejection
|
|
// point is known.
|
|
residualTokens := r.Sampler.SampleDistribution(pipelineSlot, targetDist.SliceRows(0, draftCount).ResidualAgainst(draftDist))
|
|
bonusToken := r.sampleTokenAt(targetDist, draftCount)
|
|
|
|
// Pin the arrays read after the eval, then sweep so the draft proposal
|
|
// chain and this validation forward's intermediates are freed as the eval
|
|
// consumes them, the way the plain decode dispatch sweeps before its eval.
|
|
// current and the candidate tokens stay pinned by the caller across the call.
|
|
live := []*mlx.Array{hiddenSeq, acceptedMask, residualTokens, bonusToken}
|
|
mlx.Pin(live...)
|
|
defer mlx.Unpin(live...)
|
|
mlx.Sweep()
|
|
mlx.Eval(candidates.tokens, acceptedMask, residualTokens, bonusToken)
|
|
|
|
draftIDs := candidates.tokens.Ints()
|
|
acceptedFlags := acceptedMask.Ints()
|
|
for _, ok := range acceptedFlags {
|
|
if ok == 0 {
|
|
break
|
|
}
|
|
accepted++
|
|
}
|
|
if accepted > draftCount {
|
|
return nil, 0, 0, fmt.Errorf("speculation validation accepted %d tokens for %d draft tokens", accepted, draftCount)
|
|
}
|
|
observed = draftCount
|
|
|
|
// Find where an accepted EOS ends the run, before committing, so the cut
|
|
// is known while the per-token rollback snapshots still exist. The EOS also
|
|
// caps observed: positions past it are held out, not logged as rejections.
|
|
commitIDs := make([]int32, 0, accepted+1)
|
|
keep := accepted
|
|
done := false
|
|
for i, id := range draftIDs[:accepted] {
|
|
commitIDs = append(commitIDs, int32(id))
|
|
if r.Tokenizer.IsEOS(int32(id)) {
|
|
done = true
|
|
accepted = i + 1
|
|
observed = accepted
|
|
// Leave the EOS's own state uncommitted: the next sequence won't
|
|
// contain this EOS, and recurrent state can't drop a folded-in
|
|
// token, so committing it would carry the caches past the
|
|
// reusable prefix and force the next sequence to recompute.
|
|
keep = i
|
|
break
|
|
}
|
|
}
|
|
|
|
commit(keep)
|
|
*position = before + 1 + keep
|
|
|
|
// Report the validated run (current plus kept drafts) to the drafter before
|
|
// returning, so a cancelled emission still leaves it matching the caches. A
|
|
// done generation's final token is uncommitted; it reaches finish instead.
|
|
runIDs := append([]int32{int32(current.Token.Int())}, commitIDs[:keep]...)
|
|
s.drafter.committed(
|
|
mlx.FromValues(runIDs, 1, len(runIDs)),
|
|
hiddenSeq.Slice(mlx.Slice(), mlx.Slice(0, len(runIDs)), mlx.Slice()),
|
|
before)
|
|
|
|
results = draftResults(draftIDs[:accepted])
|
|
if done {
|
|
r.Sampler.Commit(pipelineSlot, commitIDs)
|
|
return results, accepted, observed, nil
|
|
}
|
|
|
|
var nextID int32
|
|
if accepted < draftCount {
|
|
nextID = int32(residualTokens.Ints()[accepted])
|
|
} else {
|
|
nextID = int32(bonusToken.Int())
|
|
}
|
|
commitIDs = append(commitIDs, nextID)
|
|
r.Sampler.Commit(pipelineSlot, commitIDs)
|
|
|
|
results = append(results, sampler.Result{Token: mlx.FromValues([]int32{nextID}, 1)})
|
|
return results, accepted, observed, nil
|
|
}
|
|
|
|
func (r *Runner) sampleAcceptedMask(targetDist, draftDist sampler.Distribution, draftTokens *mlx.Array) *mlx.Array {
|
|
p := targetDist.Prob(draftTokens)
|
|
q := draftDist.Prob(draftTokens)
|
|
acceptP := mlx.Minimum(p.Divide(q), mlx.FromValue(float32(1)))
|
|
return r.Sampler.Bernoulli(pipelineSlot, acceptP).AsType(mlx.DTypeInt32)
|
|
}
|
|
|
|
func (r *Runner) sampleTokenAt(dist sampler.Distribution, index int) *mlx.Array {
|
|
return r.Sampler.SampleDistribution(pipelineSlot, dist.SliceRows(index, index+1))
|
|
}
|
|
|
|
// draftResults wraps accepted draft ids as sampler results; drafts carry no
|
|
// logprobs, so only the token id is set.
|
|
func draftResults(ids []int) []sampler.Result {
|
|
results := make([]sampler.Result, len(ids))
|
|
for i, id := range ids {
|
|
results[i] = sampler.Result{Token: mlx.FromValues([]int32{int32(id)}, 1)}
|
|
}
|
|
return results
|
|
}
|