gigatoken 0.1.0-x86_64-linux

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 169719e5720f7407805a3cd8060d5826c406eacd4b9b251d55c92bf670db80de
4
+ data.tar.gz: d9dbba2c2267bf27661bd583cc09765c69d1376982ab9d43a3e85cf215b5b635
5
+ SHA512:
6
+ metadata.gz: d0e545b0874158a8e45b9495cb5fa6611be6175aa1d045a427a952791a4fdbf6efd6ed42c23971c8926ec82be0722cc5cdfdfd6c8d2f13f1c810fa19b08eddbc
7
+ data.tar.gz: 3fe5187f0c53c69508bf75accca80e533ae40cd23f6c825122f71a47ac89c3ef02142386ad30cf51223bf32b9b778da1e4af0619e0a9b2f0f01685972b8179e6
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Marcel Rød
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,141 @@
1
+ # gigatoken-rb
2
+
3
+ **12 GB/s / 2.8 billion tokens per second in Ruby.**
4
+
5
+ Ruby bindings for [marcelroed/gigatoken](https://github.com/marcelroed/gigatoken), the fastest open-source BPE tokenizer around — running **1.6x faster than upstream's own Python package**, on the same Rust engine.
6
+
7
+ | | Corpus | MB/s (median) | Gtok/s (median) |
8
+ |---|---|---|---|
9
+ | **gigatoken** (this gem, Ruby) | 11.9 GB | **12,278** | **2.78** |
10
+ | gigatoken (Python wheel, upstream) | 11.9 GB | 7,400 | 1.68 |
11
+ | tiktoken (Python) | 1.35 GB | 69.7 | 0.0158 |
12
+ | tiktoken_ruby | 1.35 GB | 30.7 | 0.0070 |
13
+ | tokenizers gem (ankane) | 1.35 GB | 10.0 | 0.0023 |
14
+ | tokenizers (Python, Hugging Face) | 1.35 GB | 5.6 | 0.0013 |
15
+
16
+ Mac Studio M4 Max, OpenWebText, GPT-2 tokenizer; every library produces the same tokenization, gigatoken just does it faster. **340x faster** than the fastest existing Ruby gem (tiktoken_ruby) and **1,050x faster** than the tokenizers gem. Full methodology, exact counts, and the caveats that matter: [docs/rb/benchmarks.md](docs/rb/benchmarks.md).
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ gem install gigatoken
22
+ ```
23
+
24
+ Precompiled native gems ship for Apple Silicon macOS (`arm64-darwin`) and x86_64/aarch64 Linux — on those platforms RubyGems grabs the binary automatically, no Rust toolchain, no compile wait. In a Bundler project it's one command:
25
+
26
+ ```bash
27
+ bundle add gigatoken
28
+ ```
29
+
30
+ (or drop `gem "gigatoken"` into the Gemfile yourself).
31
+
32
+ Anywhere else (or with `--platform ruby` to opt out of the binary), the extension builds from source. That needs a Rust toolchain: `rust-toolchain.toml` pins the nightly, and `rustup` fetches it automatically on first build.
33
+
34
+ ## Use
35
+
36
+ ```ruby
37
+ require "gigatoken"
38
+
39
+ tok = Gigatoken::Tokenizer.load("openai-community/gpt2")
40
+
41
+ tok.encode("Hello, world!") # => [15496, 11, 995, 0]
42
+ tok.decode([15496, 11, 995, 0]) # => "Hello, world!"
43
+ tok.encode_batch(["Hello!", "Another"]) # => [[15496, 0], [6610]]
44
+
45
+ tok.vocab_size # => 50257
46
+ tok.special_tokens # => {"<|endoftext|>" => 50256}
47
+ ```
48
+
49
+ `load` takes a `tokenizer.json` path, a directory holding one, a HuggingFace Hub repo id, or a `.tiktoken` mergeable-ranks file, and dispatches on shape. Hub downloads run over socketry's `async-http` — no Python anywhere. Know what you have? Skip the dispatch:
50
+
51
+ ```ruby
52
+ Gigatoken::Tokenizer.from_file("tokenizer.json")
53
+ Gigatoken::Tokenizer.from_hub("openai-community/gpt2", revision: "main")
54
+ Gigatoken::Tokenizer.from_tiktoken("vocab.tiktoken")
55
+ Gigatoken::Tokenizer.from_json(File.binread("tokenizer.json"))
56
+ ```
57
+
58
+ SentencePiece-BPE models (Llama, Gemma, Mistral — any `tokenizer.json` with `byte_fallback: true`) load through the same entry points and pick the right backend automatically. One difference: the SentencePiece core decodes text, so it validates input and raises `Gigatoken::Error` on invalid UTF-8 instead of guessing.
59
+
60
+ ### Tokenize files without leaving Rust
61
+
62
+ `encode_files` reads and tokenizes files entirely on the native side — document contents never materialize as Ruby objects. `.gz` and `.zst` decompress transparently.
63
+
64
+ ```ruby
65
+ tok.encode_files("owt_train.txt", separator: "<|endoftext|>")
66
+
67
+ jsonl = Gigatoken::Native::JsonlFileSource.new(["docs.jsonl"], field: "text")
68
+ parquet = Gigatoken::Native::ParquetFileSource.new(["docs.parquet"], column: "text")
69
+ tok.encode_files(jsonl)
70
+ ```
71
+
72
+ ### Packed results
73
+
74
+ Pass `packed: true` to `encode_batch` or `encode_files` and results land in a single `IO::Buffer` of u32 token ids instead of a ragged Array of Arrays — no per-token Ruby allocation, the fastest way out of the engine:
75
+
76
+ ```ruby
77
+ packed = tok.encode_files("owt_train.txt", packed: true, separator: "<|endoftext|>")
78
+
79
+ packed.buffer # => one IO::Buffer, every document's ids back to back
80
+ packed.lens # => [12, 8, 41, ...] tokens per document
81
+ packed.token_count # => total tokens
82
+ packed[3] # => document 3's ids as an Array, on demand
83
+ ```
84
+
85
+ ### Async
86
+
87
+ `encode_batch` and `encode_files` release the GVL for the whole encode; the parallelism runs on the engine's rayon pool, not Ruby threads. Under `Async`, give the fiber scheduler a worker pool (`ASYNC_SCHEDULER_WORKER_POOL=true`) and the calling fiber yields to the reactor too. Design notes: [docs/rb/async.md](docs/rb/async.md).
88
+
89
+ ## CLI
90
+
91
+ ```bash
92
+ gigatoken bench openai-community/gpt2 owt_train.txt --doc-separator "<|endoftext|>"
93
+ gigatoken validate openai-community/gpt2 owt_train.txt --doc-separator "<|endoftext|>"
94
+ ```
95
+
96
+ `bench` reports MB/s and Mtok/s (`--packed` for the fused packed path, `--no-parallel` for the serial core). `validate` confirms native split-and-encode agrees with a Ruby-side split through `encode_batch`.
97
+
98
+ ## Development
99
+
100
+ ```bash
101
+ bundle install
102
+ bundle exec rake compile # native extension (Rust nightly, via rust-toolchain.toml)
103
+ bundle exec rspec
104
+ bundle exec standardrb
105
+ ```
106
+
107
+ The Ruby layer is fiber-first throughout — no `Thread`, no `Mutex`; all parallelism lives in the core's rayon pool. CI runs ubuntu + macos × Ruby 3.3/3.4/4.0, and `release.yml` cross-builds the precompiled native gems (arm64-darwin, x86_64-linux, aarch64-linux).
108
+
109
+ ## Fork status
110
+
111
+ This fork exists because I need fast tokenization in Ruby. The Rust core is changed as little as possible from upstream. Most of the python shell has been removed from this fork, but you can still find it [upstream](https://github.com/marcelroed/gigatoken).
112
+
113
+ Not ported/no current plans:
114
+ - the HF/tiktoken Python compat shims
115
+ - padded-batch matrices
116
+ - and BPE training
117
+
118
+ SentencePiece works but — matching upstream — is less optimized than the BPE path.
119
+
120
+ ## Citation
121
+
122
+ The engine is Marcel Rød's gigatoken. If it shows up in your research, cite that:
123
+
124
+ ```bibtex
125
+ @software{roed2026gigatoken,
126
+ author = {Marcel R{\o}d},
127
+ title = {{G}igatoken: SIMD and Cache Hierarchies for 1000x Faster Byte-Pair Encoding Tokenization on Modern CPUs},
128
+ url = {https://github.com/marcelroed/gigatoken},
129
+ year = {2026},
130
+ }
131
+ ```
132
+
133
+ ---
134
+
135
+ <details>
136
+ <summary>AI Use Disclosure</summary>
137
+
138
+ The Rust engine is upstream's — see <a href="https://github.com/marcelroed/gigatoken#readme">upstream's AI-use disclosure</a> for how that was built (majority hand-crafted, AI-assisted toward the end).
139
+
140
+ The Ruby port in this fork is 100% AI generated using Fable 5 and Sonnet 5 via [space-architect](https://github.com/jetpks/space-architect) over ~24 hours.
141
+ </details>
data/exe/gigatoken ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # The packed path uses IO::Buffer, which warns as experimental.
5
+ Warning[:experimental] = false
6
+
7
+ require "gigatoken/cli"
8
+
9
+ Dry::CLI.new(Gigatoken::CLI::Commands).call
Binary file
Binary file
Binary file
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "support"
4
+
5
+ module Gigatoken
6
+ module CLI
7
+ # `gigatoken bench TOKENIZER FILES...` — measure encode throughput in
8
+ # MB/s and Mtok/s, mirroring upstream's Python CLI `gigatoken bench`
9
+ # output shape.
10
+ class Bench < Dry::CLI::Command
11
+ desc "Measure the time to encode FILES with TOKENIZER"
12
+
13
+ argument :tokenizer, required: true, desc: "tokenizer.json path or directory, HuggingFace repo id, or .tiktoken file"
14
+ argument :files, type: :array, required: true, desc: "UTF-8 text files to encode"
15
+
16
+ option :doc_separator, desc: 'document separator to split the files on, e.g. "<|endoftext|>"; whole files are single documents otherwise'
17
+ option :limit_bytes, default: "none", desc: "cap the bytes benchmarked, e.g. 100MB; 'none' for everything (parallel mode only — ignored with --no-parallel)"
18
+ option :parallel, type: :boolean, default: true, desc: "encode on the worker pool instead of the fused serial core path"
19
+ option :packed, type: :boolean, default: false, desc: "time the fused native file path with a packed IO::Buffer result instead of per-document Ruby arrays (ignores --limit-bytes)"
20
+
21
+ def call(tokenizer:, files:, doc_separator: nil, limit_bytes: "none", parallel: true, packed: false, **)
22
+ limit = Support.parse_size(limit_bytes)
23
+ out.puts "#{label("cpu")}: #{Support.cpu_info}"
24
+
25
+ gt_tokenizer = Support.load_tokenizer(tokenizer)
26
+
27
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
28
+ if packed
29
+ encoded = gt_tokenizer.encode_files(Support.text_file_source(files, doc_separator), parallel: parallel, packed: true)
30
+ n_bytes = files.sum { |file| File.size(file) }
31
+ n_tokens = encoded.token_count
32
+ elsif parallel
33
+ docs = Support.subset_docs(Support.split_docs(files, doc_separator), limit)
34
+ encoded = gt_tokenizer.encode_batch(docs)
35
+ n_bytes = docs.sum(&:bytesize)
36
+ n_tokens = encoded.sum(&:length)
37
+ else
38
+ encoded = gt_tokenizer.encode_files(Support.text_file_source(files, doc_separator), parallel: false)
39
+ n_bytes = files.sum { |file| File.size(file) }
40
+ n_tokens = encoded.sum(&:length)
41
+ end
42
+ seconds = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
43
+
44
+ out.puts report("gigatoken", seconds, n_bytes, n_tokens)
45
+ rescue Gigatoken::Error => e
46
+ err.puts "error: #{e.message}"
47
+ exit(1)
48
+ end
49
+
50
+ private
51
+
52
+ def label(name)
53
+ format("%9s", name)
54
+ end
55
+
56
+ def report(name, seconds, n_bytes, n_tokens)
57
+ mb = n_bytes / 1e6
58
+ mtok = n_tokens / 1e6
59
+ format("%s: %8.3f s | %10.2f MB at %8.2f MB/s | %8.2f Mtok at %7.2f Mtok/s",
60
+ label(name), seconds, mb, mb / seconds, mtok, mtok / seconds)
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "etc"
4
+
5
+ module Gigatoken
6
+ module CLI
7
+ # Helpers shared by the bench and validate commands: tokenizer loading,
8
+ # byte-size parsing, document splitting, and CPU identification.
9
+ module Support
10
+ SIZE_UNITS = {"" => 1, "k" => 10**3, "m" => 10**6, "g" => 10**9, "t" => 10**12}.freeze
11
+ private_constant :SIZE_UNITS
12
+
13
+ SIZE_PATTERN = /\A\s*(\d+(?:\.\d+)?)\s*([kmgt]?)i?b?\s*\z/i
14
+ private_constant :SIZE_PATTERN
15
+
16
+ class << self
17
+ # Load TOKENIZER: a tokenizer.json path/directory, a HuggingFace
18
+ # repo id, or a .tiktoken file — see Gigatoken::Tokenizer.load.
19
+ def load_tokenizer(spec)
20
+ Gigatoken::Tokenizer.load(spec)
21
+ end
22
+
23
+ # Parse a decimal byte size like "100MB", "2.5GB", or "1000000";
24
+ # "none"/"unlimited" means no limit.
25
+ def parse_size(text)
26
+ return nil if ["none", "unlimited"].include?(text.strip.downcase)
27
+
28
+ match = SIZE_PATTERN.match(text)
29
+ raise Gigatoken::Error, "cannot parse size #{text.inspect}; expected something like 100MB (or 'none')" unless match
30
+
31
+ (match[1].to_f * SIZE_UNITS.fetch(match[2].downcase)).to_i
32
+ end
33
+
34
+ # A Native::TextFileSource for FILES, split on `separator` when
35
+ # given.
36
+ def text_file_source(files, separator)
37
+ Gigatoken::Native::TextFileSource.new(files.map(&:to_s), separator: separator)
38
+ end
39
+
40
+ # Whole files as raw bytes, one document per file, or (with a
41
+ # separator) the separator-split pieces of each file in order, empty
42
+ # documents skipped.
43
+ def split_docs(files, separator)
44
+ raws = files.map { |file| File.binread(file.to_s) }
45
+ return raws if separator.nil?
46
+
47
+ sep = separator.b
48
+ raws.flat_map { |raw| raw.split(sep).reject(&:empty?) }
49
+ end
50
+
51
+ # The prefix of `docs` totalling at most `limit_bytes`, byte-
52
+ # truncating the final document to fill the budget. Unlike a
53
+ # text-comparison tool, gigatoken encodes raw bytes and does not
54
+ # require the cut to land on a UTF-8 character boundary.
55
+ def subset_docs(docs, limit_bytes)
56
+ return docs if limit_bytes.nil?
57
+
58
+ subset = []
59
+ used = 0
60
+ docs.each do |doc|
61
+ room = limit_bytes - used
62
+ if doc.bytesize <= room
63
+ subset << doc
64
+ used += doc.bytesize
65
+ else
66
+ subset << doc.byteslice(0, room) if room > 0
67
+ break
68
+ end
69
+ end
70
+ subset
71
+ end
72
+
73
+ # The benchmark machine's CPU as "name, N cores", plus ", M sockets"
74
+ # when there is more than one socket.
75
+ def cpu_info
76
+ name, cores, sockets =
77
+ case RbConfig::CONFIG["host_os"]
78
+ when /darwin/ then darwin_cpu_info
79
+ when /linux/ then linux_cpu_info
80
+ end
81
+ name ||= RbConfig::CONFIG["host_cpu"] || "unknown CPU"
82
+ cores ||= Etc.nprocessors
83
+ parts = [name, "#{cores} core#{"s" unless cores == 1}"]
84
+ parts << "#{sockets} sockets" if sockets && sockets > 1
85
+ parts.join(", ")
86
+ end
87
+
88
+ private
89
+
90
+ def darwin_cpu_info
91
+ name = sysctl("machdep.cpu.brand_string")
92
+ [name, sysctl_int("hw.physicalcpu"), sysctl_int("hw.packages")]
93
+ end
94
+
95
+ def sysctl(key)
96
+ output = IO.popen(["sysctl", "-n", key], err: File::NULL, &:read)
97
+ output.strip unless output.nil? || output.empty? || !$?.success?
98
+ rescue Errno::ENOENT
99
+ nil
100
+ end
101
+
102
+ def sysctl_int(key)
103
+ value = sysctl(key)
104
+ Integer(value) if value&.match?(/\A\d+\z/)
105
+ end
106
+
107
+ # Within each processor block "physical id" precedes "core id", so
108
+ # (socket, core) pairs count physical cores across sockets.
109
+ def linux_cpu_info
110
+ name = nil
111
+ physical_ids = Set.new
112
+ socket_core_ids = Set.new
113
+ physical_id = ""
114
+ File.foreach("/proc/cpuinfo") do |line|
115
+ key, _, value = line.partition(":")
116
+ key, value = key.strip, value.strip
117
+ case key
118
+ when "model name" then name ||= value
119
+ when "physical id"
120
+ physical_id = value
121
+ physical_ids << value
122
+ when "core id" then socket_core_ids << [physical_id, value]
123
+ end
124
+ end
125
+ [name, (socket_core_ids.size unless socket_core_ids.empty?), (physical_ids.size unless physical_ids.empty?)]
126
+ rescue Errno::ENOENT
127
+ [nil, nil, nil]
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "support"
4
+
5
+ module Gigatoken
6
+ module CLI
7
+ # `gigatoken validate TOKENIZER FILES...` — a Ruby-vs-Ruby consistency
8
+ # check: encode FILES via `Tokenizer#encode_files` (native file loading
9
+ # and splitting) and via a Ruby-side split plus `Tokenizer#encode_batch`,
10
+ # and confirm the two paths agree per document. Cross-library validation
11
+ # against another tokenizer implementation is out of scope for v1
12
+ # (BRIEF §3.4).
13
+ class Validate < Dry::CLI::Command
14
+ desc "Check that encode_files agrees with a Ruby-side split plus encode_batch on FILES"
15
+
16
+ argument :tokenizer, required: true, desc: "tokenizer.json path or directory, HuggingFace repo id, or .tiktoken file"
17
+ argument :files, type: :array, required: true, desc: "UTF-8 text files to encode"
18
+
19
+ option :doc_separator, desc: 'document separator to split the files on, e.g. "<|endoftext|>"; whole files are single documents otherwise'
20
+
21
+ def call(tokenizer:, files:, doc_separator: nil, **)
22
+ gt_tokenizer = Support.load_tokenizer(tokenizer)
23
+
24
+ via_files = gt_tokenizer.encode_files(Support.text_file_source(files, doc_separator))
25
+ via_batch = gt_tokenizer.encode_batch(Support.split_docs(files, doc_separator))
26
+
27
+ if via_files.length != via_batch.length
28
+ err.puts "validation FAILED: encode_files produced #{via_files.length} documents, encode_batch produced #{via_batch.length}"
29
+ exit(1)
30
+ end
31
+
32
+ via_files.each_with_index do |doc, index|
33
+ next if doc == via_batch[index]
34
+
35
+ report_mismatch(index, doc, via_batch[index])
36
+ exit(1)
37
+ end
38
+
39
+ out.puts "validation OK: #{via_files.length} documents match"
40
+ rescue Gigatoken::Error => e
41
+ err.puts "error: #{e.message}"
42
+ exit(1)
43
+ end
44
+
45
+ private
46
+
47
+ def report_mismatch(index, from_files, from_batch)
48
+ at = from_files.zip(from_batch).index { |a, b| a != b } || [from_files.length, from_batch.length].min
49
+ err.puts "validation FAILED: document #{index}: first mismatch at token #{at} " \
50
+ "(encode_files #{from_files[at, 5]}... vs encode_batch #{from_batch[at, 5]}..., " \
51
+ "lengths #{from_files.length} vs #{from_batch.length})"
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "gigatoken"
4
+ require "dry/cli"
5
+
6
+ require_relative "cli/bench"
7
+ require_relative "cli/validate"
8
+
9
+ module Gigatoken
10
+ module CLI
11
+ module Commands
12
+ extend Dry::CLI::Registry
13
+
14
+ register "bench", Gigatoken::CLI::Bench
15
+ register "validate", Gigatoken::CLI::Validate
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async"
4
+ require "async/http"
5
+ require "fileutils"
6
+ require "pathname"
7
+
8
+ module Gigatoken
9
+ # HuggingFace Hub file fetch, mirroring `huggingface_hub.hf_hub_download`:
10
+ # same endpoint and URL layout, same token discovery (HF_TOKEN env var,
11
+ # then the token file written by `hf auth login`), same cache directory
12
+ # resolution — without requiring huggingface_hub, tokenizers, or
13
+ # transformers. Files already present in the standard HF cache are served
14
+ # with a pure-filesystem lookup (no network); on a miss the file is
15
+ # downloaded straight into the shared cache, so later loads (ours and
16
+ # huggingface_hub's) are served from it.
17
+ #
18
+ # Network I/O runs on async-http, on the reactor: #hub_file wraps its
19
+ # fetch in `Sync`, so it composes whether the caller is already inside a
20
+ # reactor or is plain sync code.
21
+ class Hub
22
+ # Filename suffixes of local tokenizer files (tokenizer.json contents
23
+ # and raw sentencepiece models). A name ending in one of these is never
24
+ # treated as a Hub repo id, so a mistyped local path fails fast instead
25
+ # of hitting the network. Keep in sync with `src/load_tokenizer/hub.rs`'s
26
+ # TOKENIZER_FILE_SUFFIXES.
27
+ TOKENIZER_FILE_SUFFIXES = [".json", ".model"].freeze
28
+
29
+ DEFAULT_ENDPOINT = "https://huggingface.co"
30
+ private_constant :DEFAULT_ENDPOINT
31
+
32
+ MAX_REDIRECTS = 10
33
+ private_constant :MAX_REDIRECTS
34
+
35
+ class << self
36
+ # Whether `name` is shaped like a HuggingFace Hub repo id: `org/name`,
37
+ # or a bare legacy repo name like `gpt2`. At most one slash, and not
38
+ # something that is obviously a filesystem path to a local tokenizer
39
+ # file.
40
+ def looks_like_repo_id?(name)
41
+ parts = name.split("/", -1)
42
+ return false if parts.empty? || parts.size > 2
43
+
44
+ org, rest = parts
45
+ return false unless word_part?(org, first_alnum: true)
46
+ return false if rest && !word_part?(rest, first_alnum: false)
47
+
48
+ TOKENIZER_FILE_SUFFIXES.none? { |suffix| name.end_with?(suffix) }
49
+ end
50
+
51
+ # $HF_HOME, defaulting to $XDG_CACHE_HOME/huggingface then
52
+ # ~/.cache/huggingface — the root for both the hub cache and the token
53
+ # file.
54
+ def hf_home
55
+ Pathname.new(env("HF_HOME") || File.join(env("XDG_CACHE_HOME") || File.join(Dir.home, ".cache"), "huggingface"))
56
+ end
57
+
58
+ # The standard HuggingFace hub cache directory, resolved like
59
+ # huggingface_hub does it: HF_HUB_CACHE, then $HF_HOME/hub.
60
+ def hf_hub_cache_dir
61
+ Pathname.new(env("HF_HUB_CACHE") || hf_home.join("hub").to_s)
62
+ end
63
+
64
+ # The HuggingFace access token, discovered like huggingface_hub does
65
+ # it: the HF_TOKEN (or legacy HUGGING_FACE_HUB_TOKEN) environment
66
+ # variable, then the token file (HF_TOKEN_PATH, default $HF_HOME/token).
67
+ def hf_token
68
+ token = env("HF_TOKEN") || env("HUGGING_FACE_HUB_TOKEN")
69
+ return token.strip if token
70
+
71
+ token_path = env("HF_TOKEN_PATH") || hf_home.join("token").to_s
72
+ return nil unless File.file?(token_path)
73
+
74
+ token = File.read(token_path).strip
75
+ token unless token.empty?
76
+ end
77
+
78
+ # Path of `filename` in the local HF cache, or nil when not cached. A
79
+ # pure-filesystem lookup — no request is made. `revision` may be a
80
+ # commit hash (used directly as the snapshot name) or a branch/tag
81
+ # name (followed through the cached ref).
82
+ def cached_file(repo_id, filename, revision)
83
+ repo_dir = repo_cache_dir(repo_id)
84
+ commit = commit_hash?(revision) ? revision : cached_ref(repo_dir, revision)
85
+ return nil unless commit
86
+
87
+ path = repo_dir.join("snapshots", commit, filename)
88
+ path if path.file?
89
+ end
90
+
91
+ # The cache directory of a repo (`models--org--name`).
92
+ def repo_cache_dir(repo_id)
93
+ hf_hub_cache_dir.join("models--#{repo_id.gsub("/", "--")}")
94
+ end
95
+
96
+ # A full git commit hash: cache snapshot directories are named by
97
+ # these.
98
+ def commit_hash?(revision)
99
+ revision.match?(/\A[0-9a-f]{40}\z/)
100
+ end
101
+
102
+ private
103
+
104
+ def env(key)
105
+ value = ENV[key]
106
+ value unless value.nil? || value.empty?
107
+ end
108
+
109
+ def word_part?(part, first_alnum:)
110
+ return false if part.nil? || part.empty?
111
+
112
+ first_ok = first_alnum ? part[0].match?(/[A-Za-z0-9]/) : word_char?(part[0])
113
+ first_ok && part[1..].chars.all? { |c| word_char?(c) }
114
+ end
115
+
116
+ def word_char?(char)
117
+ char.match?(/[A-Za-z0-9_.-]/)
118
+ end
119
+
120
+ def cached_ref(repo_dir, revision)
121
+ ref_path = repo_dir.join("refs", revision)
122
+ File.read(ref_path).strip if ref_path.file?
123
+ end
124
+ end
125
+
126
+ # @parameter endpoint [String] the Hub endpoint to fetch from — override
127
+ # for pointing at a local server in tests (dependency injection, not a
128
+ # mock).
129
+ def initialize(endpoint: DEFAULT_ENDPOINT)
130
+ @endpoint = endpoint.chomp("/")
131
+ @internet = Async::HTTP::Internet.new
132
+ end
133
+
134
+ # Path of `filename` from Hub repo `repo_id` at `revision`, served from
135
+ # the standard HF cache, downloading into it first when absent.
136
+ def hub_file(repo_id, filename = "tokenizer.json", revision: "main")
137
+ self.class.cached_file(repo_id, filename, revision) ||
138
+ Sync { fetch(repo_id, filename, revision) }
139
+ end
140
+
141
+ private
142
+
143
+ # GET `endpoint/repo/resolve/revision/filename` and stream the body into
144
+ # the cache snapshot named by the `x-repo-commit` response header,
145
+ # recording the branch ref so later lookups (ours and
146
+ # huggingface_hub's) resolve it.
147
+ def fetch(repo_id, filename, revision)
148
+ url = "#{@endpoint}/#{repo_id}/resolve/#{revision}/#{filename}"
149
+ token = self.class.hf_token
150
+ response = @internet.get(url, auth_headers(token))
151
+ # Unlisted headers parse as a Header::Generic (an Array of values);
152
+ # x-repo-commit is always a single value, so flatten it to a String.
153
+ commit = response.headers["x-repo-commit"]&.to_s
154
+
155
+ # Redirects are followed by hand: resolve/ URLs answer with the
156
+ # x-repo-commit header and a redirect to a CDN for LFS files, and the
157
+ # Authorization header must not travel to the other host.
158
+ hops = 0
159
+ while (300...400).cover?(response.status)
160
+ ensure_ok!(url, response.status, !!token)
161
+ hops += 1
162
+ raise Error, "#{url}: too many redirects" if hops > MAX_REDIRECTS
163
+
164
+ location = response.headers["location"] ||
165
+ raise(Error, "#{url}: redirect with no Location header")
166
+ response.close
167
+ url = absolutize(location, url)
168
+ response = @internet.get(url, {"user-agent" => "gigatoken"})
169
+ end
170
+ ensure_ok!(url, response.status, !!token)
171
+
172
+ write_to_cache(repo_id, filename, revision, commit || revision, response)
173
+ end
174
+
175
+ def auth_headers(token)
176
+ headers = {"user-agent" => "gigatoken"}
177
+ headers["authorization"] = "Bearer #{token}" if token
178
+ headers
179
+ end
180
+
181
+ # Stream the response body to a sibling temp file, then rename into
182
+ # place: concurrent downloaders race benignly and readers never observe
183
+ # a partial file.
184
+ def write_to_cache(repo_id, filename, revision, commit, response)
185
+ repo_dir = self.class.repo_cache_dir(repo_id)
186
+ target = repo_dir.join("snapshots", commit, filename)
187
+ FileUtils.mkdir_p(target.dirname)
188
+
189
+ tmp = target.dirname.join(".#{target.basename}.#{Process.pid}.tmp")
190
+ begin
191
+ response.save(tmp.to_s)
192
+ rescue
193
+ FileUtils.rm_f(tmp)
194
+ raise
195
+ ensure
196
+ response.close
197
+ end
198
+ File.rename(tmp, target)
199
+
200
+ if !self.class.commit_hash?(revision) && revision != commit
201
+ refs_dir = repo_dir.join("refs")
202
+ FileUtils.mkdir_p(refs_dir)
203
+ File.write(refs_dir.join(revision), commit)
204
+ end
205
+
206
+ target
207
+ end
208
+
209
+ def ensure_ok!(url, status, had_token)
210
+ return if (200...400).cover?(status)
211
+
212
+ case status
213
+ when 404
214
+ raise Error, "#{url}: HTTP 404 — no such repo with that file, and no such local file either"
215
+ when 401, 403
216
+ token_note = had_token ? "the request used the discovered token" : "no token was found"
217
+ raise Error,
218
+ "#{url}: HTTP #{status} — the repo may be private or gated (#{token_note}; set HF_TOKEN or run " \
219
+ "`hf auth login`, and accept the repo's terms on huggingface.co if it is gated)"
220
+ else
221
+ raise Error, "#{url}: HTTP #{status}"
222
+ end
223
+ end
224
+
225
+ # A redirect Location resolved against the request URL: absolute URLs
226
+ # pass through, host-relative (`/x/y`) and path-relative ones join the
227
+ # base.
228
+ def absolutize(location, base)
229
+ return location if location.include?("://")
230
+
231
+ origin_end = base.index("://") ? base.index("://") + 3 : 0
232
+ origin_end = base.index("/", origin_end) || base.length
233
+
234
+ if location.start_with?("/")
235
+ "#{base[0...origin_end]}#{location}"
236
+ else
237
+ dir_end = base.rindex("/") || base.length
238
+ "#{base[0...[dir_end, origin_end].max]}/#{location}"
239
+ end
240
+ end
241
+ end
242
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gigatoken
4
+ # A packed batch-encode result: one `IO::Buffer` of u32 token ids (native
5
+ # byte order) for every document, plus `lens`, each document's token count.
6
+ # Returned by `Tokenizer#encode_batch`/`#encode_files` with `packed: true`
7
+ # — the Ruby analog of the numpy/awkward-array path the native ext's
8
+ # header comment says was dropped for lack of a Ruby equivalent. Documents
9
+ # are unpacked from the buffer on demand via `#[]`/`#each`.
10
+ class PackedResult
11
+ include Enumerable
12
+
13
+ attr_reader :buffer, :lens
14
+
15
+ def initialize(buffer, lens)
16
+ @buffer = buffer
17
+ @lens = lens
18
+ @offsets = lens.inject([0]) { |offsets, len| offsets << offsets.last + len }
19
+ end
20
+
21
+ # Number of documents.
22
+ def size
23
+ lens.size
24
+ end
25
+
26
+ # Total token count across every document.
27
+ def token_count
28
+ lens.sum
29
+ end
30
+
31
+ # Array of token ids for document `i`, materialized on demand.
32
+ def [](i)
33
+ buffer.get_values(Array.new(lens[i], :u32), @offsets[i] * 4)
34
+ end
35
+
36
+ def each
37
+ return enum_for(:each) unless block_given?
38
+
39
+ lens.each_index { |i| yield self[i] }
40
+ end
41
+
42
+ # A ragged Array of Arrays, one per document — the same shape
43
+ # `encode_batch`/`encode_files` return with `packed: false`.
44
+ def to_a
45
+ each.to_a
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Gigatoken
6
+ # A tokenizer: encode, batch encode, decode, and vocabulary introspection
7
+ # over a native `Gigatoken::Native::BPETokenizer` or
8
+ # `Gigatoken::Native::SentencePieceTokenizer`.
9
+ class Tokenizer
10
+ TIKTOKEN_ENDOFTEXT = "<|endoftext|>"
11
+ private_constant :TIKTOKEN_ENDOFTEXT
12
+
13
+ FILE_SOURCE_CLASSES = [Native::TextFileSource, Native::JsonlFileSource, Native::ParquetFileSource].freeze
14
+ private_constant :FILE_SOURCE_CLASSES
15
+
16
+ # Load from in-memory tokenizer.json contents (String or bytes). Backed
17
+ # by a BPETokenizer or a SentencePieceTokenizer, per the model's
18
+ # byte_fallback flag.
19
+ def self.from_json(data)
20
+ native = Native.load_hf_json(data)
21
+ new(native, special_tokens: special_tokens_from_json(data))
22
+ end
23
+
24
+ # Load from a tokenizer.json path, or a directory containing one.
25
+ def self.from_file(path)
26
+ path = File.join(path, "tokenizer.json") if File.directory?(path)
27
+ from_json(File.binread(path))
28
+ end
29
+
30
+ # Load from a .tiktoken mergeable-ranks file.
31
+ def self.from_tiktoken(path)
32
+ native = Native::BPETokenizer.from_tiktoken(path.to_s)
33
+ new(native, special_tokens: {TIKTOKEN_ENDOFTEXT => native.vocab_size - 1})
34
+ end
35
+
36
+ # Load tokenizer.json from HuggingFace Hub repo `repo_id` at `revision`
37
+ # (downloaded directly; huggingface_hub is not required).
38
+ def self.from_hub(repo_id, revision: "main", hub: Hub.new)
39
+ from_file(hub.hub_file(repo_id, "tokenizer.json", revision: revision))
40
+ end
41
+
42
+ # Load from any of the supported source shapes: an existing file or
43
+ # directory path (a tokenizer.json, or a directory containing one), a
44
+ # .tiktoken vocabulary file, or a HuggingFace Hub repo id like
45
+ # "openai-community/gpt2".
46
+ def self.load(source, revision: "main", hub: Hub.new)
47
+ source = source.to_s
48
+ return from_tiktoken(source) if source.end_with?(".tiktoken")
49
+ return from_file(source) if File.exist?(source)
50
+ return from_hub(source, revision: revision, hub: hub) if Hub.looks_like_repo_id?(source)
51
+
52
+ raise Error, "#{source.inspect}: no such file or directory, not a .tiktoken path, and doesn't look like a HuggingFace Hub repo id"
53
+ end
54
+
55
+ def self.special_tokens_from_json(data)
56
+ added = JSON.parse(data.dup.force_encoding(Encoding::UTF_8))["added_tokens"] || []
57
+ added.each_with_object({}) { |t, h| h[t["content"]] = t["id"] if t["special"] }
58
+ end
59
+ private_class_method :special_tokens_from_json
60
+
61
+ def initialize(native, special_tokens: {})
62
+ @native = native
63
+ @special_tokens = special_tokens
64
+ end
65
+
66
+ def encode(text)
67
+ @native.encode(text)
68
+ end
69
+
70
+ # Returns a ragged Array of Arrays of token ids, one row per document —
71
+ # or, with `packed: true`, a Gigatoken::PackedResult (one IO::Buffer of
72
+ # token ids plus per-document lengths), avoiding the per-token Ruby
73
+ # array materialization the ragged shape costs.
74
+ def encode_batch(texts, packed: false)
75
+ if packed
76
+ PackedResult.new(*@native.encode_batch_packed(texts))
77
+ else
78
+ @native.encode_batch(texts)
79
+ end
80
+ end
81
+
82
+ # Tokenize whole files in Rust: reads and encodes them in one fused pass
83
+ # without the documents ever becoming Ruby objects. `source` is a
84
+ # Native::{Text,Jsonl,Parquet}FileSource, a single path, or an array of
85
+ # paths; bare path(s) are wrapped in a TextFileSource (with `separator`,
86
+ # if given). Returns a ragged Array of Arrays of token ids, one row per
87
+ # document — or, with `packed: true`, a Gigatoken::PackedResult. `parallel:
88
+ # false` loads and encodes everything on the calling thread instead, with
89
+ # identical output, never touching the core worker pool.
90
+ def encode_files(source, separator: nil, parallel: true, packed: false)
91
+ source = Native::TextFileSource.new(Array(source).map(&:to_s), separator: separator) unless FILE_SOURCE_CLASSES.any? { |klass| source.is_a?(klass) }
92
+ if packed
93
+ PackedResult.new(*@native.encode_files_packed(source, parallel: parallel))
94
+ else
95
+ @native.encode_files(source, parallel: parallel)
96
+ end
97
+ end
98
+
99
+ def decode(ids)
100
+ @native.decode(ids)
101
+ end
102
+
103
+ def vocab_size
104
+ @native.vocab_size
105
+ end
106
+
107
+ def vocab
108
+ @native.vocab
109
+ end
110
+
111
+ def merges
112
+ @native.merges
113
+ end
114
+
115
+ attr_reader :special_tokens
116
+ end
117
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gigatoken
4
+ VERSION = "0.1.0"
5
+ end
data/lib/gigatoken.rb ADDED
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "gigatoken/version"
4
+
5
+ module Gigatoken
6
+ # Raised for tokenizer load and encode failures surfaced from the native
7
+ # extension — never a raw Rust panic across the Ruby boundary.
8
+ class Error < StandardError; end
9
+
10
+ NATIVE_EXTENSIONS = %w[.bundle .so .rb].freeze
11
+
12
+ # Precompiled native gems ship per-ABI subdirs (`gigatoken/4.0/...`),
13
+ # the source-gem `rake compile` build lands flat (`gigatoken/...`).
14
+ # Pick whichever exists for the current Ruby ABI, with the per-ABI path
15
+ # winning when both are present.
16
+ def self.locate_native(base, ruby_version: RUBY_VERSION)
17
+ abi = ruby_version[/\d+\.\d+/]
18
+ candidates = [File.join(base, abi, "gigatoken_rb"), File.join(base, "gigatoken_rb")]
19
+ candidates.find { |stem| NATIVE_EXTENSIONS.any? { |ext| File.exist?(stem + ext) } }
20
+ end
21
+ end
22
+
23
+ native = Gigatoken.locate_native(File.expand_path("gigatoken", __dir__))
24
+ raise LoadError, "could not locate gigatoken native extension" unless native
25
+ require native
26
+
27
+ require_relative "gigatoken/hub"
28
+ require_relative "gigatoken/packed_result"
29
+ require_relative "gigatoken/tokenizer"
@@ -0,0 +1,8 @@
1
+ [toolchain]
2
+ channel = "nightly"
3
+ # Cross triples for the precompiled-gem builds (ruby-gem.yml). rustup's
4
+ # toolchain-file auto-install is the only thing that provisions nightly
5
+ # inside the rb-sys-dock containers, so the targets must be declared here —
6
+ # the cross-gem action's `pre-script` input is dead plumbing (undeclared at
7
+ # v1.4.4, and no released rb-sys-dock reads INPUT_PRE_SCRIPT).
8
+ targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-unknown-linux-gnu"]
metadata ADDED
@@ -0,0 +1,109 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: gigatoken
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: x86_64-linux
6
+ authors:
7
+ - Eric Jacobs
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-07-24 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: async
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.43'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.43'
27
+ - !ruby/object:Gem::Dependency
28
+ name: async-http
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '0.96'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '0.96'
41
+ - !ruby/object:Gem::Dependency
42
+ name: dry-cli
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.0'
55
+ description: 'Ruby bindings to the gigatoken core crate: BPE and SentencePiece tokenization
56
+ at GB/s.'
57
+ email:
58
+ - eric@ebj.dev
59
+ executables:
60
+ - gigatoken
61
+ extensions: []
62
+ extra_rdoc_files: []
63
+ files:
64
+ - LICENSE
65
+ - README.md
66
+ - exe/gigatoken
67
+ - lib/gigatoken.rb
68
+ - lib/gigatoken/3.3/gigatoken_rb.so
69
+ - lib/gigatoken/3.4/gigatoken_rb.so
70
+ - lib/gigatoken/4.0/gigatoken_rb.so
71
+ - lib/gigatoken/cli.rb
72
+ - lib/gigatoken/cli/bench.rb
73
+ - lib/gigatoken/cli/support.rb
74
+ - lib/gigatoken/cli/validate.rb
75
+ - lib/gigatoken/hub.rb
76
+ - lib/gigatoken/packed_result.rb
77
+ - lib/gigatoken/tokenizer.rb
78
+ - lib/gigatoken/version.rb
79
+ - rust-toolchain.toml
80
+ homepage: https://github.com/jetpks/gigatoken-rb
81
+ licenses:
82
+ - MIT
83
+ metadata:
84
+ bug_tracker_uri: https://github.com/jetpks/gigatoken-rb/issues
85
+ homepage_uri: https://github.com/jetpks/gigatoken-rb
86
+ source_code_uri: https://github.com/jetpks/gigatoken-rb
87
+ post_install_message:
88
+ rdoc_options: []
89
+ require_paths:
90
+ - lib
91
+ required_ruby_version: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '3.3'
96
+ - - "<"
97
+ - !ruby/object:Gem::Version
98
+ version: 4.1.dev
99
+ required_rubygems_version: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ requirements: []
105
+ rubygems_version: 3.5.23
106
+ signing_key:
107
+ specification_version: 4
108
+ summary: Tokenize your documents at GB/s
109
+ test_files: []