Skip to content
Merged
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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,9 @@ htmlcov/
*.so
*.dylib

# Whole-forward FPM datasets are external runtime inputs, not source assets.
**/fpm_forward_perf.parquet
**/fpm_forward_perf.metadata.json

# Rust build output
target/
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

This file adds explicit repository-wide development guards.

## Pull request titles

- Use `<type>: <short description>` for every AISimulate PR title.
- Allowed types: `feat|fix|docs|style|refactor|perf|test|chore|ci|build|revert`.
- Check the title before creating or updating a PR.

## Performance Model Changes

Before changing a performance model, its configuration, or a caller in Rust,
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ configurations offline, without bringing up a GPU serving cluster.
[FPE Support Matrix](https://ai-dynamo.org/aisimulate/fpe-support-matrix/) ·
[Legacy AIC Support Matrix](https://ai-dynamo.org/aisimulate/support-matrix/)

Whole-forward FPM data is supplied at runtime rather than shipped in this
repository. Supply both the Parquet file and its adjacent, same-stem
`.metadata.json` sidecar (for example, `reviewed-fpm.parquet` and
`reviewed-fpm.metadata.json`). Set `estimation_mode="fpm_interpolation"` and
`estimator_config.fpm_interpolation.fpm_parquet_path` in the canonical Python or Rust configuration. Prediction/recommendation YAML also accepts the legacy `timing.forward_model: fpm` and `timing.fpm_parquet_path` fields; see the [core API guide](docs/core-api.md#external-whole-forward-fpm-data).

AISimulate is the successor to the
[AIConfigurator (AIC)](https://github.com/ai-dynamo/aiconfigurator)
repository. It brings the complete AIC application and estimator into one
Expand Down
6 changes: 5 additions & 1 deletion crates/core/parity_tests/perfmodel/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,10 @@ covers 68 engine-step records, 19 compiled-engine references, and three
per-op cases. It follows removal of eager vLLM 0.24.0 FP8-block timings
and explicit reuse of the graph-timed 0.25.0 GEMM table. Declared reuse
also fills missing GEMM shapes for the other retained precisions.
Hand-built Rust engine/FPM fixtures that query this 0.24.0 FP8-block identity
must use `PerfDatabase::load_resolved` with shared-layer reuse enabled, as the
production engine does. Primary-only `PerfDatabase::load` intentionally cannot
answer those removed rows.

The three per-op cases change only 12 QKV/projection GEMM latency values;
other per-op latencies, energies, and source labels are unchanged. For
Expand All @@ -120,7 +124,7 @@ These are prediction-regression baselines, not whole-model silicon validation.
## Engine-Step Benchmark

Historical Python-vs-Rust speedup numbers (dated + commit-stamped) live in
[`perf-speedup-report.md`](../docs/perf-speedup-report.md); they cannot be
[`perf-speedup-report.md`](../../perfmodel/docs/perf-speedup-report.md); they cannot be
regenerated (the Python arm is gone). The benchmark now times the rust
engine-step alone:

Expand Down
30 changes: 29 additions & 1 deletion crates/core/src/perfmodel/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,32 @@
//! root, so `crate::EngineConfig`, `crate::BackendKind`, ... resolve unchanged.

use std::collections::BTreeMap;
use std::path::PathBuf;
use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

/// Validate an explicit FPM input before loading data or entering Python.
pub(crate) fn validate_fpm_parquet_path(
path: Option<&Path>,
is_fpm: bool,
) -> Result<Option<&str>, crate::AicError> {
let Some(path) = path else { return Ok(None) };
let path = path.to_str().ok_or_else(|| {
crate::AicError::InvalidEngineConfig("fpm_parquet_path must be valid UTF-8".into())
})?;
if path.is_empty() {
return Err(crate::AicError::InvalidEngineConfig(
"fpm_parquet_path cannot be empty".into(),
));
}
if !is_fpm {
return Err(crate::AicError::InvalidEngineConfig(
"fpm_parquet_path requires forward_model='fpm' with exactly one FpmForward op per phase".into(),
));
}
Ok(Some(path))
}

pub const ENGINE_CONFIG_SCHEMA_VERSION: u32 = 1;
// bincode op payloads are positional, so a producer/consumer skew is only
// distinguishable by this version — `EngineSpec::from_bincode` reads and
Expand Down Expand Up @@ -118,6 +140,12 @@ pub struct EngineConfig {
#[serde(default)]
pub forward_model: Option<String>,

/// Optional external FPM parquet used when `forward_model == "fpm"`.
/// The required metadata sidecar is resolved by replacing the parquet
/// extension with `.metadata.json`.
#[serde(default)]
pub fpm_parquet_path: Option<PathBuf>,

/// Use the backend-verified bounded DeepSeek-V4.1 decoder execution profile.
#[serde(default)]
pub decoder_replay: bool,
Expand Down
112 changes: 92 additions & 20 deletions crates/core/src/perfmodel/engine/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,22 @@ impl Engine {
spec.engine.database_mode, db.database_mode
)));
}
let nextn = spec
.engine
.speculative
.as_ref()
.and_then(|s| s.nextn)
.unwrap_or(0);
Self::validate_fpm_spec(&spec)?;
Ok(Engine {
context_ops: spec.context_ops,
generation_ops: spec.generation_ops,
db,
nextn,
})
}

fn validate_fpm_spec(spec: &EngineSpec) -> Result<(), AicError> {
let nextn = spec
.engine
.speculative
Expand All @@ -361,7 +377,10 @@ impl Engine {
})
}
let any_fpm = contains_fpm(&spec.context_ops) || contains_fpm(&spec.generation_ops);
if any_fpm {
if any_fpm
|| spec.engine.fpm_parquet_path.is_some()
|| spec.engine.forward_model.as_deref() == Some("fpm")
{
// Hybrid speculative shape: the FIRST op of each phase is the
// whole-model FpmForward (target), optionally followed by
// op-level DRAFT ops (the Python rewrite keeps a scheme's
Expand All @@ -376,6 +395,10 @@ impl Engine {
Some(Op::FpmForward(d)) if d.phase == FpmPhase::Decode
) && !contains_fpm(&spec.context_ops[1..])
&& !contains_fpm(&spec.generation_ops[1..]);
crate::config::validate_fpm_parquet_path(
spec.engine.fpm_parquet_path.as_deref(),
shape_ok,
)?;
if !shape_ok {
return Err(AicError::InvalidEngineConfig(
"forward_model='fpm' spec must contain exactly one FpmForward op per phase \
Expand Down Expand Up @@ -410,12 +433,7 @@ impl Engine {
));
}
}
Ok(Engine {
context_ops: spec.context_ops,
generation_ops: spec.generation_ops,
db,
nextn,
})
Ok(())
}

