Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
263 changes: 195 additions & 68 deletions crates/core/parity_tests/perfmodel/test_engine_step_parity.py

Large diffs are not rendered by default.

21 changes: 15 additions & 6 deletions crates/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,24 @@ pub use replay::{ReplayReport, ReplaySpec, Replayer};
pub use perfmodel::EngineConfig;
pub use perfmodel::{
AicError, BackendKind, DataType, ENGINE_CONFIG_SCHEMA_VERSION, ENGINE_SPEC_SCHEMA_VERSION,
EstimateSource, FPM_VERSION, ForwardPassMetrics, ForwardPassPerfDiagnostics,
ForwardPassPerfModel, ForwardPassPerfOptions, ForwardPassPerfReadiness, ForwardPassPerfSource,
KvCacheEstimate, KvCacheEstimateAdjusted, KvCacheEstimateError, KvCacheEstimateOptions,
KvCacheEstimateRequest, KvCacheMemoryFraction, MemoryBreakdown, ParallelMapping,
QuantizationConfig, QueuedRequestMetrics, ScheduledRequestMetrics, SpeculativeConfig,
EstimateSource, FPM_VERSION, ForwardPassFallbackPolicy, ForwardPassMetrics,
ForwardPassModelKind, ForwardPassPerfDiagnostics, ForwardPassPerfModel,
ForwardPassPerfModelConfig, ForwardPassPerfOptions, ForwardPassPerfProvenance,
Comment thread
jasonqinzhou marked this conversation as resolved.
ForwardPassPerfReadiness, ForwardPassPerfSource, KvCacheEstimate, KvCacheEstimateAdjusted,
KvCacheEstimateError, KvCacheEstimateOptions, KvCacheEstimateRequest, KvCacheMemoryFraction,
MemoryBreakdown, ParallelMapping, QuantizationConfig, QueuedRequestMetrics,
ScheduledRequestMetrics, SpeculativeConfig,
};

#[cfg(feature = "python")]
pub use perfmodel::{AicEngine, AicEngineBuilder, estimate_kv_cache};
pub use perfmodel::{
AicEngine,
// Low-level Rust embedder API for compiled step-latency handles. This is
// deliberately not registered on the Python module and is not an
// alternative ForwardPassPerfModel construction boundary.
AicEngineBuilder,
estimate_kv_cache,
};

// The imported perf-model sources historically used additional crate-root
// module paths. Keep these module aliases crate-private so the mirror subtree
Expand Down
43 changes: 37 additions & 6 deletions crates/core/src/perfmodel/engine/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,24 @@ impl Engine {
}
}

/// Eagerly validate the whole-forward database cell selected by an FPM
/// engine. The table itself is lazy, so compiling the engine is not enough
/// to prove that the requested model identity has collected prefill and
/// decode data. The canonical forward-pass constructor calls this before
/// returning so a missing/mismatched FPM pair fails before search starts.
pub(crate) fn validate_forward_pass_readiness(&self) -> Result<(), AicError> {
let Some((prefill, decode)) = self.fpm_ops() else {
return Ok(());
};
self.db
.fpm_forward
.select_cell(&prefill.match_identity, &prefill.model_path)?;
self.db
.fpm_forward
.select_cell(&decode.match_identity, &decode.model_path)?;
Ok(())
}

/// Convenience constructor: deserialize a bincode `EngineSpec` and load the
/// matching `PerfDatabase` from its identity, then [`Engine::build`].
///
Expand Down Expand Up @@ -344,12 +362,10 @@ impl Engine {
DatabaseMode::Silicon | DatabaseMode::Hybrid
)),
spec.engine.strict_provenance,
// Estimate-only systems (a spec yaml with no collected data) may
// back a SOL view: every SOL answer is analytic from the system
// spec, so tolerate a missing perf-data directory under SOL and
// let table-backed lookups miss lazily. All other modes keep the
// loud load-time gate.
spec.engine.database_mode == DatabaseMode::Sol,
// Formula/fallback-capable modes can serve a system spec without a
// collected primary directory. SILICON alone requires exact data
// at load time; every other mode owns its typed miss behavior.
spec.engine.database_mode != DatabaseMode::Silicon,
)?
.with_mode(spec.engine.database_mode, transfer_policy);
Engine::build(spec, Arc::new(db))
Expand Down Expand Up @@ -1717,6 +1733,21 @@ mod tests {
assert!(err.to_string().contains("exactly one FpmForward"), "{err}");
}

