Skip to content

Commit bf5cba5

Browse files
authored
feat(benchmark): add family-local qualification (#1204)
* feat(benchmark): add family-local qualification Auto-discover family-owned Accuracy and Performance cases through pytest. Exercise public TRTMC commands, keep benchmark definitions shared, and support family-specific reference environments. Signed-off-by: chaofengw <chaofengw@nvidia.com> * refactor(benchmark): separate internal qualification Keep trtmc-bench as the public user tool while moving model discovery, datasets, reference environments, metrics, and reports into a repository-only driver. Generate candidate descriptors from public build inputs so internal qualification does not depend on the E2E manifest registry. Load that registry lazily for explicit descriptors and retain compiled-to-eager fallback evidence. Signed-off-by: chaofengw <chaofengw@nvidia.com> * fix(benchmark): adapt qualification to Task contracts Expose repository Python packages to internal benchmark subprocesses so source-tree bundle inspection works. Keep explicit reference timing in shared benchmark definitions and carry it into generated performance suites required by the semantic Task API. Signed-off-by: chaofengw <chaofengw@nvidia.com> * fix(benchmark): forward runtime to bundle preparation Pass the selected runtime root to the user-facing benchmark during Accuracy bundle preparation. This lets source-tree qualification use the exact native CLI for bundle inspection before candidate execution. Signed-off-by: chaofengw <chaofengw@nvidia.com> --------- Signed-off-by: chaofengw <chaofengw@nvidia.com>
1 parent cb82158 commit bf5cba5

25 files changed

Lines changed: 2450 additions & 42 deletions

apps/benchmark/native/benchmark_worker.cpp

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -647,7 +647,8 @@ Json run_solve(trtmc::ITask& task, const Json& request, const Timing& timing) {
647647
[](const trtmc::ForecastResult& result) {
648648
return Json{{"windows", 1},
649649
{"forecast_elements", result.values.size()},
650-
{"shape", result.shape}};
650+
{"shape", result.shape},
651+
{"values", result.values}};
651652
});
652653
}
653654

