Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/cli-options.md
Original file line number Diff line number Diff line change
Expand Up @@ -1108,6 +1108,11 @@ Auto-invoke `aiperf plot` against the artifact directory after the benchmark com
Treat auto-plot failures as fatal: re-raise so `aiperf profile` exits non-zero. Only meaningful when auto-plot is on. Default False = warn and continue.
<br/>_Flag (no value required)_

#### `--export-outputs-json`

Export generated response text to outputs.json after the run. When enabled, the raw generated-text payload for each request is written to an outputs.json file in the artifact directory.
<br/>_Flag (no value required)_

#### `--otel-url` `<str>`

OTLP/HTTP metrics endpoint URL.
Expand Down Expand Up @@ -2566,6 +2571,11 @@ Auto-invoke `aiperf plot` against the artifact directory after the benchmark com
Treat auto-plot failures as fatal: re-raise so `aiperf profile` exits non-zero. Only meaningful when auto-plot is on. Default False = warn and continue.
<br/>_Flag (no value required)_

#### `--export-outputs-json`

Export generated response text to outputs.json after the run. When enabled, the raw generated-text payload for each request is written to an outputs.json file in the artifact directory.
<br/>_Flag (no value required)_

#### `--otel-url` `<str>`

OTLP/HTTP metrics endpoint URL.
Expand Down
13 changes: 13 additions & 0 deletions src/aiperf/config/artifacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,8 +221,21 @@ def validate_artifacts(self) -> ArtifactsConfig:
)
if self.slice_duration is not None and self.slice_duration <= 0:
raise ValueError("slice_duration must be > 0")
if self.export_outputs_json and self.prefix is not None:
self._check_outputs_json_collision()
return self

def _check_outputs_json_collision(self) -> None:
"""Reject prefix values that collide with the outputs.json path."""
if self.profile_export_json_file.resolve() == self.outputs_json_file.resolve():
base = self._base()
raise ValueError(
f"--profile-export-prefix resolves to '{base}' which produces "
f"'{base}.json', colliding with --export-outputs-json "
f"(also '{OutputDefaults.OUTPUTS_JSON_FILE.name}'). "
f"Use a different prefix."
)

# ==========================================================================
# COMPUTED FILE PATH PROPERTIES
# ==========================================================================
Expand Down
20 changes: 10 additions & 10 deletions src/aiperf/config/flags/_converter_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,22 +52,22 @@ def build_artifacts(cli: CLIConfig) -> dict[str, Any]:
{
"artifact_directory": "dir",
"export_http_trace": "trace",
"export_outputs_json": "export_outputs_json",
Comment thread
dferguson992 marked this conversation as resolved.
"show_trace_timing": "show_trace_timing",
},
)
cli_set = cli.model_fields_set
if "slice_duration" in cli_set and cli.slice_duration is not None:
artifacts["slice_duration"] = cli.slice_duration
# Only JSONL is wired up for per-record export today (no records-CSV
# exporter exists). RECORDS/RAW enable it; SUMMARY disables it.
if cli.export_level in (ExportLevel.RECORDS, ExportLevel.RAW):
artifacts["records"] = [ExportFormat.JSONL]
elif "export_level" in cli_set and cli.export_level == ExportLevel.SUMMARY:
artifacts["records"] = False
# Only emit raw when the user explicitly set the level OR the level is
# actually RAW (the CLIConfig default is RECORDS, so an unset field
# shouldn't noise up the artifacts dict with raw=False).
if "export_level" in cli_set or cli.export_level == ExportLevel.RAW:
# Only emit records/raw when the user explicitly set --export-level.
# The CLIConfig default is RECORDS, but we must not override a YAML
# config that set `artifacts.records: false` just because the default
# value happens to be RECORDS.
if "export_level" in cli_set:
if cli.export_level in (ExportLevel.RECORDS, ExportLevel.RAW):
artifacts["records"] = [ExportFormat.JSONL]
elif cli.export_level == ExportLevel.SUMMARY:
artifacts["records"] = False
artifacts["raw"] = cli.export_level == ExportLevel.RAW
if "profile_export_prefix" in cli_set and cli.profile_export_prefix:
# If the user passes an absolute path, drop the directory portion so
Expand Down
1 change: 1 addition & 0 deletions src/aiperf/config/flags/_section_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@
"auto_plot",
"export_http_trace",
"export_level",
"export_outputs_json",
"plot_required",
"profile_export_prefix",
"show_trace_timing",
Expand Down
15 changes: 15 additions & 0 deletions src/aiperf/config/flags/cli_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2376,6 +2376,21 @@ def url(self) -> str:
),
] = False

export_outputs_json: Annotated[
Comment thread
dferguson992 marked this conversation as resolved.
Comment thread
dferguson992 marked this conversation as resolved.
bool | None,
Field(
description=(
"Export generated response text to outputs.json after the run. "
"When enabled, the raw generated-text payload for each request is "
"written to an outputs.json file in the artifact directory."
),
),
CLIParameter(
name=("--export-outputs-json",),
group=Groups.OUTPUT,
),
] = None

##############################################################################
# OpenTelemetry / MLflow
##############################################################################
Expand Down
11 changes: 11 additions & 0 deletions src/aiperf/config/resolution/resolvers.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from typing import TYPE_CHECKING, Protocol, runtime_checkable

from aiperf.common.aiperf_logger import AIPerfLogger
from aiperf.config.artifacts import OutputDefaults
from aiperf.config.dataset.resolver import DatasetResolver

