Skip to content

Commit 23ff67f

Browse files
committed
fix: preserve split UTF-8 characters in LLM streaming
1 parent 464a5ab commit 23ff67f

8 files changed

Lines changed: 277 additions & 16 deletions

File tree

‎packages/react-native-executorch/__tests__/support/fakeJsi.ts‎

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
* The C++ side of the same contract is covered by the GoogleTest suites under
1313
* `cpp/tests/`; what these suites own is the TypeScript above it.
1414
*/
15+
import { TextDecoder } from 'util';
1516
import type { ConcreteDim, ModelSpec } from '../../src/core/schema';
1617
import type { DType } from '../../src/core/tensor';
1718
import { FakeTensor, tensorTracker } from './fakeTensor';
@@ -88,6 +89,8 @@ class FakeModel {
8889
export type FakeVocabulary = {
8990
/** Token strings, indexed by id. */
9091
tokens: readonly string[];
92+
/** Optional raw decoded bytes per id, for testing split UTF-8 tokens. */
93+
tokenBytes?: readonly (readonly number[])[];
9194
/** Ids prepended to every `encode` result (e.g. a BOS token). */
9295
prefix?: readonly number[];
9396
/** Ids appended to every `encode` result (e.g. an EOS token). */
@@ -126,6 +129,30 @@ function createFakeTokenizer(path: string, vocabulary: FakeVocabulary) {
126129
if (disposed) throw new Error(`${op}: tokenizer '${path}' has been disposed`);
127130
};
128131

132+
const decode = (
133+
tokens: Int32Array,
134+
skipSpecialTokens: boolean,
135+
streaming: boolean
136+
): string | undefined => {
137+
assertLive(streaming ? 'tryDecode' : 'decode');
138+
const special = new Set(vocabulary.specialIds ?? []);
139+
const ids = [...tokens].filter((id) => !(skipSpecialTokens && special.has(id)));
140+
if (vocabulary.tokenBytes) {
141+
const bytes = Uint8Array.from(ids.flatMap((id) => [...(vocabulary.tokenBytes![id] ?? [])]));
142+
// Valid byte streams are enough for runner tests. The native boundary
143+
// tests cover the production UTF-8 completeness check itself.
144+
try {
145+
return new TextDecoder('utf-8', { fatal: streaming }).decode(bytes);
146+
} catch {
147+
return undefined;
148+
}
149+
}
150+
return ids
151+
.map((id) => vocabulary.tokens[id] ?? '')
152+
.filter(Boolean)
153+
.join(' ');
154+
};
155+
129156
return {
130157
path,
131158

@@ -144,15 +171,12 @@ function createFakeTokenizer(path: string, vocabulary: FakeVocabulary) {
144171
},
145172

146173
decode: (tokens: Int32Array, skipSpecialTokens = true): string => {
147-
assertLive('decode');
148-
const special = new Set(vocabulary.specialIds ?? []);
149-
return [...tokens]
150-
.filter((id) => !(skipSpecialTokens && special.has(id)))
151-
.map((id) => vocabulary.tokens[id] ?? '')
152-
.filter(Boolean)
153-
.join(' ');
174+
return decode(tokens, skipSpecialTokens, false)!;
154175
},
155176

177+
tryDecode: (tokens: Int32Array, skipSpecialTokens = true): string | undefined =>
178+
decode(tokens, skipSpecialTokens, true),
179+
156180
getVocabSize: (): number => {
157181
assertLive('getVocabSize');
158182
return vocabulary.tokens.length;

‎packages/react-native-executorch/__tests__/tasks/llmRunner.test.ts‎

Lines changed: 90 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,12 @@ const GEMMA_SPEC = exported({
7979
...method('get_eos_ids', [], [{ kind: 'Int' }]),
8080
});
8181

82-
function registerModelWithSchema(schema: any, options: { maxContextLen?: number } = {}) {
82+
function registerModelWithSchema(
83+
schema: any,
84+
options: { maxContextLen?: number; tokenIds?: readonly number[] } = {}
85+
) {
8386
const maxContextLen = options.maxContextLen ?? 512;
87+
let step = 0;
8488
fakeJsi.registerModel(MODEL_PATH, {
8589
schema,
8690
execute: (methodName, _inputs, outputs) => {
@@ -96,11 +100,12 @@ function registerModelWithSchema(schema: any, options: { maxContextLen?: number
96100
return [true];
97101
case 'get_eos_ids':
98102
return [1];
103+
case 'forward':
99104
case 'text_decoder': {
100105
const out = outputs[0];
101-
if (out) {
102-
// Write a greedy choice to index 2
103-
out.setElement(2, 10);
106+
if (out && (options.tokenIds || methodName === 'text_decoder')) {
107+
for (let i = 0; i < out.numel; i++) out.setElement(i, 0);
108+
out.setElement(options.tokenIds ? (options.tokenIds[step++] ?? 1) : 2, 10);
104109
}
105110
return undefined;
106111
}
@@ -115,7 +120,6 @@ function registerModelWithSchema(schema: any, options: { maxContextLen?: number
115120
}
116121
case 'token_embedding':
117122
case 'vision_encoder':
118-
case 'forward':
119123
default:
120124
return undefined;
121125
}
@@ -130,6 +134,87 @@ beforeEach(() => {
130134
});
131135
});
132136

137+
describe.each([
138+
['text', TEXT_SPEC, []],
139+
['multimodal', MULTIMODAL_SPEC, ['image']],
140+
['gemma', GEMMA_SPEC, ['image', 'audio']],
141+
] as const)('%s runner — streaming UTF-8', (_name, schema, modalities) => {
142+
function registerBytes(pieces: readonly (readonly number[])[]) {
143+
fakeJsi.registerTokenizer(TOKENIZER_PATH, {
144+
tokens: ['prompt', '<eos>', ...pieces.map((_, i) => `piece${i}`)],
145+
tokenBytes: [[112], [...Buffer.from('<eos>')], ...pieces],
146+
specialIds: [1],
147+
});
148+
registerModelWithSchema(schema, { tokenIds: pieces.map((_, i) => i + 2) });
149+
}
150+
151+
it.each(['हिन्दी', 'اردو', 'ไทย', '한국어', '😀', 'żółć', '中文'])(
152+
'preserves split bytes in %s',
153+
async (reply) => {
154+
const bytes = [...Buffer.from(reply, 'utf8')];
155+
registerBytes(bytes.map((byte) => [byte]));
156+
const runner = tracked(await createLLMRunner(MODEL_PATH, TOKENIZER_PATH, modalities));
157+
const chunks: string[] = [];
158+
159+
const stats = runner.generate('prompt', { temperature: 0 }, (text) => chunks.push(text));
160+
161+
expect(stats.numTokens).toBe(bytes.length);
162+
expect(chunks.join('')).toBe(reply);
163+
expect(chunks).not.toContain('');
164+
expect(chunks.join('')).not.toContain('\ufffd');
165+
}
166+
);
167+
168+
it('buffers a complete prefix together with an incomplete tail', async () => {
169+
registerBytes([[65, 0xe0], [0xa4], [0x95, 66]]);
170+
const runner = tracked(await createLLMRunner(MODEL_PATH, TOKENIZER_PATH, modalities));
171+
const chunks: string[] = [];
172+
173+
runner.generate('prompt', { temperature: 0 }, (text) => chunks.push(text));
174+
175+
expect(chunks).toEqual(['AकB']);
176+
});
177+
178+
it('drops an incomplete tail at EOS', async () => {
179+
registerBytes([[65], [0xf0], [0x9f]]);
180+
const runner = tracked(await createLLMRunner(MODEL_PATH, TOKENIZER_PATH, modalities));
181+
const chunks: string[] = [];
182+
183+
const stats = runner.generate('prompt', { temperature: 0 }, (text) => chunks.push(text));
184+
185+
expect(stats.numTokens).toBe(3);
186+
expect(chunks).toEqual(['A']);
187+
});
188+
189+
it('preserves the runner special-token setting', async () => {
190+
registerBytes([[65]]);
191+
registerModelWithSchema(schema, { tokenIds: [1, 2] });
192+
const runner = tracked(await createLLMRunner(MODEL_PATH, TOKENIZER_PATH, modalities));
193+
const chunks: string[] = [];
194+
195+
runner.generate('prompt', { temperature: 0, ignoreEos: true, maxNewTokens: 2 }, (text) =>
196+
chunks.push(text)
197+
);
198+
199+
expect(chunks).toEqual(_name === 'multimodal' ? ['', 'A'] : ['<eos>', 'A']);
200+
});
201+
202+
it('does not carry a partial character into the next generation', async () => {
203+
// The first prefill samples the lead byte, then its decode step samples
204+
// EOS. The next prefill samples ASCII and must start with an empty buffer.
205+
registerBytes([[0xc3], [65]]);
206+
registerModelWithSchema(schema, { tokenIds: [2, 1, 3, 1] });
207+
const runner = tracked(await createLLMRunner(MODEL_PATH, TOKENIZER_PATH, modalities));
208+
const chunks: string[] = [];
209+
210+
runner.generate('prompt', { temperature: 0, maxNewTokens: 1 }, (text) => chunks.push(text));
211+
expect(chunks).toEqual([]);
212+
runner.generate('prompt', { temperature: 0 }, (text) => chunks.push(text));
213+
214+
expect(chunks).toEqual(['A']);
215+
});
216+
});
217+
133218
describe('createLLMRunner — variant dispatch & modalities validation', () => {
134219
it('dispatches to text runner when schema matches LLM_TEXT_SPEC', async () => {
135220
registerModelWithSchema(TEXT_SPEC);

‎packages/react-native-executorch/cpp/extensions/nlp/tokenizer.cpp‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
#include "tokenizer.h"
22

3+
#include <cstddef>
34
#include <cstdint>
45
#include <format>
56
#include <stdexcept>
@@ -25,6 +26,30 @@ namespace {
2526
constexpr uint64_t kNumAddedBosTokens = 0;
2627
constexpr uint64_t kNumAddedEosTokens = 0;
2728

29+
// Copied from ExecuTorch extension/llm/runner/util.h.
30+
// Length of the longest prefix that does not end in an incomplete UTF-8
31+
// sequence. Invalid lead bytes count as length 1 rather than stalling output.
32+
size_t utf8_complete_prefix_len(const std::string &s) {
33+
size_t i = 0;
34+
const size_t n = s.size();
35+
while (i < n) {
36+
const auto c = static_cast<unsigned char>(s[i]);
37+
size_t len = 1; // ASCII or an invalid lead byte.
38+
if ((c >> 5) == 0x6) {
39+
len = 2;
40+
} else if ((c >> 4) == 0xE) {
41+
len = 3;
42+
} else if ((c >> 3) == 0x1E) {
43+
len = 4;
44+
}
45+
if (i + len > n) {
46+
break; // Incomplete trailing sequence: hold it for more bytes.
47+
}
48+
i += len;
49+
}
50+
return i;
51+
}
52+
2853
// tokenizers::Error is its own enum (not executorch::runtime::Error), and the
2954
// tokenizers library ships no to_string for it, so map it to a readable name.
3055
std::string toString(tokenizers::Error error) {
@@ -140,6 +165,40 @@ jsi::Value TokenizerHostObject::get(jsi::Runtime &rt, const jsi::PropNameID &nam
140165
return jsi::Function::createFromHostFunction(rt, jsi::PropNameID::forAscii(rt, "decode"), 1, error::guarded(fnBody));
141166
}
142167

168+
if (nameStr == "tryDecode") {
169+
auto self = shared_from_this();
170+
auto fnBody = [self](jsi::Runtime &rt, const jsi::Value & /*thisVal*/, const jsi::Value *args, size_t count) -> jsi::Value {
171+
if (count < 1 || count > 2) {
172+
throw error::InvalidArgument("tryDecode: Usage: tryDecode(tokens, skipSpecialTokens?)");
173+
}
174+
175+
bool skipSpecialTokens = true;
176+
if (count == 2 && !args[1].isUndefined()) {
177+
skipSpecialTokens = conversions::asType<bool>(rt, "tryDecode: skipSpecialTokens", args[1]);
178+
}
179+
180+
auto lock = self->tryLockUnique(rt, "tryDecode: Tokenizer");
181+
auto ids = conversions::fromJsiTypedArray<int32_t>(rt, "tryDecode: tokens", args[0]);
182+
std::vector<uint64_t> tokens(ids.begin(), ids.end());
183+
184+
if (tokens.empty()) {
185+
return jsi::String::createFromUtf8(rt, "");
186+
}
187+
188+
auto text = unwrap("tryDecode: Failed to decode tokens",
189+
self->tokenizer_->decode(tokens, skipSpecialTokens));
190+
191+
// Check the original bytes before JSI converts them into a JS string.
192+
// The caller retains the token ids and retries when more arrive.
193+
if (utf8_complete_prefix_len(text) != text.size()) {
194+
return jsi::Value::undefined();
195+
}
196+
197+
return jsi::String::createFromUtf8(rt, text);
198+
};
199+
return jsi::Function::createFromHostFunction(rt, jsi::PropNameID::forAscii(rt, "tryDecode"), 1, error::guarded(fnBody));
200+
}
201+
143202
if (nameStr == "getVocabSize") {
144203
auto self = shared_from_this();
145204
auto fnBody = [self](jsi::Runtime &rt, const jsi::Value & /*thisVal*/, const jsi::Value * /*args*/, size_t count) -> jsi::Value {
@@ -218,6 +277,7 @@ std::vector<facebook::jsi::PropNameID> TokenizerHostObject::getPropertyNames(jsi
218277
properties.push_back(jsi::PropNameID::forAscii(rt, "path"));
219278
properties.push_back(jsi::PropNameID::forAscii(rt, "encode"));
220279
properties.push_back(jsi::PropNameID::forAscii(rt, "decode"));
280+
properties.push_back(jsi::PropNameID::forAscii(rt, "tryDecode"));
221281
properties.push_back(jsi::PropNameID::forAscii(rt, "getVocabSize"));
222282
properties.push_back(jsi::PropNameID::forAscii(rt, "idToToken"));
223283
properties.push_back(jsi::PropNameID::forAscii(rt, "tokenToId"));

‎packages/react-native-executorch/cpp/tests/extensions/TokenizerTest.cpp‎

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ std::string load(const std::string &body) {
2929
TEST_F(TokenizerTest, LoadsAHuggingFaceTokenizer) {
3030
EXPECT_TRUE(evalBool(load("return typeof t === 'object';")));
3131
EXPECT_TRUE(evalBool(load(R"(
32-
return ['encode', 'decode', 'getVocabSize', 'idToToken', 'tokenToId', 'dispose']
32+
return ['encode', 'decode', 'tryDecode', 'getVocabSize', 'idToToken', 'tokenToId', 'dispose']
3333
.every((name) => typeof t[name] === 'function');
3434
)")));
3535
}
@@ -68,6 +68,58 @@ TEST_F(TokenizerTest, ConvertsBetweenIdsAndPieces) {
6868
)")));
6969
}
7070

71+
TEST_F(TokenizerTest, TryDecodeWaitsForCompleteUtf8BeforeCrossingJsi) {
72+
EXPECT_TRUE(evalBool(load(R"(
73+
// Whisper's ByteLevel vocabulary maps each byte to a token piece.
74+
function byteToken(byte) {
75+
const direct = (b) => (b >= 33 && b <= 126) ||
76+
(b >= 161 && b <= 172) || (b >= 174 && b <= 255);
77+
let codepoint = byte;
78+
if (!direct(byte)) {
79+
codepoint = 256;
80+
for (let b = 0; b < byte; b++) if (!direct(b)) codepoint++;
81+
}
82+
return t.tokenToId(String.fromCharCode(codepoint));
83+
}
84+
const cases = [
85+
[[0xc3, 0xa9], '\u00e9'],
86+
[[0xe0, 0xa4, 0x95], '\u0915'],
87+
[[0xf0, 0x9f, 0x98, 0x80], '\ud83d\ude00'],
88+
];
89+
for (const [bytes, expected] of cases) {
90+
const ids = bytes.map(byteToken);
91+
for (let end = 1; end < ids.length; end++) {
92+
if (t.tryDecode(Int32Array.from(ids.slice(0, end))) !== undefined) return false;
93+
// A complete ASCII prefix must not hide an incomplete tail.
94+
if (t.tryDecode(Int32Array.from([byteToken(65), ...ids.slice(0, end)])) !== undefined)
95+
return false;
96+
}
97+
if (t.tryDecode(Int32Array.from(ids)) !== expected) return false;
98+
}
99+
return t.tryDecode(new Int32Array(0)) === '' &&
100+
t.tryDecode(t.encode(' hello world')) === ' hello world';
101+
)")));
102+
}
103+
104+
TEST_F(TokenizerTest, TryDecodePreservesSpecialTokenOptions) {
105+
EXPECT_TRUE(evalBool(load(R"(
106+
const ids = Int32Array.of(t.tokenToId('<|endoftext|>'));
107+
return t.tryDecode(ids) === '' &&
108+
t.tryDecode(ids, false) === '<|endoftext|>';
109+
)")));
110+
}
111+
112+
TEST_F(TokenizerTest, TryDecodeReportsArgumentAndLifecycleErrors) {
113+
EXPECT_TRUE(isCodedError(evalThrowing(load("t.tryDecode();")),
114+
"INVALID_ARGUMENT", "Usage: tryDecode(tokens, skipSpecialTokens?)"));
115+
EXPECT_TRUE(isCodedError(evalThrowing(load("t.tryDecode(new Int32Array(0), 42);")),
116+
"INVALID_ARGUMENT", "tryDecode: skipSpecialTokens"));
117+
EXPECT_TRUE(isCodedError(evalThrowing(load("t.tryDecode(Int32Array.of(t.getVocabSize() + 1000));")),
118+
"EXECUTION_FAILED", "tryDecode:"));
119+
EXPECT_TRUE(isCodedError(evalThrowing(load("t.dispose(); t.tryDecode(new Int32Array(0));")),
120+
"RESOURCE_DISPOSED", "tryDecode:"));
121+
}
122+
71123
TEST_F(TokenizerTest, ReportsUnknownPiecesAsExecutionFailures) {
72124
// A piece that is not in the vocabulary is a lookup failure inside the
73125
// tokenizer, not a malformed argument.

‎packages/react-native-executorch/src/extensions/llm/runners/llmGemmaRunner.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,7 @@ export function createLLMGemmaRunner(
457457
const [tCurPos, tTokenId, tTokenEmbed, tPleTok] = tensors;
458458

459459
let numTokens = 0;
460+
let decodeOffset = 0;
460461
let curPos = pos.getBlocking();
461462

462463
try {
@@ -468,9 +469,17 @@ export function createLLMGemmaRunner(
468469

469470
if (!config?.ignoreEos && eosIds.includes(nextToken)) break;
470471

471-
onToken?.(tokenizer.decode(Int32Array.of(nextToken), /* skipSpecialTokens= */ false));
472472
generatedTokens.push(nextToken);
473473

474+
if (onToken) {
475+
const pendingTokens = Int32Array.from(generatedTokens.slice(decodeOffset));
476+
const text = tokenizer.tryDecode(pendingTokens, /* skipSpecialTokens= */ false);
477+
if (text !== undefined) {
478+
decodeOffset = generatedTokens.length;
479+
onToken(text);
480+
}
481+
}
482+
474483
// Embed single token
475484
tTokenId.setData(BigInt64Array.of(BigInt(nextToken)));
476485
model.execute('token_embedding', [tTokenId], [tTokenEmbed, tPleTok]);

0 commit comments

Comments
 (0)