static_embeddings 0.1.1 → 0.1.3
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 +197 -0
- data/README.md +172 -275
- data/Rakefile +95 -0
- data/benchmark/_support.rb +196 -0
- data/benchmark/core_paths.rb +57 -0
- data/benchmark/gvl_threshold.rb +48 -0
- data/docs/ARCHITECTURE.md +56 -6
- data/docs/BENCHMARKING.md +87 -0
- data/docs/LIMITATIONS.md +91 -0
- data/docs/MODEL_AUDIT.md +29 -10
- data/docs/PERFORMANCE.md +112 -36
- data/ext/static_embeddings/extconf.rb +4 -0
- data/ext/static_embeddings/se_alloc_stats.c +244 -0
- data/ext/static_embeddings/se_f16.c +378 -0
- data/ext/static_embeddings/se_format.c +238 -148
- data/ext/static_embeddings/se_internal.h +159 -0
- data/ext/static_embeddings/se_tokenizer.c +144 -41
- data/ext/static_embeddings/se_topk.c +236 -0
- data/ext/static_embeddings/static_embeddings.c +277 -744
- data/lib/static_embeddings/format.rb +22 -11
- data/lib/static_embeddings/version.rb +1 -1
- data/static_embeddings.gemspec +2 -0
- data/tools/benchmark.rb +13 -4
- metadata +10 -1
data/Rakefile
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
require "bundler/gem_tasks" rescue nil
|
|
2
|
+
require "rake/testtask"
|
|
3
|
+
|
|
4
|
+
begin
|
|
5
|
+
require "rake/extensiontask"
|
|
6
|
+
Rake::ExtensionTask.new("static_embeddings") do |ext|
|
|
7
|
+
ext.lib_dir = "lib/static_embeddings"
|
|
8
|
+
ext.ext_dir = "ext/static_embeddings"
|
|
9
|
+
end
|
|
10
|
+
rescue LoadError
|
|
11
|
+
desc "Compile the extension without rake-compiler"
|
|
12
|
+
task :compile do
|
|
13
|
+
ext = File.expand_path("ext/static_embeddings", __dir__)
|
|
14
|
+
lib = File.expand_path("lib/static_embeddings", __dir__)
|
|
15
|
+
build = File.expand_path("tmp/build", __dir__)
|
|
16
|
+
FileUtils.mkdir_p(build)
|
|
17
|
+
Dir.chdir(build) do
|
|
18
|
+
sh "ruby #{ext}/extconf.rb --with-cflags=-I#{ext}"
|
|
19
|
+
sh "make -j4"
|
|
20
|
+
end
|
|
21
|
+
FileUtils.cp(File.join(build, "static_embeddings.so"), lib)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
desc "Regenerate the synthetic source model used by the test suite"
|
|
26
|
+
task :fixtures do
|
|
27
|
+
ruby "tools/make_fixture_model.rb"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
desc "Build the demo .semb shipped inside the gem"
|
|
31
|
+
task demo_model: %i[fixtures compile] do
|
|
32
|
+
ruby "tools/build_demo_model.rb"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
desc "Check the C runtime against the Python model2vec.StaticModel oracle"
|
|
36
|
+
task parity: :compile do
|
|
37
|
+
model = ENV["MODEL"] || File.expand_path("~/.cache/static_embeddings/models/potion-retrieval-32m.semb")
|
|
38
|
+
oracle = ENV["ORACLE"] || "tmp/model2vec_oracle.json"
|
|
39
|
+
|
|
40
|
+
unless File.file?(oracle)
|
|
41
|
+
abort <<~MSG
|
|
42
|
+
oracle not found: #{oracle}
|
|
43
|
+
|
|
44
|
+
Generate it first (needs Python and the model2vec package):
|
|
45
|
+
|
|
46
|
+
python3 -m venv .venv-model2vec && . .venv-model2vec/bin/activate
|
|
47
|
+
pip install -U model2vec numpy
|
|
48
|
+
python tools/model2vec_oracle.py /path/to/source-model --out #{oracle}
|
|
49
|
+
MSG
|
|
50
|
+
end
|
|
51
|
+
abort "model not found: #{model}" unless File.file?(model)
|
|
52
|
+
|
|
53
|
+
ruby "-Ilib tools/check_model2vec_parity.rb --model #{model.inspect} --oracle #{oracle.inspect}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
Rake::TestTask.new(:test) do |t|
|
|
57
|
+
t.libs << "test" << "lib"
|
|
58
|
+
t.test_files = FileList["test/**/*_test.rb"].exclude("test/cancellation_timing_test.rb")
|
|
59
|
+
t.warning = false
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
Rake::TestTask.new(:gc_compaction) do |t|
|
|
63
|
+
t.libs << "test" << "lib"
|
|
64
|
+
t.test_files = ["test/gc_compaction_test.rb"]
|
|
65
|
+
t.warning = false
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
Rake::TestTask.new(:cancellation_timing) do |t|
|
|
69
|
+
t.libs << "test" << "lib"
|
|
70
|
+
t.test_files = ["test/cancellation_timing_test.rb"]
|
|
71
|
+
t.warning = false
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
task test: %i[compile fixtures]
|
|
75
|
+
task cancellation_timing: %i[compile fixtures]
|
|
76
|
+
task gc_compaction: %i[compile fixtures]
|
|
77
|
+
|
|
78
|
+
namespace :benchmark do
|
|
79
|
+
Dir[File.join(__dir__, "benchmark", "*.rb")].sort.each do |path|
|
|
80
|
+
name = File.basename(path, ".rb")
|
|
81
|
+
next if name.start_with?("_")
|
|
82
|
+
|
|
83
|
+
desc "Run benchmark/#{name}.rb (BENCH_MODEL=... to pick a model)"
|
|
84
|
+
task name => %i[compile fixtures] do
|
|
85
|
+
ruby path
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
desc "Run every benchmark"
|
|
91
|
+
task benchmark: Dir[File.join(__dir__, "benchmark", "*.rb")]
|
|
92
|
+
.sort.map { |p| File.basename(p, ".rb") }
|
|
93
|
+
.reject { |n| n.start_with?("_") }
|
|
94
|
+
.map { |n| "benchmark:#{n}" }
|
|
95
|
+
task default: %i[compile fixtures test]
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
|
|
5
|
+
ROOT = File.expand_path("..", __dir__)
|
|
6
|
+
$LOAD_PATH.unshift(File.join(ROOT, "lib")) unless $LOAD_PATH.include?(File.join(ROOT, "lib"))
|
|
7
|
+
$stdout.sync = true
|
|
8
|
+
$stderr.sync = true
|
|
9
|
+
|
|
10
|
+
begin
|
|
11
|
+
require "static_embeddings"
|
|
12
|
+
rescue LoadError
|
|
13
|
+
warn "Native extension is not built. Run: bundle exec rake compile"
|
|
14
|
+
raise
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
module SEBench
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
DEFAULT_REPEATS = 7
|
|
21
|
+
DEFAULT_MIN_SECONDS = 0.25
|
|
22
|
+
DEFAULT_WARMUP_SECONDS = 0.05
|
|
23
|
+
MAX_ITERATIONS = 100_000_000
|
|
24
|
+
CANDIDATE_WORDS = %w[
|
|
25
|
+
hello world ruby gem static embedding vector search text token
|
|
26
|
+
the a of and for local fast model cafe naive
|
|
27
|
+
postgres pipeline database query batch matrix cosine kernel window prefix runtime memory latency
|
|
28
|
+
].freeze
|
|
29
|
+
CANDIDATE_SUFFIXES = (0..15).map(&:to_s).freeze
|
|
30
|
+
|
|
31
|
+
def env_integer(name, default, min: 1)
|
|
32
|
+
value = Integer(ENV.fetch(name, default.to_s))
|
|
33
|
+
raise ArgumentError, "#{name} must be >= #{min}, got #{value}" if value < min
|
|
34
|
+
|
|
35
|
+
value
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def env_float(name, default, min: 0.0)
|
|
39
|
+
value = Float(ENV.fetch(name, default.to_s))
|
|
40
|
+
raise ArgumentError, "#{name} must be >= #{min}, got #{value}" if value < min
|
|
41
|
+
|
|
42
|
+
value
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def repeats = env_integer("BENCH_REPEATS", DEFAULT_REPEATS)
|
|
46
|
+
def min_seconds = env_float("BENCH_MIN_SECONDS", DEFAULT_MIN_SECONDS)
|
|
47
|
+
def warmup_seconds = env_float("BENCH_WARMUP_SECONDS", DEFAULT_WARMUP_SECONDS)
|
|
48
|
+
def adaptive? = ENV.fetch("BENCH_ADAPTIVE", "1") != "0"
|
|
49
|
+
def kv? = ENV["BENCH_FORMAT"] == "kv"
|
|
50
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
51
|
+
|
|
52
|
+
def realtime
|
|
53
|
+
started = monotonic
|
|
54
|
+
yield
|
|
55
|
+
monotonic - started
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def median(values)
|
|
59
|
+
sorted = values.sort
|
|
60
|
+
return 0.0 if sorted.empty?
|
|
61
|
+
|
|
62
|
+
mid = sorted.length / 2
|
|
63
|
+
sorted.length.odd? ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2.0
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def model
|
|
67
|
+
@model ||= begin
|
|
68
|
+
path = ENV["BENCH_MODEL"]
|
|
69
|
+
m =
|
|
70
|
+
if path
|
|
71
|
+
StaticEmbeddings.load(path)
|
|
72
|
+
elsif StaticEmbeddings.builtin_available?
|
|
73
|
+
StaticEmbeddings.load_builtin
|
|
74
|
+
else
|
|
75
|
+
abort "No model. Set BENCH_MODEL=/path/to/model.semb or run: bundle exec rake demo_model"
|
|
76
|
+
end
|
|
77
|
+
m.warmup!
|
|
78
|
+
m
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def candidate_pool
|
|
83
|
+
@candidate_pool ||= begin
|
|
84
|
+
suffixed = CANDIDATE_WORDS.flat_map do |word|
|
|
85
|
+
CANDIDATE_SUFFIXES.map { |suffix| "#{word}#{suffix}" }
|
|
86
|
+
end
|
|
87
|
+
(CANDIDATE_WORDS + suffixed).uniq.freeze
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def nonzero_vector?(blob)
|
|
92
|
+
blob.unpack("e*").any? { |value| value != 0.0 }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def in_vocabulary?(word)
|
|
96
|
+
stats = model.embed_with_stats(word)
|
|
97
|
+
stats[:token_count].positive? && stats[:unk_count].zero? && nonzero_vector?(stats[:vector])
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def words
|
|
101
|
+
@words ||= begin
|
|
102
|
+
pool = candidate_pool.select { |word| in_vocabulary?(word) }
|
|
103
|
+
if pool.empty?
|
|
104
|
+
abort "No benchmark candidate word is in this model's vocabulary with a non-zero vector. " \
|
|
105
|
+
"Set BENCH_MODEL to a larger model or extend SEBench::CANDIDATE_WORDS."
|
|
106
|
+
end
|
|
107
|
+
pool.freeze
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def text(word_count, rng)
|
|
112
|
+
Array.new(word_count) { words[rng.rand(words.length)] }.join(" ")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def corpus(count, word_count, seed: 20_260_829)
|
|
116
|
+
rng = Random.new(seed)
|
|
117
|
+
Array.new(count) { text(word_count, rng) }.each(&:valid_encoding?).freeze
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def calibrate(initial, &block)
|
|
121
|
+
iterations = initial
|
|
122
|
+
return iterations unless adaptive?
|
|
123
|
+
return iterations if min_seconds <= 0.0
|
|
124
|
+
|
|
125
|
+
loop do
|
|
126
|
+
GC.start
|
|
127
|
+
elapsed = realtime { block.call(iterations) }
|
|
128
|
+
break iterations if elapsed >= min_seconds || iterations >= MAX_ITERATIONS
|
|
129
|
+
|
|
130
|
+
scale = elapsed <= 0.0 ? 10 : (min_seconds / elapsed * 1.5).ceil
|
|
131
|
+
iterations = [[iterations * scale, iterations * 2].max, MAX_ITERATIONS].min
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def measure(initial_iterations: 1, operations_per_iteration: 1, &block)
|
|
136
|
+
iterations = calibrate(initial_iterations, &block)
|
|
137
|
+
|
|
138
|
+
deadline = monotonic + warmup_seconds
|
|
139
|
+
block.call(iterations) while monotonic < deadline
|
|
140
|
+
|
|
141
|
+
samples = Array.new(repeats) do
|
|
142
|
+
GC.start
|
|
143
|
+
before = GC.stat(:total_allocated_objects)
|
|
144
|
+
seconds = realtime { block.call(iterations) }
|
|
145
|
+
[seconds, GC.stat(:total_allocated_objects) - before]
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
times = samples.map(&:first)
|
|
149
|
+
best = times.min
|
|
150
|
+
worst = times.max
|
|
151
|
+
operations = iterations * operations_per_iteration
|
|
152
|
+
|
|
153
|
+
{
|
|
154
|
+
iterations: iterations,
|
|
155
|
+
repeats: repeats,
|
|
156
|
+
operations: operations,
|
|
157
|
+
median_sec: median(times),
|
|
158
|
+
best_sec: best,
|
|
159
|
+
worst_sec: worst,
|
|
160
|
+
spread_pct: best.positive? ? ((worst - best) / best * 100.0) : 0.0,
|
|
161
|
+
ops_per_sec: operations / median(times),
|
|
162
|
+
ns_per_op: median(times) / operations * 1e9,
|
|
163
|
+
allocations_per_op: median(samples.map(&:last)) / operations.to_f
|
|
164
|
+
}
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def header(title)
|
|
168
|
+
m = model
|
|
169
|
+
puts
|
|
170
|
+
puts "== #{title} =="
|
|
171
|
+
puts "ruby=#{RUBY_VERSION} platform=#{RUBY_PLATFORM} backend=#{StaticEmbeddings.simd_backend}"
|
|
172
|
+
puts "model=#{m.model_id} dim=#{m.dim} vocab=#{m.vocab_size} max_tokens=#{m.max_tokens}"
|
|
173
|
+
puts "repeats=#{repeats} min_seconds=#{min_seconds} adaptive=#{adaptive? ? "yes" : "no"}"
|
|
174
|
+
puts "corpus_vocabulary=#{words.length} distinct in-vocabulary words " \
|
|
175
|
+
"(small pools keep matrix rows cached and overstate throughput)"
|
|
176
|
+
puts "input_coderange=cached unless a row says otherwise"
|
|
177
|
+
puts "spread_pct is best-to-worst across repeats; treat smaller differences as noise."
|
|
178
|
+
puts
|
|
179
|
+
return if kv?
|
|
180
|
+
|
|
181
|
+
printf("%-34s %12s %12s %10s %10s\n", "case", "ns/op", "ops/sec", "spread%", "alloc/op")
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def report(name, stats, **fields)
|
|
185
|
+
if kv?
|
|
186
|
+
payload = { benchmark: name, ruby: RUBY_VERSION, platform: RUBY_PLATFORM,
|
|
187
|
+
backend: StaticEmbeddings.simd_backend, dim: model.dim }
|
|
188
|
+
.merge(fields).merge(stats)
|
|
189
|
+
puts payload.map { |k, v| "#{k}=#{v.is_a?(Float) ? format("%.6g", v) : v}" }.join(" ")
|
|
190
|
+
else
|
|
191
|
+
label = fields.empty? ? name : "#{name} #{fields.map { |k, v| "#{k}=#{v}" }.join(" ")}"
|
|
192
|
+
printf("%-34s %12.1f %12.0f %9.1f%% %10.2f\n", label[0, 34], stats[:ns_per_op],
|
|
193
|
+
stats[:ops_per_sec], stats[:spread_pct], stats[:allocations_per_op])
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require_relative "_support"
|
|
3
|
+
|
|
4
|
+
SEBench.header("core_paths")
|
|
5
|
+
|
|
6
|
+
model = SEBench.model
|
|
7
|
+
short = SEBench.corpus(2_000, 20)
|
|
8
|
+
one = short.first
|
|
9
|
+
ids = model.tokenize(one)
|
|
10
|
+
|
|
11
|
+
SEBench.report("tokenize", SEBench.measure(initial_iterations: 500) { |n| n.times { model.tokenize(one) } },
|
|
12
|
+
words: 20, coderange: "cached")
|
|
13
|
+
|
|
14
|
+
SEBench.report("embed", SEBench.measure(initial_iterations: 500) { |n| n.times { model.embed(one) } },
|
|
15
|
+
words: 20, coderange: "cached")
|
|
16
|
+
|
|
17
|
+
SEBench.report("embed rotating corpus",
|
|
18
|
+
SEBench.measure(initial_iterations: 1, operations_per_iteration: short.length) { |n|
|
|
19
|
+
n.times { short.each { |t| model.embed(t) } }
|
|
20
|
+
}, texts: short.length, coderange: "cached")
|
|
21
|
+
|
|
22
|
+
SEBench.report("embed_batch",
|
|
23
|
+
SEBench.measure(initial_iterations: 1, operations_per_iteration: short.length) { |n|
|
|
24
|
+
n.times { model.embed_batch(short) }
|
|
25
|
+
}, texts: short.length, coderange: "cached")
|
|
26
|
+
|
|
27
|
+
SEBench.report("embed_batch f16",
|
|
28
|
+
SEBench.measure(initial_iterations: 1, operations_per_iteration: short.length) { |n|
|
|
29
|
+
n.times { model.embed_batch(short, format: :f16) }
|
|
30
|
+
}, texts: short.length, coderange: "cached")
|
|
31
|
+
|
|
32
|
+
SEBench.report("embed_token_ids",
|
|
33
|
+
SEBench.measure(initial_iterations: 500) { |n| n.times { model.embed_token_ids(ids) } },
|
|
34
|
+
tokens: ids.length)
|
|
35
|
+
|
|
36
|
+
query = model.embed(one)
|
|
37
|
+
abort "Benchmark query pooled to a zero vector; cosine_top_k requires a non-zero query" unless SEBench.nonzero_vector?(query)
|
|
38
|
+
matrix = model.embed_batch(short).freeze
|
|
39
|
+
rows = matrix.bytesize / (model.dim * 4)
|
|
40
|
+
|
|
41
|
+
SEBench.report("dot_top_k", SEBench.measure(initial_iterations: 5) { |n| n.times { model.dot_top_k(query, matrix, 10) } },
|
|
42
|
+
rows: rows)
|
|
43
|
+
SEBench.report("cosine_top_k", SEBench.measure(initial_iterations: 5) { |n| n.times { model.cosine_top_k(query, matrix, 10) } },
|
|
44
|
+
rows: rows)
|
|
45
|
+
|
|
46
|
+
query16 = model.embed(one, format: :f16)
|
|
47
|
+
matrix16 = model.embed_batch(short, format: :f16).freeze
|
|
48
|
+
SEBench.report("dot_top_k f16",
|
|
49
|
+
SEBench.measure(initial_iterations: 5) { |n|
|
|
50
|
+
n.times { StaticEmbeddings.dot_top_k(query16, matrix16, 10, dim: model.dim, format: :f16) }
|
|
51
|
+
}, rows: rows)
|
|
52
|
+
|
|
53
|
+
large = (SEBench.corpus(1, 200_000).first).freeze
|
|
54
|
+
large.valid_encoding?
|
|
55
|
+
SEBench.report("embed large truncated",
|
|
56
|
+
SEBench.measure(initial_iterations: 20) { |n| n.times { model.embed(large) } },
|
|
57
|
+
logical_bytes: large.bytesize, coderange: "cached")
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require_relative "_support"
|
|
3
|
+
|
|
4
|
+
SEBench.header("gvl_threshold")
|
|
5
|
+
|
|
6
|
+
THRESHOLD_BYTES = [512, 1_024, 2_040, 2_048, 2_056, 4_096, 8_192, 16_384, 65_536].freeze
|
|
7
|
+
PROGRESS_BYTES = [1_024, 2_048, 8_192, 65_536].freeze
|
|
8
|
+
PROGRESS_ITERATIONS = SEBench.env_integer("BENCH_GVL_PROGRESS_ITERATIONS", 200)
|
|
9
|
+
PROGRESS_THREAD_YIELD_EVERY = 4_096
|
|
10
|
+
|
|
11
|
+
def text_with_bytes(target_bytes)
|
|
12
|
+
rng = Random.new(target_bytes)
|
|
13
|
+
text = +""
|
|
14
|
+
text << SEBench.words[rng.rand(SEBench.words.length)] << " " while text.bytesize < target_bytes
|
|
15
|
+
text = text.byteslice(0, target_bytes).freeze
|
|
16
|
+
text.valid_encoding?
|
|
17
|
+
text
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
THRESHOLD_BYTES.each do |target_bytes|
|
|
21
|
+
text = text_with_bytes(target_bytes)
|
|
22
|
+
stats = SEBench.measure(initial_iterations: 200) { |n| n.times { SEBench.model.embed(text) } }
|
|
23
|
+
SEBench.report("embed", stats, bytes: text.bytesize, coderange: "cached")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
puts
|
|
27
|
+
puts "second-thread progress during repeated embed calls (higher is better for the caller's neighbours)"
|
|
28
|
+
PROGRESS_BYTES.each do |target_bytes|
|
|
29
|
+
text = text_with_bytes(target_bytes)
|
|
30
|
+
counter = 0
|
|
31
|
+
running = true
|
|
32
|
+
neighbour = Thread.new do
|
|
33
|
+
while running
|
|
34
|
+
counter += 1
|
|
35
|
+
Thread.pass if (counter % PROGRESS_THREAD_YIELD_EVERY).zero?
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
elapsed =
|
|
40
|
+
begin
|
|
41
|
+
SEBench.realtime { PROGRESS_ITERATIONS.times { SEBench.model.embed(text) } }
|
|
42
|
+
ensure
|
|
43
|
+
running = false
|
|
44
|
+
neighbour.join
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
printf("%-34s %12d ticks %9.3f s\n", "bytes=#{target_bytes}", counter, elapsed)
|
|
48
|
+
end
|
data/docs/ARCHITECTURE.md
CHANGED
|
@@ -24,6 +24,15 @@ Everything expensive, fragile or security-sensitive about reading third-party
|
|
|
24
24
|
model files happens once, offline, in a language where it is easy to get
|
|
25
25
|
right. What remains in C is a bounds-checked mmap and three loops.
|
|
26
26
|
|
|
27
|
+
The C side is `se_format.c` (mmap and validation), `se_tokenizer.c`,
|
|
28
|
+
`se_embed.c`, `se_unicode.c`, `se_f16.c` (half-precision codec and kernels),
|
|
29
|
+
`se_topk.c`, `se_alloc_stats.c` (optional allocation counters), and
|
|
30
|
+
`static_embeddings.c` for the Ruby bindings. Shared size arithmetic,
|
|
31
|
+
`static_assert`s on the mmapped struct layouts and the big-endian rejection live
|
|
32
|
+
in `se_internal.h`: the header fields are decoded little-endian explicitly, but
|
|
33
|
+
the mmapped structures and the float matrix are read in native order, so a
|
|
34
|
+
big-endian host is refused at load rather than silently misread.
|
|
35
|
+
|
|
27
36
|
## `.semb` v2
|
|
28
37
|
|
|
29
38
|
Little-endian throughout. 320-byte header, then sections aligned to 64 bytes.
|
|
@@ -90,10 +99,23 @@ tries inspired by double-array trie libraries such as libdatrie:
|
|
|
90
99
|
- `root_trie` for tokens that can start a word;
|
|
91
100
|
- `continuation_trie` for `##token` entries stored without the `##` prefix.
|
|
92
101
|
|
|
93
|
-
At runtime `append_wordpiece`
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
102
|
+
At runtime `append_wordpiece` first tries the hash table for the whole word,
|
|
103
|
+
which is the common case for in-vocabulary text, and falls back to walking the
|
|
104
|
+
relevant trie once, recording the longest terminal node. Both halves earn their
|
|
105
|
+
place: removing the hash pre-check made tokenization several times slower on
|
|
106
|
+
ordinary text, and replacing the trie with repeated hash lookups by decreasing
|
|
107
|
+
length made out-of-vocabulary words several times slower still.
|
|
108
|
+
|
|
109
|
+
Normalization has an ASCII fast path. Below `0x80` the CJK, NFD, combining-mark
|
|
110
|
+
and case-folding branches always resolve the same way, so those runs go through
|
|
111
|
+
a 128-entry classification table instead of the full
|
|
112
|
+
`emit_cleaned -> emit_stripped -> emit_lowered -> feed_token_cp` chain, and a
|
|
113
|
+
word reserves its codepoint buffer once rather than once per character. The
|
|
114
|
+
table is generated to agree with `is_control`, `se_is_ascii_whitespace` and
|
|
115
|
+
`se_is_ascii_punct`, and `test/ascii_parity_test.rb` sweeps every byte in
|
|
116
|
+
`0x00..0x7F` against the Ruby reference to keep it that way. That test exists
|
|
117
|
+
because `U+007F DEL` was misclassified through 0.1.1 and every sampled test in
|
|
118
|
+
the suite passed anyway.
|
|
97
119
|
|
|
98
120
|
Loading a model still performs **zero runtime insertions**. It is `mmap` plus a
|
|
99
121
|
validation pass over the hash table, trie node ranges and trie edge ordering.
|
|
@@ -224,6 +246,33 @@ not triple the cost.
|
|
|
224
246
|
With `max_tokens: false` there is nothing to truncate and the whole input is
|
|
225
247
|
copied.
|
|
226
248
|
|
|
249
|
+
The window bounds tokenizing, not the whole call. Ruby has to answer whether the
|
|
250
|
+
`String` is valid UTF-8, and `rb_enc_str_coderange` answers it for the whole
|
|
251
|
+
`String` — an O(bytes) scan when Ruby has not computed a coderange yet, running
|
|
252
|
+
before the prefix is even chosen. `validate_encoding: :prefix` skips that scan
|
|
253
|
+
and leans on the fact that the C side already validates what it reads:
|
|
254
|
+
`decode_one` rejects malformed UTF-8, and `se_prefix_boundary_len` returns 0
|
|
255
|
+
when it cannot decode, which degrades to a full copy rather than reading out of
|
|
256
|
+
bounds. So `:prefix` is memory-safe in every case; what it gives up is noticing
|
|
257
|
+
invalid bytes the tokenizer never reaches. A cached coderange is always
|
|
258
|
+
honoured, which keeps the cheap answer authoritative when Ruby already has one.
|
|
259
|
+
|
|
260
|
+
## Mapping the model file
|
|
261
|
+
|
|
262
|
+
POSIX uses `mmap` with `PROT_READ`/`MAP_PRIVATE`; Windows uses
|
|
263
|
+
`CreateFileMapping` plus `MapViewOfFile`. Both give the same three properties
|
|
264
|
+
the format was designed around: pages fault in lazily instead of being read up
|
|
265
|
+
front, the page cache is shared between forked or sibling processes, and the
|
|
266
|
+
runtime never owns a writable copy of the matrix. Windows previously read the
|
|
267
|
+
file into the heap, which cost every process a private copy of the model and a
|
|
268
|
+
full read before the first query.
|
|
269
|
+
|
|
270
|
+
Windows keeps the heap path as a fallback when mapping fails, since some network
|
|
271
|
+
filesystems refuse it, and `model->mapped` records which one was taken so
|
|
272
|
+
`se_model_close` unmaps or frees correctly. `verify` is separate from loading and
|
|
273
|
+
streams the file through SHA-256 in chunks, so checking an artifact never
|
|
274
|
+
materialises it either.
|
|
275
|
+
|
|
227
276
|
## Concurrency
|
|
228
277
|
|
|
229
278
|
Small calls run inline. Large `embed_batch` calls copy Ruby input into C-owned
|
|
@@ -239,8 +288,9 @@ job/application layer.
|
|
|
239
288
|
|
|
240
289
|
Cancellation is cooperative: the tokenizer checks a flag every 1024 codepoints
|
|
241
290
|
(counted by iteration, not by byte offset, so the interval does not depend on
|
|
242
|
-
how wide the input's codepoints are), the
|
|
243
|
-
|
|
291
|
+
how wide the input's codepoints are), and the ASCII fast path checks on the same
|
|
292
|
+
cadence in bytes so a long ASCII run is no less interruptible. The pooling loop
|
|
293
|
+
checks every 256 rows, and the top-k scan every 1024 rows. `unblock_cancel` sets that flag when Ruby
|
|
244
294
|
interrupts a GVL-free region.
|
|
245
295
|
|
|
246
296
|
No global mutable state exists in C. The model is immutable after load and
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Benchmarking
|
|
2
|
+
|
|
3
|
+
Two different tools, for two different questions.
|
|
4
|
+
|
|
5
|
+
`benchmark/` answers "how fast is this, and is the difference I am looking at
|
|
6
|
+
real". `samples/` answers "where does the time go" by keeping a process alive
|
|
7
|
+
for a profiler to attach to. Do not use one for the other's job.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
bundle exec rake benchmark # everything
|
|
11
|
+
bundle exec rake benchmark:core_paths
|
|
12
|
+
bundle exec rake benchmark:gvl_threshold
|
|
13
|
+
|
|
14
|
+
BENCH_MODEL="$HOME/.cache/static_embeddings/models/potion-retrieval-32m.semb" \
|
|
15
|
+
bundle exec rake benchmark:core_paths
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Without `BENCH_MODEL` the demo model is used, which has `dim=8` and a 176-token
|
|
19
|
+
vocabulary. It proves the harness runs; it says nothing about production speed.
|
|
20
|
+
|
|
21
|
+
## Reading the output
|
|
22
|
+
|
|
23
|
+
```text
|
|
24
|
+
case ns/op ops/sec spread% alloc/op
|
|
25
|
+
embed words=20 8540.5 117089 3.0% 1.00
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`spread_pct` is the distance between the fastest and slowest repeat. **A
|
|
29
|
+
difference smaller than `spread_pct` is not a result.** On a laptop or a shared
|
|
30
|
+
CI runner it is routinely 5-10%, which is wider than most changes worth making.
|
|
31
|
+
If you want to claim an improvement, either get the spread down or show the
|
|
32
|
+
change across several independent runs.
|
|
33
|
+
|
|
34
|
+
`alloc/op` is Ruby object allocations per operation. It moves for different
|
|
35
|
+
reasons than time does, and it is the more stable signal of the two.
|
|
36
|
+
|
|
37
|
+
Generated text inputs are primed with `valid_encoding?` before timing, so these
|
|
38
|
+
benchmarks measure the cached-coderange hot path. Fresh strings from disk or the
|
|
39
|
+
database can pay an extra whole-string UTF-8 scan; see `docs/PERFORMANCE.md`.
|
|
40
|
+
|
|
41
|
+
Iteration counts are calibrated automatically so every measurement runs for at
|
|
42
|
+
least `BENCH_MIN_SECONDS`. Knobs:
|
|
43
|
+
|
|
44
|
+
| Variable | Default | Meaning |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| `BENCH_MODEL` | demo model | model to load |
|
|
47
|
+
| `BENCH_REPEATS` | 7 | repeats per case, median reported |
|
|
48
|
+
| `BENCH_MIN_SECONDS` | 0.25 | minimum duration of one repeat |
|
|
49
|
+
| `BENCH_WARMUP_SECONDS` | 0.05 | warmup before timing |
|
|
50
|
+
| `BENCH_ADAPTIVE` | 1 | set to 0 to use the literal iteration count |
|
|
51
|
+
| `BENCH_FORMAT` | table | set to `kv` for machine-readable lines |
|
|
52
|
+
|
|
53
|
+
## What the two benchmarks are for
|
|
54
|
+
|
|
55
|
+
`core_paths` covers tokenize, single embed, batch embed, pooling in isolation
|
|
56
|
+
and top-k in both formats. The `embed` and `embed rotating corpus` rows are
|
|
57
|
+
deliberately both present: the first repeats one string and keeps its matrix
|
|
58
|
+
rows in cache, the second rotates through 2000 texts and does not. The gap
|
|
59
|
+
between them is the cache effect, and quoting the first as throughput is how you
|
|
60
|
+
end up with a number nobody can reproduce.
|
|
61
|
+
|
|
62
|
+
`gvl_threshold` sweeps input sizes around `SE_GVL_UNLOCK_THRESHOLD`, the point
|
|
63
|
+
where a single `embed` stops running under the GVL and is handed to the batch
|
|
64
|
+
machinery. Look for the step, not the slope. On one x86-64 run the step cost
|
|
65
|
+
about 32% latency and four extra allocations, and it is also the point where a
|
|
66
|
+
second Ruby thread starts making progress at all. Both halves of that trade have
|
|
67
|
+
to be measured on your hardware before the constant is worth changing.
|
|
68
|
+
|
|
69
|
+
## Traps this repository has already fallen into
|
|
70
|
+
|
|
71
|
+
**Quoting noise.** An `embed_batch f16` change was reported as a 6% speedup; it
|
|
72
|
+
was inside the run-to-run spread and the next run showed the opposite sign. The
|
|
73
|
+
memory result from the same change was real and reproducible. Report the one you
|
|
74
|
+
can reproduce.
|
|
75
|
+
|
|
76
|
+
**Cache-resident working sets.** Embedding the same text in a loop keeps a tiny
|
|
77
|
+
slice of the matrix hot. Real corpora do not.
|
|
78
|
+
|
|
79
|
+
**Logical vs processed bytes.** With truncation active, a 3 MB document is
|
|
80
|
+
tokenized only until `max_tokens` is reached. Any MB/s figure computed from the
|
|
81
|
+
string length divides by bytes that were never read.
|
|
82
|
+
|
|
83
|
+
**GC state.** `GC.disable` does not stop an in-flight incremental cycle from
|
|
84
|
+
sweeping. The harness leaves GC on and reports allocations instead.
|
|
85
|
+
|
|
86
|
+
See `docs/PERFORMANCE.md` for the budget itself and where the time actually
|
|
87
|
+
goes.
|
data/docs/LIMITATIONS.md
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Limitations
|
|
2
|
+
|
|
3
|
+
`static_embeddings` is a runtime for one narrow thing: turning text into a
|
|
4
|
+
vector with a converted Model2Vec / potion model, and scanning a matrix of such
|
|
5
|
+
vectors. It is not a vector database, not a general embedding library, and not a
|
|
6
|
+
tokenizer toolkit.
|
|
7
|
+
|
|
8
|
+
This page is the answer to "does it do X". If X is here, the answer is no, and
|
|
9
|
+
the entry says whether that is a decision or just unfinished work.
|
|
10
|
+
|
|
11
|
+
## Models
|
|
12
|
+
|
|
13
|
+
- **Only `.semb` files from this repository's converter.** Loading a
|
|
14
|
+
HuggingFace directory at runtime is not supported and will not be. The
|
|
15
|
+
converter is the only thing that reads third-party files, and it runs offline.
|
|
16
|
+
- **Only the `BERT_WORDPIECE_V1` tokenizer profile.** SentencePiece, BPE, Unigram
|
|
17
|
+
and byte-level tokenizers are rejected at conversion time rather than
|
|
18
|
+
approximated.
|
|
19
|
+
- **No model training, distillation or fine-tuning.** Produce the model with
|
|
20
|
+
upstream `model2vec`, then convert it.
|
|
21
|
+
- **No transformer inference.** Static embeddings have no attention and no
|
|
22
|
+
context; a word contributes the same row wherever it appears. If you need
|
|
23
|
+
contextual embeddings, this is the wrong tool.
|
|
24
|
+
|
|
25
|
+
## Runtime
|
|
26
|
+
|
|
27
|
+
- **No internal parallelism.** `threads:` is rejected rather than ignored. Split
|
|
28
|
+
work at the job level and run more application workers.
|
|
29
|
+
- **No Ractor support.** Model, and everything holding native state, are not
|
|
30
|
+
shareable; `Ractor.make_shareable` raises.
|
|
31
|
+
- **No GPU, no BLAS, no external SIMD library.** The kernels are plain C with
|
|
32
|
+
compiler autovectorisation, plus a hand-written half-precision path.
|
|
33
|
+
- **No streaming input.** `embed` and `embed_batch` take Ruby `String`s that are
|
|
34
|
+
already in memory.
|
|
35
|
+
- **`embed_batch` peaks at roughly twice the returned blob**, because the result
|
|
36
|
+
is built in C memory and then copied into a Ruby `String`. Chunk large
|
|
37
|
+
corpora.
|
|
38
|
+
- **Big-endian hosts are refused at load.** The header is decoded
|
|
39
|
+
little-endian explicitly, but the mmapped structures and the float matrix are
|
|
40
|
+
read in native order.
|
|
41
|
+
|
|
42
|
+
## Search
|
|
43
|
+
|
|
44
|
+
- **No index.** `dot_top_k` and `cosine_top_k` are exhaustive scans. There is no
|
|
45
|
+
ANN, no IVF, no HNSW, no clustering.
|
|
46
|
+
- **No batched queries.** One query per scan; N queries stream the matrix N
|
|
47
|
+
times.
|
|
48
|
+
- **No candidate filtering.** You cannot restrict a scan to a subset of rows.
|
|
49
|
+
- **No precomputed row norms.** `cosine_top_k` recomputes each row's norm on
|
|
50
|
+
every query. Normalise your rows once and use `dot_top_k` instead.
|
|
51
|
+
- **No persistence, no ids, no updates.** A matrix is a frozen `String`; mapping
|
|
52
|
+
rows to documents, appending, and deleting are the caller's problem. If you
|
|
53
|
+
want that, use pgvector and keep this gem for producing the vectors.
|
|
54
|
+
- **No metric other than dot product and cosine.** No Euclidean, no Manhattan,
|
|
55
|
+
no Hamming.
|
|
56
|
+
|
|
57
|
+
## Storage formats
|
|
58
|
+
|
|
59
|
+
- **`f32` and `f16` only.** No int8, no binary quantisation.
|
|
60
|
+
- **`f16` is a storage trade-off, not a speed feature.** See
|
|
61
|
+
`docs/PERFORMANCE.md`; it wins on some CPUs and loses on others.
|
|
62
|
+
- **`f16` rounding is half-up, not ties-to-even.** Blobs written by this gem can
|
|
63
|
+
differ from NumPy or PyTorch by one ULP on exact halfway values.
|
|
64
|
+
|
|
65
|
+
## Platforms
|
|
66
|
+
|
|
67
|
+
- **Windows is built and smoke-tested by cross-compilation under wine, not on a
|
|
68
|
+
real Windows runner.** The loader uses `CreateFileMapping`, with a
|
|
69
|
+
read-into-heap fallback.
|
|
70
|
+
- **No JRuby or TruffleRuby.** The gem is a CRuby C extension.
|
|
71
|
+
|
|
72
|
+
## Verification
|
|
73
|
+
|
|
74
|
+
- **`load` does not check the SHA-256.** Structural validation always runs, but
|
|
75
|
+
a bit flip inside the matrix is only caught by `verify: true` or
|
|
76
|
+
`StaticEmbeddings.verify`.
|
|
77
|
+
- **Parity is per model and per runtime version.** A converted model is not
|
|
78
|
+
trusted until it has a record in `docs/MODEL_AUDIT.md`, and that record
|
|
79
|
+
expires when the tokenizer changes.
|
|
80
|
+
|
|
81
|
+
## Known open questions
|
|
82
|
+
|
|
83
|
+
These are not decisions, just work that has not been done or measured:
|
|
84
|
+
|
|
85
|
+
- The `SE_GVL_UNLOCK_THRESHOLD` value has a benchmark now
|
|
86
|
+
(`rake benchmark:gvl_threshold`) but has not been retuned from it.
|
|
87
|
+
- Top-k keeps its candidates in a sorted array, which degrades for large `k`; a
|
|
88
|
+
heap would be better somewhere above a few dozen.
|
|
89
|
+
- `madvise(MADV_RANDOM)` and huge pages for the embedding section are untested.
|
|
90
|
+
- The C allocation counters cover the runtime's own allocations only; Ruby heap,
|
|
91
|
+
fragmentation and mmap residency are outside them.
|