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.
@@ -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.
@@ -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.
@@ -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,6 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "static_embeddings"
4
+ require "static_embeddings/cli"
5
+
6
+ exit(StaticEmbeddings::CLI.run(ARGV) || 0)
@@ -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
+ }