Files
ollama/x/mlxrunner/speculate_depth.go
Jesse Gross 505e35f2b9 mlxrunner: choose the speculative draft length to maximize throughput
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.
2026-06-22 15:25:45 -07:00

303 lines
11 KiB
Go

package mlxrunner
import (
"fmt"
"slices"
"strings"
"time"
)
// depthProbeInterval is the base cadence at which the controller drafts one past its
// selection to refresh the next position up; without it a shallow controller never
// notices a deeper draft becoming worthwhile.
const depthProbeInterval = 4
// depthProbeIntervalMax caps the probe backoff. Probes that keep changing nothing
// double the interval up to this cap, trading slower re-engagement — worst case
// plain decode speed, never below — for fewer probes.
const depthProbeIntervalMax = 512
// depthController drafts argmax_N EV(N) where EV(N) = committed(N) / cost(N), over
// depths from 0 (plain decode) up to one past the frontier. The depth-0 floor lets
// it stop speculating when no draft pays; the frontier ceiling keeps it from scoring
// a depth on the optimistic inherited rate, so it climbs outward one position at a
// time as acceptance is measured rather than leaping deep.
//
// It holds the depth-selection state learned across requests — the target forward's
// per-depth cost curve, the drafts' per-position acceptance rates, the probe cadence,
// and the depth scheduled for the next round — persisted on the speculation so a fresh
// request starts at the proven-out depth instead of re-ramping from shallow.
type depthController struct {
cost *costModel
acc *acceptanceModel
probed bool // the previous round drafted a probe
// scheduled is the draft depth next() chose for the upcoming round, carried
// across requests so a new request's first round consumes it instead of
// recomputing at the boundary. Its zero value is the depth-0 warmup start.
scheduled int
// Probe cadence, persisted so a backed-off request need not restart at the base.
probeInterval int // rounds between probes; backs off while probes change nothing
probeSince int // rounds since the cadence was last calibrated
lastSelected int // the selection the cadence was calibrated against
}
func newDepthController() *depthController {
return &depthController{cost: newCostModel(), acc: newAcceptanceModel(), probeInterval: depthProbeInterval}
}
func (c *depthController) frontier() int { return c.acc.frontier() }
// next returns the draft depth for the upcoming step: the EV-optimal depth (capped
// at frontier+1), except periodically it probes one past the selection to refresh
// the next position up. The probe stays within the frontier window. The cadence
// doubles toward its cap while probes change nothing and resets on any selection
// change, giving the new selection a full interval to settle. The chosen depth is
// recorded in c.scheduled for the next request's open to consume.
func (c *depthController) next() (depth int) {
defer func() { c.scheduled = depth }()
sel := c.selected()
if sel != c.lastSelected {
c.probeInterval = depthProbeInterval
c.probeSince = 0
c.lastSelected = sel
} else if c.probed {
c.probeInterval = min(c.probeInterval*2, depthProbeIntervalMax)
}
c.probed = false
c.probeSince++
if c.probeSince >= c.probeInterval {
c.probeSince = 0
if probe := min(sel+1, c.frontier()+1); probe != sel {
c.probed = true
return probe
}
}
// Seed a clean cost sample for every depth in [0, frontier+1] before judging by
// EV. Cost is only recorded when a round's draft depth matches the prior round's
// (the same-depth gate dropping batch-shape transitions), so a depth never dwelt
// at has no clean sample; draft the shallowest such depth to dwell there. Without
// this the controller stays at the one depth it can sit at without a transition
// (depth 0) and never learns that drafting pays on a deep-optimum model.
if seed := c.costSeedDepth(); seed >= 0 {
return seed
}
return sel
}
// costSeedDepth is the shallowest depth in [0, frontier+1] with no clean
// cost sample, or -1 if all are sampled; bounding to frontier+1 keeps cost-seeding
// from outrunning the acceptance frontier.
func (c *depthController) costSeedDepth() int {
limit := c.frontier() + 1
for n := 0; n <= limit; n++ {
if !c.cost.sampled(n) {
return n
}
}
return -1
}
// selected returns the EV-optimal draft depth without mutating probe state, the
// argmax over [0, frontier+1]. The frontier bound keeps the inherited optimistic
// rate from making ever-deeper depths look best; the depth-0 floor lets it stop
// speculating. Returns 0 until the cost model can compare depths.
func (c *depthController) selected() int {
if !c.cost.ready() {
return 0
}
limit := c.frontier() + 1
best, bestEV := 0, c.acc.expectedCommitted(0)/c.cost.cost(0)
for n := 1; n <= limit; n++ {
if ev := c.acc.expectedCommitted(n) / c.cost.cost(n); ev > bestEV {
best, bestEV = n, ev
}
}
return best
}
// costEWMAAlpha weights the newest cost sample. Fixed-depth cost is low-variance,
// so a responsive alpha converges in a few visits while smoothing scheduler jitter.
const costEWMAAlpha = 0.3
// costModel is the target-forward cost for validating N drafts — a fused batch of
// 1 current token plus N drafts — as an EWMA per visited draft depth read by
// piecewise-linear interpolation between samples (flat beyond the extremes).
// Interpolation assumes no curve shape, so a steep compute-bound or flat
// bandwidth-bound forward is represented as measured. Cost is static within a run,
// learned from decode steps that already sync the forward, so there is no startup
// probe.
type costModel struct {
ewma map[int]float64 // EWMA of measured ms by draft depth
depths []int // sampled draft depths, sorted; updated as new depths arrive
}
func newCostModel() *costModel {
return &costModel{ewma: map[int]float64{}}
}
// costClampFraction bounds one sample's EWMA innovation. A host stall (cache trim,
// backpressure) can inflate a sample severalfold; unclamped it can flip the EV
// comparison against plain decode, and once the controller stops parking it stops
// resampling depth 0, so the error never heals. A genuine cost change still
// converges because every sample keeps pushing toward it.
const costClampFraction = 0.25
// observe folds one forward's wall time into the draft depth's EWMA, clamping the
// innovation so one stall-inflated sample cannot move it far.
func (m *costModel) observe(drafts int, dt time.Duration) {
if drafts < 0 || dt <= 0 {
return
}
ms := float64(dt) / float64(time.Millisecond)
if v, ok := m.ewma[drafts]; ok {
limit := costClampFraction * v
m.ewma[drafts] = v + costEWMAAlpha*max(-limit, min(limit, ms-v))
} else {
m.ewma[drafts] = ms
i, _ := slices.BinarySearch(m.depths, drafts)
m.depths = slices.Insert(m.depths, i, drafts)
}
}
// ready reports whether two distinct depths have been sampled, so a slope exists.
func (m *costModel) ready() bool { return len(m.ewma) >= 2 }
func (m *costModel) sampled(drafts int) bool {
_, ok := m.ewma[drafts]
return ok
}
// cost returns the estimated forward wall time (ms) for validating drafts tokens:
// a piecewise-linear interpolation of the per-depth EWMAs, clamping to the nearest
// sample outside the sampled range (the curve beyond is unknown).
func (m *costModel) cost(drafts int) float64 {
ds := m.depths
if len(ds) == 0 {
return 0
}
if drafts <= ds[0] {
return m.ewma[ds[0]]
}
if drafts >= ds[len(ds)-1] {
return m.ewma[ds[len(ds)-1]]
}
for i := 1; i < len(ds); i++ {
if drafts <= ds[i] {
lo, hi := ds[i-1], ds[i]
t := float64(drafts-lo) / float64(hi-lo)
return m.ewma[lo] + t*(m.ewma[hi]-m.ewma[lo])
}
}
return m.ewma[ds[len(ds)-1]]
}
// sampleString reports the EWMA ms per visited draft depth for diagnostics.
func (m *costModel) sampleString() string {
ds := m.depths
parts := make([]string, 0, len(ds))
for _, d := range ds {
parts = append(parts, fmt.Sprintf("%d:%.0fms", d, m.ewma[d]))
}
return strings.Join(parts, " ")
}
// acceptanceEWMAAlpha weights the newest acceptance outcome. Acceptance drifts with
// content, so an EWMA follows the drift instead of being anchored by early tokens.
const acceptanceEWMAAlpha = 0.1
// acceptanceMinSamples is how many reaches a position needs before its rate is
// trusted; since the search reaches one past the frontier, it also gates how fast
// the frontier advances. Set near the EWMA's memory (~1/alpha); larger only slows
// the ramp, since each depth is re-measured as it is drafted.
const acceptanceMinSamples = 10
// acceptanceModel learns the per-position conditional acceptance rate — the chance
// position i is accepted given every draft before it already was — as an EWMA, shared
// across requests so a fresh request keeps the proven-out frontier. Drift is handled
// by the EWMA forgetting, not by discarding the estimate.
type acceptanceModel struct {
rate []float64 // EWMA acceptance rate given the prefix survived; index i is position i
seen []int // times position i was reached (warmup gate for rate)
}
func newAcceptanceModel() *acceptanceModel {
return &acceptanceModel{rate: []float64{0}, seen: []int{0}}
}
func (a *acceptanceModel) grow(i int) {
for len(a.seen) <= i {
a.rate = append(a.rate, 0)
a.seen = append(a.seen, 0)
}
}
// observe folds a step's outcome into each reached position's EWMA. A position is
// reached only when the prefix before it survived (accepted >= i-1), and is accepted
// iff accepted >= i; updating only the surviving prefix avoids diluting deeper
// positions the step never reached.
func (a *acceptanceModel) observe(drafted, accepted int) {
for i := 1; i <= drafted; i++ {
if accepted < i-1 {
break // prefix did not survive to position i; deeper positions unreached
}
a.grow(i)
outcome := 0.0
if accepted >= i {
outcome = 1.0
}
if a.seen[i] == 0 {
a.rate[i] = outcome
} else {
a.rate[i] += acceptanceEWMAAlpha * (outcome - a.rate[i])
}
a.seen[i]++
}
}
// acceptance returns the rate position i is accepted given its prefix survived. An under-sampled
// position inherits the deepest trusted rate rather than zero, so the controller
// keeps exploring deeper instead of locking shallow on noise.
func (a *acceptanceModel) acceptance(i int) float64 {
if i >= 1 && i < len(a.seen) && a.seen[i] >= acceptanceMinSamples {
return a.rate[i]
}
// Inherit the deepest trusted rate; fall back to optimistic 1 if none yet.
for j := i - 1; j >= 1; j-- {
if j < len(a.seen) && a.seen[j] >= acceptanceMinSamples {
return a.rate[j]
}
}
return 1
}
// expectedCommitted returns expected committed tokens at depth N: the current token,
// which always commits, plus the expected number of accepted drafts — each draft
// position contributes the probability its whole prefix was accepted, the running
// product of the per-position acceptance rates summed over positions.
func (a *acceptanceModel) expectedCommitted(n int) float64 {
total, prod := 1.0, 1.0
for k := 1; k <= n; k++ {
prod *= a.acceptance(k)
total += prod
}
return total
}
// frontier is the deepest position with a trusted acceptance rate. The controller
// never selects beyond frontier+1, so the selection grows outward one position at a
// time instead of jumping deep on the inherited optimistic rate.
func (a *acceptanceModel) frontier() int {
f := 0
for i := 1; i < len(a.seen); i++ {
if a.seen[i] >= acceptanceMinSamples {
f = i
} else {
break
}
}
return f
}