Test categories
Three shapes of test exist; the first two are the focus today, the third is partial.- Single-node — runs on exactly one compute node. Used for per-device validation (HPL, NCCL loopback, DCGMI, NVMe, node health). One SLURM job per device. Orchestrated by
SingleNodeTest. - Multi-node collective — runs across two or more nodes as a single coordinated job and produces aggregate metrics (multi-node NCCL all-reduce/all-gather/all-to-all, iperf matrix, distributed training, parallel storage benchmarks). One SLURM job for the whole cohort. Orchestrated by
MultiNodeTest. - Multi-node pairwise — fans out per-NIC-pair tests across a cohort and reports per-pair metrics (
PAIRWISE_NET_BW,PAIRWISE_NET_LAT, GPU variants). Orchestrated byPairwiseNetworkTestBase(aMultiNodeTest). Status: WIP. The execution path works and reports include pairwise data, but the UI surfaces only single-node and multi-node collective results today; pairwise is reports-only.
Architecture
Burn-in tests cleanly separate orchestration (infleet-manager) from test execution (in the hardware-tests-runner library). Both live in this repo so they share types and version, but they sit in different Python packages so the dependency direction is enforced and the runner stays thin enough to extract if we ever need to ship it independently.
The runner library never imports from
fleet_manager. Fleet-manager never imports from hardware_tests_runner. They communicate through the JSON-serialised ExecutionContext and the artifacts written to S3.
For the repo-internal agent checklist and training-specific gotchas, see .agents/skills/burn-in-test-creator/SKILL.md.
Runtime topology
What each piece does:- Operator kicks off a burn-in
Jobthrough the Fleet Manager API. - Fleet Manager API + DB (hub) own the desired state (jobs, presets, thresholds) and the materialised results.
- Workflow Agent (per satellite) reconciles
Jobs into Temporal workflow executions. - Temporal
BurnInWorkflow(single-node, multi-node collective, or multi-node pairwise variant) renders an sbatch script per test, dispatches it, polls for completion, then pulls artifacts from object storage and writes results back to the API. - SLURM restd → compute nodes runs the actual job and invokes the runner CLI.
- Compute nodes stream stdout/stderr and custom artifacts to object storage via presigned URLs.
- The workflow then pulls from object storage to evaluate metrics against thresholds and persist a
TestResult.
Life of a single test
- The Temporal workflow asks the activity to render and submit a job for a given
(slug, params, RunContext). - The activity loads the test class via
registry.get_test_class, validates params, builds aSlurmBatchConfigand anExecutionContext, generates presigned S3 URLs, and renders an sbatch script fromshared/templates/sbatch.sh.j2. - SLURM runs the script. The script invokes
hardware-tests run <slug> --workspace $WORKSPACE --config-json "$CONFIG_JSON"on the compute node. - On the node, the runner instantiates the
BaseRunnersubclass for the slug and callsasync execute(...).WorkspaceManagercaptures stdout/stderr, writesmetadata.json, packagesresults/intocustom-artifacts.tar.gz, and uploads via the presigned URLs. - The workflow downloads the artifacts via
ArtifactCollector, calls the test’sextract_metrics(...)for typed metrics, runsevaluate(...)against the thresholds, and writes aTestResultback through the API.
Author a new test
Use the Hello World test (single_node/hello_world.py and the matching runner file) as the canonical reference.
1. Extend the shared contract
Inlibs/hardware-tests-contract/:
- Add a slug to
slugs.py(SingleNodeTestsorMultiNodeTestsenum +Slugs.*constant). - Add a Pydantic params model under
params/<family>.py, then re-export it fromparams/__init__.pyso callers can import it fromhardware_tests_contract.params. Keep orchestration fields out (partition, nodes, timeout, GPUs) — those come fromRunContext. Params describe what the test does, not how SLURM should schedule it. Expose only genuine workload knobs — enough that real knobs aren’t hard-coded, not so many that the surface sprawls — and keep out any knob that would move the right threshold. Thresholds are calibrated against one fixed configuration and are never switched on param values at runtime, so a threshold-moving param silently invalidates its gate; bake those into the recipe/config at the calibrated value instead. - Add a
*Metricsmodel tometrics/models.pyextendingBaseMetrics(frommetrics/base.py), or usePairwiseNetworkMetricsfor pairwise. Implementto_metrics_response()so the API/DB representation is explicit. - Add the new slug to at least one operation’s
test_slugslist insrc/fleet_manager/core/burn_in/stale_test_operations.yaml— the hardware operation(s) that should invalidate this test’s evidence (e.g. node swap, firmware update, power cycle). There is no default mapping: a coverage-gate unit test (tests/core/burn_in/test_stale_test_operations.py) fails CI if a registered slug isn’t listed under at least one operation.
2. Implement the worker-side runner
Underlibs/hardware-tests-runner/src/hardware_tests_runner/tests/:
- Subclass
BaseRunner[MyParams], setslugandparams_class, and implementasync execute(self, context, workspace_manager). - Use
workspace_manager.run_command(...)for shell workloads,write_custom_artifact(...)/get_custom_artifact_path(...)for outputs.BaseRunnertakes care of log files,metadata.json, archiving, and uploads. - Provide tooling via the node OS image PATH, a test container image, or explicit binary paths in params (e.g.
hpl_binary). - Register the runner in
hardware_tests_runner/registry.py.
3. Implement the fleet-manager orchestration class
Place the file undersingle_node/ or multi_node/ (or extend PairwiseNetworkTestBase for pairwise) and inherit SingleNodeTest / MultiNodeTest. Required:
slug,display_name,params_class,metrics_class,thresholds_class.make_batch_config(self, context: RunContext) -> SlurmBatchConfig— usemake_single_node_slurm_batch_config/make_multi_node_slurm_batch_config.RunContextexposespartition,timeout_minutes, optionalnodelist, and thenum_nodesproperty — always usecontext.num_nodesfor multi-node tests, notcontext.nodes. When anodelistis supplied,nodesis forced to 1 and the real count comes from the list length;num_nodesresolves both cases. GPU allocation comes from params or class attributes (e.g.gpus_per_node=0for non-GPU tests).extract_metrics(self, artifact_collector) -> BaseMetrics | PairwiseNetworkMetrics— pure parsing, no I/O. Reach intoartifact_collector.paths[TestArtifactFile.STDOUT]for logs,artifact_collector.get_custom_file("pattern")for unpacked custom artifacts, andartifact_collector.metadatafor the standardHardwareTestMetadata.evaluate(cls, metrics, thresholds, iteration_summary=None, resolved_params=None) -> EvaluationResult— populatemetric_evaluationswithTestMetricEvaluationentries (usemetric_pass()/metric_fail(...)), set a concisereason, and never raise — the activity wrapsEvaluationResultinto a typedTestFailedEvaluationError. Useresolved_paramsonly for persisted per-run policy that genuinely changes the verdict; parse it with the test’s params model and return a failedEvaluationResultif it is malformed.
GenericThresholds for plain numeric thresholds) and implement catalogue_thresholds() on it so the test catalogue can render numeric expectations. BaseTest.__init_subclass__ enforces both — the YAML cannot satisfy these requirements because it has no methods. Then add the threshold values keyed by slug to src/fleet_manager/core/hardware_tests/thresholds/thresholds.yaml (or, for resource-class-specific tests, under a defaults: map keyed by resource class). At runtime BaseTest.load_thresholds reads the raw dict via the thresholds.loader module and calls thresholds_class.model_validate(raw).
Then register the orchestration class in _REGISTERED_TESTS in src/fleet_manager/core/hardware_tests/registry.py.
4. Design the metrics for the UI
metrics_class is a UX surface, not a debug dump. Whatever you emit lands directly in the test results UI and the catalogue, so make every choice with the operator’s attention in mind.
- Only expose metrics operators should care about. If a number isn’t going to drive a pass/fail decision or a triage step, leave it out of the metrics model — write it to a custom artifact instead. Attention is a precious resource.
- Order matters. Field order in the Pydantic model is the display order. Put the most important metric first; less important context last.
- Name metrics for humans. Use clear, plain names (
bus_bandwidth,min_latency) — not internal column names or test-runner jargon. - Don’t repeat units in names. That’s what
MetricUnitis for.bus_bandwidthwithMetricUnit.GB_S, notbus_bandwidth_gbps. Same for latency, throughput, percentages. - Pick the right unit and stick with it across tests. Use
MetricUnit.GB_Sfor gigabytes/s (memory/bus) andMetricUnit.GBPSfor gigabits/s (network). They are not interchangeable. - Match thresholds to the metrics you display. If a metric isn’t worth a threshold or a comparator, it probably isn’t worth showing.
- Hide non-decision fields, don’t drop them. Mark fields an operator wouldn’t act on (problem size, block size, raw counts)
api_visible=False— they still persist for evaluation and debugging but stay out of the UI. - Use
detailfor actionable failure context. The per-MetricValuedetailstring (~2 KB, shown on the UI) is the operator’s first clue when a gate trips — name what and where, not just the number: the misconfigured NICs and their settings, the worst GPU pairs, the GPUs that failed ECC.
5. Verify locally
--local to dispatch to submit via the sbatch CLI instead of the REST API — useful for narrowing down REST submission bugs (resource parameter mapping, etc.).
For failed runs, uv run fleetctl hardware-tests debug <workflow-id> pulls the DebugBundle from the Temporal memo and produces presigned URLs to the artifacts.
Once it passes on real hardware, attach the evidence to the PR — the workflow id, SLURM state, and key metrics from a passing run — so reviewers can confirm it ran green on hardware, not just in unit tests.
6. Tests and pre-commit
Minimum coverage:- Unit tests for
extract_metricsagainst canned artifacts. - Unit tests for
evaluateagainst representative metrics + thresholds. - A worker-side test for
executethat exercisesWorkspaceManagerwrites. - Wiring assertions: the slug appears in
list_all_tests()andhardware_tests_runner.registry.
Common mistakes
- Putting orchestration fields (
partition,nodes,timeout, GPU counts) in the params model instead ofRunContext. - Exposing a param that changes the correct threshold. Thresholds don’t switch on param values, so the gate silently goes meaningless when a caller flips it — bake threshold-moving knobs into the recipe/config at the calibrated value.
- Doing I/O inside
extract_metrics— it must be pure parsing againstArtifactCollector. - Scraping stdout for metrics when the runner could write a JSON artifact. Emit structured JSON and
extract_metricsbecomes a trivialjson.loads; reserve log parsing for third-party tools whose output you don’t control. - Swallowing an error or defaulting a metric on bad input. A silent fallback hides a bigger fault and wastes time later —
extract_metricsraises on a missing or malformed artifact instead of returning zeros that pass the gate. - Raising inside
evaluateinstead of returning anEvaluationResult. - Threading ad-hoc runtime params into
evaluate(). It receives metrics + thresholds, optionaliteration_summary, and the persisted executionresolved_params; if a value is a measured hardware fact rather than per-run policy, tag it onto the metrics payload host-side instead. - Flipping a comparator. Latency and error-rate gate as upper bounds; bandwidth, throughput, and TFLOPs as lower bounds — a swapped comparator passes garbage green.
- Aborting the runner on a legitimate hardware-fault exit. A benchmark exiting non-zero because it found a real fault is data: capture it and let
evaluatefail the verdict. Fail the runner loudly only for an execution error (missing binary, SLURM/container problem) where the test couldn’t run. - Importing from
fleet_managerinsidehardware_tests_runner(or vice versa). The contract package is the only shared dependency. - Registering the runner but forgetting to register the fleet-manager orchestration class (or the other way round). Both registries must list the slug.
- Forgetting to add the new slug to
stale_test_operations.yaml. The coverage-gate unit test catches this at CI time, but it’s cheaper to decide up front which hardware operations should invalidate the test’s evidence. - Trying to define the thresholds schema in
thresholds.yaml. The schema (andcatalogue_thresholds()) lives in Python; the YAML only carries values. - Forgetting to add the threshold values for the slug to
thresholds.yaml—BaseTest.load_thresholdswill fail at runtime. - Reading
context.nodesin a multi-nodemake_batch_config. Usecontext.num_nodes—nodesis forced to 1 whennodelistis supplied. - Dumping every parsed value into
metrics_class. Anything that isn’t decision-grade belongs in a custom artifact, not the UI. - Encoding units in metric names (
*_gbps,*_us) instead of usingMetricUnit. - Ordering metrics by parsing convenience instead of operator importance.
- Coining a near-duplicate slug. Name it for the workload, stage-prefixed and consistent with siblings (
multi-node.nccl-all-reduce), and reuse an existing family where one fits. - Building a standalone test for a small node-level check. Extend
NodeHealthCheckTest(an opt-inMetricValuefield + achecksentry) instead of a new slug + runner + thresholds. - Choosing pairwise network tests when a collective gives the signal. Collective is simpler and surfaces in the UI; pairwise fans out into per-pair executions off the standard metrics surface — use it only for per-NIC-pair isolation.
Good references
src/fleet_manager/core/hardware_tests/single_node/hello_world.py+libs/hardware-tests-runner/src/hardware_tests_runner/tests/hello_world.py— minimal end-to-end example.src/fleet_manager/core/hardware_tests/single_node/hpl_gpu.py— GPU single-node test with thresholds.src/fleet_manager/core/hardware_tests/multi_node/nccl.py— multi-node collective.src/fleet_manager/core/hardware_tests/multi_node/pairwise_base.py— pairwise base class (WIP in the UI, used for reports today)..agents/skills/burn-in-test-creator/SKILL.md— internal agent checklist for recipe-driven tests, fail-loud review constraints, SLURM gotchas, and calibration discipline.