Skip to content

Commit 6030a44

Browse files
authored
Refactor(regen): emit generation boundary as loss mask for on-policy data, eliminate redundant re-tokenize roundtrip (vllm-project#729)
# Purpose The current on-policy response regeneration has re-tokenization roundtrip redundancy. This PR helps eliminate the roundtrip: ``` BEFORE AFTER regen -> text regen -> return_token_ids write conversations write input_ids + loss_mask (boundary) re-tokenize + REGEX/HF mask _preprocess_batch passthrough (no masking) vLLM -> hidden states vLLM -> hidden states ``` The masking machinery still exists (off-policy needs it until PR2); it is just no longer on the on-policy path. ## Concrete example — a 2-turn conversation Before (one row of text, masked later by regex over the whole rendering): ```json {"id": "sample_7", "conversations": [{"from":"human","value":"2+2?"}, {"from":"gpt","value":"4"}, {"from":"human","value":"3+3?"}, {"from":"gpt","value":"6"}]} ``` After (one row per assistant turn, mask already applied): ```json {"id":"sample_7_turn0", "input_ids":[<prompt>,<"4">], "loss_mask":[0,0,0,1,1], "conversations":[...]} {"id":"sample_7_turn1", "input_ids":[<prompt+history>,<"6">], "loss_mask":[0,...,0,1,1], "conversations":[...]} ``` `loss_mask` 0 is the prompt the target conditioned on; 1 is what it generated. No `{% generation %}` markers, no regex — just the boundary the serving engine reports. ## Why On-policy data never needed regex masking: we generated the response, so we already know where the assistant tokens are, and capturing that boundary is exact. It is also correct for multi-turn reasoning models, where each turn supervises the `<think>` it actually generated while history keeps only parsed content — matching inference. The regex path cannot reproduce this, because the same turn tokenizes differently as "generated" vs as "history". ## What changes (observable) | | Before | After | |---|---|---| | Output schema | `conversations` (text) | `input_ids` + `loss_mask` (+ `conversations`, review-only) | | Rows per conversation | 1 | 1 per assistant turn | | Mask made | downstream regex / HF | generation boundary | | Endpoint | text out | must support `return_token_ids` | `conversations` is retained as an inert human-review twin of `input_ids`: `_preprocess_batch` routes on `input_ids`/`loss_mask` (checked first) and drops `conversations` before training, so it is never re-masked. ## Tests `build_boundary_sample` produces the boundary mask; pre-tokenized rows pass through `_preprocess_batch` without a processor, and a review `conversations` field is ignored. Off-policy suite unchanged (56 passed / 2 skipped). ## Scope — step 1 of 5 ("renderer is the single source of truth") 1. (this PR) on-policy → generation boundary; stop feeding the regex path. (Please note that this PR is a concrete standalone PR. No speculative scaffolding. ) 2. To unify the chat template generation tag. we should vendor generation-tagged template from trl huggingface/trl#4879. 3. on-policy → we have to diverge a bit from pure on-policy regen: real inference has a thinking compression mechanism - newer models, e.g., GLM5.2, Qwen3.6 only keep the latest turn's thinking, and drop 0:n-1 thinking. But we need all thinking because that's where speculative decoding can helps. PR1 is correct (completely on policy, no divergence) but inefficient because it fan out multiturn to multi-row jsonl. PR3 can linearize them back to single-row, with some tradeoff. This is also the same solution with trl, llama-factory for multi-turn SFT. We can discuss further on the tradeoff when I have a draft PR. 4. off-policy → vLLM `/render` this path can be decoupled from the regex & hf path 5. delete the regex/HF masking once both producers are redirected. small followup PRs: `--turn-dropout` not compatible with this PR. need to run some tests on real data to see if we should improve it or remove it. ## Checklist I have filled in: - [x] The purpose of the PR, such as "Fix some issue (link existing issues this PR will resolve)". - [x] The test plan/results, such as providing test command and pasting the results. - [ ] (Optional) The necessary documentation update. - [x] I (a human) have written or reviewed the code in this pr to the best of my ability. --------- Signed-off-by: Ranran Haoran Zhang <ranzhang@redhat.com>
1 parent 5330499 commit 6030a44

5 files changed

Lines changed: 315 additions & 145 deletions

File tree

docs/cli/response_regeneration.md

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ python scripts/response_regeneration/script.py --dataset magpie
9393

9494
- **`--outfile`** (str, default: auto-generated) Output JSONL path. If not specified, auto-generated as `{dataset}_{model}.jsonl`.
9595

96-
- **`--resume`** (flag) Skip rows already present in the output file (matched by uuid or index).
96+
- **`--resume`** (flag) Skip conversations already present in the output file (matched by `primary_id`: the row's `id`/`uuid` if it has one, otherwise a content hash).
9797

9898
### Full Example
9999

@@ -117,40 +117,41 @@ python scripts/response_regeneration/script.py \
117117

118118
## Output Format
119119

120-
Each line of the output JSONL file pairs the original user prompt with the newly generated response, in a conversations format compatible with fine-tuning:
120+
Rows are pre-tokenized and ready for training: one row per assistant turn, holding the prompt the target conditioned on followed by the tokens it generated. The endpoint must support `return_token_ids`, which the script uses to read the generation boundary directly instead of re-tokenizing the text and recovering the boundary with a regex.
121121

122122
```json
123123
{
124-
"id": "sample_0",
124+
"id": "conv-abc_turn0",
125+
"primary_id": "conv-abc",
126+
"input_ids": [151644, 872, ...],
127+
"loss_mask": [0, 0, ..., 1, 1],
125128
"conversations": [
126-
{"from": "human", "value": "What is the capital of France?"},
127-
{"from": "gpt", "value": "The capital of France is Paris."}
129+
{"role": "user", "content": "What is the capital of France?"},
130+
{"role": "assistant", "content": "The capital of France is Paris."}
128131
],
129132
"metadata": {
130133
"idx": 0,
131134
"finish_reason": "stop",
132-
"latency_s": 1.234,
133135
"usage": {...},
134-
"endpoint": "http://127.0.0.1:8000/v1/chat/completions",
135-
"reasoning_content": "..."
136+
"endpoint": "http://127.0.0.1:8000/v1/chat/completions"
136137
}
137138
}
138139
```
139140

140-
- The `id` field uses the dataset's UUID if available, otherwise falls back to `sample_{idx}`.
141-
- The `reasoning_content` field in metadata is only included when the model provides reasoning content (e.g., with reasoning models).
141+
- `loss_mask` is `0` over the prompt and `1` over the generated tokens. This *is* the generation boundary, so training applies no further masking.
142+
- A conversation with N assistant turns yields N rows, each carrying the history before it. Turn `k`'s row is `{primary_id}_turn{k}`.
143+
- `primary_id` is the conversation's stable id, used by `--resume`. The row `id` is turn-suffixed and never matches it.
144+
- `conversations` is a human-readable twin of `input_ids` for review only. Training drops it.
142145

143-
On error, the response conversation turn is omitted and an `error` field is included in metadata:
146+
Rows are written only after every turn of a conversation succeeds. A conversation that fails partway writes nothing to the output file and one row to a sibling error file instead (`--outfile out.jsonl` gives `out.errors.jsonl`), so `--resume` retries it whole:
144147

145148
```json
146149
{
147-
"id": "sample_0",
148-
"conversations": [
149-
{"from": "human", "value": "What is the capital of France?"}
150-
],
150+
"id": "conv-abc",
151151
"metadata": {
152152
"idx": 0,
153153
"error": "ConnectionError(...)",
154+
"turns_completed": 1,
154155
"endpoint": "http://127.0.0.1:8000/v1/chat/completions"
155156
}
156157
}

docs/user_guide/tutorials/response_regeneration.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,25 +50,29 @@ For larger models, use data parallelism and/or tensor parallelism:
5050

5151
## Step 2: Verify the Output
5252

53-
The output is a JSONL file where each line contains the original prompt and the generated response in a conversations format compatible with fine-tuning:
53+
The output is a JSONL file with one pre-tokenized row per assistant turn. `loss_mask` is `0` over the prompt the target conditioned on and `1` over the tokens it generated, so training needs no further masking:
5454

5555
```json
5656
{
57-
"id": "sample_0",
57+
"id": "conv-abc_turn0",
58+
"primary_id": "conv-abc",
59+
"input_ids": [151644, 872, ...],
60+
"loss_mask": [0, 0, ..., 1, 1],
5861
"conversations": [
59-
{"from": "human", "value": "What is the capital of France?"},
60-
{"from": "gpt", "value": "The capital of France is Paris."}
62+
{"role": "user", "content": "What is the capital of France?"},
63+
{"role": "assistant", "content": "The capital of France is Paris."}
6164
],
6265
"metadata": {
6366
"idx": 0,
6467
"finish_reason": "stop",
65-
"latency_s": 1.234,
6668
"usage": {...},
6769
"endpoint": "http://127.0.0.1:8000/v1/chat/completions"
6870
}
6971
}
7072
```
7173

74+
A conversation with N assistant turns produces N rows, so expect more lines than input conversations. `conversations` is a review-only twin of `input_ids`; training drops it.
75+
7276
Check that the output looks correct:
7377

7478
```bash

scripts/response_regeneration/script.py

Lines changed: 78 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
import os
77
import re
88
import sys
9-
import time
109
from typing import Any
1110

1211
import aiohttp
@@ -187,11 +186,16 @@ def _primary_identifier(row: dict[str, Any]) -> str:
187186

188187

189188
def load_seen(path: str) -> set[str]:
190-
"""Load previously written output ids from the output file.
189+
"""Load previously completed conversation ids from the output file.
191190
192-
Each record stores its stable id under the top-level ``id`` (equal to the
193-
row's :func:`_primary_identifier`), so a resumed run skips a row when its
194-
recomputed id is already present.
191+
A conversation fans out to one row per assistant turn, whose ``id`` carries a
192+
``_turn<N>`` suffix; the conversation's own :func:`_primary_identifier` is
193+
kept alongside it as ``primary_id``. Resume keys on that, since the suffixed
194+
ids never match a recomputed one. Rows are written only after every turn
195+
succeeds, so one row is enough to mark the conversation done.
196+
197+
``id`` is the fallback for output files written before the fan-out, where the
198+
top-level ``id`` *was* the primary identifier.
195199
"""
196200
seen: set[str] = set()
197201
if not os.path.isfile(path):
@@ -203,8 +207,11 @@ def load_seen(path: str) -> set[str]:
203207
obj = json.loads(line)
204208
except json.JSONDecodeError:
205209
continue
206-
if _is_present(obj.get("id")):
207-
seen.add(str(obj["id"]))
210+
key = obj.get("primary_id")
211+
if not _is_present(key):
212+
key = obj.get("id")
213+
if _is_present(key):
214+
seen.add(str(key))
208215
return seen
209216

210217

@@ -270,6 +277,19 @@ async def _post_chat(
270277
return await response.json()
271278

272279

280+
def build_boundary_sample(
281+
prompt_token_ids: list[int],
282+
completion_token_ids: list[int],
283+
) -> tuple[list[int], list[int]]:
284+
"""Build one training sample: prompt (loss_mask 0) + generated tokens (1).
285+
286+
The generation boundary is the mask -- no ``{% generation %}`` markers, no regex.
287+
"""
288+
input_ids = [*prompt_token_ids, *completion_token_ids]
289+
loss_mask = [0] * len(prompt_token_ids) + [1] * len(completion_token_ids)
290+
return input_ids, loss_mask
291+
292+
273293
async def worker(
274294
session: aiohttp.ClientSession,
275295
queue: "asyncio.Queue[dict[str, Any]]",
@@ -280,12 +300,10 @@ async def worker(
280300
progress,
281301
stats: dict[str, int],
282302
):
283-
"""Worker that pulls conversations from the queue and regenerates them.
303+
"""Regenerate each queued conversation into pre-tokenized training samples.
284304
285-
Each user turn is sent to the endpoint with the freshly generated prefix
286-
(system + regenerated history); the original assistant turns are discarded
287-
and replaced by the model's responses. Single-turn rows are the degenerate
288-
case of one user turn.
305+
One sample per assistant turn: the prompt the target conditioned on
306+
(loss_mask 0) followed by the tokens it generated (1).
289307
"""
290308
while True:
291309
item = await queue.get()
@@ -295,28 +313,23 @@ async def worker(
295313

296314
idx = item["idx"]
297315
turns = item["turns"]
316+
conv_id = item["primary_id"]
298317

299-
# The API prefix (role/content) and the output conversation (from/value)
300-
# are built in lockstep as we walk the turns.
301318
prefix: list[dict[str, Any]] = []
302-
out_convs: list[dict[str, Any]] = []
303-
finish_reasons: list[str | None] = []
304-
usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
305-
start_time = time.time()
319+
samples: list[dict[str, Any]] = []
306320
try:
307321
for turn in turns:
308322
if turn["role"] == "system":
309323
prefix.append({"role": "system", "content": turn["content"]})
310-
out_convs.append({"from": "system", "value": turn["content"]})
311324
continue
312325

313326
prefix.append({"role": "user", "content": turn["content"]})
314-
out_convs.append({"from": "human", "value": turn["content"]})
315327

316328
payload = {
317329
"model": args.model,
318330
"messages": prefix,
319331
"max_tokens": args.max_tokens,
332+
"return_token_ids": True, # prompt_token_ids + completion token_ids
320333
}
321334
data = await _post_chat(
322335
session,
@@ -326,53 +339,60 @@ async def worker(
326339
)
327340

328341
choice = data["choices"][0]
329-
message = choice["message"]
330-
generated_text = message.get("content")
331-
reasoning_content = message.get("reasoning_content")
332-
if reasoning_content is None:
333-
reasoning_content = message.get("reasoning")
334-
finish_reasons.append(choice.get("finish_reason"))
335-
turn_usage = data.get("usage") or {}
336-
for key in usage:
337-
usage[key] += turn_usage.get(key) or 0
338-
339-
# Empty content (e.g. truncated mid-reasoning) would corrupt the
340-
# next turn's prefix and emit a null target; fail the conversation.
342+
generated_text = choice["message"].get("content")
343+
344+
# Empty content corrupts the next turn's prefix; fail the conversation.
341345
if not generated_text:
342-
raise ValueError(f"empty assistant content (turn {len(out_convs)})")
343-
344-
prefix.append({"role": "assistant", "content": generated_text})
345-
gpt_turn = {"from": "gpt", "value": generated_text}
346-
# Stored per turn: data prep reads it here, not top-level metadata.
347-
if reasoning_content is not None:
348-
gpt_turn["reasoning_content"] = reasoning_content
349-
out_convs.append(gpt_turn)
350-
351-
metadata = {
352-
"idx": idx,
353-
"finish_reasons": finish_reasons,
354-
"latency_s": round(time.time() - start_time, 3),
355-
"usage": usage,
356-
"endpoint": endpoint,
357-
}
346+
raise ValueError(f"empty assistant content (turn {len(samples)})")
347+
348+
prompt_token_ids = data.get("prompt_token_ids")
349+
completion_token_ids = choice.get("token_ids")
350+
if not prompt_token_ids or not completion_token_ids:
351+
raise ValueError(
352+
"endpoint returned no token ids; it must support "
353+
"return_token_ids"
354+
)
358355

359-
output = {
360-
"id": item["primary_id"],
361-
"conversations": out_convs,
362-
"metadata": metadata,
363-
}
364-
out_fh.write(json.dumps(output, ensure_ascii=False) + "\n")
356+
input_ids, loss_mask = build_boundary_sample(
357+
prompt_token_ids, completion_token_ids
358+
)
359+
# History keeps parsed content; the generated <think> is supervised
360+
# in this turn's completion tokens above.
361+
assistant_msg = {"role": "assistant", "content": generated_text}
362+
samples.append(
363+
{
364+
"id": f"{conv_id}_turn{len(samples)}",
365+
# Conversation-level key for --resume; the row `id` is
366+
# turn-suffixed and would never match a recomputed one.
367+
"primary_id": conv_id,
368+
"input_ids": input_ids,
369+
"loss_mask": loss_mask,
370+
# Review-only twin of input_ids; ignored by training.
371+
"conversations": [*prefix, assistant_msg],
372+
"metadata": {
373+
"idx": idx,
374+
"finish_reason": choice.get("finish_reason"),
375+
"usage": data.get("usage") or {},
376+
"endpoint": endpoint,
377+
},
378+
}
379+
)
380+
prefix.append(assistant_msg)
381+
382+
# Written only after every turn succeeds, so any row in the output
383+
# file means the whole conversation is done (see load_seen).
384+
for sample in samples:
385+
out_fh.write(json.dumps(sample, ensure_ascii=False) + "\n")
365386
out_fh.flush()
366387
stats["ok"] += 1
367388
except Exception as e: # noqa: BLE001
368-
# Failures go to a separate error file, not the training output; an
369-
# in-band marker would be invisible (the pipeline drops metadata).
389+
# Failures go to a separate error file, not the training output.
370390
error_output = {
371-
"id": item["primary_id"],
372-
"conversations": out_convs,
391+
"id": conv_id,
373392
"metadata": {
374393
"idx": idx,
375394
"error": repr(e),
395+
"turns_completed": len(samples),
376396
"endpoint": endpoint,
377397
},
378398
}

src/speculators/data_generation/preprocessing.py

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,37 @@ def _parse_conv_tools(conv_tools: object, idx: int) -> list | None:
507507
return None
508508

509509

510+
def _passthrough_pretokenized(
511+
examples: dict, max_length: int, minimum_valid_tokens: int | None = None
512+
) -> dict[str, list]:
513+
"""Carry pre-tokenized ``(input_ids, loss_mask)`` rows through, truncated only.
514+
515+
On-policy regeneration already applied the boundary as the mask, so these rows
516+
need no chat-template rendering or regex span detection.
517+
"""
518+
results: dict[str, list] = {"input_ids": [], "loss_mask": [], "seq_len": []}
519+
for ids, mask in zip(examples["input_ids"], examples["loss_mask"], strict=True):
520+
# `strict=True` only pairs the columns; a per-row skew would survive it and
521+
# the collator packs each key independently, silently shifting the mask
522+
# against the ids for every sample packed after this one.
523+
if len(ids) != len(mask):
524+
raise ValueError(
525+
f"Pre-tokenized row shape mismatch: "
526+
f"input_ids={len(ids)}, loss_mask={len(mask)}"
527+
)
528+
trimmed_ids = ids[:max_length]
529+
trimmed_mask = mask[:max_length]
530+
if (
531+
minimum_valid_tokens is not None
532+
and sum(trimmed_mask) < minimum_valid_tokens
533+
):
534+
continue
535+
results["input_ids"].append(torch.tensor(trimmed_ids, dtype=torch.long))
536+
results["loss_mask"].append(torch.tensor(trimmed_mask, dtype=torch.long))
537+
results["seq_len"].append(len(trimmed_ids))
538+
return results
539+
540+
510541
def _preprocess_batch(
511542
examples: dict,
512543
processor: ProcessorLike,
@@ -517,6 +548,11 @@ def _preprocess_batch(
517548
) -> dict[str, list]:
518549
"""Process a batch of conversations into tokenized format with loss masks."""
519550

551+
# On-policy regeneration rows are already masked (boundary); pass them through
552+
# instead of re-tokenizing and re-masking.
553+
if "input_ids" in examples and "loss_mask" in examples:
554+
return _passthrough_pretokenized(examples, max_length, minimum_valid_tokens)
555+
520556
results: dict[str, list] = {"input_ids": [], "loss_mask": [], "seq_len": []}
521557
conversations: list[dict] = examples.get("conversations", [])
522558

@@ -617,8 +653,21 @@ def build_eagle3_dataset(
617653
conversation
618654
minimum_valid_tokens: Number of tokens to consider for a valid sample
619655
"""
656+
original_cols = dataset.column_names
657+
# These rows carry the generation boundary as their mask, so _preprocess_batch
658+
# passes them through: no chat template, no span detection, no turn dropout.
659+
pretokenized = {"input_ids", "loss_mask"} <= set(original_cols)
660+
661+
if pretokenized:
662+
log.info("Pre-tokenized rows: using their loss mask, skipping chat template")
663+
if turn_dropout:
664+
log.warning("turn_dropout does not apply to pre-tokenized rows; ignoring")
665+
if assistant_pattern is not None:
666+
log.warning(
667+
"assistant_pattern does not apply to pre-tokenized rows; ignoring"
668+
)
620669
# Detect and use provided assistant message pattern
621-
if assistant_pattern is not None:
670+
elif assistant_pattern is not None:
622671
log.info(f"Using custom assistant pattern: {str(assistant_pattern)[:80]}...")
623672
elif _supports_assistant_mask(processor):
624673
assistant_pattern = None # Signal to use HF mask in _preprocess_batch
@@ -627,8 +676,6 @@ def build_eagle3_dataset(
627676
assistant_pattern = _detect_assistant_pattern(processor)
628677
log.info(f"Detected assistant pattern: {str(assistant_pattern)[:80]}...")
629678

630-
original_cols = dataset.column_names
631-
632679
# Avoid CPU contention for MM processing:
633680
# https://github.com/vllm-project/vllm/pull/31879
634681
with (

0 commit comments

Comments
 (0)