Files
Model-Optimizer/examples/dataset/conversation_utils.py
Chenhan D. Yu 0246041b01 feat(speculative): add vLLM data synthesis pipeline and Nemotron dataset preparation scripts (#1176)
### What does this PR do?

Type of change: New feature, new example, bug fix

Adds a vLLM-based synthetic data generation pipeline for speculative
decoding draft model training, along with dataset preparation scripts
for NVIDIA's Nemotron Post-Training dataset collections.

**Data synthesis pipeline** (`tools/launcher/common/vllm/query.sh` +
`common/query.py`):
- Launch a vLLM server and run multi-turn inference to synthesize
training data from input conversation skeletons
- Fork-safe OpenAI client: reinitializes HTTP connection pool after
`datasets.map()` forks worker processes, preventing 400 errors from
corrupted connections
- Clear Docker `ENTRYPOINT` so vLLM containers (which default to `vllm
serve`) work correctly under NeMo Run's executor
- `--max-tokens` argument to bound generation length
- Local file loading support (`--data /path/to/file.jsonl`)
- Re-raise connection errors so `datasets.map()` halts the shard instead
of silently producing empty rows
- Map `developer` role to `system` (OpenAI format compatibility)

**Multi-turn reasoning trace handling** (`common/query.py`):
- Strip `<think>...</think>` blocks from intermediate assistant turns
before re-feeding to the model; preserve the full trace only on the
final turn

**Nemotron dataset preparation** (`examples/dataset/`):
- `make_nemotron_ptv2_dataset.py` — prepares
[nvidia/Nemotron-Post-Training-Dataset-v2](https://huggingface.co/datasets/nvidia/Nemotron-Post-Training-Dataset-v2)
(~3.3M rows generate, ~1.9M rows train)
- `make_nemotron_ptv3_dataset.py` — prepares the [Nemotron PTv3
collection](https://huggingface.co/collections/nvidia/nemotron-post-training-v3)
of 16 datasets (~3.4M rows generate, ~3.9M rows train)
- Both support `generate` mode (strips assistant turns for synthesis
input) and `train` mode (normalizes to clean OpenAI format for SFT)
- `conversation_utils.py` — shared utilities: `strip_assistant_turns`,
`normalize_messages`, `make_augment_fn`, `AugmentationSpec`
- `augmentations.yaml` — 12 language-redirect variants + style/format
hints, cycled across dataset rows
- Scripts live in `examples/dataset/` (not under
`speculative_decoding/`) to signal reusability beyond speculative
decoding

**Bug fixes**:
- `strip_assistant_turns()`: return `{"messages": []}` when no user
turns remain (system-only rows were previously passed through instead of
being filtered)
- `concatenate_datasets()`: guard against empty parts list
- SSH tunnel user precedence: explicit `user` arg now correctly
overrides `slurm_config.user`

### Usage

```bash
# Prepare PTv3 input conversations for synthesis (~3.4M rows):
python examples/dataset/make_nemotron_ptv3_dataset.py --output-dir /tmp/ptv3_gen

# Launch vLLM server + synthesize responses:
bash tools/launcher/common/vllm/query.sh \
    --model /path/to/model \
    --tensor-parallel-size 4 \
    -- \
    --data /tmp/ptv3_gen/default.jsonl \
    --save /tmp/ptv3_responses \
    --num-shards 10 --num-proc 4 --max-tokens 4096

# Prepare PTv2 for direct SFT training (~1.9M rows):
python examples/dataset/make_nemotron_ptv2_dataset.py --mode train --output-dir /tmp/ptv2_train
```

### Testing

Tested end-to-end on an NVIDIA GB10 node (119 GiB GPU memory) with
`vllm/vllm-openai:qwen3_5-cu130` container and `Qwen/Qwen3.5-4B`:
- vLLM server starts correctly with cleared Docker entrypoint
- `datasets.map(num_proc=4)` runs without connection errors (fork-safe
client)
- Multi-turn synthesis produces correct assistant responses with
thinking traces handled

### Before your PR is "*Ready for review*"

Make sure you read and follow [Contributor
guidelines](https://github.com/NVIDIA/Model-Optimizer/blob/main/CONTRIBUTING.md)
and your commits are signed (`git commit -s -S`).

Make sure you read and follow the [Security Best
Practices](https://github.com/NVIDIA/Model-Optimizer/blob/main/SECURITY.md#security-coding-practices-for-contributors)
(e.g. avoiding hardcoded `trust_remote_code=True`, `torch.load(...,
weights_only=False)`, `pickle`, etc.).

- Is this change backward compatible?: ✅
- If you copied code from any other sources or added a new PIP
dependency, did you follow guidance in `CONTRIBUTING.md`: N/A
- Did you write any new necessary tests?: N/A (data synthesis scripts;
tested manually)
- Did you update
[Changelog](https://github.com/NVIDIA/Model-Optimizer/blob/main/CHANGELOG.rst)?:
N/A


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added dataset generation and augmentation capabilities for Nemotron
post-training datasets (v2 and v3)
* Enhanced query functionality with thinking-block filtering and
improved client management for robust parallel processing
* Added support for local dataset file paths alongside HuggingFace Hub
datasets

* **Bug Fixes**
* Fixed SLURM executor user resolution and Docker container entrypoint
configuration
* Improved error handling for connection failures during dataset
synthesis

* **Documentation**
* Updated dataset preparation guide with new generation modes and
augmentation configuration details

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Signed-off-by: Chenhan D. Yu <5185878+ChenhanYu@users.noreply.github.com>
Signed-off-by: Chenhan Yu <chenhany@nvidia.com>
2026-04-08 05:22:11 +00:00

201 lines
8.0 KiB
Python

# SPDX-FileCopyrightText: Copyright (c) 2023-2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Shared conversation manipulation and augmentation utilities for dataset preparation.
Imported by make_nemotron_ptv2_dataset.py and make_nemotron_ptv3_dataset.py.
These scripts produce *input conversations* for synthetic data generation: the
conversations are fed to a target model which generates responses, producing
training data for the speculative-decoding draft model.
Conversation format
-------------------
Each conversation is stripped down to a skeleton of system + user turns only — all
assistant turns are removed. The downstream generation pipeline (query.py) feeds this
skeleton to the target model turn-by-turn, appending each generated response before
sending the next user turn, so the model produces coherent multi-turn continuations.
Augmentations (``user_suffix``) are applied to *all* user messages so that the
language or style instruction is present at every turn — important for multi-turn
synthetic generation where the model must maintain the requested style throughout.
"""
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import yaml
logger = logging.getLogger(__name__)
@dataclass
class AugmentationSpec:
"""One augmentation variant.
type: "user_suffix" — appends ``text`` to the last user message.
"system_prompt" — prepends a system message with ``content``.
"""
type: str
text: str = ""
content: str = ""
enabled: bool = True
def __post_init__(self):
if self.type not in ("user_suffix", "system_prompt"):
raise ValueError(
f"Unknown augmentation type '{self.type}'. "
"Expected 'user_suffix' or 'system_prompt'."
)
if self.type == "user_suffix" and not self.text:
raise ValueError("user_suffix augmentation requires a non-empty 'text' field.")
if self.type == "system_prompt" and not self.content:
raise ValueError("system_prompt augmentation requires a non-empty 'content' field.")
def load_augmentations(config_path: Path) -> list[AugmentationSpec]:
"""Load and validate augmentation specs from a YAML file, returning only enabled ones."""
with config_path.open("r", encoding="utf-8") as f:
data = yaml.safe_load(f)
specs = [AugmentationSpec(**entry) for entry in data.get("augmentations", [])]
enabled = [s for s in specs if s.enabled]
if not enabled:
raise ValueError(
f"No enabled augmentations found in {config_path}. "
"Enable at least one entry or pass --no-augmentation."
)
logger.info(
"Loaded %d augmentation spec(s) from %s (%d disabled).",
len(enabled),
config_path,
len(specs) - len(enabled),
)
for spec in enabled:
if spec.type == "user_suffix":
logger.info(" [user_suffix] %r", spec.text.strip())
else:
logger.info(" [system_prompt] %r", spec.content.strip())
return enabled
def make_augment_fn(specs: list[AugmentationSpec]):
"""Return a datasets.map-compatible function that cycles through *specs* by row index.
user_suffix: appended to ALL user messages so the language/style instruction is
present at every turn — important for multi-turn synthetic generation where the
model must maintain the requested language or style throughout the conversation.
system_prompt: prepended only when the conversation has no existing system
message. If the dataset already provides a system prompt it is kept as-is and
this augmentation variant is skipped for that row (returning it unchanged).
"""
def _augment(example: dict[str, Any], idx: int) -> dict[str, Any]:
spec = specs[idx % len(specs)]
messages = [dict(m) for m in example["messages"]] # shallow copy per row
if spec.type == "user_suffix":
for msg in messages:
if msg["role"] == "user":
msg["content"] = msg["content"] + spec.text
else: # system_prompt
has_system = messages and messages[0]["role"] == "system"
if has_system:
# Conflict: dataset already has a system prompt — skip this augmentation.
pass
else:
messages = [{"role": "system", "content": spec.content}, *messages]
return {"messages": messages}
return _augment
def has_tool_turns(example: dict[str, Any]) -> bool:
"""Return True if the conversation contains any ``tool`` role message."""
return any(m.get("role") == "tool" for m in example["messages"])
def strip_assistant_turns(example: dict[str, Any], idx: int) -> dict[str, Any]:
"""Keep only the system prompt and user turns; remove all assistant turns.
This produces a conversation skeleton for synthetic data generation.
The downstream generation pipeline feeds this skeleton to the target model
turn-by-turn, appending each generated response before sending the next user
turn:
dataset: [system, user1, user2, user3]
step 1: feed [system, user1] → gen_asst1
step 2: feed [system, user1, gen_asst1, user2] → gen_asst2
step 3: feed [system, user1, gen_asst1, user2, gen_asst2, user3] → gen_asst3
Rows with no user turns are returned empty and filtered out by the caller.
"""
messages = [m for m in example["messages"] if m["role"] in ("system", "user")]
if not any(m["role"] == "user" for m in messages):
return {"messages": []}
return {"messages": messages}
def normalize_messages(example: dict[str, Any], idx: int) -> dict[str, Any]:
"""Normalize to clean OpenAI message format for SFT training.
Drops dataset-specific extra fields (``reasoning_content``, etc.) while
preserving the fields required by the OpenAI chat format for each role:
system / user → {role, content}
assistant → {role, content} + tool_calls if present
tool → {role, content, tool_call_id}
``tool`` turns are kept because dropping them breaks agentic conversations:
an assistant message that issued a tool_call must be followed by the tool
result before the next assistant message, or the training signal is corrupted.
Prompt-only rows (no assistant turn) are returned with their messages intact;
callers should filter them out for training use.
"""
normalized = []
for m in example["messages"]:
role = m.get("role")
if role in ("system", "user"):
normalized.append({"role": role, "content": m.get("content") or ""})
elif role == "assistant":
msg: dict[str, Any] = {"role": "assistant", "content": m.get("content") or ""}
if m.get("tool_calls"):
msg["tool_calls"] = m["tool_calls"]
normalized.append(msg)
elif role == "tool":
normalized.append(
{
"role": "tool",
"content": m.get("content") or "",
"tool_call_id": m.get("tool_call_id", ""),
}
)
elif role == "developer":
# Map developer-role messages to system per OpenAI schema conventions.
normalized.append({"role": "system", "content": m.get("content") or ""})
# other roles (e.g. function, unknown) are dropped
return {"messages": normalized}