mirror of
https://github.com/anchore/syft.git
synced 2026-10-11 21:57:22 +02:00
fix(ai): GGUF layer handling and safetensors parameter counts (#5343)
* fix(ai): keep merging GGUF layers when one fails to parse a single unparseable layer in an OCI GGUF artifact skipped the merge, so the named model lost its parts and nameless layers were dropped. The error is still returned. Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * fix(ai): only send GGUF layers to the GGUF cataloger the media type glob `application/vnd.docker.ai*` also matched config, model.file, license and safetensors layers, so every safetensors OCI model reported spurious "invalid GGUF magic number" unknowns. Match `application/vnd.docker.ai.gguf.v3` exactly. Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * fix(ai): don't wrap safetensors parameter counts - tensors whose shape product overflows uint64 are skipped instead of wrapping - per-file and per-shard parameter totals saturate instead of wrapping - parameter count and dominant dtype are computed in one pass - GGUF licenses are created with the scan context, and drop no-op unknown checks Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * refactor(ai): derive safetensors model info from the header in one place the file and OCI layer parsers built identical metadata from a parsed header. Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * fix(ai): keep the GGUF media type with the GGUF cataloger also corrects stale comments in the GGUF merge processor. Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> --------- Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
This commit is contained in:
@@ -11,8 +11,12 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
catalogerName = "gguf-cataloger"
|
catalogerName = "gguf-cataloger"
|
||||||
ggufLayerMediaType = "application/vnd.docker.ai*"
|
|
||||||
safeTensorsCatalogerName = "safetensors-cataloger"
|
safeTensorsCatalogerName = "safetensors-cataloger"
|
||||||
|
|
||||||
|
// dockerAIGGUFMediaType mirrors ggufLayerMediaType in
|
||||||
|
// syft/source/ocimodelsource/registry_client.go. It is matched exactly: the
|
||||||
|
// other application/vnd.docker.ai.* layers are not GGUF.
|
||||||
|
dockerAIGGUFMediaType = "application/vnd.docker.ai.gguf.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewGGUFCataloger returns a new cataloger instance for GGUF model files.
|
// NewGGUFCataloger returns a new cataloger instance for GGUF model files.
|
||||||
@@ -21,7 +25,7 @@ const (
|
|||||||
func NewGGUFCataloger() pkg.Cataloger {
|
func NewGGUFCataloger() pkg.Cataloger {
|
||||||
return generic.NewCataloger(catalogerName).
|
return generic.NewCataloger(catalogerName).
|
||||||
WithParserByGlobs(parseGGUFModel, "**/*.gguf").
|
WithParserByGlobs(parseGGUFModel, "**/*.gguf").
|
||||||
WithParserByMediaType(parseGGUFModel, ggufLayerMediaType).
|
WithParserByMediaType(parseGGUFModel, dockerAIGGUFMediaType).
|
||||||
WithProcessors(ggufMergeProcessor)
|
WithProcessors(ggufMergeProcessor)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
package ai
|
package ai
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/anchore/syft/internal/tmpdir"
|
||||||
|
"github.com/anchore/syft/syft/internal/fileresolver"
|
||||||
|
|
||||||
"github.com/anchore/syft/syft/artifact"
|
"github.com/anchore/syft/syft/artifact"
|
||||||
"github.com/anchore/syft/syft/pkg"
|
"github.com/anchore/syft/syft/pkg"
|
||||||
"github.com/anchore/syft/syft/pkg/cataloger/internal/pkgtest"
|
"github.com/anchore/syft/syft/pkg/cataloger/internal/pkgtest"
|
||||||
@@ -138,3 +145,42 @@ func TestGGUFCataloger(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGGUFCataloger_ociLayers(t *testing.T) {
|
||||||
|
// a GGUF OCI artifact is merged into one named package, while the other
|
||||||
|
// application/vnd.docker.ai.* layers (config, model.file, license and
|
||||||
|
// safetensors weights) never reach the GGUF parser.
|
||||||
|
named := newTestGGUFBuilder().
|
||||||
|
withStringKV("general.architecture", "llama").
|
||||||
|
withStringKV("general.name", "llama3-8b").
|
||||||
|
build()
|
||||||
|
part := newTestGGUFBuilder().withStringKV("general.architecture", "llama").build()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
layers := map[string]fileresolver.LayerInfo{}
|
||||||
|
for digest, l := range map[string]struct {
|
||||||
|
mediaType string
|
||||||
|
content []byte
|
||||||
|
}{
|
||||||
|
"sha256:named": {dockerAIGGUFMediaType, named},
|
||||||
|
"sha256:part": {dockerAIGGUFMediaType, part},
|
||||||
|
"sha256:config": {dockerAIModelConfigMediaTypes[0], []byte("not a gguf file")},
|
||||||
|
"sha256:file": {dockerAIModelFileMediaType, []byte("not a gguf file")},
|
||||||
|
"sha256:license": {dockerAILicenseMediaType, []byte("not a gguf file")},
|
||||||
|
"sha256:weights": {dockerAISafeTensorsMediaType, []byte("not a gguf file")},
|
||||||
|
} {
|
||||||
|
p := filepath.Join(dir, digest[len("sha256:"):])
|
||||||
|
require.NoError(t, os.WriteFile(p, l.content, 0o600))
|
||||||
|
layers[digest] = fileresolver.LayerInfo{TempPath: p, MediaType: l.mediaType}
|
||||||
|
}
|
||||||
|
resolver := fileresolver.NewContainerImageModel(dir, layers, "example/model:latest")
|
||||||
|
ctx := tmpdir.WithValue(context.Background(), tmpdir.FromPath(t.TempDir()))
|
||||||
|
|
||||||
|
pkgs, _, err := NewGGUFCataloger().Catalog(ctx, resolver)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, pkgs, 1)
|
||||||
|
assert.Equal(t, "llama3-8b", pkgs[0].Name)
|
||||||
|
header, ok := pkgs[0].Metadata.(pkg.GGUFFileHeader)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Len(t, header.Parts, 1)
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,17 +8,17 @@ import (
|
|||||||
// ggufMergeProcessor consolidates multiple GGUF packages into a single package
|
// ggufMergeProcessor consolidates multiple GGUF packages into a single package
|
||||||
// representing the AI model. When scanning OCI images with multiple layers,
|
// representing the AI model. When scanning OCI images with multiple layers,
|
||||||
// each layer may produce a separate package. This processor finds the package
|
// each layer may produce a separate package. This processor finds the package
|
||||||
// with a name and merges metadata from nameless packages into its GGUFFileParts field.
|
// with a name and merges metadata from nameless packages into its Parts field.
|
||||||
// Only packages with a non-empty name are returned in the final result.
|
// Only packages with a non-empty name are returned in the final result.
|
||||||
func ggufMergeProcessor(pkgs []pkg.Package, rels []artifact.Relationship, err error) ([]pkg.Package, []artifact.Relationship, error) {
|
func ggufMergeProcessor(pkgs []pkg.Package, rels []artifact.Relationship, err error) ([]pkg.Package, []artifact.Relationship, error) {
|
||||||
if err != nil {
|
// don't early-return on err: it aggregates per-file failures, and skipping the
|
||||||
return pkgs, rels, err
|
// merge would drop every nameless layer and the named package's Parts along
|
||||||
}
|
// with it (see safeTensorsMergeProcessor).
|
||||||
if len(pkgs) == 0 {
|
if len(pkgs) == 0 {
|
||||||
return pkgs, rels, err
|
return pkgs, rels, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Separate packages with names from those without
|
// separate packages with names from those without
|
||||||
var namedPkgs []pkg.Package
|
var namedPkgs []pkg.Package
|
||||||
var namelessHeaders []pkg.GGUFFileHeader
|
var namelessHeaders []pkg.GGUFFileHeader
|
||||||
|
|
||||||
@@ -27,14 +27,14 @@ func ggufMergeProcessor(pkgs []pkg.Package, rels []artifact.Relationship, err er
|
|||||||
namedPkgs = append(namedPkgs, p)
|
namedPkgs = append(namedPkgs, p)
|
||||||
} else {
|
} else {
|
||||||
if header, ok := p.Metadata.(pkg.GGUFFileHeader); ok {
|
if header, ok := p.Metadata.(pkg.GGUFFileHeader); ok {
|
||||||
// We do not want a kv hash for nameless headers
|
// we do not want a kv hash for nameless headers
|
||||||
header.MetadataKeyValuesHash = ""
|
header.MetadataKeyValuesHash = ""
|
||||||
namelessHeaders = append(namelessHeaders, header)
|
namelessHeaders = append(namelessHeaders, header)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If there are no named packages, return nothing
|
// if there are no named packages, return nothing
|
||||||
if len(namedPkgs) == 0 {
|
if len(namedPkgs) == 0 {
|
||||||
return nil, rels, err
|
return nil, rels, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ func mergeShardsInto(merged *pkg.SafeTensorsModelInfo, shards []pkg.SafeTensorsM
|
|||||||
seenKV := map[string]bool{}
|
seenKV := map[string]bool{}
|
||||||
for _, s := range shards {
|
for _, s := range shards {
|
||||||
shardTensorTotal += s.TensorCount
|
shardTensorTotal += s.TensorCount
|
||||||
merged.Parameters += s.Parameters
|
merged.Parameters = saturatingAdd(merged.Parameters, s.Parameters)
|
||||||
firstNonEmpty(&merged.Quantization, s.Quantization)
|
firstNonEmpty(&merged.Quantization, s.Quantization)
|
||||||
for _, kv := range s.UserMetadata {
|
for _, kv := range s.UserMetadata {
|
||||||
if seenKV[kv.Key] {
|
if seenKV[kv.Key] {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package ai
|
package ai
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"math"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -164,3 +165,10 @@ func TestRollupHash(t *testing.T) {
|
|||||||
assert.NotEqual(t, "a", ab)
|
assert.NotEqual(t, "a", ab)
|
||||||
assert.NotEqual(t, "b", ab)
|
assert.NotEqual(t, "b", ab)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMergeShardsInto_parametersSaturate(t *testing.T) {
|
||||||
|
var merged pkg.SafeTensorsModelInfo
|
||||||
|
shards := []pkg.SafeTensorsModelInfo{{Parameters: math.MaxUint64 - 1}, {Parameters: 2}}
|
||||||
|
mergeShardsInto(&merged, shards)
|
||||||
|
assert.Equal(t, uint64(math.MaxUint64), merged.Parameters)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
package ai
|
package ai
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
"github.com/anchore/syft/syft/file"
|
"github.com/anchore/syft/syft/file"
|
||||||
"github.com/anchore/syft/syft/pkg"
|
"github.com/anchore/syft/syft/pkg"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newGGUFPackage(metadata *pkg.GGUFFileHeader, modelName, version, license string, locations ...file.Location) pkg.Package {
|
func newGGUFPackage(ctx context.Context, metadata *pkg.GGUFFileHeader, modelName, version, license string, locations ...file.Location) pkg.Package {
|
||||||
p := pkg.Package{
|
p := pkg.Package{
|
||||||
Name: modelName,
|
Name: modelName,
|
||||||
Version: version,
|
Version: version,
|
||||||
Locations: file.NewLocationSet(locations...),
|
Locations: file.NewLocationSet(locations...),
|
||||||
Type: pkg.ModelPkg,
|
Type: pkg.ModelPkg,
|
||||||
Licenses: pkg.NewLicenseSet(pkg.NewLicensesFromValues(license)...),
|
Licenses: pkg.NewLicenseSet(pkg.NewLicensesFromValuesWithContext(ctx, license)...),
|
||||||
Metadata: *metadata,
|
Metadata: *metadata,
|
||||||
// NOTE: PURL is intentionally not set as the package-url spec
|
// NOTE: PURL is intentionally not set as the package-url spec
|
||||||
// has not yet finalized support for ML model packages
|
// has not yet finalized support for ML model packages
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package ai
|
package ai
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -103,6 +104,7 @@ func TestNewGGUFPackage(t *testing.T) {
|
|||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
actual := newGGUFPackage(
|
actual := newGGUFPackage(
|
||||||
|
context.Background(),
|
||||||
tt.metadata,
|
tt.metadata,
|
||||||
tt.input.modelName,
|
tt.input.modelName,
|
||||||
tt.input.version,
|
tt.input.version,
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ func copyHeader(w io.Writer, r io.Reader) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to convert gguf_parser metadata to simpler types
|
// helper to convert gguf_parser metadata to simpler types
|
||||||
func convertGGUFMetadataKVs(kvs gguf_parser.GGUFMetadataKVs) map[string]any {
|
func convertGGUFMetadataKVs(kvs gguf_parser.GGUFMetadataKVs) map[string]any {
|
||||||
result := make(map[string]any)
|
result := make(map[string]any)
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import (
|
|||||||
"github.com/anchore/syft/internal"
|
"github.com/anchore/syft/internal"
|
||||||
"github.com/anchore/syft/internal/log"
|
"github.com/anchore/syft/internal/log"
|
||||||
"github.com/anchore/syft/internal/tmpdir"
|
"github.com/anchore/syft/internal/tmpdir"
|
||||||
"github.com/anchore/syft/internal/unknown"
|
|
||||||
"github.com/anchore/syft/syft/artifact"
|
"github.com/anchore/syft/syft/artifact"
|
||||||
"github.com/anchore/syft/syft/file"
|
"github.com/anchore/syft/syft/file"
|
||||||
"github.com/anchore/syft/syft/pkg"
|
"github.com/anchore/syft/syft/pkg"
|
||||||
@@ -79,6 +78,7 @@ func parseGGUFModel(ctx context.Context, _ file.Resolver, _ *generic.Environment
|
|||||||
|
|
||||||
// Create package from metadata
|
// Create package from metadata
|
||||||
p := newGGUFPackage(
|
p := newGGUFPackage(
|
||||||
|
ctx,
|
||||||
syftMetadata,
|
syftMetadata,
|
||||||
metadata.Name,
|
metadata.Name,
|
||||||
modelVersion,
|
modelVersion,
|
||||||
@@ -86,7 +86,7 @@ func parseGGUFModel(ctx context.Context, _ file.Resolver, _ *generic.Environment
|
|||||||
reader.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation),
|
reader.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation),
|
||||||
)
|
)
|
||||||
|
|
||||||
return []pkg.Package{p}, nil, unknown.IfEmptyf([]pkg.Package{p}, "unable to parse GGUF file")
|
return []pkg.Package{p}, nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// computeKVMetadataHash computes a stable hash of the KV metadata for use as a global identifier
|
// computeKVMetadataHash computes a stable hash of the KV metadata for use as a global identifier
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"math"
|
||||||
|
"math/bits"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -82,47 +84,67 @@ func readSafeTensorsHeader(r io.Reader) (*safeTensorsHeader, error) {
|
|||||||
return h, nil
|
return h, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// parameterCount sums the element counts across all tensors in the header.
|
// modelInfo returns the metadata derivable from the header bytes alone. Naming,
|
||||||
func (h *safeTensorsHeader) parameterCount() uint64 {
|
// licenses and ShardCount are left to the merge processor.
|
||||||
var total uint64
|
func (h *safeTensorsHeader) modelInfo() pkg.SafeTensorsModelInfo {
|
||||||
for _, t := range h.tensors {
|
params, dtype := h.parameterStats()
|
||||||
count := uint64(1)
|
return pkg.SafeTensorsModelInfo{
|
||||||
for _, dim := range t.Shape {
|
Format: "safetensors",
|
||||||
if dim <= 0 {
|
TensorCount: uint64(len(h.tensors)),
|
||||||
count = 0
|
Parameters: params,
|
||||||
break
|
Quantization: normalizeDType(dtype),
|
||||||
}
|
UserMetadata: userMetadataKeyValues(h.metadata),
|
||||||
count *= uint64(dim)
|
MetadataHash: h.metadataHash(),
|
||||||
}
|
|
||||||
total += count
|
|
||||||
}
|
}
|
||||||
return total
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// dominantDType returns the dtype that accounts for the largest fraction of parameters.
|
// parameterStats sums the element counts across all tensors and returns the
|
||||||
// For mixed-precision models the "dominant" dtype is still a useful summary.
|
// dtype that accounts for the largest share of them. For mixed-precision models
|
||||||
func (h *safeTensorsHeader) dominantDType() string {
|
// the "dominant" dtype is still a useful summary. Totals saturate rather than wrap.
|
||||||
|
func (h *safeTensorsHeader) parameterStats() (total uint64, dominantDType string) {
|
||||||
sizeByDType := make(map[string]uint64)
|
sizeByDType := make(map[string]uint64)
|
||||||
for _, t := range h.tensors {
|
for _, t := range h.tensors {
|
||||||
count := uint64(1)
|
count, ok := tensorElements(t.Shape)
|
||||||
for _, dim := range t.Shape {
|
if !ok {
|
||||||
if dim <= 0 {
|
continue
|
||||||
count = 0
|
|
||||||
break
|
|
||||||
}
|
|
||||||
count *= uint64(dim)
|
|
||||||
}
|
}
|
||||||
sizeByDType[t.DType] += count
|
total = saturatingAdd(total, count)
|
||||||
|
sizeByDType[t.DType] = saturatingAdd(sizeByDType[t.DType], count)
|
||||||
}
|
}
|
||||||
var best string
|
|
||||||
var bestSize uint64
|
var bestSize uint64
|
||||||
for dtype, size := range sizeByDType {
|
for dtype, size := range sizeByDType {
|
||||||
if size > bestSize || (size == bestSize && dtype < best) {
|
if size > bestSize || (size == bestSize && dtype < dominantDType) {
|
||||||
best = dtype
|
dominantDType = dtype
|
||||||
bestSize = size
|
bestSize = size
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return best
|
return total, dominantDType
|
||||||
|
}
|
||||||
|
|
||||||
|
// tensorElements returns the element count for a shape, or ok=false on a
|
||||||
|
// non-positive dim or when the product overflows uint64.
|
||||||
|
func tensorElements(shape []int64) (uint64, bool) {
|
||||||
|
n := uint64(1)
|
||||||
|
for _, d := range shape {
|
||||||
|
if d <= 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
hi, lo := bits.Mul64(n, uint64(d))
|
||||||
|
if hi != 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
n = lo
|
||||||
|
}
|
||||||
|
return n, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// saturatingAdd returns a+b, clamped to math.MaxUint64 instead of wrapping.
|
||||||
|
func saturatingAdd(a, b uint64) uint64 {
|
||||||
|
sum, carry := bits.Add64(a, b, 0)
|
||||||
|
if carry != 0 {
|
||||||
|
return math.MaxUint64
|
||||||
|
}
|
||||||
|
return sum
|
||||||
}
|
}
|
||||||
|
|
||||||
// metadataHash returns a stable xxhash64 over the logical tensor content
|
// metadataHash returns a stable xxhash64 over the logical tensor content
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
|
|
||||||
"github.com/anchore/syft/internal"
|
"github.com/anchore/syft/internal"
|
||||||
"github.com/anchore/syft/internal/unknown"
|
|
||||||
"github.com/anchore/syft/syft/artifact"
|
"github.com/anchore/syft/syft/artifact"
|
||||||
"github.com/anchore/syft/syft/file"
|
"github.com/anchore/syft/syft/file"
|
||||||
"github.com/anchore/syft/syft/pkg"
|
"github.com/anchore/syft/syft/pkg"
|
||||||
@@ -26,22 +25,13 @@ func parseSafeTensorsFile(_ context.Context, _ file.Resolver, _ *generic.Environ
|
|||||||
return nil, nil, fmt.Errorf("failed to read safetensors header: %w", err)
|
return nil, nil, fmt.Errorf("failed to read safetensors header: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ShardCount is intentionally not set here: the merge processor is the single
|
md := header.modelInfo()
|
||||||
// owner of ShardCount and derives it from the number of shards in the group.
|
|
||||||
md := pkg.SafeTensorsModelInfo{
|
|
||||||
Format: "safetensors",
|
|
||||||
TensorCount: uint64(len(header.tensors)),
|
|
||||||
Parameters: header.parameterCount(),
|
|
||||||
Quantization: normalizeDType(header.dominantDType()),
|
|
||||||
UserMetadata: userMetadataKeyValues(header.metadata),
|
|
||||||
MetadataHash: header.metadataHash(),
|
|
||||||
}
|
|
||||||
|
|
||||||
p := newSafeTensorsPackage(
|
p := newSafeTensorsPackage(
|
||||||
&md,
|
&md,
|
||||||
reader.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation),
|
reader.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation),
|
||||||
)
|
)
|
||||||
return []pkg.Package{p}, nil, unknown.IfEmptyf([]pkg.Package{p}, "unable to parse safetensors file")
|
return []pkg.Package{p}, nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// integrity check
|
// integrity check
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/anchore/syft/internal"
|
"github.com/anchore/syft/internal"
|
||||||
"github.com/anchore/syft/internal/unknown"
|
|
||||||
"github.com/anchore/syft/syft/artifact"
|
"github.com/anchore/syft/syft/artifact"
|
||||||
"github.com/anchore/syft/syft/file"
|
"github.com/anchore/syft/syft/file"
|
||||||
"github.com/anchore/syft/syft/pkg"
|
"github.com/anchore/syft/syft/pkg"
|
||||||
@@ -81,7 +80,7 @@ func parseSafeTensorsOCIConfig(_ context.Context, _ file.Resolver, _ *generic.En
|
|||||||
&md,
|
&md,
|
||||||
reader.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation),
|
reader.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation),
|
||||||
)
|
)
|
||||||
return []pkg.Package{p}, nil, unknown.IfEmptyf([]pkg.Package{p}, "unable to parse docker AI safetensors config")
|
return []pkg.Package{p}, nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseSafeTensorsOCILayer decodes the JSON header of a SafeTensors weight
|
// parseSafeTensorsOCILayer decodes the JSON header of a SafeTensors weight
|
||||||
@@ -94,14 +93,7 @@ func parseSafeTensorsOCILayer(_ context.Context, _ file.Resolver, _ *generic.Env
|
|||||||
return nil, nil, fmt.Errorf("failed to read safetensors layer header: %w", err)
|
return nil, nil, fmt.Errorf("failed to read safetensors layer header: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
md := pkg.SafeTensorsModelInfo{
|
md := header.modelInfo()
|
||||||
Format: "safetensors",
|
|
||||||
TensorCount: uint64(len(header.tensors)),
|
|
||||||
Parameters: header.parameterCount(),
|
|
||||||
Quantization: normalizeDType(header.dominantDType()),
|
|
||||||
UserMetadata: userMetadataKeyValues(header.metadata),
|
|
||||||
MetadataHash: header.metadataHash(),
|
|
||||||
}
|
|
||||||
|
|
||||||
p := newSafeTensorsPackage(
|
p := newSafeTensorsPackage(
|
||||||
&md,
|
&md,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
|
"math"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
@@ -1146,8 +1147,9 @@ func TestSafeTensorsHeader_parameterCountAndDType(t *testing.T) {
|
|||||||
"small": {DType: "F32", Shape: []int64{16, 16}},
|
"small": {DType: "F32", Shape: []int64{16, 16}},
|
||||||
"scalar": {DType: "F32", Shape: []int64{}}, // empty shape contributes 1
|
"scalar": {DType: "F32", Shape: []int64{}}, // empty shape contributes 1
|
||||||
}}
|
}}
|
||||||
assert.Equal(t, uint64(1000*16+16*16+1), h.parameterCount())
|
params, dtype := h.parameterStats()
|
||||||
assert.Equal(t, "BF16", h.dominantDType())
|
assert.Equal(t, uint64(1000*16+16*16+1), params)
|
||||||
|
assert.Equal(t, "BF16", dtype)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSafeTensorsHeader_parameterCount_nonPositiveDims(t *testing.T) {
|
func TestSafeTensorsHeader_parameterCount_nonPositiveDims(t *testing.T) {
|
||||||
@@ -1159,7 +1161,29 @@ func TestSafeTensorsHeader_parameterCount_nonPositiveDims(t *testing.T) {
|
|||||||
"zero": {DType: "F32", Shape: []int64{4, 0}},
|
"zero": {DType: "F32", Shape: []int64{4, 0}},
|
||||||
"negative": {DType: "F32", Shape: []int64{-1, 8}},
|
"negative": {DType: "F32", Shape: []int64{-1, 8}},
|
||||||
}}
|
}}
|
||||||
assert.Equal(t, uint64(16), h.parameterCount())
|
params, _ := h.parameterStats()
|
||||||
|
assert.Equal(t, uint64(16), params)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSafeTensorsHeader_parameterStats_overflow(t *testing.T) {
|
||||||
|
// a shape whose product overflows uint64 is skipped instead of wrapping (the
|
||||||
|
// first tensor would wrap to 0) and can't flip the dominant dtype; the total
|
||||||
|
// saturates instead of wrapping.
|
||||||
|
h := &safeTensorsHeader{tensors: map[string]safeTensorsEntry{
|
||||||
|
"good": {DType: "BF16", Shape: []int64{4, 4}},
|
||||||
|
"wraps": {DType: "F32", Shape: []int64{4294967296, 4294967296}},
|
||||||
|
"overflow": {DType: "F32", Shape: []int64{math.MaxInt64, 3}},
|
||||||
|
}}
|
||||||
|
params, dtype := h.parameterStats()
|
||||||
|
assert.Equal(t, uint64(16), params)
|
||||||
|
assert.Equal(t, "BF16", dtype)
|
||||||
|
|
||||||
|
h = &safeTensorsHeader{tensors: map[string]safeTensorsEntry{
|
||||||
|
"a": {DType: "F32", Shape: []int64{math.MaxInt64, 2}},
|
||||||
|
"b": {DType: "F32", Shape: []int64{math.MaxInt64, 2}},
|
||||||
|
}}
|
||||||
|
params, _ = h.parameterStats()
|
||||||
|
assert.Equal(t, uint64(math.MaxUint64), params)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSafeTensorsHeader_dominantDType_tieBreak(t *testing.T) {
|
func TestSafeTensorsHeader_dominantDType_tieBreak(t *testing.T) {
|
||||||
@@ -1169,7 +1193,8 @@ func TestSafeTensorsHeader_dominantDType_tieBreak(t *testing.T) {
|
|||||||
"a": {DType: "F32", Shape: []int64{4, 4}},
|
"a": {DType: "F32", Shape: []int64{4, 4}},
|
||||||
"b": {DType: "BF16", Shape: []int64{4, 4}},
|
"b": {DType: "BF16", Shape: []int64{4, 4}},
|
||||||
}}
|
}}
|
||||||
assert.Equal(t, "BF16", h.dominantDType())
|
_, dtype := h.parameterStats()
|
||||||
|
assert.Equal(t, "BF16", dtype)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNormalizeDType(t *testing.T) {
|
func TestNormalizeDType(t *testing.T) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package ai
|
package ai
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -61,3 +62,20 @@ func Test_ggufMergeProcessor(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Test_ggufMergeProcessor_mergesDespiteError(t *testing.T) {
|
||||||
|
// one layer failing to parse must not drop the rest of the model
|
||||||
|
parseErr := errors.New("one layer failed to parse")
|
||||||
|
pkgs := []pkg.Package{
|
||||||
|
{Name: "model", Metadata: pkg.GGUFFileHeader{MetadataKeyValuesHash: "abc"}},
|
||||||
|
{Name: "", Metadata: pkg.GGUFFileHeader{MetadataKeyValuesHash: "part1"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
got, _, err := ggufMergeProcessor(pkgs, nil, parseErr)
|
||||||
|
require.ErrorIs(t, err, parseErr)
|
||||||
|
require.Len(t, got, 1)
|
||||||
|
assert.Equal(t, "model", got[0].Name)
|
||||||
|
header, ok := got[0].Metadata.(pkg.GGUFFileHeader)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Len(t, header.Parts, 1)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user