Skip to content

Additional YAML Features: units, run settings, and workloads#266

Open
caitlinross wants to merge 11 commits into
codes-org:masterfrom
caitlinross:yaml-features
Open

Additional YAML Features: units, run settings, and workloads#266
caitlinross wants to merge 11 commits into
codes-org:masterfrom
caitlinross:yaml-features

Conversation

@caitlinross

@caitlinross caitlinross commented Jul 15, 2026

Copy link
Copy Markdown
Member

With #259, the YAML config front-end could describe a machine (the topology and its model parameters) but everything else about an experiment (how long to run, what traffic to generate, how many messages, where jobs land) lived on the command line or was hardcoded in the model mains. This branch moves a substantial piece of that into the config: run-level simulation settings and synthetic workload definition can now be expressed in YAML.

This is a step toward a config that describes the full experiment: ROSS runtime options, trace-based workloads (dumpi and friends are recognized but rejected with a clear diagnostic), multi-job execution, and QoS remain outside the format for now.

What's new

  • Physical units in model parameters. Dimensioned values can be written with a unit suffix (25GiB/s, 100us) and are converted to each model's internal unit via table-driven per-parameter metadata. This matters because the models quietly disagree, e.g., link_bandwidth is GiB/s in torus but bytes/ns in fattree. Until now the user had to know each model's convention. Bare numbers still pass through verbatim, so every existing config compiles to the identical tree.
  • A run-level simulation: block for settings that belong to the run rather than the topology (end_time, pe_mem_factor). The end time is applied centrally in codes_mapping_setup, so every model honors it without per-main changes, and an explicit --end on the command line still wins.
  • Workload configuration via an inline workload: shortcut on the compute component or a top-level jobs: block that places one or more workloads onto the topology's node slots with validated allocations (in-range, no double-booking, rank counts consistent). Both spellings lower to the same internal representation.
  • Synthetic mains read workload params from the config (traffic pattern, num_messages, payload_size, arrival_time) with a consistent precedence of command line > config > built-in default. This also fixes a latent bug where two synthetic mains unconditionally clobbered the end time, silently discarding both config and --end values.

Scope note: multi-job is compile-and-validate only

