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.
@@ -0,0 +1,87 @@
1
+ # Benchmarking
2
+
3
+ Two different tools, for two different questions.
4
+
5
+ `benchmark/` answers "how fast is this, and is the difference I am looking at
6
+ real". `samples/` answers "where does the time go" by keeping a process alive
7
+ for a profiler to attach to. Do not use one for the other's job.
8
+
9
+ ```bash
10
+ bundle exec rake benchmark # everything
11
+ bundle exec rake benchmark:core_paths
12
+ bundle exec rake benchmark:gvl_threshold
13
+
14
+ BENCH_MODEL="$HOME/.cache/static_embeddings/models/potion-retrieval-32m.semb" \
15
+ bundle exec rake benchmark:core_paths
16
+ ```
17
+
18
+ Without `BENCH_MODEL` the demo model is used, which has `dim=8` and a 176-token
19
+ vocabulary. It proves the harness runs; it says nothing about production speed.
20
+
21
+ ## Reading the output
22
+
23
+ ```text
24
+ case ns/op ops/sec spread% alloc/op
25
+ embed words=20 8540.5 117089 3.0% 1.00
26
+ ```
27
+
28
+ `spread_pct` is the distance between the fastest and slowest repeat. **A
29
+ difference smaller than `spread_pct` is not a result.** On a laptop or a shared
30
+ CI runner it is routinely 5-10%, which is wider than most changes worth making.
31
+ If you want to claim an improvement, either get the spread down or show the
32
+ change across several independent runs.
33
+
34
+ `alloc/op` is Ruby object allocations per operation. It moves for different
35
+ reasons than time does, and it is the more stable signal of the two.
36
+
37
+ Generated text inputs are primed with `valid_encoding?` before timing, so these
38
+ benchmarks measure the cached-coderange hot path. Fresh strings from disk or the
39
+ database can pay an extra whole-string UTF-8 scan; see `docs/PERFORMANCE.md`.
40
+
41
+ Iteration counts are calibrated automatically so every measurement runs for at
42
+ least `BENCH_MIN_SECONDS`. Knobs:
43
+
44
+ | Variable | Default | Meaning |
45
+ |---|---|---|
46
+ | `BENCH_MODEL` | demo model | model to load |
47
+ | `BENCH_REPEATS` | 7 | repeats per case, median reported |
48
+ | `BENCH_MIN_SECONDS` | 0.25 | minimum duration of one repeat |
49
+ | `BENCH_WARMUP_SECONDS` | 0.05 | warmup before timing |
50
+ | `BENCH_ADAPTIVE` | 1 | set to 0 to use the literal iteration count |
51
+ | `BENCH_FORMAT` | table | set to `kv` for machine-readable lines |
52
+
53
+ ## What the two benchmarks are for
54
+
55
+ `core_paths` covers tokenize, single embed, batch embed, pooling in isolation
56
+ and top-k in both formats. The `embed` and `embed rotating corpus` rows are
57
+ deliberately both present: the first repeats one string and keeps its matrix
58
+ rows in cache, the second rotates through 2000 texts and does not. The gap
59
+ between them is the cache effect, and quoting the first as throughput is how you
60
+ end up with a number nobody can reproduce.
61
+
62
+ `gvl_threshold` sweeps input sizes around `SE_GVL_UNLOCK_THRESHOLD`, the point
63
+ where a single `embed` stops running under the GVL and is handed to the batch
64
+ machinery. Look for the step, not the slope. On one x86-64 run the step cost
65
+ about 32% latency and four extra allocations, and it is also the point where a
66
+ second Ruby thread starts making progress at all. Both halves of that trade have
67
+ to be measured on your hardware before the constant is worth changing.
68
+
69
+ ## Traps this repository has already fallen into
70
+
71
+ **Quoting noise.** An `embed_batch f16` change was reported as a 6% speedup; it
72
+ was inside the run-to-run spread and the next run showed the opposite sign. The
73
+ memory result from the same change was real and reproducible. Report the one you
74
+ can reproduce.
75
+
76
+ **Cache-resident working sets.** Embedding the same text in a loop keeps a tiny
77
+ slice of the matrix hot. Real corpora do not.
78
+
79
+ **Logical vs processed bytes.** With truncation active, a 3 MB document is
80
+ tokenized only until `max_tokens` is reached. Any MB/s figure computed from the
81
+ string length divides by bytes that were never read.
82
+
83
+ **GC state.** `GC.disable` does not stop an in-flight incremental cycle from
84
+ sweeping. The harness leaves GC on and reports allocations instead.
85
+
86
+ See `docs/PERFORMANCE.md` for the budget itself and where the time actually
87
+ goes.
@@ -0,0 +1,93 @@
1
+ # Limitations
2
+
3
+ `static_embeddings` is a runtime for one narrow thing: turning text into a
4
+ vector with a converted Model2Vec / potion model, and scanning a matrix of such
5
+ vectors. It is not a vector database, not a general embedding library, and not a
6
+ tokenizer toolkit.
7
+
8
+ This page is the answer to "does it do X". If X is here, the answer is no, and
9
+ the entry says whether that is a decision or just unfinished work.
10
+
11
+ ## Models
12
+
13
+ - **Only `.semb` files from this repository's converter.** Loading a
14
+ HuggingFace directory at runtime is not supported and will not be. The
15
+ converter is the only thing that reads third-party files, and it runs offline.
16
+ - **Only the `BERT_WORDPIECE_V1` tokenizer profile.** SentencePiece, BPE, Unigram
17
+ and byte-level tokenizers are rejected at conversion time rather than
18
+ approximated.
19
+ - **No model training, distillation or fine-tuning.** Produce the model with
20
+ upstream `model2vec`, then convert it.
21
+ - **No transformer inference.** Static embeddings have no attention and no
22
+ context; a word contributes the same row wherever it appears. If you need
23
+ contextual embeddings, this is the wrong tool.
24
+
25
+ ## Runtime
26
+
27
+ - **No internal parallelism.** `threads:` is rejected rather than ignored. Split
28
+ work at the job level and run more application workers.
29
+ - **No Ractor support.** Model, and everything holding native state, are not
30
+ shareable; `Ractor.make_shareable` raises.
31
+ - **No GPU, no BLAS, no external SIMD library.** The kernels are plain C with
32
+ compiler autovectorisation, plus a hand-written half-precision path.
33
+ - **No streaming input.** `embed` and `embed_batch` take Ruby `String`s that are
34
+ already in memory.
35
+ - **`embed_batch` peaks at roughly twice the returned blob**, because the result
36
+ is built in C memory and then copied into a Ruby `String`. Chunk large
37
+ corpora.
38
+ - **Big-endian hosts are refused at load.** The header is decoded
39
+ little-endian explicitly, but the mmapped structures and the float matrix are
40
+ read in native order.
41
+
42
+ ## Search
43
+
44
+ - **No index.** `dot_top_k` and `cosine_top_k` are exhaustive scans. There is no
45
+ ANN, no IVF, no HNSW, no clustering.
46
+ - **No batched queries.** One query per scan; N queries stream the matrix N
47
+ times.
48
+ - **No candidate filtering.** You cannot restrict a scan to a subset of rows.
49
+ - **No precomputed row norms.** `cosine_top_k` recomputes each row's norm on
50
+ every query. Normalise your rows once and use `dot_top_k` instead.
51
+ - **No persistence, no ids, no updates.** A matrix is a frozen `String`; mapping
52
+ rows to documents, appending, and deleting are the caller's problem. If you
53
+ want that, use pgvector and keep this gem for producing the vectors.
54
+ - **No metric other than dot product and cosine.** No Euclidean, no Manhattan,
55
+ no Hamming.
56
+
57
+ ## Storage formats
58
+
59
+ - **`f32` and `f16` only.** No int8, no binary quantisation.
60
+ - **`f16` is a storage encoding, not a different model.** Top-k over an f16
61
+ matrix can be faster or slower than f32 depending on the decode kernel; see
62
+ `docs/PERFORMANCE.md`. `embed_batch(format: :f16)` still encodes the blob
63
+ with the scalar converter.
64
+ - **`f16` rounding is half-up, not ties-to-even.** Blobs written by this gem can
65
+ differ from NumPy or PyTorch by one ULP on exact halfway values.
66
+
67
+ ## Platforms
68
+
69
+ - **Windows is built and smoke-tested by cross-compilation under wine, not on a
70
+ real Windows runner.** The loader uses `CreateFileMapping`, with a
71
+ read-into-heap fallback.
72
+ - **No JRuby or TruffleRuby.** The gem is a CRuby C extension.
73
+
74
+ ## Verification
75
+
76
+ - **`load` does not check the SHA-256.** Structural validation always runs, but
77
+ a bit flip inside the matrix is only caught by `verify: true` or
78
+ `StaticEmbeddings.verify`.
79
+ - **Parity is per model and per runtime version.** A converted model is not
80
+ trusted until it has a record in `docs/MODEL_AUDIT.md`, and that record
81
+ expires when the tokenizer changes.
82
+
83
+ ## Known open questions
84
+
85
+ These are not decisions, just work that has not been done or measured:
86
+
87
+ - The `SE_GVL_UNLOCK_THRESHOLD` value has a benchmark now
88
+ (`rake benchmark:gvl_threshold`) but has not been retuned from it.
89
+ - Top-k keeps its candidates in a sorted array, which degrades for large `k`; a
90
+ heap would be better somewhere above a few dozen.
91
+ - `madvise(MADV_RANDOM)` and huge pages for the embedding section are untested.
92
+ - The C allocation counters cover the runtime's own allocations only; Ruby heap,
93
+ fragmentation and mmap residency are outside them.
data/docs/MODEL_AUDIT.md CHANGED
@@ -11,25 +11,29 @@ runtime half of it even though the file bytes are untouched.
11
11
  | runtime | parity | note |
