mirror of
https://github.com/anchore/syft.git
synced 2026-10-30 14:02:26 +01:00
Pin Gguff lib to prevent dropping packages (#5447)
* test: catalog GGUF models larger than the header gguf-parser-go v0.26.3+ rejects a header whose tensors declare more data than follows it. Syft parses only a 50 MB copy of each model, so any larger model is dropped. Signed-off-by: Christopher Phillips <32073428+spiffcs@users.noreply.github.com> * fix: fix parser with rollback and verify with test Signed-off-by: Christopher Phillips <32073428+spiffcs@users.noreply.github.com> * fix: bound GGUF tensor info before the parser reads it The header pre-scan stopped after the metadata KVs and left tensor infos to gguf-parser-go. Older releases of the library allocate whatever length a tensor name declares, so a tiny file could force a multi-GB allocation, or a fatal out of memory that panic recovery can't catch. - tensor names are bounded like metadata keys (non-empty, length limit, shared string budget) - dimension count is capped at `GGML_MAX_DIMS` - a tensor count larger than the header now fails at EOF in the pre-scan Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * fix: give the GGUF header copy the model's real size The cataloger copies at most 50 MB of a model and parses that copy. Newer gguf-parser-go releases check the tensor data the header declares against the file size, so the truncated copy of any larger model was rejected. - after the header is validated, the copy is extended to the size the resolver reports for the model, leaving the tail as a hole the parser never reads - sources that report no size (OCI model artifacts today) keep the old behavior Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * chore(deps): restore gguf-parser-go v0.26.4 The header copy now carries the model's real size, so the pin to v0.26.0 and its dependabot ignore are no longer needed. This also brings back upstream's string and array length bounds, and the library's own `IsNumeric`. Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * fix: keep the extended GGUF header copy sparse on Windows NTFS reserves disk for the full length of a file extended with truncate unless the file is marked sparse, so cataloging a large model on Windows reserved the model's size in the temp dir until cleanup. - the header copy is marked sparse (`FSCTL_SET_SPARSE`) before it is extended - if the filesystem can't make it sparse (FAT32, exFAT), the copy is not extended, and models larger than it fail to parse as before - `golang.org/x/sys` moves from an indirect to a direct dependency Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> --------- Signed-off-by: Christopher Phillips <32073428+spiffcs@users.noreply.github.com> Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> Co-authored-by: Alex Goodman <wagoodman@users.noreply.github.com>
This commit is contained in:
co-authored by
Alex Goodman
parent
a81193226f
commit
18517c0bc3
@@ -103,6 +103,7 @@ require (
|
||||
require (
|
||||
github.com/pb33f/ordered-map/v2 v2.3.2
|
||||
github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd
|
||||
golang.org/x/sys v0.49.0
|
||||
howett.net/plist v1.0.1
|
||||
)
|
||||
|
||||
@@ -332,7 +333,6 @@ require (
|
||||
golang.org/x/crypto v0.58.0 // indirect
|
||||
golang.org/x/oauth2 v0.37.0 // indirect
|
||||
golang.org/x/sync v0.24.0 // indirect
|
||||
golang.org/x/sys v0.49.0 // indirect
|
||||
golang.org/x/term v0.47.0 // indirect
|
||||
golang.org/x/text v0.43.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
|
||||
|
||||
@@ -129,6 +129,45 @@ func TestGGUFCataloger(t *testing.T) {
|
||||
},
|
||||
expectedRelationships: nil,
|
||||
},
|
||||
{
|
||||
// syft copies at most maxHeaderSize bytes of a model to parse, so in any model larger than that the
|
||||
// tensors declare far more data than the copy holds
|
||||
name: "catalog GGUF file larger than the header copy",
|
||||
setup: func(t *testing.T) string {
|
||||
dir := t.TempDir()
|
||||
data := newTestGGUFBuilder().
|
||||
withStringKV("general.architecture", "qwen2").
|
||||
withUint32KV("general.file_type", 0). // ALL_F32
|
||||
withTensor("token_embd.weight", ggmlTypeF32, 1024, 8192). // 32 MiB
|
||||
withTensor("blk.0.ffn_up.weight", ggmlTypeF32, 1024, 8192). // 32 MiB
|
||||
build()
|
||||
|
||||
path := filepath.Join(dir, "qwen2.5-0.5b-instruct-f32.gguf")
|
||||
require.NoError(t, os.WriteFile(path, data, 0644))
|
||||
// the tensor data is never read, so it is left as a sparse range: alignment padding, then 64 MiB
|
||||
require.NoError(t, os.Truncate(path, int64(len(data))+32+64<<20))
|
||||
return dir
|
||||
},
|
||||
expectedPackages: []pkg.Package{
|
||||
{
|
||||
Name: "qwen2.5-0.5b-instruct-f32",
|
||||
Type: pkg.ModelPkg,
|
||||
Licenses: pkg.NewLicenseSet(),
|
||||
Metadata: pkg.GGUFFileHeader{
|
||||
Architecture: "qwen2",
|
||||
Quantization: "F32",
|
||||
Parameters: 2 * 1024 * 8192,
|
||||
GGUFVersion: 3,
|
||||
TensorCount: 2,
|
||||
MetadataKeyValuesHash: "fddec34fd07b3547",
|
||||
RemainingKeyValues: map[string]any{
|
||||
"general.file_type": uint32(0),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedRelationships: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -28,6 +28,8 @@ const (
|
||||
maxGGUFSplitTensors = 1 << 17
|
||||
// maxGGUFKeyLength is the key length limit from the GGUF spec.
|
||||
maxGGUFKeyLength = 65535
|
||||
// maxGGUFTensorDims is GGML_MAX_DIMS.
|
||||
maxGGUFTensorDims = 4
|
||||
// maxGGUFKVCount bounds the metadata KV count. Real files hold hundreds; vocabularies live in arrays.
|
||||
maxGGUFKVCount = 65536
|
||||
// maxGGUFReadStringBytes bounds, across the header, the key and string bytes the library reads in
|
||||
@@ -78,10 +80,10 @@ func copyHeader(w io.Writer, r io.Reader) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateGGUFHeader walks the metadata KV section and rejects values that gguf-parser-go would panic
|
||||
// on, recurse on without bound, or overallocate for. It reads without storing anything.
|
||||
// ponytail: tensor infos are not checked here (zero block size types, dimension overflow); those
|
||||
// panics are recoverable and are left to the per-file panic recovery in the generic cataloger.
|
||||
// validateGGUFHeader walks the metadata KV and tensor info sections and rejects values that gguf-parser-go
|
||||
// would panic on, recurse on without bound, or overallocate for. It reads without storing anything.
|
||||
// ponytail: tensor types and dimensions are not checked here (zero block size types, dimension overflow);
|
||||
// those panics are recoverable and are left to the per-file panic recovery in the generic cataloger.
|
||||
func validateGGUFHeader(r io.Reader) error {
|
||||
v := &ggufHeaderValidator{
|
||||
r: bufio.NewReader(r),
|
||||
@@ -96,8 +98,9 @@ func validateGGUFHeader(r io.Reader) error {
|
||||
return fmt.Errorf("reading header prefix: %w", err)
|
||||
}
|
||||
v.v1 = prefix.Version <= 1
|
||||
// the tensor count is validated by the library
|
||||
if _, err := v.readLen(); err != nil {
|
||||
// tensors are walked below, so a count beyond the header runs into EOF
|
||||
tensorCount, err := v.readLen()
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading tensor count: %w", err)
|
||||
}
|
||||
kvCount, err := v.readLen()
|
||||
@@ -121,9 +124,38 @@ func validateGGUFHeader(r io.Reader) error {
|
||||
return fmt.Errorf("metadata key %q: %w", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
for i := uint64(0); i < tensorCount; i++ {
|
||||
if err := v.skipTensorInfo(); err != nil {
|
||||
return fmt.Errorf("tensor info %d: %w", i, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// skipTensorInfo consumes one tensor info: name, dimension count, dimensions, type, data offset.
|
||||
func (v *ggufHeaderValidator) skipTensorInfo() error {
|
||||
// the library reads names in full, so they are bounded like keys
|
||||
if _, err := v.readKey(); err != nil {
|
||||
return fmt.Errorf("name: %w", err)
|
||||
}
|
||||
var nDims uint32
|
||||
if err := binary.Read(v.r, binary.LittleEndian, &nDims); err != nil {
|
||||
return err
|
||||
}
|
||||
if nDims > maxGGUFTensorDims {
|
||||
return fmt.Errorf("dimension count %d exceeds %d", nDims, maxGGUFTensorDims)
|
||||
}
|
||||
for range nDims {
|
||||
if _, err := v.readLen(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// type (uint32) and data offset (uint64)
|
||||
_, err := v.r.Discard(4 + 8)
|
||||
return err
|
||||
}
|
||||
|
||||
type ggufHeaderValidator struct {
|
||||
r *bufio.Reader
|
||||
v1 bool // v1 uses 32-bit lengths
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
gguf_parser "github.com/gpustack/gguf-parser-go"
|
||||
|
||||
"github.com/anchore/syft/internal"
|
||||
"github.com/anchore/syft/internal/log"
|
||||
"github.com/anchore/syft/internal/tmpdir"
|
||||
"github.com/anchore/syft/syft/artifact"
|
||||
"github.com/anchore/syft/syft/file"
|
||||
@@ -22,7 +24,7 @@ import (
|
||||
|
||||
// parseGGUFModel parses a GGUF model file and returns the discovered package.
|
||||
// This implementation only reads the header portion of the file, not the entire model.
|
||||
func parseGGUFModel(ctx context.Context, _ file.Resolver, _ *generic.Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) {
|
||||
func parseGGUFModel(ctx context.Context, resolver file.Resolver, _ *generic.Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) {
|
||||
defer internal.CloseAndLogError(reader, reader.Path())
|
||||
|
||||
td := tmpdir.FromContext(ctx)
|
||||
@@ -34,7 +36,6 @@ func parseGGUFModel(ctx context.Context, _ file.Resolver, _ *generic.Environment
|
||||
return nil, nil, fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
defer cleanup()
|
||||
tempPath := tempFile.Name()
|
||||
|
||||
// copy and validate the GGUF file header (bounded to maxHeaderSize)
|
||||
if err := copyHeader(tempFile, reader); err != nil {
|
||||
@@ -45,14 +46,18 @@ func parseGGUFModel(ctx context.Context, _ file.Resolver, _ *generic.Environment
|
||||
tempFile.Close()
|
||||
return nil, nil, fmt.Errorf("failed to rewind GGUF header: %w", err)
|
||||
}
|
||||
err = validateGGUFHeader(tempFile)
|
||||
if err := validateGGUFHeader(tempFile); err != nil {
|
||||
tempFile.Close()
|
||||
return nil, nil, fmt.Errorf("invalid GGUF header: %w", err)
|
||||
}
|
||||
err = extendToModelSize(tempFile, modelSize(resolver, reader.Location))
|
||||
tempFile.Close()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("invalid GGUF header: %w", err)
|
||||
return nil, nil, fmt.Errorf("failed to extend GGUF header copy: %w", err)
|
||||
}
|
||||
|
||||
// Parse using gguf-parser-go with options to skip unnecessary data
|
||||
ggufFile, err := gguf_parser.ParseGGUFFile(tempPath,
|
||||
ggufFile, err := gguf_parser.ParseGGUFFile(tempFile.Name(),
|
||||
gguf_parser.SkipLargeMetadata(),
|
||||
)
|
||||
if err != nil {
|
||||
@@ -99,6 +104,33 @@ func parseGGUFModel(ctx context.Context, _ file.Resolver, _ *generic.Environment
|
||||
return []pkg.Package{p}, nil, nil
|
||||
}
|
||||
|
||||
// modelSize returns the size of the model file, or 0 when the resolver can't say.
|
||||
func modelSize(resolver file.Resolver, loc file.Location) int64 {
|
||||
if resolver == nil {
|
||||
return 0
|
||||
}
|
||||
m, err := resolver.FileMetadataByLocation(loc)
|
||||
if err != nil || m.FileInfo == nil {
|
||||
return 0
|
||||
}
|
||||
return m.Size()
|
||||
}
|
||||
|
||||
// extendToModelSize grows the header copy to the model's real size, since the parser checks the tensor data
|
||||
// the header declares against the file size. It must run after validation so the header itself never reads
|
||||
// from the hole. The file is marked sparse first so the hole takes no disk space; where that isn't possible
|
||||
// the copy is left as is, and the parser rejects models larger than it.
|
||||
func extendToModelSize(f *os.File, size int64) error {
|
||||
if size <= maxHeaderSize {
|
||||
return nil
|
||||
}
|
||||
if err := markSparse(f); err != nil {
|
||||
log.Debugf("unable to mark GGUF header copy sparse, not extending it: %v", err)
|
||||
return nil
|
||||
}
|
||||
return f.Truncate(size)
|
||||
}
|
||||
|
||||
// computeKVMetadataHash computes a stable hash of the sanitized KV metadata for use as a global identifier
|
||||
func computeKVMetadataHash(metadata gguf_parser.GGUFMetadataKVs) (string, error) {
|
||||
// Sort the KV pairs by key for stable hashing
|
||||
|
||||
@@ -57,6 +57,11 @@ func TestParseGGUFModel_malformedHeader(t *testing.T) {
|
||||
// a header that declares more KVs than the cap, with none following
|
||||
tooManyKVs := append(append([]byte("GGUF"), le(uint32(3))...), le([]uint64{0, maxGGUFKVCount + 1})...)
|
||||
|
||||
// a tensor whose name declares far more bytes than the file holds
|
||||
hostileTensorName := newTestGGUFBuilder().withStringKV("general.architecture", "llama")
|
||||
hostileTensorName.tensorCount = 1
|
||||
hugeTensorName := append(hostileTensorName.build(), le(uint64(1<<40))...)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
builder *testGGUFBuilder
|
||||
@@ -150,6 +155,11 @@ func TestParseGGUFModel_malformedHeader(t *testing.T) {
|
||||
name: "skipped scalar array",
|
||||
builder: newTestGGUFBuilder().withRawKV("tokenizer.ggml.scores", ggufTypeArray, ggufArray(ggufTypeFloat32, 3, make([]byte, 12))),
|
||||
},
|
||||
{name: "tensor name too long", data: hugeTensorName, wantErr: true},
|
||||
{name: "tensor name empty", builder: newTestGGUFBuilder().withTensor("", ggmlTypeF32, 1), wantErr: true},
|
||||
{name: "tensor with too many dimensions", builder: newTestGGUFBuilder().withTensor("t", ggmlTypeF32, 1, 1, 1, 1, 1), wantErr: true},
|
||||
{name: "tensor count beyond the header", builder: newTestGGUFBuilder().withTensorCount(1), wantErr: true},
|
||||
{name: "tensor info", builder: newTestGGUFBuilder().withTensor("t", ggmlTypeF32, 8)},
|
||||
// ponytail: zero block size tensor types and dimension overflow panic inside tensor info parsing;
|
||||
// those are left to the generic cataloger's panic handling rather than checked here.
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
//go:build !windows
|
||||
|
||||
package ai
|
||||
|
||||
import "os"
|
||||
|
||||
// markSparse is a no-op: the filesystems syft runs on elsewhere leave the range a file is extended by with
|
||||
// Truncate as a hole.
|
||||
func markSparse(_ *os.File) error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// markSparse sets the sparse attribute on the file. NTFS reserves disk space for the whole length of a file
|
||||
// extended with Truncate unless it is sparse.
|
||||
func markSparse(f *os.File) error {
|
||||
var n uint32
|
||||
return windows.DeviceIoControl(windows.Handle(f.Fd()), windows.FSCTL_SET_SPARSE, nil, 0, nil, 0, &n, nil)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"unsafe"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// fileStandardInfo mirrors FILE_STANDARD_INFO, which golang.org/x/sys/windows does not define.
|
||||
type fileStandardInfo struct {
|
||||
AllocationSize int64
|
||||
EndOfFile int64
|
||||
NumberOfLinks uint32
|
||||
DeletePending bool
|
||||
Directory bool
|
||||
}
|
||||
|
||||
func TestExtendToModelSize_doesNotReserveDiskOnWindows(t *testing.T) {
|
||||
const size = 1 << 30
|
||||
f, err := os.Create(filepath.Join(t.TempDir(), "header.gguf"))
|
||||
require.NoError(t, err)
|
||||
defer f.Close()
|
||||
_, err = f.Write(make([]byte, 4096))
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, extendToModelSize(f, size))
|
||||
|
||||
var info fileStandardInfo
|
||||
require.NoError(t, windows.GetFileInformationByHandleEx(windows.Handle(f.Fd()), windows.FileStandardInfo,
|
||||
(*byte)(unsafe.Pointer(&info)), uint32(unsafe.Sizeof(info))))
|
||||
assert.Equal(t, int64(size), info.EndOfFile)
|
||||
// a non-sparse file would have the whole length allocated
|
||||
assert.Less(t, info.AllocationSize, int64(16<<20))
|
||||
}
|
||||
@@ -30,6 +30,13 @@ type testGGUFBuilder struct {
|
||||
version uint32
|
||||
tensorCount uint64
|
||||
kvPairs []testKVPair
|
||||
tensors []testTensorInfo
|
||||
}
|
||||
|
||||
type testTensorInfo struct {
|
||||
name string
|
||||
dims []uint64
|
||||
ggmlType uint32
|
||||
}
|
||||
|
||||
type testKVPair struct {
|
||||
@@ -72,6 +79,13 @@ func (b *testGGUFBuilder) withUint32KV(key string, value uint32) *testGGUFBuilde
|
||||
return b
|
||||
}
|
||||
|
||||
// withTensor adds a tensor info and counts it in the header tensor count.
|
||||
func (b *testGGUFBuilder) withTensor(name string, ggmlType uint32, dims ...uint64) *testGGUFBuilder {
|
||||
b.tensors = append(b.tensors, testTensorInfo{name: name, dims: dims, ggmlType: ggmlType})
|
||||
b.tensorCount++
|
||||
return b
|
||||
}
|
||||
|
||||
// withRawKV adds a KV whose value bytes are written verbatim, for arrays and malformed values.
|
||||
func (b *testGGUFBuilder) withRawKV(key string, valueType uint32, raw []byte) *testGGUFBuilder {
|
||||
b.kvPairs = append(b.kvPairs, testKVPair{key: key, valueType: valueType, value: raw})
|
||||
@@ -145,6 +159,19 @@ func (b *testGGUFBuilder) build() []byte {
|
||||
}
|
||||
}
|
||||
|
||||
// Write tensor infos: name, dimension count, dimensions, type, data offset
|
||||
var offset uint64
|
||||
for _, ti := range b.tensors {
|
||||
b.writeString(ti.name)
|
||||
binary.Write(b.buf, binary.LittleEndian, uint32(len(ti.dims)))
|
||||
for _, d := range ti.dims {
|
||||
b.writeLen(d)
|
||||
}
|
||||
binary.Write(b.buf, binary.LittleEndian, ti.ggmlType)
|
||||
binary.Write(b.buf, binary.LittleEndian, offset)
|
||||
offset += 32
|
||||
}
|
||||
|
||||
return b.buf.Bytes()
|
||||
}
|
||||
|
||||
@@ -154,3 +181,14 @@ func (b *testGGUFBuilder) buildInvalidMagic() []byte {
|
||||
binary.Write(buf, binary.LittleEndian, uint32(0x12345678))
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// GGML tensor types used by the test builder
|
||||
const (
|
||||
ggmlTypeF32 = 0
|
||||
ggmlTypeF16 = 1
|
||||
ggmlTypeQ4_0 = 2
|
||||
ggmlTypeQ8_0 = 8
|
||||
ggmlTypeQ2_K = 10
|
||||
ggmlTypeQ4_K = 12
|
||||
ggmlTypeQ6_K = 14
|
||||
)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// modelSize is larger than the 50 MB syft copies from a model to parse its header, and than the tensor data
|
||||
// the header fixture declares.
|
||||
const modelSize = 512 << 20
|
||||
|
||||
// TestGGUFModelIsCataloged scans a model larger than the header copy syft parses: a real GGUF header followed
|
||||
// by a sparse range standing in for the tensor data. Models under the copy size are parsed whole, so only a
|
||||
// model this large catches a parser that checks the declared tensor data against the bytes it was given.
|
||||
func TestGGUFModelIsCataloged(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
header, err := os.ReadFile("testdata/models/gguf-header-only/header.gguf")
|
||||
require.NoError(t, err)
|
||||
modelPath := filepath.Join(root, "qwen2.5-0.5b-instruct-q2_k.gguf")
|
||||
require.NoError(t, os.WriteFile(modelPath, header, 0o600))
|
||||
require.NoError(t, os.Truncate(modelPath, modelSize))
|
||||
|
||||
out := filepath.Join(t.TempDir(), "sbom.json")
|
||||
cmd, stdout, stderr := runSyft(t, nil, "scan", "dir:"+root, "-o", "syft-json="+out)
|
||||
defer logOutputOnFailure(t, cmd, stdout, stderr)
|
||||
assertSuccessfulReturnCode(t, stdout, stderr, cmd.ProcessState.ExitCode())
|
||||
|
||||
b, err := os.ReadFile(out)
|
||||
require.NoError(t, err, "no SBOM written")
|
||||
var doc struct {
|
||||
Artifacts []struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Licenses []struct {
|
||||
Value string `json:"value"`
|
||||
} `json:"licenses"`
|
||||
Metadata struct {
|
||||
Architecture string `json:"architecture"`
|
||||
Quantization string `json:"quantization"`
|
||||
Parameters uint64 `json:"parameters"`
|
||||
TensorCount uint64 `json:"tensorCount"`
|
||||
} `json:"metadata"`
|
||||
} `json:"artifacts"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(b, &doc), "SBOM is not valid JSON")
|
||||
require.Len(t, doc.Artifacts, 1, "expected the model package")
|
||||
|
||||
model := doc.Artifacts[0]
|
||||
assert.Equal(t, "qwen2.5-0.5b-instruct", model.Name)
|
||||
assert.Equal(t, "model", model.Type)
|
||||
require.Len(t, model.Licenses, 1)
|
||||
assert.Equal(t, "apache-2.0", model.Licenses[0].Value)
|
||||
assert.Equal(t, "qwen2", model.Metadata.Architecture)
|
||||
assert.Equal(t, "Q2_K_L", model.Metadata.Quantization)
|
||||
assert.Equal(t, uint64(290), model.Metadata.TensorCount)
|
||||
assert.Equal(t, uint64(494032768), model.Metadata.Parameters)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// generate writes header.gguf: the header of a GGUF model shaped like Qwen2.5-0.5B-Instruct at Q2_K (290
|
||||
// tensors declaring about 175 MB of data), padded to the tensor data alignment. Tests extend a copy to a
|
||||
// model's size as a sparse file, which stands in for the full model without storing its data.
|
||||
//
|
||||
// This file lives under testdata/ so the Go build system ignores it. Run it when changing the fixture:
|
||||
//
|
||||
// go run ./testdata/models/gguf-header-only/generate.go ./testdata/models/gguf-header-only/header.gguf
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
const (
|
||||
typeUint32 = 4
|
||||
typeString = 8
|
||||
typeArray = 9
|
||||
|
||||
ggmlF32 = 0
|
||||
ggmlQ2K = 10
|
||||
ggmlQ3K = 11
|
||||
ggmlQ4K = 12
|
||||
fileQ2K = 10
|
||||
)
|
||||
|
||||
type tensor struct {
|
||||
name string
|
||||
typ uint32
|
||||
dims []uint64
|
||||
}
|
||||
|
||||
type writer struct{ bytes.Buffer }
|
||||
|
||||
func (w *writer) le(v any) { _ = binary.Write(w, binary.LittleEndian, v) }
|
||||
|
||||
func (w *writer) str(s string) {
|
||||
w.le(uint64(len(s)))
|
||||
w.WriteString(s)
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) != 2 {
|
||||
fmt.Fprintln(os.Stderr, "usage: generate <output path>")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
const (
|
||||
hidden = 896
|
||||
kvDim = 128
|
||||
ffn = 4864
|
||||
vocab = 151936
|
||||
layers = 24
|
||||
)
|
||||
tensors := []tensor{{"token_embd.weight", ggmlQ4K, []uint64{hidden, vocab}}}
|
||||
for i := range layers {
|
||||
blk := func(n string) string { return fmt.Sprintf("blk.%d.%s", i, n) }
|
||||
tensors = append(tensors,
|
||||
tensor{blk("attn_norm.weight"), ggmlF32, []uint64{hidden}},
|
||||
tensor{blk("attn_q.weight"), ggmlQ2K, []uint64{hidden, hidden}},
|
||||
tensor{blk("attn_q.bias"), ggmlF32, []uint64{hidden}},
|
||||
tensor{blk("attn_k.weight"), ggmlQ2K, []uint64{hidden, kvDim}},
|
||||
tensor{blk("attn_k.bias"), ggmlF32, []uint64{kvDim}},
|
||||
tensor{blk("attn_v.weight"), ggmlQ3K, []uint64{hidden, kvDim}},
|
||||
tensor{blk("attn_v.bias"), ggmlF32, []uint64{kvDim}},
|
||||
tensor{blk("attn_output.weight"), ggmlQ3K, []uint64{hidden, hidden}},
|
||||
tensor{blk("ffn_norm.weight"), ggmlF32, []uint64{hidden}},
|
||||
tensor{blk("ffn_gate.weight"), ggmlQ2K, []uint64{hidden, ffn}},
|
||||
tensor{blk("ffn_up.weight"), ggmlQ2K, []uint64{hidden, ffn}},
|
||||
tensor{blk("ffn_down.weight"), ggmlQ3K, []uint64{ffn, hidden}},
|
||||
)
|
||||
}
|
||||
tensors = append(tensors, tensor{"output_norm.weight", ggmlF32, []uint64{hidden}})
|
||||
|
||||
var w writer
|
||||
w.le(uint32(0x46554747)) // "GGUF"
|
||||
w.le(uint32(3))
|
||||
w.le(uint64(len(tensors)))
|
||||
w.le(uint64(6)) // metadata KV count
|
||||
|
||||
w.str("general.architecture")
|
||||
w.le(uint32(typeString))
|
||||
w.str("qwen2")
|
||||
w.str("general.name")
|
||||
w.le(uint32(typeString))
|
||||
w.str("qwen2.5-0.5b-instruct")
|
||||
w.str("general.license")
|
||||
w.le(uint32(typeString))
|
||||
w.str("apache-2.0")
|
||||
w.str("general.file_type")
|
||||
w.le(uint32(typeUint32))
|
||||
w.le(uint32(fileQ2K))
|
||||
w.str("qwen2.block_count")
|
||||
w.le(uint32(typeUint32))
|
||||
w.le(uint32(layers))
|
||||
// a vocabulary the parser skips over
|
||||
w.str("tokenizer.ggml.tokens")
|
||||
w.le(uint32(typeArray))
|
||||
w.le(uint32(typeString))
|
||||
w.le(uint64(3))
|
||||
for _, tok := range []string{"!", "\"", "#"} {
|
||||
w.str(tok)
|
||||
}
|
||||
|
||||
var offset uint64
|
||||
for _, t := range tensors {
|
||||
w.str(t.name)
|
||||
w.le(uint32(len(t.dims)))
|
||||
for _, d := range t.dims {
|
||||
w.le(d)
|
||||
}
|
||||
w.le(t.typ)
|
||||
w.le(offset)
|
||||
offset += 1 << 20 // not used by syft; real offsets depend on each type's block size
|
||||
}
|
||||
|
||||
// pad to the default 32-byte alignment, where the tensor data starts
|
||||
w.Write(make([]byte, (32-w.Len()%32)%32))
|
||||
|
||||
if err := os.WriteFile(os.Args[1], w.Bytes(), 0o644); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Reference in New Issue
Block a user