static_embeddings 0.1.2 → 0.1.4

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: f72b45c2d6fa5310e3b6d5a9b0a86ab0430fc4af71f6811f9aa02655de6272a8
4
+ data.tar.gz: 51f0bb96a3b0adc5f2799e1df425cf1747ca521d92ae90afba59e0014f1d4106
5
5
  SHA512:
6
- metadata.gz: 71f7a2e68300934fe1362c5b8f546312ced1424dc0616a752400d8d48c9d80a1b82e053592bcf882e24f3a0ba7a10044b7b17a23a2bb5ea6bbe6f8dfcb896e97
7
- data.tar.gz: 957e006602a759274873d894cbaf40a50018af531a0a9cd39806ef5eea8f6159b59b4ebba3e4bc2f8daaf3fe6b89f3ed4eeb9e10d28a62eef1057a6e363226c1
6
+ metadata.gz: 53630b742cd2723656b5d826a82f57042dcaab1e3259758aa6a71eac20ba387a23b3fb3905f0f36f3ce641b122229f366c9af1f029205b44cd82110f538a49cb
7
+ data.tar.gz: 199cd93e9898b7457d5e19b9cf1857b4d78ffc46777ca26778b094fa70ee02559dc90733e4e815eba74297ef9c066940792cef09e022801ef48c704e868fa8db
data/CHANGELOG.md CHANGED
@@ -1,5 +1,166 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.4 (unreleased)
4
+
5
+ Hot-path work in the C runtime. Token ids, the pooling contract and f16
6
+ rounding are unchanged: the ASCII parity sweep and the differential fuzz
7
+ digest against `StaticEmbeddings::Reference` still match. The
8
+ `model2vec.StaticModel` oracle was re-run for 0.1.4 against the same
9
+ `potion-retrieval-32m` snapshot and `.semb` as 0.1.3: 31/31 id rows, 31/31
10
+ vectors, `min_cosine=0.9999999999989528`, `max_abs_all=2.980232238769531e-07`
11
+ — the same printed numbers as 0.1.3. L2 still accumulates sum-of-squares in
12
+ double; 0.1.4 does that with SIMD pairwise adds.
13
+
14
+ Measured on an M1 Pro, `potion-retrieval-32m`, `samples/run_all.sh`
15
+ `DURATION=25`, production build (no alloc-stats). The 0.1.3 comparison run
16
+ had `STATIC_EMBEDDINGS_ALLOC_STATS=1`, which the docs cost at about 3% on
17
+ `embed` and 13% on `tokenize`; differences smaller than that, or inside the
18
+ usual 5–10% laptop spread, are not claimed.
19
+
20
+ ### Changed
21
+
22
+ - **L2 normalisation is a SIMD double reduction plus one scale into `out`.**
23
+ It used to `scale_copy` with `1.0` and then walk the vector twice in scalar
24
+ double. On this model `dim=512` and short in-vocabulary English, that L2
25
+ slot was about 19% of an ASCII `embed_batch` sample tree. The fused path
26
+ has a NEON/`__aarch64__` kernel and an SSE2 kernel; the scalar tail is
27
+ unchanged. ASCII batch went 307k → 338k texts/s on the M1 Pro (+10%). The
28
+ SSE2 path is compiled, not timed.
29
+
30
+ - **The AArch64 f16 top-k kernel decodes 16 halves per iteration, not 8.**
31
+ The x86 F16C kernel is untouched. Same 50 000-row, `k=10`, ASCII matrix:
32
+
33
+ ```text
34
+ f32 f16
35
+ x86-64, f16c 3.00 ms 1.65 ms f16 1.8x faster (0.1.2, F16C)
36
+ M1 Pro, neon-fp16 2.36 ms 1.81 ms f16 1.3x faster (0.1.4)
37
+ ```
38
+
39
+ The M1 Pro row used to read 2.29 / 2.88 ms, f16 1.3x *slower*. `docs/PERFORMANCE.md`
40
+ and the README follow the new numbers. On the lookup-table fallback f16 is
41
+ still usually slower than f32.
42
+
43
+ - **Scratch buffers are reused per OS thread, and freed when the thread
44
+ exits.** `embed`, `tokenize`, `embed_batch` and `embed_token_ids` used to
45
+ `malloc`/`free` a scratch set on every call. A first cut of 0.1.4 used
46
+ `__thread` storage with no destructor, which leaked the heap buffers inside
47
+ the slot — bounded for a Puma pool, unbounded on the fiber-scheduler path
48
+ that `rb_thread_create`s one OS thread per large call. The slot is now a
49
+ `pthread_key` / `FlsAlloc` value whose destructor frees it. On release the
50
+ slot is trimmed back to the reserve sizes, so one `max_tokens: false`
51
+ document does not pin megabytes on that thread until process exit.
52
+ `tokenize` and the small `embed` path release the slot through `rb_ensure`,
53
+ so `rb_ary_push` raising cannot leave `in_use` stuck. `memory_smoke` calls
54
+ acquire/release so ASan/valgrind actually see this code.
55
+
56
+ - **ASCII WordPiece copies an all-ASCII word as bytes** instead of
57
+ encoding UTF-8 through the codepoint buffer, then hashes and splits on the
58
+ trie with identity offsets. Output is the same ids. On the OOV tokenize
59
+ probes this is inside the run-to-run floor.
60
+
61
+ - **Latin-1 (`U+0080..U+00FF`) lower/NFD/Mn/P/C/Zs tables are built at
62
+ load.** Codepoints below 256 skip the binary search into the mmap'd maps.
63
+ ASCII (`< 0x80`) still uses the existing 128-entry class table. Not visible
64
+ as texts/s on the English batch corpus.
65
+
66
+ - **Vocabulary compare is an inline unaligned equality test** instead of
67
+ libc `memcmp`. Trie child lookup special-cases one- and two-edge nodes
68
+ before the linear/binary search. Same ids; no measured throughput claim.
69
+
70
+ Two things were measured and not shipped. Unrolling `add_row` to 32 floats
71
+ and stretching the prefetch distance to 8 cost about 13% on
72
+ `random_pooling_hot_path` — that loop is already memory-bound on random 2 KiB
73
+ row gathers. Walking the ASCII trie without `cps2` (`start += matched_len`)
74
+ cost about 38% on the OOV tokenize probes.
75
+
76
+ ### Added
77
+
78
+ - **Batch samples print `mean_tokens_per_text`, `unk_ratio` and
79
+ `tokens_per_sec`.** 307k vs 103k texts/s is not readable without token
80
+ density: on this corpus ASCII is 31 tokens/text and 0 unk, hashes 73 and 0,
81
+ unicode 68 and 4.4% unk.
82
+
83
+ - **`test/scratch_tls_test.rb`.** When the instrumented allocator is loaded:
84
+ scratch bytes plateau across eight generations of helper threads, and a
85
+ large `max_tokens: false` embed must not leave the calling thread's slot
86
+ pinned. The `alloc_stats` CI job is what actually runs it.
87
+
88
+ ## 0.1.3 (unreleased)
89
+
90
+ Hardening and tooling, mostly borrowed from the sibling `tg_geometry` gem after
91
+ reading its C extension side by side with this one. No behaviour changes to
92
+ tokenization, pooling or search: the differential fuzz digest against
93
+ `StaticEmbeddings::Reference` is unchanged.
94
+
95
+ ### Fixed
96
+
97
+ - **Unknown keywords are rejected instead of ignored.** `rb_hash_lookup2`
98
+ returns the default for an absent key, so every misspelled keyword used to be
99
+ silently dropped and the caller got a plausible wrong answer. Two cases were
100
+ actively dangerous: `embed(text, fromat: :f16)` returned a full-width f32
101
+ vector, and `embed_batch(texts, threds: 4)` slipped past the guard whose only
102
+ job is to say that `threads:` buys nothing. All eight keyword-accepting
103
+ methods now validate against a whitelist and name the accepted keywords in the
104
+ error.
105
+
106
+ ```ruby
107
+ model.embed("hi", fromat: :f16)
108
+ # ArgumentError: unknown keyword: :fromat
109
+ # (accepted: :max_tokens, :format, :validate_encoding, :threads)
110
+ ```
111
+
112
+ - **`memsize` no longer charges the mapped model to the Ruby heap.** A mapped
113
+ `.semb` is file-backed, shared between forked workers and reclaimable by the
114
+ kernel, but `ObjectSpace.memsize_of(model)` reported its full size, which fed
115
+ GC heuristics memory the VM could neither free nor account for. It now reports
116
+ only process-owned bytes; `mapped_bytes` still reports the mapping. The
117
+ Windows heap fallback really is process memory and is still counted.
118
+
119
+ ### Added
120
+
121
+ - **`benchmark/` with a shared harness.** Iteration counts are calibrated so
122
+ every measurement runs for at least `BENCH_MIN_SECONDS`, repeats are taken,
123
+ the median is reported, and every row carries `spread_pct` — the best-to-worst
124
+ distance across repeats. This release exists partly because a 6% throughput
125
+ claim in 0.1.2 turned out to be inside that spread. `BENCH_FORMAT=kv` gives
126
+ machine-readable output. Run with `rake benchmark`. Inputs are
127
+ `valid_encoding?`-primed, so rows report `coderange=cached` and measure the
128
+ hot path rather than a string fresh from a file or a database.
129
+
130
+ - **`benchmark/gvl_threshold.rb`.** `SE_GVL_UNLOCK_THRESHOLD` has never been
131
+ justified by data. The sweep measures both halves of the trade: on one x86-64
132
+ run the step at 2048 bytes cost about 32% latency and four extra allocations,
133
+ and it is also the point where a second Ruby thread starts making progress at
134
+ all. The constant is unchanged; now there is something to argue from.
135
+
136
+ - **`benchmark/core_paths.rb`,** covering tokenize, single embed, batch embed,
137
+ pooling in isolation and top-k in both formats. It reports single-text embed
138
+ twice, once repeating one string and once rotating a corpus, because the gap
139
+ between them is the cache effect that makes hot-loop numbers unreproducible.
140
+
141
+ - **`test/gc_compaction_test.rb` and a `gc_compaction` CI job.** `top_k`,
142
+ `embed` and `embed_batch` hand raw pointers into C and release the GVL, so
143
+ compaction moving Ruby objects would show up as wrong numbers rather than a
144
+ crash.
145
+
146
+ Getting this test to test anything took two corrections. It first used a
147
+ 64-row matrix, which is 2 KiB against a 1 MiB `SE_TOPK_GVL_UNLOCK_THRESHOLD`,
148
+ so the scan never released the GVL and compaction could not interleave with
149
+ it. The same mistake then turned out to be hiding in the embed case: 2038
150
+ bytes of corpus against a 2048-byte `SE_GVL_UNLOCK_THRESHOLD`, ten bytes
151
+ under. Measured with a spinning neighbour thread, both old cases produced zero
152
+ ticks during the call and both fixed ones produce hundreds of millions. Every
153
+ concurrency case now asserts it is over the relevant threshold, because
154
+ without that assertion the test silently stops testing the moment a constant
155
+ or a fixture changes.
156
+
157
+ - **`docs/LIMITATIONS.md`.** One page answering "does it do X", split into
158
+ decisions and unfinished work: no ANN index, no batched queries, no candidate
159
+ filtering, no int8, no Ractor, no internal parallelism, and so on.
160
+
161
+ - **`docs/BENCHMARKING.md`,** covering how to read `spread_pct` and the four
162
+ measurement traps this repository has already fallen into.
163
+
3
164
  ## 0.1.2 (unreleased)