#[test]
fn fpm_readiness_eagerly_validates_the_exact_model_cell() {
let tmp = tempfile::tempdir().unwrap();
let mut engine = build_fpm_engine(tmp.path(), None).unwrap();
engine.validate_forward_pass_readiness().unwrap();

let [Op::FpmForward(prefill)] = engine.context_ops.as_mut_slice() else {
panic!("FPM fixture must contain one prefill op");
};
prefill.model_path = "org/uncollected-model".into();
let err = engine.validate_forward_pass_readiness().unwrap_err();
assert!(err.to_string().contains("No FPM cell matches"), "{err}");
assert!(err.to_string().contains("uncollected-model"), "{err}");
}

/// The marginal-decode mixed composition, exact arithmetic over the
/// fixture rows: the prefill component prices the step's SCHEDULED TOTAL
/// (ctx + gen tokens) on the prefill curve; decode is the in-curve lerp
Expand Down
227 changes: 227 additions & 0 deletions crates/core/src/perfmodel/fpm/config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

//! Canonical construction contract for [`super::ForwardPassPerfModel`].

use std::path::PathBuf;

use serde::{Deserialize, Serialize};

use crate::common::enums::{DatabaseMode, TransferPolicy};
use crate::{AicError, BackendKind};

const fn one() -> u32 {
1
}

/// Forward-pass modeling implementation selected for the compiled engine.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ForwardPassModelKind {
/// Compose the forward pass from individually modeled operations.
#[default]
OpLevel,
/// Query the exact whole-forward performance database.
Fpm,
}

impl ForwardPassModelKind {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::OpLevel => "op_level",
Self::Fpm => "fpm",
}
}
}

/// Policy used when the native AIC estimator cannot serve the requested config.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ForwardPassFallbackPolicy {
/// Fail closed. Replay and Sweeper use this policy unless explicitly changed.
#[default]
Error,
/// Use the in-memory regression model and require observations before estimates.
Regression,
}

