Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- `--prompt-lookup` runs as a token-only drafter (`PromptLookupDrafter`) on the engine, so it works over any model whose KV state can drop a rejected block, and it pipelines its plain rounds as before. `PromptLookupGenerator` and `CxxGenerator` are removed from the public API (`MlxInferenceSession` and `mlxcel_core::engine::DirectEngine` replace them).
- `mlxcel generate --profile` reports the sampler graph build and the first evaluation as one phase: the `[TTFT]` line's `sample` is 0 and `eval` carries both. The line is printed for the measured run only, not for the one-token warmup. `mlxcel-bench-decode` and the parity harness render the chat template through the same function as the server and `generate` (`server::chat_front`), so their prompts carry the same `enable_thinking` default.

### Fixed

- Checkpoints that load through the SentencePiece fallback (a `tokenizer.model` with no `tokenizer.json`) now encode their `"special": false` added tokens as the one id each was declared with, as `transformers` does, instead of splitting them into ordinary pieces (#1758). IQuest-Coder and IQuest-Coder Loop tool-calling prompts now carry `<tools>`, `<tool_call>`, `<tool_response>` and their closers as single ids, Baichuan-M1's `<C_A>` turn marker and ERNIE-4.5-VL's `<|IMAGE_PLACEHOLDER|>` and `<|LOC_n|>` tokens do the same, and each added token's `lstrip`, `rstrip` and `single_word` flags are honored. `skip_special_tokens` still drops only the special ones. On this path `parse_special: false` (`/tokenize`, logit-bias text keys) now also recognizes non-special added tokens, as llama.cpp does, `added_tokens.json` is read when `added_tokens_decoder` is absent, and a `single_word` special token glued to a word (IQuest's `</s>` in `d</s>e`) encodes as text, as in `transformers`.

## [v0.7.0] - 2026-09-09

### llama-server b10621 compatibility, complete
Expand Down
66 changes: 66 additions & 0 deletions TECHNICAL_REPORTS/pr-2319-sp-nonspecial-added-tokens.en.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# PR #2319: Encode non-special added tokens on the SentencePiece path

**Date**: 2026-10-16
**Status**: Implemented and verified on CUDA (GB10) with real IQuest checkpoints before merge; ERNIE-4.5-VL is tokenizer-only and the Baichuan-M1 greedy diff is not usable (see "Not verified")
**Risk**: Medium (changes token ids for any SentencePiece-path prompt that contains a non-special added token; prompts without one are unchanged)

## Summary

Issue #1758: the SentencePiece fallback (`tokenizer.model` with no `tokenizer.json`) registered only `"special": true` added tokens for encoding. Entries marked `"special": false` decoded to their own spelling but, on encode, were split into 3 to 5 ordinary pieces. IQuest-Coder's `<think>`, `<tool_call>`, `<tools>`, `<tool_response>` and their closers were affected, so a rendered tool prompt reached the model as ordinary text instead of the single reserved ids it was trained on.

1. New `src/tokenizer/sp_added_vocab.rs` holds every `added_tokens_decoder` entry and splits the input on all of them with leftmost-longest matching, then applies each matched token's `rstrip`, `lstrip` and `single_word` flags to its neighbours. This is a port of the loop in the transformers slow tokenizer's `tokenize`.
2. `special` now only governs `skip_special_tokens` on decode and what `parse_special: false` stops matching. Non-special tokens are still matched under `parse_special: false`, as llama.cpp and the tokenizers backend do.
3. With no decoder table, `added_tokens.json` supplies plain non-special tokens, the same precedence as `read_added_tokens_sorted` on the tokenizers path.

## Problem

`parse_special_tokens` in `src/tokenizer/mod.rs` split the decoder by `special`. Special entries went into the encode table; non-special ones went into `added_token_contents`, which decode consulted and encode did not. HuggingFace makes no such distinction on encode: every entry is an `AddedToken` in the added vocabulary and is matched before SentencePiece sees any text.

The impact shows only on tool-calling prompts. The IQuest chat template emits the six tool tags literally, and the plain chat path uses only special tokens. After the fix the 7B tool prompt shrinks from 193 to 181 tokens and the Loop one from 205 to 185.

### Survey

Local `~/models/mlx` (184 checkpoints) has two SentencePiece-path checkpoints, both IQuest. A remote survey of the 153 repos in `docs/model-catalog.tsv` found seven SentencePiece-path snapshots.

| Checkpoint | Non-special entries |
|------------|---------------------|
| IQuest-Coder-V1-7B-Instruct-8bit | 8 (`<think>`, `</think>`, six tool tags) |
| IQuest-Coder-V1-40B-Loop-Instruct-4bit (not catalogued) | 8, same layout |
| Baichuan-M1-14B-Instruct-4bit | 20 (`<tool_call>`, FIM and repo markers, `<C_A>`, `<B_FUNC>`, ...) |
| ERNIE-4.5-VL-28B-A3B-Thinking-4bit | 1015 (image and video markers, `<|LOC_0|>` to `<|LOC_1000|>`) |
| ERNIE-4.5-0.3B-PT-4bit, internlm3-8b-instruct-4bit, TeleChat3-36B-Thinking-4bit | 0 |

No surveyed checkpoint has an `added_tokens.json` entry missing from its decoder.

## Technical decisions

- Match the slow tokenizer, not the fast one. These checkpoints declare slow-only tokenizer classes, so the transformers slow path is the reference; the flags (`single_word`, `lstrip`, `rstrip`) are ported rather than ignored, because IQuest declares `</s>` and `<unk>` as `single_word`.
- Ignore `normalized`. The slow tokenizer reads it only under `do_lower_case`, which no SentencePiece checkpoint sets.
- Keep non-special tokens matchable under `parse_special: false`. Dropping them would make `/tokenize` and logit-bias text keys disagree with llama.cpp for user-defined tokens.
- Resolve a content declared twice to the higher id, and route `token_to_id` through the same table as encode, so the two cannot disagree.
- Read added tokens leniently (`read_sp_added_tokens` skips a malformed entry) because the SentencePiece loader never failed on one and a checkpoint that loads today must keep loading. The per-entry parser is shared with the strict tokenizers-path reader in `added_tokens.rs`.
- An `added_tokens.json` entry is always non-special. transformers' legacy loader would mark one special when it matches a declared special token; no surveyed checkpoint relies on that.

## Changes

- `src/tokenizer/sp_added_vocab.rs` (new): `SpAddedVocab` with `split`, `id_of` and `read_sp_added_tokens`.
- `src/tokenizer/mod.rs`: the `special_token_to_id` / `special_tokens_sorted` tables and `split_with_special_tokens` are replaced by `added_vocab` and one `encode_split(text, add_special_tokens, parse_special)` used by both encode entry points.
- `src/tokenizer/added_tokens.rs`: `parse_added_token_entry` extracted for sharing.
- `src/tokenizer/sp_added_vocab_tests.rs` (new), `CHANGELOG.md`, `docs/supported-models.md` (IQuest entries no longer describe the gap).

## Behavior change

IQuest declares `</s>` `single_word: true`, so `d</s>e` now encodes as text, as in transformers. A `single_word` special token glued to a word no longer fires.

## Verification

- Tokenization diff, old versus new binary, on all seven tokenizers (base prompts, each non-special token embedded mid-word and space-delimited, plain and tool-calling prompts through each chat template) against a transformers 4.57.1 reference: the new binary matches on every prompt; the old one mismatched on every prompt containing a non-special token. Prompts without one are unchanged.
- Greedy ids (`/completion`, temperature 0, `MLXCEL_SDPA_DETERMINISTIC=1`, old binary self-parity checked) on `iquest-coder-v1-7b-instruct-8bit` and `iquest-coder-v1-40b-loop-instruct-4bit`: ids identical for prompts without non-special tokens; the tool-calling prompts shrink as above and both still emit the `get_weather` call.
- Revert check: with non-special entries dropped from the encode table, 5 of the new or inverted tests fail.
- `tokenizer::` (137 pass), fmt, `cargo clippy --profile test-fast --features cuda --lib --tests -D warnings` and the contract checks pass.

## Not verified and follow-up

- ERNIE-4.5-VL was checked tokenizer-only because its weights are not on the box. The image placeholder and its markers are now single ids, which should let image expansion find the template's placeholder, but no end-to-end run exists.
- Baichuan-M1: old-binary self-parity already fails on CUDA even for unchanged prompt ids (pre-existing nondeterminism), so its greedy diff is not usable. The old path echoed a literal `<C_A>`; the new one answers.
- Checkpoints that previously relied on a non-special token splitting into pieces would change ids. The survey found none such, but a new SentencePiece checkpoint with a common-substring added token should be diffed the same way.
66 changes: 66 additions & 0 deletions TECHNICAL_REPORTS/pr-2319-sp-nonspecial-added-tokens.ko.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# PR #2319: SentencePiece 경로에서 non-special added token 인코딩

**날짜**: 2026-10-16
**상태**: 머지 전 CUDA(GB10)에서 실제 IQuest 체크포인트로 구현 및 검증 완료, ERNIE-4.5-VL은 토크나이저만 확인했고 Baichuan-M1 greedy 비교는 쓸 수 없음 ("미검증" 참고)
**위험도**: 중간 (non-special added token을 포함한 SentencePiece 경로 프롬프트의 토큰 id가 바뀜, 포함하지 않은 프롬프트는 그대로)

## 요약

이슈 #1758: SentencePiece 폴백(`tokenizer.model`만 있고 `tokenizer.json` 없음)은 `"special": true`인 added token만 인코딩용으로 등록했다. `"special": false` 항목은 디코드에서는 제 철자로 복원됐지만, 인코드에서는 3~5개의 일반 piece로 쪼개졌다. IQuest-Coder의 `<think>`, `<tool_call>`, `<tools>`, `<tool_response>`와 닫는 태그가 해당되어, 렌더링된 tool 프롬프트가 학습 때 쓰인 단일 예약 id 대신 일반 텍스트로 모델에 전달되었다.

1. 새 `src/tokenizer/sp_added_vocab.rs`가 `added_tokens_decoder`의 모든 항목을 보유하고, leftmost-longest 매칭으로 입력을 나눈 뒤 매칭된 토큰의 `rstrip`, `lstrip`, `single_word` 플래그를 이웃 조각에 적용한다. transformers slow tokenizer의 `tokenize` 루프를 옮긴 것이다.
2. `special`은 이제 디코드의 `skip_special_tokens`와 `parse_special: false`가 매칭을 멈추는 대상만 결정한다. `parse_special: false`에서도 non-special 토큰은 llama.cpp 및 tokenizers 백엔드처럼 계속 매칭된다.
3. decoder 표가 없으면 `added_tokens.json`이 일반 non-special 토큰을 제공한다. tokenizers 경로의 `read_added_tokens_sorted`와 같은 우선순위다.

## 문제 정의

`src/tokenizer/mod.rs`의 `parse_special_tokens`는 decoder를 `special`로 나눴다. special 항목은 인코드 표로, non-special 항목은 디코드만 참조하는 `added_token_contents`로 들어갔다. HuggingFace는 인코드에서 이런 구분을 하지 않는다. 모든 항목이 added vocabulary의 `AddedToken`이며 SentencePiece가 텍스트를 보기 전에 매칭된다.

영향은 tool 호출 프롬프트에서만 보인다. IQuest chat template은 여섯 개의 tool 태그를 문자 그대로 출력하고, 일반 채팅 경로는 special 토큰만 쓴다. 수정 후 7B tool 프롬프트는 193개에서 181개로, Loop는 205개에서 185개로 줄어든다.

### 조사

로컬 `~/models/mlx`(체크포인트 184개)에서 SentencePiece 경로는 IQuest 두 개뿐이다. `docs/model-catalog.tsv`의 153개 repo를 원격 조사한 결과 SentencePiece 경로 스냅샷은 일곱 개였다.

| 체크포인트 | non-special 항목 |
|------------|------------------|
| IQuest-Coder-V1-7B-Instruct-8bit | 8개 (`<think>`, `</think>`, tool 태그 6개) |
| IQuest-Coder-V1-40B-Loop-Instruct-4bit (카탈로그에 없음) | 8개, 같은 구성 |
| Baichuan-M1-14B-Instruct-4bit | 20개 (`<tool_call>`, FIM 및 repo 마커, `<C_A>`, `<B_FUNC>` 등) |
| ERNIE-4.5-VL-28B-A3B-Thinking-4bit | 1015개 (이미지/비디오 마커, `<|LOC_0|>`부터 `<|LOC_1000|>`) |
| ERNIE-4.5-0.3B-PT-4bit, internlm3-8b-instruct-4bit, TeleChat3-36B-Thinking-4bit | 0개 |

조사한 체크포인트 중 `added_tokens.json`에만 있고 decoder에 없는 항목은 없었다.

## 기술적 선택과 그 이유

- fast가 아니라 slow tokenizer를 기준으로 맞춘다. 이 체크포인트들은 slow 전용 tokenizer 클래스를 선언하므로 transformers slow 경로가 기준이다. IQuest가 `</s>`와 `<unk>`를 `single_word`로 선언하므로 플래그(`single_word`, `lstrip`, `rstrip`)는 무시하지 않고 그대로 옮겼다.
- `normalized`는 무시한다. slow tokenizer는 `do_lower_case`일 때만 읽는데, SentencePiece 체크포인트 중 이를 켠 것이 없다.
- `parse_special: false`에서도 non-special 토큰은 매칭 가능하게 둔다. 빼면 `/tokenize`와 logit-bias 텍스트 키가 user-defined 토큰에 대해 llama.cpp와 달라진다.
- 내용이 두 번 선언되면 더 큰 id로 해석하고, `token_to_id`도 인코드와 같은 표를 거치게 해서 둘이 어긋나지 않게 한다.
- added token은 관대하게 읽는다(`read_sp_added_tokens`는 잘못된 항목을 건너뜀). SentencePiece 로더는 잘못된 항목에도 실패한 적이 없고, 지금 로드되는 체크포인트는 계속 로드되어야 하기 때문이다. 항목 파서는 `added_tokens.rs`의 엄격한 tokenizers 경로 리더와 공유한다.
- `added_tokens.json` 항목은 항상 non-special이다. transformers의 레거시 로더는 선언된 special 토큰과 내용이 같으면 special로 표시하지만, 조사한 체크포인트 중 이에 의존하는 것은 없다.

## 변경 사항

- `src/tokenizer/sp_added_vocab.rs` (신규): `SpAddedVocab`과 `split`, `id_of`, `read_sp_added_tokens`.
- `src/tokenizer/mod.rs`: `special_token_to_id` / `special_tokens_sorted` 표와 `split_with_special_tokens`를 `added_vocab`과, 두 인코드 진입점이 함께 쓰는 `encode_split(text, add_special_tokens, parse_special)`으로 교체.
- `src/tokenizer/added_tokens.rs`: 공유를 위해 `parse_added_token_entry` 추출.
- `src/tokenizer/sp_added_vocab_tests.rs` (신규), `CHANGELOG.md`, `docs/supported-models.md` (IQuest 항목에서 기존 한계 설명 제거).

## 동작 변경

IQuest는 `</s>`를 `single_word: true`로 선언하므로 `d</s>e`는 이제 transformers처럼 텍스트로 인코딩된다. 단어에 붙은 `single_word` special 토큰은 더 이상 발동하지 않는다.

## 검증

- 일곱 토크나이저 모두에서 이전 바이너리와 새 바이너리의 토큰화 비교(기본 프롬프트, 각 non-special 토큰을 단어 중간과 공백 구분으로 삽입, 각 chat template을 거친 일반 및 tool 호출 프롬프트)를 transformers 4.57.1 기준과 대조했다. 새 바이너리는 모든 프롬프트에서 일치하고, 이전 바이너리는 non-special 토큰을 포함한 모든 프롬프트에서 불일치했다. 포함하지 않은 프롬프트는 그대로다.
- `iquest-coder-v1-7b-instruct-8bit`와 `iquest-coder-v1-40b-loop-instruct-4bit`에서 greedy id(`/completion`, 온도 0, `MLXCEL_SDPA_DETERMINISTIC=1`, 이전 바이너리 자기 일치 확인): non-special 토큰이 없는 프롬프트는 id가 동일하고, tool 호출 프롬프트는 위처럼 줄며 두 모델 모두 `get_weather` 호출을 그대로 낸다.
- 되돌림 확인: encode 표에서 non-special 항목을 빼면 새로 추가되거나 뒤집힌 테스트 5개가 실패한다.
- `tokenizer::`(137개 통과), fmt, `cargo clippy --profile test-fast --features cuda --lib --tests -D warnings`, 계약 검사가 통과한다.

## 미검증 및 후속

- ERNIE-4.5-VL은 가중치가 이 장비에 없어 토크나이저만 확인했다. 이미지 placeholder와 마커가 단일 id가 되어 이미지 확장이 template의 placeholder를 찾을 것으로 보이지만, end-to-end 실행은 없다.
- Baichuan-M1: 이전 바이너리의 자기 일치가 id가 바뀌지 않은 프롬프트에서도 CUDA에서 이미 깨진다(기존 비결정성). 따라서 greedy 비교는 쓸 수 없다. 이전 경로는 문자 그대로의 `<C_A>`를 반복했고 새 경로는 답한다.
- 쪼개진 piece에 의존하던 체크포인트가 있다면 id가 바뀐다. 조사에서는 없었지만, 흔한 부분 문자열을 added token으로 갖는 새 SentencePiece 체크포인트는 같은 방식으로 비교해야 한다.
Loading
Loading