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.
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` walks the relevant trie once, records the longest
94
- terminal node and emits that token id. This matches the shape of WordPiece much
95
- better than exact hash lookup: longest-prefix matching is one forward pass
96
- rather than many candidate hashes and memcmps.
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 pooling loop checks every 256 rows,
243
- and the top-k scan every 1024 rows. `unblock_cancel` sets that flag when Ruby
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.
@@ -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.