static_embeddings 0.1.1 → 0.1.2

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.
data/docs/ARCHITECTURE.md CHANGED
@@ -24,6 +24,15 @@ Everything expensive, fragile or security-sensitive about reading third-party
24
24
  model files happens once, offline, in a language where it is easy to get
25
25
  right. What remains in C is a bounds-checked mmap and three loops.
26
26
 
27
+ The C side is `se_format.c` (mmap and validation), `se_tokenizer.c`,
28
+ `se_embed.c`, `se_unicode.c`, `se_f16.c` (half-precision codec and kernels),
29
+ `se_topk.c`, `se_alloc_stats.c` (optional allocation counters), and
30
+ `static_embeddings.c` for the Ruby bindings. Shared size arithmetic,
31
+ `static_assert`s on the mmapped struct layouts and the big-endian rejection live
32
+ in `se_internal.h`: the header fields are decoded little-endian explicitly, but
33
+ the mmapped structures and the float matrix are read in native order, so a
34
+ big-endian host is refused at load rather than silently misread.
35
+
27
36
  ## `.semb` v2
28
37
 
29
38
  Little-endian throughout. 320-byte header, then sections aligned to 64 bytes.
@@ -90,10 +99,23 @@ tries inspired by double-array trie libraries such as libdatrie:
90
99
  - `root_trie` for tokens that can start a word;
91
100
  - `continuation_trie` for `##token` entries stored without the `##` prefix.
92
101
 
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.
102
+ At runtime `append_wordpiece` first tries the hash table for the whole word,
103
+ which is the common case for in-vocabulary text, and falls back to walking the
104
+ relevant trie once, recording the longest terminal node. Both halves earn their
105
+ place: removing the hash pre-check made tokenization several times slower on
106
+ ordinary text, and replacing the trie with repeated hash lookups by decreasing
107
+ length made out-of-vocabulary words several times slower still.
108
+
109
+ Normalization has an ASCII fast path. Below `0x80` the CJK, NFD, combining-mark
110
+ and case-folding branches always resolve the same way, so those runs go through
111
+ a 128-entry classification table instead of the full
112
+ `emit_cleaned -> emit_stripped -> emit_lowered -> feed_token_cp` chain, and a
113
+ word reserves its codepoint buffer once rather than once per character. The
114
+ table is generated to agree with `is_control`, `se_is_ascii_whitespace` and
115
+ `se_is_ascii_punct`, and `test/ascii_parity_test.rb` sweeps every byte in
116
+ `0x00..0x7F` against the Ruby reference to keep it that way. That test exists
117
+ because `U+007F DEL` was misclassified through 0.1.1 and every sampled test in
118
+ the suite passed anyway.
97
119
 
98
120
  Loading a model still performs **zero runtime insertions**. It is `mmap` plus a
99
121
  validation pass over the hash table, trie node ranges and trie edge ordering.
@@ -224,6 +246,33 @@ not triple the cost.
224
246
  With `max_tokens: false` there is nothing to truncate and the whole input is
225
247
  copied.
226
248
 
249
+ The window bounds tokenizing, not the whole call. Ruby has to answer whether the
250
+ `String` is valid UTF-8, and `rb_enc_str_coderange` answers it for the whole
251
+ `String` — an O(bytes) scan when Ruby has not computed a coderange yet, running
252
+ before the prefix is even chosen. `validate_encoding: :prefix` skips that scan
253
+ and leans on the fact that the C side already validates what it reads:
254
+ `decode_one` rejects malformed UTF-8, and `se_prefix_boundary_len` returns 0
255
+ when it cannot decode, which degrades to a full copy rather than reading out of
256
+ bounds. So `:prefix` is memory-safe in every case; what it gives up is noticing
257
+ invalid bytes the tokenizer never reaches. A cached coderange is always
258
+ honoured, which keeps the cheap answer authoritative when Ruby already has one.
259
+
260
+ ## Mapping the model file
261
+
262
+ POSIX uses `mmap` with `PROT_READ`/`MAP_PRIVATE`; Windows uses
263
+ `CreateFileMapping` plus `MapViewOfFile`. Both give the same three properties
264
+ the format was designed around: pages fault in lazily instead of being read up
265
+ front, the page cache is shared between forked or sibling processes, and the
266
+ runtime never owns a writable copy of the matrix. Windows previously read the
267
+ file into the heap, which cost every process a private copy of the model and a
268
+ full read before the first query.
269
+
270
+ Windows keeps the heap path as a fallback when mapping fails, since some network
271
+ filesystems refuse it, and `model->mapped` records which one was taken so
272
+ `se_model_close` unmaps or frees correctly. `verify` is separate from loading and
273
+ streams the file through SHA-256 in chunks, so checking an artifact never
274
+ materialises it either.
275
+
227
276
  ## Concurrency