apps/benchmark/performance/README.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,16 @@ trtmc-bench run --model distilgpt2 --runtime-root /opt/trtmc/lib -o results/dist
2828
trtmc-bench run apps/benchmark/example.yaml -o results/example
2929
```
3030

31+
Without another workload, `--model` uses that model's E2E testcase. To benchmark
32+
user input, pass public Task request JSON or JSONL. Each JSONL row may be a raw
33+
request or `{"name": "case-name", "request": {...}}`; relative `*_path` values
34+
are resolved from the data file.
35+
36+
```bash
37+
trtmc-bench run --model gpt2-125m --data requests.jsonl \
38+
--runtime-root /opt/trtmc/lib -o results/gpt2-data
39+
```
40+
3141
Missing bundles are built through the public build command and cached. Pass
3242
`--no-build` when every selected bundle must already exist.
3343

@@ -317,3 +327,30 @@ Install benchmark-only dependencies without adding them to a model family:
317327
```bash
318328
python -m pip install -r apps/benchmark/performance/requirements.txt
319329
```
330+
331+
## Internal Accuracy and Performance qualification
332+
333+
The installed `trtmc-bench` remains a user application. Repository CI and QA
334+
use the separate, non-packaged `tools/model_benchmark.py` driver. A family opts
335+
in by adding one YAML file under `families/<family>/tests/benchmark/`; the file
336+
may contain multiple `accuracy` and `performance` cases. Families without that
337+
file, including L0-only models, are not discovered.
338+
339+
Run every discovered case or select an exact model:
340+
341+
```bash
342+
python3 tools/model_benchmark.py list
343+
python3 tools/model_benchmark.py run --all --kind performance \
344+
--runtime-root /opt/trtmc/lib --worker /opt/trtmc/bin/trtmc_benchmark_worker
345+
python3 tools/model_benchmark.py run --model gpt2-125m --kind accuracy \
346+
--dataset mmlu-five-shot=/data/mmlu_dataset.json \
347+
--runtime-root /opt/trtmc/lib --worker /opt/trtmc/bin/trtmc_benchmark_worker
348+
```
349+
350+
Manual or restricted datasets are supplied as `--dataset ID=PATH`; public
351+
download definitions may instead materialize into `--data-root`. Model files do
352+
not select a GPU. Both Accuracy and Performance invoke the installed
353+
`trtmc-bench` through its public bundle and Task path. Performance tries the
354+
declared compiled reference first and uses eager only when that reference fails
355+
to execute. A completed reference whose output disagrees with the candidate is
356+
a failed contract and never triggers fallback.

apps/benchmark/performance/baselines/task_reference.py

Lines changed: 80 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ class Session:
110110
timing_scope: str = "task-model-call-wall"
111111
input_preparation_included: bool = False
112112
asset_loading_included: bool = False
113+
compile_evidence: dict[str, Any] | None = None
113114

114115

115116
def build_parser() -> argparse.ArgumentParser:
@@ -125,7 +126,9 @@ def build_parser() -> argparse.ArgumentParser:
125126
parser.add_argument("--adapter-options-json", default="{}")
126127
parser.add_argument("--timing-contract-json", default="{}")
127128
parser.add_argument("--precision", required=True, choices=("fp16", "fp32", "bf16"))
128-
parser.add_argument("--mode", required=True, choices=("hf-eager", "pytorch-eager"))
129+
parser.add_argument(
130+
"--mode", required=True, choices=("hf-eager", "pytorch-eager", "torch-compile")
131+
)
129132
parser.add_argument("--padding", default="longest")
130133
parser.add_argument("--trust-remote-code", action="store_true")
131134
parser.add_argument("--local-files-only", action="store_true")
@@ -285,10 +288,25 @@ def _tensor_summary(value: Any) -> dict[str, Any]:
285288
}
286289

287290

291+
def _flatten_tensor_values(value: Any) -> list[float]:
292+
flattened: list[float] = []
293+
294+
def visit(item: Any) -> None:
295+
if isinstance(item, (list, tuple)):
296+
for child in item:
297+
visit(child)
298+
else:
299+
flattened.append(float(item))
300+
301+
visit(value.detach().float().cpu().tolist())
302+
return flattened
303+
304+
288305
def _forecast_summary(
289306
value: Any, task: str, quantile_levels: Sequence[float] = ()
290307
) -> dict[str, Any]:
291308
summary = _tensor_summary(value)
309+
summary["values"] = _flatten_tensor_values(value)
292310
if task not in {"series_to_point_forecast", "series_to_quantile_forecast"}:
293311
return summary
294312
shape = summary["shape"]
@@ -1352,6 +1370,9 @@ def _load_timeseries(
13521370
chronos_options = _processor_kwargs(arguments)
13531371
chronos_options.update({"device_map": str(device), "dtype": dtype})
13541372
model = ChronosBoltPipeline.from_pretrained(arguments.model, **chronos_options)
1373+
compile_evidence = None
1374+
if arguments.mode == "torch-compile":
1375+
compile_evidence = _compile_forward(model.model)
13551376
raw = _numeric_values(request, "past_values")
13561377
observed = _observed_values(request, len(raw))
13571378
context = torch.tensor([value if mask > 0 else float("nan")
@@ -1367,7 +1388,7 @@ def invoke() -> Mapping[str, Any]:
13671388
quantiles = model.model.config.chronos_config["quantiles"]
13681389
return _forecast_summary(value, task_id, quantiles)
13691390

1370-
return Session(invoke, "chronos")
1391+
return Session(invoke, "chronos", compile_evidence=compile_evidence)
13711392

13721393
config = transformers.AutoConfig.from_pretrained(
13731394
arguments.model, **_processor_kwargs(arguments)
@@ -2288,18 +2309,60 @@ def _synchronize() -> None:
22882309
return
22892310

22902311

2312+
def _compile_forward(model: Any) -> dict[str, Any]:
2313+
import torch
2314+
from torch._dynamo.backends.registry import lookup_backend
2315+
2316+
evidence = {"compiled_graph_count": 0}
2317+
inductor = lookup_backend("inductor")
2318+
2319+
def compile_graph(graph: Any, inputs: Any, **options: Any) -> Any:
2320+
compiled = inductor(graph, inputs, **options)
2321+
evidence["compiled_graph_count"] += 1
2322+
return compiled
2323+
2324+
model.forward = torch.compile(
2325+
model.forward,
2326+
backend=compile_graph,
2327+
fullgraph=False,
2328+
dynamic=False,
2329+
)
2330+
evidence.update(
2331+
{
2332+
"api": "torch.compile",
2333+
"target": "model.forward",
2334+
"backend": "inductor",
2335+
"mode": "default",
2336+
"fullgraph": False,
2337+
"dynamic": False,
2338+
"applied": True,
2339+
}
2340+
)
2341+
return evidence
2342+
2343+
22912344
def _measure(session: Session, warmup: int, iterations: int) -> tuple[list[float], dict[str, Any]]:
22922345
output: Mapping[str, Any] = {}
22932346
for _ in range(warmup):
22942347
output = session.invoke()
22952348
_synchronize()
2349+
compiled_graphs = None
2350+
if session.compile_evidence is not None:
2351+
compiled_graphs = int(session.compile_evidence["compiled_graph_count"])
2352+
if compiled_graphs < 1:
2353+
raise RuntimeError("warmup did not execute a compiled graph")
22962354
samples = []
22972355
for _ in range(iterations):
22982356
_synchronize()
22992357
started = time.perf_counter()
23002358
output = session.invoke()
23012359
_synchronize()
23022360
samples.append((time.perf_counter() - started) * 1000.0)
2361+
if (
2362+
compiled_graphs is not None
2363+
and int(session.compile_evidence["compiled_graph_count"]) != compiled_graphs
2364+
):
2365+
raise RuntimeError("model compilation occurred inside timed samples")
23032366
return samples, dict(output)
23042367

23052368

@@ -2715,8 +2778,13 @@ def run(arguments: argparse.Namespace) -> int:
27152778
if arguments.warmup < 0 or arguments.iterations <= 0:
27162779
raise ValueError("warmup must be non-negative and iterations must be positive")
27172780
expected_mode = "pytorch-eager" if arguments.adapter in PYTORCH_ADAPTERS else "hf-eager"
2718-
if arguments.mode != expected_mode:
2719-
raise ValueError(f"adapter {arguments.adapter} requires mode {expected_mode}")
2781+
supported_modes = {expected_mode}
2782+
if arguments.adapter == "pytorch-timeseries" and arguments.family == "chronos_bolt":
2783+
supported_modes.add("torch-compile")
2784+
if arguments.mode not in supported_modes:
2785+
raise ValueError(
2786+
f"adapter {arguments.adapter} requires one of {sorted(supported_modes)}"
2787+
)
27202788
request = flatten_config(_json_object(arguments.request_json, "--request-json"))
27212789
options = _json_object(arguments.adapter_options_json, "--adapter-options-json")
27222790
configured_timing = _json_object(arguments.timing_contract_json, "--timing-contract-json")
@@ -2729,6 +2797,7 @@ def run(arguments: argparse.Namespace) -> int:
27292797
expected_timing = {name: declared[name] for name in fields}
27302798
load_started = time.perf_counter()
27312799
load_seconds: float | None = None
2800+
compile_evidence: dict[str, Any] | None = None
27322801
if arguments.adapter == "upstream-elf":
27332802
samples, output_summary, framework, timing_scope, input_included, asset_included = _run_elf(
27342803
arguments, request, options
@@ -2762,6 +2831,7 @@ def run(arguments: argparse.Namespace) -> int:
27622831
) = _run_sana_wm(arguments, request, options)
27632832
else:
27642833
session = LOADERS[arguments.adapter](arguments, request, options)
2834+
compile_evidence = session.compile_evidence
27652835
load_seconds = time.perf_counter() - load_started
27662836
framework = session.framework
27672837
timing_scope = session.timing_scope
@@ -2821,8 +2891,8 @@ def run(arguments: argparse.Namespace) -> int:
28212891
"precision": arguments.precision,
28222892
"padding": arguments.padding,
28232893
"experts_implementation": None,
2824-
"compile_scope": None,
2825-
"compile_evidence": None,
2894+
"compile_scope": "model.forward" if arguments.mode == "torch-compile" else None,
2895+
"compile_evidence": compile_evidence,
28262896
"timing_scope": timing_scope,
28272897
"input_preparation_included": input_included,
28282898
"asset_loading_included": asset_included,
@@ -2839,6 +2909,7 @@ def run(arguments: argparse.Namespace) -> int:
28392909
"input_preparation_included": input_included,
28402910
"asset_loading_included": asset_included,
28412911
"model_load_excluded": True,
2912+
"compile_excluded": True,
28422913
"warmup_excluded": True,
28432914
"output_materialization_included": True,
28442915
},
@@ -2856,6 +2927,9 @@ def run(arguments: argparse.Namespace) -> int:
28562927
"environment": _environment(),
28572928
"finished_at": datetime.now(timezone.utc).isoformat(),
28582929
}
2930+
if compile_evidence is not None:
2931+
compile_evidence["warmup_completed"] = True
2932+
compile_evidence["timed_callable_uses_compiled_target"] = True
28592933
if not all(math.isfinite(float(value)) and float(value) > 0.0 for value in samples):
28602934
raise RuntimeError("reference produced an invalid timing sample")
28612935
arguments.output.parent.mkdir(parents=True, exist_ok=True)

apps/benchmark/trtmc_benchmark/catalog.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,16 @@ def default_manifest_root() -> Path:
4141

4242
class ManifestCatalog:
4343
def __init__(self, root: Path | None = None) -> None:
44-
self.root = (root or default_manifest_root()).expanduser().resolve()
44+
self.root = root.expanduser().resolve() if root is not None else None
45+
46+
def _root(self) -> Path:
47+
root = self.root or default_manifest_root()
48+
if not root.is_dir():
49+
raise BenchmarkError(f"manifest root does not exist: {root}")
50+
return root
4551

4652
def _manifest_paths(self) -> tuple[Path, ...]:
47-
if not self.root.is_dir():
48-
raise BenchmarkError(f"manifest root does not exist: {self.root}")
49-
return tuple(sorted(self.root.glob("*/tests/manifests/*.json")))
53+
return tuple(sorted(self._root().glob("*/tests/manifests/*.json")))
5054

5155
def entries(self) -> tuple[CatalogEntry, ...]:
5256
entries: list[CatalogEntry] = []
@@ -121,7 +125,7 @@ def resolve(self, selector: str) -> ModelDescriptor:
121125
if selector in {path.stem, model.name, model.hf_id}:
122126
matches.append(model)
123127
if not matches:
124-
raise BenchmarkError(f"unknown model {selector!r} under {self.root}")
128+
raise BenchmarkError(f"unknown model {selector!r} under {self._root()}")
125129
if len(matches) != 1:
126130
paths = ", ".join(str(model.manifest_path) for model in matches)
127131
raise BenchmarkError(f"ambiguous model {selector!r}: {paths}")

apps/benchmark/trtmc_benchmark/cli.py

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,13 @@ def build_parser() -> argparse.ArgumentParser:
4242
run.add_argument("--rebuild", action="store_true")
4343
run.add_argument("--manifest-root", type=Path)
4444
run.add_argument("--case", action="append", default=[])
45+
run.add_argument(
46+
"--data",
47+
action="append",
48+
default=[],
49+
metavar="[MODEL=]PATH",
50+
help="Use public Task request JSON or JSONL instead of the manifest testcase input",
51+
)
4552
run.add_argument("--operation")
4653
run.add_argument("--task", dest="selected_task", help="Select a Task bound by the model")
4754
run.add_argument("--set", dest="sets", action="append", default=[], metavar="FIELD=VALUE")
@@ -204,6 +211,7 @@ def _resolve_cases(
204211
) -> tuple[ResolvedCase, ...]:
205212
entries = _model_entries(arguments.model, spec)
206213
bundles = _path_arguments(arguments.bundle)
214+
data_paths = _path_arguments(arguments.data)
207215
configured_roots = spec.get("bundle_roots", [])
208216
if not isinstance(configured_roots, list):
209217
raise BenchmarkError("bundle_roots must be a list")
@@ -225,6 +233,8 @@ def _resolve_cases(
225233
selector = str(entry["model"])
226234
model = catalog.resolve(selector)
227235
explicit = _entry_path(entry.get("bundle"), selector, bundles, len(entries))
236+
data_path = _entry_path(entry.get("data"), selector, data_paths, len(entries))
237+
data_requests = _load_data_requests(data_path) if data_path is not None else ()
228238
bundle = find_bundle(model, explicit=explicit, roots=roots)
229239
if bundle is None:
230240
bundle = builder.provisional_path(model)
@@ -258,7 +268,24 @@ def _resolve_cases(
258268
name=display, runtime_root=runtime_root, bundle_is_explicit=explicit is not None
259269
)
260270
sweeps = _merge_sweeps(case_spec.get("sweep", {}), cli_sweeps)
261-
resolved.extend(expand_sweeps(base, sweeps))
271+
swept = expand_sweeps(base, sweeps)
272+
if not data_requests:
273+
resolved.extend(swept)
274+
continue
275+
for swept_case in swept:
276+
for data_name, request in data_requests:
277+
merged = {**swept_case.request, **request}
278+
sources = {
279+
**swept_case.sources,
280+
**{name: f"data file {data_path}" for name in request},
281+
}
282+
resolved.append(
283+
swept_case.with_values(
284+
name=f"{swept_case.name}/{data_name}",
285+
request=merged,
286+
sources=sources,
287+
)
288+
)
262289
if selected_names and arguments.config:
263290
missing = selected_names - matched_names
264291
if missing:
@@ -385,6 +412,67 @@ def _path_arguments(values: list[str]) -> dict[str, Path]:
385412
return result
386413

387414

415+
def _load_data_requests(path: Path) -> tuple[tuple[str, dict[str, Any]], ...]:
416+
path = _absolute(path)
417+
if not path.is_file():
418+
raise BenchmarkError(f"benchmark data does not exist: {path}")
419+
try:
420+
text = path.read_text(encoding="utf-8")
421+
except OSError as error:
422+
raise BenchmarkError(f"cannot read benchmark data {path}: {error}") from error
423+
try:
424+
parsed = json.loads(text)
425+
except json.JSONDecodeError:
426+
values = []
427+
for line_number, line in enumerate(text.splitlines(), start=1):
428+
if not line.strip():
429+
continue
430+
try:
431+
values.append(json.loads(line))
432+
except json.JSONDecodeError as error:
433+
raise BenchmarkError(
434+
f"benchmark data {path}:{line_number} is not valid JSON: {error}"
435+
) from error
436+
else:
437+
values = parsed if isinstance(parsed, list) else [parsed]
438+
if not values:
439+
raise BenchmarkError(f"benchmark data contains no requests: {path}")
440+
441+
requests = []
442+
seen: set[str] = set()
443+
for index, value in enumerate(values, start=1):
444+
if not isinstance(value, Mapping):
445+
raise BenchmarkError(f"benchmark data request {index} must be an object")
446+
raw_request = value.get("request", value)
447+
if not isinstance(raw_request, Mapping):
448+
raise BenchmarkError(f"benchmark data request {index}.request must be an object")
449+
raw_name = value.get("name") if "request" in value else None
450+
name = str(raw_name or f"data-{index:04d}")
451+
if not name or name in seen:
452+
raise BenchmarkError(f"benchmark data request name is empty or repeated: {name!r}")
453+
seen.add(name)
454+
request = _resolve_data_paths(dict(raw_request), path.parent)
455+
requests.append((name, request))
456+
return tuple(requests)
457+
458+
459+
def _resolve_data_paths(value: Any, root: Path) -> Any:
460+
if isinstance(value, Mapping):
461+
result = {}
462+
for name, nested in value.items():
463+
if isinstance(nested, str) and str(name).endswith("_path"):
464+
candidate = Path(nested).expanduser()
465+
result[str(name)] = str(
466+
candidate if candidate.is_absolute() else (root / candidate).resolve()
467+
)
468+
else:
469+
result[str(name)] = _resolve_data_paths(nested, root)
470+
return result
471+
if isinstance(value, list):
472+
return [_resolve_data_paths(item, root) for item in value]
473+
return value
474+
475+
388476
def _entry_path(
389477
configured: Any,
390478
selector: str,

0 commit comments

Comments
 (0)