-
Notifications
You must be signed in to change notification settings - Fork 27
Support Kimi K3 Tokenizer #435
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
ef50478
fefa32f
67ce1c5
62ab1e1
0ade45f
06b8151
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,14 +21,16 @@ | |
| per-sample text. The sharded pool is the drain-phase accelerator and is | ||
| auto-sized (one shard per core block); live mid-run flushes run on a small | ||
| in-process thread pool (``--tokenizer-workers``, default 2) owned by the | ||
| queue's live loop. A tokenizer without a fast (Rust) backend is a startup | ||
| error, never a silent slow path. Platforms without CPU affinity (e.g. macOS) | ||
| shard unpinned at full speed; only cache/NUMA locality is lost. | ||
| queue's live loop. A tokenizer without a supported optimized backend (a Hugging | ||
| Face Fast ``tokenizers`` backend or a Rust ``tiktoken.Encoding`` core) is a | ||
| startup error, never a silent slow path. Platforms without CPU affinity (e.g. | ||
| macOS) shard unpinned at full speed; only cache/NUMA locality is lost. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import asyncio | ||
| import inspect | ||
| import json | ||
| import logging | ||
| import multiprocessing | ||
|
|
@@ -38,7 +40,7 @@ | |
| from collections.abc import Callable | ||
| from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor | ||
| from itertools import chain | ||
| from typing import TYPE_CHECKING, Any, Protocol, cast | ||
| from typing import TYPE_CHECKING, Any, Protocol | ||
|
|
||
| import msgspec | ||
| from inference_endpoint.endpoint_client.cpu_affinity import ( | ||
|
|
@@ -122,14 +124,64 @@ def load_reference_tokenizer(tokenizer_name: str) -> Any: | |
| return AutoTokenizer.from_pretrained(tokenizer_name, trust_remote_code=True) | ||
|
|
||
|
|
||
| class _TikTokenBackend: | ||
| """Length-counting adapter for a Transformers wrapper around tiktoken. | ||
|
|
||
| Hugging Face labels custom tokenizers such as Kimi K3's | ||
| ``TikTokenTokenizer`` as "slow" because they inherit | ||
| ``PreTrainedTokenizer`` and do not expose ``backend_tokenizer``. Their BPE | ||
| core is nevertheless the Rust implementation in ``tiktoken.Encoding``. | ||
|
|
||
| Keep the model-provided wrapper in the loop instead of calling | ||
| ``Encoding.encode_batch`` directly. Kimi K3's ``encode`` method preserves | ||
| its special-token policy and splits very long or pathological strings to | ||
| avoid tiktoken's input-size and long-whitespace-run failure modes. The | ||
| outer Endpoints process sharding supplies parallelism across texts. | ||
| """ | ||
|
|
||
| def __init__(self, tokenizer: Any) -> None: | ||
| self._tokenizer = tokenizer | ||
| # Kimi K3 exposes this explicit switch so raw user/model text cannot | ||
| # reinterpret a literal ``<|...|>`` substring as an XTML control token. | ||
| # Other tiktoken-backed wrappers may not expose it. | ||
| try: | ||
| self._supports_allow_special = ( | ||
| "allow_special_tokens" in inspect.signature(tokenizer.encode).parameters | ||
| ) | ||
| except (TypeError, ValueError): | ||
| self._supports_allow_special = False | ||
|
|
||
| def count_texts(self, texts: list[str]) -> list[int]: | ||
| if self._supports_allow_special: | ||
| return [ | ||
| len(self._tokenizer.encode(text, allow_special_tokens=False)) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Claude] low (data-integrity): the tiktoken text path forces |
||
| for text in texts | ||
| ] | ||
| return [len(self._tokenizer.encode(text)) for text in texts] | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Codex + Claude] medium (data-integrity): this fallback branch calls Every other counting path in this module is deliberately no-specials: the HF fast path uses Fix: probe # __init__
params = inspect.signature(tokenizer.encode).parameters
self._encode_kwargs = {}
if "allow_special_tokens" in params:
self._encode_kwargs["allow_special_tokens"] = False
if "add_special_tokens" in params:
self._encode_kwargs["add_special_tokens"] = False
# count_texts
return [len(self._tokenizer.encode(t, **self._encode_kwargs)) for t in texts] |
||
|
|
||
|
|
||
| def _backend_from_tokenizer(tokenizer: Any) -> Any | None: | ||
| """Return a supported optimized length-counting backend.""" | ||
| backend = getattr(tokenizer, "backend_tokenizer", None) | ||
| if backend is not None: | ||
| return backend | ||
|
|
||
| model = getattr(tokenizer, "model", None) | ||
| model_module = type(model).__module__.partition(".")[0] | ||
| if model_module == "tiktoken" and callable(getattr(tokenizer, "encode", None)): | ||
| return _TikTokenBackend(tokenizer) | ||
| return None | ||
|
|
||
|
|
||
| def load_reference_backend(tokenizer_name: str) -> Any | None: | ||
| """Raw tokenizers backend (fast Rust path) for length counting. | ||
| """Optimized backend for reference-tokenizer length counting. | ||
|
|
||
| Counting through the backend avoids the transformers "sequence longer than | ||
| model_max_length" warning the Python wrapper emits, so no ``model_max_length`` | ||
| override is needed. ``None`` if the tokenizer has no fast backend. | ||
| Usually this is the raw Hugging Face ``tokenizers`` backend. A custom | ||
| Transformers tokenizer backed by ``tiktoken.Encoding`` receives an adapter | ||
| that preserves its model-specific encode behavior. ``None`` means there is | ||
| no supported accelerated backend. | ||
| """ | ||
| return getattr(load_reference_tokenizer(tokenizer_name), "backend_tokenizer", None) | ||
| return _backend_from_tokenizer(load_reference_tokenizer(tokenizer_name)) | ||
|
|
||
|
|
||
| def _init_worker(tokenizer_name: str, core_set: list[int]) -> None: | ||
|
|
@@ -160,15 +212,34 @@ def _init_worker(tokenizer_name: str, core_set: list[int]) -> None: | |
| global _WORKER_BACKEND | ||
| _WORKER_BACKEND = load_reference_backend(tokenizer_name) | ||
| if _WORKER_BACKEND is not None: | ||
| _WORKER_BACKEND.encode("warmup", add_special_tokens=False) | ||
| encode_lengths(_WORKER_BACKEND, ["warmup"]) | ||
|
|
||
|
|
||
| def encode_lengths(backend: Any, texts: list[str]) -> list[int]: | ||
| """Per-text token counts via the raw tokenizers backend, one rayon call.""" | ||
| """Per-text counts through an optimized HF Fast or tiktoken backend.""" | ||
| count_texts = getattr(backend, "count_texts", None) | ||
| if count_texts is not None: | ||
| return count_texts(texts) | ||
| encode_batch = getattr(backend, "encode_batch_fast", None) or backend.encode_batch | ||
| return [len(e.ids) for e in encode_batch(texts, add_special_tokens=False)] | ||
|
|
||
|
|
||
| def _chat_template_token_count(tokenizer: Any, messages: list[dict[str, Any]]) -> int: | ||
| """Count a rendered conversation through the tokenizer's native path. | ||
|
|
||
| In particular, Kimi K3 assigns ``allow_special`` per XTML segment. Rendering | ||
| to one string and subsequently calling ``tokenize`` loses those boundaries | ||
| and can reinterpret literal special-token text from users or tools as | ||
| structure. ``tokenize=True`` preserves the model tokenizer's exact policy. | ||
| """ | ||
| encoded = tokenizer.apply_chat_template( | ||
| messages, tokenize=True, add_generation_prompt=False | ||
| ) | ||
| if isinstance(encoded, dict): | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Claude, orchestrator-verified] critical (bug): Reproduced end-to-end against the pinned install with this exact function and a real chat tokenizer (TinyLlama-1.1B-Chat): the function returns 2 where the true token count is 28 (the pre-PR Downstream, no exception is raised so nothing falls back: The unit test ( Fix: pass |
||
| encoded = encoded["input_ids"] | ||
| return len(encoded) | ||
|
|
||
|
|
||
| def _worker_encode_lengths(texts: list[str]) -> list[int]: | ||
| """Per-text token counts for a shard, in one rayon-parallel call.""" | ||
| backend = _WORKER_BACKEND | ||
|
|
@@ -248,6 +319,7 @@ def __init__( | |
| max_workers=max(1, live_workers), thread_name_prefix="tok-thread" | ||
| ) | ||
| self._load_tokenizer() # also computes the chat-template baseline | ||
| self._backend = _backend_from_tokenizer(self._tokenizer) | ||
| # Process shards for the batched text path. Empty only when | ||
| # in-process mode was explicitly requested (n_workers=0 or | ||
| # cores_per_worker<=0; ctor overrides used primarily by tests — | ||
|
|
@@ -263,22 +335,11 @@ def _load_tokenizer(self) -> None: | |
| # Baseline = tokens from a [user, empty-assistant] pair minus the [user] | ||
| # prefix alone, so the assistant frame is subtracted from message counts. | ||
| try: | ||
| prefix = cast( | ||
| str, | ||
| tok.apply_chat_template( | ||
| [_PREFIX_USER_MSG], tokenize=False, add_generation_prompt=False | ||
| ), | ||
| self._prefix_len = _chat_template_token_count(tok, [_PREFIX_USER_MSG]) | ||
| with_assistant = _chat_template_token_count( | ||
| tok, [_PREFIX_USER_MSG, {"role": "assistant", "content": ""}] | ||
| ) | ||
| self._prefix_len = len(tok.tokenize(prefix)) | ||
| with_assistant = cast( | ||
| str, | ||
| tok.apply_chat_template( | ||
| [_PREFIX_USER_MSG, {"role": "assistant", "content": ""}], | ||
| tokenize=False, | ||
| add_generation_prompt=False, | ||
| ), | ||
| ) | ||
| self._baseline = len(tok.tokenize(with_assistant)) - self._prefix_len | ||
| self._baseline = with_assistant - self._prefix_len | ||
| except Exception: | ||
| self._prefix_len = 0 | ||
| self._baseline = 0 | ||
|
|
@@ -296,18 +357,19 @@ def _setup_shards(self, cores_per_worker: int, n_workers: int) -> None: | |
| process's affinity mask (or the online CPU count when the platform | ||
| has no affinity API — shards then run unpinned), always at least one; | ||
| an explicit count is clamped to that capacity. An environment that | ||
| cannot shard — no fast Rust backend, a warmup that fails or exceeds | ||
| its budget — raises instead of silently degrading to a slow path | ||
| that cannot keep up with completions. | ||
| cannot shard — no optimized backend, a warmup that fails or exceeds | ||
| its budget — raises instead of silently degrading to a slow path that | ||
| cannot keep up with completions. | ||
| """ | ||
| if cores_per_worker <= 0 or n_workers == 0: | ||
| logger.info("BatchTokenizer: in-process tokenization (explicit)") | ||
| return | ||
| if getattr(self._tokenizer, "backend_tokenizer", None) is None: | ||
| if self._backend is None: | ||
| raise RuntimeError( | ||
| f"tokenizer {self._tokenizer_name!r} has no fast (Rust) " | ||
| "backend; token metrics require one to keep up with " | ||
| "completions. Use a fast tokenizer, or disable token metrics." | ||
| f"tokenizer {self._tokenizer_name!r} has no supported optimized " | ||
| "backend (Hugging Face Fast or tiktoken); token metrics require " | ||
| "one to keep up with completions. Use a supported tokenizer, " | ||
| "add a backend adapter, or disable token metrics." | ||
| ) | ||
| # The full allowed CPU universe (cgroup-clamped) drives the shard block | ||
| # math. cgroup_clamped_cpus owns the probe-and-restore of this process's | ||
|
|
@@ -364,11 +426,9 @@ def _setup_shards(self, cores_per_worker: int, n_workers: int) -> None: | |
| # -- batched text path -------------------------------------------------- | ||
|
|
||
| def _encode_lengths_inproc(self, texts: list[str]) -> list[int]: | ||
| tok = self._tokenizer | ||
| backend = getattr(tok, "backend_tokenizer", None) | ||
| if backend is not None: | ||
| return encode_lengths(backend, texts) | ||
| return [len(tok.tokenize(t)) for t in texts] # type: ignore[union-attr] | ||
| if self._backend is not None: | ||
| return encode_lengths(self._backend, texts) | ||
| return [len(self._tokenizer.tokenize(t)) for t in texts] # type: ignore[union-attr] | ||
|
|
||
| async def count_texts_async( | ||
| self, | ||
|
|
@@ -424,10 +484,9 @@ def _token_count_message( | |
| if tool_calls: | ||
| msg["tool_calls"] = _normalize_tool_calls_for_template(tool_calls) | ||
| try: | ||
| rendered = tok.apply_chat_template( # type: ignore[union-attr] | ||
| [_PREFIX_USER_MSG, msg], tokenize=False, add_generation_prompt=False | ||
| full = _chat_template_token_count( # type: ignore[arg-type] | ||
| tok, [_PREFIX_USER_MSG, msg] | ||
| ) | ||
| full = len(tok.tokenize(rendered)) # type: ignore[union-attr] | ||
| return max(0, full - self._prefix_len - self._baseline) | ||
| except Exception as exc: | ||
| key = f"{self._tokenizer_name}:{type(exc).__name__}" | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Claude + Code-Quality] medium (performance):
_TikTokenBackend.count_textstokenizes a batch with a serial Python loop of single-stringencodecalls ([len(self._tokenizer.encode(text, allow_special_tokens=False)) for text in texts]). This backend runs on both the live in-process lane and inside each drain shard. The HF path (line 223-224) instead issues oneencode_batch(...)rayon call that parallelizes across the whole pinned core block. Shards pinCORES_PER_WORKER = 8cores and exportRAYON_NUM_THREADS=8on the assumption one encode call saturates ~8 cores — but single-string tiktokenencodeis single-threaded, so each 8-core shard runs on ~1 core (roughly 8x per-shard throughput loss) plus per-text GIL overhead on the 1024-item live flushes. The docstring claims sharding "supplies parallelism across texts," but that's cross-process; the 8 cores within each block sit idle for tiktoken. This weakens the very "tokenizer keeps up with completions" guarantee that makes a slow backend a startup error.Root cause: the two
count_textsbranches are also a copy-pasted comprehension differing only by one kwarg (DRY). The durable fix addresses both: factor the loop once (kwargs = {"allow_special_tokens": False} if self._supports_allow_special else {}), and either batch the encode or adjust the shard core-block sizing to reflect that tiktoken shards are effectively single-core, so allocated CPUs aren't wasted.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Verified against
moonshotai/Kimi-K3revision9f62e4e9fffbd0a83ddd60e1c209d828994b3569. For 256 ordinary ~12k-character texts, the wrapper loop took 0.111s versus 0.0177s fortiktoken.Encoding.encode_batch(..., num_threads=8), with identical counts—approximately 6.3x in this single warm-process comparison.One important caveat: replacing the wrapper loop with raw full-string
encode_batchis not token-count equivalent for long inputs. For a 450,003-character input, the Kimi wrapper returned 150,003 tokens while raw encoding returned 150,002 because the wrapper applies its 400k outer split and 25k consecutive-run split.Suggested fix: reproduce Kimi’s exact safe chunk boundaries, batch the resulting chunks with
disallowed_special=(), retain an owner index for each chunk, and sum chunk lengths back into each original text. This preserves the PR’sallow_special_tokens=Falsepolicy while using all assigned cores.Please add parity tests for ordinary text, literal special-token text, the 25k/400k boundaries, and over-boundary inputs, plus a small throughput regression benchmark.