/// FPM whole-model engine: each phase list LEADS with one `FpmForward`
Expand Down Expand Up @@ -451,6 +469,7 @@ impl Engine {
systems_root: &std::path::Path,
) -> Result<Engine, AicError> {
let spec = EngineSpec::from_bincode(bytes)?;
Self::validate_fpm_spec(&spec)?;
Self::validate_engine_database_mode(spec.engine.database_mode)?;
let version = spec.engine.backend_version.as_deref().ok_or_else(|| {
AicError::InvalidEngineConfig(
Expand All @@ -468,7 +487,7 @@ impl Engine {
// engines would lazily re-parse the same parquet files on its first
// query (~0.5s per engine on data-rich systems). Mode/policy, memo
// caches, and the provenance accumulator stay per-engine.
let db = PerfDatabase::load_resolved_shared(
let db = PerfDatabase::load_resolved_shared_with_fpm(
systems_root,
&spec.engine.system_name,
spec.engine.backend.as_str(),
Expand All @@ -493,6 +512,7 @@ impl Engine {
spec.engine.database_mode,
DatabaseMode::Empirical | DatabaseMode::Sol
) || spec.engine.tolerate_dirless_version,
spec.engine.fpm_parquet_path.as_deref(),
)?
.with_mode(spec.engine.database_mode, transfer_policy);
Engine::build(spec, Arc::new(db))
Expand Down Expand Up @@ -2281,6 +2301,7 @@ mod tests {
backend: BackendKind::Vllm,
backend_version: Some("0.24.0".to_string()),
forward_model: None,
fpm_parquet_path: None,
decoder_replay: false,
kv_block_size: None,
parallel: ParallelMapping {
Expand Down Expand Up @@ -3196,17 +3217,25 @@ mod tests {
/// prefill], generation = [FpmForward decode], empty sol_ops (grid-exact
/// queries never call SOL).
fn build_fpm_engine(tmp: &std::path::Path, nextn: Option<u32>) -> Result<Engine, AicError> {
let spec = build_fpm_spec(tmp, nextn);
Engine::from_spec_bytes(&spec.to_bincode()?, tmp)
}

fn build_fpm_spec(tmp: &std::path::Path, nextn: Option<u32>) -> EngineSpec {
use crate::perf_database::fpm_forward::tests::{
default_identity, default_rows, write_pair,
};
write_pair(tmp, &default_rows());
let mut db = PerfDatabase::load(&systems_root(), "b200_sxm", "vllm", "0.24.0").unwrap();
db.set_fpm_forward_for_test(crate::perf_database::FpmForwardTable::new(
tmp.to_path_buf(),
"b200_sxm",
"vllm",
"0.25.1",
));
let parquet = write_pair(tmp, &default_rows());
std::fs::copy(
systems_root().join("b200_sxm.yaml"),
tmp.join("b200_sxm.yaml"),
)
.unwrap();
let mut config = fixture_engine_config(nextn);
config.backend_version = Some("0.25.1".into());
config.forward_model = Some("fpm".into());
config.fpm_parquet_path = Some(parquet);
config.systems_path = Some(tmp.to_path_buf());
let fpm_op = |phase: FpmPhase| {
Op::FpmForward(FpmForwardOp {
name: format!("fpm_forward_{}", phase.as_str()),
Expand All @@ -3218,12 +3247,55 @@ mod tests {
sol_ops: vec![],
})
};
let spec = EngineSpec::new(
fixture_engine_config(nextn),
EngineSpec::new(
config,
vec![fpm_op(FpmPhase::Prefill)],
vec![fpm_op(FpmPhase::Decode)],
);
Engine::build(spec, Arc::new(db))
)
}

#[test]
fn external_fpm_engines_share_tables_without_backend_data() {
let tmp = tempfile::tempdir().unwrap();
let first = build_fpm_engine(tmp.path(), None).unwrap();
let second = build_fpm_engine(tmp.path(), None).unwrap();
assert!(!tmp.path().join("data").exists());
assert!(Arc::ptr_eq(
first.database().tables_arc(),
second.database().tables_arc()
));
}

#[test]
fn external_fpm_rejects_invalid_specs_before_database_loading() {
let tmp = tempfile::tempdir().unwrap();
for path in ["", "/missing/reviewed-fpm.parquet"] {
let mut config = fixture_engine_config(None);
config.fpm_parquet_path = Some(path.into());
let spec = EngineSpec::new(config, context_ops(), generation_ops());
let err = Engine::from_spec_bytes(&spec.to_bincode().unwrap(), tmp.path()).unwrap_err();
assert!(matches!(err, AicError::InvalidEngineConfig(_)), "{err}");
assert!(err.to_string().contains("fpm_parquet_path"), "{err}");
}
Comment thread
simone-chen marked this conversation as resolved.
}

#[test]
fn external_fpm_invalid_artifacts_fail_on_first_query() {
for missing_parquet in [true, false] {
let tmp = tempfile::tempdir().unwrap();
let spec = build_fpm_spec(tmp.path(), None);
let path = spec.engine.fpm_parquet_path.as_ref().unwrap();
let expected = if missing_parquet {
std::fs::remove_file(path).unwrap();
"does not exist"
} else {
std::fs::write(path.with_extension("metadata.json"), "not JSON").unwrap();
"metadata"
};
let engine = Engine::from_spec_bytes(&spec.to_bincode().unwrap(), tmp.path()).unwrap();
let err = engine.predict_prefill_latency(1, 512, 0).unwrap_err();
assert!(err.to_string().contains(expected), "{err}");
}
}

#[test]
Expand Down
1 change: 1 addition & 0 deletions crates/core/src/perfmodel/engine/spec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -824,6 +824,7 @@ mod tests {
backend: crate::BackendKind::Trtllm,
backend_version: Some("1.0.0rc3".into()),
forward_model: None,
fpm_parquet_path: None,
decoder_replay: false,
kv_block_size: Some(64),
parallel: ParallelMapping {
Expand Down
14 changes: 12 additions & 2 deletions crates/core/src/perfmodel/fpm/estimator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

//! Typed estimator-specific controls. Defaults preserve the existing algorithms.

use std::path::PathBuf;

use serde::{Deserialize, Serialize};

use super::options::{ForwardPassPerfOptions, validate_options};
Expand All @@ -23,8 +25,12 @@ pub struct EstimatorConfig {
pub struct OpLevelConfig {}

#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FpmInterpolationConfig {}
#[serde(default, deny_unknown_fields)]
pub struct FpmInterpolationConfig {
/// External parquet and its same-stem metadata sidecar.
#[serde(skip_serializing_if = "Option::is_none")]
pub fpm_parquet_path: Option<PathBuf>,
}

/// These weights currently affect regression. Native correction keeps its
/// established workload coordinates until a replacement is accuracy-qualified.
Expand Down Expand Up @@ -190,6 +196,10 @@ impl EstimatorConfig {
}

pub(crate) fn validate(&self) -> Result<(), AicError> {
crate::config::validate_fpm_parquet_path(
self.fpm_interpolation.fpm_parquet_path.as_deref(),
true,
)?;
let ridge = self.fpm_regression.fit.singular_ridge_scale;
if !ridge.is_finite() || ridge < 0.0 {
return Err(AicError::InvalidEngineConfig("estimator_config.fpm_regression.fit.singular_ridge_scale must be finite and nonnegative".into()));
Expand Down
31 changes: 30 additions & 1 deletion crates/core/src/perfmodel/fpm/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -311,8 +311,18 @@ impl ForwardPassPerfModel {
/// Construct and pin one estimator. Auto searches all modes even when
/// fallback is denied; explicit modes use the requested fallback policy.
/// A regression model may be constructed before it has enough observations.
pub fn best_available(config: ForwardPassPerfModelConfig) -> Result<Self, AicError> {
pub fn best_available(mut config: ForwardPassPerfModelConfig) -> Result<Self, AicError> {
config.validate()?;
if let Some(path) = config
.estimator_config
.fpm_interpolation
.fpm_parquet_path
.as_mut()
{
*path = std::path::absolute(&*path).map_err(|error| {
AicError::InvalidEngineConfig(format!("cannot resolve fpm_parquet_path: {error}"))
})?;
}
let requested_estimation_mode = config.estimation_mode;
let mut failures = Vec::new();
let mut last_error = None;
Expand Down Expand Up @@ -621,6 +631,25 @@ impl ForwardPassPerfModel {
&self.options
}

/// Static phase latency before online correction, using the native engine's
/// existing integration. Decode returns the total for all generated tokens.
pub fn static_phase_latency(
&self,
batch_size: u32,
input_tokens: u32,
output_tokens: u32,
prefill: bool,
) -> Result<f64, AicError> {
let engine = self.native_engine().ok_or_else(|| {
AicError::InvalidEngineConfig("static phase latency requires a native estimator".into())
})?;
if prefill {
engine.predict_prefill_latency(batch_size, input_tokens, 0)
} else {
engine.predict_decode_latency(batch_size, input_tokens, output_tokens)
}
}

/// Native operation evidence for one static prefill or decode step. Values
/// precede learned online correction; SOL is a comparison only. Whole-model
/// estimators cannot provide an operation decomposition and fail explicitly.
Expand Down
Loading
Loading