12
12
  |---|---|---|
13
13
  | 0.1.1 | `parity OK`, recorded below | superseded |
14
- | 0.1.2 | **not re-run** | required before release |
14
+ | 0.1.2 | **not re-run** | superseded before release |
15
+ | 0.1.3 | `parity OK` | tokenizer / pooling contract; same numbers as 0.1.4 |
16
+ | 0.1.4 | `parity OK`, recorded below | current |
15
17
 
16
18
  0.1.2 changed `is_control()`, which changes token ids for any input containing
17
19
  `U+007F`. `StaticEmbeddings::Reference` cannot settle whether the new behaviour
18
20
  matches HuggingFace, because it is an implementation twin of the C runtime
19
- written in this repository. Only the oracle can, and the oracle now carries DEL
20
- and control-character rows it did not have when the result below was recorded —
21
- so `rows=28` would itself be evidence of a stale run.
21
+ written in this repository. The 0.1.3 release candidate was checked against a
22
+ fresh upstream `model2vec.StaticModel` oracle that includes DEL, control
23
+ characters, Unicode, OOV, long-word and truncation rows. 0.1.4 re-ran that
24
+ oracle against the SIMD L2 runtime on the same snapshot and `.semb`.
22
25
 
23
26
  Source model:
24
27
 
25
28
  - Hugging Face repository: `minishlab/potion-retrieval-32M`
