0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Hugging FaceのTokenizers v1がCPU処理を刷新。速いGPUを待たせる「文字列処理」をどう削ったか

0
Posted at

LLMの推論を速くしたのに、リクエストを増やすとGPUが待っている。そんなとき、モデルに入力を渡すまでのCPU処理も見ていますか。

Hugging Faceは2026年9月21日、Tokenizers v1のリリース候補を解説する記事を公開しました。Apple M4 Maxの単一スレッドで、対象の10モデル系列におけるencodeがv0.23比で3〜30倍高速になったと報告しています。これは開発元のRust実装の測定で、Python呼び出しのオーバーヘッドは含みません。

注目したいのは、その速さを生んだ変更です。

文字を分割する。見たことのある断片を再利用する。作業用メモリを使い回す。そして、複数のスレッドが同じ場所で待たないようにする。

モデルの重みを変える話ではありません。モデルに渡す整数列を作るまでの仕事を、組み直した話です。

結論から言うと

自前でLLMを動かしているなら、推論時間と一緒に、トークン化にかかるCPU時間を分けて記録してください。長い入力を大量に処理するサービスでは、GPUに仕事が届く前の工程が処理量を制限する可能性があります。

Tokenizers v1は、その工程を軽くするための具体的な改善を示しています。ただし、2026年10月1日に確認した公式リポジトリでは、まだv1.0.0.rc.0という位置づけです。Python APIの一部は復帰予定の項目として残っています。

最初に試す場所は、独立したトークン化の検証環境です。そこで出力と性能を確認してから、自分の推論スタックへの組み込みを考えます。

文字列を小さなタイルへ分割し、複数の流れで計算装置へ送る抽象的な構図
AI生成の概念イラスト。文字列の分割、結果の再利用、並列処理をタイルの流れで表現しています。実際の内部構成図ではありません。

GPUが読むのは、文字列ではなくトークンID

まず、どこに仕事があるのかを確認します。

公式のパイプライン説明では、encodeの処理は次の4段階です。

入力文字列
  ↓ normalization       文字の正規化
  ↓ pre-tokenization    小さな断片への分割
  ↓ model               断片をトークンIDへ変換
  ↓ post-processing     必要な特殊トークンなどを追加
出力のトークンID列

ここでいうmodelは、文章を生成するLLM本体ではありません。BPEなどのトークン化アルゴリズムを指します。

BPEは、隣り合う要素の組を決められた優先順位で結合していく方式です。入力を読み、分割位置を決め、辞書や結合規則を参照し、結果を格納する。GPUへ送る前にも、これだけの処理があります。

サービス全体を考えると、入力の受信からトークンIDの準備までが遅ければ、その先の計算装置は十分に使えません。これはパイプラインの依存関係から分かることです。実際に自分のサービスで起きているかは、時刻と待ち時間を測って確かめます。

では、v1はこの工程の何を変えたのでしょうか。

1. 毎回解釈していた分割規則を、専用処理にする

文字列を小さな断片へ切るとき、正規表現は便利です。ただ、モデルに付属する分割規則が決まっているなら、毎回汎用エンジンを通す必要があるでしょうか。

v1の改修に関連する分割処理のPRでは、モデルの実際の分割規則を調べ、対応するビットストリーム処理へまとめています。共通の規則を持つモデルは同じ実装を使い、SIMDの処理も組み込んでいます。

SIMDは、一つの命令で複数のデータを扱うCPUの仕組みです。分割位置の判定をまとめて処理できれば、入力を細かく追いかける仕事を減らせます。

ただし、規則を雑に近似したら、別のトークンIDができてしまいます。

このPRには、従来の正規表現エンジンとの比較や、文字境界と処理ブロックの境界をずらす検証が記載されています。Unicodeの結合文字や、絵文字に関係するvariation selectorも検討されています。

速くする対象は、同じ規則を実行する方法です。分割規則そのものを都合よく変えるわけではありません。

日本語、絵文字、改行、コードが混ざる入力を扱う人には、ここが直接関係します。英語の短文だけで移行を判断せず、実際に受け取る文字列を比較用データへ入れる理由です。

2. 同じ断片を、何度もトークン化しない

次に削るのは、すでに答えが分かっている仕事です。

