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.
303 lines
11 KiB
Go
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
|
|
}
|