Skip to content

Commit b975e6c

Browse files
authored
feat: Enable per dataset max-osl (#344)
* Enable per dataset max-osl --------- Signed-off-by: Rashid Kaleem <230885705+arekay-nv@users.noreply.github.com>
1 parent 85a9375 commit b975e6c

9 files changed

Lines changed: 602 additions & 27 deletions

File tree

examples/04_GPTOSS120B_Example/Readme.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,39 @@ The config uses `api_type: openai_completions`, which routes to `/v1/completions
5757
token IDs (`prompt: [id, id, ...]`). This applies the Harmony format client-side and bypasses vLLM's
5858
chat template, producing the same token sequence as the SGLang path and matching SGLang accuracy scores.
5959

60+
#### MLPerf per-dataset output budgets
61+
62+
The MLPerf Inference gpt-oss-120b rules use **different max output lengths** for the performance and
63+
accuracy phases (see the
64+
[MLCommons reference](https://github.com/mlcommons/inference/tree/master/language/gpt-oss-120b)):
65+
66+
| Phase | `max_output_len` | `reasoning_effort` |
67+
| ----------- | ---------------- | ------------------ |
68+
| performance | 10240 | low |
69+
| accuracy | 32768 | high |
70+
71+
[`vllm_gptoss_120b_per_dataset_osl_example.yaml`](vllm_gptoss_120b_per_dataset_osl_example.yaml)
72+
expresses this in a single run using per-dataset `generation_config_override`: the global
73+
`model_params.max_new_tokens` sets the perf budget (10240) and each accuracy dataset overrides it to
74+
32768 so the harmony CoT is not truncated before the `assistantfinal` answer. This config sets only the
75+
output budget: `reasoning_effort` is not a YAML field. With `api_type: openai_completions` the Harmony
76+
format is applied client-side, so reasoning effort is encoded into the prompt tokens — pre-tokenized in
77+
the perf dataset, and fixed at `high` by the adapter's `Harmonize()` for the accuracy datasets.
78+
79+
> **Reasoning effort caveat.** This config controls only the output budget, not reasoning effort. The
80+
> accuracy datasets carry text prompts, so the client-side `Harmonize()` encodes them at its default
81+
> `reasoning_effort=high` — matching the MLPerf accuracy setting. The perf dataset is consumed
82+
> pre-tokenized (`parser.input_tokens`), so `Harmonize()` is skipped and its reasoning effort is fixed at
83+
> parquet-build time (the perf parquet is supplied by the LLM task-force — see "Getting the Dataset").
84+
> MLPerf's perf phase uses `reasoning_effort=low`, so a perf-compliant run depends on that parquet being
85+
> built with `low`; it is not something this client YAML (or the repo's default `Harmonize()`, which
86+
> encodes `high`) can set. Treat this example as a demonstration of the per-dataset OSL split.
87+
88+
```bash
89+
uv run inference-endpoint benchmark from-config \
90+
-c examples/04_GPTOSS120B_Example/vllm_gptoss_120b_per_dataset_osl_example.yaml
91+
```
92+
6093
### vllm bench serve (Reference Comparison)
6194

6295
`vllm bench serve` supports custom datasets only in `jsonl` format. To convert the parquet file:
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# gpt-oss-120b — MLPerf-style per-dataset output budgets in a single run.
2+
#
3+
# The MLPerf Inference gpt-oss-120b rules use DIFFERENT max output lengths for the
4+
# performance and accuracy phases:
5+
# - performance: max_output_len = 10240 (reasoning_effort low)
6+
# - accuracy: max_output_len = 32768 (reasoning_effort high)
7+
# See https://github.com/mlcommons/inference/tree/master/language/gpt-oss-120b
8+
#
9+
# This config expresses that with the per-dataset `generation_config_override`
10+
# mechanism: model_params.max_new_tokens sets the global perf budget (10240) and the
11+
# accuracy datasets override it to 32768 so their harmony CoT is not truncated before
12+
# the `assistantfinal` answer. Only the OUTPUT budget is set here; reasoning_effort is
13+
# applied client-side by the `openai_completions` adapter's Harmony encoding — baked
14+
# into the pre-tokenized perf dataset, and fixed at "high" (the Harmonize default) for
15+
# the text-prompt accuracy datasets.
16+
name: "gpt-oss-120b-per-dataset-osl"
17+
version: "1.0"
18+
type: "online"
19+
timeout: 9000
20+
21+
model_params:
22+
name: "openai/gpt-oss-120b"
23+
temperature: 1.0
24+
top_p: 1.0
25+
top_k: 0
26+
skip_special_tokens: false
27+
max_new_tokens: 10240 # global default = MLPerf perf budget; accuracy datasets override to 32768
28+
streaming: "on"
29+
30+
datasets:
31+
- name: "perf-test" # PERF phase: capped at the MLPerf perf budget (10240)
32+
type: "performance"
33+
path: "examples/04_GPTOSS120B_Example/data/perf_eval_ref.parquet"
34+
parser:
35+
input_tokens: "input_tokens"
36+
generation_config_override:
37+
max_new_tokens: 10240 # same as the global default; stated per-dataset so both phases use the mechanism
38+
- name: "aime25::gptoss" # ACCURACY: raised to the MLPerf accuracy budget (32768)
39+
type: "accuracy"
40+
generation_config_override:
41+
max_new_tokens: 32768
42+
accuracy_config:
43+
eval_method: "pass_at_1"
44+
ground_truth: "answer"
45+
extractor: "boxed_math_extractor"
46+
num_repeats: 8
47+
- name: "gpqa::gptoss"
48+
type: "accuracy"
49+
generation_config_override:
50+
max_new_tokens: 32768
51+
accuracy_config:
52+
eval_method: "pass_at_1"
53+
extractor: "abcd_extractor"
54+
ground_truth: "ground_truth"
55+
num_repeats: 5
56+
- name: "livecodebench::gptoss"
57+
type: "accuracy"
58+
generation_config_override:
59+
max_new_tokens: 32768
60+
accuracy_config:
61+
eval_method: "code_bench_scorer"
62+
extractor: "python_code_extractor"
63+
num_repeats: 3
64+
65+
settings:
66+
runtime:
67+
min_duration_ms: 30000
68+
max_duration_ms: 14400000 # 4h whole-session deadline (perf + all accuracy phases)
69+
scheduler_random_seed: 42
70+
dataloader_random_seed: 42
71+
n_samples_to_issue: 2000 # PERF phase only; accuracy phases issue their own sample counts
72+
73+
load_pattern:
74+
type: "concurrency"
75+
target_concurrency: 1024
76+
77+
client:
78+
num_workers: 16
79+
worker_initialization_timeout: 300.0
80+
log_level: "WARN"
81+
worker_gc_mode: "disabled"
82+
83+
endpoint_config:
84+
endpoints:
85+
- "http://localhost:8000"
86+
api_key: null
87+
api_type: "openai_completions"
88+
89+
report_dir: "results/vllm_gptoss_120b_per_dataset_osl/"

src/inference_endpoint/commands/benchmark/execute.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -333,9 +333,11 @@ def _load_datasets(
333333
acc_cfg.accuracy_config.extras or {},
334334
)
335335
)
336-
ds.load(
337-
api_type=config.endpoint_config.api_type, model_params=config.model_params
338-
)
336+
# Value/api-type validity of the override is already enforced at config
337+
# construction (BenchmarkConfig validates each dataset's effective params),
338+
# so this cannot raise for a validated config.
339+
ds_model_params = acc_cfg.effective_generation_config(config.model_params)
340+
ds.load(api_type=config.endpoint_config.api_type, model_params=ds_model_params)
339341
logger.info(f"Loaded {ds} - {ds.num_samples()} samples")
340342

341343
if not accuracy_cfgs:
@@ -349,16 +351,18 @@ def _load_datasets(
349351
if len(performance_cfgs) > 1:
350352
raise InputValidationError("Multiple performance datasets not supported")
351353
perf_cfg = performance_cfgs[0]
354+
# Override validity is enforced at config construction (see accuracy loop).
355+
perf_model_params = perf_cfg.effective_generation_config(config.model_params)
352356
try:
353357
dataloader = DataLoaderFactory.create_loader(perf_cfg)
354358
dataloader.load(
355359
api_type=config.endpoint_config.api_type,
356-
model_params=config.model_params,
360+
model_params=perf_model_params,
357361
)
358362
logger.info(f"Loaded {dataloader.num_samples()} samples")
359363
except FileNotFoundError as e:
360364
raise InputValidationError(
361-
f"Dataset file not found: {performance_cfgs[0].path}"
365+
f"Dataset file not found: {perf_cfg.path}"
362366
) from e
363367
except Exception as e:
364368
raise SetupError(f"Failed to load dataset: {e}") from e

src/inference_endpoint/config/schema.py

Lines changed: 155 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,45 @@ class SystemDefaults(BaseModel):
5959
DEFAULT_METRIC: ClassVar[metrics.Metric] = metrics.Throughput(0.0)
6060

6161

62+
def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
63+
"""Recursively merge ``override`` into ``base`` and return the result.
64+
65+
For overlapping keys whose values are both dicts, recurse; otherwise the
66+
override value wins. Mutates a *copy* — callers can safely pass model_dump()
67+
output. Used by ``Dataset.effective_generation_config`` so a sparse nested
68+
override (e.g. ``{osl_distribution: {max: 512}}``) preserves siblings.
69+
"""
70+
out = dict(base)
71+
for k, v in override.items():
72+
if isinstance(v, dict) and isinstance(out.get(k), dict):
73+
out[k] = _deep_merge(out[k], v)
74+
else:
75+
out[k] = v
76+
return out
77+
78+
79+
# ModelParams fields that drive the single global tokenizer / MetricsAggregator
80+
# (launched once from top-level model_params), so a per-dataset override would
81+
# desync ISL/OSL/TTFT/TPOT accounting without changing what is measured. Rejected
82+
# as generation_config_override keys — they are per-run/identity, not per-dataset.
83+
_METRICS_DECOUPLED_OVERRIDE_KEYS = frozenset({"name", "streaming", "tokenizer_name"})
84+
85+
86+
def _non_default_completion_controls(mp: ModelParams) -> list[str]:
87+
"""Completion-only ModelParams controls set to a non-default value.
88+
89+
``min_new_tokens``/``skip_special_tokens`` are only honored by the
90+
``openai_completions`` adapter; ``BenchmarkConfig`` rejects them for other
91+
``api_type``s. Shared by the top-level and per-dataset-override checks so
92+
both config surfaces validate identically.
93+
"""
94+
checks = {
95+
"min_new_tokens": mp.min_new_tokens != 1,
96+
"skip_special_tokens": not mp.skip_special_tokens,
97+
}
98+
return [name for name, non_default in checks.items() if non_default]
99+
100+
62101
class LoadPatternType(str, Enum):
63102
"""Load pattern types."""
64103

@@ -430,6 +469,36 @@ class Dataset(BaseModel):
430469
agentic_inference: AgenticInferenceConfig | None = Field(
431470
None, description="Agentic inference conversation configuration"
432471
)
472+
# Per-dataset generation config is a first-class capability: different
473+
# accuracy datasets legitimately want different generation settings (e.g.
474+
# per-dataset max OSL or top_p, as seen in DS-V4), and dataset-scoping also
475+
# enables per-dataset dynamic OSL distributions. Only generation knobs are
476+
# overridable — per-run/identity fields (`_METRICS_DECOUPLED_OVERRIDE_KEYS`:
477+
# name / streaming / tokenizer_name) drive the single global tokenizer and
478+
# MetricsAggregator, so overriding them per-dataset would desync ISL/OSL/
479+
# TTFT/TPOT accounting; they are rejected at validation.
480+
#
481+
# TODO(post-mortem): split ModelParams into a per-run ModelIdentity and a
482+
# GenerationConfig, so the override surface is exactly the generation fields
483+
# and identity fields cannot be named here at all. Field/method names use
484+
# "generation_config" to keep that migration mechanical.
485+
#
486+
# Nested dicts (`osl_distribution`, `chat_template_kwargs`) are deep-merged
487+
# so sparse overrides preserve sibling defaults.
488+
generation_config_override: dict[str, Any] | None = Field(
489+
None,
490+
description=(
491+
"Per-dataset overrides for the top-level model_params (sparse — "
492+
"only the fields you want to override). Merged on top of "
493+
"BenchmarkConfig.model_params at dataset-load time. Useful for "
494+
"MLPerf-style runs where accuracy and performance use different "
495+
"output budgets in the same fleet, e.g. "
496+
"generation_config_override: {max_new_tokens: 32768, "
497+
"temperature: 0.0}. NOTE: per-run/identity keys (`name`, "
498+
"`streaming`, `tokenizer_name`) are rejected here — set them on "
499+
"top-level model_params."
500+
),
501+
)
433502

434503
@model_validator(mode="after")
435504
def _auto_derive_name(self) -> Self:
@@ -438,6 +507,53 @@ def _auto_derive_name(self) -> Self:
438507
object.__setattr__(self, "name", Path(self.path).stem)
439508
return self
440509

510+
@model_validator(mode="after")
511+
def _validate_generation_config_override(self) -> Self:
512+
"""Fail fast on unknown keys and on per-run/identity keys the single
513+
global tokenizer / MetricsAggregator would ignore. Override *values*
514+
are validated at merge time (see ``effective_generation_config``)
515+
because cross-field validation needs the base ``ModelParams`` from
516+
``BenchmarkConfig``.
517+
"""
518+
if self.generation_config_override:
519+
keys = set(self.generation_config_override)
520+
valid = set(ModelParams.model_fields)
521+
bad = sorted(keys - valid)
522+
if bad:
523+
raise ValueError(
524+
f"Dataset '{self.name}': unknown keys in "
525+
f"generation_config_override: {bad}. "
526+
f"Valid keys: {sorted(valid)}"
527+
)
528+
decoupled = sorted(keys & _METRICS_DECOUPLED_OVERRIDE_KEYS)
529+
if decoupled:
530+
raise ValueError(
531+
f"Dataset '{self.name}': generation_config_override keys "
532+
f"{decoupled} are not honored per-dataset — the single "
533+
"global tokenizer / metrics aggregator is launched from "
534+
"top-level model_params, so a per-dataset value would "
535+
"desync ISL/OSL/TTFT/TPOT accounting. Set them on "
536+
"top-level model_params instead."
537+
)
538+
return self
539+
540+
def effective_generation_config(self, base: ModelParams) -> ModelParams:
541+
"""Return base merged with this dataset's generation-config overrides.
542+
543+
Nested dicts are deep-merged so a sparse nested override preserves
544+
sibling defaults (e.g. ``{osl_distribution: {max: 512}}`` keeps the
545+
base ``type/mean/std/min``). The merged dict is re-validated through
546+
``ModelParams.model_validate`` so type-invalid scalar overrides (e.g.
547+
``temperature: 'hot'``) are rejected. Note that this only catches
548+
scalar invalidity — a sparse nested override whose merged result
549+
passes default-validation will not raise (callers that need stricter
550+
nested validation should set ``base`` to an explicit instance).
551+
"""
552+
if not self.generation_config_override:
553+
return base
554+
merged = _deep_merge(base.model_dump(), self.generation_config_override)
555+
return ModelParams.model_validate(merged)
556+
441557

442558
class AccuracyConfig(BaseModel):
443559
"""Accuracy configuration.
@@ -988,30 +1104,47 @@ def _resolve_and_validate(self) -> Self:
9881104

9891105
# TODO(vir): Move API-type-specific validation out of this generic
9901106
# cross-model validator and into the selected adapter. Requires a larger refactor.
991-
non_default_completion_controls = {
992-
"model_params.min_new_tokens": self.model_params.min_new_tokens != 1,
993-
"model_params.skip_special_tokens": not self.model_params.skip_special_tokens,
1107+
#
1108+
# Completion-only controls must be gated by api_type for BOTH the
1109+
# top-level model_params AND every per-dataset generation_config_override,
1110+
# so the two config surfaces validate identically. Merge each dataset's
1111+
# effective params once here (parse time) — this also surfaces
1112+
# value-invalid overrides before setup produces side effects — and reuse
1113+
# the result for the agentic-inference check below.
1114+
effective_by_dataset: dict[int, ModelParams] = {
1115+
id(dataset): dataset.effective_generation_config(self.model_params)
1116+
for dataset in self.datasets
1117+
if dataset.generation_config_override
9941118
}
995-
configured_controls = [
996-
name
997-
for name, is_non_default in non_default_completion_controls.items()
998-
if is_non_default
1119+
completion_control_surfaces: list[tuple[str, ModelParams]] = [
1120+
("model_params", self.model_params)
9991121
]
1000-
if (
1001-
configured_controls
1002-
and self.endpoint_config.api_type != APIType.OPENAI_COMPLETIONS
1003-
):
1004-
controls = " and ".join(configured_controls)
1005-
verb = "requires" if len(configured_controls) == 1 else "require"
1006-
required_api_type = "endpoint_config.api_type=openai_completions"
1007-
raise ValueError(f"{controls} {verb} {required_api_type}")
1008-
if configured_controls and any(
1009-
dataset.agentic_inference is not None for dataset in self.datasets
1010-
):
1011-
raise ValueError(
1012-
"OpenAI text-completion generation controls are not supported "
1013-
"for agentic inference datasets"
1014-
)
1122+
for dataset in self.datasets:
1123+
effective = effective_by_dataset.get(id(dataset))
1124+
if effective is not None:
1125+
completion_control_surfaces.append(
1126+
(
1127+
f"datasets['{dataset.name}'].generation_config_override",
1128+
effective,
1129+
)
1130+
)
1131+
for prefix, mp in completion_control_surfaces:
1132+
controls = _non_default_completion_controls(mp)
1133+
if controls and self.endpoint_config.api_type != APIType.OPENAI_COMPLETIONS:
1134+
names = " and ".join(f"{prefix}.{name}" for name in controls)
1135+
verb = "requires" if len(controls) == 1 else "require"
1136+
raise ValueError(
1137+
f"{names} {verb} endpoint_config.api_type=openai_completions"
1138+
)
1139+
for dataset in self.datasets:
1140+
if dataset.agentic_inference is None:
1141+
continue
1142+
effective = effective_by_dataset.get(id(dataset), self.model_params)
1143+
if _non_default_completion_controls(effective):
1144+
raise ValueError(
1145+
"OpenAI text-completion generation controls are not supported "
1146+
"for agentic inference datasets"
1147+
)
10151148

10161149
# --- Validate (cross-model checks only; sub-models self-validate) ---
10171150
if self.type == TestType.SUBMISSION and not self.benchmark_mode:

src/inference_endpoint/config/templates/concurrency_template_full.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ datasets: # Dataset configs
3131
generate_params: null # Dataset-specific parameters passed to the generate() method
3232
accuracy_config: null # Accuracy evaluation settings
3333
agentic_inference: null # Agentic inference conversation configuration
34+
generation_config_override: null # Per-dataset overrides for the top-level model_params (sparse — only the fields you want to override). Merged on top of BenchmarkConfig.model_params at dataset-load time. Useful for MLPerf-style runs where accuracy and performance use different output budgets in the same fleet, e.g. generation_config_override: {max_new_tokens: 32768, temperature: 0.0}. NOTE: per-run/identity keys (`name`, `streaming`, `tokenizer_name`) are rejected here — set them on top-level model_params.
3435
- name: accuracy
3536
type: accuracy # Dataset purpose: performance or accuracy | options: performance, accuracy
3637
path: '<DATASET_PATH eg: tests/assets/datasets/ds_samples.jsonl>' # Dataset file path
@@ -47,6 +48,7 @@ datasets: # Dataset configs
4748
extractor: boxed_math_extractor # Answer extractor (abcd_extractor, boxed_math_extractor, identity_extractor, python_code_extractor)
4849
num_repeats: 1 # Repeat dataset N times for evaluation
4950
agentic_inference: null # Agentic inference conversation configuration
51+
generation_config_override: null # Per-dataset overrides for the top-level model_params (sparse — only the fields you want to override). Merged on top of BenchmarkConfig.model_params at dataset-load time. Useful for MLPerf-style runs where accuracy and performance use different output budgets in the same fleet, e.g. generation_config_override: {max_new_tokens: 32768, temperature: 0.0}. NOTE: per-run/identity keys (`name`, `streaming`, `tokenizer_name`) are rejected here — set them on top-level model_params.
5052
settings:
5153
runtime:
5254
min_duration_ms: 600000 # Min duration (ms, or with suffix: 600s, 10m)

0 commit comments

Comments
 (0)