Files
Daniel HiltgenandGitHub 2506f851e4 dflash: add the DFlash2 draft model (#19149)
DFlash2 adds block-local dynamic convolutions and a candidate selector
to DFlash. The selector proposes each token conditioned on its
predecessor and returns each position's candidate distribution, which
verification uses to keep the output exact. Like the reference, the
draft samples with the request's temperature only.

A DFlash2 session drafts seven tokens every round on the existing
snapshot verify path instead of following the depth controller, whose
shallower picks lose more accepted tokens than they save in verify time.

qwen3.5-family targets now expose the layer taps, token embeddings and
raw logits a DFlash draft needs.
2026-10-26 16:04:43 -07:00

216 lines
7.1 KiB
Go

package mlxrunner
import (
"fmt"
"github.com/ollama/ollama/mlx"
"github.com/ollama/ollama/mlxrunner/batch"
"github.com/ollama/ollama/mlxrunner/model"
"github.com/ollama/ollama/mlxrunner/sample"
)
// dflashPendingFlushTokens bounds the held feature rows between flushes.
const dflashPendingFlushTokens = 256
// dflash2Depth is a DFlash2 session's fixed draft length. The depth controller's
// shallower picks lose more accepted tokens than they save in verify time.
const dflash2Depth = 7
// dflashDrafter drafts with a block-diffusion draft model (DFlash): one
// forward proposes a whole block. A context-cache entry depends only on its
// own position, so the trie keys need no look-ahead.
type dflashDrafter struct {
spec *speculation
blockSize int
maskToken int32
}
func newDFlashDrafter(s *speculation, draft model.BlockDraft) *dflashDrafter {
blockSize, maskToken := draft.BlockParams()
return &dflashDrafter{spec: s, blockSize: blockSize, maskToken: maskToken}
}
func (d *dflashDrafter) draftLimit() int { return d.blockSize - 1 }
// open returns a session synced to the draft caches' restored offset.
func (d *dflashDrafter) open(layout []any) draftSession {
s := &dflashDraftSession{drafter: d, layout: layout}
if kv := d.spec.draftKV; len(kv) > 0 {
s.ctxOffset = kv[0].Offset()
}
return s
}
// dflashDraftSession runs one request's drafting. A context entry at slot S
// derives only from the features at S; ctxOffset+pendingCount is the slot
// after the last reported token.
type dflashDraftSession struct {
drafter *dflashDrafter
layout []any
// ctxOffset is the slot after the last feature row written; pendingCount
// rows are buffered past it.
ctxOffset int
pendingFeatures []*mlx.Array
pendingCount int
pending *mlx.Scope // holds the rows until the flush
// blockOutstanding tracks the proposal's scheduled rollback point, which
// commitBlock has to drain even when it needs no rewind.
blockOutstanding bool
}
// committed ignores the media manifest: a context row derives from the
// target hidden at its slot, which already carries any image content.
func (d *dflashDraftSession) committed(tokens, features *mlx.Array, position int, _ []batch.MediaItem) {
n := tokens.Dim(1)
// Skip leading rows the session already has (a restored prefix). A run
// that starts past the frontier would leave a gap, which is a bug.
start := d.ctxOffset + d.pendingCount - position
if start < 0 {
panic(fmt.Sprintf("dflash: committed run at %d leaves a context gap at %d", position, d.ctxOffset+d.pendingCount))
}
if start < n {
f := features.Slice(mlx.Slice(), mlx.Slice(start, n), mlx.Slice())
if d.pending == nil {
d.pending = mlx.NewScope()
}
d.pending.Attach(f)
d.pendingFeatures = append(d.pendingFeatures, f)
d.pendingCount += n - start
if d.pendingCount >= dflashPendingFlushTokens {
d.flush()
}
}
}
// settle writes buffered rows through, leveling the draft caches with the
// target's; next is unused.
func (d *dflashDraftSession) settle(_ *mlx.Array) {
d.flush()
}
func (d *dflashDraftSession) close() {
d.flush()
}
// takePending returns the buffered rows, advancing ctxOffset past them.
func (d *dflashDraftSession) takePending() *mlx.Array {
if len(d.pendingFeatures) == 0 {
return nil
}
features := mlx.Concatenate(d.pendingFeatures, 1)
d.pending.Close()
d.pending = nil
d.pendingFeatures = nil
d.ctxOffset += d.pendingCount
d.pendingCount = 0
return features
}
// commitBlock rewinds the round's block out of the draft caches; the caches
// keep only context rows, and accepted tokens arrive as context later. Every
// write path must run this first, otherwise the new rows land after the block
// and the cache contents no longer match their positions.
func (d *dflashDraftSession) commitBlock() {
if !d.blockOutstanding {
return
}
commitSpeculation(d.drafter.spec.draftKV, 0, 1, d.ctxOffset)
d.blockOutstanding = false
}
// flush rewinds the round's block, then writes the pending rows in one
// context-only forward. The block is rewound even when nothing is pending.
func (d *dflashDraftSession) flush() {
spec := d.drafter.spec
d.commitBlock()
if len(d.pendingFeatures) == 0 {
return
}
offset := d.ctxOffset
// Evaluating the cache state forces the writes: a session that never
// drafts would otherwise leave the flush chain unevaluated, holding
// every feature until close.
mlx.ScopedAsyncEval(func() []*mlx.Array {
spec.draft.Forward(&batch.Batch{
SeqOffsets: []int32{int32(offset)},
Hidden: d.takePending(),
Layout: d.layout,
}, spec.targets, spec.draftKV)
state := make([]*mlx.Array, 0, 2*len(spec.draftKV))
for _, c := range spec.draftKV {
state = append(state, c.State()...)
}
return state
})
}
// propose drafts a block after the not-yet-validated current token, one
// forward filling every mask position.
func (d *dflashDraftSession) propose(current *mlx.Array, maxTokens int) *draftCandidates {
spec := d.drafter.spec
blockSize := d.drafter.blockSize
n := min(maxTokens, blockSize-1)
if n <= 0 || d.ctxOffset+d.pendingCount == 0 {
return nil
}
d.commitBlock()
// Draft only the requested rows, not the full trained block: exact for
// causal layers, while a bidirectional draft's proposal distribution
// changes; verification uses the actual proposal probabilities.
var candidates *draftCandidates
mlx.ScopedArrays(func() []*mlx.Array {
masks := make([]int32, n)
for i := range masks {
masks[i] = d.drafter.maskToken
}
block := current.ExpandDims(-1).Concatenate(1, mlx.FromValues(masks, 1, len(masks)))
offset := d.ctxOffset
features := d.takePending()
scheduleSpeculation(spec.draftKV, d.ctxOffset, 1)
d.blockOutstanding = true
hidden, _ := spec.draft.Forward(&batch.Batch{
InputIDs: block,
SeqOffsets: []int32{int32(offset)},
SeqQueryLens: []int32{int32(n + 1)},
Hidden: features,
Layout: d.layout,
}, spec.targets, spec.draftKV)
candidates = d.selectBlock(hidden, current, n)
return candidates.Arrays()
})
return candidates
}
// selectBlock samples the proposal from a draft forward's block rows.
func (d *dflashDraftSession) selectBlock(hidden, current *mlx.Array, n int) *draftCandidates {
spec := d.drafter.spec
r := spec.r
// Row i predicts the token at its own position; the anchor row is unused.
hidden = hidden.Slice(mlx.Slice(), mlx.Slice(1, n+1), mlx.Slice())
logits := spec.draft.Unembed(hidden)
if selector, ok := spec.draft.(model.BlockSelector); ok {
tokens, ids, probs := selector.Select(hidden, logits, current, func(ids, scores *mlx.Array) (*mlx.Array, *mlx.Array) {
tokens, dist := r.Sampler.SampleCandidates(pipelineSlot, ids, scores)
return tokens, dist.Probs
})
return &draftCandidates{tokens: tokens, dist: sample.Distribution{IDs: ids.Squeeze(0), Probs: probs.Squeeze(0)}}
}
// Independent DFlash rows see the committed history, without the other
// rows of the block contributing to penalties.
dist := r.Sampler.Distribution(pipelineSlot, logits, nil)
tokens := r.Sampler.SampleDistribution(pipelineSlot, dist)
return &draftCandidates{
tokens: tokens.ExpandDims(0),
dist: dist,
}
}