WordCacheのPRでは、文字列断片をキーにしてトークン化結果を再利用するキャッシュが導入されています。作業領域もencode呼び出しをまたいで使い回します。

たとえば同じコードの識別子が何度も出る場合、毎回同じ結合処理を繰り返す必要はありません。最初の結果を覚えておき、後の入力で再利用できます。この説明は、仕組みを示す例です。

断片が届く
  ├─ 結果がある → 保存済みのトークンIDを使う
  └─ 結果がない → トークン化して、再利用できる形で保存する

ここで再利用しているのは、文字列から整数列への変換結果です。LLMが生成した回答や、Attentionの計算に使うKVキャッシュとは、置かれる場所も役割も異なります。

そのため、GPU側のキャッシュを改善していても、CPU側で繰り返している処理が残ることはあります。自分のサービスを調べるときも、「キャッシュを使っている」の一言で終わらせず、どの工程の何を保存しているかを確認します。

3. メモリ確保と関数呼び出しを、細切れにしない

同じ結果を再利用できない断片にも、削れる仕事があります。

作業用メモリの改修PRでは、モデルごとに必要なバッファを持つscratch構造体を用意し、encodeの中心処理がその領域を使う設計が説明されています。小さな処理のたびに、作業用のメモリを用意する回数を減らす発想です。

さらに断片をまとめて渡すPRは、パイプラインがすでに持っている断片一覧を、一つずつmodelへ渡していた点を変えています。

一つずつ渡す経路では、断片のたびに呼び出し、スライス作成、結果の処理、出力容量の確認が発生していました。まとめて渡すことで、BPE側の作業領域の取り出しや出力領域の予約をループの外へ移せます。

断片ごとに渡す:
  呼び出し → 準備 → 断片A
  呼び出し → 準備 → 断片B
  呼び出し → 準備 → 断片C

まとめて渡す:
  呼び出し → 共通の準備 → 断片A、断片B、断片C

これは処理単位を示す概念図です。

一回の準備が小さくても、入力が長くなれば回数が増えます。結合処理を速くした後には、こうした周辺の仕事も相対的に大きくなります。実装を読むと、v1が一つの高速なアルゴリズムを追加しただけではないことが見えてきます。

4. バッファを再利用したら、今度はロックが詰まった

作業領域を使い回す設計には、続きがあります。

複数のスレッドが同じプールからバッファを借りると、プールを守るロックに集中します。スレッドごとのサブプールを導入したPRでは、従来の構成はencodeごとに、作業領域の取り出しと返却で共通のMutexをロックしていたと説明されています。

つまり、各スレッドが速く処理できても、入口と出口で列に並んでいました。

改修ではプールを分割し、各スレッドが使うサブプールを保持します。借りた作業領域を元の場所へ戻すことで、その内部のWordCacheもスレッドごとに再利用しやすくなります。空いていなければ、別のプールを待たずに試す経路もあります。

ここで効くのは、多数のスレッドから同じtokenizerへencodeを実行する使い方です。同PRには、別に測定したHTTPフロントエンドでは変化がなかったことも記載されています。

自分のサービスでも、CPUコアを増やしたのに処理量が伸びないなら、共有ロックを確認する価値があります。逆に、入力の到着を待っているだけなら、この改善を入れても別の場所が上限になります。

Pythonで試す前に、APIの境界を確認する

実装の仕組みは面白い。でも、普段の推論コードへ入れられるのでしょうか。

公式READMEのロードマップには、Python側の学習、パイプライン部品の変更、保存、ペア入力、offset出力、windowing/strideなど、復帰予定の項目が並んでいます。

テキスト分類でoffsetを使っている人と、トークンIDだけを作りたい人では、必要なAPIが違います。Transformers経由で使う場合には、そのバージョンが要求する依存関係と呼び出しも確認してください。

まず、既存環境をコピーした検証環境と、RC用の独立した環境を用意します。そして、両方で同一のtokenizer.jsonと入力データを使います。

以下は、各環境で実行してトークンIDを保存する最小例です。構文は確認済みですが、この記事では実モデルを使った実行や性能測定はしていません。

import json
import sys
from pathlib import Path

from tokenizers import Tokenizer