29
+ - Hugging Face snapshot: `6fc8051fab2a1e0ee76689cf08c853792ac285e7`
26
30
  - Oracle implementation: `model2vec.StaticModel.from_pretrained`
27
- - Python package: `model2vec 0.9.0`
31
+ - Python package: `model2vec 0.9.0` (`tokenizers 0.23.1`, `numpy 2.5.2`)
28
32
  - Oracle file: `tmp/model2vec_oracle.json`
29
- - Oracle rows in this recorded run: `28`
33
+ - Oracle rows in this recorded run: `31`
30
34
  - Oracle dimension: `512`
31
35
  - Oracle max length: `512`
32
- - Runtime at time of this record: `static_embeddings 0.1.1`
36
+ - Runtime at time of this record: `static_embeddings 0.1.4`
33
37
 
34
38
  Converted `.semb`:
35
39
 
@@ -42,16 +46,20 @@ Converted `.semb`:
42
46
  Parity command:
43
47
 
44
48
  ```bash
49
+ python tools/model2vec_oracle.py \
50
+ ~/.cache/huggingface/hub/models--minishlab--potion-retrieval-32M/snapshots/6fc8051fab2a1e0ee76689cf08c853792ac285e7 \
51
+ --out tmp/model2vec_oracle.json
52
+
45
53
  bundle exec rake parity \
46
54
  MODEL="$HOME/.cache/static_embeddings/models/potion-retrieval-32m.semb" \
47
55
  ORACLE=tmp/model2vec_oracle.json
48
56
  ```
49
57
 
50
- Parity result:
58
+ Parity result (0.1.4, 2026-08-31):
51
59
 
52
60
  ```text
53
- rows=28
54
- id_rows_checked=28
61
+ rows=31
62
+ id_rows_checked=31
55
63
  min_cosine=0.9999999999989528
56
64
  max_abs_all=2.980232238769531e-07
57
65
  token_id_failures=[]
@@ -59,6 +67,11 @@ vector_failures=[]
59
67
  parity OK
60
68
  ```
61
69
 
70
+ The printed `min_cosine` and `max_abs_all` are identical to the 0.1.3 record.
71
+ Worst vector row is still idx=29 (`"word" + 400 spaces`, 80 800 bytes) at
72
+ `max_abs=2.9802322e-07`. SIMD pairwise double L2 did not move the oracle
73
+ agreement on this corpus.
74
+
62
75
  Decision:
63
76
 
64
77
  - Tokenization parity: pass
@@ -68,7 +81,8 @@ Decision:
68
81
  - Unicode normalization behavior: pass
69
82
  - Unknown-word behavior: pass
70
83
  - Long input / truncation behavior: pass
84
+ - DEL / control-character behavior: pass
71
85
 