4
165
 
5
166
  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,15 +124,19 @@ 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`,
129
133
  `embed_token_ids_with_stats`, `pack`, `unpack`, and the top-k helpers.
130
134
 
131
- Choose `f16` for the RAM and storage it saves, not for speed. It halves the
132
- bytes a top-k scan streams, but every row still has to be decoded before
133
- scoring, and which effect wins is a property of the machine — this repository's
134
- own samples show `f16` winning on x86-64 F16C and losing on M1 Pro. See
135
+ Choose `f16` for the RAM and storage it saves; speed is a property of the
136
+ machine and of the decode kernel. It halves the bytes a top-k scan streams, but
137
+ every row still has to be decoded before scoring — this repository's own
138
+ samples show `f16` winning on x86-64 F16C and, as of 0.1.4, on M1 Pro neon-fp16
139
+ as well. On the lookup-table fallback it usually loses. See
135
140
  `docs/PERFORMANCE.md` before assuming either.
136
141
 
137
142
  `StaticEmbeddings.simd_backend` reports the live kernel: `"neon-fp16"`,
@@ -300,6 +305,8 @@ a test fixture and API demo, not a retrieval quality baseline.
300
305
 
301
306
  ```bash
302
307
  ruby tools/benchmark.rb # normalised performance budget
