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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 250b847231ef37114e5c1c6b12076565eaa42d6193c305b0b107b424b12b3397
4
- data.tar.gz: e59f63626b715759a286c25949b3c03bed78bc58744085716b96cd96a8fa8f98
3
+ metadata.gz: 7ec20ad222235414c2e6f081a2cea5f932a7e3004cb78dcca5552c20208bd6a8
4
+ data.tar.gz: 80553b44c62b46625562accee74ac9bfe689400f05258e551dc9603105f64f7a
5
5
  SHA512:
6
- metadata.gz: 71f7a2e68300934fe1362c5b8f546312ced1424dc0616a752400d8d48c9d80a1b82e053592bcf882e24f3a0ba7a10044b7b17a23a2bb5ea6bbe6f8dfcb896e97
7
- data.tar.gz: 957e006602a759274873d894cbaf40a50018af531a0a9cd39806ef5eea8f6159b59b4ebba3e4bc2f8daaf3fe6b89f3ed4eeb9e10d28a62eef1057a6e363226c1
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.
@@ -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** | required before release |
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. Only the oracle can, and the oracle now carries DEL
20
- and control-character rows it did not have when the result below was recorded —
21
- so `rows=28` would itself be evidence of a stale run.
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: `28`
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.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=28
54
- id_rows_checked=28
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.1**. The `.semb` file is
73
- unchanged and its SHA256 still matches; what expired is the runtime half of the
74
- record.
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.
@@ -845,5 +845,8 @@ size_t se_model_memsize(const se_model_t *model) {
845
845
  if (!model)
846
846
  return 0;
847
847
 
848
+ if (model->mapped)
849
+ return sizeof(se_model_t);
850
+
848
851
  return sizeof(se_model_t) + model->map_size;
849
852
  }
@@ -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);
@@ -1,3 +1,3 @@
1
1
  module StaticEmbeddings
2
- VERSION = "0.1.2"
2
+ VERSION = "0.1.3"
3
3
  end
@@ -25,8 +25,10 @@ Gem::Specification.new do |spec|
25
25
  "lib/models/*.semb",
26
26
  "exe/*",
27
27
  "ext/**/*.{c,h,rb}",
28
+ "benchmark/**/*.rb",
28
29
  "docs/**/*.md",
29
30
  "tools/*.rb",
31
+ "Rakefile",
30
32
  "static_embeddings.gemspec",
31
33
  "README.md",
32
34
  "CHANGELOG.md",
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.2
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