# 使い方: python check_ids.py tokenizer.json inputs.json ids.json
# inputs.json は文字列の配列。両環境へ同じファイルを渡す。
tokenizer_path, inputs_path, output_path = sys.argv[1:]
texts = json.loads(Path(inputs_path).read_text(encoding="utf-8"))
if not isinstance(texts, list) or not all(isinstance(x, str) for x in texts):
    raise ValueError("inputs.json must be an array of strings")

tokenizer = Tokenizer.from_file(tokenizer_path)
ids = [tokenizer.encode(text, add_special_tokens=False).ids for text in texts]
Path(output_path).write_text(
    json.dumps(ids, ensure_ascii=False), encoding="utf-8"
)

旧版の出力をold-ids.json、RCの出力をrc-ids.jsonとして保存したら、次の比較は標準ライブラリだけでできます。

import json
from pathlib import Path

old = json.loads(Path("old-ids.json").read_text(encoding="utf-8"))
new = json.loads(Path("rc-ids.json").read_text(encoding="utf-8"))
if old != new:
    raise SystemExit("token IDs differ")
print("token IDs match")

add_special_tokens=Falseに固定している点を見てください。これは本文の変換を比べる第一段階です。本番の特殊トークンやチャットテンプレートを含む入力、必要なoffsetやmaskの検証は、実際の呼び出し条件で追加します。

比較用データには、短い日本語だけでなく、長い文書、コード、連続した数字、絵文字、空文字列、改行や結合文字を含む入力を入れてください。RCに必要な機能がなければ、その時点で組み込みを保留できます。

次に測るのは、リクエストのどこで待っているか

出力を確認できたら、性能を測ります。開発元のtokbenchは、出力IDの検証と、読み込み時間をencode時間から分ける仕組みを備えています。公開データで実装を比較したい場合の出発点になります。

サービス側では、次の区間を分けて記録すると、改善すべき場所を絞れます。これは本記事からの運用上の提案です。

区間 確認すること
リクエスト到着 → トークン化開始 CPU側のキューで待っているか
トークン化開始 → ID準備完了 文字列処理そのものに時間がかかるか
ID準備完了 → GPU処理開始 スケジューラやバッチ形成で待っているか
GPU処理開始 → 最初の出力 モデル側の処理が支配的か

入力の長さと同時リクエスト数を変えながら、各区間の時間と処理量を見ます。単体のencodeが速くなった後も、サービスの応答時間まで確認してください。

GPUは忙しいのに遅いのか。GPUへ届く前に仕事が詰まっているのか。そこが分かれば、モデル、CPU処理、スケジューラのどこを調べるべきかが変わります。

Tokenizers v1の面白さは、文字列処理の中にあった細かな繰り返しを、順に取り除いていることです。規則の実行、結果の再利用、メモリの準備、呼び出し単位、共有ロック。入力を届ける道を整えるだけでも、改善できる場所はあります。

次の推論チューニングでは、GPUへ入力が届く時刻もログに残してみてください。

参考資料

Hugging Face — tokenizers v1: encode, decode and scaling, measured(2026年9月21日)
https://huggingface.co/blog/tokenizers-v1

Hugging Face — Tokenizers公式リポジトリ、RCとロードマップ(2026年10月1日確認)
https://github.com/huggingface/tokenizers

Hugging Face — The tokenization pipeline
https://huggingface.co/docs/tokenizers/main/en/pipeline

Hugging Face — bitsplit: one crate, all model grammars, atomsplit deleted(PR #2317)
https://github.com/huggingface/tokenizers/pull/2317

Hugging Face — perf(bpe): WordCache(PR #2262)
https://github.com/huggingface/tokenizers/pull/2262

Hugging Face — no alloc model, follow-ups(PR #2175)
https://github.com/huggingface/tokenizers/pull/2175

Hugging Face — perf(pipeline): give the model a whole chunk of pre-tokens at a time(PR #2304)
https://github.com/huggingface/tokenizers/pull/2304

Hugging Face — perf(pipeline): give each thread its own scratch sub-pool(PR #2365)
https://github.com/huggingface/tokenizers/pull/2365

Hugging Face — tokbench
https://github.com/huggingface/tokbench

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?