308
+ bundle exec rake benchmark # benchmark/, see docs/BENCHMARKING.md
309
+ bundle exec rake gc_compaction # GC.compact hardening
303
310
  bundle exec rake cancellation_timing # timing-sensitive, excluded from rake test
304
311
  samples/run_all.sh # native hot-path probes; see samples/README.md
305
312
  ```
@@ -317,7 +324,7 @@ cc -O2 -std=c99 -Wall -Wextra -Iext/static_embeddings \
317
324
  ext/static_embeddings/se_unicode.c \
318
325
  ext/static_embeddings/se_tokenizer.c \
319
326
  ext/static_embeddings/se_embed.c \
320
- -lm -o tmp/memory_smoke
327
+ -lm -pthread -o tmp/memory_smoke
321
328
  ./tmp/memory_smoke tmp/test-tiny.semb
322
329
  ```
323
330
 
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
@@ -174,10 +174,13 @@ storage/transport choice, not a different model. `embed_array` and
174
174
 
175
175
  The f32 compute path uses small explicit SIMD kernels where they keep the format
176
176
  simple: row accumulation and scaling use NEON/SSE when the compiler target
177
- exposes them, with scalar fallback everywhere else. `dot_top_k`/`cosine_top_k`
177
+ exposes them, L2 sum-of-squares uses SIMD double (NEON on AArch64, SSE2 on
178
+ x86) so the inverse stays a `1/sqrt` of a double accumulation, and there is a
179
+ scalar fallback everywhere else. `dot_top_k`/`cosine_top_k`
178
180
  share the SIMD dot kernel for `format: :f32`; `cosine_top_k` additionally
