ollama/x/create/draft_test.go
Patrick Devine 964ea42c09
mlx: x/create rewrite (#16919)
This is a rewrite of the create functionality for the MLX engine.

The core idea behind the create functionality is to break the import/convert into a pipeline of distinct phases:

* Read (scan the safetensors directory for the various bits of metadata)
* Classify (determine what the import type)
* Plan (determine any transforms that need to be done)
* Write (transform any data as necessary and write out the blobs)
* Create the manifest

Each architecture has a "policy" which determines how to convert the model correctly. A number of different formats for safetensors are supported including:

* nvfp4 (two formats: model optimized, torch)
* fp8 datatypes (convert to mxfp8)
* standard bf16 based weights

A number of cleanups/simplifications have been done including:

* using the baked in names for the tensors instead of munging them into something else
* unified 3d expert tensors (instead of separate per expert tensors)
* fewer unnecessary transforms to the various tensors in a model (keep a model as close to the source as possible)
* unified capability checking
* draft model handling (for MTP) is done on the same path

Image generation has been intentionally removed.
2026-07-03 18:30:45 -07:00

121 lines
4.3 KiB
Go

package create
import (
"io"
"os"
"path/filepath"
"slices"
"testing"
st "github.com/ollama/ollama/x/safetensors"
)
// recordingStore captures the blobs a pipeline run produces so tests can assert
// on their names and contents.
type recordingStore struct {
names []string
blobs map[string][]byte
}
func (s *recordingStore) WriteBlob(r io.Reader, mediaType, name string) (LayerInfo, error) {
data, err := io.ReadAll(r)
if err != nil {
return LayerInfo{}, err
}
if s.blobs == nil {
s.blobs = map[string][]byte{}
}
s.blobs[name] = data
s.names = append(s.names, name)
return LayerInfo{Digest: "sha256:" + name, Size: int64(len(data)), MediaType: mediaType, Name: name}, nil
}
func TestPrefixSpecs(t *testing.T) {
specs := []BlobSpec{
{
Name: "model.layers.0.mlp.experts",
Tensors: []TensorSpec{{
Name: "model.layers.0.mlp.experts.gate_proj.weight",
Sources: []SourceTensor{{Name: "model.layers.0.mlp.experts.0.gate_proj.weight", File: "a.safetensors"}},
Quantize: "int8",
}},
},
}
got := prefixSpecs(specs, "draft.")
if got[0].Name != "draft.model.layers.0.mlp.experts" {
t.Errorf("blob name = %q, want draft.-prefixed", got[0].Name)
}
if got[0].Tensors[0].Name != "draft.model.layers.0.mlp.experts.gate_proj.weight" {
t.Errorf("tensor name = %q, want draft.-prefixed", got[0].Tensors[0].Name)
}
if got[0].Tensors[0].Quantize != "int8" {
t.Errorf("quantize = %q, want it preserved", got[0].Tensors[0].Quantize)
}
// Sources point at the source files and must not be prefixed.
if got[0].Tensors[0].Sources[0].Name != "model.layers.0.mlp.experts.0.gate_proj.weight" {
t.Errorf("source name = %q, want unchanged", got[0].Tensors[0].Sources[0].Name)
}
// The input must not be mutated.
if specs[0].Name != "model.layers.0.mlp.experts" || specs[0].Tensors[0].Name != "model.layers.0.mlp.experts.gate_proj.weight" {
t.Errorf("prefixSpecs mutated its input: %+v", specs[0])
}
}
func TestDraftPolicyKeepsEmbeddingsUnquantized(t *testing.T) {
p := draftPolicy{defaultQuantPolicy{}}
// Draft token embeddings start unquantized regardless of the request.
if got := p.quantizationType("model.embed_tokens.weight", []int32{4096, 2048}, "int8"); got != "" {
t.Errorf("draft embed_tokens quant = %q, want \"\"", got)
}
// Other eligible weights still follow the wrapped policy.
if got := p.quantizationType("model.layers.0.mlp.down_proj.weight", []int32{2048, 2048}, "int8"); got == "" {
t.Errorf("draft down_proj quant = \"\", want it quantized via the inner policy")
}
}
func TestCreateDraftLayersPrefixesNamesAndConfig(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(`{"architectures":["LlamaForCausalLM"]}`), 0o644); err != nil {
t.Fatal(err)
}
createTestSafetensors(t, filepath.Join(dir, "model.safetensors"), []*st.TensorData{
st.NewTensorDataFromBytes("model.embed_tokens.weight", "BF16", []int32{4, 8}, make([]byte, 4*8*2)),
st.NewTensorDataFromBytes("model.norm.weight", "BF16", []int32{8}, make([]byte, 8*2)),
})
store := &recordingStore{}
layers, err := CreateDraftLayers(dir, "draft.", "draft/", "", store, func(string) {})
if err != nil {
t.Fatalf("CreateDraftLayers: %v", err)
}
if len(layers) == 0 {
t.Fatal("CreateDraftLayers returned no layers")
}
// Tensor blobs are namespaced under draft.; the config under draft/.
if !slices.Contains(store.names, "draft.model.embed_tokens.weight") {
t.Errorf("missing draft.-prefixed tensor blob; got %v", store.names)
}
if !slices.Contains(store.names, "draft/config.json") {
t.Errorf("missing draft/config.json; got %v", store.names)
}
// The prefix must also land inside the blob, since the runtime resolves
// draft tensors by the "draft." name prefix.
names := readSafetensorsHeaderNames(t, store.blobs["draft.model.embed_tokens.weight"])
if !slices.Contains(names, "draft.model.embed_tokens.weight") {
t.Errorf("in-blob tensor name not prefixed; got %v", names)
}
}
func TestCreateDraftLayersRejectsEmptyPrefixes(t *testing.T) {
store := &recordingStore{}
if _, err := CreateDraftLayers(t.TempDir(), "", "draft/", "", store, func(string) {}); err == nil {
t.Error("expected an error for an empty tensor prefix")
}
if _, err := CreateDraftLayers(t.TempDir(), "draft.", "", "", store, func(string) {}); err == nil {
t.Error("expected an error for an empty config prefix")
}
}