use crate::error::{OptimError, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
const SOFTMAX_FLOPS_PER_ELEMENT: f64 = 5.0;
const BATCHNORM_FLOPS_PER_ELEMENT: f64 = 2.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Bottleneck {
Compute,
Bandwidth,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum PoolKind {
Max,
Average,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ActivationKind {
Relu,
Sigmoid,
Tanh,
Gelu,
Softmax,
}
impl ActivationKind {
pub fn flops_per_element(self) -> f64 {
match self {
ActivationKind::Relu => 1.0,
ActivationKind::Sigmoid => 4.0,
ActivationKind::Tanh => 6.0,
ActivationKind::Gelu => 8.0,
ActivationKind::Softmax => 5.0,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum LayerKind {
Conv2d,
Linear,
Attention,
Pooling,
Activation,
BatchNorm,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum LayerSpec {
Conv2d {
in_channels: usize,
out_channels: usize,
kernel: (usize, usize),
stride: (usize, usize),
padding: (usize, usize),
input_hw: (usize, usize),
},
Linear {
in_features: usize,
out_features: usize,
},
Attention {
seq_len: usize,
embed_dim: usize,
num_heads: usize,
},
Pooling {
kind: PoolKind,
channels: usize,
kernel: (usize, usize),
stride: (usize, usize),
padding: (usize, usize),
input_hw: (usize, usize),
},
Activation {
kind: ActivationKind,
num_elements: usize,
},
BatchNorm {
num_features: usize,
spatial_hw: (usize, usize),
},
}
fn windowed_output_dim(
in_dim: usize,
kernel: usize,
stride: usize,
padding: usize,
) -> Result<usize> {
if stride == 0 {
return Err(OptimError::InvalidParameter(
"stride must be >= 1".to_string(),
));
}
let padded = in_dim + 2 * padding;
if kernel == 0 || kernel > padded {
return Err(OptimError::InvalidParameter(format!(
"kernel {kernel} exceeds padded input extent {padded}"
)));
}
Ok((padded - kernel) / stride + 1)
}
fn windowed_output_hw(
input_hw: (usize, usize),
kernel: (usize, usize),
stride: (usize, usize),
padding: (usize, usize),
) -> Result<(usize, usize)> {
let out_h = windowed_output_dim(input_hw.0, kernel.0, stride.0, padding.0)?;
let out_w = windowed_output_dim(input_hw.1, kernel.1, stride.1, padding.1)?;
Ok((out_h, out_w))
}
fn require_positive(name: &str, value: usize) -> Result<()> {
if value == 0 {
Err(OptimError::InvalidParameter(format!("{name} must be >= 1")))
} else {
Ok(())
}
}
impl LayerSpec {
pub fn kind(&self) -> LayerKind {
match self {
LayerSpec::Conv2d { .. } => LayerKind::Conv2d,
LayerSpec::Linear { .. } => LayerKind::Linear,
LayerSpec::Attention { .. } => LayerKind::Attention,
LayerSpec::Pooling { .. } => LayerKind::Pooling,
LayerSpec::Activation { .. } => LayerKind::Activation,
LayerSpec::BatchNorm { .. } => LayerKind::BatchNorm,
}
}
pub fn validate(&self) -> Result<()> {
match *self {
LayerSpec::Conv2d {
in_channels,
out_channels,
kernel,
stride,
padding,
input_hw,
} => {
require_positive("conv in_channels", in_channels)?;
require_positive("conv out_channels", out_channels)?;
require_positive("conv kernel height", kernel.0)?;
require_positive("conv kernel width", kernel.1)?;
require_positive("conv input height", input_hw.0)?;
require_positive("conv input width", input_hw.1)?;
windowed_output_hw(input_hw, kernel, stride, padding)?;
}
LayerSpec::Linear {
in_features,
out_features,
} => {
require_positive("linear in_features", in_features)?;
require_positive("linear out_features", out_features)?;
}
LayerSpec::Attention {
seq_len,
embed_dim,
num_heads,
} => {
require_positive("attention seq_len", seq_len)?;
require_positive("attention embed_dim", embed_dim)?;
require_positive("attention num_heads", num_heads)?;
if embed_dim % num_heads != 0 {
return Err(OptimError::InvalidParameter(format!(
"embed_dim {embed_dim} is not divisible by num_heads {num_heads}"
)));
}
}
LayerSpec::Pooling {
channels,
kernel,
stride,
padding,
input_hw,
..
} => {
require_positive("pool channels", channels)?;
require_positive("pool kernel height", kernel.0)?;
require_positive("pool kernel width", kernel.1)?;
require_positive("pool input height", input_hw.0)?;
require_positive("pool input width", input_hw.1)?;
windowed_output_hw(input_hw, kernel, stride, padding)?;
}
LayerSpec::Activation { num_elements, .. } => {
require_positive("activation num_elements", num_elements)?;
}
LayerSpec::BatchNorm {
num_features,
spatial_hw,
} => {
require_positive("batchnorm num_features", num_features)?;
require_positive("batchnorm spatial height", spatial_hw.0)?;
require_positive("batchnorm spatial width", spatial_hw.1)?;
}
}
Ok(())
}
fn input_elements(&self) -> f64 {
match *self {
LayerSpec::Conv2d {
in_channels,
input_hw,
..
} => in_channels as f64 * input_hw.0 as f64 * input_hw.1 as f64,
LayerSpec::Linear { in_features, .. } => in_features as f64,
LayerSpec::Attention {
seq_len, embed_dim, ..
} => seq_len as f64 * embed_dim as f64,
LayerSpec::Pooling {
channels, input_hw, ..
} => channels as f64 * input_hw.0 as f64 * input_hw.1 as f64,
LayerSpec::Activation { num_elements, .. } => num_elements as f64,
LayerSpec::BatchNorm {
num_features,
spatial_hw,
} => num_features as f64 * spatial_hw.0 as f64 * spatial_hw.1 as f64,
}
}
pub fn output_elements(&self) -> Result<f64> {
let elements = match *self {
LayerSpec::Conv2d {
out_channels,
kernel,
stride,
padding,
input_hw,
..
} => {
let (out_h, out_w) = windowed_output_hw(input_hw, kernel, stride, padding)?;
out_channels as f64 * out_h as f64 * out_w as f64
}
LayerSpec::Linear { out_features, .. } => out_features as f64,
LayerSpec::Attention {
seq_len, embed_dim, ..
} => seq_len as f64 * embed_dim as f64,
LayerSpec::Pooling {
channels,
kernel,
stride,
padding,
input_hw,
..
} => {
let (out_h, out_w) = windowed_output_hw(input_hw, kernel, stride, padding)?;
channels as f64 * out_h as f64 * out_w as f64
}
LayerSpec::Activation { num_elements, .. } => num_elements as f64,
LayerSpec::BatchNorm {
num_features,
spatial_hw,
} => num_features as f64 * spatial_hw.0 as f64 * spatial_hw.1 as f64,
};
Ok(elements)
}
pub fn parameter_count(&self) -> f64 {
match *self {
LayerSpec::Conv2d {
in_channels,
out_channels,
kernel,
..
} => {
out_channels as f64 * in_channels as f64 * kernel.0 as f64 * kernel.1 as f64
+ out_channels as f64
}
LayerSpec::Linear {
in_features,
out_features,
} => in_features as f64 * out_features as f64 + out_features as f64,
LayerSpec::Attention { embed_dim, .. } => {
let d = embed_dim as f64;
4.0 * d * d + 4.0 * d
}
LayerSpec::Pooling { .. } | LayerSpec::Activation { .. } => 0.0,
LayerSpec::BatchNorm { num_features, .. } => 2.0 * num_features as f64,
}
}
pub fn flops(&self) -> Result<f64> {
self.validate()?;
let flops = match *self {
LayerSpec::Conv2d {
in_channels,
out_channels,
kernel,
stride,
padding,
input_hw,
} => {
let (out_h, out_w) = windowed_output_hw(input_hw, kernel, stride, padding)?;
2.0 * out_h as f64
* out_w as f64
* out_channels as f64
* in_channels as f64
* kernel.0 as f64
* kernel.1 as f64
}
LayerSpec::Linear {
in_features,
out_features,
} => 2.0 * in_features as f64 * out_features as f64,
LayerSpec::Attention {
seq_len,
embed_dim,
num_heads,
} => {
let s = seq_len as f64;
let d = embed_dim as f64;
let h = num_heads as f64;
let qkv_and_out_proj = 8.0 * s * d * d;
let scores_and_context = 4.0 * s * s * d;
let softmax = SOFTMAX_FLOPS_PER_ELEMENT * h * s * s;
qkv_and_out_proj + scores_and_context + softmax
}
LayerSpec::Pooling {
channels,
kernel,
stride,
padding,
input_hw,
..
} => {
let (out_h, out_w) = windowed_output_hw(input_hw, kernel, stride, padding)?;
channels as f64 * out_h as f64 * out_w as f64 * kernel.0 as f64 * kernel.1 as f64
}
LayerSpec::Activation { kind, num_elements } => {
num_elements as f64 * kind.flops_per_element()
}
LayerSpec::BatchNorm {
num_features,
spatial_hw,
} => {
num_features as f64
* spatial_hw.0 as f64
* spatial_hw.1 as f64
* BATCHNORM_FLOPS_PER_ELEMENT
}
};
Ok(flops)
}
pub fn activation_bytes(&self, dtype_bytes: usize) -> Result<f64> {
self.validate()?;
Ok(self.output_elements()? * dtype_bytes as f64)
}
pub fn bytes_moved(&self, dtype_bytes: usize) -> Result<f64> {
self.validate()?;
let elements = self.input_elements() + self.parameter_count() + self.output_elements()?;
Ok(elements * dtype_bytes as f64)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HardwareProfile {
pub name: String,
pub peak_flops_per_s: f64,
pub memory_bandwidth_bytes_per_s: f64,
pub energy_per_flop_j: f64,
pub energy_per_byte_j: f64,
pub dtype_bytes: usize,
}
impl HardwareProfile {
pub fn new(
name: impl Into<String>,
peak_flops_per_s: f64,
memory_bandwidth_bytes_per_s: f64,
energy_per_flop_j: f64,
energy_per_byte_j: f64,
dtype_bytes: usize,
) -> Self {
Self {
name: name.into(),
peak_flops_per_s,
memory_bandwidth_bytes_per_s,
energy_per_flop_j,
energy_per_byte_j,
dtype_bytes,
}
}
pub fn edge_cpu() -> Self {
Self::new("edge-cpu", 2.5e10, 1.5e10, 1.0e-10, 1.5e-9, 4)
}
pub fn mobile_gpu() -> Self {
Self::new("mobile-gpu", 1.2e12, 6.0e10, 2.0e-11, 6.0e-10, 2)
}
pub fn server_gpu() -> Self {
Self::new("server-gpu", 1.95e13, 1.55e12, 5.0e-12, 1.0e-10, 4)
}
pub fn ridge_point(&self) -> f64 {
self.peak_flops_per_s / self.memory_bandwidth_bytes_per_s
}
pub fn validate(&self) -> Result<()> {
if !self.peak_flops_per_s.is_finite() || self.peak_flops_per_s <= 0.0 {
return Err(OptimError::InvalidParameter(
"peak_flops_per_s must be finite and > 0".to_string(),
));
}
if !self.memory_bandwidth_bytes_per_s.is_finite()
|| self.memory_bandwidth_bytes_per_s <= 0.0
{
return Err(OptimError::InvalidParameter(
"memory_bandwidth_bytes_per_s must be finite and > 0".to_string(),
));
}
if !self.energy_per_flop_j.is_finite() || self.energy_per_flop_j < 0.0 {
return Err(OptimError::InvalidParameter(
"energy_per_flop_j must be finite and >= 0".to_string(),
));
}
if !self.energy_per_byte_j.is_finite() || self.energy_per_byte_j < 0.0 {
return Err(OptimError::InvalidParameter(
"energy_per_byte_j must be finite and >= 0".to_string(),
));
}
if self.dtype_bytes == 0 {
return Err(OptimError::InvalidParameter(
"dtype_bytes must be >= 1".to_string(),
));
}
Ok(())
}
pub fn energy_joules(&self, flops: f64, bytes_moved: f64) -> f64 {
flops * self.energy_per_flop_j + bytes_moved * self.energy_per_byte_j
}
pub fn roofline(&self, layer: &LayerSpec) -> Result<RooflineResult> {
self.validate()?;
let flops = layer.flops()?;
let bytes_moved = layer.bytes_moved(self.dtype_bytes)?;
let arithmetic_intensity = if bytes_moved > 0.0 {
flops / bytes_moved
} else {
0.0
};
let compute_bound_s = flops / self.peak_flops_per_s;
let memory_bound_s = bytes_moved / self.memory_bandwidth_bytes_per_s;
let (latency_s, bottleneck) = if compute_bound_s >= memory_bound_s {
(compute_bound_s, Bottleneck::Compute)
} else {
(memory_bound_s, Bottleneck::Bandwidth)
};
Ok(RooflineResult {
flops,
bytes_moved,
arithmetic_intensity,
compute_bound_s,
memory_bound_s,
latency_s,
bottleneck,
})
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct RooflineResult {
pub flops: f64,
pub bytes_moved: f64,
pub arithmetic_intensity: f64,
pub compute_bound_s: f64,
pub memory_bound_s: f64,
pub latency_s: f64,
pub bottleneck: Bottleneck,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct LayerSignature {
pub spec: LayerSpec,
pub dtype_bytes: usize,
}
impl LayerSignature {
pub fn new(spec: LayerSpec, dtype_bytes: usize) -> Self {
Self { spec, dtype_bytes }
}
pub fn of(layer: &LayerSpec, dtype_bytes: usize) -> Self {
Self {
spec: *layer,
dtype_bytes,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum LatencySource {
TableExact,
TableInterpolated,
Roofline,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct LatencyPrediction {
pub latency_s: f64,
pub source: LatencySource,
}
#[derive(Debug, Clone, Default)]
pub struct LatencyLookupTable {
entries: HashMap<LayerSignature, f64>,
}
impl LatencyLookupTable {
pub fn new() -> Self {
Self::default()
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn insert(&mut self, signature: LayerSignature, latency_s: f64) {
self.entries.insert(signature, latency_s);
}
pub fn record(&mut self, layer: &LayerSpec, dtype_bytes: usize, latency_s: f64) -> Result<()> {
if !latency_s.is_finite() || latency_s < 0.0 {
return Err(OptimError::InvalidParameter(
"measured latency must be finite and >= 0".to_string(),
));
}
if dtype_bytes == 0 {
return Err(OptimError::InvalidParameter(
"dtype_bytes must be >= 1".to_string(),
));
}
layer.validate()?;
self.entries
.insert(LayerSignature::of(layer, dtype_bytes), latency_s);
Ok(())
}
pub fn predict(
&self,
layer: &LayerSpec,
profile: &HardwareProfile,
) -> Result<LatencyPrediction> {
profile.validate()?;
let query_flops = layer.flops()?;
let dtype_bytes = profile.dtype_bytes;
let signature = LayerSignature::of(layer, dtype_bytes);
if let Some(&latency_s) = self.entries.get(&signature) {
return Ok(LatencyPrediction {
latency_s,
source: LatencySource::TableExact,
});
}
let kind = layer.kind();
let mut lower: Option<(f64, f64)> = None;
let mut upper: Option<(f64, f64)> = None;
for (entry_sig, &entry_latency) in &self.entries {
if entry_sig.dtype_bytes != dtype_bytes || entry_sig.spec.kind() != kind {
continue;
}
let entry_flops = match entry_sig.spec.flops() {
Ok(value) => value,
Err(_) => continue,
};
if entry_flops <= query_flops {
let replace = match lower {
None => true,
Some((flops, _)) => entry_flops > flops,
};
if replace {
lower = Some((entry_flops, entry_latency));
}
}
if entry_flops >= query_flops {
let replace = match upper {
None => true,
Some((flops, _)) => entry_flops < flops,
};
if replace {
upper = Some((entry_flops, entry_latency));
}
}
}
let prediction = match (lower, upper) {
(Some((lo_flops, lo_lat)), Some((hi_flops, hi_lat))) => {
let span = hi_flops - lo_flops;
let latency_s = if span.abs() < f64::EPSILON {
lo_lat
} else {
let t = (query_flops - lo_flops) / span;
lo_lat + t * (hi_lat - lo_lat)
};
LatencyPrediction {
latency_s,
source: LatencySource::TableInterpolated,
}
}
(Some((lo_flops, lo_lat)), None) => {
let latency_s = if lo_flops > 0.0 {
lo_lat * (query_flops / lo_flops)
} else {
lo_lat
};
LatencyPrediction {
latency_s,
source: LatencySource::TableInterpolated,
}
}
(None, Some((hi_flops, hi_lat))) => {
let latency_s = if hi_flops > 0.0 {
hi_lat * (query_flops / hi_flops)
} else {
hi_lat
};
LatencyPrediction {
latency_s,
source: LatencySource::TableInterpolated,
}
}
(None, None) => {
let roofline = profile.roofline(layer)?;
LatencyPrediction {
latency_s: roofline.latency_s,
source: LatencySource::Roofline,
}
}
};
Ok(prediction)
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct PerLayerCost {
pub layer_index: usize,
pub kind: LayerKind,
pub flops: f64,
pub parameters: f64,
pub activation_bytes: f64,
pub bytes_moved: f64,
pub arithmetic_intensity: f64,
pub compute_bound_s: f64,
pub memory_bound_s: f64,
pub latency_s: f64,
pub latency_source: LatencySource,
pub energy_j: f64,
pub bottleneck: Bottleneck,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CostReport {
pub total_flops: f64,
pub total_params: f64,
pub total_activation_bytes: f64,
pub latency_s: f64,
pub energy_j: f64,
pub per_layer: Vec<PerLayerCost>,
pub bottleneck: Bottleneck,
}
#[derive(Debug, Clone, Default)]
pub struct HardwareCostModel {
lookup: LatencyLookupTable,
}
impl HardwareCostModel {
pub fn new() -> Self {
Self::default()
}
pub fn with_lookup(lookup: LatencyLookupTable) -> Self {
Self { lookup }
}
pub fn lookup(&self) -> &LatencyLookupTable {
&self.lookup
}
pub fn lookup_mut(&mut self) -> &mut LatencyLookupTable {
&mut self.lookup
}
pub fn estimate(&self, layers: &[LayerSpec], profile: &HardwareProfile) -> Result<CostReport> {
profile.validate()?;
let mut per_layer = Vec::with_capacity(layers.len());
let mut total_flops = 0.0;
let mut total_params = 0.0;
let mut total_activation_bytes = 0.0;
let mut latency_s = 0.0;
let mut energy_j = 0.0;
let mut compute_latency = 0.0;
let mut bandwidth_latency = 0.0;
for (layer_index, layer) in layers.iter().enumerate() {
let roofline = profile.roofline(layer)?;
let parameters = layer.parameter_count();
let activation_bytes = layer.activation_bytes(profile.dtype_bytes)?;
let energy = profile.energy_joules(roofline.flops, roofline.bytes_moved);
let prediction = self.lookup.predict(layer, profile)?;
let cost = PerLayerCost {
layer_index,
kind: layer.kind(),
flops: roofline.flops,
parameters,
activation_bytes,
bytes_moved: roofline.bytes_moved,
arithmetic_intensity: roofline.arithmetic_intensity,
compute_bound_s: roofline.compute_bound_s,
memory_bound_s: roofline.memory_bound_s,
latency_s: prediction.latency_s,
latency_source: prediction.source,
energy_j: energy,
bottleneck: roofline.bottleneck,
};
total_flops += cost.flops;
total_params += cost.parameters;
total_activation_bytes += cost.activation_bytes;
latency_s += cost.latency_s;
energy_j += cost.energy_j;
match cost.bottleneck {
Bottleneck::Compute => compute_latency += cost.latency_s,
Bottleneck::Bandwidth => bandwidth_latency += cost.latency_s,
}
per_layer.push(cost);
}
let bottleneck = if compute_latency >= bandwidth_latency {
Bottleneck::Compute
} else {
Bottleneck::Bandwidth
};
Ok(CostReport {
total_flops,
total_params,
total_activation_bytes,
latency_s,
energy_j,
per_layer,
bottleneck,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
const EPS: f64 = 1e-6;
fn small_conv() -> LayerSpec {
LayerSpec::Conv2d {
in_channels: 3,
out_channels: 8,
kernel: (3, 3),
stride: (1, 1),
padding: (0, 0),
input_hw: (8, 8),
}
}
#[test]
fn test_conv2d_flops_hand_verified() {
let conv = small_conv();
let flops = conv.flops().expect("conv flops");
assert!((flops - 15552.0).abs() < EPS, "got {flops}");
}
#[test]
fn test_conv2d_params_and_activation_bytes() {
let conv = small_conv();
let params = conv.parameter_count();
assert!((params - 224.0).abs() < EPS, "params {params}");
let bytes = conv.activation_bytes(4).expect("conv activation bytes");
assert!((bytes - 1152.0).abs() < EPS, "bytes {bytes}");
}
#[test]
fn test_linear_flops_hand_verified() {
let linear = LayerSpec::Linear {
in_features: 128,
out_features: 64,
};
let flops = linear.flops().expect("linear flops");
assert!((flops - 16384.0).abs() < EPS, "flops {flops}");
let params = linear.parameter_count();
assert!((params - 8256.0).abs() < EPS, "params {params}");
let bytes = linear.activation_bytes(4).expect("linear activation bytes");
assert!((bytes - 256.0).abs() < EPS, "bytes {bytes}");
}
#[test]
fn test_attention_flops_and_params_hand_verified() {
let attention = LayerSpec::Attention {
seq_len: 2,
embed_dim: 4,
num_heads: 2,
};
let flops = attention.flops().expect("attention flops");
assert!((flops - 360.0).abs() < EPS, "flops {flops}");
let params = attention.parameter_count();
assert!((params - 80.0).abs() < EPS, "params {params}");
}
#[test]
fn test_conv_output_dim_with_stride_and_padding() {
let conv = LayerSpec::Conv2d {
in_channels: 16,
out_channels: 16,
kernel: (3, 3),
stride: (2, 2),
padding: (1, 1),
input_hw: (32, 32),
};
let elements = conv.output_elements().expect("output elements");
assert!((elements - 4096.0).abs() < EPS, "elements {elements}");
}
#[test]
fn test_roofline_compute_bound_for_high_intensity_layer() {
let conv = LayerSpec::Conv2d {
in_channels: 64,
out_channels: 64,
kernel: (3, 3),
stride: (1, 1),
padding: (0, 0),
input_hw: (56, 56),
};
let profile = HardwareProfile::server_gpu();
let roofline = profile.roofline(&conv).expect("roofline");
assert!(
roofline.arithmetic_intensity > profile.ridge_point(),
"intensity {} should exceed ridge {}",
roofline.arithmetic_intensity,
profile.ridge_point()
);
assert_eq!(roofline.bottleneck, Bottleneck::Compute);
}
#[test]
fn test_roofline_bandwidth_bound_for_low_intensity_layer() {
let relu = LayerSpec::Activation {
kind: ActivationKind::Relu,
num_elements: 1_000_000,
};
let profile = HardwareProfile::server_gpu();
let roofline = profile.roofline(&relu).expect("roofline");
assert!(
roofline.arithmetic_intensity < profile.ridge_point(),
"intensity {} should be below ridge {}",
roofline.arithmetic_intensity,
profile.ridge_point()
);
assert_eq!(roofline.bottleneck, Bottleneck::Bandwidth);
}
#[test]
fn test_energy_is_monotonic_in_flops() {
let profile = HardwareProfile::server_gpu();
let small = LayerSpec::Activation {
kind: ActivationKind::Relu,
num_elements: 1_000_000,
};
let large = LayerSpec::Activation {
kind: ActivationKind::Relu,
num_elements: 2_000_000,
};
let energy_small = {
let r = profile.roofline(&small).expect("roofline small");
profile.energy_joules(r.flops, r.bytes_moved)
};
let energy_large = {
let r = profile.roofline(&large).expect("roofline large");
profile.energy_joules(r.flops, r.bytes_moved)
};
assert!(
energy_large > energy_small,
"energy should grow with FLOPs: {energy_small} vs {energy_large}"
);
}
#[test]
fn test_lookup_table_exact_hit() {
let profile = HardwareProfile::server_gpu();
let conv = small_conv();
let mut table = LatencyLookupTable::new();
table
.record(&conv, profile.dtype_bytes, 0.005)
.expect("record");
let prediction = table.predict(&conv, &profile).expect("predict");
assert_eq!(prediction.source, LatencySource::TableExact);
assert!((prediction.latency_s - 0.005).abs() < 1e-12);
}
#[test]
fn test_lookup_table_interpolation() {
let profile = HardwareProfile::server_gpu();
let make_conv = |channels: usize| LayerSpec::Conv2d {
in_channels: channels,
out_channels: channels,
kernel: (3, 3),
stride: (1, 1),
padding: (0, 0),
input_hw: (16, 16),
};
let low = make_conv(8);
let mid = make_conv(16);
let high = make_conv(32);
let mut table = LatencyLookupTable::new();
table.record(&low, profile.dtype_bytes, 0.001).expect("low");
table
.record(&high, profile.dtype_bytes, 0.010)
.expect("high");
let prediction = table.predict(&mid, &profile).expect("predict mid");
assert_eq!(prediction.source, LatencySource::TableInterpolated);
assert!(
(prediction.latency_s - 0.0028).abs() < 1e-9,
"interpolated latency {}",
prediction.latency_s
);
}
#[test]
fn test_lookup_table_roofline_fallback() {
let profile = HardwareProfile::server_gpu();
let conv = small_conv();
let linear = LayerSpec::Linear {
in_features: 512,
out_features: 512,
};
let mut table = LatencyLookupTable::new();
table
.record(&conv, profile.dtype_bytes, 0.005)
.expect("record");
let prediction = table.predict(&linear, &profile).expect("predict");
assert_eq!(prediction.source, LatencySource::Roofline);
let roofline = profile.roofline(&linear).expect("roofline");
assert!((prediction.latency_s - roofline.latency_s).abs() < 1e-15);
}
#[test]
fn test_estimate_full_model_aggregates() {
let profile = HardwareProfile::server_gpu();
let model = HardwareCostModel::new();
let layers = vec![
LayerSpec::Conv2d {
in_channels: 3,
out_channels: 32,
kernel: (3, 3),
stride: (1, 1),
padding: (1, 1),
input_hw: (32, 32),
},
LayerSpec::BatchNorm {
num_features: 32,
spatial_hw: (32, 32),
},
LayerSpec::Activation {
kind: ActivationKind::Relu,
num_elements: 32 * 32 * 32,
},
LayerSpec::Pooling {
kind: PoolKind::Max,
channels: 32,
kernel: (2, 2),
stride: (2, 2),
padding: (0, 0),
input_hw: (32, 32),
},
LayerSpec::Linear {
in_features: 32 * 16 * 16,
out_features: 10,
},
];
let report = model.estimate(&layers, &profile).expect("estimate");
assert_eq!(report.per_layer.len(), 5);
let summed_flops: f64 = report.per_layer.iter().map(|c| c.flops).sum();
let summed_params: f64 = report.per_layer.iter().map(|c| c.parameters).sum();
let summed_latency: f64 = report.per_layer.iter().map(|c| c.latency_s).sum();
assert!((report.total_flops - summed_flops).abs() < 1e-3);
assert!((report.total_params - summed_params).abs() < 1e-3);
assert!((report.latency_s - summed_latency).abs() < 1e-15);
assert!(report.total_flops > 0.0);
assert!(report.total_params > 0.0);
assert!(report.total_activation_bytes > 0.0);
assert!(report.energy_j > 0.0);
assert!(report
.per_layer
.iter()
.all(|c| c.latency_source == LatencySource::Roofline));
}
#[test]
fn test_invalid_layer_geometry_rejected() {
let conv = LayerSpec::Conv2d {
in_channels: 8,
out_channels: 8,
kernel: (9, 9),
stride: (1, 1),
padding: (0, 0),
input_hw: (4, 4),
};
assert!(matches!(conv.flops(), Err(OptimError::InvalidParameter(_))));
let profile = HardwareProfile::server_gpu();
let model = HardwareCostModel::new();
assert!(matches!(
model.estimate(&[conv], &profile),
Err(OptimError::InvalidParameter(_))
));
}
#[test]
fn test_attention_requires_divisible_heads() {
let attention = LayerSpec::Attention {
seq_len: 8,
embed_dim: 10,
num_heads: 3,
};
assert!(matches!(
attention.validate(),
Err(OptimError::InvalidParameter(_))
));
}
#[test]
fn test_invalid_profile_rejected() {
let profile = HardwareProfile::new("broken", 0.0, 1.0e9, 1.0e-12, 1.0e-12, 4);
assert!(matches!(
profile.validate(),
Err(OptimError::InvalidParameter(_))
));
let model = HardwareCostModel::new();
let linear = LayerSpec::Linear {
in_features: 8,
out_features: 8,
};
assert!(matches!(
model.estimate(&[linear], &profile),
Err(OptimError::InvalidParameter(_))
));
}
#[test]
fn test_presets_are_valid_and_have_positive_ridge() {
for profile in [
HardwareProfile::edge_cpu(),
HardwareProfile::mobile_gpu(),
HardwareProfile::server_gpu(),
] {
assert!(profile.validate().is_ok(), "{} invalid", profile.name);
assert!(profile.ridge_point() > 0.0);
}
}
#[test]
fn test_signature_hash_distinguishes_precision() {
use std::collections::HashSet;
let conv = small_conv();
let mut set: HashSet<LayerSignature> = HashSet::new();
set.insert(LayerSignature::of(&conv, 4));
set.insert(LayerSignature::of(&conv, 4));
set.insert(LayerSignature::of(&conv, 2));
assert_eq!(set.len(), 2);
}
#[test]
fn test_layer_spec_serde_roundtrip() {
let conv = small_conv();
let json = serde_json::to_string(&conv).expect("serialize");
let restored: LayerSpec = serde_json::from_str(&json).expect("deserialize");
assert_eq!(conv, restored);
}
}