179
181
  accumulates each row's sum of squares so it can divide by the true norms.
180
- `format: :f16` top-k decodes half components on the fly. What is still
182
+ `format: :f16` top-k decodes half components on the fly (16-wide on AArch64
183
+ NEON-FP16, 16-wide on x86 F16C). What is still
181
184
  deliberately absent is a dependency on BLAS: pooling is gathering random
182
185
  embedding rows, not a dense matrix multiply.
183
186
 
@@ -293,8 +296,13 @@ cadence in bytes so a long ASCII run is no less interruptible. The pooling loop
293
296
  checks every 256 rows, and the top-k scan every 1024 rows. `unblock_cancel` sets that flag when Ruby
294
297
  interrupts a GVL-free region.
295
298
 
296
- No global mutable state exists in C. The model is immutable after load and
297
- scratch buffers are per call.
299
+ No global mutable state exists in C. The model is immutable after load.
300
+ Scratch buffers are reused per OS thread via `pthread_key` / `FlsAlloc`, and
301
+ the destructor frees them when the thread exits — including the short-lived
302
+ threads the fiber scheduler path creates per large call. On release the slot
303
+ is trimmed back to the reserve sizes, so a single unlimited-`max_tokens`
304
+ document does not pin its working set until the thread dies. A nested call on
305
+ the same thread allocates a one-off heap scratch that is freed on release.
298
306
 
299
307
  ## Where this fits in a RAG pipeline
300
308