ollama/x/mlxrunner/cache/cache.go
Jesse Gross d00622060f mlxrunner: drive MTP speculation through cache snapshots
Speculation used a parallel hierarchy of wrapper cache types that shadowed
the live caches and reconciled against them on commit. Replace it with
snapshot/restore on the live caches themselves: a cache snapshots itself as
a write crosses each offset, and the runner commits a batched draft by
restoring to the accepted count. The wrappers and the comparison plumbing
around them are gone.

Snapshots are lazy. A KV or rotating capture indexes into the live buffer and
owns no memory until a destructive write forces a copy-out, so rejecting a
draft is free.

Recurrent layers now validate in the same batched pass rather than falling
back to serial. A gated-delta layer reports its interior split offsets and
hands back the recurrent state at each one, which the cache records as a
snapshot.
2026-06-09 00:39:19 -07:00

150 lines
6.2 KiB
Go

package cache
import (
"fmt"
"github.com/ollama/ollama/x/mlxrunner/mlx"
)
// Cache is common state management shared by every cache kind. Writers
// live on the specific caches
type Cache interface {
// State returns the cache-owned state roots that should be kept/evaluated.
State() []*mlx.Array
Free()
Offset() int
// Snapshot copies cache state from fromOffset to current offset into
// pinned VRAM arrays. The active cache is unchanged.
Snapshot(fromOffset int) Snapshot
// PrepareSnapshots schedules the cache to capture a snapshot as its
// storage offset reaches each listed offset during subsequent writes.
// Offsets are storage offsets, must not already be passed (current
// offset <= offset), and must be sorted ascending and unique. Scheduled
// offsets persist across multiple writes until TakeSnapshots is called;
// captures accumulate. The previous schedule must be drained first.
//
// Unlike Snapshot, capture happens at the moment a write crosses the
// scheduled offset, so it can record states interior to a batched write
// without the caller breaking the write into pieces.
PrepareSnapshots(offsets []int)
// TakeSnapshots returns the snapshots captured since PrepareSnapshots,
// one per scheduled offset in the caller's order, and clears the
// schedule. An entry is nil when its scheduled offset captured a
// zero-width range (the offset equalled the previous boundary, e.g. an
// offset scheduled at the current position): rolling back there needs
// only a live rewind, so there is nothing to page out.
TakeSnapshots() []Snapshot
// Restore brings the cache to target. If snapshot is nil, rewinds
// using the cache's own live state. Returns false if the target is
// unreachable (e.g. target > current offset, or negative).
Restore(snapshot Snapshot, target int) bool
// Merge combines two sequential snapshots [a,b) and [b,c) into [a,c).
// Takes ownership of both inputs.
Merge(parent, child Snapshot) Snapshot
// Split divides a snapshot [a,c) at offset b into [a,b) and [b,c).
// Takes ownership of the input. Cache types that cannot split
// (e.g. recurrent) return (nil, snapshot).
Split(snapshot Snapshot, at int) (parent, child Snapshot)
}
// Snapshot is paged-out cache state that can be restored later.
type Snapshot interface {
// Size returns the byte size of the paged-out data (in VRAM). A lazy
// snapshot that still indexes a live cache buffer returns 0 — it owns
// no extra memory yet. Once materialized (the cache copies the range
// out before overwriting its slots), Size returns the owned bytes.
Size() int
// SetMaterializeHook installs a callback fired once when a lazy
// snapshot materializes (allocates its owned arrays). delta is the
// newly-allocated byte count. Pass nil to detach. Snapshots that are
// never lazy may treat this as a no-op.
SetMaterializeHook(func(delta int))
// Close unpins the snapshot's arrays so they can be freed by Sweep.
Close()
}
// pendingSnapshots holds the per-token snapshot capture state shared by all
// cache kinds. The owning cache calls capture(offset) from its write path
// after each token's storage is in place; capture materializes a snapshot
// for every scheduled offset that the write has now reached.
type pendingSnapshots struct {
offsets []int // scheduled storage offsets, in caller order
captured []Snapshot // captured[i] corresponds to offsets[i]; nil until reached
base int // running capture cursor: the from-offset of the next capture
}
// prepare schedules the listed storage offsets. See Cache.PrepareSnapshots.
func (p *pendingSnapshots) prepare(currentOffset int, offsets []int) {
if p.captured != nil {
panic("PrepareSnapshots: previous schedule not drained; TakeSnapshots first or its captures leak")
}
for i, o := range offsets {
if o < currentOffset {
panic(fmt.Sprintf("PrepareSnapshots: offset %d already passed (current %d)", o, currentOffset))
}
// captureReached and scheduledIn walk offsets in storage order and rely
// on it being ascending and unique.
if i > 0 && o <= offsets[i-1] {
panic(fmt.Sprintf("PrepareSnapshots: offsets must be sorted and unique, got %v", offsets))
}
}
p.offsets = append([]int(nil), offsets...)
p.captured = make([]Snapshot, len(offsets))
p.base = currentOffset
}
// take returns the captured snapshots and clears the schedule. See
// Cache.TakeSnapshots. Captures fire in ascending offset order, so by take time
// every scheduled offset the writes crossed has been visited; a nil entry is a
// zero-width capture, not a missed one.
func (p *pendingSnapshots) take() []Snapshot {
out := p.captured
p.offsets, p.captured = nil, nil
return out
}
// captureReached materializes a snapshot for every scheduled offset that equals
// reached and hasn't been captured yet, using snap to produce the rollback
// state. The capture cursor base holds the previous scheduled boundary: snap
// reads it (yielding a [base, reached) range for position-sliceable caches),
// then base advances to reached so the next capture starts there. base only
// advances when a capture actually fires — write boundaries between scheduled
// offsets must not move it, or a later capture would record a fromOffset past
// the previous scheduled offset and break the alignment between each capture's
// range and the trie edge the caller attaches it to.
func (p *pendingSnapshots) captureReached(reached int, snap func(offset int) Snapshot) {
captured := false
for i, o := range p.offsets {
if p.captured[i] == nil && o == reached {
p.captured[i] = snap(o)
captured = true
}
}
if captured {
p.base = reached
}
}
// scheduledIn returns the scheduled offsets in [start, end], ascending. A write
// spanning [start, end) drives captureReached at each of these so any capture
// due there fires; for position-sliceable caches, the resulting snapshots cover
// [previous-scheduled-offset, this-offset) via captureReached's running cursor.
// p.offsets is ascending and unique (prepare enforces it), so the range filter
// preserves both.
func (p *pendingSnapshots) scheduledIn(start, end int) []int {
var boundaries []int
for _, o := range p.offsets {
if o >= start && o <= end {
boundaries = append(boundaries, o)
}
}
return boundaries
}