72
- Accepted for runtime and benchmark use **under 0.1.1**. The `.semb` file is
73
- unchanged and its SHA256 still matches; what expired is the runtime half of the
74
- record.
86
+ Accepted for runtime and benchmark use **under 0.1.4**. The `.semb` file is
87
+ unchanged and its SHA256 still matches. The runtime half of the record was
88
+ refreshed after the 0.1.4 L2 kernel change.
data/docs/PERFORMANCE.md CHANGED
@@ -22,33 +22,42 @@ ruby tools/benchmark.rb [model.semb]
22
22
 
23
23
  ## Where the time actually goes
24
24
 
25
- Tokenization dominates; pooling is a handful of row reads and adds. That is why
26
- the tokenizer is the file to profile, and why SIMD in the pooling loop is not
27
- where the wins are that loop is memory-bound on random row lookups and the
28
- compiler already vectorises it.
29
-
30
- Out-of-vocabulary text is the main cliff. In-vocabulary words hit the
31
- vocabulary hash directly; everything else falls through to trie-driven subword
32
- splitting, which on a synthetic all-OOV corpus measured several times the
33
- per-text cost. Non-English input on an English model pays this on top of the
34
- `[UNK]` quality problem.
25
+ Which file to profile depends on the model and the text. On a short-dimension
26
+ demo model, or on synthetic all-OOV input, tokenization dominates. On
27
+ `potion-retrieval-32m` (`dim=512`) with in-vocabulary English, pooling is the
28
+ larger share: each token is a random 2 KiB row add, and before 0.1.4 the scalar
29
+ L2 pass showed up as about 19% of an ASCII `embed_batch` sample tree. SIMD in
30
+ that L2 slot was a real win; another unroll of the add loop was not it is
31
+ memory-bound on those gathers, and the compiler already vectorises it.
32
+
33
+ Out-of-vocabulary text is the remaining tokenizer cliff. In-vocabulary words
34
+ hit the vocabulary hash directly; everything else falls through to trie-driven
35
+ subword splitting, which on a synthetic all-OOV corpus measured several times
36
+ the per-text cost. Non-English input on an English model pays this on top of
37
+ the `[UNK]` quality problem. Quote `mean_tokens_per_text` and `unk_ratio` from
38
+ the batch samples before comparing texts/s across modes: hashes are slower
39
+ than ASCII on this corpus mostly because they emit ~73 tokens/text against
40
+ ~31, not only because they miss the hash.
35
41
 
36
42
  ## `f16` is a storage trade-off
37
43
 
38
- `format: :f16` halves the bytes a top-k scan streams and doubles the decode
39
- work. Which one wins is a property of the machine, and this repository's own
40
- sample runs disagree with each other — same corpus, same `k`, native decode
41
- kernel in both cases:
44
+ `format: :f16` halves the bytes a top-k scan streams and costs a decode per
45
+ row. Which one wins is a property of the machine and of the decode kernel, and
46
+ this repository's own sample runs disagree with each other — same corpus, same
47
+ `k`, native decode kernel in both cases:
42
48
 
43
49
  ```text
44
50
  f32 f16
45
- x86-64, f16c 3.00 ms 1.65 ms f16 1.8x faster
46
- M1 Pro, neon-fp16 2.29 ms 2.88 ms f16 1.3x slower
51
+ x86-64, f16c 3.00 ms 1.65 ms f16 1.8x faster (F16C kernel, unchanged)
52
+ M1 Pro, neon-fp16 2.36 ms 1.81 ms f16 1.3x faster (0.1.4, 16-wide NEON)
47
53
  ```
48
54
 
49
- Neither ratio transfers. Confirm `StaticEmbeddings.simd_backend`, then measure
50
- on the hardware you will run on. On the lookup-table fallback `f16` is usually
51
- slower than `f32`.
55
+ The M1 Pro row in 0.1.3 was 2.29 / 2.88 ms, f16 1.3x *slower*, with an 8-wide
56
+ decode. Neither ratio transfers. Confirm `StaticEmbeddings.simd_backend`, then
57
+ measure on the hardware you will run on. On the lookup-table fallback `f16` is
58
+ usually slower than `f32`. `embed_batch(format: :f16)` still encodes the
59
+ returned blob with the scalar half converter, so a batch that is not a top-k
60
+ scan can still be slower than `f32` on the same machine.
52
61
 
53
62
  ## C allocation counters
54
63
 
@@ -72,6 +81,14 @@ for those. And the instrumentation is not free: about 13% on `tokenize`, 3% on
72
81
  `embed`, within noise on `embed_batch`. A run captured with it is not
73
82
  comparable to one captured without it.
74
83
 