/// Immutable model identity and selection policy for a forward-pass estimator.
///
/// This is the one public construction schema shared by Rust, Python, Replay,
/// Sweeper, and Planner. Runtime learning/tuning controls deliberately live in
/// [`super::ForwardPassPerfOptions`].
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ForwardPassPerfModelConfig {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please revise the public construction contract around the current worker-role-bound implementation before finalizing this schema.

I recommend the following API:

  1. Keep one production constructor, best_available(config), in Rust and Python. The complete config should own model/system/backend/version/topology/quantization/data-selection identity, a required immutable worker_type (prefill, decode, or aggregated), and the estimator configuration below. Fold the old standalone options into this object.
  2. Rename forward_model to estimation_mode, supporting auto (default), op_level, fpm_interpolation, and fpm_regression. Default fallback_policy to allow. Auto tries op_level -> fpm_interpolation -> fpm_regression; an explicit mode is attempted first, then remaining modes in that global priority order when fallback is allowed. With deny, only the first selected candidate is attempted. Invalid configuration or incompatible FPM input must surface as errors, and an untrained regression must report not-ready.
  3. Add a structured estimator_config namespace. CLI/Python/Sweeper/Replay should pass it through completely; Rust should own its typed schema, defaults, and validation. Preserve the latest regression feature weights and expose supported sampling, fitting, and correction controls here. Unknown fields should report their full paths instead of being ignored.
  4. Let worker_type determine the shared two-dimensional feature definition for both regression and correction: critical attention and global FFN/MoE. Keep the feature weights in one shared location. Each instance has one role-bound regression and one role-bound correction feature space; do not expose correction.by_workload. An aggregated instance handles pure prefill, pure decode, and mixed batches in that shared space.
  5. Reuse sampling/bucketing infrastructure and config types, but keep regression and correction settings and sample state separate. Regression buckets balance retained samples for one fit; correction buckets supply local observed/base ratios. Expose per-axis bucket shapes, sample budgets, readiness controls, correction bounds, and applicable range settings. Correction state must also be separate for op-level versus interpolation because their baseline predictions differ.

Illustrative shape; omitted fields use Core defaults:

worker_type: aggregated
estimation_mode: auto
fallback_policy: allow
estimator_config:
  features:
    attention_kv_weight: 1.0
    prefill_attention_pair_weight: 1.0
    ffn_token_weight: 1.0
  op_level: {}
  fpm_interpolation: {}
  fpm_regression:
    sampling:
      bins_per_axis: [4, 4]
      max_observations: 64
    min_observations: 5
    fit:
      kind: standardized_nnls
      singular_ridge_scale: 1.0e-9
  correction:
    enabled: true
    sampling:
      bins_per_axis: [4, 4]
      max_observations: 64
    min_observations: 5
    factor_bounds: {min: 0.5, max: 2.0}

Keep estimates read-only. If runtime fallback is supported, explicit tuning calls should also accumulate samples for an eligible standby regression; otherwise the fallback can remain permanently cold. Return the fully resolved config and selection/readiness provenance so replay artifacts and cache identity preserve the actual settings.

The shared role-bound correction space is a proposed modeling change beyond current main, so validate aggregated pure-prefill, pure-decode, mixed, and phase-transition accuracy before replacing the existing correction stores. Please also cover Rust/Python config round trips, independent regression/correction settings, fallback ordering and cold-start behavior, and replay preservation. The Rust-owned construction implementation with a thin Python binding remains the architectural target discussed in the earlier comment.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks Hongkuan, I agree with the overall direction, especially the explicit estimator modes and separate regression/correction settings. Two details I'd like us to settle before stabilizing the API:

  1. Fallback default: I would prefer fallback_policy: deny by default, with allow as an explicit opt-in. For simulation and recommendation, an unexpected estimator change can affect results even when construction succeeds. If we choose allow as the new default, let's treat that as an intentional behavior change and preserve the original fallback semantics when migrating existing saved configs.

  2. Shared correction feature space: As you noted, this is a modeling change beyond the config refactor. Let's make accuracy validation against the existing correction stores across aggregated pure-prefill, pure-decode, mixed batches, and phase transitions a prerequisite for switching behavior. We can adopt the config structure while keeping the correction implementation unchanged until that evidence is available.

With those two points addressed, I think this gives us a stronger basis for a stable public API.

pub model: String,
pub system: String,
pub backend: BackendKind,
#[serde(default)]
pub backend_version: Option<String>,

#[serde(default = "one", alias = "tp_size")]
pub tp: u32,
#[serde(default = "one", alias = "pp_size")]
pub pp: u32,
#[serde(default = "one", alias = "attention_dp_size")]
pub attention_dp: u32,
#[serde(default)]
pub moe_tp_size: Option<u32>,
#[serde(default)]
pub moe_ep_size: Option<u32>,

#[serde(default, alias = "gemm_dtype")]
pub gemm_quant_mode: Option<String>,
#[serde(default, alias = "moe_dtype")]
pub moe_quant_mode: Option<String>,
#[serde(default, alias = "fmha_dtype")]
pub fmha_quant_mode: Option<String>,
#[serde(default, alias = "kv_cache_dtype")]
pub kvcache_quant_mode: Option<String>,
#[serde(default, alias = "comm_dtype")]
pub comm_quant_mode: Option<String>,

#[serde(default)]
pub nextn: u32,
#[serde(default)]
pub kv_block_size: Option<u32>,
#[serde(default)]
pub forward_model: ForwardPassModelKind,

@tedzhouhk tedzhouhk Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it better to rename this field to estimation_mode? With supported values auto (default), op_level, fpm_interpolation, and fpm_regression.

auto should try estimators in this priority order: op_level -> fpm_interpolation -> fpm_regression. Set fallback_policy to allow by default; when fallback is allowed, it should follow the same priority order. Interpolation and regression should be explicit estimation modes.

Please keep these names, defaults, and selection semantics consistent across the Rust config, Python facade, and serialized CLI/Replay configuration.

#[serde(default)]
pub database_mode: DatabaseMode,
/// Explicit transfer-kind tokens. `None` means the core default (all).
#[serde(default)]
pub transfer_policy: Option<Vec<String>>,
/// Ordered request-scoped systems roots. Empty uses normal package/env discovery.
#[serde(default)]
pub systems_paths: Vec<PathBuf>,
#[serde(default)]
pub fallback_policy: ForwardPassFallbackPolicy,
}