A multi-job jobs: block compiles to a validated JOBS section (job count plus each job's resolved workload and node allocation) but nothing executes it yet. The synthetic mains generate one global traffic pattern with no per-job destination dispatch, so they call a guard that aborts with guidance on a multi-job config rather than silently running global traffic over the placement. The compiled JOBS section is the deliberate seam for the follow-up. It doesn't really change much code to take that support out, so I decided to leave it in even though it isn't used yet. What you get today is early validation and lintability: placement errors surface at yaml read time, and the resolved allocation is inspectable via the config dump.

Guardrails

The front-end's rule is that nothing is ever silently dropped or misinterpreted, and this branch extends that in several directions:

  • User params that would shadow compiler-derived PARAMS keys are rejected instead of silently ignored.
  • Synthetic mains abort with a clear diagnostic on multi-job configs they cannot execute (see above), verified by an integration test..
  • Unrecognized units, wrong-quantity units, and unknown keys are all hard errors with diagnostics naming the offender.

Traceability and testing

  • A new opt-in CODES_RESOLVED_CONFIG_DUMP hook writes the fully-resolved config tree (every derived and defaulted value) for any run, whether legacy .conf or YAML, so a result is traceable to one complete configuration. Combined with the harness's new load-only mode it doubles as a config linter.
  • A new compiled-tree equivalence test loads each .conf/.yaml twin through the production loader and asserts the trees are structurally equal, catching defaulting or derivation drift that a short behavioral run can mask. (cf_equal was reimplemented as an order-insensitive structural comparison to make this possible; it previously had no callers.)
  • New unit and integration tests cover unit conversion, the workload/jobs compiler paths, config-vs-CLI precedence, and end-time handling.

Legacy .conf behavior is unchanged throughout: a .conf carries none of the new sections, so every apply path is a no-op and existing runs are byte-identical.

The compiler emits modelnet_order (derived from the fabric/network model) as
the first PARAMS key, then appends the user's fabric and component params. The
config store returns the first match for a key name, so a user-supplied param
of the same name would land after the derived value and be silently ignored --
violating the front-end's rule that it never silently drops a key.

Route every user-supplied PARAMS key in compile_fabric and compile_flat through
a new add_user_param() helper that rejects any name the compiler derives
itself, with a diagnostic telling the user to remove it. modelnet_order is the
only such key today; it is kept in a small array so more can be added without
touching the call sites. The explicit-groups form derives no PARAMS and writes
modelnet_order itself, so it builds PARAMS directly and is intentionally not
guarded.

Covered by unit tests for each reachable path (flat component param, fabric
pass-through key, host-component param), that a prefix-sharing key
(modelnet_scheduler) is still accepted, and that the explicit-groups form still
allows a hand-written modelnet_order.
Add a standalone harness (config-tree-equivalence-test) that loads two config
files through the production loader -- a .conf via the legacy text parser, a
.yaml via the YAML front-end, including collective include resolution -- and
asserts the two compiled trees are equal with cf_equal. It runs no model, so
unlike the lp-io / marker equivalence tests it compares every section, key and
value between a twin pair, catching defaulting or derivation drift a short
behavioral run can mask, at a fraction of the cost. It is registered once per
twin (data-driven, taking the two paths as arguments) for the same set the
behavioral equivalence tests use; the surrogate twin is excluded because its
config is assembled per run from a template by envsubst, so there is no static
file pair to diff.

To make this usable, cf_equal is reimplemented as an order-insensitive
structural comparison: the config store keys every lookup by name (sections
case-insensitively, keys case-sensitively) and never by position, so two trees
are equal when they hold the same named entries with the same values regardless
of the order entries were written -- while the order of values *within* a key
(e.g. modelnet_order) is still significant and is checked. The previous
implementation compared entries positionally by type, so a .conf and its twin
that list PARAMS keys in a different order (every twin does -- the compiler
emits modelnet_order first) compared unequal despite being identical to every
model. It also mishandled empty-valued keys; comparing whole value lists via
getMultiKey fixes that uniformly. cf_equal had no callers, so nothing depended
on the old behavior.

A new cf_equal_report(h1, h2, FILE*) writes each divergence under its
section/key path (missing on one side, key-vs-section, or differing values) so a
failure names exactly what diverged rather than only that the trees differ;
cf_equal is now a thin wrapper over it with no report.

All twins compile to identical trees -- no compiler emission or twin fixes were
needed.
Add an environment-variable hook, CODES_RESOLVED_CONFIG_DUMP, checked in
configuration_load(). When set, rank 0 writes the fully-resolved config
tree (every compiler-derived and defaulted value, in .conf text form) so a run
is traceable to one complete configuration. The value is a file path, or
"-"/"stdout" for standard output; the dump reuses cf_dump for stdout and the
same underlying writer for a file.

It is opt-in and default-off, so it does not perturb existing tests or workflows
(several diff lp-io output or grep stdout). It works identically for a legacy
.conf run and a compiled YAML config, since both produce the same in-memory
tree -- so it is about run traceability, not a YAML feature. A destination that
can't be opened is a non-fatal warning; the run continues.

The config-tree harness gains a single-config load-only mode (a config linter),
which combined with the dump env var prints a resolved tree without running a
model; the new resolved-config-dump test uses it to assert the dump carries a
derived key (modelnet_order). Documented in doc/dev/yaml-config.md, noting it
works for .conf runs too.
A dimensioned config value can now be written with a unit suffix and is
converted to the target model's internal unit before it reaches PARAMS,
instead of every value being a bare number in whatever internal unit the
model happens to use.

Add a small pure module (unit_convert) that classifies a raw value string
against a fixed, case-sensitive suffix grammar and normalizes a recognized
unit to a canonical base unit: time -> ns, size -> bytes, bandwidth ->
bytes/second. It also formats a converted number as a plain decimal a C
reader parses (integers without a decimal point, fractions in shortest
round-tripping fixed notation, never an exponent). The module is unit-tested.

Extend the model registry with per-parameter unit metadata: each dimensioned
PARAMS key carries its quantity and the model's internal unit as a scale
factor, so conversion is table-driven and per-model. This matters because the
models disagree on units -- link_bandwidth is GiB/s in torus but bytes/ns in
fattree, net_bw_mbps is really MiB/s, and the dragonfly QoS counting windows
are microseconds. A unit-bearing value is converted; a bare number is emitted
verbatim, so it already means the internal unit and every existing config
compiles to the identical tree. A unit of the wrong quantity, a negative
magnitude, or unknown trailing text is rejected with a diagnostic naming the
parameter. A unit suffix on a knob the compiler cannot classify is also
rejected: the model would atof() it and silently keep the bare number.

Bare bandwidth numbers stay pass-through (there is no safe universal default)
and units are recommended for them.
Introduce a run-level simulation: block for settings that belong to the
run rather than the topology or a model: end_time and pe_mem_factor.

Each is resolved to the PARAMS key the simulator already reads
(PARAMS/end_time in nanoseconds, PARAMS/pe_mem_factor), so nothing
downstream changes and the values appear in the resolved-config dump
naturally. end_time is time-valued and reuses the units subsystem
(bare number = ns, or a time unit like "100us"); a size/bandwidth unit,
a non-positive value, or garbage is rejected. pe_mem_factor must be a
positive integer.

Setting one of these keys both in simulation: and as a pass-through
model parameter would let the config store's first-match lookup silently
drop one value, so the collision is rejected instead. Unknown keys in
the block are rejected like everywhere else in the front-end.
Read PARAMS/end_time (nanoseconds) in codes_mapping_setup and use it for
ROSS's g_tw_ts_end, but only when the command line did not set one. ROSS's
--end option writes g_tw_ts_end directly and records no "was set" flag, so
we compare against ROSS's compiled-in default (100000.0): an unchanged
value means no --end was given and the config value applies; any other
value is a command-line override and wins. Passing --end=100000 explicitly
is therefore indistinguishable from omitting it.

codes_mapping_setup is the chokepoint every model calls between loading the
config and running, so no per-model main() changes. A model that hardcodes
g_tw_ts_end after this call (several synthetic drivers do) keeps its value;
a model that never sets it honors the config. The key rides in PARAMS, so a
legacy .conf can carry it too.

Adds two integration tests on model-net-synthetic-fattree (which never sets
its own end time): one asserting a YAML end_time terminates the run at the
configured time via ROSS's "Simulation End Time" banner, one asserting a
command-line --end overrides it.
Add a workload front-end to the YAML config compiler: a node's workload (what
it runs) is now configured separately from the node model (what it is).

