static_embeddings 0.1.2 → 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 +76 -0
- data/README.md +7 -1
- 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/BENCHMARKING.md +87 -0
- data/docs/LIMITATIONS.md +91 -0
- data/docs/MODEL_AUDIT.md +17 -11
- data/docs/PERFORMANCE.md +8 -0
- data/ext/static_embeddings/se_format.c +3 -0
- data/ext/static_embeddings/static_embeddings.c +52 -0
- data/lib/static_embeddings/version.rb +1 -1
- data/static_embeddings.gemspec +2 -0
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7ec20ad222235414c2e6f081a2cea5f932a7e3004cb78dcca5552c20208bd6a8
|
|
4
|
+
data.tar.gz: 80553b44c62b46625562accee74ac9bfe689400f05258e551dc9603105f64f7a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7fc51c583e8d0f05e5e6b7181767450e4fc93601180a638444cb715be0321d993454003d93bd9bd9f7154e5d678b57dc0fdeba7d5f86c81154f0b61886898101
|
|
7
|
+
data.tar.gz: 1739214fe214cbb86a81e643878a371547b36365d408f2e81e7e95c6c0f0b66c2e214c839f41744347c5015c82832f5a34410e585eb504dab4fe2d3a106ff459
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,81 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.3 (unreleased)
|
|
4
|
+
|
|
5
|
+
Hardening and tooling, mostly borrowed from the sibling `tg_geometry` gem after
|
|
6
|
+
reading its C extension side by side with this one. No behaviour changes to
|
|
7
|
+
tokenization, pooling or search: the differential fuzz digest against
|
|
8
|
+
`StaticEmbeddings::Reference` is unchanged.
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **Unknown keywords are rejected instead of ignored.** `rb_hash_lookup2`
|
|
13
|
+
returns the default for an absent key, so every misspelled keyword used to be
|
|
14
|
+
silently dropped and the caller got a plausible wrong answer. Two cases were
|
|
15
|
+
actively dangerous: `embed(text, fromat: :f16)` returned a full-width f32
|
|
16
|
+
vector, and `embed_batch(texts, threds: 4)` slipped past the guard whose only
|
|
17
|
+
job is to say that `threads:` buys nothing. All eight keyword-accepting
|
|
18
|
+
methods now validate against a whitelist and name the accepted keywords in the
|
|
19
|
+
error.
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
model.embed("hi", fromat: :f16)
|
|
23
|
+
# ArgumentError: unknown keyword: :fromat
|
|
24
|
+
# (accepted: :max_tokens, :format, :validate_encoding, :threads)
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
- **`memsize` no longer charges the mapped model to the Ruby heap.** A mapped
|
|
28
|
+
`.semb` is file-backed, shared between forked workers and reclaimable by the
|
|
29
|
+
kernel, but `ObjectSpace.memsize_of(model)` reported its full size, which fed
|
|
30
|
+
GC heuristics memory the VM could neither free nor account for. It now reports
|
|
31
|
+
only process-owned bytes; `mapped_bytes` still reports the mapping. The
|
|
32
|
+
Windows heap fallback really is process memory and is still counted.
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
|
|
36
|
+
- **`benchmark/` with a shared harness.** Iteration counts are calibrated so
|
|
37
|
+
every measurement runs for at least `BENCH_MIN_SECONDS`, repeats are taken,
|
|
38
|
+
the median is reported, and every row carries `spread_pct` — the best-to-worst
|
|
39
|
+
distance across repeats. This release exists partly because a 6% throughput
|
|
40
|
+
claim in 0.1.2 turned out to be inside that spread. `BENCH_FORMAT=kv` gives
|
|
41
|
+
machine-readable output. Run with `rake benchmark`. Inputs are
|
|
42
|
+
`valid_encoding?`-primed, so rows report `coderange=cached` and measure the
|
|
43
|
+
hot path rather than a string fresh from a file or a database.
|
|
44
|
+
|
|
45
|
+
- **`benchmark/gvl_threshold.rb`.** `SE_GVL_UNLOCK_THRESHOLD` has never been
|
|
46
|
+
justified by data. The sweep measures both halves of the trade: on one x86-64
|
|
47
|
+
run the step at 2048 bytes cost about 32% latency and four extra allocations,
|
|
48
|
+
and it is also the point where a second Ruby thread starts making progress at
|
|
49
|
+
all. The constant is unchanged; now there is something to argue from.
|
|
50
|
+
|
|
51
|
+
- **`benchmark/core_paths.rb`,** covering tokenize, single embed, batch embed,
|
|
52
|
+
pooling in isolation and top-k in both formats. It reports single-text embed
|
|
53
|
+
twice, once repeating one string and once rotating a corpus, because the gap
|
|
54
|
+
between them is the cache effect that makes hot-loop numbers unreproducible.
|
|
55
|
+
|
|
56
|
+
- **`test/gc_compaction_test.rb` and a `gc_compaction` CI job.** `top_k`,
|
|
57
|
+
`embed` and `embed_batch` hand raw pointers into C and release the GVL, so
|
|
58
|
+
compaction moving Ruby objects would show up as wrong numbers rather than a
|
|
59
|
+
crash.
|
|
60
|
+
|
|
61
|
+
Getting this test to test anything took two corrections. It first used a
|
|
62
|
+
64-row matrix, which is 2 KiB against a 1 MiB `SE_TOPK_GVL_UNLOCK_THRESHOLD`,
|
|
63
|
+
so the scan never released the GVL and compaction could not interleave with
|
|
64
|
+
it. The same mistake then turned out to be hiding in the embed case: 2038
|
|
65
|
+
bytes of corpus against a 2048-byte `SE_GVL_UNLOCK_THRESHOLD`, ten bytes
|
|
66
|
+
under. Measured with a spinning neighbour thread, both old cases produced zero
|
|
67
|
+
ticks during the call and both fixed ones produce hundreds of millions. Every
|
|
68
|
+
concurrency case now asserts it is over the relevant threshold, because
|
|
69
|
+
without that assertion the test silently stops testing the moment a constant
|
|
70
|
+
or a fixture changes.
|
|
71
|
+
|
|
72
|
+
- **`docs/LIMITATIONS.md`.** One page answering "does it do X", split into
|
|
73
|
+
decisions and unfinished work: no ANN index, no batched queries, no candidate
|
|
74
|
+
filtering, no int8, no Ractor, no internal parallelism, and so on.
|
|
75
|
+
|
|
76
|
+
- **`docs/BENCHMARKING.md`,** covering how to read `spread_pct` and the four
|
|
77
|
+
measurement traps this repository has already fallen into.
|
|
78
|
+
|
|
3
79
|
## 0.1.2 (unreleased)
|
|
4
80
|
|
|
5
81
|
A tokenizer correctness fix, the ASCII fast path it made safe to write, and an
|
data/README.md
CHANGED
|
@@ -22,7 +22,8 @@ batch = model.embed_batch([
|
|
|
22
22
|
```
|
|
23
23
|
|
|
24
24
|
New to the gem? Start with `GET_STARTED.md`. Design rationale is in
|
|
25
|
-
`docs/ARCHITECTURE.md
|
|
25
|
+
`docs/ARCHITECTURE.md`, and `docs/LIMITATIONS.md` is the short answer to "does
|
|
26
|
+
it do X".
|
|
26
27
|
|
|
27
28
|
## Runtime contract
|
|
28
29
|
|
|
@@ -123,6 +124,9 @@ faster path for ordinary text.
|
|
|
123
124
|
|
|
124
125
|
### `format: :f16`
|
|
125
126
|
|
|
127
|
+
Unknown keywords raise. `embed(text, fromat: :f16)` is an `ArgumentError`
|
|
128
|
+
naming the accepted keywords, not a silent f32 vector.
|
|
129
|
+
|
|
126
130
|
`format:` selects the returned storage encoding only. The model always computes
|
|
127
131
|
in float32. For a 512-dimensional model one vector goes from 2048 to 1024 bytes.
|
|
128
132
|
It is accepted by `embed`, `embed_batch`, `embed_with_stats`, `embed_token_ids`,
|
|
@@ -300,6 +304,8 @@ a test fixture and API demo, not a retrieval quality baseline.
|
|
|
300
304
|
|
|
301
305
|
```bash
|
|
302
306
|
ruby tools/benchmark.rb # normalised performance budget
|
|
307
|
+
bundle exec rake benchmark # benchmark/, see docs/BENCHMARKING.md
|
|
308
|
+
bundle exec rake gc_compaction # GC.compact hardening
|
|
303
309
|
bundle exec rake cancellation_timing # timing-sensitive, excluded from rake test
|
|
304
310
|
samples/run_all.sh # native hot-path probes; see samples/README.md
|
|
305
311
|
```
|
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
|
|
@@ -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.
|
data/docs/MODEL_AUDIT.md
CHANGED
|
@@ -11,25 +11,27 @@ runtime half of it even though the file bytes are untouched.
|
|
|
11
11
|
| runtime | parity | note |
|
|
12
12
|
|---|---|---|
|
|
13
13
|
| 0.1.1 | `parity OK`, recorded below | superseded |
|
|
14
|
-
| 0.1.2 | **not re-run** |
|
|
14
|
+
| 0.1.2 | **not re-run** | superseded before release |
|
|
15
|
+
| 0.1.3 | `parity OK`, recorded below | current |
|
|
15
16
|
|
|
16
17
|
0.1.2 changed `is_control()`, which changes token ids for any input containing
|
|
17
18
|
`U+007F`. `StaticEmbeddings::Reference` cannot settle whether the new behaviour
|
|
18
19
|
matches HuggingFace, because it is an implementation twin of the C runtime
|
|
19
|
-
written in this repository.
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
written in this repository. The 0.1.3 release candidate was checked against a
|
|
21
|
+
fresh upstream `model2vec.StaticModel` oracle that includes DEL, control
|
|
22
|
+
characters, Unicode, OOV, long-word and truncation rows.
|
|
22
23
|
|
|
23
24
|
Source model:
|
|
24
25
|
|
|
25
26
|
- Hugging Face repository: `minishlab/potion-retrieval-32M`
|
|
27
|
+
- Hugging Face snapshot: `6fc8051fab2a1e0ee76689cf08c853792ac285e7`
|
|
26
28
|
- Oracle implementation: `model2vec.StaticModel.from_pretrained`
|
|
27
29
|
- Python package: `model2vec 0.9.0`
|
|
28
30
|
- Oracle file: `tmp/model2vec_oracle.json`
|
|
29
|
-
- Oracle rows in this recorded run: `
|
|
31
|
+
- Oracle rows in this recorded run: `31`
|
|
30
32
|
- Oracle dimension: `512`
|
|
31
33
|
- Oracle max length: `512`
|
|
32
|
-
- Runtime at time of this record: `static_embeddings 0.1.
|
|
34
|
+
- Runtime at time of this record: `static_embeddings 0.1.3`
|
|
33
35
|
|
|
34
36
|
Converted `.semb`:
|
|
35
37
|
|
|
@@ -42,6 +44,9 @@ Converted `.semb`:
|
|
|
42
44
|
Parity command:
|
|
43
45
|
|
|
44
46
|
```bash
|
|
47
|
+
python tools/model2vec_oracle.py minishlab/potion-retrieval-32M \
|
|
48
|
+
--out tmp/model2vec_oracle.json
|
|
49
|
+
|
|
45
50
|
bundle exec rake parity \
|
|
46
51
|
MODEL="$HOME/.cache/static_embeddings/models/potion-retrieval-32m.semb" \
|
|
47
52
|
ORACLE=tmp/model2vec_oracle.json
|
|
@@ -50,8 +55,8 @@ bundle exec rake parity \
|
|
|
50
55
|
Parity result:
|
|
51
56
|
|
|
52
57
|
```text
|
|
53
|
-
rows=
|
|
54
|
-
id_rows_checked=
|
|
58
|
+
rows=31
|
|
59
|
+
id_rows_checked=31
|
|
55
60
|
min_cosine=0.9999999999989528
|
|
56
61
|
max_abs_all=2.980232238769531e-07
|
|
57
62
|
token_id_failures=[]
|
|
@@ -68,7 +73,8 @@ Decision:
|
|
|
68
73
|
- Unicode normalization behavior: pass
|
|
69
74
|
- Unknown-word behavior: pass
|
|
70
75
|
- Long input / truncation behavior: pass
|
|
76
|
+
- DEL / control-character behavior: pass
|
|
71
77
|
|
|
72
|
-
Accepted for runtime and benchmark use **under 0.1.
|
|
73
|
-
unchanged and its SHA256 still matches;
|
|
74
|
-
|
|
78
|
+
Accepted for runtime and benchmark use **under 0.1.3**. The `.semb` file is
|
|
79
|
+
unchanged and its SHA256 still matches; the runtime half of the record was
|
|
80
|
+
refreshed after the DEL fix.
|
data/docs/PERFORMANCE.md
CHANGED
|
@@ -72,6 +72,14 @@ for those. And the instrumentation is not free: about 13% on `tokenize`, 3% on
|
|
|
72
72
|
`embed`, within noise on `embed_batch`. A run captured with it is not
|
|
73
73
|
comparable to one captured without it.
|
|
74
74
|
|
|
75
|
+
## Running the benchmarks
|
|
76
|
+
|
|
77
|
+
`bundle exec rake benchmark`, or one at a time with `rake benchmark:core_paths`
|
|
78
|
+
and `rake benchmark:gvl_threshold`. Generated text inputs are
|
|
79
|
+
`valid_encoding?`-primed before timing, so these are cached-coderange hot-path
|
|
80
|
+
numbers. Every row carries `spread_pct`, and a difference smaller than that is
|
|
81
|
+
not a result. See `docs/BENCHMARKING.md`.
|
|
82
|
+
|
|
75
83
|
## Benchmark hygiene
|
|
76
84
|
|
|
77
85
|
Three failure modes have bitten this repository already.
|
|
@@ -503,6 +503,51 @@ static VALUE lookup_option(VALUE opts, ID id) {
|
|
|
503
503
|
return rb_hash_lookup2(opts, ID2SYM(id), Qundef);
|
|
504
504
|
}
|
|
505
505
|
|
|
506
|
+
typedef struct {
|
|
507
|
+
const ID *allowed;
|
|
508
|
+
size_t count;
|
|
509
|
+
} keyword_check_t;
|
|
510
|
+
|
|
511
|
+
static int reject_unknown_keyword_i(VALUE key, VALUE value, VALUE arg) {
|
|
512
|
+
const keyword_check_t *check = (const keyword_check_t *)arg;
|
|
513
|
+
(void)value;
|
|
514
|
+
|
|
515
|
+
if (!SYMBOL_P(key))
|
|
516
|
+
rb_raise(rb_eArgError, "keyword must be a Symbol");
|
|
517
|
+
|
|
518
|
+
ID id = SYM2ID(key);
|
|
519
|
+
for (size_t i = 0; i < check->count; i++) {
|
|
520
|
+
if (id == check->allowed[i])
|
|
521
|
+
return ST_CONTINUE;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
VALUE names = rb_ary_new_capa((long)check->count);
|
|
525
|
+
for (size_t i = 0; i < check->count; i++)
|
|
526
|
+
rb_ary_push(names, rb_sprintf(":%s", rb_id2name(check->allowed[i])));
|
|
527
|
+
|
|
528
|
+
const char *name = rb_id2name(id);
|
|
529
|
+
rb_raise(rb_eArgError, "unknown keyword: :%s (accepted: %" PRIsVALUE ")", name ? name : "?",
|
|
530
|
+
rb_ary_join(names, rb_str_new_cstr(", ")));
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
static void check_keywords(VALUE opts, const ID *allowed, size_t count) {
|
|
534
|
+
if (NIL_P(opts))
|
|
535
|
+
return;
|
|
536
|
+
if (!RB_TYPE_P(opts, T_HASH))
|
|
537
|
+
rb_raise(rb_eArgError, "keywords must be a Hash");
|
|
538
|
+
|
|
539
|
+
keyword_check_t check;
|
|
540
|
+
check.allowed = allowed;
|
|
541
|
+
check.count = count;
|
|
542
|
+
rb_hash_foreach(opts, reject_unknown_keyword_i, (VALUE)&check);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
#define SE_CHECK_KEYWORDS(opts, ...) \
|
|
546
|
+
do { \
|
|
547
|
+
const ID se_allowed_[] = {__VA_ARGS__}; \
|
|
548
|
+
check_keywords((opts), se_allowed_, sizeof(se_allowed_) / sizeof(se_allowed_[0])); \
|
|
549
|
+
} while (0)
|
|
550
|
+
|
|
506
551
|
static void reject_parallel_threads(VALUE opts) {
|
|
507
552
|
VALUE v = lookup_option(opts, id_threads);
|
|
508
553
|
if (v == Qundef || v == Qnil)
|
|
@@ -867,6 +912,7 @@ static VALUE embed_batch_internal(VALUE self, VALUE texts, VALUE max_tokens_opt,
|
|
|
867
912
|
static VALUE model_embed_batch(int argc, VALUE *argv, VALUE self) {
|
|
868
913
|
VALUE texts, opts;
|
|
869
914
|
rb_scan_args(argc, argv, "1:", &texts, &opts);
|
|
915
|
+
SE_CHECK_KEYWORDS(opts, id_max_tokens, id_format, id_validate_encoding, id_threads);
|
|
870
916
|
reject_parallel_threads(opts);
|
|
871
917
|
|
|
872
918
|
VALUE max_tokens = lookup_option(opts, id_max_tokens);
|
|
@@ -927,6 +973,7 @@ static VALUE embed_one_value(VALUE self, VALUE text, VALUE max_tokens_opt,
|
|
|
927
973
|
static VALUE model_embed(int argc, VALUE *argv, VALUE self) {
|
|
928
974
|
VALUE text, opts;
|
|
929
975
|
rb_scan_args(argc, argv, "1:", &text, &opts);
|
|
976
|
+
SE_CHECK_KEYWORDS(opts, id_max_tokens, id_format, id_validate_encoding, id_threads);
|
|
930
977
|
reject_parallel_threads(opts);
|
|
931
978
|
return embed_one_value(self, text, lookup_option(opts, id_max_tokens),
|
|
932
979
|
resolve_vector_format(lookup_option(opts, id_format)),
|
|
@@ -937,6 +984,7 @@ static VALUE model_embed(int argc, VALUE *argv, VALUE self) {
|
|
|
937
984
|
static VALUE model_embed_with_stats(int argc, VALUE *argv, VALUE self) {
|
|
938
985
|
VALUE text, opts;
|
|
939
986
|
rb_scan_args(argc, argv, "1:", &text, &opts);
|
|
987
|
+
SE_CHECK_KEYWORDS(opts, id_max_tokens, id_format, id_validate_encoding, id_threads);
|
|
940
988
|
reject_parallel_threads(opts);
|
|
941
989
|
|
|
942
990
|
se_token_stats_t stats;
|
|
@@ -956,6 +1004,7 @@ static VALUE model_embed_with_stats(int argc, VALUE *argv, VALUE self) {
|
|
|
956
1004
|
static VALUE model_tokenize(int argc, VALUE *argv, VALUE self) {
|
|
957
1005
|
VALUE text, opts;
|
|
958
1006
|
rb_scan_args(argc, argv, "1:", &text, &opts);
|
|
1007
|
+
SE_CHECK_KEYWORDS(opts, id_max_tokens, id_validate_encoding);
|
|
959
1008
|
|
|
960
1009
|
model_wrapper_t *w = get_model(self);
|
|
961
1010
|
Check_Type(text, T_STRING);
|
|
@@ -1168,6 +1217,7 @@ static VALUE embed_token_ids_value(VALUE self, VALUE ids_value, VALUE max_tokens
|
|
|
1168
1217
|
static VALUE model_embed_token_ids(int argc, VALUE *argv, VALUE self) {
|
|
1169
1218
|
VALUE ids_value, opts;
|
|
1170
1219
|
rb_scan_args(argc, argv, "1:", &ids_value, &opts);
|
|
1220
|
+
SE_CHECK_KEYWORDS(opts, id_max_tokens, id_format, id_threads);
|
|
1171
1221
|
reject_parallel_threads(opts);
|
|
1172
1222
|
return embed_token_ids_value(self, ids_value, lookup_option(opts, id_max_tokens),
|
|
1173
1223
|
resolve_vector_format(lookup_option(opts, id_format)), NULL);
|
|
@@ -1176,6 +1226,7 @@ static VALUE model_embed_token_ids(int argc, VALUE *argv, VALUE self) {
|
|
|
1176
1226
|
static VALUE model_embed_token_ids_with_stats(int argc, VALUE *argv, VALUE self) {
|
|
1177
1227
|
VALUE ids_value, opts;
|
|
1178
1228
|
rb_scan_args(argc, argv, "1:", &ids_value, &opts);
|
|
1229
|
+
SE_CHECK_KEYWORDS(opts, id_max_tokens, id_format, id_threads);
|
|
1179
1230
|
reject_parallel_threads(opts);
|
|
1180
1231
|
|
|
1181
1232
|
se_token_stats_t stats;
|
|
@@ -1315,6 +1366,7 @@ static void topk_check_matrix(VALUE matrix, size_t matrix_bytes, se_vector_forma
|
|
|
1315
1366
|
static VALUE top_k_impl(int argc, VALUE *argv, VALUE self, int cosine) {
|
|
1316
1367
|
VALUE query, matrix, k_val, opts;
|
|
1317
1368
|
rb_scan_args(argc, argv, "3:", &query, &matrix, &k_val, &opts);
|
|
1369
|
+
SE_CHECK_KEYWORDS(opts, id_dim, id_format, id_allow_unfrozen);
|
|
1318
1370
|
(void)self;
|
|
1319
1371
|
|
|
1320
1372
|
Check_Type(query, T_STRING);
|
data/static_embeddings.gemspec
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: static_embeddings
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Roman Haydarov
|
|
@@ -80,7 +80,13 @@ files:
|
|
|
80
80
|
- CHANGELOG.md
|
|
81
81
|
- LICENSE.txt
|
|
82
82
|
- README.md
|
|
83
|
+
- Rakefile
|
|
84
|
+
- benchmark/_support.rb
|
|
85
|
+
- benchmark/core_paths.rb
|
|
86
|
+
- benchmark/gvl_threshold.rb
|
|
83
87
|
- docs/ARCHITECTURE.md
|
|
88
|
+
- docs/BENCHMARKING.md
|
|
89
|
+
- docs/LIMITATIONS.md
|
|
84
90
|
- docs/MODEL_AUDIT.md
|
|
85
91
|
- docs/PERFORMANCE.md
|
|
86
92
|
- exe/static_embeddings
|