566 lines
17 KiB
Go
566 lines
17 KiB
Go
package create
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/ollama/ollama/envconfig"
|
|
"github.com/ollama/ollama/x/safetensors"
|
|
)
|
|
|
|
// ModelConfig represents the config blob stored with a model.
|
|
type ModelConfig struct {
|
|
ModelFormat string `json:"model_format"`
|
|
Capabilities []string `json:"capabilities"`
|
|
}
|
|
|
|
// Manifest represents the manifest JSON structure.
|
|
type Manifest struct {
|
|
SchemaVersion int `json:"schemaVersion"`
|
|
MediaType string `json:"mediaType"`
|
|
Config ManifestLayer `json:"config"`
|
|
Layers []ManifestLayer `json:"layers"`
|
|
}
|
|
|
|
// ManifestLayer represents a layer in the manifest.
|
|
type ManifestLayer struct {
|
|
MediaType string `json:"mediaType"`
|
|
Digest string `json:"digest"`
|
|
Size int64 `json:"size"`
|
|
Name string `json:"name,omitempty"`
|
|
}
|
|
|
|
// defaultManifestDir returns the manifest storage directory.
|
|
func defaultManifestDir() string {
|
|
return filepath.Join(envconfig.Models(), "manifests")
|
|
}
|
|
|
|
// defaultBlobDir returns the blob storage directory.
|
|
func defaultBlobDir() string {
|
|
return filepath.Join(envconfig.Models(), "blobs")
|
|
}
|
|
|
|
// resolveManifestPath converts a model name to a manifest file path.
|
|
func resolveManifestPath(modelName string) string {
|
|
host := "registry.ollama.ai"
|
|
namespace := "library"
|
|
name := modelName
|
|
tag := "latest"
|
|
|
|
if idx := strings.LastIndex(name, ":"); idx != -1 {
|
|
tag = name[idx+1:]
|
|
name = name[:idx]
|
|
}
|
|
|
|
parts := strings.Split(name, "/")
|
|
switch len(parts) {
|
|
case 3:
|
|
host = parts[0]
|
|
namespace = parts[1]
|
|
name = parts[2]
|
|
case 2:
|
|
namespace = parts[0]
|
|
name = parts[1]
|
|
}
|
|
|
|
return filepath.Join(defaultManifestDir(), host, namespace, name, tag)
|
|
}
|
|
|
|
// loadManifest loads a manifest for the given model name.
|
|
func loadManifest(modelName string) (*Manifest, error) {
|
|
manifestPath := resolveManifestPath(modelName)
|
|
|
|
data, err := os.ReadFile(manifestPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var manifest Manifest
|
|
if err := json.Unmarshal(data, &manifest); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &manifest, nil
|
|
}
|
|
|
|
// loadModelConfig loads the config blob for a model.
|
|
func loadModelConfig(modelName string) (*ModelConfig, error) {
|
|
manifest, err := loadManifest(modelName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Read the config blob
|
|
blobName := strings.Replace(manifest.Config.Digest, ":", "-", 1)
|
|
blobPath := filepath.Join(defaultBlobDir(), blobName)
|
|
|
|
data, err := os.ReadFile(blobPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var config ModelConfig
|
|
if err := json.Unmarshal(data, &config); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &config, nil
|
|
}
|
|
|
|
// IsSafetensorsLLMModel checks if a model is a safetensors LLM model
|
|
// (has completion capability, not image generation).
|
|
func IsSafetensorsLLMModel(modelName string) bool {
|
|
config, err := loadModelConfig(modelName)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return config.ModelFormat == "safetensors" && slices.Contains(config.Capabilities, "completion")
|
|
}
|
|
|
|
// IsTensorModelDir checks if the directory contains a diffusers-style tensor model
|
|
// by looking for model_index.json, which is the standard diffusers pipeline config.
|
|
func IsTensorModelDir(dir string) bool {
|
|
_, err := os.Stat(filepath.Join(dir, "model_index.json"))
|
|
return err == nil
|
|
}
|
|
|
|
// IsSafetensorsModelDir checks if the directory contains a standard safetensors model
|
|
// by looking for config.json and at least one .safetensors file.
|
|
func IsSafetensorsModelDir(dir string) bool {
|
|
// Must have config.json
|
|
if _, err := os.Stat(filepath.Join(dir, "config.json")); err != nil {
|
|
return false
|
|
}
|
|
|
|
// Must have at least one .safetensors file
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
if strings.HasSuffix(entry.Name(), ".safetensors") {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// LayerInfo holds metadata for a created layer.
|
|
type LayerInfo struct {
|
|
Digest string
|
|
Size int64
|
|
MediaType string
|
|
Name string // Path-style name: "component/tensor" or "path/to/config.json"
|
|
}
|
|
|
|
// LayerCreator is called to create a blob layer.
|
|
// name is the path-style name (e.g., "tokenizer/tokenizer.json")
|
|
type LayerCreator func(r io.Reader, mediaType, name string) (LayerInfo, error)
|
|
|
|
// ManifestWriter writes the manifest file.
|
|
type ManifestWriter func(modelName string, config LayerInfo, layers []LayerInfo) error
|
|
|
|
// ShouldQuantize returns true if a tensor should be quantized.
|
|
// For image gen models (component non-empty): quantizes linear weights, skipping VAE, embeddings, norms.
|
|
// For LLM models (component empty): quantizes linear weights, skipping embeddings, norms, and small tensors.
|
|
func ShouldQuantize(name, component string) bool {
|
|
// Image gen specific: skip VAE entirely
|
|
if component == "vae" {
|
|
return false
|
|
}
|
|
|
|
// Skip audio encoder tensors (highly sensitive to quantization)
|
|
if strings.Contains(name, "audio_tower") || strings.Contains(name, "embed_audio") {
|
|
return false
|
|
}
|
|
|
|
// Skip embeddings
|
|
if strings.Contains(name, "embed") {
|
|
return false
|
|
}
|
|
|
|
// Skip layer norms and RMS norms
|
|
if strings.Contains(name, "norm") || strings.Contains(name, "ln_") || strings.Contains(name, "layernorm") {
|
|
return false
|
|
}
|
|
|
|
// Skip biases
|
|
if strings.HasSuffix(name, ".bias") {
|
|
return false
|
|
}
|
|
|
|
// Only quantize weights
|
|
return strings.HasSuffix(name, ".weight")
|
|
}
|
|
|
|
// normalizeQuantType converts various quantization type aliases to canonical forms.
|
|
// Supports: q4/Q4/int4/INT4/fp4/FP4 -> int4, q8/Q8/int8/INT8/fp8/FP8 -> int8, nvfp4/NVFP4, mxfp4/MXFP4, mxfp8/MXFP8
|
|
func normalizeQuantType(quantize string) string {
|
|
switch strings.ToUpper(quantize) {
|
|
case "Q4", "INT4", "FP4":
|
|
return "int4"
|
|
case "Q8", "INT8", "FP8":
|
|
return "int8"
|
|
case "NVFP4":
|
|
return "nvfp4"
|
|
case "MXFP4":
|
|
return "mxfp4"
|
|
case "MXFP8":
|
|
return "mxfp8"
|
|
default:
|
|
return quantize
|
|
}
|
|
}
|
|
|
|
// isAligned checks if a tensor's last dimension is divisible by the
|
|
// group size required for the given quantization type.
|
|
func isAligned(shape []int32, quantType string) bool {
|
|
if len(shape) == 0 {
|
|
return false
|
|
}
|
|
groupSize := int32(32)
|
|
switch normalizeQuantType(quantType) {
|
|
case "nvfp4":
|
|
groupSize = 16
|
|
case "int4", "int8":
|
|
groupSize = 64
|
|
}
|
|
return shape[len(shape)-1]%groupSize == 0
|
|
}
|
|
|
|
func isStackedExpertWeight(name string) bool {
|
|
// Combined/stacked expert tensors may be emitted either as "...proj.weight" (per-expert)
|
|
// or "...proj" (pre-stacked packed tensor).
|
|
if strings.HasSuffix(name, ".bias") || strings.HasSuffix(name, ".scale") || strings.HasSuffix(name, ".qbias") {
|
|
return false
|
|
}
|
|
|
|
// ".experts." covers the common case (.mlp.experts., .moe.experts.) as well
|
|
// as gemma's bare "...layers.N.experts.gate_up_proj" (no .mlp/.moe prefix).
|
|
return strings.Contains(name, ".experts.") ||
|
|
strings.Contains(name, ".mlp.switch_mlp.") ||
|
|
strings.Contains(name, ".mlp.shared_experts.")
|
|
}
|
|
|
|
// isRoutingGate reports the small MoE routing/gate weights that select the
|
|
// active experts. Quantization noise there can flip expert selection, so they
|
|
// are kept at source precision regardless of architecture.
|
|
func isRoutingGate(name string) bool {
|
|
return strings.HasSuffix(name, ".mlp.gate.weight") ||
|
|
strings.HasSuffix(name, ".shared_expert_gate.weight") ||
|
|
strings.HasSuffix(name, ".router.proj.weight")
|
|
}
|
|
|
|
// GetTensorQuantization returns the appropriate quantization type for a tensor.
|
|
// Returns "" if the tensor should not be quantized.
|
|
func GetTensorQuantization(name string, shape []int32, quantize string) string {
|
|
stackedExpert := isStackedExpertWeight(name)
|
|
|
|
// Use basic name-based check first
|
|
if !stackedExpert && !ShouldQuantize(name, "") {
|
|
return ""
|
|
}
|
|
|
|
// Quantize standard linear weights (2D). Also allow stacked expert weights (3D),
|
|
// e.g. qwen switch_mlp / experts combined tensors.
|
|
if len(shape) != 2 && !(len(shape) == 3 && stackedExpert) {
|
|
return ""
|
|
}
|
|
|
|
// Skip small tensors (less than 1024 elements) - not worth quantizing
|
|
var elems int64 = 1
|
|
for _, d := range shape {
|
|
elems *= int64(d)
|
|
}
|
|
if elems < 1024 {
|
|
return ""
|
|
}
|
|
|
|
// Normalize quantization type to canonical form
|
|
quantNorm := normalizeQuantType(quantize)
|
|
|
|
// Routing gates are tiny and selection-sensitive — keep them at source precision.
|
|
if isRoutingGate(name) {
|
|
return ""
|
|
}
|
|
|
|
// lm_head is too sensitive for the fp quant modes; keep it at source precision.
|
|
if strings.HasSuffix(name, "lm_head.weight") && (quantNorm == "nvfp4" || quantNorm == "mxfp4" || quantNorm == "mxfp8") {
|
|
return ""
|
|
}
|
|
|
|
// MLX quantization requires last dimension to be divisible by group size.
|
|
if !isAligned(shape, quantNorm) {
|
|
return ""
|
|
}
|
|
|
|
// Promote sensitive projections to 8-bit; fp4 skips experts since their kernels take a single mode.
|
|
if quantNorm == "int4" || ((quantNorm == "nvfp4" || quantNorm == "mxfp4") && !stackedExpert) {
|
|
if strings.Contains(name, ".v_proj") || strings.Contains(name, ".k_proj") || strings.Contains(name, "down_proj") {
|
|
if e := eightBit(quantNorm); isAligned(shape, e) {
|
|
return e
|
|
}
|
|
}
|
|
}
|
|
|
|
return quantNorm
|
|
}
|
|
|
|
var (
|
|
expertLayerPrefixRegexp = regexp.MustCompile(`^(?:model\.language_model\.|language_model(?:\.model)?\.|model\.)?layers\.\d+$`)
|
|
)
|
|
|
|
// ExpertGroupPrefix returns the group prefix for expert tensors that should be packed together.
|
|
// For example:
|
|
// - "model.layers.1.mlp.experts.0.down_proj.weight" -> "model.layers.1.mlp.experts"
|
|
// - "model.layers.1.mlp.shared_experts.down_proj.weight" -> "model.layers.1.mlp.shared_experts"
|
|
// - "language_model.model.layers.1.mlp.switch_mlp.down_proj.weight" -> "language_model.model.layers.1.mlp.switch_mlp"
|
|
// - "model.layers.0.mlp.down_proj.weight" -> "" (dense layer, no experts)
|
|
// - "model.layers.1.mlp.gate.weight" -> "" (routing gate, not an expert)
|
|
func ExpertGroupPrefix(tensorName string) string {
|
|
if !strings.HasSuffix(tensorName, ".weight") {
|
|
return ""
|
|
}
|
|
|
|
for _, marker := range []string{
|
|
".mlp.experts.",
|
|
".mlp.shared_experts.",
|
|
".mlp.switch_mlp.",
|
|
".moe.experts.",
|
|
} {
|
|
idx := strings.Index(tensorName, marker)
|
|
if idx == -1 {
|
|
continue
|
|
}
|
|
|
|
layerPrefix := tensorName[:idx]
|
|
if !expertLayerPrefixRegexp.MatchString(layerPrefix) {
|
|
continue
|
|
}
|
|
|
|
return layerPrefix + strings.TrimSuffix(marker, ".")
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
type sourceQuantization struct {
|
|
Bits int `json:"bits"`
|
|
GroupSize int `json:"group_size"`
|
|
Mode string `json:"mode"`
|
|
Format string `json:"format"`
|
|
QuantMethod string `json:"quant_method"`
|
|
WeightBlockSize []int32 `json:"weight_block_size"`
|
|
ConfigGroups map[string]struct {
|
|
Format string `json:"format"`
|
|
Weights struct {
|
|
BlockStructure []int32 `json:"block_structure"`
|
|
NumBits int `json:"num_bits"`
|
|
Type string `json:"type"`
|
|
} `json:"weights"`
|
|
} `json:"config_groups"`
|
|
}
|
|
|
|
type sourceModelConfig struct {
|
|
ModelType string `json:"model_type"`
|
|
Architectures []string `json:"architectures"`
|
|
Quantization sourceQuantization `json:"quantization"`
|
|
QuantizationConfig sourceQuantization `json:"quantization_config"`
|
|
CompressionConfig sourceQuantization `json:"compression_config"`
|
|
TextConfig struct {
|
|
ModelType string `json:"model_type"`
|
|
Quantization sourceQuantization `json:"quantization"`
|
|
QuantizationConfig sourceQuantization `json:"quantization_config"`
|
|
CompressionConfig sourceQuantization `json:"compression_config"`
|
|
} `json:"text_config"`
|
|
}
|
|
|
|
// readSourceModelConfig parses config.json into the shared sourceModelConfig
|
|
// and returns the raw bytes alongside it. The raw bytes are retained on the
|
|
// Inventory so architecture-specific factories can parse their own fields
|
|
// without re-opening the file.
|
|
func readSourceModelConfig(modelDir string) (sourceModelConfig, json.RawMessage, error) {
|
|
configPath := filepath.Join(modelDir, "config.json")
|
|
data, err := os.ReadFile(configPath)
|
|
if err != nil {
|
|
return sourceModelConfig{}, nil, err
|
|
}
|
|
|
|
var cfg sourceModelConfig
|
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
|
return sourceModelConfig{}, nil, err
|
|
}
|
|
|
|
return cfg, data, nil
|
|
}
|
|
|
|
func (cfg sourceModelConfig) Architecture() string {
|
|
if len(cfg.Architectures) > 0 && cfg.Architectures[0] != "" {
|
|
return cfg.Architectures[0]
|
|
}
|
|
if cfg.ModelType != "" {
|
|
return cfg.ModelType
|
|
}
|
|
return cfg.TextConfig.ModelType
|
|
}
|
|
|
|
func (cfg sourceModelConfig) QuantMetadata() map[string]string {
|
|
// Use the first non-empty quantization config found
|
|
var q sourceQuantization
|
|
for _, candidate := range cfg.quantizationConfigs() {
|
|
if candidate.Bits != 0 {
|
|
q = candidate
|
|
break
|
|
}
|
|
}
|
|
|
|
quantType := sourceQuantType(q.Mode, q.Bits)
|
|
if quantType == "" {
|
|
return nil
|
|
}
|
|
|
|
metadata := map[string]string{"quant_type": quantType}
|
|
if q.GroupSize > 0 {
|
|
metadata["group_size"] = strconv.Itoa(q.GroupSize)
|
|
}
|
|
return metadata
|
|
}
|
|
|
|
func (cfg sourceModelConfig) quantizationConfigs() []sourceQuantization {
|
|
return []sourceQuantization{
|
|
cfg.Quantization,
|
|
cfg.QuantizationConfig,
|
|
cfg.CompressionConfig,
|
|
cfg.TextConfig.Quantization,
|
|
cfg.TextConfig.QuantizationConfig,
|
|
cfg.TextConfig.CompressionConfig,
|
|
}
|
|
}
|
|
|
|
func (cfg sourceModelConfig) HFFP8WeightBlockSize() (rows, cols int32, ok bool) {
|
|
for _, q := range cfg.quantizationConfigs() {
|
|
if !strings.EqualFold(q.QuantMethod, "fp8") || len(q.WeightBlockSize) != 2 {
|
|
if !strings.EqualFold(q.QuantMethod, "compressed-tensors") && !strings.EqualFold(q.Format, "float-quantized") {
|
|
continue
|
|
}
|
|
for _, group := range q.ConfigGroups {
|
|
if !strings.EqualFold(group.Format, "float-quantized") || group.Weights.NumBits != 8 || !strings.EqualFold(group.Weights.Type, "float") || len(group.Weights.BlockStructure) != 2 {
|
|
continue
|
|
}
|
|
return group.Weights.BlockStructure[0], group.Weights.BlockStructure[1], true
|
|
}
|
|
continue
|
|
}
|
|
return q.WeightBlockSize[0], q.WeightBlockSize[1], true
|
|
}
|
|
return 0, 0, false
|
|
}
|
|
|
|
type tensorImportTransformFactory func(rawConfig json.RawMessage) (quantizePolicy, error)
|
|
|
|
var tensorImportTransformRegistry = map[string]tensorImportTransformFactory{
|
|
"Qwen3_5ForCausalLM": newQwen35ImportTransform,
|
|
"Qwen3_5ForConditionalGeneration": newQwen35ImportTransform,
|
|
"Qwen3NextForCausalLM": newQwen35ImportTransform,
|
|
"Qwen3NextForConditionalGeneration": newQwen35ImportTransform,
|
|
"Qwen3_5MoeForCausalLM": newQwen35ImportTransform,
|
|
"Qwen3_5MoeForConditionalGeneration": newQwen35ImportTransform,
|
|
"Qwen3NextMoeForCausalLM": newQwen35ImportTransform,
|
|
"Qwen3NextMoeForConditionalGeneration": newQwen35ImportTransform,
|
|
"Gemma4ForCausalLM": newGemma4ImportTransform,
|
|
"Gemma4ForConditionalGeneration": newGemma4ImportTransform,
|
|
"Gemma4UnifiedForCausalLM": newGemma4ImportTransform,
|
|
"Gemma4UnifiedForConditionalGeneration": newGemma4ImportTransform,
|
|
"gemma4_unified": newGemma4ImportTransform,
|
|
"gemma4_unified_text": newGemma4ImportTransform,
|
|
"LagunaForCausalLM": newLagunaImportTransform,
|
|
"Cohere2MoeForCausalLM": newCohere2MoeImportTransform,
|
|
"Gemma4AssistantForCausalLM": newGemma4ImportTransform,
|
|
"Gemma4UnifiedAssistantForCausalLM": newGemma4ImportTransform,
|
|
"gemma4_unified_assistant": newGemma4ImportTransform,
|
|
}
|
|
|
|
func newTensorImportTransform(inv Inventory) (quantizePolicy, error) {
|
|
if factory, ok := tensorImportTransformRegistry[inv.Config.Architecture()]; ok {
|
|
return factory(inv.RawConfig)
|
|
}
|
|
return defaultQuantPolicy{}, nil
|
|
}
|
|
|
|
func buildSourceFP8Reader(weightTD, scaleTD *safetensors.TensorData) io.Reader {
|
|
scaleName := weightTD.Name + ".scale_inv"
|
|
if strings.HasSuffix(scaleTD.Name, "_scale") && !strings.HasSuffix(scaleTD.Name, "_scale_inv") {
|
|
scaleName = weightTD.Name + ".scale"
|
|
}
|
|
return safetensors.BuildPackedSafetensorsReader([]*safetensors.TensorData{weightTD, scaleTD.WithName(scaleName)})
|
|
}
|
|
|
|
func validateScalarFloat32TensorData(td *safetensors.TensorData, name string) (*safetensors.TensorData, error) {
|
|
if td == nil {
|
|
return nil, nil
|
|
}
|
|
if strings.ToUpper(td.Dtype) != "F32" {
|
|
return nil, fmt.Errorf("expected F32 tensor, got %s", td.Dtype)
|
|
}
|
|
n := int32(1)
|
|
for _, dim := range td.Shape {
|
|
n *= dim
|
|
}
|
|
if n != 1 {
|
|
return nil, fmt.Errorf("expected scalar F32 tensor, got shape %v", td.Shape)
|
|
}
|
|
return td.WithName(name), nil
|
|
}
|
|
|
|
func invertScalarFloat32TensorData(td *safetensors.TensorData, name string) (*safetensors.TensorData, error) {
|
|
td, err := validateScalarFloat32TensorData(td, name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
raw, err := io.ReadAll(td.Reader())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(raw)%4 != 0 {
|
|
return nil, fmt.Errorf("invalid F32 tensor byte length %d", len(raw))
|
|
}
|
|
out := make([]byte, len(raw))
|
|
for i := 0; i < len(raw); i += 4 {
|
|
v := math.Float32frombits(binary.LittleEndian.Uint32(raw[i : i+4]))
|
|
if v == 0 {
|
|
return nil, fmt.Errorf("cannot invert zero F32 scale")
|
|
}
|
|
binary.LittleEndian.PutUint32(out[i:i+4], math.Float32bits(1/v))
|
|
}
|
|
return safetensors.NewTensorDataFromBytes(name, td.Dtype, td.Shape, out), nil
|
|
}
|
|
|
|
func readSourceTensorFiles(modelDir string) (map[string]string, error) {
|
|
indexPath := filepath.Join(modelDir, "model.safetensors.index.json")
|
|
data, err := os.ReadFile(indexPath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
var index struct {
|
|
WeightMap map[string]string `json:"weight_map"`
|
|
}
|
|
if err := json.Unmarshal(data, &index); err != nil {
|
|
return nil, err
|
|
}
|
|
return index.WeightMap, nil
|
|
}
|