gigatoken 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Cargo.lock +2 -2
- data/Cargo.toml +1 -1
- data/README.md +54 -11
- data/ext/gigatoken/Cargo.toml +1 -1
- data/ext/gigatoken/src/cache.rs +37 -0
- data/ext/gigatoken/src/lib.rs +25 -0
- data/ext/gigatoken/src/sentencepiece.rs +12 -1
- data/ext/gigatoken/src/tokenizer.rs +33 -4
- data/lib/gigatoken/cli/bench.rb +4 -3
- data/lib/gigatoken/cli/support.rb +7 -4
- data/lib/gigatoken/cli/validate.rb +4 -3
- data/lib/gigatoken/encodings/PROVENANCE.md +110 -0
- data/lib/gigatoken/encodings/cl100k_base.tiktoken +100256 -0
- data/lib/gigatoken/encodings/o200k_base.tiktoken +199998 -0
- data/lib/gigatoken/encodings/r50k_base.tiktoken +50256 -0
- data/lib/gigatoken/encodings.rb +103 -0
- data/lib/gigatoken/tokenizer.rb +48 -11
- data/lib/gigatoken/version.rb +1 -1
- data/lib/gigatoken.rb +15 -0
- data/src/batch.rs +2 -7
- data/src/bindings/cache.rs +38 -0
- data/src/bindings/mod.rs +3 -1
- data/src/bindings/pretokenize.rs +11 -0
- data/src/bpe/pretoken_cache.rs +32 -1
- data/src/bpe/sentencepiece.rs +137 -5
- data/src/bpe/tiktoken.rs +784 -31
- data/src/lib.rs +52 -35
- data/src/load_tokenizer/hf.rs +1 -0
- data/src/load_tokenizer/tiktoken.rs +17 -49
- data/src/main.rs +6 -2
- data/src/pretokenize/options.rs +16 -1
- metadata +9 -1
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gigatoken
|
|
4
|
+
# The tiktoken encodings gigatoken vendors ranks for, and the pieces a
|
|
5
|
+
# .tiktoken file doesn't carry: its pretokenizer scheme and special-token
|
|
6
|
+
# table (see Tokenizer.from_tiktoken and lib/gigatoken/encodings/
|
|
7
|
+
# PROVENANCE.md, the source of truth this is transcribed from).
|
|
8
|
+
module Encodings
|
|
9
|
+
DATA_DIR = File.expand_path("encodings", __dir__)
|
|
10
|
+
private_constant :DATA_DIR
|
|
11
|
+
|
|
12
|
+
# The non-contiguous head of openai_public.py's o200k_harmony()
|
|
13
|
+
# special-token table, transcribed verbatim (see PROVENANCE.md): its ten
|
|
14
|
+
# named control tokens, plus the six reserved slots sitting in the gaps
|
|
15
|
+
# between them (200000, 200001, 200004, 200009, 200010, 200011). The
|
|
16
|
+
# reserved range only goes contiguous at 200013.
|
|
17
|
+
HARMONY_HEAD_TOKENS = {
|
|
18
|
+
"<|startoftext|>" => 199998,
|
|
19
|
+
"<|endoftext|>" => 199999,
|
|
20
|
+
"<|reserved_200000|>" => 200000,
|
|
21
|
+
"<|reserved_200001|>" => 200001,
|
|
22
|
+
"<|return|>" => 200002,
|
|
23
|
+
"<|constrain|>" => 200003,
|
|
24
|
+
"<|reserved_200004|>" => 200004,
|
|
25
|
+
"<|channel|>" => 200005,
|
|
26
|
+
"<|start|>" => 200006,
|
|
27
|
+
"<|end|>" => 200007,
|
|
28
|
+
"<|message|>" => 200008,
|
|
29
|
+
"<|reserved_200009|>" => 200009,
|
|
30
|
+
"<|reserved_200010|>" => 200010,
|
|
31
|
+
"<|reserved_200011|>" => 200011,
|
|
32
|
+
"<|call|>" => 200012,
|
|
33
|
+
"<|endofprompt|>" => 200018
|
|
34
|
+
}.freeze
|
|
35
|
+
private_constant :HARMONY_HEAD_TOKENS
|
|
36
|
+
|
|
37
|
+
# The contiguous tail of that reserved range: 200013..201087. With the
|
|
38
|
+
# head above, 1091 entries total — 10 named, 1081 reserved (see
|
|
39
|
+
# PROVENANCE.md).
|
|
40
|
+
HARMONY_RESERVED_TAIL = (200013..201087).to_h { |id| ["<|reserved_#{id}|>", id] }.freeze
|
|
41
|
+
private_constant :HARMONY_RESERVED_TAIL
|
|
42
|
+
|
|
43
|
+
REGISTRY = {
|
|
44
|
+
"r50k_base" => {
|
|
45
|
+
rank_file: File.join(DATA_DIR, "r50k_base.tiktoken"),
|
|
46
|
+
pretokenizer: "gpt2",
|
|
47
|
+
special_tokens: {"<|endoftext|>" => 50256}
|
|
48
|
+
},
|
|
49
|
+
"cl100k_base" => {
|
|
50
|
+
rank_file: File.join(DATA_DIR, "cl100k_base.tiktoken"),
|
|
51
|
+
pretokenizer: "gpt4",
|
|
52
|
+
special_tokens: {
|
|
53
|
+
"<|endoftext|>" => 100257,
|
|
54
|
+
"<|fim_prefix|>" => 100258,
|
|
55
|
+
"<|fim_middle|>" => 100259,
|
|
56
|
+
"<|fim_suffix|>" => 100260,
|
|
57
|
+
"<|endofprompt|>" => 100276
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"o200k_base" => {
|
|
61
|
+
rank_file: File.join(DATA_DIR, "o200k_base.tiktoken"),
|
|
62
|
+
pretokenizer: "o200k",
|
|
63
|
+
special_tokens: {"<|endoftext|>" => 199999, "<|endofprompt|>" => 200018}
|
|
64
|
+
},
|
|
65
|
+
"o200k_harmony" => {
|
|
66
|
+
rank_file: File.join(DATA_DIR, "o200k_base.tiktoken"),
|
|
67
|
+
pretokenizer: "o200k",
|
|
68
|
+
special_tokens: HARMONY_HEAD_TOKENS.merge(HARMONY_RESERVED_TAIL).freeze
|
|
69
|
+
}
|
|
70
|
+
}.freeze
|
|
71
|
+
private_constant :REGISTRY
|
|
72
|
+
|
|
73
|
+
# The packaged encoding names — the single source error messages naming
|
|
74
|
+
# what's available are built from (the same discipline
|
|
75
|
+
# Native.pretokenizer_names / PretokenizerType::NAMES applies to
|
|
76
|
+
# pretokenizer scheme names).
|
|
77
|
+
NAMES = REGISTRY.keys.freeze
|
|
78
|
+
|
|
79
|
+
# Encodings known by name but deliberately not packaged, keyed to the
|
|
80
|
+
# reason a caller asking for one by name deserves to hear.
|
|
81
|
+
UNPACKABLE_REASONS = {
|
|
82
|
+
"p50k_base" => "its ranks are not dense (50256 is left free for <|endoftext|>), " \
|
|
83
|
+
"and the rank loader rejects non-dense ranks",
|
|
84
|
+
"p50k_edit" => "it loads the same p50k_base.tiktoken ranks, which are not dense " \
|
|
85
|
+
"(50256 is left free for <|endoftext|>), and the rank loader rejects non-dense ranks"
|
|
86
|
+
}.freeze
|
|
87
|
+
private_constant :UNPACKABLE_REASONS
|
|
88
|
+
|
|
89
|
+
class << self
|
|
90
|
+
# The {rank_file:, pretokenizer:, special_tokens:} registered for a
|
|
91
|
+
# packaged encoding name, or nil.
|
|
92
|
+
def [](name)
|
|
93
|
+
REGISTRY[name]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Why `name` can't be packaged, or nil when there's no reason on
|
|
97
|
+
# record (it's either packaged, or simply not one gigatoken knows of).
|
|
98
|
+
def unpackable_reason(name)
|
|
99
|
+
UNPACKABLE_REASONS[name]
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
data/lib/gigatoken/tokenizer.rb
CHANGED
|
@@ -7,9 +7,6 @@ module Gigatoken
|
|
|
7
7
|
# over a native `Gigatoken::Native::BPETokenizer` or
|
|
8
8
|
# `Gigatoken::Native::SentencePieceTokenizer`.
|
|
9
9
|
class Tokenizer
|
|
10
|
-
TIKTOKEN_ENDOFTEXT = "<|endoftext|>"
|
|
11
|
-
private_constant :TIKTOKEN_ENDOFTEXT
|
|
12
|
-
|
|
13
10
|
FILE_SOURCE_CLASSES = [Native::TextFileSource, Native::JsonlFileSource, Native::ParquetFileSource].freeze
|
|
14
11
|
private_constant :FILE_SOURCE_CLASSES
|
|
15
12
|
|
|
@@ -27,10 +24,27 @@ module Gigatoken
|
|
|
27
24
|
from_json(File.binread(path))
|
|
28
25
|
end
|
|
29
26
|
|
|
30
|
-
# Load from a .tiktoken mergeable-ranks file.
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
27
|
+
# Load from a .tiktoken mergeable-ranks file. The file carries neither a
|
|
28
|
+
# pretokenization scheme nor special tokens — nothing is guessed here,
|
|
29
|
+
# so `pretokenizer:` is required: one of the schemes gigatoken ships
|
|
30
|
+
# (see Native.pretokenizer_names, e.g. "gpt2"/"r50k", "gpt4"/"cl100k",
|
|
31
|
+
# "o200k", "qwen2", "qwen35", "olmo3", "deepseek_v3", "nemotron", "kimi").
|
|
32
|
+
# `special_tokens:` maps token content to id (none by default).
|
|
33
|
+
def self.from_tiktoken(path, pretokenizer:, special_tokens: {})
|
|
34
|
+
native = Native::BPETokenizer.from_tiktoken(path.to_s, pretokenizer, special_tokens)
|
|
35
|
+
new(native, special_tokens: special_tokens)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Load one of the tiktoken encodings gigatoken vendors ranks for, by
|
|
39
|
+
# name — see Gigatoken::Encodings::NAMES — entirely from the vendored
|
|
40
|
+
# files: no network, no writable cache.
|
|
41
|
+
def self.from_encoding(name)
|
|
42
|
+
encoding = Encodings[name]
|
|
43
|
+
return from_tiktoken(encoding[:rank_file], pretokenizer: encoding[:pretokenizer], special_tokens: encoding[:special_tokens]) if encoding
|
|
44
|
+
|
|
45
|
+
reason = Encodings.unpackable_reason(name)
|
|
46
|
+
detail = reason ? " — #{reason}" : ""
|
|
47
|
+
raise Error, "#{name.inspect}: not a packaged encoding#{detail} (packaged encodings: #{Encodings::NAMES.join(", ")})"
|
|
34
48
|
end
|
|
35
49
|
|
|
36
50
|
# Load tokenizer.json from HuggingFace Hub repo `repo_id` at `revision`
|
|
@@ -41,12 +55,29 @@ module Gigatoken
|
|
|
41
55
|
|
|
42
56
|
# Load from any of the supported source shapes: an existing file or
|
|
43
57
|
# directory path (a tokenizer.json, or a directory containing one), a
|
|
44
|
-
# .tiktoken vocabulary file,
|
|
45
|
-
# "
|
|
46
|
-
|
|
58
|
+
# .tiktoken vocabulary file, a packaged encoding name (see
|
|
59
|
+
# Gigatoken::Encodings::NAMES, e.g. "cl100k_base"), or a HuggingFace Hub
|
|
60
|
+
# repo id like "openai-community/gpt2". A .tiktoken file carries no
|
|
61
|
+
# pretokenizer scheme of its own, so one must be named explicitly via
|
|
62
|
+
# `pretokenizer:` — nothing here is guessed. Packaged encoding names are
|
|
63
|
+
# checked before the Hub-repo-id shape: a bare name like "o200k_base" is
|
|
64
|
+
# also shaped like a legacy repo id, and must resolve locally rather
|
|
65
|
+
# than reach the network. Names the registry knows but doesn't package
|
|
66
|
+
# (see Encodings.unpackable_reason, e.g. "p50k_base") are intercepted
|
|
67
|
+
# here too, raising the same explanation from_encoding gives rather than
|
|
68
|
+
# reaching the Hub — but only those; an unrecognized bare name like
|
|
69
|
+
# "gpt2" still dispatches to the Hub.
|
|
70
|
+
def self.load(source, pretokenizer: nil, special_tokens: {}, revision: "main", hub: Hub.new)
|
|
47
71
|
source = source.to_s
|
|
48
|
-
|
|
72
|
+
if source.end_with?(".tiktoken")
|
|
73
|
+
unless pretokenizer
|
|
74
|
+
raise Error, "#{source.inspect}: a .tiktoken file carries no pretokenizer scheme of its own — " \
|
|
75
|
+
"pass pretokenizer: (one of #{Native.pretokenizer_names.join(", ")})"
|
|
76
|
+
end
|
|
77
|
+
return from_tiktoken(source, pretokenizer: pretokenizer, special_tokens: special_tokens)
|
|
78
|
+
end
|
|
49
79
|
return from_file(source) if File.exist?(source)
|
|
80
|
+
return from_encoding(source) if Encodings::NAMES.include?(source) || Encodings.unpackable_reason(source)
|
|
50
81
|
return from_hub(source, revision: revision, hub: hub) if Hub.looks_like_repo_id?(source)
|
|
51
82
|
|
|
52
83
|
raise Error, "#{source.inspect}: no such file or directory, not a .tiktoken path, and doesn't look like a HuggingFace Hub repo id"
|
|
@@ -112,6 +143,12 @@ module Gigatoken
|
|
|
112
143
|
@native.merges
|
|
113
144
|
end
|
|
114
145
|
|
|
146
|
+
# Cached pretoken/unit entries on this tokenizer's single-document
|
|
147
|
+
# encode path — see Gigatoken.max_cache_bytes.
|
|
148
|
+
def cache_entries
|
|
149
|
+
@native.cache_entries
|
|
150
|
+
end
|
|
151
|
+
|
|
115
152
|
attr_reader :special_tokens
|
|
116
153
|
end
|
|
117
154
|
end
|
data/lib/gigatoken/version.rb
CHANGED
data/lib/gigatoken.rb
CHANGED
|
@@ -7,6 +7,20 @@ module Gigatoken
|
|
|
7
7
|
# extension — never a raw Rust panic across the Ruby boundary.
|
|
8
8
|
class Error < StandardError; end
|
|
9
9
|
|
|
10
|
+
class << self
|
|
11
|
+
# The process-global encode-cache budget in bytes per worker (a parallel
|
|
12
|
+
# batch encode may use up to workers x budget), applied to tokenizers of
|
|
13
|
+
# either backend constructed *afterward* — changing it never affects an
|
|
14
|
+
# already-built Tokenizer. nil means unbounded. Default: 512 MiB.
|
|
15
|
+
def max_cache_bytes
|
|
16
|
+
Native.get_max_cache_bytes
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def max_cache_bytes=(bytes)
|
|
20
|
+
Native.set_max_cache_bytes(bytes)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
10
24
|
NATIVE_EXTENSIONS = %w[.bundle .so .rb].freeze
|
|
11
25
|
|
|
12
26
|
# Precompiled native gems ship per-ABI subdirs (`gigatoken/4.0/...`),
|
|
@@ -26,4 +40,5 @@ require native
|
|
|
26
40
|
|
|
27
41
|
require_relative "gigatoken/hub"
|
|
28
42
|
require_relative "gigatoken/packed_result"
|
|
43
|
+
require_relative "gigatoken/encodings"
|
|
29
44
|
require_relative "gigatoken/tokenizer"
|
data/src/batch.rs
CHANGED
|
@@ -600,13 +600,8 @@ impl Committer {
|
|
|
600
600
|
unsafe {
|
|
601
601
|
flat.set_len(total);
|
|
602
602
|
}
|
|
603
|
-
//
|
|
604
|
-
// the
|
|
605
|
-
// mmap'd chunks via mremap): pointer-stable, no copy, and the
|
|
606
|
-
// untouched tail pages were never faulted so there is nothing to
|
|
607
|
-
// tear down. An allocator that copies instead only costs one
|
|
608
|
-
// memcpy; correctness is unaffected.
|
|
609
|
-
flat.shrink_to_fit();
|
|
603
|
+
// The unused reservation tail is unfaulted virtual address space —
|
|
604
|
+
// freed with the Vec on drop, no trim needed to reclaim it.
|
|
610
605
|
Some(flat)
|
|
611
606
|
}
|
|
612
607
|
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
//! The process-global cache-budget knob, read once per tokenizer
|
|
2
|
+
//! construction. Semantics in the .pyi stubs, mechanism in
|
|
3
|
+
//! `Tokenizer::set_max_cache_bytes`.
|
|
4
|
+
|
|
5
|
+
use crate::bpe::{SentencePieceBPE, Tokenizer};
|
|
6
|
+
use pyo3::prelude::*;
|
|
7
|
+
use std::sync::Mutex;
|
|
8
|
+
|
|
9
|
+
/// The budget applied to tokenizers constructed after the last
|
|
10
|
+
/// `set_max_cache_bytes` call; `None` = unbounded.
|
|
11
|
+
static MAX_CACHE_BYTES: Mutex<Option<usize>> = Mutex::new(Some(Tokenizer::DEFAULT_MAX_CACHE_BYTES));
|
|
12
|
+
|
|
13
|
+
/// Apply the global setting to a freshly constructed tokenizer (which
|
|
14
|
+
/// already carries the built-in default, hence the `!=` skip).
|
|
15
|
+
pub(crate) fn apply_max_cache_bytes(mut tokenizer: Tokenizer) -> Tokenizer {
|
|
16
|
+
let configured = *MAX_CACHE_BYTES.lock().unwrap();
|
|
17
|
+
if configured != tokenizer.max_cache_bytes() {
|
|
18
|
+
tokenizer.set_max_cache_bytes(configured);
|
|
19
|
+
}
|
|
20
|
+
tokenizer
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/// SentencePiece analog of [`apply_max_cache_bytes`].
|
|
24
|
+
pub(crate) fn apply_max_cache_bytes_sp(mut model: SentencePieceBPE) -> SentencePieceBPE {
|
|
25
|
+
model.set_max_cache_bytes(*MAX_CACHE_BYTES.lock().unwrap());
|
|
26
|
+
model
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
#[pyfunction]
|
|
30
|
+
#[pyo3(signature = (max_bytes))]
|
|
31
|
+
pub(crate) fn set_max_cache_bytes(max_bytes: Option<usize>) {
|
|
32
|
+
*MAX_CACHE_BYTES.lock().unwrap() = max_bytes;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
#[pyfunction]
|
|
36
|
+
pub(crate) fn get_max_cache_bytes() -> Option<usize> {
|
|
37
|
+
*MAX_CACHE_BYTES.lock().unwrap()
|
|
38
|
+
}
|
data/src/bindings/mod.rs
CHANGED
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
//! submodule per feature: Python<->Rust bridging shared by the bindings
|
|
4
4
|
//! (bridge), the FileSource/BytesSource classes (sources), BPE training (train), padded
|
|
5
5
|
//! compat-API encoding (padding), the compat layers' special-token scanner
|
|
6
|
-
//! (matcher),
|
|
6
|
+
//! (matcher), the pretokenizer helpers (pretokenize), and the
|
|
7
|
+
//! process-global cache-budget knob (cache).
|
|
7
8
|
|
|
8
9
|
pub(crate) mod bridge;
|
|
10
|
+
pub(crate) mod cache;
|
|
9
11
|
pub(crate) mod hub;
|
|
10
12
|
pub(crate) mod matcher;
|
|
11
13
|
pub(crate) mod padding;
|
data/src/bindings/pretokenize.rs
CHANGED
|
@@ -29,6 +29,17 @@ impl PretokenizerIter {
|
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/// The pretokenization scheme a loader was given by name, for the sources
|
|
33
|
+
/// whose format carries no split regex (tiktoken rank files).
|
|
34
|
+
pub(crate) fn pretokenizer_scheme(name: &str) -> PyResult<pretokenize::PretokenizerType> {
|
|
35
|
+
pretokenize::PretokenizerType::from_name(name).ok_or_else(|| {
|
|
36
|
+
pyo3::exceptions::PyValueError::new_err(format!(
|
|
37
|
+
"unknown pretokenizer scheme {name:?}; expected one of {}",
|
|
38
|
+
pretokenize::PretokenizerType::NAMES.join(", ")
|
|
39
|
+
))
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
32
43
|
#[pyfunction]
|
|
33
44
|
pub(crate) fn pretokenizer<'py>(text: Bound<'py, PyBytes>) -> PyResult<PretokenizerIter> {
|
|
34
45
|
Ok(PretokenizerIter {
|
data/src/bpe/pretoken_cache.rs
CHANGED
|
@@ -178,11 +178,42 @@ impl ShortPretokenCache {
|
|
|
178
178
|
/// 3/4 load. Either way the table is constructed exactly once, at the
|
|
179
179
|
/// max of the two requirements.
|
|
180
180
|
pub(crate) fn with_at_least(n: usize, min_slots: usize) -> Self {
|
|
181
|
+
Self::with_pow2_capacity(Self::required_capacity(n, min_slots))
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/// The slot count [`Self::with_at_least`] would allocate for `n`
|
|
185
|
+
/// entries starting from `min_slots`, without allocating anything.
|
|
186
|
+
pub(crate) fn required_capacity(n: usize, min_slots: usize) -> usize {
|
|
181
187
|
let mut cap = min_slots.max(1 << 16).next_power_of_two();
|
|
182
188
|
while (n + 1) * 4 > cap * 3 {
|
|
183
189
|
cap *= 2;
|
|
184
190
|
}
|
|
185
|
-
|
|
191
|
+
cap
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/// Zero every slot in place (all-zero == all-empty), keeping the
|
|
195
|
+
/// allocation — the generational-wipe path. Like [`Self::insert`]'s
|
|
196
|
+
/// grow, this logically invalidates any outstanding [`ProbeView`].
|
|
197
|
+
pub(crate) fn clear(&mut self) {
|
|
198
|
+
// SAFETY: the allocation holds exactly `cap` entries.
|
|
199
|
+
unsafe { std::ptr::write_bytes(self.slots.ptr.as_ptr(), 0, self.slots.cap) };
|
|
200
|
+
self.len = 0;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/// Reset to an empty table of exactly `cap` slots (power of two,
|
|
204
|
+
/// >= 2), reusing the current allocation when the capacity matches.
|
|
205
|
+
pub(crate) fn reset_to_capacity(&mut self, cap: usize) {
|
|
206
|
+
debug_assert!(cap.is_power_of_two() && cap >= 2);
|
|
207
|
+
if cap == self.slots.cap {
|
|
208
|
+
self.clear();
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
// Drop the old table (via a 2-slot placeholder) before building
|
|
212
|
+
// the new one, so two full-size tables never coexist.
|
|
213
|
+
self.slots = Slots::new_zeroed(2);
|
|
214
|
+
self.slots = Slots::new_zeroed(cap);
|
|
215
|
+
self.mask = cap - 1;
|
|
216
|
+
self.len = 0;
|
|
186
217
|
}
|
|
187
218
|
|
|
188
219
|
/// Address of `h`'s home pair; both slots share the addressed line.
|
data/src/bpe/sentencepiece.rs
CHANGED
|
@@ -296,6 +296,8 @@ pub struct SentencePieceBPE {
|
|
|
296
296
|
/// Bitset over the byte just before a candidate boundary (the last byte
|
|
297
297
|
/// of some `cross_pieces` pre) for a one-load rejection in the scanner.
|
|
298
298
|
pub(crate) cross_prev: [u64; 4],
|
|
299
|
+
/// Cache budget snapshot by each [`Self::encoder`]; `None` = unbounded.
|
|
300
|
+
pub(crate) max_cache_bytes: Option<usize>,
|
|
299
301
|
}
|
|
300
302
|
|
|
301
303
|
/// How many distinct punctuation bytes the unit splitter checks for.
|
|
@@ -764,10 +766,22 @@ impl SentencePieceBPE {
|
|
|
764
766
|
pub fn encoder(&self) -> Encoder<'_> {
|
|
765
767
|
Encoder {
|
|
766
768
|
model: self,
|
|
767
|
-
state: EncodeState::
|
|
769
|
+
state: EncodeState::with_budget(self.max_cache_bytes),
|
|
768
770
|
}
|
|
769
771
|
}
|
|
770
772
|
|
|
773
|
+
/// SentencePiece analog of [`super::Tokenizer::set_max_cache_bytes`],
|
|
774
|
+
/// same default: bounds the caches of [`EncodeState`]s created
|
|
775
|
+
/// afterward, which wipe back to empty (there is no vocab seed here).
|
|
776
|
+
pub fn set_max_cache_bytes(&mut self, budget: Option<usize>) {
|
|
777
|
+
self.max_cache_bytes = budget;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/// The configured cache budget in bytes; `None` when unbounded.
|
|
781
|
+
pub fn max_cache_bytes(&self) -> Option<usize> {
|
|
782
|
+
self.max_cache_bytes
|
|
783
|
+
}
|
|
784
|
+
|
|
771
785
|
/// Size of the vocabulary: one greater than the largest token ID,
|
|
772
786
|
/// including added tokens (IDs with no assigned content count too).
|
|
773
787
|
pub fn vocab_size(&self) -> usize {
|
|
@@ -854,6 +868,14 @@ fn collapse_space_runs(input: &str, content: &str) -> String {
|
|
|
854
868
|
/// Zipf-hot working set resident despite eviction churn from tail units.
|
|
855
869
|
const FRONT_BITS: u32 = 20;
|
|
856
870
|
|
|
871
|
+
/// Fixed footprint of the front cache (keys + vals), subtracted from the
|
|
872
|
+
/// budget before bounding the growing caches.
|
|
873
|
+
const FRONT_BYTES: usize = (1 << FRONT_BITS) * (16 + 8);
|
|
874
|
+
/// Estimated bytes per map entry, payload + SwissTable slack (`long` adds
|
|
875
|
+
/// its key bytes on top). Length-based, because the wipe keeps allocations.
|
|
876
|
+
const SHORT_ENTRY_BYTES: usize = 48;
|
|
877
|
+
const LONG_ENTRY_BYTES: usize = 48;
|
|
878
|
+
|
|
857
879
|
/// Index of `key` in the front cache: multiplicative hash, top bits.
|
|
858
880
|
#[inline(always)]
|
|
859
881
|
fn front_index(key: u128) -> usize {
|
|
@@ -882,10 +904,26 @@ pub struct EncodeState {
|
|
|
882
904
|
symbols: Vec<TokenId>,
|
|
883
905
|
/// Scratch for composing keys with a virtual leading space.
|
|
884
906
|
key_buf: Vec<u8>,
|
|
907
|
+
/// Byte budget for the growing caches ([`Self::with_budget`]);
|
|
908
|
+
/// `usize::MAX` = unbounded.
|
|
909
|
+
budget: usize,
|
|
910
|
+
/// Estimated `long` bytes: key bytes + [`LONG_ENTRY_BYTES`] per entry.
|
|
911
|
+
long_bytes_used: usize,
|
|
912
|
+
/// Largest single encoding, as wipe-trigger slack: one recurring giant
|
|
913
|
+
/// unit must not force a wipe per occurrence. Kept across wipes.
|
|
914
|
+
max_encoding: usize,
|
|
885
915
|
}
|
|
886
916
|
|
|
887
917
|
impl EncodeState {
|
|
888
918
|
pub fn new() -> Self {
|
|
919
|
+
Self::with_budget(Some(super::Tokenizer::DEFAULT_MAX_CACHE_BYTES))
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/// A state whose growing caches (short/long maps + arena; the front
|
|
923
|
+
/// cache is fixed-size) wipe back to empty when their estimated
|
|
924
|
+
/// footprint exceeds `max_bytes` minus the front cache, floored at
|
|
925
|
+
/// 1 MiB so tiny budgets stay functional.
|
|
926
|
+
pub fn with_budget(max_bytes: Option<usize>) -> Self {
|
|
889
927
|
EncodeState {
|
|
890
928
|
arena: Vec::new(),
|
|
891
929
|
front_keys: vec![0u128; 1 << FRONT_BITS],
|
|
@@ -894,6 +932,10 @@ impl EncodeState {
|
|
|
894
932
|
long: HashMap::with_hasher(FxBuildHasher),
|
|
895
933
|
symbols: Vec::new(),
|
|
896
934
|
key_buf: Vec::new(),
|
|
935
|
+
budget: max_bytes
|
|
936
|
+
.map_or(usize::MAX, |t| t.saturating_sub(FRONT_BYTES).max(1 << 20)),
|
|
937
|
+
long_bytes_used: 0,
|
|
938
|
+
max_encoding: 0,
|
|
897
939
|
}
|
|
898
940
|
}
|
|
899
941
|
|
|
@@ -901,6 +943,24 @@ impl EncodeState {
|
|
|
901
943
|
pub fn cache_size(&self) -> usize {
|
|
902
944
|
self.short.len() + self.long.len()
|
|
903
945
|
}
|
|
946
|
+
|
|
947
|
+
#[inline]
|
|
948
|
+
fn over_budget(&self) -> bool {
|
|
949
|
+
self.short.len() * SHORT_ENTRY_BYTES + self.long_bytes_used + self.arena.len() * 4
|
|
950
|
+
> self.budget.saturating_add(self.max_encoding * 4)
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/// Wipe every cache back to empty, keeping allocations. The front keys
|
|
954
|
+
/// must go too: their values are slices of the truncated arena.
|
|
955
|
+
#[cold]
|
|
956
|
+
#[inline(never)]
|
|
957
|
+
fn wipe(&mut self) {
|
|
958
|
+
self.arena.clear();
|
|
959
|
+
self.front_keys.fill(0);
|
|
960
|
+
self.short.clear();
|
|
961
|
+
self.long.clear();
|
|
962
|
+
self.long_bytes_used = 0;
|
|
963
|
+
}
|
|
904
964
|
}
|
|
905
965
|
|
|
906
966
|
impl Default for EncodeState {
|
|
@@ -1330,7 +1390,8 @@ impl SentencePieceBPE {
|
|
|
1330
1390
|
let (offset, len) = unsafe { *state.front_vals.get_unchecked(front_idx) };
|
|
1331
1391
|
let start = offset as usize;
|
|
1332
1392
|
// SAFETY: entries are recorded right after appending `len`
|
|
1333
|
-
// tokens at `offset`;
|
|
1393
|
+
// tokens at `offset`; the arena only clears in a budget
|
|
1394
|
+
// wipe, which also zeroes every front key.
|
|
1334
1395
|
f(unsafe { state.arena.get_unchecked(start..start + len as usize) });
|
|
1335
1396
|
return;
|
|
1336
1397
|
}
|
|
@@ -1347,13 +1408,18 @@ impl SentencePieceBPE {
|
|
|
1347
1408
|
}
|
|
1348
1409
|
let start = offset as usize;
|
|
1349
1410
|
// SAFETY: every cached (offset, len) was recorded right after
|
|
1350
|
-
// appending those `len` tokens at `offset
|
|
1351
|
-
//
|
|
1411
|
+
// appending those `len` tokens at `offset`; the arena only
|
|
1412
|
+
// clears in a budget wipe, which also clears both maps.
|
|
1352
1413
|
f(unsafe { state.arena.get_unchecked(start..start + len as usize) });
|
|
1353
1414
|
return;
|
|
1354
1415
|
}
|
|
1355
1416
|
|
|
1356
|
-
// Miss:
|
|
1417
|
+
// Miss: budget check first (the lookups above already missed, and
|
|
1418
|
+
// stay misses against the freshly wiped caches), then character
|
|
1419
|
+
// init → ranked merge, then record in the arena.
|
|
1420
|
+
if state.over_budget() {
|
|
1421
|
+
state.wipe();
|
|
1422
|
+
}
|
|
1357
1423
|
state.symbols.clear();
|
|
1358
1424
|
if virtual_prefix {
|
|
1359
1425
|
state.symbols.extend_from_slice(&self.space_init);
|
|
@@ -1367,6 +1433,7 @@ impl SentencePieceBPE {
|
|
|
1367
1433
|
let offset = state.arena.len() as u32;
|
|
1368
1434
|
let len = state.symbols.len() as u32;
|
|
1369
1435
|
state.arena.extend_from_slice(&state.symbols);
|
|
1436
|
+
state.max_encoding = state.max_encoding.max(len as usize);
|
|
1370
1437
|
match packed {
|
|
1371
1438
|
Some(key) => {
|
|
1372
1439
|
state.short.insert(key, (offset, len));
|
|
@@ -1379,6 +1446,7 @@ impl SentencePieceBPE {
|
|
|
1379
1446
|
} else {
|
|
1380
1447
|
unit.into()
|
|
1381
1448
|
};
|
|
1449
|
+
state.long_bytes_used += key.len() + LONG_ENTRY_BYTES;
|
|
1382
1450
|
state.long.insert(key, (offset, len));
|
|
1383
1451
|
}
|
|
1384
1452
|
}
|
|
@@ -1482,4 +1550,68 @@ mod tests {
|
|
|
1482
1550
|
assert_eq!(collapse_space_runs(" ", "▁"), "▁");
|
|
1483
1551
|
assert_eq!(collapse_space_runs("no runs", "▁"), "no runs");
|
|
1484
1552
|
}
|
|
1553
|
+
|
|
1554
|
+
/// PARITY + BOUNDS: a budgeted EncodeState that wipes mid-corpus must
|
|
1555
|
+
/// produce the exact token stream of an unbounded one, while the
|
|
1556
|
+
/// estimated footprint of the growing caches stays within the budget
|
|
1557
|
+
/// (plus the documented giant-encoding slack).
|
|
1558
|
+
#[test]
|
|
1559
|
+
fn budgeted_wipe_matches_unbounded() {
|
|
1560
|
+
let Some(path) = crate::test_hub::hf_tokenizer_json("TinyLlama/TinyLlama-1.1B-Chat-v1.0")
|
|
1561
|
+
else {
|
|
1562
|
+
eprintln!("Skipping: TinyLlama tokenizer.json not in the HF cache");
|
|
1563
|
+
return;
|
|
1564
|
+
};
|
|
1565
|
+
let model = crate::load_tokenizer::hf::load_hf_sentencepiece(&path).unwrap();
|
|
1566
|
+
assert_eq!(
|
|
1567
|
+
model.max_cache_bytes(),
|
|
1568
|
+
Some(super::super::Tokenizer::DEFAULT_MAX_CACHE_BYTES)
|
|
1569
|
+
);
|
|
1570
|
+
|
|
1571
|
+
// ~100k distinct short words plus interleaved > 15-byte words (the
|
|
1572
|
+
// long-map path) and repeats of a common head.
|
|
1573
|
+
let mut text = String::new();
|
|
1574
|
+
let mut x = 0x1234_5678_9ABC_DEF0u64;
|
|
1575
|
+
let mut rand = |m: u64| {
|
|
1576
|
+
x ^= x << 13;
|
|
1577
|
+
x ^= x >> 7;
|
|
1578
|
+
x ^= x << 17;
|
|
1579
|
+
x % m
|
|
1580
|
+
};
|
|
1581
|
+
for i in 0..100_000u32 {
|
|
1582
|
+
let len = if i % 17 == 0 { 16 + rand(24) } else { 3 + rand(8) };
|
|
1583
|
+
for _ in 0..len {
|
|
1584
|
+
text.push((b'a' + rand(26) as u8) as char);
|
|
1585
|
+
}
|
|
1586
|
+
text.push(' ');
|
|
1587
|
+
if i % 5 == 0 {
|
|
1588
|
+
text.push_str("the quick brown fox ");
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
let mut unbounded = EncodeState::with_budget(None);
|
|
1593
|
+
let mut expected = Vec::new();
|
|
1594
|
+
model.encode_raw_with(&mut unbounded, &text, &mut expected);
|
|
1595
|
+
|
|
1596
|
+
// 25 MiB total = ~1 MiB effective past the 24 MiB front cache:
|
|
1597
|
+
// several wipes over ~100k distinct units.
|
|
1598
|
+
let mut budgeted = EncodeState::with_budget(Some(25 << 20));
|
|
1599
|
+
let mut actual = Vec::new();
|
|
1600
|
+
model.encode_raw_with(&mut budgeted, &text, &mut actual);
|
|
1601
|
+
assert_eq!(actual, expected, "budgeted output diverged");
|
|
1602
|
+
assert!(budgeted.long_bytes_used > 0, "corpus never hit the long map");
|
|
1603
|
+
// Both saw ~100k distinct units, so a small survivor count proves
|
|
1604
|
+
// wipes happened, and the estimate must end up within budget.
|
|
1605
|
+
assert!(
|
|
1606
|
+
budgeted.cache_size() < unbounded.cache_size() / 3,
|
|
1607
|
+
"survivors {} vs unbounded {}",
|
|
1608
|
+
budgeted.cache_size(),
|
|
1609
|
+
unbounded.cache_size()
|
|
1610
|
+
);
|
|
1611
|
+
let used = budgeted.short.len() * SHORT_ENTRY_BYTES
|
|
1612
|
+
+ budgeted.long_bytes_used
|
|
1613
|
+
+ budgeted.arena.len() * 4;
|
|
1614
|
+
let limit = budgeted.budget + budgeted.max_encoding * 4;
|
|
1615
|
+
assert!(used <= limit + 4096, "estimate {used} exceeds budget {limit}");
|
|
1616
|
+
}
|
|
1485
1617
|
}
|