mirror of
https://github.com/NVIDIA/Model-Optimizer.git
synced 2026-10-30 07:34:44 +08:00
### 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>
201 lines
8.0 KiB
Python
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}
|