Two spellings compile to the same in-memory tree:

  - an inline `workload:` on the topology's compute component -- the common
    single-workload shortcut -- lowers to a dedicated WORKLOAD section carrying
    the synthetic params (traffic, num_messages, payload_size, arrival_time);
  - a top-level `jobs:` block places one or more workloads on the topology's
    node slots, each with a rank count and a placement (contiguous policy or an
    explicit node list). A single synthetic job covering every slot contiguously
    desugars to exactly the WORKLOAD section the inline shortcut produces; any
    richer placement emits a JOBS section, one subsection per job with its
    resolved workload, rank count, and node allocation.

Size/time params are unit-resolved at parse time (payload_size -> bytes,
arrival_time -> nanoseconds), matching the rest of the front-end; traffic stays
a verbatim pattern name the model main maps to its own enum, since the name ->
value meaning is model-specific. Only the `synthetic` workload type is wired;
other types (dumpi, ...) and a job `qos:` key are recognized but rejected with a
clear "not yet configurable from this format" diagnostic, so a user keeps the
legacy path for them. Placement is validated against the topology's slot count
(in range, no double-booking, ranks consistent with the placement, total ranks
within the available slots). Unknown keys are rejected like everywhere else.

The two forms are mutually exclusive, a workload: may live only on the compute
component, and both are rejected for an explicit-groups topology (which has no
single compute-component notion). A config with neither emits no new section, so
a legacy config's tree is unchanged.
Make the synthetic-traffic knobs (traffic, num_messages, payload_size,
arrival_time) settable from a YAML workload:/jobs: config instead of only from
the command line, so a parameter sweep no longer needs a near-identical config
copy per point.

