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 +4 -4
- data/CHANGELOG.md +161 -0
- data/README.md +13 -6
- data/Rakefile +95 -0
- data/benchmark/_support.rb +196 -0
- data/benchmark/core_paths.rb +57 -0
- data/benchmark/gvl_threshold.rb +48 -0
- data/docs/ARCHITECTURE.md +12 -4
- data/docs/BENCHMARKING.md +87 -0
- data/docs/LIMITATIONS.md +93 -0
- data/docs/MODEL_AUDIT.md +27 -13
- data/docs/PERFORMANCE.md +36 -19
- data/ext/static_embeddings/se_embed.c +46 -7
- data/ext/static_embeddings/se_f16.c +43 -7
- data/ext/static_embeddings/se_format.c +83 -5
- data/ext/static_embeddings/se_internal.h +9 -0
- data/ext/static_embeddings/se_tokenizer.c +301 -34
- data/ext/static_embeddings/static_embeddings.c +157 -47
- data/lib/static_embeddings/version.rb +1 -1
- data/static_embeddings.gemspec +2 -0
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f72b45c2d6fa5310e3b6d5a9b0a86ab0430fc4af71f6811f9aa02655de6272a8
|
|
4
|
+
data.tar.gz: 51f0bb96a3b0adc5f2799e1df425cf1747ca521d92ae90afba59e0014f1d4106
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
132
|
-
bytes a top-k scan streams, but
|
|
133
|
-
|
|
134
|
-
|
|
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,
|
|
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
|
|
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
|
|
297
|
-
|
|
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
|
|