static_embeddings 0.1.4 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +68 -0
- data/README.md +50 -23
- data/Rakefile +1 -1
- data/docs/ARCHITECTURE.md +42 -31
- data/docs/LIMITATIONS.md +12 -6
- data/docs/MODEL_AUDIT.md +85 -52
- data/ext/static_embeddings/se_embed.c +2 -1
- data/ext/static_embeddings/se_format.c +41 -4
- data/ext/static_embeddings/se_internal.h +17 -5
- data/ext/static_embeddings/se_tokenizer.c +155 -18
- data/ext/static_embeddings/se_unicode.c +1 -1
- data/ext/static_embeddings/static_embeddings.c +32 -2
- data/lib/static_embeddings/cli.rb +1 -0
- data/lib/static_embeddings/converter.rb +51 -7
- data/lib/static_embeddings/format.rb +60 -22
- data/lib/static_embeddings/paths.rb +18 -1
- data/lib/static_embeddings/reference.rb +54 -7
- data/lib/static_embeddings/safetensors.rb +178 -34
- data/lib/static_embeddings/version.rb +1 -1
- data/lib/static_embeddings.rb +3 -5
- data/tools/check_model2vec_parity.rb +85 -54
- metadata +1 -1
|
@@ -3,7 +3,7 @@ require "digest"
|
|
|
3
3
|
module StaticEmbeddings
|
|
4
4
|
module Format
|
|
5
5
|
MAGIC = "SEMBv1\0\0"
|
|
6
|
-
VERSION =
|
|
6
|
+
VERSION = 3
|
|
7
7
|
HEADER_SIZE = 320
|
|
8
8
|
ALIGNMENT = 64
|
|
9
9
|
|
|
@@ -12,7 +12,7 @@ module StaticEmbeddings
|
|
|
12
12
|
POOLING_MEAN = 1
|
|
13
13
|
NORMALIZATION_NONE = 0
|
|
14
14
|
NORMALIZATION_L2 = 1
|
|
15
|
-
|
|
15
|
+
TRUNCATE_USABLE_IDS_BEFORE_POOLING = 2
|
|
16
16
|
UNK_INCLUDE = 0
|
|
17
17
|
UNK_DROP = 1
|
|
18
18
|
EMPTY_ZERO_VECTOR = 0
|
|
@@ -26,6 +26,14 @@ module StaticEmbeddings
|
|
|
26
26
|
CHECKSUM_OFFSET = 240
|
|
27
27
|
CHECKSUM_SIZE = 32
|
|
28
28
|
MAX_PROBE_OFFSET = 304
|
|
29
|
+
ADDED_TOKEN_MASK_OFFSET = 308
|
|
30
|
+
|
|
31
|
+
ADDED_PAD = 1 << 0
|
|
32
|
+
ADDED_UNK = 1 << 1
|
|
33
|
+
ADDED_CLS = 1 << 2
|
|
34
|
+
ADDED_SEP = 1 << 3
|
|
35
|
+
ADDED_MASK = 1 << 4
|
|
36
|
+
ADDED_TOKEN_MASK_ALL = ADDED_PAD | ADDED_UNK | ADDED_CLS | ADDED_SEP | ADDED_MASK
|
|
29
37
|
|
|
30
38
|
SECTION_FIELDS = {
|
|
31
39
|
vocab_strings: 128,
|
|
@@ -44,7 +52,7 @@ module StaticEmbeddings
|
|
|
44
52
|
28 => TOKENIZER_BERT_WORDPIECE_V1,
|
|
45
53
|
32 => DTYPE_F32,
|
|
46
54
|
36 => POOLING_MEAN,
|
|
47
|
-
48 =>
|
|
55
|
+
48 => TRUNCATE_USABLE_IDS_BEFORE_POOLING,
|
|
48
56
|
108 => HASH_SEED
|
|
49
57
|
}.freeze
|
|
50
58
|
|
|
@@ -102,7 +110,7 @@ module StaticEmbeddings
|
|
|
102
110
|
def write(path:, meta:, tokens:, matrix:, norm_tables:, provenance:)
|
|
103
111
|
hash_size, strings, hash_blob, max_probe = build_hash_table(tokens)
|
|
104
112
|
root_trie, continuation_trie = build_wordpiece_tries(tokens, meta.fetch(:subword_prefix))
|
|
105
|
-
|
|
113
|
+
payloads = {
|
|
106
114
|
vocab_strings: strings,
|
|
107
115
|
vocab_hash: hash_blob,
|
|
108
116
|
embeddings: matrix,
|
|
@@ -110,15 +118,48 @@ module StaticEmbeddings
|
|
|
110
118
|
provenance: provenance,
|
|
111
119
|
root_trie: root_trie,
|
|
112
120
|
continuation_trie: continuation_trie
|
|
113
|
-
|
|
114
|
-
|
|
121
|
+
}
|
|
122
|
+
sections, file_size = layout_sections(payloads)
|
|
115
123
|
header = build_header(meta, tokens.length, hash_size, max_probe, sections)
|
|
116
|
-
file = header << body
|
|
117
|
-
digest = Digest::SHA256.digest(file)
|
|
118
|
-
file[CHECKSUM_OFFSET, CHECKSUM_SIZE] = digest
|
|
119
124
|
|
|
120
|
-
|
|
121
|
-
|
|
125
|
+
digest = Digest::SHA256.new
|
|
126
|
+
File.open(path, "wb") do |io|
|
|
127
|
+
io.write(header)
|
|
128
|
+
digest << header
|
|
129
|
+
offset = HEADER_SIZE
|
|
130
|
+
|
|
131
|
+
payloads.each do |name, payload|
|
|
132
|
+
target = sections.fetch(name).first
|
|
133
|
+
padding = target - offset
|
|
134
|
+
if padding.positive?
|
|
135
|
+
zeros = "\0".b * padding
|
|
136
|
+
io.write(zeros)
|
|
137
|
+
digest << zeros
|
|
138
|
+
end
|
|
139
|
+
write_payload(io, digest, payload)
|
|
140
|
+
offset = target + payload.bytesize
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
checksum = digest.digest
|
|
145
|
+
File.open(path, "r+b") do |io|
|
|
146
|
+
io.seek(CHECKSUM_OFFSET, IO::SEEK_SET)
|
|
147
|
+
io.write(checksum)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
{ bytes: file_size, sha256: checksum.unpack1("H*"), hash_table_size: hash_size }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def write_payload(io, digest, payload)
|
|
154
|
+
if payload.respond_to?(:each_chunk)
|
|
155
|
+
payload.each_chunk do |chunk|
|
|
156
|
+
io.write(chunk)
|
|
157
|
+
digest << chunk
|
|
158
|
+
end
|
|
159
|
+
else
|
|
160
|
+
io.write(payload)
|
|
161
|
+
digest << payload
|
|
162
|
+
end
|
|
122
163
|
end
|
|
123
164
|
|
|
124
165
|
def verify(path)
|
|
@@ -240,20 +281,16 @@ module StaticEmbeddings
|
|
|
240
281
|
end
|
|
241
282
|
end
|
|
242
283
|
|
|
243
|
-
def
|
|
244
|
-
|
|
284
|
+
def layout_sections(payloads)
|
|
285
|
+
offset = HEADER_SIZE
|
|
245
286
|
sections = {}
|
|
246
287
|
payloads.each do |name, payload|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
288
|
+
padding = (ALIGNMENT - (offset % ALIGNMENT)) % ALIGNMENT
|
|
289
|
+
offset += padding
|
|
290
|
+
sections[name] = [offset, payload.bytesize]
|
|
291
|
+
offset += payload.bytesize
|
|
250
292
|
end
|
|
251
|
-
[sections,
|
|
252
|
-
end
|
|
253
|
-
|
|
254
|
-
def align_body!(body)
|
|
255
|
-
padding = (ALIGNMENT - ((HEADER_SIZE + body.bytesize) % ALIGNMENT)) % ALIGNMENT
|
|
256
|
-
body << "\0".b * padding if padding.positive?
|
|
293
|
+
[sections, offset]
|
|
257
294
|
end
|
|
258
295
|
|
|
259
296
|
def build_header(meta, vocab_size, hash_size, max_probe, sections)
|
|
@@ -268,6 +305,7 @@ module StaticEmbeddings
|
|
|
268
305
|
put_u32(header, 104, hash_size)
|
|
269
306
|
put_u32(header, MAX_TOKEN_CHARS_OFFSET, meta.fetch(:max_token_chars))
|
|
270
307
|
put_u32(header, MAX_PROBE_OFFSET, max_probe)
|
|
308
|
+
put_u32(header, ADDED_TOKEN_MASK_OFFSET, meta.fetch(:added_token_mask, 0))
|
|
271
309
|
put_prefix(header, meta.fetch(:subword_prefix))
|
|
272
310
|
SECTION_FIELDS.each { |name, field| put_section(header, field, sections.fetch(name)) }
|
|
273
311
|
|
|
@@ -11,7 +11,24 @@ module StaticEmbeddings
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def model_path(model_id, env = ENV)
|
|
14
|
-
|
|
14
|
+
id = model_id.to_s
|
|
15
|
+
raise ArgumentError, "model_id must not be empty" if id.empty?
|
|
16
|
+
raise ArgumentError, "model_id contains a NUL byte" if id.include?("\0")
|
|
17
|
+
|
|
18
|
+
normalized = id.tr("\\", "/")
|
|
19
|
+
parts = normalized.split("/", -1)
|
|
20
|
+
if normalized.start_with?("/") ||
|
|
21
|
+
parts.any? { |part| part.empty? || part == "." || part == ".." || part.include?(":") }
|
|
22
|
+
raise ArgumentError, "model_id must be a relative slash-separated identifier"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
base = File.expand_path(File.join(cache_dir(env), "models"))
|
|
26
|
+
path = File.expand_path(File.join(base, "#{normalized}.semb"))
|
|
27
|
+
prefix = base.end_with?(File::SEPARATOR) ? base : "#{base}#{File::SEPARATOR}"
|
|
28
|
+
unless path.start_with?(prefix)
|
|
29
|
+
raise ArgumentError, "model_id escapes the model cache"
|
|
30
|
+
end
|
|
31
|
+
path
|
|
15
32
|
end
|
|
16
33
|
|
|
17
34
|
def builtin_path(name)
|
|
@@ -1,14 +1,18 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "static_embeddings/converter"
|
|
3
|
+
require "static_embeddings/safetensors"
|
|
4
|
+
|
|
1
5
|
module StaticEmbeddings
|
|
2
6
|
class Reference
|
|
3
7
|
CJK_RANGES = [
|
|
4
8
|
0x4E00..0x9FFF, 0x3400..0x4DBF, 0x20000..0x2A6DF, 0x2A700..0x2B73F,
|
|
5
|
-
0x2B740..0x2B81F,
|
|
9
|
+
0x2B740..0x2B81F, 0x2B920..0x2CEAF, 0xF900..0xFAFF, 0x2F800..0x2FA1F
|
|
6
10
|
].freeze
|
|
7
11
|
|
|
8
12
|
ASCII_PUNCT = [33..47, 58..64, 91..96, 123..126].freeze
|
|
9
13
|
ASCII_SPACES = [" ", "\t", "\n", "\r"].freeze
|
|
10
14
|
RE_PUNCT = /\A\p{P}\z/
|
|
11
|
-
RE_CONTROL = /\A(?:\p{Cc}|\p{Cf}|\p{Co}
|
|
15
|
+
RE_CONTROL = /\A(?:\p{Cc}|\p{Cf}|\p{Co})\z/
|
|
12
16
|
RE_MN = /\A\p{Mn}\z/
|
|
13
17
|
RE_WHITESPACE = /\A(?:\p{Zs}|[\u0085\u2028\u2029])\z/
|
|
14
18
|
|
|
@@ -29,7 +33,7 @@ module StaticEmbeddings
|
|
|
29
33
|
tokens = tokens_from(tokenizer)
|
|
30
34
|
tensor = Safetensors.read(File.join(dir, "model.safetensors"))[:tensors].values.first
|
|
31
35
|
rows, dim = tensor[:shape]
|
|
32
|
-
floats = tensor
|
|
36
|
+
floats = Safetensors.f32_bytes(tensor).unpack("e*")
|
|
33
37
|
normalizer = tokenizer["normalizer"] || {}
|
|
34
38
|
|
|
35
39
|
new(tokens: tokens,
|
|
@@ -53,10 +57,21 @@ module StaticEmbeddings
|
|
|
53
57
|
max_input_chars_per_word: tokenizer.dig("model", "max_input_chars_per_word") || 100,
|
|
54
58
|
unk_token: tokenizer.dig("model", "unk_token") || "[UNK]",
|
|
55
59
|
normalize: config.key?("normalize") ? config["normalize"] : true,
|
|
56
|
-
max_tokens: max_tokens
|
|
60
|
+
max_tokens: max_tokens,
|
|
61
|
+
added_tokens: supported_added_tokens(tokenizer)
|
|
57
62
|
}
|
|
58
63
|
end
|
|
59
64
|
|
|
65
|
+
def self.supported_added_tokens(tokenizer)
|
|
66
|
+
allowed = Converter::STANDARD_SPECIAL_TOKENS
|
|
67
|
+
(tokenizer["added_tokens"] || []).each_with_object({}) do |token, out|
|
|
68
|
+
content = token["content"]
|
|
69
|
+
next unless token["special"] && allowed.key?(content) && token["normalized"] == false
|
|
70
|
+
|
|
71
|
+
out[content] = Integer(token.fetch("id"))
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
60
75
|
def normalize_text(text)
|
|
61
76
|
chars = text.chars
|
|
62
77
|
chars = clean_chars(chars) if @meta[:clean_text]
|
|
@@ -71,13 +86,13 @@ module StaticEmbeddings
|
|
|
71
86
|
end
|
|
72
87
|
|
|
73
88
|
def tokenize(text, max_tokens: @meta[:max_tokens])
|
|
74
|
-
ids =
|
|
75
|
-
pre_tokenize(normalize_text(text)).each { |word| ids.concat(wordpiece(word)) }
|
|
89
|
+
ids = tokenize_with_added_tokens(text)
|
|
76
90
|
max_tokens && max_tokens.positive? && ids.length > max_tokens ? ids.first(max_tokens) : ids
|
|
77
91
|
end
|
|
78
92
|
|
|
79
93
|
def embed(text, max_tokens: @meta[:max_tokens])
|
|
80
|
-
used = tokenize(text, max_tokens:
|
|
94
|
+
used = tokenize(text, max_tokens: false).reject { |id| id == unk_id }
|
|
95
|
+
used = used.first(max_tokens) if max_tokens && max_tokens.positive? && used.length > max_tokens
|
|
81
96
|
return Array.new(@dim, 0.0) if used.empty?
|
|
82
97
|
|
|
83
98
|
vector = pooled(used)
|
|
@@ -86,6 +101,38 @@ module StaticEmbeddings
|
|
|
86
101
|
|
|
87
102
|
private
|
|
88
103
|
|
|
104
|
+
def tokenize_with_added_tokens(text)
|
|
105
|
+
added = @meta[:added_tokens]
|
|
106
|
+
return tokenize_plain(text) if added.empty?
|
|
107
|
+
|
|
108
|
+
ids = []
|
|
109
|
+
cursor = 0
|
|
110
|
+
binary = text.b
|
|
111
|
+
while cursor < text.bytesize
|
|
112
|
+
match = added.keys.filter_map do |literal|
|
|
113
|
+
index = binary.index(literal.b, cursor)
|
|
114
|
+
index && [index, -literal.bytesize, literal]
|
|
115
|
+
end.min
|
|
116
|
+
|
|
117
|
+
unless match
|
|
118
|
+
ids.concat(tokenize_plain(text.byteslice(cursor, text.bytesize - cursor)))
|
|
119
|
+
break
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
index, _, literal = match
|
|
123
|
+
ids.concat(tokenize_plain(text.byteslice(cursor, index - cursor))) if index > cursor
|
|
124
|
+
ids << added.fetch(literal)
|
|
125
|
+
cursor = index + literal.bytesize
|
|
126
|
+
end
|
|
127
|
+
ids
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def tokenize_plain(text)
|
|
131
|
+
return [] if text.nil? || text.empty?
|
|
132
|
+
|
|
133
|
+
pre_tokenize(normalize_text(text)).flat_map { |word| wordpiece(word) }
|
|
134
|
+
end
|
|
135
|
+
|
|
89
136
|
def clean_chars(chars)
|
|
90
137
|
chars.filter_map do |ch|
|
|
91
138
|
cp = ch.ord
|
|
@@ -3,61 +3,157 @@ require "json"
|
|
|
3
3
|
module StaticEmbeddings
|
|
4
4
|
module Safetensors
|
|
5
5
|
MAX_HEADER_BYTES = 100 * 1024 * 1024
|
|
6
|
-
SUPPORTED_DTYPES = { "F32" => 4 }.freeze
|
|
6
|
+
SUPPORTED_DTYPES = { "F32" => 4, "F16" => 2, "BF16" => 2 }.freeze
|
|
7
|
+
CONVERT_CHUNK_ELEMENTS = 64 * 1024
|
|
8
|
+
IO_CHUNK_BYTES = 1024 * 1024
|
|
9
|
+
|
|
10
|
+
class F32Payload
|
|
11
|
+
attr_reader :bytesize
|
|
12
|
+
|
|
13
|
+
def initialize(path, tensor)
|
|
14
|
+
@path = path
|
|
15
|
+
@offset = tensor.fetch(:absolute_offset)
|
|
16
|
+
@source_bytes = tensor.fetch(:source_bytes)
|
|
17
|
+
@dtype = tensor.fetch(:dtype)
|
|
18
|
+
@bytesize = tensor.fetch(:shape).reduce(1) { |count, dim| count * dim } * 4
|
|
19
|
+
end
|
|
7
20
|
|
|
8
|
-
|
|
21
|
+
def each_chunk
|
|
22
|
+
return enum_for(__method__) unless block_given?
|
|
23
|
+
|
|
24
|
+
File.open(@path, "rb") do |io|
|
|
25
|
+
io.seek(@offset, IO::SEEK_SET)
|
|
26
|
+
remaining = @source_bytes
|
|
27
|
+
while remaining.positive?
|
|
28
|
+
read_size = [remaining, IO_CHUNK_BYTES].min
|
|
29
|
+
# F16/BF16 elements are two bytes; never split one between chunks.
|
|
30
|
+
read_size -= 1 if @dtype != "F32" && read_size.odd?
|
|
31
|
+
read_size = remaining if read_size.zero?
|
|
32
|
+
chunk = io.read(read_size)
|
|
33
|
+
unless chunk&.bytesize == read_size
|
|
34
|
+
raise ConversionError, "tensor data is truncated while streaming #{@path}"
|
|
35
|
+
end
|
|
36
|
+
chunk.force_encoding(Encoding::BINARY)
|
|
37
|
+
yield case @dtype
|
|
38
|
+
when "F32" then chunk
|
|
39
|
+
when "F16" then Safetensors.convert_f16_to_f32(chunk)
|
|
40
|
+
when "BF16" then Safetensors.convert_bf16_to_f32(chunk)
|
|
41
|
+
else
|
|
42
|
+
raise ConversionError, "tensor dtype #{@dtype} is not supported"
|
|
43
|
+
end
|
|
44
|
+
remaining -= read_size
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
9
49
|
|
|
10
|
-
|
|
11
|
-
data = File.binread(path)
|
|
12
|
-
header, body_offset = parse_header(data)
|
|
13
|
-
body_size = data.bytesize - body_offset
|
|
50
|
+
module_function
|
|
14
51
|
|
|
15
|
-
|
|
16
|
-
|
|
52
|
+
def describe(path)
|
|
53
|
+
File.open(path, "rb") do |io|
|
|
54
|
+
file_size = io.stat.size
|
|
55
|
+
header, body_offset = read_header(io, file_size)
|
|
56
|
+
body_size = file_size - body_offset
|
|
57
|
+
|
|
58
|
+
tensors = header.each_with_object({}) do |(name, spec), acc|
|
|
59
|
+
next if name == "__metadata__"
|
|
60
|
+
|
|
61
|
+
dtype = spec["dtype"]
|
|
62
|
+
shape = spec["shape"]
|
|
63
|
+
begin_off, end_off = checked_offsets(name, spec["data_offsets"], body_size)
|
|
64
|
+
expected = checked_tensor_bytes(name, dtype, shape)
|
|
65
|
+
actual = end_off - begin_off
|
|
66
|
+
if actual != expected
|
|
67
|
+
raise ConversionError,
|
|
68
|
+
"tensor #{name}: shape #{shape.inspect} implies #{expected} bytes, offsets span #{actual}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
acc[name] = {
|
|
72
|
+
dtype: dtype,
|
|
73
|
+
shape: shape,
|
|
74
|
+
absolute_offset: body_offset + begin_off,
|
|
75
|
+
source_bytes: actual
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
{ metadata: header["__metadata__"] || {}, tensors: tensors }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
17
82
|
|
|
18
|
-
|
|
83
|
+
def read(path)
|
|
84
|
+
description = describe(path)
|
|
85
|
+
tensors = description.fetch(:tensors).transform_values do |tensor|
|
|
86
|
+
File.open(path, "rb") do |io|
|
|
87
|
+
io.seek(tensor.fetch(:absolute_offset), IO::SEEK_SET)
|
|
88
|
+
bytes = io.read(tensor.fetch(:source_bytes))
|
|
89
|
+
unless bytes&.bytesize == tensor.fetch(:source_bytes)
|
|
90
|
+
raise ConversionError, "tensor data is truncated while reading #{path}"
|
|
91
|
+
end
|
|
92
|
+
tensor.merge(bytes: bytes.force_encoding(Encoding::BINARY))
|
|
93
|
+
end
|
|
19
94
|
end
|
|
95
|
+
{ metadata: description.fetch(:metadata), tensors: tensors }
|
|
96
|
+
end
|
|
20
97
|
|
|
21
|
-
|
|
98
|
+
def f32_payload(path, tensor)
|
|
99
|
+
F32Payload.new(path, tensor)
|
|
22
100
|
end
|
|
23
101
|
|
|
24
102
|
def write(path, name, shape, floats)
|
|
25
103
|
body = floats.pack("e*")
|
|
26
|
-
|
|
104
|
+
write_raw(path, name, shape, "F32", body)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def write_raw(path, name, shape, dtype, body)
|
|
108
|
+
checked_tensor_bytes(name, dtype, shape).tap do |expected|
|
|
109
|
+
raise ArgumentError, "body size does not match #{dtype} shape" unless body.bytesize == expected
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
header = JSON.generate(name => { "dtype" => dtype, "shape" => shape, "data_offsets" => [0, body.bytesize] })
|
|
27
113
|
padded = header << (" " * ((8 - (header.bytesize % 8)) % 8))
|
|
28
|
-
File.
|
|
114
|
+
File.open(path, "wb") do |io|
|
|
115
|
+
io.write([padded.bytesize].pack("Q<"))
|
|
116
|
+
io.write(padded)
|
|
117
|
+
io.write(body)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def f32_bytes(tensor)
|
|
122
|
+
case tensor.fetch(:dtype)
|
|
123
|
+
when "F32"
|
|
124
|
+
tensor.fetch(:bytes)
|
|
125
|
+
when "F16"
|
|
126
|
+
convert_f16_to_f32(tensor.fetch(:bytes))
|
|
127
|
+
when "BF16"
|
|
128
|
+
convert_bf16_to_f32(tensor.fetch(:bytes))
|
|
129
|
+
else
|
|
130
|
+
raise ConversionError, "tensor dtype #{tensor[:dtype]} is not supported"
|
|
131
|
+
end
|
|
29
132
|
end
|
|
30
133
|
|
|
31
|
-
def
|
|
32
|
-
raise ConversionError, "safetensors file is shorter than its length prefix" if
|
|
134
|
+
def read_header(io, file_size)
|
|
135
|
+
raise ConversionError, "safetensors file is shorter than its length prefix" if file_size < 8
|
|
136
|
+
|
|
137
|
+
prefix = io.read(8)
|
|
138
|
+
raise ConversionError, "safetensors file is shorter than its length prefix" unless prefix&.bytesize == 8
|
|
33
139
|
|
|
34
|
-
header_len =
|
|
140
|
+
header_len = prefix.unpack1("Q<")
|
|
35
141
|
unless header_len.positive? && header_len <= MAX_HEADER_BYTES
|
|
36
142
|
raise ConversionError, "implausible safetensors header length #{header_len}"
|
|
37
143
|
end
|
|
38
144
|
|
|
39
145
|
body_offset = 8 + header_len
|
|
40
|
-
raise ConversionError, "safetensors header runs past end of file" if body_offset >
|
|
146
|
+
raise ConversionError, "safetensors header runs past end of file" if body_offset > file_size
|
|
41
147
|
|
|
42
|
-
raw_header =
|
|
148
|
+
raw_header = io.read(header_len)
|
|
149
|
+
unless raw_header&.bytesize == header_len
|
|
150
|
+
raise ConversionError, "safetensors header runs past end of file"
|
|
151
|
+
end
|
|
43
152
|
raise ConversionError, "safetensors header is not a JSON object" unless raw_header.lstrip.start_with?("{")
|
|
44
153
|
|
|
45
154
|
[JSON.parse(raw_header), body_offset]
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def tensor_from(data, body_offset, body_size, name, spec)
|
|
49
|
-
dtype = spec["dtype"]
|
|
50
|
-
shape = spec["shape"]
|
|
51
|
-
begin_off, end_off = checked_offsets(name, spec["data_offsets"], body_size)
|
|
52
|
-
expected = checked_tensor_bytes(name, dtype, shape)
|
|
53
|
-
actual = end_off - begin_off
|
|
54
|
-
|
|
55
|
-
if actual != expected
|
|
56
|
-
raise ConversionError,
|
|
57
|
-
"tensor #{name}: shape #{shape.inspect} implies #{expected} bytes, offsets span #{actual}"
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
{ dtype: dtype, shape: shape, bytes: data.byteslice(body_offset + begin_off, actual) }
|
|
155
|
+
rescue JSON::ParserError => e
|
|
156
|
+
raise ConversionError, "invalid safetensors JSON header: #{e.message}"
|
|
61
157
|
end
|
|
62
158
|
|
|
63
159
|
def checked_offsets(name, offsets, body_size)
|
|
@@ -76,12 +172,60 @@ module StaticEmbeddings
|
|
|
76
172
|
|
|
77
173
|
def checked_tensor_bytes(name, dtype, shape)
|
|
78
174
|
element_size = SUPPORTED_DTYPES[dtype]
|
|
79
|
-
|
|
175
|
+
unless element_size
|
|
176
|
+
raise ConversionError, "tensor #{name}: dtype #{dtype} is not supported (expected F32, F16, or BF16)"
|
|
177
|
+
end
|
|
80
178
|
unless shape.is_a?(Array) && shape.all? { |dim| dim.is_a?(Integer) && dim >= 0 }
|
|
81
179
|
raise ConversionError, "tensor #{name}: malformed shape #{shape.inspect}"
|
|
82
180
|
end
|
|
83
181
|
|
|
84
|
-
shape.reduce(1,
|
|
182
|
+
shape.reduce(1) { |count, dim| count * dim } * element_size
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def convert_f16_to_f32(bytes)
|
|
186
|
+
out = String.new(capacity: (bytes.bytesize / 2) * 4, encoding: Encoding::BINARY)
|
|
187
|
+
offset = 0
|
|
188
|
+
chunk_bytes = CONVERT_CHUNK_ELEMENTS * 2
|
|
189
|
+
while offset < bytes.bytesize
|
|
190
|
+
chunk = bytes.byteslice(offset, [chunk_bytes, bytes.bytesize - offset].min)
|
|
191
|
+
floats = if StaticEmbeddings.respond_to?(:decode_f16)
|
|
192
|
+
StaticEmbeddings.decode_f16(chunk)
|
|
193
|
+
else
|
|
194
|
+
chunk.unpack("v*").map { |bits| half_to_float(bits) }
|
|
195
|
+
end
|
|
196
|
+
out << floats.pack("e*")
|
|
197
|
+
offset += chunk.bytesize
|
|
198
|
+
end
|
|
199
|
+
out
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def convert_bf16_to_f32(bytes)
|
|
203
|
+
out = String.new(capacity: (bytes.bytesize / 2) * 4, encoding: Encoding::BINARY)
|
|
204
|
+
offset = 0
|
|
205
|
+
chunk_bytes = CONVERT_CHUNK_ELEMENTS * 2
|
|
206
|
+
while offset < bytes.bytesize
|
|
207
|
+
chunk = bytes.byteslice(offset, [chunk_bytes, bytes.bytesize - offset].min)
|
|
208
|
+
words = chunk.unpack("v*")
|
|
209
|
+
out << words.map { |bits| bits << 16 }.pack("V*")
|
|
210
|
+
offset += chunk.bytesize
|
|
211
|
+
end
|
|
212
|
+
out
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def half_to_float(bits)
|
|
216
|
+
sign = (bits >> 15) & 1
|
|
217
|
+
exponent = (bits >> 10) & 0x1F
|
|
218
|
+
fraction = bits & 0x3FF
|
|
219
|
+
|
|
220
|
+
value =
|
|
221
|
+
if exponent.zero?
|
|
222
|
+
fraction.zero? ? 0.0 : Math.ldexp(fraction.to_f, -24)
|
|
223
|
+
elsif exponent == 0x1F
|
|
224
|
+
fraction.zero? ? Float::INFINITY : Float::NAN
|
|
225
|
+
else
|
|
226
|
+
Math.ldexp(1.0 + fraction.to_f / 1024.0, exponent - 15)
|
|
227
|
+
end
|
|
228
|
+
sign.zero? ? value : -value
|
|
85
229
|
end
|
|
86
230
|
end
|
|
87
231
|
end
|
data/lib/static_embeddings.rb
CHANGED
|
@@ -18,10 +18,6 @@ end
|
|
|
18
18
|
require "static_embeddings/errors"
|
|
19
19
|
require "static_embeddings/paths"
|
|
20
20
|
require "static_embeddings/format"
|
|
21
|
-
require "static_embeddings/unicode_tables"
|
|
22
|
-
require "static_embeddings/safetensors"
|
|
23
|
-
require "static_embeddings/converter"
|
|
24
|
-
require "static_embeddings/reference"
|
|
25
21
|
require "static_embeddings/model"
|
|
26
22
|
|
|
27
23
|
module StaticEmbeddings
|
|
@@ -64,7 +60,9 @@ module StaticEmbeddings
|
|
|
64
60
|
load(path, verify: verify)
|
|
65
61
|
end
|
|
66
62
|
|
|
67
|
-
def convert(source_dir, output_path:, model_id: nil, max_tokens:
|
|
63
|
+
def convert(source_dir, output_path:, model_id: nil, max_tokens: nil)
|
|
64
|
+
require "static_embeddings/converter"
|
|
65
|
+
max_tokens = Converter::REFERENCE_MAX_TOKENS if max_tokens.nil?
|
|
68
66
|
converter = Converter.new(source_dir)
|
|
69
67
|
converter.convert(output_path: output_path, model_id: model_id, max_tokens: max_tokens)
|
|
70
68
|
converter.report
|