impl ForwardPassPerfModelConfig {
pub(crate) fn validate(&self) -> Result<(), AicError> {
if self.model.trim().is_empty() {
return Err(invalid_config("model cannot be empty"));
}
if self.system.trim().is_empty() {
return Err(invalid_config("system cannot be empty"));
}
if self
.backend_version
.as_ref()
.is_some_and(|value| value.trim().is_empty())
{
return Err(invalid_config("backend_version cannot be empty"));
}
if self.tp == 0 || self.pp == 0 || self.attention_dp == 0 {
return Err(invalid_config("tp, pp, and attention_dp must be positive"));
}
if self.moe_tp_size.is_some() != self.moe_ep_size.is_some() {
return Err(invalid_config(
"moe_tp_size and moe_ep_size must be configured together",
));
}
if let (Some(moe_tp), Some(moe_ep)) = (self.moe_tp_size, self.moe_ep_size) {
if moe_tp == 0 || moe_ep == 0 {
return Err(invalid_config(
"moe_tp_size and moe_ep_size must be positive",
));
}
if u64::from(self.tp) * u64::from(self.attention_dp)
!= u64::from(moe_tp) * u64::from(moe_ep)
{
return Err(invalid_config(
"topology requires tp * attention_dp == moe_tp_size * moe_ep_size",
));
}
}
if self.nextn > 5 {
return Err(invalid_config("nextn must be in 0..=5"));
}
if self.forward_model == ForwardPassModelKind::Fpm && self.nextn != 0 {
return Err(invalid_config(
"forward_model='fpm' does not support MTP speculative decoding",
));
}
TransferPolicy::from_wire(self.transfer_policy.as_deref()).map_err(invalid_config)?;
for root in &self.systems_paths {
if !root.is_dir() {
return Err(invalid_config(format!(
"systems_paths entry is not an existing directory: {}",
root.display()
)));
}
}
Ok(())
}
}

fn invalid_config(message: impl Into<String>) -> AicError {
AicError::InvalidEngineConfig(format!(
"invalid forward pass perf model config: {}",
message.into()
))
}

#[cfg(test)]
mod tests {
use super::*;

fn config() -> ForwardPassPerfModelConfig {
ForwardPassPerfModelConfig {
model: "Qwen/Qwen3-32B".into(),
system: "h200_sxm".into(),
backend: BackendKind::Vllm,
backend_version: Some("0.19.0".into()),
tp: 1,
pp: 1,
attention_dp: 1,
moe_tp_size: None,
moe_ep_size: None,
gemm_quant_mode: None,
moe_quant_mode: None,
fmha_quant_mode: None,
kvcache_quant_mode: None,
comm_quant_mode: None,
nextn: 0,
kv_block_size: None,
forward_model: ForwardPassModelKind::OpLevel,
database_mode: DatabaseMode::Silicon,
transfer_policy: None,
systems_paths: Vec::new(),
fallback_policy: ForwardPassFallbackPolicy::Error,
}
}

#[test]
fn serde_defaults_are_fail_closed_and_typed() {
let parsed: ForwardPassPerfModelConfig = serde_json::from_value(serde_json::json!({
"model": "Qwen/Qwen3-32B",
"system": "h200_sxm",
"backend": "vllm"
}))
.unwrap();
assert_eq!(parsed.tp, 1);
assert_eq!(parsed.forward_model, ForwardPassModelKind::OpLevel);
assert_eq!(parsed.fallback_policy, ForwardPassFallbackPolicy::Error);
parsed.validate().unwrap();
}

#[test]
fn validation_rejects_invalid_policy_and_topology() {
let mut invalid = config();
invalid.transfer_policy = Some(vec!["mystery".into()]);
assert!(invalid.validate().is_err());

invalid = config();
invalid.moe_tp_size = Some(1);
invalid.moe_ep_size = Some(2);
assert!(invalid.validate().is_err());

invalid = config();
invalid.forward_model = ForwardPassModelKind::Fpm;
invalid.nextn = 1;
assert!(invalid.validate().is_err());
}
}
6 changes: 4 additions & 2 deletions crates/core/src/perfmodel/fpm/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
//! - [`samples`]: shared bucketed-sample infrastructure.
//! - [`options`]: tuning controls.

mod config;
mod correction;
mod metrics;
mod model;
Expand All @@ -32,10 +33,11 @@ mod samples;
#[cfg(test)]
mod tests;

pub use config::{ForwardPassFallbackPolicy, ForwardPassModelKind, ForwardPassPerfModelConfig};
pub(crate) use metrics::validate_forward_pass_metrics;
pub use metrics::{FPM_VERSION, ForwardPassMetrics, QueuedRequestMetrics, ScheduledRequestMetrics};
pub use model::{
ForwardPassPerfDiagnostics, ForwardPassPerfModel, ForwardPassPerfReadiness,
ForwardPassPerfSource,
ForwardPassPerfDiagnostics, ForwardPassPerfModel, ForwardPassPerfProvenance,
ForwardPassPerfReadiness, ForwardPassPerfSource,
};
pub use options::ForwardPassPerfOptions;
Loading