84
+ ## Running the benchmarks
85
+
86
+ `bundle exec rake benchmark`, or one at a time with `rake benchmark:core_paths`
87
+ and `rake benchmark:gvl_threshold`. Generated text inputs are
88
+ `valid_encoding?`-primed before timing, so these are cached-coderange hot-path
89
+ numbers. Every row carries `spread_pct`, and a difference smaller than that is
90
+ not a result. See `docs/BENCHMARKING.md`.
91
+
75
92
  ## Benchmark hygiene
76
93
 
77
94
  Three failure modes have bitten this repository already.
@@ -8,6 +8,7 @@
8
8
  #define SE_HAVE_NEON 1
9
9
  #elif defined(__SSE__)
10
10
  #include <xmmintrin.h>
11
+ #include <emmintrin.h>
11
12
  #define SE_HAVE_SSE 1
12
13
  #endif
13
14
 
@@ -71,15 +72,49 @@ static void scale_copy(float *out, const float *acc, uint32_t dim, float inv) {
71
72
  out[j] = acc[j] * inv;
72
73
  }
73
74
 
74
- void se_l2_normalize(float *vec, uint32_t dim) {
75
+ static double sum_sq_d(const float *vec, uint32_t dim) {
76
+ uint32_t j = 0;
75
77
  double sum = 0.0;
76
- for (uint32_t j = 0; j < dim; j++)
78
+ #if defined(SE_HAVE_NEON) && defined(__aarch64__)
79
+ float64x2_t s0 = vdupq_n_f64(0.0);
80
+ float64x2_t s1 = vdupq_n_f64(0.0);
81
+ for (; j + 7 < dim; j += 8) {
82
+ float32x4_t a = vld1q_f32(vec + j);
83
+ float32x4_t b = vld1q_f32(vec + j + 4);
84
+ float64x2_t a0 = vcvt_f64_f32(vget_low_f32(a));
85
+ float64x2_t a1 = vcvt_f64_f32(vget_high_f32(a));
86
+ float64x2_t b0 = vcvt_f64_f32(vget_low_f32(b));
87
+ float64x2_t b1 = vcvt_f64_f32(vget_high_f32(b));
88
+ s0 = vaddq_f64(s0, vmulq_f64(a0, a0));
89
+ s1 = vaddq_f64(s1, vmulq_f64(a1, a1));
90
+ s0 = vaddq_f64(s0, vmulq_f64(b0, b0));
91
+ s1 = vaddq_f64(s1, vmulq_f64(b1, b1));
92
+ }
93
+ sum = vaddvq_f64(vaddq_f64(s0, s1));
94
+ #elif defined(SE_HAVE_SSE)
95
+ __m128d s0 = _mm_setzero_pd();
96
+ __m128d s1 = _mm_setzero_pd();
97
+ for (; j + 3 < dim; j += 4) {
98
+ __m128 v = _mm_loadu_ps(vec + j);
99
+ __m128d lo = _mm_cvtps_pd(v);
100
+ __m128d hi = _mm_cvtps_pd(_mm_movehl_ps(v, v));
101
+ s0 = _mm_add_pd(s0, _mm_mul_pd(lo, lo));
102
+ s1 = _mm_add_pd(s1, _mm_mul_pd(hi, hi));
103
+ }
104
+ double tmp[2];
105
+ _mm_storeu_pd(tmp, _mm_add_pd(s0, s1));
106
+ sum = tmp[0] + tmp[1];
107
+ #endif
108
+ for (; j < dim; j++)
77
109
  sum += (double)vec[j] * (double)vec[j];
110
+ return sum;
111
+ }
112
+
113
+ void se_l2_normalize(float *vec, uint32_t dim) {
114
+ double sum = sum_sq_d(vec, dim);
78
115
  if (sum <= 0.0)
79
116
  return;
80
- float inv = (float)(1.0 / sqrt(sum));
81
- for (uint32_t j = 0; j < dim; j++)
82
- vec[j] *= inv;
117
+ scale_copy(vec, vec, dim, (float)(1.0 / sqrt(sum)));
83
118
  }
84
119
 
85
120
  static se_status_t embed_ids_core(const se_model_t *model, se_scratch_t *sc, const uint32_t *ids,
@@ -128,8 +163,12 @@ static se_status_t embed_ids_core(const se_model_t *model, se_scratch_t *sc, con
128
163
  }
129
164
 
130
165
  if (model->meta.normalization_type == SE_NORMALIZATION_L2) {
131
- scale_copy(out, acc, dim, 1.0f);
132
- se_l2_normalize(out, dim);
166
+ double sum = sum_sq_d(acc, dim);
167
+ if (sum <= 0.0) {
168
+ memset(out, 0, (size_t)dim * sizeof(float));
169
+ return SE_OK;
170
+ }
171
+ scale_copy(out, acc, dim, (float)(1.0 / sqrt(sum)));
133
172
  } else {
134
173
  const float inv = 1.0f / (float)used;
135
174
  scale_copy(out, acc, dim, inv);
@@ -221,17 +221,31 @@ static float dot_product_f16_neon(const float *q, const uint8_t *row, size_t dim
221
221
  size_t j = 0;
222
222
  float32x4_t a0 = vdupq_n_f32(0.0f);
223
223
  float32x4_t a1 = vdupq_n_f32(0.0f);
224
+ float32x4_t a2 = vdupq_n_f32(0.0f);
225
+ float32x4_t a3 = vdupq_n_f32(0.0f);
226
+ for (; j + 15 < dim; j += 16) {
227
+ float16x8_t h0 =
228
+ vreinterpretq_f16_u16(vld1q_u16((const uint16_t *)(const void *)(row + j * 2)));
229
+ float16x8_t h1 =
230
+ vreinterpretq_f16_u16(vld1q_u16((const uint16_t *)(const void *)(row + (j + 8) * 2)));
231
+ float32x4_t r0 = vcvt_f32_f16(vget_low_f16(h0));
232
+ float32x4_t r1 = vcvt_f32_f16(vget_high_f16(h0));
233
+ float32x4_t r2 = vcvt_f32_f16(vget_low_f16(h1));
234
+ float32x4_t r3 = vcvt_f32_f16(vget_high_f16(h1));
235
+ a0 = vmlaq_f32(a0, vld1q_f32(q + j), r0);
236
+ a1 = vmlaq_f32(a1, vld1q_f32(q + j + 4), r1);
237
+ a2 = vmlaq_f32(a2, vld1q_f32(q + j + 8), r2);
238
+ a3 = vmlaq_f32(a3, vld1q_f32(q + j + 12), r3);
239
+ }
224
240
  for (; j + 7 < dim; j += 8) {
225
241
  float16x4_t h0 =
226
242
  vreinterpret_f16_u16(vld1_u16((const uint16_t *)(const void *)(row + j * 2)));
227
243
  float16x4_t h1 =
228
244
  vreinterpret_f16_u16(vld1_u16((const uint16_t *)(const void *)(row + (j + 4) * 2)));
229
- float32x4_t r0 = vcvt_f32_f16(h0);
230
- float32x4_t r1 = vcvt_f32_f16(h1);
231
- a0 = vmlaq_f32(a0, vld1q_f32(q + j), r0);
232
- a1 = vmlaq_f32(a1, vld1q_f32(q + j + 4), r1);
245
+ a0 = vmlaq_f32(a0, vld1q_f32(q + j), vcvt_f32_f16(h0));
246
+ a1 = vmlaq_f32(a1, vld1q_f32(q + j + 4), vcvt_f32_f16(h1));
233
247
  }
234
- float32x4_t sumv = vaddq_f32(a0, a1);
248
+ float32x4_t sumv = vaddq_f32(vaddq_f32(a0, a1), vaddq_f32(a2, a3));
235
249
  float dot = vaddvq_f32(sumv);
236
250
  for (; j < dim; j++)
237
251
  dot += q[j] * se_f16_bits_to_float(read_u16le(row + j * 2));
@@ -243,8 +257,30 @@ static float dot_and_row_sq_f16_neon(const float *q, const uint8_t *row, size_t
243
257
  size_t j = 0;
244
258
  float32x4_t d0 = vdupq_n_f32(0.0f);
245
259
  float32x4_t d1 = vdupq_n_f32(0.0f);
260
+ float32x4_t d2 = vdupq_n_f32(0.0f);
261
+ float32x4_t d3 = vdupq_n_f32(0.0f);
246
262
  float32x4_t s0 = vdupq_n_f32(0.0f);
247
263
  float32x4_t s1 = vdupq_n_f32(0.0f);
264
+ float32x4_t s2 = vdupq_n_f32(0.0f);
265
+ float32x4_t s3 = vdupq_n_f32(0.0f);
266
+ for (; j + 15 < dim; j += 16) {
267
+ float16x8_t h0 =
268
+ vreinterpretq_f16_u16(vld1q_u16((const uint16_t *)(const void *)(row + j * 2)));
269
+ float16x8_t h1 =
270
+ vreinterpretq_f16_u16(vld1q_u16((const uint16_t *)(const void *)(row + (j + 8) * 2)));
271
+ float32x4_t r0 = vcvt_f32_f16(vget_low_f16(h0));
272
+ float32x4_t r1 = vcvt_f32_f16(vget_high_f16(h0));
273
+ float32x4_t r2 = vcvt_f32_f16(vget_low_f16(h1));
274
+ float32x4_t r3 = vcvt_f32_f16(vget_high_f16(h1));
275
+ d0 = vmlaq_f32(d0, vld1q_f32(q + j), r0);
276
+ d1 = vmlaq_f32(d1, vld1q_f32(q + j + 4), r1);
277
+ d2 = vmlaq_f32(d2, vld1q_f32(q + j + 8), r2);
278
+ d3 = vmlaq_f32(d3, vld1q_f32(q + j + 12), r3);
279
+ s0 = vmlaq_f32(s0, r0, r0);
280
+ s1 = vmlaq_f32(s1, r1, r1);
281
+ s2 = vmlaq_f32(s2, r2, r2);
282
+ s3 = vmlaq_f32(s3, r3, r3);
283
+ }
248
284
  for (; j + 7 < dim; j += 8) {
249
285
  float16x4_t h0 =
250
286
  vreinterpret_f16_u16(vld1_u16((const uint16_t *)(const void *)(row + j * 2)));
@@ -257,8 +293,8 @@ static float dot_and_row_sq_f16_neon(const float *q, const uint8_t *row, size_t
257
293
  s0 = vmlaq_f32(s0, r0, r0);
258
294
  s1 = vmlaq_f32(s1, r1, r1);
259
295
  }
260
- float dot = vaddvq_f32(vaddq_f32(d0, d1));
261
- float row_sq = vaddvq_f32(vaddq_f32(s0, s1));
296
+ float dot = vaddvq_f32(vaddq_f32(vaddq_f32(d0, d1), vaddq_f32(d2, d3)));
297
+ float row_sq = vaddvq_f32(vaddq_f32(vaddq_f32(s0, s1), vaddq_f32(s2, s3)));
262
298
  for (; j < dim; j++) {
263
299
  float r = se_f16_bits_to_float(read_u16le(row + j * 2));
264
300
  dot += q[j] * r;
@@ -61,6 +61,34 @@ static uint32_t hash_bytes_continue(uint32_t h, const uint8_t *data, size_t len)
61
61
  return h;
62
62
  }
63
63
 
64
+ static int se_memeq(const uint8_t *a, const uint8_t *b, size_t n) {
65
+ while (n >= 8) {
66
+ uint64_t ua, ub;
67
+ memcpy(&ua, a, 8);
68
+ memcpy(&ub, b, 8);
69
+ if (ua != ub)
70
+ return 0;
71
+ a += 8;
72
+ b += 8;
73
+ n -= 8;
74
+ }
75
+ if (n >= 4) {
76
+ uint32_t ua, ub;
77
+ memcpy(&ua, a, 4);
78
+ memcpy(&ub, b, 4);
79
+ if (ua != ub)
80
+ return 0;
81
+ a += 4;
82
+ b += 4;
83
+ n -= 4;
84
+ }
85
+ while (n--) {
86
+ if (*a++ != *b++)
87
+ return 0;
88
+ }
89
+ return 1;
90
+ }
91
+
64
92
  int se_vocab_lookup_piece(const se_model_t *model, const uint8_t *prefix, size_t prefix_len,
65
93
  const uint8_t *bytes, size_t len, uint32_t *id_out) {
66
94
  if (prefix_len > UINT32_MAX || len > UINT32_MAX || prefix_len > UINT32_MAX - len)
@@ -79,9 +107,9 @@ int se_vocab_lookup_piece(const se_model_t *model, const uint8_t *prefix, size_t
79
107
  if (slot->token_id == SE_SLOT_EMPTY)
80
108
  return 0;
81
109
  if (slot->hash == h && slot->str_len == total_len) {
82
- const char *token = model->vocab_strings + slot->str_off;
83
- if ((prefix_len == 0 || memcmp(token, prefix, prefix_len) == 0) &&
84
- (len == 0 || memcmp(token + prefix_len, bytes, len) == 0)) {
110
+ const uint8_t *token = (const uint8_t *)(model->vocab_strings + slot->str_off);
111
+ if ((prefix_len == 0 || se_memeq(token, prefix, prefix_len)) &&
112
+ (len == 0 || se_memeq(token + prefix_len, bytes, len))) {
85
113
  *id_out = slot->token_id;
86
114
  return 1;
87
115
  }
@@ -103,6 +131,17 @@ static inline uint32_t trie_find_child(const se_trie_t *trie, const se_trie_node
103
131
  if (count == 0)
104
132
  return SE_SLOT_EMPTY;
105
133
 
134
+ if (count == 1)
135
+ return trie->edges[start].byte == byte ? trie->edges[start].child : SE_SLOT_EMPTY;
136
+
137
+ if (count == 2) {
138
+ if (trie->edges[start].byte == byte)
139
+ return trie->edges[start].child;
140
+ if (trie->edges[start + 1u].byte == byte)
141
+ return trie->edges[start + 1u].child;
142
+ return SE_SLOT_EMPTY;
143
+ }
144
+
106
145
  if (count <= 8) {
107
146
  for (uint32_t i = 0; i < count; i++) {
108
147
  const se_trie_edge_t *edge = &trie->edges[start + i];
@@ -342,6 +381,37 @@ static se_status_t validate_norm_ranges(const se_range_t *ranges, uint32_t count
342
381
  return SE_OK;
343
382
  }
344
383
 
384
+ static void fill_map256(const se_map_entry_t **out, const se_map_entry_t *entries, uint32_t count) {
385
+ memset(out, 0, 256 * sizeof(*out));
386
+ for (uint32_t i = 0; i < count; i++) {
387
+ if (entries[i].cp < 256)
388
+ out[entries[i].cp] = &entries[i];
389
+ }
390
+ }
391
+
392
+ static void fill_range256(uint8_t *out, const se_range_t *ranges, uint32_t count) {
393
+ memset(out, 0, 256);
394
+ for (uint32_t i = 0; i < count; i++) {
395
+ uint32_t lo = ranges[i].lo;
396
+ uint32_t hi = ranges[i].hi;
397
+ if (lo > 255)
398
+ continue;
399
+ if (hi > 255)
400
+ hi = 255;
401
+ for (uint32_t cp = lo; cp <= hi; cp++)
402
+ out[cp] = 1;
403
+ }
404
+ }
405
+
406
+ static void prepare_norm_fast_tables(se_model_t *model) {
407
+ fill_map256(model->norm.lower256, model->norm.lower, model->norm.lower_count);
408
+ fill_map256(model->norm.nfd256, model->norm.nfd, model->norm.nfd_count);
409
+ fill_range256(model->norm.mn256, model->norm.mn, model->norm.mn_count);
410
+ fill_range256(model->norm.punct256, model->norm.punct, model->norm.punct_count);
411
+ fill_range256(model->norm.control256, model->norm.control, model->norm.control_count);
412
+ fill_range256(model->norm.whitespace256, model->norm.whitespace, model->norm.whitespace_count);
413
+ }
414
+
345
415
  static se_status_t parse_norm_tables(se_model_t *model, const uint8_t *base, struct section sec,
346
416
  se_error_t *err) {
347
417
  memset(&model->norm, 0, sizeof(model->norm));
@@ -417,8 +487,13 @@ static se_status_t parse_norm_tables(se_model_t *model, const uint8_t *base, str
417
487
  rc = validate_norm_ranges(model->norm.control, model->norm.control_count, "control", err);
418
488
  if (rc != SE_OK)
419
489
  return rc;
420
- return validate_norm_ranges(model->norm.whitespace, model->norm.whitespace_count, "whitespace",
421
- err);
490
+ rc = validate_norm_ranges(model->norm.whitespace, model->norm.whitespace_count, "whitespace",
491
+ err);
492
+ if (rc != SE_OK)
493
+ return rc;
494
+
495
+ prepare_norm_fast_tables(model);
496
+ return SE_OK;
422
497
  }
423
498
 
424
499
  static se_status_t validate_vocab_hash(const se_model_t *model, struct section vocab_strings,
@@ -845,5 +920,8 @@ size_t se_model_memsize(const se_model_t *model) {
845
920
  if (!model)
846
921
  return 0;
847
922
 
923
+ if (model->mapped)
924
+ return sizeof(se_model_t);
925
+
848
926
  return sizeof(se_model_t) + model->map_size;
849
927
  }
@@ -136,6 +136,12 @@ typedef struct {
136
136
  uint32_t control_count;
137
137
  const se_range_t *whitespace;
138
138
  uint32_t whitespace_count;
139
+ const se_map_entry_t *lower256[256];
140
+ const se_map_entry_t *nfd256[256];
141
+ uint8_t mn256[256];
142
+ uint8_t punct256[256];
143
+ uint8_t control256[256];
144
+ uint8_t whitespace256[256];
139
145
  } se_norm_tables_t;
140
146
 
141
147
  typedef struct {
@@ -382,6 +388,9 @@ static inline int se_is_ascii_boundary(uint32_t cp) {
382
388
  void se_scratch_init(se_scratch_t *s);
383
389
  void se_scratch_free(se_scratch_t *s);
384
390
  int se_scratch_reserve(se_scratch_t *s, uint32_t dim);
391
+ se_scratch_t *se_scratch_acquire(uint32_t dim);
392
+ void se_scratch_release(se_scratch_t *s);
393
+ void se_scratch_drop_thread(void);
385
394
 
386
395
  size_t se_prefix_boundary_len(const se_model_t *model, const uint8_t *input, size_t input_len,
387
396
  size_t target, size_t backscan);