mirror of
https://github.com/ollama/ollama.git
synced 2026-09-21 05:28:00 -05:00
The MLX runner is the only Go inference runner left and is no longer experimental, so its packages leave x/. The bindings become a top-level mlx package beside the carried patches in mlx/compat, mirroring how llama/ holds the llama.cpp integration, and the runner becomes mlxrunner with the architectures nested under the package they implement. Subpackages move with their parent unless listed. x/mlxrunner/mlx mlx x/internal/mlxthread mlx/mlxthread x/internal/mlxthreadtest mlx/mlxthread/mlxthreadtest x/internal/mlxtest mlx/mlxtest x/quant mlx/quant mlx/compat/*.patch mlx/compat/mlx-c (MLX patches go in mlx/compat/mlx) x/mlxrunner mlxrunner x/models/nn mlxrunner/nn x/models/<arch> mlxrunner/model/<arch> x/mlxrunner/imports.go mlxrunner/model/architectures (new package) x/create create x/safetensors fs/safetensors x/tokenizer mlxrunner/tokenizer Every package keeps its name, so the Go changes are the import path rewrites the moves force, and the CMake, Dockerfile, CI cache keys, drift check and Darwin payload script follow the new paths. Four edits are not paths: the runner's blank architecture imports become the package mlxrunner/model/architectures, so the list to extend for a new model sits beside the architecture directories; a depguard rule keeps the two test harnesses out of non-test code, as the x/internal placement used to; the CI change filter's two entries for the long-deleted x/imagegen/mlx now name the bindings' CMake project and the carried patches, so a change to either builds the payload; and the tokenizer parity test reads its fixtures from its own testdata instead of walking out of x/. x/server and x/imagegen/manifest stay for the next two commits.
363 lines
11 KiB
Go
363 lines
11 KiB
Go
package mlxrunner
|
|
|
|
import (
|
|
"errors"
|
|
"io/fs"
|
|
"math"
|
|
"path/filepath"
|
|
"slices"
|
|
"testing"
|
|
|
|
"github.com/ollama/ollama/mlx"
|
|
"github.com/ollama/ollama/mlx/mlxtest"
|
|
"github.com/ollama/ollama/mlxrunner/xgrammar"
|
|
)
|
|
|
|
func TestApplyTokenMask(t *testing.T) {
|
|
mlxtest.Run(t, func(t *mlxtest.T) {
|
|
const (
|
|
bitsPerMaskWord = 32
|
|
firstTokenID = 0 // Least-significant bit of the first mask word.
|
|
interiorTokenID = 7 // Last bit of the first mask word's low byte.
|
|
lastIDInFirstMaskWord = bitsPerMaskWord - 1 // Sign bit of the int32-backed first mask word.
|
|
lastVocabID = 40 // Final valid ID in a partially used second mask word.
|
|
vocabSize = lastVocabID + 1
|
|
)
|
|
allowedIDs := []int{firstTokenID, interiorTokenID, lastIDInFirstMaskWord, lastVocabID}
|
|
word0 := uint32(1)<<firstTokenID |
|
|
uint32(1)<<interiorTokenID |
|
|
uint32(1)<<lastIDInFirstMaskWord
|
|
packed := []int32{
|
|
int32(word0),
|
|
int32(uint32(1) << (lastVocabID - bitsPerMaskWord)),
|
|
}
|
|
e := &grammarEngine{}
|
|
e.initMask(vocabSize)
|
|
t.Cleanup(e.close)
|
|
logits := mlx.Zeros(mlx.DTypeFloat32, 1, vocabSize)
|
|
masked := e.apply(logits, mlx.FromValues(packed, 1, len(packed)))
|
|
mlx.Eval(masked)
|
|
got := masked.Floats()
|
|
for id := range vocabSize {
|
|
allowed := false
|
|
for _, a := range allowedIDs {
|
|
if id == a {
|
|
allowed = true
|
|
}
|
|
}
|
|
if allowed && got[id] != 0 {
|
|
t.Fatalf("allowed token %d masked to %v", id, got[id])
|
|
}
|
|
if !allowed && !math.IsInf(float64(got[id]), -1) {
|
|
t.Fatalf("disallowed token %d = %v, want -Inf", id, got[id])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
const (
|
|
draftTestEOS int32 = 32
|
|
draftTestVocab int = 40
|
|
)
|
|
|
|
func draftTestVocabulary() []string {
|
|
pieces := []string{
|
|
"{", "}", `"`, ":", ",", "a", "n", "s", "w", "e", "r", "o", "k", " ",
|
|
"1", "2", "[", "]", "true", "false", "ok", "answer",
|
|
}
|
|
for len(pieces) < draftTestVocab {
|
|
pieces = append(pieces, "")
|
|
}
|
|
pieces[draftTestEOS] = "<eos>"
|
|
return pieces
|
|
}
|
|
|
|
func draftPieceIDs(t *mlxtest.T, pieces ...string) []int32 {
|
|
t.Helper()
|
|
vocab := draftTestVocabulary()
|
|
ids := make([]int32, len(pieces))
|
|
for i, piece := range pieces {
|
|
id := slices.Index(vocab, piece)
|
|
if id < 0 {
|
|
t.Fatalf("test vocabulary does not contain %q", piece)
|
|
}
|
|
ids[i] = int32(id)
|
|
}
|
|
return ids
|
|
}
|
|
|
|
// testDraftGrammar builds a grammar over the small draft-test vocabulary and
|
|
// an engine sized to match, skipping when the native payloads are not built.
|
|
func testDraftGrammar(t *mlxtest.T, schema string) (*grammarEngine, *grammar) {
|
|
t.Helper()
|
|
path, err := mlx.LoadedLibraryPath()
|
|
if err != nil {
|
|
t.Skipf("native MLX payload is not built: %v", err)
|
|
}
|
|
compiler, err := xgrammar.New(filepath.Dir(path), draftTestVocabulary(), draftTestVocab, []int32{draftTestEOS}, 8, 128<<20)
|
|
if err != nil {
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
t.Skipf("native xgrammar payload is not built: %v", err)
|
|
}
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(compiler.Close)
|
|
m, err := compiler.Compile(schemaTag(schema))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(m.Close)
|
|
e := &grammarEngine{}
|
|
e.initMask(draftTestVocab)
|
|
t.Cleanup(e.close)
|
|
return e, &grammar{m: m}
|
|
}
|
|
|
|
// requireSameMask asserts two matchers fill identical masks, proving their
|
|
// parser states are equivalent.
|
|
func requireSameMask(t *mlxtest.T, got, want *grammar, vocab int) {
|
|
t.Helper()
|
|
words := (vocab + 31) / 32
|
|
gotRow, wantRow := make([]int32, words), make([]int32, words)
|
|
gotConstrained, err := got.m.Fill(gotRow)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wantConstrained, err := want.m.Fill(wantRow)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if gotConstrained != wantConstrained || !slices.Equal(gotRow, wantRow) {
|
|
t.Fatalf("mask %v %032b, want %v %032b", gotConstrained, gotRow, wantConstrained, wantRow)
|
|
}
|
|
}
|
|
|
|
// acceptRun advances a grammar over committed tokens through the engine's
|
|
// accept, failing the test on any rejection.
|
|
func acceptRun(t *mlxtest.T, e *grammarEngine, g *grammar, ids []int32) {
|
|
t.Helper()
|
|
for _, id := range ids {
|
|
if err := errors.Join(e.accept([]*grammar{g}, []int32{id})...); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// maskedRows evaluates mask's logits, returning per-row allowed flags.
|
|
func maskedRows(t *mlxtest.T, masked *mlx.Array, rows int) [][]bool {
|
|
t.Helper()
|
|
mlx.Eval(masked)
|
|
vals := masked.Floats()
|
|
out := make([][]bool, rows)
|
|
for i := range rows {
|
|
out[i] = make([]bool, draftTestVocab)
|
|
for id := range draftTestVocab {
|
|
out[i][id] = !math.IsInf(float64(vals[i*draftTestVocab+id]), -1)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
const draftTestSchema = `{"type":"object","properties":{"answer":{"type":"string","enum":["ok"]}},"required":["answer"],"additionalProperties":false}`
|
|
|
|
func TestMaskDraftPositions(t *testing.T) {
|
|
mlxtest.Run(t, func(t *mlxtest.T) {
|
|
e, g := testDraftGrammar(t, draftTestSchema)
|
|
_, shadow := testDraftGrammar(t, draftTestSchema)
|
|
|
|
drafts := draftPieceIDs(t, "{", `"`, "answer")
|
|
logits := mlx.Zeros(mlx.DTypeFloat32, 1, len(drafts)+1, draftTestVocab)
|
|
masked, errs := e.mask([]*grammar{g}, logits, [][]int32{drafts})
|
|
if errs != nil {
|
|
t.Fatal(errs)
|
|
}
|
|
// The walk left no matcher state behind: the mask matches a fresh shadow's.
|
|
requireSameMask(t, g, shadow, draftTestVocab)
|
|
|
|
rows := maskedRows(t, masked, len(drafts)+1)
|
|
open := draftPieceIDs(t, "{", "}")
|
|
if !rows[0][open[0]] || rows[0][open[1]] {
|
|
t.Fatal("row 0 does not constrain to the object opener")
|
|
}
|
|
for i, id := range drafts {
|
|
if !rows[i][id] {
|
|
t.Fatalf("row %d masks its own draft token %d", i, id)
|
|
}
|
|
}
|
|
quote := draftPieceIDs(t, `"`)[0]
|
|
if !rows[3][quote] {
|
|
t.Fatal("bonus row masks the value-opening quote")
|
|
}
|
|
|
|
// Verification rejects the third draft; the committed run reaches the
|
|
// grammar through accept, like every emitted token.
|
|
final := draftPieceIDs(t, "answer")[0]
|
|
run := append(drafts[:2:2], final)
|
|
acceptRun(t, e, g, run)
|
|
for _, id := range run {
|
|
if err := shadow.m.Accept(id); err != nil {
|
|
t.Fatalf("shadow accept %d: %v", id, err)
|
|
}
|
|
}
|
|
requireSameMask(t, g, shadow, draftTestVocab)
|
|
})
|
|
}
|
|
|
|
func TestMaskStopsAtRejectedDraft(t *testing.T) {
|
|
mlxtest.Run(t, func(t *mlxtest.T) {
|
|
e, g := testDraftGrammar(t, draftTestSchema)
|
|
_, shadow := testDraftGrammar(t, draftTestSchema)
|
|
|
|
// "}" is invalid after "{": the answer property is required.
|
|
drafts := draftPieceIDs(t, "{", "}", `"`)
|
|
logits := mlx.Zeros(mlx.DTypeFloat32, 1, len(drafts)+1, draftTestVocab)
|
|
masked, errs := e.mask([]*grammar{g}, logits, [][]int32{drafts})
|
|
if errs != nil {
|
|
t.Fatal(errs)
|
|
}
|
|
requireSameMask(t, g, shadow, draftTestVocab)
|
|
|
|
rows := maskedRows(t, masked, len(drafts)+1)
|
|
if rows[1][drafts[1]] {
|
|
t.Fatal("row 1 allows the invalid draft")
|
|
}
|
|
for i := 2; i < len(rows); i++ {
|
|
for id, ok := range rows[i] {
|
|
if !ok {
|
|
t.Fatalf("unreached row %d masks token %d", i, id)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestMaskCrossesAnAcceptedStop(t *testing.T) {
|
|
mlxtest.Run(t, func(t *mlxtest.T) {
|
|
e, g := testDraftGrammar(t, `{"type":"object"}`)
|
|
|
|
drafts := draftPieceIDs(t, "{", "}", "<eos>")
|
|
logits := mlx.Zeros(mlx.DTypeFloat32, 1, len(drafts)+1, draftTestVocab)
|
|
masked, errs := e.mask([]*grammar{g}, logits, [][]int32{drafts})
|
|
if errs != nil {
|
|
t.Fatal(errs)
|
|
}
|
|
rows := maskedRows(t, masked, len(drafts)+1)
|
|
if !rows[2][draftTestEOS] {
|
|
t.Fatal("row 2 masks EOS after the object completes")
|
|
}
|
|
// The walk accepts the stop and the restore un-terminates: the position
|
|
// past it is unconstrained, and the matcher replays from the start.
|
|
for id, ok := range rows[3] {
|
|
if !ok {
|
|
t.Fatalf("row past the stop masks token %d", id)
|
|
}
|
|
}
|
|
if g.m.Terminated() {
|
|
t.Fatal("matcher terminated after the restore")
|
|
}
|
|
acceptRun(t, e, g, drafts)
|
|
if !g.m.Terminated() {
|
|
t.Fatal("matcher not terminated after the committed EOS")
|
|
}
|
|
// A terminated grammar accepts nothing further and needs nothing
|
|
// accepted: the row is skipped, not faulted.
|
|
if err := errors.Join(e.accept([]*grammar{g}, []int32{drafts[0]})...); err != nil {
|
|
t.Fatalf("accept after termination: %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestMaskLeavesDeadPositionsUnmasked(t *testing.T) {
|
|
mlxtest.Run(t, func(t *mlxtest.T) {
|
|
// After `{"` this schema requires a property name no vocabulary piece can
|
|
// start, so that position's fill rejects every token.
|
|
schema := `{"type":"object","properties":{"z":{"type":"string"}},"required":["z"],"additionalProperties":false}`
|
|
e, g := testDraftGrammar(t, schema)
|
|
_, shadow := testDraftGrammar(t, schema)
|
|
|
|
drafts := draftPieceIDs(t, "{", `"`)
|
|
logits := mlx.Zeros(mlx.DTypeFloat32, 1, len(drafts)+1, draftTestVocab)
|
|
masked, errs := e.mask([]*grammar{g}, logits, [][]int32{drafts})
|
|
if errs != nil {
|
|
t.Fatal(errs)
|
|
}
|
|
requireSameMask(t, g, shadow, draftTestVocab)
|
|
rows := maskedRows(t, masked, len(drafts)+1)
|
|
if !rows[0][drafts[0]] || !rows[1][drafts[1]] {
|
|
t.Fatal("leading rows mask their own drafts")
|
|
}
|
|
for id, ok := range rows[2] {
|
|
if !ok {
|
|
t.Fatalf("dead position masks token %d", id)
|
|
}
|
|
}
|
|
// A run committed into the dead state fails at its accept.
|
|
acceptRun(t, e, g, drafts)
|
|
if err := errors.Join(e.accept([]*grammar{g}, draftPieceIDs(t, "a"))...); err == nil {
|
|
t.Fatal("accept into a dead grammar state unexpectedly succeeded")
|
|
}
|
|
|
|
// At position 0 the dead state is already committed: the row fails.
|
|
e0, g0 := testDraftGrammar(t, `{"enum":[3]}`)
|
|
logits0 := mlx.Zeros(mlx.DTypeFloat32, 1, 2, draftTestVocab)
|
|
if _, errs := e0.mask([]*grammar{g0}, logits0, [][]int32{draftPieceIDs(t, "1")}); errs == nil || errs[0] == nil {
|
|
t.Fatal("dead position 0 did not fail the row")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestMaskBatchesRows(t *testing.T) {
|
|
mlxtest.Run(t, func(t *mlxtest.T) {
|
|
e, g0 := testDraftGrammar(t, draftTestSchema)
|
|
_, shadow0 := testDraftGrammar(t, draftTestSchema)
|
|
_, g1 := testDraftGrammar(t, `{"type":"object"}`)
|
|
_, shadow1 := testDraftGrammar(t, `{"type":"object"}`)
|
|
|
|
// Ragged rows: row 0 advances three drafts, row 1 one; row 1's tail
|
|
// positions pad with all-ones.
|
|
drafts := [][]int32{draftPieceIDs(t, "{", `"`, "answer"), draftPieceIDs(t, "{")}
|
|
logits := mlx.Zeros(mlx.DTypeFloat32, 2, 4, draftTestVocab)
|
|
masked, errs := e.mask([]*grammar{g0, g1}, logits, drafts)
|
|
if errs != nil {
|
|
t.Fatal(errs)
|
|
}
|
|
requireSameMask(t, g0, shadow0, draftTestVocab)
|
|
requireSameMask(t, g1, shadow1, draftTestVocab)
|
|
|
|
rows := maskedRows(t, masked, 8)
|
|
if !rows[4][drafts[1][0]] {
|
|
t.Fatal("row 1 position 0 masks its own draft")
|
|
}
|
|
// The bands differ where the schemas do: only the permissive object may
|
|
// close immediately after the opener.
|
|
closeID := draftPieceIDs(t, "}")[0]
|
|
if rows[1][closeID] {
|
|
t.Fatal("strict schema allows closing an empty object")
|
|
}
|
|
if !rows[5][closeID] {
|
|
t.Fatal("permissive schema masks closing an empty object")
|
|
}
|
|
for id, ok := range rows[6] {
|
|
if !ok {
|
|
t.Fatalf("padding position masks token %d", id)
|
|
}
|
|
}
|
|
|
|
// Each row's committed run reconciles against its own matcher.
|
|
acceptRun(t, e, g0, append(drafts[0][:2:2], draftPieceIDs(t, "answer")[0]))
|
|
acceptRun(t, e, g1, []int32{drafts[1][0], closeID})
|
|
for _, id := range draftPieceIDs(t, "{", `"`, "answer") {
|
|
if err := shadow0.m.Accept(id); err != nil {
|
|
t.Fatalf("shadow accept %d: %v", id, err)
|
|
}
|
|
}
|
|
for _, id := range []int32{draftPieceIDs(t, "{")[0], closeID} {
|
|
if err := shadow1.m.Accept(id); err != nil {
|
|
t.Fatalf("shadow accept %d: %v", id, err)
|
|
}
|
|
}
|
|
requireSameMask(t, g0, shadow0, draftTestVocab)
|
|
requireSameMask(t, g1, shadow1, draftTestVocab)
|
|
})
|
|
}
|