static_embeddings 0.1.1
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 +7 -0
- data/CHANGELOG.md +136 -0
- data/LICENSE.txt +21 -0
- data/README.md +439 -0
- data/docs/ARCHITECTURE.md +278 -0
- data/docs/MODEL_AUDIT.md +61 -0
- data/docs/PERFORMANCE.md +60 -0
- data/exe/static_embeddings +6 -0
- data/ext/static_embeddings/extconf.rb +44 -0
- data/ext/static_embeddings/se_embed.c +192 -0
- data/ext/static_embeddings/se_format.c +762 -0
- data/ext/static_embeddings/se_internal.h +266 -0
- data/ext/static_embeddings/se_tokenizer.c +506 -0
- data/ext/static_embeddings/se_unicode.c +120 -0
- data/ext/static_embeddings/static_embeddings.c +2070 -0
- data/lib/static_embeddings/cli.rb +178 -0
- data/lib/static_embeddings/converter.rb +284 -0
- data/lib/static_embeddings/errors.rb +6 -0
- data/lib/static_embeddings/format.rb +289 -0
- data/lib/static_embeddings/model.rb +48 -0
- data/lib/static_embeddings/paths.rb +29 -0
- data/lib/static_embeddings/reference.rb +191 -0
- data/lib/static_embeddings/safetensors.rb +87 -0
- data/lib/static_embeddings/unicode_tables.rb +127 -0
- data/lib/static_embeddings/version.rb +3 -0
- data/lib/static_embeddings.rb +118 -0
- data/static_embeddings.gemspec +45 -0
- data/tools/benchmark.rb +38 -0
- data/tools/build_demo_model.rb +16 -0
- data/tools/check_model2vec_parity.rb +107 -0
- data/tools/make_fixture_model.rb +165 -0
- metadata +134 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
## The shape of the thing
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
HuggingFace / Model2Vec files offline, once, on your machine
|
|
7
|
+
tokenizer.json, config.json,
|
|
8
|
+
model.safetensors
|
|
9
|
+
|
|
|
10
|
+
v
|
|
11
|
+
Converter (pure Ruby, strict) <--- all parsing, all validation, all
|
|
12
|
+
| Unicode table generation happens here
|
|
13
|
+
v
|
|
14
|
+
model.semb <--- flat, versioned, mmap-able
|
|
15
|
+
|
|
|
16
|
+
v
|
|
17
|
+
C runtime <--- mmap + bounds checks + tokenize +
|
|
18
|
+
(no JSON, no safetensors, lookup + pool. Nothing else.
|
|
19
|
+
no Unicode database, no net)
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The single most important decision: **the runtime reads only our own format.**
|
|
23
|
+
Everything expensive, fragile or security-sensitive about reading third-party
|
|
24
|
+
model files happens once, offline, in a language where it is easy to get
|
|
25
|
+
right. What remains in C is a bounds-checked mmap and three loops.
|
|
26
|
+
|
|
27
|
+
## `.semb` v2
|
|
28
|
+
|
|
29
|
+
Little-endian throughout. 320-byte header, then sections aligned to 64 bytes.
|
|
30
|
+
|
|
31
|
+
| Offset | Size | Field |
|
|
32
|
+
|---|---|---|
|
|
33
|
+
| 0 | 8 | magic `SEMBv1\0\0` |
|
|
34
|
+
| 8 | 4 | format_version |
|
|
35
|
+
| 12 | 4 | header_size (320) |
|
|
36
|
+
| 16 | 4 | flags |
|
|
37
|
+
| 20 | 4 | dim |
|
|
38
|
+
| 24 | 4 | vocab_size |
|
|
39
|
+
| 28 | 4 | tokenizer_type (1 = BERT_WORDPIECE_V1) |
|
|
40
|
+
| 32 | 4 | embedding_dtype (1 = F32) |
|
|
41
|
+
| 36 | 4 | pooling_type (1 = MEAN) |
|
|
42
|
+
| 40 | 4 | normalization_type (0 none, 1 = L2) |
|
|
43
|
+
| 44 | 4 | **max_tokens_default** |
|
|
44
|
+
| 48 | 4 | truncation_policy |
|
|
45
|
+
| 52 | 4 | add_special_tokens |
|
|
46
|
+
| 56 | 4 | unk_policy (0 include, 1 drop) |
|
|
47
|
+
| 60 | 4 | empty_policy (0 zero vector, 1 raise) |
|
|
48
|
+
| 64 | 4 | do_lower_case |
|
|
49
|
+
| 68 | 4 | strip_accents |
|
|
50
|
+
| 72 | 4 | handle_chinese_chars |
|
|
51
|
+
| 76 | 4 | clean_text |
|
|
52
|
+
| 80 | 4 | max_input_chars_per_word |
|
|
53
|
+
| 84–100 | 20 | pad/unk/cls/sep/mask ids |
|
|
54
|
+
| 104 | 4 | hash_table_size (power of two) |
|
|
55
|
+
| 108 | 4 | hash_seed |
|
|
56
|
+
| 112 | 4 | subword_prefix_len |
|
|
57
|
+
| 116 | 8 | subword_prefix bytes |
|
|
58
|
+
| 124 | 4 | max_token_chars |
|
|
59
|
+
| 128–207 | 80 | five (offset, size) u64 pairs: `vocab_strings`, `vocab_hash`, `embeddings`, `norm_tables`, `provenance` |
|
|
60
|
+
| 208–239 | 32 | two (offset, size) u64 pairs: `root_trie`, `continuation_trie` |
|
|
61
|
+
| 240 | 32 | sha256 of the file with these 32 bytes zeroed |
|
|
62
|
+
| 304 | 4 | max_probe observed in the vocabulary hash |
|
|
63
|
+
|
|
64
|
+
Sections, in fixed order: `vocab_strings`, `vocab_hash`, `embeddings`,
|
|
65
|
+
`norm_tables`, `provenance`, `root_trie`, `continuation_trie`.
|
|
66
|
+
|
|
67
|
+
Extension points are the enums and `format_version`, not stub code. `dtype`
|
|
68
|
+
already has a slot for I8; there is no dead I8 path in C.
|
|
69
|
+
|
|
70
|
+
### Why the semantics live in the header
|
|
71
|
+
|
|
72
|
+
`max_tokens_default`, `unk_policy`, `empty_policy` and the normalizer flags
|
|
73
|
+
are properties of the *model*, not options the caller guesses. The reference
|
|
74
|
+
implementation truncates at 512 tokens; a model card's `model_max_length` of
|
|
75
|
+
1,000,000 is a different number entirely, and confusing them silently changes
|
|
76
|
+
every vector for long chunks. Anything that can silently change output is a
|
|
77
|
+
recorded field.
|
|
78
|
+
|
|
79
|
+
## Vocabulary lookup and WordPiece trie
|
|
80
|
+
|
|
81
|
+
The converter still writes a finished open-addressing hash table into the
|
|
82
|
+
file: 16-byte slots of `{hash, str_off, str_len, token_id}`, power-of-two size,
|
|
83
|
+
load factor ≤ 0.70, linear probing, FNV-1a with a fixed seed. The hash table is
|
|
84
|
+
kept for validation, direct lookup and debugging.
|
|
85
|
+
|
|
86
|
+
The hot WordPiece path no longer performs repeated hash lookups for every
|
|
87
|
+
`end--` candidate. Format v2 also stores two converter-built, mmap-readable
|
|
88
|
+
tries inspired by double-array trie libraries such as libdatrie:
|
|
89
|
+
|
|
90
|
+
- `root_trie` for tokens that can start a word;
|
|
91
|
+
- `continuation_trie` for `##token` entries stored without the `##` prefix.
|
|
92
|
+
|
|
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.
|
|
97
|
+
|
|
98
|
+
Loading a model still performs **zero runtime insertions**. It is `mmap` plus a
|
|
99
|
+
validation pass over the hash table, trie node ranges and trie edge ordering.
|
|
100
|
+
The vocabulary structures live in the page cache, shared between every forked
|
|
101
|
+
Puma worker.
|
|
102
|
+
|
|
103
|
+
Rejected alternatives:
|
|
104
|
+
|
|
105
|
+
- **khash / any runtime hash map.** Would rebuild 250k entries at every boot
|
|
106
|
+
and allocate outside the mapping — exactly what the offline format exists
|
|
107
|
+
to avoid.
|
|
108
|
+
- **Sorted table + binary search.** ~18 cache misses per probe at 250k
|
|
109
|
+
entries versus one or two for open addressing.
|
|
110
|
+
- **Vendored trie runtime.** MARISA, cedar and libdatrie are useful references,
|
|
111
|
+
but the runtime needs a small `.semb` section that can be bounds-checked and
|
|
112
|
+
mmap-read directly, not an external object format or system dependency.
|
|
113
|
+
|
|
114
|
+
## Unicode without a Unicode library
|
|
115
|
+
|
|
116
|
+
`BertNormalizer` needs NFD, simple lowercase, and the Mn / P / C / Zs
|
|
117
|
+
categories. Rather than vendoring utf8proc, the converter generates exactly
|
|
118
|
+
those tables from the Ruby VM's own Unicode data and writes them into the
|
|
119
|
+
model file; the runtime binary-searches them.
|
|
120
|
+
|
|
121
|
+
This removes the last third-party dependency, and it sidesteps a specific
|
|
122
|
+
trap: utf8proc's case *folding* is not the same as case *lowering*. Folding
|
|
123
|
+
maps `ß` to `ss`; `str::to_lowercase`, which HF uses, does not. A gem that
|
|
124
|
+
picked the wrong one would produce plausible, slightly wrong vectors forever.
|
|
125
|
+
|
|
126
|
+
The Ruby build that generated the tables is stamped into provenance, so a
|
|
127
|
+
model file and its normalizer can never drift apart unnoticed.
|
|
128
|
+
|
|
129
|
+
## The embedding kernel
|
|
130
|
+
|
|
131
|
+
```
|
|
132
|
+
tokenize -> ids
|
|
133
|
+
truncate to max_tokens (before pooling — the reference contract)
|
|
134
|
+
drop [UNK] if unk_policy = DROP
|
|
135
|
+
acc[j] += row[j] for each id, in token order
|
|
136
|
+
divide by the number of pooled rows
|
|
137
|
+
L2 normalize if the model says so
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Deliberately absent:
|
|
141
|
+
|
|
142
|
+
- **Deduplicating repeated token ids** and multiplying by a count. It saves
|
|
143
|
+
memory traffic and changes the float rounding, in the one place where we are
|
|
144
|
+
trying to stay inside 1e-6 of a reference. Not worth it.
|
|
145
|
+
|
|
146
|
+
The runtime computes embeddings in float32. Returned storage is selected by the
|
|
147
|
+
caller's `format:` option: `:f32` returns the native 4-byte components and `:f16`
|
|
148
|
+
encodes the same vector as little-endian IEEE float16 components. This is a
|
|
149
|
+
storage/transport choice, not a different model. `embed_array` and
|
|
150
|
+
`embed_batch_arrays` decode whichever storage format was requested back to Ruby
|
|
151
|
+
`Float` values.
|
|
152
|
+
|
|
153
|
+
The f32 compute path uses small explicit SIMD kernels where they keep the format
|
|
154
|
+
simple: row accumulation and scaling use NEON/SSE when the compiler target
|
|
155
|
+
exposes them, with scalar fallback everywhere else. `dot_top_k`/`cosine_top_k`
|
|
156
|
+
share the SIMD dot kernel for `format: :f32`; `cosine_top_k` additionally
|
|
157
|
+
accumulates each row's sum of squares so it can divide by the true norms.
|
|
158
|
+
`format: :f16` top-k decodes half components on the fly. What is still
|
|
159
|
+
deliberately absent is a dependency on BLAS: pooling is gathering random
|
|
160
|
+
embedding rows, not a dense matrix multiply.
|
|
161
|
+
|
|
162
|
+
A matrix at or above `SE_TOPK_GVL_UNLOCK_THRESHOLD` (1 MiB) is scanned with the
|
|
163
|
+
GVL released and must therefore be frozen. The alternatives were both bad:
|
|
164
|
+
copying a corpus-sized blob on every query defeats the purpose of the API, and
|
|
165
|
+
`rb_str_locktmp` is an exclusive lock, so it turns a read-only shared corpus
|
|
166
|
+
into something exactly one thread at a time may search. Freezing removes the
|
|
167
|
+
mutation hazard outright and keeps the scan lock-free. Because a raw blob has no
|
|
168
|
+
dimension or format tag, `dim:` is required and the query length is checked
|
|
169
|
+
against it; that is what stops an f32 query and an f16 matrix from dividing out
|
|
170
|
+
to a consistent-looking row count.
|
|
171
|
+
|
|
172
|
+
Top-k insertion uses `!(score > worst)` rather than `score <= worst` so that a
|
|
173
|
+
`NaN` score is skipped. With the naive comparison a single `NaN` row would fall
|
|
174
|
+
through the early exit, settle at the last slot and then make every subsequent
|
|
175
|
+
comparison false, turning an O(1) reject into an O(k) insert for the rest of the
|
|
176
|
+
scan and leaking `NaN` into the result.
|
|
177
|
+
|
|
178
|
+
## Large inputs and the prefix window
|
|
179
|
+
|
|
180
|
+
Ruby strings must be copied into C-owned memory before the GVL is released, and
|
|
181
|
+
that copy runs with the GVL held. Copying a whole multi-megabyte document
|
|
182
|
+
therefore serialised the process on work that the tokenizer would discard after
|
|
183
|
+
`max_tokens` anyway.
|
|
184
|
+
|
|
185
|
+
`embed` and `embed_batch` instead copy a leading slice. The budget starts at
|
|
186
|
+
`max_tokens * 16` bytes clamped to `[4096, 65536]`, and the slice is cut at a
|
|
187
|
+
position where the tokenizer would have started a fresh word. A result is
|
|
188
|
+
accepted only when that slice actually reached `max_tokens`; otherwise the
|
|
189
|
+
budget doubles and the text is tokenized again. Growth is geometric, so the
|
|
190
|
+
pathological case costs about twice the single-pass work.
|
|
191
|
+
|
|
192
|
+
Three properties make this exact rather than approximate:
|
|
193
|
+
|
|
194
|
+
- The scan only stops on UTF-8 lead bytes, so the cut always lands on a
|
|
195
|
+
codepoint boundary.
|
|
196
|
+
- WordPiece segmentation is word-local, so the token stream of a prefix cut at a
|
|
197
|
+
word boundary is a prefix of the token stream of the whole document.
|
|
198
|
+
- Normalization carries no state across a word boundary: combining marks are
|
|
199
|
+
dropped independently, CJK characters are wrapped in spaces individually, and
|
|
200
|
+
control characters are deleted pointwise.
|
|
201
|
+
|
|
202
|
+
The boundary predicate is `se_prefix_boundary_len`, and it lives in
|
|
203
|
+
`se_tokenizer.c` next to the tokenizer so it can call the very same
|
|
204
|
+
`is_whitespace` / `is_punct` / `se_is_cjk` predicates rather than a parallel
|
|
205
|
+
copy of them. A position is a legal cut when the codepoint ending there is
|
|
206
|
+
whitespace or punctuation, or when either side of it is a CJK codepoint - CJK is
|
|
207
|
+
space-wrapped by `emit_cleaned`, so both of its edges are word boundaries.
|
|
208
|
+
Codepoints that the normaliser rewrites (lowercase map, NFD map, dropped
|
|
209
|
+
combining marks) are skipped: their class after normalisation is not necessarily
|
|
210
|
+
their class before it.
|
|
211
|
+
|
|
212
|
+
Restricting the cut to ASCII, as 0.1.0 did, was correct but degenerate: a CJK or
|
|
213
|
+
NBSP-separated document has no ASCII byte anywhere, so the budget grew to the
|
|
214
|
+
full length and the whole input was copied under the GVL after all. That is why
|
|
215
|
+
the predicate is Unicode-aware. Text with genuinely no legal cut inside the scan
|
|
216
|
+
window - one enormous word, a run of combining marks - still falls back to a
|
|
217
|
+
full copy, because there is nothing else that would be correct.
|
|
218
|
+
|
|
219
|
+
`test/prefix_chunking_test.rb` sweeps every printable ASCII separator, checks
|
|
220
|
+
CJK, NBSP and Unicode-punctuation documents against
|
|
221
|
+
`embed_token_ids(tokenize(text))`, and asserts that quadrupling the input does
|
|
222
|
+
not triple the cost.
|
|
223
|
+
|
|
224
|
+
With `max_tokens: false` there is nothing to truncate and the whole input is
|
|
225
|
+
copied.
|
|
226
|
+
|
|
227
|
+
## Concurrency
|
|
228
|
+
|
|
229
|
+
Small calls run inline. Large `embed_batch` calls copy Ruby input into C-owned
|
|
230
|
+
memory and then run outside the GVL. This is enough for Puma's threaded model:
|
|
231
|
+
one request can compute embeddings while other Ruby threads keep running.
|
|
232
|
+
|
|
233
|
+
When a fiber scheduler is installed, large batches are handed to one Ruby
|
|
234
|
+
thread before the caller waits, so the reactor is not pinned by the C loop. This
|
|
235
|
+
costs one OS thread per call, so under high concurrency batching beats many
|
|
236
|
+
small `embed` calls. The runtime intentionally does not expose internal
|
|
237
|
+
`threads:` parallelism; offline indexing should split work at the
|
|
238
|
+
job/application layer.
|
|
239
|
+
|
|
240
|
+
Cancellation is cooperative: the tokenizer checks a flag every 1024 codepoints
|
|
241
|
+
(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
|
|
244
|
+
interrupts a GVL-free region.
|
|
245
|
+
|
|
246
|
+
No global mutable state exists in C. The model is immutable after load and
|
|
247
|
+
scratch buffers are per call.
|
|
248
|
+
|
|
249
|
+
## Where this fits in a RAG pipeline
|
|
250
|
+
|
|
251
|
+
Dense retrieval with static embeddings is a weaker first stage than a
|
|
252
|
+
transformer. The cheap fix is fusion, not a bigger model: BM25 and dense
|
|
253
|
+
vectors fail in different ways — BM25 misses paraphrases, dense misses rare
|
|
254
|
+
identifiers and exact phrases — and Reciprocal Rank Fusion over both recovers
|
|
255
|
+
most of the difference at zero additional cost, since SQLite's FTS5 gives you
|
|
256
|
+
BM25 for free.
|
|
257
|
+
|
|
258
|
+
```
|
|
259
|
+
chunks -> FTS5 (BM25) ---\
|
|
260
|
+
>-- RRF --> top 20 -> optional cross-encoder rerank
|
|
261
|
+
chunks -> .semb vectors --/
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
## Open questions before v1.0
|
|
265
|
+
|
|
266
|
+
1. **Which potion models actually match `BERT_WORDPIECE_V1`.** The 32M family
|
|
267
|
+
has a larger vocabulary than the bge-base tokenizer it was distilled from,
|
|
268
|
+
which means tokens were added somewhere. If they live in `added_tokens`
|
|
269
|
+
rather than `model.vocab`, HF matches them with a separate trie pass over
|
|
270
|
+
raw text that this runtime does not implement. The converter refuses such
|
|
271
|
+
models today; whether it has to is an audit question.
|
|
272
|
+
2. **Russian.** Distilling a multilingual teacher into a WordPiece vocabulary
|
|
273
|
+
we control keeps the pure-C path, but skips the Tokenlearn pre-training
|
|
274
|
+
that gives the published potion models their quality. That gap has to be
|
|
275
|
+
measured before the multilingual story is real.
|
|
276
|
+
3. **int8.** The format has the slot; the runtime does not have the path.
|
|
277
|
+
f32 stays the reference forever regardless; f16 is only a returned storage
|
|
278
|
+
encoding.
|
data/docs/MODEL_AUDIT.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Model Audit
|
|
2
|
+
|
|
3
|
+
This document records the trust and parity status of converted `.semb` models.
|
|
4
|
+
A converted production model is accepted only after parity against the upstream
|
|
5
|
+
Python `model2vec.StaticModel` implementation is recorded here.
|
|
6
|
+
|
|
7
|
+
## potion-retrieval-32m
|
|
8
|
+
|
|
9
|
+
Status: audited / accepted
|
|
10
|
+
|
|
11
|
+
Source model:
|
|
12
|
+
|
|
13
|
+
- Hugging Face repository: `minishlab/potion-retrieval-32M`
|
|
14
|
+
- Oracle implementation: `model2vec.StaticModel.from_pretrained`
|
|
15
|
+
- Python package: `model2vec 0.9.0`
|
|
16
|
+
- Oracle file: `tmp/model2vec_oracle.json`
|
|
17
|
+
- Oracle rows: `28`
|
|
18
|
+
- Oracle dimension: `512`
|
|
19
|
+
- Oracle max length: `512`
|
|
20
|
+
- Runtime checked with: `static_embeddings 0.1.1`
|
|
21
|
+
- Runtime source note: re-run parity after changing tokenizer, normalizer, prefix-window, pooling, or output-normalization code.
|
|
22
|
+
|
|
23
|
+
Converted `.semb`:
|
|
24
|
+
|
|
25
|
+
- Path: `$HOME/.cache/static_embeddings/models/potion-retrieval-32m.semb`
|
|
26
|
+
- Format version: `2`
|
|
27
|
+
- Header size: `320`
|
|
28
|
+
- Bytes: `135411608`
|
|
29
|
+
- SHA256: `79e087863d2bab825779fd7de3574e5625542ef6a5ecad33fe681ea16d4b3ab0`
|
|
30
|
+
|
|
31
|
+
Parity command:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
bundle exec rake parity \
|
|
35
|
+
MODEL="$HOME/.cache/static_embeddings/models/potion-retrieval-32m.semb" \
|
|
36
|
+
ORACLE=tmp/model2vec_oracle.json
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Parity result:
|
|
40
|
+
|
|
41
|
+
```text
|
|
42
|
+
rows=28
|
|
43
|
+
id_rows_checked=28
|
|
44
|
+
min_cosine=0.9999999999989528
|
|
45
|
+
max_abs_all=2.980232238769531e-07
|
|
46
|
+
token_id_failures=[]
|
|
47
|
+
vector_failures=[]
|
|
48
|
+
parity OK
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Decision:
|
|
52
|
+
|
|
53
|
+
- Tokenization parity: pass
|
|
54
|
+
- Vector parity: pass
|
|
55
|
+
- Empty input behavior: pass
|
|
56
|
+
- Whitespace behavior: pass
|
|
57
|
+
- Unicode normalization behavior: pass
|
|
58
|
+
- Unknown-word behavior: pass
|
|
59
|
+
- Long input / truncation behavior: pass
|
|
60
|
+
|
|
61
|
+
This converted model is accepted for runtime and benchmark use.
|
data/docs/PERFORMANCE.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Performance budget
|
|
2
|
+
|
|
3
|
+
Absolute latency numbers are not comparable across models: `potion-base-2M`
|
|
4
|
+
and `potion-retrieval-32M` differ by roughly 8x in row width, so swapping the
|
|
5
|
+
model would read as a regression. The budget is therefore normalised.
|
|
6
|
+
|
|
7
|
+
Run `ruby tools/benchmark.rb [model.semb]`.
|
|
8
|
+
|
|
9
|
+
## Metrics tracked
|
|
10
|
+
|
|
11
|
+
| Metric | Unit | Why |
|
|
12
|
+
|---|---|---|
|
|
13
|
+
| tokenization | ns / input byte | independent of `dim`; catches normalizer regressions |
|
|
14
|
+
| pooling | ns / token / 100 dims | comparable across models |
|
|
15
|
+
| single embed | µs / call | the latency a query pays |
|
|
16
|
+
| batch throughput | texts/s, tokens/s | indexing capacity |
|
|
17
|
+
| RSS overhead | bytes beyond the mapped file | must stay O(scratch), not O(model) |
|
|
18
|
+
| cold vs warm | first query after a cold page cache | mmap means the first pass faults in the matrix |
|
|
19
|
+
| time to first query | ms from `load` to first vector | what a Puma `before_fork` or a serverless cold start pays |
|
|
20
|
+
|
|
21
|
+
`samples/cold_start.rb` measures the last row. It is single shot on purpose:
|
|
22
|
+
`load` walks the whole vocabulary hash table and both tries exactly once, and
|
|
23
|
+
`warmup!` only faults pages in once. Drop the page cache first
|
|
24
|
+
(`sudo purge` on macOS, `echo 3 > /proc/sys/vm/drop_caches` on Linux) or the
|
|
25
|
+
output will label itself a warm start.
|
|
26
|
+
|
|
27
|
+
Every other sample under `samples/` runs a hot loop and measures steady state.
|
|
28
|
+
`warmup_hot_path.rb` in particular re-touches pages that are already resident;
|
|
29
|
+
its `mapped_range_gb_per_sec` is address-range coverage, not memory bandwidth,
|
|
30
|
+
because warmup reads one byte per page.
|
|
31
|
+
|
|
32
|
+
## CI status
|
|
33
|
+
|
|
34
|
+
The CI benchmark job is informational. Treat regression gates as meaningful only when a checked-in baseline is compared on the same runner class. Absolute thresholds on shared CI hardware produce noise, not signal.
|
|
35
|
+
|
|
36
|
+
## Benchmark hygiene
|
|
37
|
+
|
|
38
|
+
Two failure modes have bitten this repository already and are worth stating:
|
|
39
|
+
|
|
40
|
+
- **Logical vs processed bytes.** With truncation active, a 3 MB document is
|
|
41
|
+
tokenized only until `max_tokens` is reached. Any "MB/s of input" figure
|
|
42
|
+
divides by bytes that were never read. Samples that still print one label it
|
|
43
|
+
`logical_input_mb_per_sec` and set `truncation_active`.
|
|
44
|
+
- **Resident working sets.** Embedding the same text, or the same frozen id
|
|
45
|
+
array, in a loop keeps a tiny slice of the matrix in cache and overstates
|
|
46
|
+
throughput. `random_pooling_hot_path.rb` rotates through many id sets and
|
|
47
|
+
prints `touched_matrix_mb` so the reader can check it exceeds the last level
|
|
48
|
+
cache.
|
|
49
|
+
|
|
50
|
+
`GC.disable` is off by default in the sample harness: it does not stop an
|
|
51
|
+
already started incremental cycle from sweeping, so zeroed GC counters used to
|
|
52
|
+
appear next to profiles full of `gc_sweep`. Set `GC_DISABLE=1` deliberately and
|
|
53
|
+
read `gc_disabled=` before quoting `gc_delta`.
|
|
54
|
+
|
|
55
|
+
## Where the time actually goes
|
|
56
|
+
|
|
57
|
+
For static embeddings, tokenization dominates: pooling is a handful of row
|
|
58
|
+
reads and adds. That is why the tokenizer is the file to profile, and why
|
|
59
|
+
SIMD in the pooling loop is not where the wins are — the loop is memory-bound
|
|
60
|
+
on random row lookups, and the compiler already vectorises it.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
require "mkmf"
|
|
2
|
+
|
|
3
|
+
def drop_unsupported_ruby_warnflags
|
|
4
|
+
return if RbConfig::CONFIG["CC"].to_s.include?("clang")
|
|
5
|
+
|
|
6
|
+
flags = %w[
|
|
7
|
+
-Wno-self-assign
|
|
8
|
+
-Wno-parentheses-equality
|
|
9
|
+
-Wno-constant-logical-operand
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[RbConfig::CONFIG, RbConfig::MAKEFILE_CONFIG].each do |config|
|
|
13
|
+
warnflags = config["warnflags"].to_s.split
|
|
14
|
+
config["warnflags"] = (warnflags - flags).join(" ")
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
drop_unsupported_ruby_warnflags
|
|
19
|
+
|
|
20
|
+
MINIMUM_RUBY_VERSION = Gem::Version.new("3.1.0")
|
|
21
|
+
if Gem::Version.new(RUBY_VERSION) < MINIMUM_RUBY_VERSION
|
|
22
|
+
abort "static_embeddings requires Ruby >= #{MINIMUM_RUBY_VERSION}; current Ruby is #{RUBY_VERSION}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
$srcs = %w[
|
|
26
|
+
static_embeddings.c
|
|
27
|
+
se_format.c
|
|
28
|
+
se_unicode.c
|
|
29
|
+
se_tokenizer.c
|
|
30
|
+
se_embed.c
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
have_header("ruby/fiber/scheduler.h")
|
|
34
|
+
have_header("sys/mman.h")
|
|
35
|
+
have_func("madvise", "sys/mman.h")
|
|
36
|
+
|
|
37
|
+
unless [RbConfig::CONFIG["LIBS"], RbConfig::CONFIG["LIBRUBYARG_SHARED"], RbConfig::CONFIG["DLDFLAGS"]].compact.any? { |v| v.include?("-lm") }
|
|
38
|
+
have_library("m")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
$CFLAGS += " -O3 -std=gnu99 -fvisibility=hidden"
|
|
42
|
+
$CFLAGS += " -Wall -Wextra -Wno-unused-parameter"
|
|
43
|
+
|
|
44
|
+
create_makefile("static_embeddings/static_embeddings")
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
#include "se_internal.h"
|
|
2
|
+
|
|
3
|
+
#include <math.h>
|
|
4
|
+
#include <string.h>
|
|
5
|
+
|
|
6
|
+
#if defined(__ARM_NEON) || defined(__ARM_NEON__)
|
|
7
|
+
#include <arm_neon.h>
|
|
8
|
+
#define SE_HAVE_NEON 1
|
|
9
|
+
#elif defined(__SSE__)
|
|
10
|
+
#include <xmmintrin.h>
|
|
11
|
+
#define SE_HAVE_SSE 1
|
|
12
|
+
#endif
|
|
13
|
+
|
|
14
|
+
#if defined(HAVE_MADVISE) && !defined(_WIN32)
|
|
15
|
+
#include <sys/mman.h>
|
|
16
|
+
#include <unistd.h>
|
|
17
|
+
#endif
|
|
18
|
+
|
|
19
|
+
#if defined(__GNUC__) || defined(__clang__)
|
|
20
|
+
#define SE_PREFETCH(addr) __builtin_prefetch((addr), 0, 1)
|
|
21
|
+
#else
|
|
22
|
+
#define SE_PREFETCH(addr)
|
|
23
|
+
#endif
|
|
24
|
+
|
|
25
|
+
#define SE_PREFETCH_DISTANCE 4
|
|
26
|
+
|
|
27
|
+
static void add_row(float *acc, const float *row, uint32_t dim) {
|
|
28
|
+
uint32_t j = 0;
|
|
29
|
+
#if defined(SE_HAVE_NEON)
|
|
30
|
+
for (; j + 15 < dim; j += 16) {
|
|
31
|
+
vst1q_f32(acc + j, vaddq_f32(vld1q_f32(acc + j), vld1q_f32(row + j)));
|
|
32
|
+
vst1q_f32(acc + j + 4, vaddq_f32(vld1q_f32(acc + j + 4), vld1q_f32(row + j + 4)));
|
|
33
|
+
vst1q_f32(acc + j + 8, vaddq_f32(vld1q_f32(acc + j + 8), vld1q_f32(row + j + 8)));
|
|
34
|
+
vst1q_f32(acc + j + 12, vaddq_f32(vld1q_f32(acc + j + 12), vld1q_f32(row + j + 12)));
|
|
35
|
+
}
|
|
36
|
+
#elif defined(SE_HAVE_SSE)
|
|
37
|
+
for (; j + 15 < dim; j += 16) {
|
|
38
|
+
_mm_storeu_ps(acc + j, _mm_add_ps(_mm_loadu_ps(acc + j), _mm_loadu_ps(row + j)));
|
|
39
|
+
_mm_storeu_ps(acc + j + 4,
|
|
40
|
+
_mm_add_ps(_mm_loadu_ps(acc + j + 4), _mm_loadu_ps(row + j + 4)));
|
|
41
|
+
_mm_storeu_ps(acc + j + 8,
|
|
42
|
+
_mm_add_ps(_mm_loadu_ps(acc + j + 8), _mm_loadu_ps(row + j + 8)));
|
|
43
|
+
_mm_storeu_ps(acc + j + 12,
|
|
44
|
+
_mm_add_ps(_mm_loadu_ps(acc + j + 12), _mm_loadu_ps(row + j + 12)));
|
|
45
|
+
}
|
|
46
|
+
#endif
|
|
47
|
+
for (; j < dim; j++)
|
|
48
|
+
acc[j] += row[j];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
static void scale_copy(float *out, const float *acc, uint32_t dim, float inv) {
|
|
52
|
+
uint32_t j = 0;
|
|
53
|
+
#if defined(SE_HAVE_NEON)
|
|
54
|
+
float32x4_t vinv = vdupq_n_f32(inv);
|
|
55
|
+
for (; j + 15 < dim; j += 16) {
|
|
56
|
+
vst1q_f32(out + j, vmulq_f32(vld1q_f32(acc + j), vinv));
|
|
57
|
+
vst1q_f32(out + j + 4, vmulq_f32(vld1q_f32(acc + j + 4), vinv));
|
|
58
|
+
vst1q_f32(out + j + 8, vmulq_f32(vld1q_f32(acc + j + 8), vinv));
|
|
59
|
+
vst1q_f32(out + j + 12, vmulq_f32(vld1q_f32(acc + j + 12), vinv));
|
|
60
|
+
}
|
|
61
|
+
#elif defined(SE_HAVE_SSE)
|
|
62
|
+
__m128 vinv = _mm_set1_ps(inv);
|
|
63
|
+
for (; j + 15 < dim; j += 16) {
|
|
64
|
+
_mm_storeu_ps(out + j, _mm_mul_ps(_mm_loadu_ps(acc + j), vinv));
|
|
65
|
+
_mm_storeu_ps(out + j + 4, _mm_mul_ps(_mm_loadu_ps(acc + j + 4), vinv));
|
|
66
|
+
_mm_storeu_ps(out + j + 8, _mm_mul_ps(_mm_loadu_ps(acc + j + 8), vinv));
|
|
67
|
+
_mm_storeu_ps(out + j + 12, _mm_mul_ps(_mm_loadu_ps(acc + j + 12), vinv));
|
|
68
|
+
}
|
|
69
|
+
#endif
|
|
70
|
+
for (; j < dim; j++)
|
|
71
|
+
out[j] = acc[j] * inv;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
void se_l2_normalize(float *vec, uint32_t dim) {
|
|
75
|
+
double sum = 0.0;
|
|
76
|
+
for (uint32_t j = 0; j < dim; j++)
|
|
77
|
+
sum += (double)vec[j] * (double)vec[j];
|
|
78
|
+
if (sum <= 0.0)
|
|
79
|
+
return;
|
|
80
|
+
float inv = (float)(1.0 / sqrt(sum));
|
|
81
|
+
for (uint32_t j = 0; j < dim; j++)
|
|
82
|
+
vec[j] *= inv;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
static se_status_t embed_ids_core(const se_model_t *model, se_scratch_t *sc, const uint32_t *ids,
|
|
86
|
+
size_t n, float *out, se_error_t *err,
|
|
87
|
+
volatile sig_atomic_t *cancelled) {
|
|
88
|
+
const uint32_t dim = model->meta.dim;
|
|
89
|
+
float *acc = sc->acc;
|
|
90
|
+
memset(acc, 0, (size_t)dim * sizeof(float));
|
|
91
|
+
|
|
92
|
+
const uint32_t drop_unk = (model->meta.unk_policy == SE_UNK_DROP);
|
|
93
|
+
const uint32_t unk_id = model->meta.unk_id;
|
|
94
|
+
|
|
95
|
+
size_t used = 0;
|
|
96
|
+
for (size_t i = 0; i < n; i++) {
|
|
97
|
+
if (((i & 255u) == 0) && cancelled && *cancelled) {
|
|
98
|
+
se_error_set(err, SE_ERR_INTERNAL, "operation cancelled");
|
|
99
|
+
return SE_ERR_INTERNAL;
|
|
100
|
+
}
|
|
101
|
+
uint32_t id = ids[i];
|
|
102
|
+
if (id >= model->meta.vocab_size) {
|
|
103
|
+
se_error_set(err, SE_ERR_INVALID_FORMAT, "token id %u is out of range", id);
|
|
104
|
+
return SE_ERR_INVALID_FORMAT;
|
|
105
|
+
}
|
|
106
|
+
if (drop_unk && id == unk_id)
|
|
107
|
+
continue;
|
|
108
|
+
|
|
109
|
+
if (i + SE_PREFETCH_DISTANCE < n) {
|
|
110
|
+
uint32_t next = ids[i + SE_PREFETCH_DISTANCE];
|
|
111
|
+
if (next < model->meta.vocab_size)
|
|
112
|
+
SE_PREFETCH(model->embeddings + (size_t)next * dim);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const float *row = model->embeddings + (size_t)id * dim;
|
|
116
|
+
|
|
117
|
+
add_row(acc, row, dim);
|
|
118
|
+
used++;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (used == 0) {
|
|
122
|
+
if (model->meta.empty_policy == SE_EMPTY_RAISE) {
|
|
123
|
+
se_error_set(err, SE_ERR_EMPTY_INPUT, "input produced no usable tokens");
|
|
124
|
+
return SE_ERR_EMPTY_INPUT;
|
|
125
|
+
}
|
|
126
|
+
memset(out, 0, (size_t)dim * sizeof(float));
|
|
127
|
+
return SE_OK;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (model->meta.normalization_type == SE_NORMALIZATION_L2) {
|
|
131
|
+
scale_copy(out, acc, dim, 1.0f);
|
|
132
|
+
se_l2_normalize(out, dim);
|
|
133
|
+
} else {
|
|
134
|
+
const float inv = 1.0f / (float)used;
|
|
135
|
+
scale_copy(out, acc, dim, inv);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return SE_OK;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
se_status_t se_embed_ids(const se_model_t *model, se_scratch_t *sc, const uint32_t *ids,
|
|
142
|
+
size_t n_ids, float *out, se_token_stats_t *stats, se_error_t *err,
|
|
143
|
+
volatile sig_atomic_t *cancelled) {
|
|
144
|
+
if (n_ids > UINT32_MAX) {
|
|
145
|
+
se_error_set(err, SE_ERR_OOM, "too many token ids");
|
|
146
|
+
return SE_ERR_OOM;
|
|
147
|
+
}
|
|
148
|
+
stats->token_count = (uint32_t)n_ids;
|
|
149
|
+
stats->unk_count = 0;
|
|
150
|
+
stats->truncated = 0;
|
|
151
|
+
for (size_t i = 0; i < n_ids; i++) {
|
|
152
|
+
if (ids[i] == model->meta.unk_id)
|
|
153
|
+
stats->unk_count++;
|
|
154
|
+
}
|
|
155
|
+
return embed_ids_core(model, sc, ids, n_ids, out, err, cancelled);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
se_status_t se_embed_one(const se_model_t *model, se_scratch_t *sc, const uint8_t *input,
|
|
159
|
+
size_t input_len, uint32_t max_tokens, float *out, se_token_stats_t *stats,
|
|
160
|
+
se_error_t *err, volatile sig_atomic_t *cancelled) {
|
|
161
|
+
se_status_t rc = se_tokenize(model, sc, input, input_len, max_tokens, stats, err, cancelled);
|
|
162
|
+
if (rc != SE_OK)
|
|
163
|
+
return rc;
|
|
164
|
+
return embed_ids_core(model, sc, sc->ids, stats->token_count, out, err, cancelled);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
size_t se_model_warmup(const se_model_t *model) {
|
|
168
|
+
const volatile uint8_t *p = (const uint8_t *)model->map_base;
|
|
169
|
+
size_t n = model->map_size;
|
|
170
|
+
size_t page = 4096;
|
|
171
|
+
#if !defined(_WIN32) && defined(_SC_PAGESIZE)
|
|
172
|
+
long sys_page = sysconf(_SC_PAGESIZE);
|
|
173
|
+
if (sys_page > 0)
|
|
174
|
+
page = (size_t)sys_page;
|
|
175
|
+
#endif
|
|
176
|
+
volatile uint64_t sink = 0;
|
|
177
|
+
size_t touched = 0;
|
|
178
|
+
|
|
179
|
+
#if defined(HAVE_MADVISE) && !defined(_WIN32)
|
|
180
|
+
(void)madvise(model->map_base, model->map_size, MADV_WILLNEED);
|
|
181
|
+
#endif
|
|
182
|
+
|
|
183
|
+
for (size_t i = 0; i < n; i += page) {
|
|
184
|
+
sink += p[i];
|
|
185
|
+
touched++;
|
|
186
|
+
}
|
|
187
|
+
if (n)
|
|
188
|
+
sink += p[n - 1];
|
|
189
|
+
|
|
190
|
+
(void)sink;
|
|
191
|
+
return touched;
|
|
192
|
+
}
|