228
277
 
229
278
  Small calls run inline. Large `embed_batch` calls copy Ruby input into C-owned
@@ -239,8 +288,9 @@ job/application layer.
239
288
 
240
289
  Cancellation is cooperative: the tokenizer checks a flag every 1024 codepoints
241
290
  (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
291
+ how wide the input's codepoints are), and the ASCII fast path checks on the same
292
+ cadence in bytes so a long ASCII run is no less interruptible. The pooling loop
293
+ checks every 256 rows, and the top-k scan every 1024 rows. `unblock_cancel` sets that flag when Ruby
244
294
  interrupts a GVL-free region.
245
295
 
246
296
  No global mutable state exists in C. The model is immutable after load and
data/docs/MODEL_AUDIT.md CHANGED
@@ -1,12 +1,24 @@
1
1
  # Model Audit
2
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.
3
+ A converted model is trusted only after parity against upstream
4
+ `model2vec.StaticModel` is recorded here. An audit record covers one `.semb`
5
+ file **and** one runtime version: changing the tokenizer, normalizer, prefix
6
+ window, pooling, output normalization or the oracle's corpus invalidates the
7
+ runtime half of it even though the file bytes are untouched.
6
8
 
7
9
  ## potion-retrieval-32m
8
10
 
9
- Status: audited / accepted
11
+ | runtime | parity | note |
12
+ |---|---|---|
13
+ | 0.1.1 | `parity OK`, recorded below | superseded |
14
+ | 0.1.2 | **not re-run** | required before release |
15
+
16
+ 0.1.2 changed `is_control()`, which changes token ids for any input containing
17
+ `U+007F`. `StaticEmbeddings::Reference` cannot settle whether the new behaviour
18
+ 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.
10
22
 
11
23
  Source model:
12
24
 
@@ -14,11 +26,10 @@ Source model:
14
26
  - Oracle implementation: `model2vec.StaticModel.from_pretrained`
15
27
  - Python package: `model2vec 0.9.0`
16
28
  - Oracle file: `tmp/model2vec_oracle.json`
17
- - Oracle rows: `28`
29
+ - Oracle rows in this recorded run: `28`
18
30
  - Oracle dimension: `512`
19
31
  - 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.
32
+ - Runtime at time of this record: `static_embeddings 0.1.1`
22
33
 
23
34
  Converted `.semb`:
24
35
 
@@ -58,4 +69,6 @@ Decision:
58
69
  - Unknown-word behavior: pass
59
70
  - Long input / truncation behavior: pass
60
71
 
61
- This converted model is accepted for runtime and benchmark use.
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.
data/docs/PERFORMANCE.md CHANGED
@@ -1,60 +1,128 @@
1
1
  # Performance budget
2
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.
3
+ Absolute latency is not comparable across models: `potion-base-2M` and
4
+ `potion-retrieval-32M` differ by roughly 8x in row width, so swapping the model
5
+ would read as a regression. The budget is therefore normalised.
6
6
 
7
- Run `ruby tools/benchmark.rb [model.semb]`.
7
+ ```bash
8
+ ruby tools/benchmark.rb [model.semb]
9
+ ```
8
10
 
9
11
  ## Metrics tracked
10
12
 
11
13
  | Metric | Unit | Why |
12
14
  |---|---|---|
13
- | tokenization | ns / input byte | independent of `dim`; catches normalizer regressions |
15
+ | tokenization | ns / processed byte | independent of `dim`; catches normalizer regressions |
14
16
  | pooling | ns / token / 100 dims | comparable across models |
15
17
  | single embed | µs / call | the latency a query pays |
16
18
  | batch throughput | texts/s, tokens/s | indexing capacity |
17
19
  | 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
20
  | time to first query | ms from `load` to first vector | what a Puma `before_fork` or a serverless cold start pays |
21
+ | C allocation stats | bytes and counts by native category | optional build, explains heap churn |
20
22
 
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.
23
+ ## Where the time actually goes
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.
35
+
36
+ ## `f16` is a storage trade-off
37
+
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:
42
+
43
+ ```text
44
+ 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
47
+ ```
48
+
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`.
52
+
53
+ ## C allocation counters
54
+
55
+ Compiling with `STATIC_EMBEDDINGS_ALLOC_STATS=1` wraps the runtime's own
56
+ allocations and records bytes and counts per category. Default builds do not
57
+ define the internal methods and do not pay for the counters.
26
58
 
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.
59
+ ```bash
60
+ STATIC_EMBEDDINGS_ALLOC_STATS=1 samples/run_all.sh
61
+ ```
31
62
 
32
- ## CI status
63
+ The harness forces `rake clobber compile` in this mode, because an existing
64
+ mkmf Makefile otherwise keeps the previous `DEFS`, and it fails the run if the
65
+ instrumented methods did not load. Samples then print
66
+ `c_alloc.<category>.<metric>` lines.
33
67
 
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.
68
+ Two limits worth stating. The counters cover only allocations made through the
69
+ instrumented wrappers, so Ruby object heap, allocator fragmentation and mmap
70
+ residency are all outside them — use Instruments, heaptrack, Massif or `vmmap`
71
+ for those. And the instrumentation is not free: about 13% on `tokenize`, 3% on
72
+ `embed`, within noise on `embed_batch`. A run captured with it is not
73
+ comparable to one captured without it.
35
74
 
36
75
  ## Benchmark hygiene
37
76
 
38
- Two failure modes have bitten this repository already and are worth stating:
77
+ Three failure modes have bitten this repository already.
39
78
 
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.
79
+ **Logical vs processed bytes.** With truncation active a 3 MB document is
80
+ tokenized only until `max_tokens` is reached, so any "MB/s of input" figure
81
+ computed from the string length divides by bytes that were never read. Samples
82
+ print both `logical_input_mb_per_sec` and a measured
83
+ `processed_input_mb_per_sec`; quote the second. `tools/benchmark.rb` refuses to
84
+ print a ns/byte figure at all when truncation makes the two differ.
49
85
 
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`.
86
+ **Resident working sets.** Embedding the same text, or the same frozen id array,
87
+ in a loop keeps a tiny slice of the matrix in cache and overstates throughput.
88
+ `random_pooling_hot_path.rb` rotates through many id sets and prints
89
+ `touched_matrix_mb` so you can check it exceeds the last level cache.
54
90
 
55
- ## Where the time actually goes
91
+ **GC state.** `GC.disable` does not stop an already started incremental cycle
92
+ from sweeping, so zeroed GC counters used to appear next to profiles full of
93
+ `gc_sweep`. It is off by default in the harness; set `GC_DISABLE=1`
94
+ deliberately and read `gc_disabled=` before quoting `gc_delta`.
95
+
96
+ ## Two costs that are not the tokenizer
97
+
98
+ `embed` cannot start until Ruby has confirmed the `String` is valid UTF-8, and
99
+ for a `String` whose coderange has not been computed that is an O(bytes) scan
100
+ which runs before the prefix window is chosen. On a 3 MB freshly read document
101
+ it dominated: 442 µs against 71 µs for the same string once Ruby had cached the
102
+ answer. Reuse the `String`, call `valid_encoding?` outside the hot path, or pass
103
+ `validate_encoding: :prefix` to bound validation the same way tokenizing is
104
+ bounded — 78 µs, at the cost of not inspecting bytes past the window.
105
+
106
+ `StaticEmbeddings.verify` hashes the artifact and is deliberately not on the
107
+ load path. It streams in 1 MiB chunks: on a 62 MB model, 249 ms and 30 MB of
108
+ peak RSS. Reading the file whole and duplicating it to zero the checksum field
109
+ cost 433 ms and 280 MB.
110
+
111
+ ## Cold start
112
+
113
+ `samples/cold_start.rb` measures `load`, first embed and `warmup!` in one shot,
114
+ because each of those happens exactly once: `load` walks the vocabulary hash and
115
+ both tries, and `warmup!` faults pages in. Drop the page cache first
116
+ (`sudo purge` on macOS, `echo 3 > /proc/sys/vm/drop_caches` on Linux) or the
117
+ output labels itself a warm start.
118
+
119
+ Every other sample runs a hot loop and measures steady state. `warmup_hot_path.rb`
120
+ in particular re-touches resident pages; its `mapped_range_gb_per_sec` is
121
+ address-range coverage, not memory bandwidth, because warmup reads one byte per
122
+ page.
123
+
124
+ ## CI
56
125
 
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.
126
+ The benchmark job is informational. Absolute thresholds on shared runners
127
+ produce noise, not signal; a regression gate is only meaningful against a
128
+ checked-in baseline on the same runner class.
@@ -24,6 +24,9 @@ end
24
24
 
25
25
  $srcs = %w[
26
26
  static_embeddings.c
27
+ se_alloc_stats.c
28
+ se_f16.c
29
+ se_topk.c
27
30
  se_format.c
28
31
  se_unicode.c
29
32
  se_tokenizer.c
@@ -40,5 +43,6 @@ end
40
43
 
41
44
  $CFLAGS += " -O3 -std=gnu99 -fvisibility=hidden"
42
45
  $CFLAGS += " -Wall -Wextra -Wno-unused-parameter"
46
+ $defs << "-DSE_ENABLE_ALLOC_STATS=1" if ENV["STATIC_EMBEDDINGS_ALLOC_STATS"] == "1"
43
47
 
44
48
  create_makefile("static_embeddings/static_embeddings")
@@ -0,0 +1,244 @@
1
+ #include "se_internal.h"
2
+
3
+ #include <stdint.h>
4
+ #include <stdlib.h>
5
+ #include <string.h>
6
+
7
+ static const char *const se_alloc_category_names[SE_ALLOC_CATEGORY_COUNT] = {
8
+ "unknown", "scratch", "batch_input", "batch_output",
9
+ "batch_stats", "batch_index", "token_ids", "topk_query",
10
+ "topk_best", "topk_matrix_copy", "format_validate", "model_file"};
11
+
12
+ const char *se_alloc_category_name(se_alloc_category_t category) {
13
+ if ((unsigned)category >= SE_ALLOC_CATEGORY_COUNT)
14
+ return se_alloc_category_names[SE_ALLOC_UNKNOWN];
15
+ return se_alloc_category_names[category];
16
+ }
17
+
18
+ #if SE_ENABLE_ALLOC_STATS
19
+
20
+ #define SE_ALLOC_STATS_MAGIC 0x5EA110C5u
21
+
22
+ typedef union {
23
+ struct {
24
+ uint32_t magic;
25
+ uint32_t category;
26
+ size_t size;
27
+ } h;
28
+ long double align_long_double;
29
+ void *align_ptr;
30
+ uint64_t align_u64;
31
+ } se_alloc_header_t;
32
+
33
+ static se_alloc_stats_t se_alloc_stats[SE_ALLOC_CATEGORY_COUNT];
34
+
35
+ static se_alloc_category_t normalize_category(se_alloc_category_t category) {
36
+ if ((unsigned)category >= SE_ALLOC_CATEGORY_COUNT)
37
+ return SE_ALLOC_UNKNOWN;
38
+ return category;
39
+ }
40
+
41
+ #if defined(__GNUC__) || defined(__clang__)
42
+ static size_t atomic_add_size(size_t *ptr, size_t value) {
43
+ return __sync_add_and_fetch(ptr, value);
44
+ }
45
+
46
+ static size_t atomic_sub_size(size_t *ptr, size_t value) {
47
+ return __sync_sub_and_fetch(ptr, value);
48
+ }
49
+
50
+ static void atomic_inc_size(size_t *ptr) {
51
+ __sync_fetch_and_add(ptr, (size_t)1);
52
+ }
53
+
54
+ static size_t atomic_load_size(const size_t *ptr) {
55
+ return __sync_fetch_and_add((size_t *)ptr, (size_t)0);
56
+ }
57
+
58
+ static void atomic_store_size(size_t *ptr, size_t value) {
59
+ __sync_lock_test_and_set(ptr, value);
60
+ }
61
+
62
+ static int atomic_cas_size(size_t *ptr, size_t old_value, size_t new_value) {
63
+ return __sync_bool_compare_and_swap(ptr, old_value, new_value);
64
+ }
65
+ #else
66
+ static size_t atomic_add_size(size_t *ptr, size_t value) {
67
+ *ptr += value;
68
+ return *ptr;
69
+ }
70
+
71
+ static size_t atomic_sub_size(size_t *ptr, size_t value) {
72
+ *ptr -= value;
73
+ return *ptr;
74
+ }
75
+
76
+ static void atomic_inc_size(size_t *ptr) {
77
+ (*ptr)++;
78
+ }
79
+
80
+ static size_t atomic_load_size(const size_t *ptr) {
81
+ return *ptr;
82
+ }
83
+
84
+ static void atomic_store_size(size_t *ptr, size_t value) {
85
+ *ptr = value;
86
+ }
87
+
88
+ static int atomic_cas_size(size_t *ptr, size_t old_value, size_t new_value) {
89
+ if (*ptr != old_value)
90
+ return 0;
91
+ *ptr = new_value;
92
+ return 1;
93
+ }
94
+ #endif
95
+
96
+ static void record_peak(se_alloc_category_t category, size_t current) {
97
+ size_t peak = atomic_load_size(&se_alloc_stats[category].peak_bytes);
98
+ while (current > peak) {
99
+ if (atomic_cas_size(&se_alloc_stats[category].peak_bytes, peak, current))
100
+ return;
101
+ peak = atomic_load_size(&se_alloc_stats[category].peak_bytes);
102
+ }
103
+ }
104
+
105
+ static void record_alloc(se_alloc_category_t category, size_t bytes) {
106
+ se_alloc_stats_t *stats = &se_alloc_stats[category];
107
+ size_t current = atomic_add_size(&stats->current_bytes, bytes);
108
+ atomic_add_size(&stats->total_allocated_bytes, bytes);
109
+ atomic_inc_size(&stats->alloc_count);
110
+ record_peak(category, current);
111
+ }
112
+
113
+ static void record_realloc(se_alloc_category_t old_category, size_t old_bytes,
114
+ se_alloc_category_t new_category, size_t new_bytes) {
115
+ se_alloc_stats_t *old_stats = &se_alloc_stats[old_category];
116
+ se_alloc_stats_t *new_stats = &se_alloc_stats[new_category];
117
+ atomic_sub_size(&old_stats->current_bytes, old_bytes);
118
+ atomic_add_size(&old_stats->total_freed_bytes, old_bytes);
119
+ atomic_inc_size(&old_stats->free_count);
120
+
121
+ size_t current = atomic_add_size(&new_stats->current_bytes, new_bytes);
122
+ atomic_add_size(&new_stats->total_allocated_bytes, new_bytes);
123
+ atomic_inc_size(&new_stats->alloc_count);
124
+ atomic_inc_size(&new_stats->realloc_count);
125
+ record_peak(new_category, current);
126
+ }
127
+
128
+ static void record_free(se_alloc_category_t category, size_t bytes) {
129
+ se_alloc_stats_t *stats = &se_alloc_stats[category];
130
+ atomic_sub_size(&stats->current_bytes, bytes);
131
+ atomic_add_size(&stats->total_freed_bytes, bytes);
132
+ atomic_inc_size(&stats->free_count);
133
+ }
134
+
135
+ static se_alloc_header_t *header_from_ptr(void *ptr) {
136
+ return ((se_alloc_header_t *)ptr) - 1;
137
+ }
138
+
139
+ void *se_alloc_stats_malloc(se_alloc_category_t category, size_t bytes) {
140
+ size_t total = 0;
141
+ if (!se_checked_add_size(sizeof(se_alloc_header_t), bytes, &total))
142
+ return NULL;
143
+
144
+ se_alloc_header_t *header = (se_alloc_header_t *)malloc(total);
145
+ if (!header)
146
+ return NULL;
147
+
148
+ category = normalize_category(category);
149
+ header->h.magic = SE_ALLOC_STATS_MAGIC;
150
+ header->h.category = (uint32_t)category;
151
+ header->h.size = bytes;
152
+ record_alloc(category, bytes);
153
+ return (void *)(header + 1);
154
+ }
155
+
156
+ void *se_alloc_stats_calloc(se_alloc_category_t category, size_t count, size_t elem_size) {
157
+ size_t bytes = 0;
158
+ if (!se_checked_mul_size(count, elem_size, &bytes))
159
+ return NULL;
160
+
161
+ void *ptr = se_alloc_stats_malloc(category, bytes);
162
+ if (ptr)
163
+ memset(ptr, 0, bytes);
164
+ return ptr;
165
+ }
166
+
167
+ void *se_alloc_stats_realloc(se_alloc_category_t category, void *ptr, size_t bytes) {
168
+ if (!ptr)
169
+ return se_alloc_stats_malloc(category, bytes);
170
+
171
+ size_t total = 0;
172
+ if (!se_checked_add_size(sizeof(se_alloc_header_t), bytes, &total))
173
+ return NULL;
174
+
175
+ se_alloc_header_t *old_header = header_from_ptr(ptr);
176
+ if (old_header->h.magic != SE_ALLOC_STATS_MAGIC)
177
+ abort();
178
+
179
+ se_alloc_category_t old_category = normalize_category((se_alloc_category_t)old_header->h.category);
180
+ size_t old_bytes = old_header->h.size;
181
+ se_alloc_header_t *new_header = (se_alloc_header_t *)realloc(old_header, total);
182
+ if (!new_header)
183
+ return NULL;
184
+
185
+ category = normalize_category(category);
186
+ new_header->h.magic = SE_ALLOC_STATS_MAGIC;
187
+ new_header->h.category = (uint32_t)category;
188
+ new_header->h.size = bytes;
189
+ record_realloc(old_category, old_bytes, category, bytes);
190
+ return (void *)(new_header + 1);
191
+ }
192
+
193
+ void se_alloc_stats_free(void *ptr) {
194
+ if (!ptr)
195
+ return;
196
+
197
+ se_alloc_header_t *header = header_from_ptr(ptr);
198
+ if (header->h.magic != SE_ALLOC_STATS_MAGIC)
199
+ abort();
200
+
201
+ se_alloc_category_t stored_category =
202
+ normalize_category((se_alloc_category_t)header->h.category);
203
+ size_t bytes = header->h.size;
204
+ header->h.magic = 0;
205
+ record_free(stored_category, bytes);
206
+ free(header);
207
+ }
208
+
209
+ void se_alloc_stats_reset(void) {
210
+ for (size_t i = 0; i < SE_ALLOC_CATEGORY_COUNT; i++) {
211
+ se_alloc_stats_t *stats = &se_alloc_stats[i];
212
+ size_t current = atomic_load_size(&stats->current_bytes);
213
+ atomic_store_size(&stats->peak_bytes, current);
214
+ atomic_store_size(&stats->total_allocated_bytes, 0);
215
+ atomic_store_size(&stats->total_freed_bytes, 0);
216
+ atomic_store_size(&stats->alloc_count, 0);
217
+ atomic_store_size(&stats->realloc_count, 0);
218
+ atomic_store_size(&stats->free_count, 0);
219
+ }
220
+ }
221
+
222
+ void se_alloc_stats_snapshot(se_alloc_stats_t out[SE_ALLOC_CATEGORY_COUNT]) {
223
+ for (size_t i = 0; i < SE_ALLOC_CATEGORY_COUNT; i++) {
224
+ out[i].current_bytes = atomic_load_size(&se_alloc_stats[i].current_bytes);
225
+ out[i].peak_bytes = atomic_load_size(&se_alloc_stats[i].peak_bytes);
226
+ out[i].total_allocated_bytes =
227
+ atomic_load_size(&se_alloc_stats[i].total_allocated_bytes);
228
+ out[i].total_freed_bytes = atomic_load_size(&se_alloc_stats[i].total_freed_bytes);
229
+ out[i].alloc_count = atomic_load_size(&se_alloc_stats[i].alloc_count);
230
+ out[i].realloc_count = atomic_load_size(&se_alloc_stats[i].realloc_count);
231
+ out[i].free_count = atomic_load_size(&se_alloc_stats[i].free_count);
232
+ }
233
+ }
234
+
235
+ #else
236
+
237
+ void se_alloc_stats_reset(void) {
238
+ }
239
+
240
+ void se_alloc_stats_snapshot(se_alloc_stats_t out[SE_ALLOC_CATEGORY_COUNT]) {
241
+ memset(out, 0, sizeof(se_alloc_stats_t) * SE_ALLOC_CATEGORY_COUNT);
242
+ }
243
+
244
+ #endif