if TYPE_CHECKING:
Expand Down Expand Up @@ -105,6 +106,16 @@ def resolve(self, run: BenchmarkRun, *, for_probe: bool = False) -> None:
run.resolved.artifact_dir_created = True
_logger.debug(f"Artifact directory created: {artifact_dir}")

# Purge stale output fragments from a prior failed run. Fragment files
# use random service IDs as suffixes, so leftovers from a crashed run
# would silently contaminate the next outputs.json export.
if cfg.artifacts.export_outputs_json:
fragments_dir = artifact_dir / OutputDefaults.OUTPUT_FRAGMENTS_FOLDER
if fragments_dir.exists():
for stale in fragments_dir.glob("output_fragments_*.jsonl"):
stale.unlink(missing_ok=True)
_logger.debug("Purged stale output fragments")

if run.cfg.artifacts.user_files and not for_probe:
from aiperf.config.user_files import (
build_user_file_context,
Expand Down
77 changes: 77 additions & 0 deletions tests/unit/config/test_outputs_json_prefix_collision.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
"""Tests for --profile-export-prefix / --export-outputs-json path collision guard.

When both flags are active and the prefix resolves to 'outputs', the summary
JSON (`outputs.json`) and the generated-output export (`outputs.json`) would
target the same file. The model validator must reject this combination.
"""

from __future__ import annotations

import pytest
from pydantic import ValidationError
from pytest import param

from aiperf.config.artifacts import ArtifactsConfig
from aiperf.config.flags.cli_config import CLIConfig
from aiperf.config.flags.converter import convert_cli_to_aiperf


class TestOutputsJsonPrefixCollision:
"""ArtifactsConfig.validate_artifacts rejects colliding prefix + export_outputs_json."""

@pytest.mark.parametrize(
"prefix",
[
param("outputs", id="bare"),
param("outputs.json", id="with-json-suffix"),
param("outputs.jsonl", id="with-jsonl-suffix"),
param("outputs.csv", id="with-csv-suffix"),
param("outputs_raw.jsonl", id="with-raw-suffix"),
param("outputs_timeslices.json", id="with-timeslices-suffix"),
],
) # fmt: skip
def test_validate_artifacts_colliding_prefix_raises_error(
self, prefix: str
) -> None:
with pytest.raises(
ValidationError, match="colliding with --export-outputs-json"
):
ArtifactsConfig(prefix=prefix, export_outputs_json=True)

def test_validate_artifacts_colliding_prefix_without_export_allows(self) -> None:
cfg = ArtifactsConfig(prefix="outputs", export_outputs_json=False)
assert cfg.profile_export_json_file.name == "outputs.json"

def test_validate_artifacts_non_colliding_prefix_with_export_allows(self) -> None:
cfg = ArtifactsConfig(prefix="foo", export_outputs_json=True)
assert cfg.profile_export_json_file.name == "foo.json"
assert cfg.outputs_json_file.name == "outputs.json"

def test_validate_artifacts_no_prefix_with_export_allows(self) -> None:
cfg = ArtifactsConfig(export_outputs_json=True)
assert cfg.profile_export_json_file.name == "profile_export_aiperf.json"
assert cfg.outputs_json_file.name == "outputs.json"


class TestOutputsJsonPrefixCollisionCLI:
"""End-to-end: CLI converter propagates the collision to ArtifactsConfig validation."""

def test_convert_cli_to_aiperf_colliding_prefix_with_export_raises_error(
self,
) -> None:
cli = CLIConfig(
model_names=["m"],
profile_export_prefix="outputs",
export_outputs_json=True,
)
with pytest.raises(
ValidationError, match="colliding with --export-outputs-json"
):
convert_cli_to_aiperf(cli)

def test_convert_cli_to_aiperf_colliding_prefix_without_export_allows(self) -> None:
cli = CLIConfig(model_names=["m"], profile_export_prefix="outputs")
cfg = convert_cli_to_aiperf(cli)
assert cfg.benchmark.artifacts.profile_export_json_file.name == "outputs.json"
36 changes: 36 additions & 0 deletions tests/unit/config/test_resolvers.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,42 @@ def test_user_centric_default_artifact_name_uses_current_fields(self, tmp_path):

assert "user_centric-users2-qps1.0" in run.artifact_dir.name

def test_resolve_purges_stale_output_fragments(self, minimal_config, tmp_path):
"""Stale fragment files from a prior failed run are removed at resolve time."""
target = tmp_path / "artifacts"
target.mkdir()
fragments_dir = target / "output_fragments"
fragments_dir.mkdir()
stale = fragments_dir / "output_fragments_old_service_id.jsonl"
stale.write_text('{"session_num": 999}\n')

minimal_config.artifacts.export_outputs_json = True
minimal_config.artifacts.dir = target
run = _make_run(minimal_config, artifact_dir=target)

ArtifactDirResolver().resolve(run)

assert not stale.exists()

def test_resolve_skips_fragment_purge_when_export_disabled(
self, minimal_config, tmp_path
):
"""Fragment directory is left alone when export_outputs_json is False."""
target = tmp_path / "artifacts"
target.mkdir()
fragments_dir = target / "output_fragments"
fragments_dir.mkdir()
stale = fragments_dir / "output_fragments_old_service_id.jsonl"
stale.write_text('{"session_num": 999}\n')

minimal_config.artifacts.export_outputs_json = False
minimal_config.artifacts.dir = target
run = _make_run(minimal_config, artifact_dir=target)

ArtifactDirResolver().resolve(run)

assert stale.exists()


# ---------------------------------------------------------------------------
# TokenizerResolver
Expand Down
Loading