NVIDIA-NeMo/RL #4295.
All permalinks pin the PR head 4fcc987. Token ids measured with the Qwen2.5-0.5B-Instruct tokenizer;
Llama-3.1, Llama-2 and Nemotron-Nano-9B-v2 give the same split.
skip_chat_template=True for messages whose text is already templated. Its helper tokenizes the joined text once and cuts it into turns.Two pages on this PR. This one: why an answer can end up with no tokens.
A tokenizer cuts text into pieces called tokens, each with an id. SFT needs those ids split back into turns, because only the assistant's tokens are trained on. The PR's helper tokenizes all turns joined together, then has to work out where turn 1 ends in that token list. That breaks when one token covers the end of turn 1 and the start of turn 2. The figure walks one real example through each step.
The same fallback can also repeat tokens. Token counts of growing prefixes do not always grow (Qwen2.5:
turns ["\n ", " \n", "xx \n", "a"] give 5 tokens where the full text has 4). It is also the only path
for tokenizers that cannot report character ranges, and for the PR's own unit test, whose fake tokenizer cannot be called directly.
Turns that end on a chat-template marker like <|im_end|>\n split correctly.
Same code, same bug, in Megatron-LM. The PR's helper follows Megatron-LM's raw-conversation tokenizer step for step.
Links pin Megatron-LM 6a36609, the commit NeMo-RL uses (through its Megatron-Bridge submodule 1f8873bb).
| step | this PR — _tokenize_preformatted_message_log | Megatron-LM — MultimodalTokenizer |
|---|---|---|
| find where each turn ends in the token list; give up if a token crosses | if token_index < len(offsets) and offsets[token_index][0] < boundary:
cumulative_lengths = []
breakllm_message_utils.py L530-L536 |
if start < boundary:
return None_tokenize_text_with_offsets L617-L631 |
| fall back: count the tokens of each growing prefix | for turn in raw_turns:
cumulative_text += turn
cumulative_lengths.append(len(_encode_raw(cumulative_text)))L544-L550 |
for turn, ... in zip(rendered_turns, ...):
cumulative_text += turn["content"]
cumulative_lengths.append(
len(self._encode_with_markers(cumulative_text, ...)))_tokenize_raw_conversation_slow L644-L652 |
| cut the full token list at those counts | new_message["token_ids"] = token_ids[start:end]L552-L564 |
if turn["role"].lower() == "assistant":
target[start:end] = tokens[start:end]L661-L666 |
run on ["A: ", "Paris"] (Qwen2.5) |
user [32, 25, 12095] assistant [] |
tokens [32, 25, 12095] trained [] (target all IGNORE_INDEX) |
Megatron-LM's own docstring calls the fallback deliberate:
"In that case callers must use the cumulative-prefix path to preserve legacy behavior."
Megatron-LM turns this path on per dataset with the same subflavor,
skip_chat_template,
so Megatron-LM SFT runs on such data lose the same answers. The Megatron-LM result above comes from running its
_tokenize_raw_conversation methods, copied unchanged from that commit, with the Qwen2.5 tokenizer.
The damage does not spread to everything after it. At each turn boundary that falls inside a token, that one token
moves into the turn before it; the cut lines up again at the next clean boundary (a newline, an <|im_end|>).
But it repeats at every boundary that crosses. In question-and-answer text where every answer starts right after
"A: ", every answer loses its first word:
Longer answers lose only that first word: "Paris is the capital.\n" trains on " is the capital.\n".
Turns that end on a clean boundary, such as answers on their own line or chat-template turns ending in <|im_end|>\n,
come out exactly right. All values above are from running the PR's helper with the Qwen2.5 tokenizer.
Below is the same conversation as the trainer receives it: all turns joined into one token sequence, with the loss mask
under each token (1 = trained on, 0 = only read). The mask comes from NeMo-RL's own
add_loss_mask_to_message_log,
which sets 1 on assistant tokens. The fix row is the proposed chat_template_preapplied path, run on the same turns.
The two sequences have different lengths (24 vs 29) because one encode of the joined text merges each space into the next word.
Where the fix lands: RL only, one function.
_tokenize_preformatted_message_log, L494-L565.
Nothing below is implemented yet. It was run locally on the cases above, and the PR's existing test still passes.
The - lines are shortened with ...; the full block is in the review's suggestion block.
Each turn is tokenized on its own, so a token can never belong to two turns, and every turn decodes back to its own text.
This is also what generation sees: the prompt is tokenized alone, then the model writes the answer.
It deletes the broad try/except and the fallback together.
For chat-template turns the ids come out the same as before.
🔴 review comment — llm_message_utils.py:494-565