Add codes-workload-config, a small helper a synthetic main calls once after
codes_mapping_setup(): for each option the main registered, it applies the value
from the compiled WORKLOAD section unless the command line already set it. CLI
detection follows the end_time pattern -- the global is compared to the option's
registered default (captured before tw_init parses argv), so the precedence is
command line > config > built-in default. Traffic is mapped from a friendly
pattern name to the model's own enum through a per-main table, since the name ->
value meaning differs per model. A legacy .conf carries no WORKLOAD section, so
every apply is a no-op and .conf behavior is byte-identical.

Wire all four synthetic mains (model-net-synthetic, -dragonfly-all, -fattree,
-slimfly), each passing the knobs it actually registers.

Fix an end-time clobber while here: model-net-synthetic and
model-net-synthetic-dragonfly-all unconditionally set g_tw_ts_end to a 5-day
default after codes_mapping_setup(), which silently discarded both a
config-provided end_time and a --end on the command line. The hardcoded value is
now a fallback applied only when neither the config nor the command line set an
end time (g_tw_ts_end still at ROSS's 100000.0 default). This also makes --end
take effect on those two mains, which it silently did not before.

Add two integration tests on a cheap dragonfly synthetic run: one proving a
config-provided num_messages takes effect (2 vs the model's default 20), one
proving --num_messages=1 overrides the config.
An explicit multi-job jobs: config compiles to a JOBS section (one subsection
per job with its resolved workload and node allocation), but the synthetic mains
generate a single global traffic pattern and have no per-job destination
dispatch, so they cannot execute a multi-job placement. Running such a config
would silently ignore the placement.

Emit a num_jobs marker on the JOBS section and add
codes_workload_config_check_unsupported_jobs(), which each synthetic main calls
after applying its workload params: if a JOBS section is present it aborts with a
clear diagnostic (job count plus guidance to use a single workload:, a one-job
all-nodes jobs: entry, or model-net-mpi-replay), rather than misrunning. A
single-workload (WORKLOAD-section) or legacy config is unaffected.

This keeps multi-job as a validated compile only: the JOBS allocation is the
inspectable input a future job-aware synthetic executor (or an in-memory
codes-jobmap list constructor feeding one) would consume, but wiring that
execution needs job-aware destination selection in a main's core loop, which is
out of scope here.
@codecov

codecov Bot commented Jul 15, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 70.19868% with 180 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/modelconfig/config_compiler.cxx 60.42% 150 Missing ⚠️
src/modelconfig/configuration.c 56.00% 11 Missing ⚠️
src/modelconfig/configfile.c 90.72% 9 Missing ⚠️
src/util/codes-workload-config.c 77.77% 6 Missing ⚠️
src/modelconfig/unit_convert.cxx 89.74% 4 Missing ⚠️

📢 Thoughts on this report? Let us know!

The synthetic mains' only runtime behavior for a multi-job config -- the
codes_workload_config_check_unsupported_jobs() abort -- had no test: the
compiler side (JOBS emission, placement validation) is unit-tested, but
nothing verified a main actually refuses to run one. Add a two-job
dragonfly config and a test feeding it to model-net-synthetic, gated on
the guard's diagnostic with PASS_REGULAR_EXPRESSION (a match passes and
the abort's non-zero exit is ignored), proving the run stops with
guidance instead of silently running one global pattern over the
placement. The guard fires before tw_run, so the aborted run leaves no
output files behind.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant