Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
e5dd91b
fix: read llm_config decoders in the FPM collector and profile check
Arsene12358 Oct 30, 2026
00cecff
fix: read llm_config decoders in FPM config intake
Arsene12358 Oct 30, 2026
3bd920d
test: pin malformed wrapper architectures in the decoder key helper
Arsene12358 Oct 30, 2026
ddbf3d9
fix: keep the wrapper architecture when the decoder declares none
Arsene12358 Oct 30, 2026
1b9658e
test: expect the bundled Nemotron 3.5 Super VL configuration to read …
Arsene12358 Nov 6, 2026
35d7f9e
fix: read llm_config decoders in the remaining FPM paths
Arsene12358 Oct 30, 2026
d962742
perf: compute grouped admission bytes without building a lease
Arsene12358 Oct 30, 2026
478b25e
test: pin the decoder rules and the empty grouped pool
Arsene12358 Oct 30, 2026
774ef6d
fix: refuse malformed and nested decoders in the collector and the pr…
Arsene12358 Oct 30, 2026
f41a905
docs: name every reader that refuses nested or malformed decoders
Arsene12358 Oct 30, 2026
e12d00a
fix: refuse a malformed llm_config of a registered llm_config wrapper
Arsene12358 Nov 6, 2026
6c880bb
test: pin the profile mismatch of a wrapper name over an llm_config d…
Arsene12358 Nov 6, 2026
2692694
test: cover a nested llm_config checkpoint through collection plannin…
Arsene12358 Nov 7, 2026
68bcdff
Merge main into the llm_config decoder branch
Arsene12358 Nov 9, 2026
865213b
test: give the grouped pool test helper the blocks_per_request field
Arsene12358 Nov 9, 2026
c2f5e21
ci: switch workflows to renamed AISim runner pools (#861)
saturley-hall Nov 13, 2026
0b09385
Merge main into the llm_config decoder branch
Arsene12358 Nov 14, 2026
f3df4ad
refactor: use the aisim package names in the decoder checks
Arsene12358 Nov 14, 2026
18abf3c
fix: keep the grouped admission bytes of the state-aware projection
Arsene12358 Nov 14, 2026
2102478
docs: say how each reader words a decoder problem
Arsene12358 Nov 14, 2026
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
2 changes: 1 addition & 1 deletion docs/perf-model/fpm-self-service/implementation.md
Original file line number Diff line number Diff line change
Expand Up @@ -713,7 +713,7 @@ Setup reads that file and an adjacent `hf_quant_config.json` ModelOpt sidecar, w

With no parallelism flags, guided setup asks for missing shared precision/layout facts, shows a small topology shortlist with resource reasons, and lets you choose one worker with `--output`, or several with [`--output-dir`](#onboard-multiple-parallel-configurations). Enter selects only the displayed single default, and only when all required profile inputs are resolved and its estimated bytes fit the budget. Otherwise a candidate selection is required before setup resolves the remaining precision and cache geometry. Any explicit parallelism flag bypasses suggestions and retains the existing topology validation. Each chosen topology then follows the same profile review, edit, accept and cancellation flow below.

When present, `text_config` must be one nonempty decoder configuration object. Only its geometry is used; wrapper and encoder dimensions are never merged into it. A nonempty `llm_config` object is read the same way. It takes precedence over `text_config` only for wrappers that AISim maps to `llm_config`, such as `NemotronH_Omni_Reasoning_V3`. The FPM collector and the profile architecture check choose the decoder key the same way. Without such a decoder object, setup reads the flat decoder fields even when vision or audio metadata is present. Nested decoder architecture/model-type declarations select supported decoder validation and resource estimates. The profile preserves the declared architecture used by collection: nested `architectures` when present, otherwise the wrapper architecture. Provenance records both identities when they differ. A wrapper architecture never establishes an unknown nested decoder's resource layout. Shared outer dtype and quantization metadata are inherited only when the text section does not declare that metadata. The checkpoint identity hint and SHA-256 remain those of the original document. `model_max_length` is accepted as a context-limit alias; conflicting context declarations fail explicitly.
When present, `text_config` must be one nonempty decoder configuration object. Only its geometry is used; wrapper and encoder dimensions are never merged into it. A nonempty `llm_config` object is read the same way. It takes precedence over `text_config` only for wrappers that AISim maps to `llm_config`, such as `NemotronH_Omni_Reasoning_V3`. For those wrappers, `llm_config` is the decoder key whenever the config holds it. The FPM collector and the profile architecture check choose the decoder key the same way. Setup, the FPM collector and the profile architecture check refuse a decoder object that holds a `text_config` key or a nonempty `llm_config` object. Such a config nests two decoders. When the chosen decoder key does not hold one nonempty object, the three refuse it the same way. When no decoder key is chosen, setup reads the flat decoder fields even when vision or audio metadata is present. Nested decoder architecture/model-type declarations select supported decoder validation and resource estimates. The profile preserves the declared architecture used by collection: nested `architectures` when present, otherwise the wrapper architecture. Provenance records both identities when they differ. A wrapper architecture never establishes an unknown nested decoder's resource layout. Shared outer dtype and quantization metadata are inherited only when the text section does not declare that metadata. The checkpoint identity hint and SHA-256 remain those of the original document. `model_max_length` is accepted as a context-limit alias; conflicting context declarations fail explicitly.

Scripted intake and interactive final review state that FPM models the text decoder only. Config-derived estimates exclude multimodal encoders, projectors, preprocessing and other non-text components. Observed runtime cache capacity accounts for all components actually loaded by the worker, while timing describes the text decoder only. The saved profile preserves that notice in provenance through planning and generated prediction/recommendation configurations. Text-decoder cache layouts that `config.json` cannot establish, such as linear-attention, recurrent state or an architecture that intake does not recognize, are not rejected: their [cache geometry stays pending the runtime probe](#cache-geometry-pending-the-runtime-probe). Other known incompatible layouts remain rejected. MLA/DSA layouts and configs that name no architecture require explicit resource accounting.

Expand Down
5 changes: 3 additions & 2 deletions python/aisim/collector/fpm_forward/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import yaml

from aisim.fpm_contract import FPM_RESOLVED_CONFIG_GLOB
from aisim_core.decoder_config import decoder_config_key
from aisim_core.sdk.fpm_identity import EXECUTION_COLUMNS, LEGACY_EXECUTION_IDENTITY

from .capabilities import GENERIC_RUNTIME_TEMPLATE_ID
Expand Down Expand Up @@ -143,13 +144,13 @@ def _dense_synthetic_kv_eligible(
):
return False
source = plan.capability.model_config.payload
text = source.get("text_config")
decoder = source.get(decoder_config_key(source))
# Dynamo checks both configs for experts before considering recurrent state:
# https://github.com/ai-dynamo/dynamo/blob/92cf45e2e6707ac32b1fee275f949270fdd61200/components/src/dynamo/vllm/instrumented_scheduler.py#L5585
# Its skip reason alone therefore does not establish dense full attention.
if any(
config.get(key)
for config in (source, text)
for config in (source, decoder)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
if isinstance(config, dict)
for key in ("num_local_experts", "num_experts", "n_routed_experts", "moe_num_experts")
):
Expand Down
9 changes: 8 additions & 1 deletion python/aisim/collector/fpm_forward/model_capability.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
_get_model_config_path,
_parse_hf_config_json,
)
from aisim_core.decoder_config import decoder_config_problem
from aisim_core.sdk.fpm_model_metadata import (
attach_quantization_sidecar,
decoder_config_key,
Expand Down Expand Up @@ -70,11 +71,17 @@ def payload(self) -> dict[str, Any]:

@property
def effective_payload(self) -> dict[str, Any]:
"""Return decoder metadata, inheriting only identity and shared precision."""
"""Return decoder metadata, inheriting only identity and shared precision.

A decoder config that is not one nonempty object, or that holds a decoder key, raises ValueError, as config
intake refuses it. Only a config with no decoder key is read flat.
"""

payload = self.payload
key = decoder_config_key(payload)
decoder = payload.get(key)
if key is not None and (problem := decoder_config_problem(key, decoder)) is not None:
raise ValueError(f"{problem}; supply one text decoder config")
if isinstance(decoder, dict):
effective, _ = inherit_decoder_precision(payload, decoder)
if effective.get("architectures") is None and payload.get("architectures") is not None:
Expand Down
10 changes: 6 additions & 4 deletions python/aisim/src/aisim/capacity.py
Original file line number Diff line number Diff line change
Expand Up @@ -596,16 +596,18 @@ def estimate_kv_bytes_per_token(
def resolve_model_context_length(model_name: str) -> int:
"""Resolve ``context_length: max`` from a local, cached, or HF config."""

from aisim_core.decoder_config import decoder_config_key, decoder_config_problem
from aisim_core.sdk.memory import NaiveKVCacheEstimator

# Both modules ship in the same distribution; reuse the canonical loader.
config = NaiveKVCacheEstimator._load_config(model_name, allow_hf_config_download=True)
if not isinstance(config, dict):
raise ValueError(f"could not load Hugging Face config for {model_name!r}")
text_config = config.get("text_config")
mappings = [config]
if isinstance(text_config, dict):
mappings.insert(0, text_config)
key = decoder_config_key(config)
decoder = config.get(key)
if key is not None and (problem := decoder_config_problem(key, decoder)) is not None:
raise ValueError(f"Hugging Face config for {model_name!r}: {problem}")
mappings = [decoder, config] if decoder is not None else [config]
for mapping in mappings:
for name in (
"max_position_embeddings",
Expand Down
8 changes: 3 additions & 5 deletions python/aisim/src/aisim/support/config_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -606,7 +606,7 @@ def load_model_config(path: str | Path) -> ModelConfig:
"""Load local config/ModelOpt metadata with separate byte-level provenance."""
# Imported here, not at module level: tests/unit/test_fpm_config_imports.py and the repository-root
# tests/test_resource_supervision.py fail when aisim_core.decoder_config loads before a config is read.
from aisim_core.decoder_config import decoder_config_key
from aisim_core.decoder_config import decoder_config_key, nested_decoder_ambiguity

path = Path(path).expanduser().absolute()
payload = path.read_bytes()
Expand All @@ -631,10 +631,8 @@ def load_model_config(path: str | Path) -> ModelConfig:
f"{decoder_key} must be a nonempty object describing one text decoder; "
"supply its decoder config or a complete --fpm-profile"
)
if decoder_key in decoder:
raise ValueError(
f"ambiguous nested {decoder_key}; supply one text decoder config or a complete --fpm-profile"
)
if (ambiguity := nested_decoder_ambiguity(decoder_key, decoder)) is not None:
raise ValueError(f"{ambiguity}; supply one text decoder config or a complete --fpm-profile")
raw = dict(decoder)
notes["decoder_config"] = f"config {decoder_key}; outer model and encoder geometry are excluded"
inherited = []
Expand Down
83 changes: 60 additions & 23 deletions python/aisim/src/aisim_core/decoder_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,42 +3,79 @@

"""Choose the key of a checkpoint config that nests the text decoder.

FPM config intake, the FPM collector and the FPM profile check call this function, so all three read one decoder.
FPM config intake, the FPM collector and the FPM profile check call decoder_config_key, so all three read one decoder.
The module imports only the standard library. Config intake must stay usable in a process that does not import
aisim_core.sdk. The cold-process tests in tests/unit/test_support_topology.py and
tests/unit/test_onboard_topology.py check that. The one exception is a document that holds both text_config and a
nonempty llm_config: for that shape the function reads the registry from aisim_core.sdk.common.
tests/unit/test_onboard_topology.py check that, and tests/unit/sdk/test_fpm_model_metadata.py checks it for a wrapper
in LLM_CONFIG_WRAPPERS.
"""

from __future__ import annotations

from collections.abc import Mapping
from typing import Any

# The wrappers whose text decoder is under llm_config. aisim_core.sdk.common builds the llm_config entries of
# MULTIMODAL_TEXT_CONFIG_KEY from this set, so the op-level parser and decoder_config_key unwrap the same wrappers.
LLM_CONFIG_WRAPPERS = frozenset({"NemotronH_Omni_Reasoning_V3"})


def decoder_config_key(document: Mapping[str, Any]) -> str | None:
"""Return the key that nests the text decoder in a checkpoint config, or None for a flat config.

The candidates, in order, are the key that MULTIMODAL_TEXT_CONFIG_KEY registers for the wrapper's first
architecture, then text_config, then llm_config. The first candidate that the document holds wins.
text_config is held whenever the key is present, so a malformed one is still chosen and intake reports it.
llm_config is held only as a nonempty object, so a null, empty or non-object value leaves the config flat.
The function never raises.
A wrapper in LLM_CONFIG_WRAPPERS, named by the first architecture, holds llm_config whenever the key is present,
as the op-level parser does. Otherwise text_config is held whenever the key is present, and then llm_config only
as a nonempty object, so a null, empty or non-object llm_config leaves the config flat. A held key is chosen even
when its value is malformed, so the caller reports it. The function never raises.

The registry maps every wrapper to text_config or llm_config. It changes the answer only when the document holds
both keys and the wrapper maps to llm_config, so the registry is imported only in that case.
tests/unit/sdk/test_fpm_model_metadata.py checks that the registry holds no other key.
MULTIMODAL_TEXT_CONFIG_KEY maps every other wrapper to text_config. tests/unit/sdk/test_fpm_model_metadata.py
checks that the registry holds no other key and that it maps exactly LLM_CONFIG_WRAPPERS to llm_config.
"""
text_held = "text_config" in document
llm_config = document.get("llm_config")
llm_held = isinstance(llm_config, dict) and bool(llm_config)
if text_held and llm_held:
from aisim_core.sdk.common import MULTIMODAL_TEXT_CONFIG_KEY

architectures = document.get("architectures")
wrapper = architectures[0] if isinstance(architectures, list) and architectures else None
if isinstance(wrapper, str) and MULTIMODAL_TEXT_CONFIG_KEY.get(wrapper) == "llm_config":
return "llm_config"
if text_held:
architectures = document.get("architectures")
wrapper = architectures[0] if isinstance(architectures, list) and architectures else None
if isinstance(wrapper, str) and wrapper in LLM_CONFIG_WRAPPERS and "llm_config" in document:
return "llm_config"
if "text_config" in document:
return "text_config"
return "llm_config" if llm_held else None
llm_config = document.get("llm_config")
return "llm_config" if isinstance(llm_config, dict) and llm_config else None
Comment thread
Arsene12358 marked this conversation as resolved.
Resolved


def nested_decoder_ambiguity(key: str, decoder: Mapping[str, Any]) -> str | None:
"""Name the decoder key that a decoder config holds, or return None when it holds none.

key is the result of decoder_config_key and decoder is the object under it. A decoder that holds a decoder key
nests a second decoder, so no caller can tell which one to read. The held rule is that of decoder_config_key for a
document that names no wrapper in LLM_CONFIG_WRAPPERS: text_config is held whenever the key is present, and
llm_config only as a nonempty object. The decoder's own key comes first, then the other decoder key.

The text is the diagnosis only: "ambiguous nested <key>" for the own key and "ambiguous nested <inner> in <key>"
for the other key. Config intake adds its remedy after "; ", and so does the collector through
decoder_config_problem. The remedies differ, because intake accepts a complete --fpm-profile and the collector
does not. The FPM profile check and the context lookup of replay put their own text before the diagnosis and add
no remedy. The function never raises and never reads the registry.
"""
llm_config = decoder.get("llm_config")
held = {"text_config": "text_config" in decoder, "llm_config": isinstance(llm_config, dict) and bool(llm_config)}
if held.get(key):
return f"ambiguous nested {key}"
inner = "llm_config" if key == "text_config" else "text_config"
return f"ambiguous nested {inner} in {key}" if held[inner] else None


def decoder_config_problem(key: str, decoder: object) -> str | None:
"""Name what keeps the value under a decoder key from describing one text decoder, or return None.

key is the result of decoder_config_key and decoder is the value under it. The value must be a nonempty object
that holds no decoder key. text_config, and the llm_config of a wrapper in LLM_CONFIG_WRAPPERS, are held whenever
the key is present, so a null, empty or non-object value reaches this function. Any other llm_config is held only
as a nonempty object, so it never fails the first check.

The text is the diagnosis only: "<key> must be a nonempty object describing one text decoder", or the text of
nested_decoder_ambiguity. The collector adds its remedy after "; ". The FPM profile check and the context lookup
of replay put their own text before the diagnosis and add no remedy. Config intake keeps its own two checks,
because it words the remedy of each differently. The function never raises.
"""
if not isinstance(decoder, dict) or not decoder:
return f"{key} must be a nonempty object describing one text decoder"
return nested_decoder_ambiguity(key, decoder)
4 changes: 3 additions & 1 deletion python/aisim/src/aisim_core/sdk/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from packaging.version import InvalidVersion, Version

from aisim_core.decoder_config import LLM_CONFIG_WRAPPERS
from aisim_core.quantization import CommQuantMode as CommQuantMode
from aisim_core.quantization import FMHAQuantMode as FMHAQuantMode
from aisim_core.quantization import GEMMQuantMode as GEMMQuantMode
Expand Down Expand Up @@ -923,7 +924,8 @@ def get_default_models() -> set[str]:
# Multimodal architectures whose LLM config lives under a nested key (e.g. "text_config").
# _parse_hf_config_json will flatten these before parsing.
MULTIMODAL_TEXT_CONFIG_KEY = {
"NemotronH_Omni_Reasoning_V3": "llm_config",
# FPM config intake reads these wrappers without importing the SDK, so the set lives in decoder_config.
**dict.fromkeys(LLM_CONFIG_WRAPPERS, "llm_config"),
"DeepseekV41ForCausalLM": "text_config",
"Glm5NextForConditionalGeneration": "text_config",
"KimiK25ForConditionalGeneration": "text_config",
Expand Down
10 changes: 7 additions & 3 deletions python/aisim/src/aisim_core/sdk/fpm_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ def _load_pinned_checkpoint_config(model: str, revision: str) -> dict | None:

def _validate_checkpoint_architecture(profile: FpmModelProfile) -> None:
"""Verify raw checkpoint metadata without loading weights or remote code."""
from aisim_core.sdk.fpm_model_metadata import decoder_config_key
from aisim_core.decoder_config import decoder_config_key, decoder_config_problem
from aisim_core.sdk.utils import HuggingFaceDownloadError, _load_local_config

checkpoint = Path(profile.model).expanduser()
Expand All @@ -116,8 +116,12 @@ def _validate_checkpoint_architecture(profile: FpmModelProfile) -> None:
if not isinstance(metadata, dict):
raise ValueError(f"FPM profile checkpoint config.json must be a mapping for {identity}")
# Match the collector's text-model view of checkpoint metadata. A decoder that declares no architectures,
# absent or null, keeps the wrapper's, as the collector and config intake read it.
decoder = metadata.get(decoder_config_key(metadata))
# absent or null, keeps the wrapper's, as the collector and config intake read it. A decoder key that does not
# hold one text decoder is refused, as the collector and config intake refuse it.
key = decoder_config_key(metadata)
decoder = metadata.get(key)
if key is not None and (problem := decoder_config_problem(key, decoder)) is not None:
raise ValueError(f"FPM profile checkpoint config.json for {identity} has no single text decoder: {problem}")
if isinstance(decoder, dict):
merged = {**metadata, **decoder}
if decoder.get("architectures") is None:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,13 +193,13 @@ def test_failed_engine_capture_cannot_establish_dense_eligibility(tmp_path):
assert _regimes(case) == {"fake_fallback"}


@pytest.mark.parametrize("location", ["root", "text_config"])
@pytest.mark.parametrize("location", ["root", "text_config", "llm_config"])
@pytest.mark.parametrize("field", ["num_local_experts", "num_experts", "n_routed_experts", "moe_num_experts"])
def test_expert_evidence_in_either_config_rejects_dense_skip(tmp_path, location, field):
case = _dense_cell(tmp_path)
capability = case[0].capability
source = capability.model_config.payload
target = source if location == "root" else source.setdefault("text_config", {})
target = source if location == "root" else source.setdefault(location, {})
target[field] = 1 # Dynamo treats even one declared expert as a non-dense model.
capability.model_config = ResolvedModelConfig(source, source_kind="explicit", source_reference="test")
assert _regimes(case) == {"fake_fallback"}
Expand Down
Loading
Loading