> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.nvidia.com/aiperf/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.nvidia.com/aiperf/_mcp/server.

# User-Defined Output Files (`artifacts.user_files`)

`artifacts.user_files` lets you declare arbitrary templated output files that are
materialized into the run directory before the benchmark begins. Files are rendered
with jinja2 against the user `variables:` block plus a small set of system-injected
names.

`AIPerfJob` accepts the same block — `artifacts` is part of the shared
`benchmark:` config envelope. `aiperf kube profile --config` serializes the spec
with camelCase aliases before submitting it, so snake_case authoring in an AIPerf
YAML is fine; in hand-written CR YAML use `artifacts.userFiles`, because the
apiserver prunes keys the CRD schema does not advertise. `content` is a typeless
`x-kubernetes-preserve-unknown-fields` node in both CRDs, so every legal shape —
including a bare string for `format: text` — is accepted at admission, and the
operator's Pydantic validators enforce the format/content pairing on reconcile.

## Where the files are written

The two execution paths reach the same result through different code:

| Path | Who writes the files |
|---|---|
| Local (`aiperf profile`, local multi-run subprocess runner) | The config resolver chain (`ArtifactDirResolver`), which derives the render identity from the resolved artifact dir. |
| Kubernetes (`AIPerfJob`, `AIPerfSweep` children) | The controller pod's `system_controller` container, before the benchmark starts, via `aiperf.kubernetes.user_files.materialize_serialized_run_user_files`. |

Kubernetes service containers boot from the controller-rendered `BenchmarkRun`
(`aiperf service --benchmark-run`) and deliberately skip the resolver chain, so
that seeds, synthesized defaults, and artifact identity stay identical across
pods. The declared entries therefore travel inside the serialized run itself and
are rendered from that data — nothing is re-resolved in-pod. Only the
`system_controller` container writes them: it owns the run directory that the
results sidecar serves and the operator harvests, while worker pods mount their
own private `/results` volume.

> **Do not put secrets in `content`.** The serialized run is stored in a
> Kubernetes ConfigMap, so anything you write into `content` is readable by any
> principal with `get configmaps` in the benchmark namespace, and it is echoed
> into the harvested artifacts. Pass credentials through
> `podTemplate.envFromSecrets` instead; see
> [Configuration](/aiperf/dev/kubernetes/kubernetes-configuration-reference).

## Quickstart

```yaml
variables:
  isl: 1024
  osl: 512

benchmark:
  artifacts:
    user_files:
      - path: input_config.json
        format: json                 # optional; inferred from content type
        content:
          isl: "{{ isl }}"
          osl: "{{ osl }}"
          endpoint: "{{ endpoint_url }}"
          model: "{{ model }}"

      - path: meta/notes.md          # subdirectories allowed
        content: |
          Run {{ job_name }} started at {{ epoch }}.
          Targeting {{ model }} @ {{ endpoint_url }}.

  models:
    - my-org/my-model
  endpoint:
    type: chat
    urls: ["http://my-frontend:8000"]
  datasets:
    - name: main
      type: synthetic
  phases:
    - name: profiling
      type: concurrency
      concurrency: 10
      requests: 100
```

Result in the run directory:

```
{artifact_dir}/               # the resolved artifacts.dir for this run
├── input_config.json
├── meta/
│   └── notes.md
└── ... (standard AIPerf artifacts)
```

Files land directly in the run directory — there is no extra per-run wrapper. The
run directory is whatever `artifacts.dir` resolves to: the explicit
`--artifact-dir` / `artifacts.dir` value, or `./artifacts/<auto-generated-name>`
when neither was set. In-cluster the operator pins `artifacts.dir` to the
`/results` container mount, so the files are harvested alongside the standard
exports and appear under
`/api/v1/results/<namespace>/<name>/runs/<epoch>/` and in `aiperf kube results`.

## Schema

Each entry is:

| Field | Type | Required | Description |
|---|---|---|---|
| `path` | string | yes | Output path **relative** to the run directory. Subdirectories OK. Absolute paths and any segment equal to `..` are rejected. |
| `format` | `json` \| `yaml` \| `text` | no | Serialization format. If omitted: `text` when `content` is a string, `json` otherwise. |
| `content` | structured or string | yes | Templated value. Dict/list/scalar for `json`/`yaml`; string for `text`. Jinja2 expressions in any string leaf are rendered. |

Format/content compatibility:
- `format: json` or `format: yaml` requires structured `content` (dict/list/scalar).
- `format: text` requires string `content`.

**Rendered scalars are coerced in `json`/`yaml` output.** A leaf like `"{{ isl }}"`
with `isl: 1024` is written as the number `1024`, not the string `"1024"`; `true`/`false`
become booleans. Strings containing no Jinja2 markers are passed through untouched, so a
hand-written `"1024"` stays a string. `format: text` never coerces.

**Dict keys are not rendered.** Jinja2 expressions only resolve in string *values*,
not in dict keys. `content: {"{{ model }}": "x"}` writes a file with the literal key
`"{{ model }}"`, not the resolved model name. Put templated values where they belong
— in values — or pre-flatten the dict before passing it to AIPerf.

## Templating context

Inside `content`, you can reference:

**1. User-declared variables** — anything you put in the top-level `variables:` block of your config. Variables may reference each other (in any YAML order); cross-references are resolved in dependency order (a Kahn-style topological pass) at config-load time, so a derived variable like `total_concurrency: "{{ concurrency_per_gpu * deployment_gpu_count }}"` already holds its computed value by the time `user_files` rendering runs. Cycles raise `ConfigurationError` naming the participating variables.

**2. System-injected names** (stable API):

| Name | Type | Meaning |
|---|---|---|
| `epoch` | str | Run epoch identifier (e.g. `"1714000000"`). Locally: wall-clock seconds captured at run start, unless the run-directory basename is itself epoch-shaped (9-10 digits, optionally with a 6-digit uid suffix) or the literal `legacy`, in which case that basename is used. Under the operator: the same epoch key the run's PVC directory is named after, so `{{ epoch }}` matches the `/api/v1/results/<ns>/<name>/runs/<epoch>/` path the artifacts land under. |
| `job_name` | str | Locally: run-directory basename (or the parent directory name when `epoch` came from the basename). Under the operator: the `AIPerfJob` name. |
| `namespace` | str | Locally: value of `AIPERF_NAMESPACE`, empty string when unset. Under the operator: the CR's namespace. |
| `model` | str | First model name from `benchmark.models` / `benchmark.model`; empty string when none. |
| `endpoint_url` | str | First entry of `benchmark.endpoint.urls`; empty string when none. |
| `artifact_dir` | str | Absolute path to the run directory. |

**Collision rule:** if a user `variables:` key shadows an injected name, the injected name wins and a `WARNING` is logged when the render context is built at run start. Rename your variable.

## Errors

These are all fatal — the benchmark does not start.

| Failure | Cause | Where you see it |
|---|---|---|
| Path validation | Absolute path, `..` segment, empty path, control chars | Config load (pydantic `ValidationError`) |
| Format/content mismatch | e.g. `format: json` with `content: "string"` | Config load (pydantic `ValidationError`) |
| Undefined variable | Template references a name not in context | Run start (`UserFileError`); message names the file path and the variable |
| Path escape | Resolved path is not inside the run directory | Run start (`UserFileError`) |
| Write failure | Disk full, permission denied, etc. | Run start (`UserFileError`); message includes resolved path and OS error |

Config-load failures reject the YAML (and, for an `AIPerfJob`, the CR submitted by
`aiperf kube profile`) before anything reaches the cluster. Run-start failures are
raised by the process executing the benchmark: locally that aborts `aiperf
profile`, and in-cluster it aborts the `system_controller` container before the
benchmark begins, which the operator surfaces as `status.phase=Failed`.

## Use cases

- **Sidecar metadata** — produce an `input_config.json` for downstream tooling that expects
  the dynamo-style deployment-shape file.
- **Run notes** — write a `notes.md` summarizing what this run is for, who triggered it.
- **Manifests** — emit a manifest a downstream pipeline will read.

## Limitations (v1)

- **Pre-run only.** Files render before the benchmark starts. Post-run files that include
  results are tracked as a future extension.
- **Files always overwrite.** No `overwrite: false` safety net.
- **No `required: false`.** Every declared file must materialize successfully or the run aborts.
- **Strict undefined.** A typo in `{{ varaibles_name }}` is a hard error, not a silent empty string.