Skip to content

Commit e2ae6b4

Browse files
avrabeclaude
andauthored
feat(rta): Interrupt_Overhead folds into ISR WCET (v0.9.x Tier A #5 cont.) (#209)
Folds Spar_Timing::Interrupt_Overhead into each ISR's effective WCET as 1x per firing — vector dispatch, ISR-context save/restore, EOI ack — mirroring the 2x Context_Switch_Time inflation tasks pay per dispatch (REQ-RTA-008 / PR #198). The ISR pays this kernel-resident interrupt cost once per firing, not twice; default unset = 0 keeps RTA output byte-identical to v0.9.2. Closes the Tier A #5 reviewer follow-up. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2bde211 commit e2ae6b4

4 files changed

Lines changed: 328 additions & 3 deletions

File tree

artifacts/requirements.yaml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1833,4 +1833,18 @@ artifacts:
18331833
status: planned
18341834
tags: [trace-topology, track-g, v0100, crate]
18351835

1836+
- id: REQ-RTA-010
1837+
type: requirement
1838+
title: Interrupt_Overhead folds into ISR WCET (Tier A #5 cont.)
1839+
description: >
1840+
System shall fold the `Spar_Timing::Interrupt_Overhead` property
1841+
into each ISR's effective WCET as 1× per firing — vector dispatch,
1842+
ISR-context save/restore, EOI ack — when computing RTA. Mirrors the
1843+
`Context_Switch_Time` 2× inflation for tasks (REQ-RTA-009 / PR
1844+
#198) but applied 1× because the ISR pays this cost once per
1845+
firing, not twice. Closes the Tier A #5 reviewer follow-up after
1846+
v0.9.2's task-side context-switch fix.
1847+
status: implemented
1848+
tags: [rta, timing, isr, v093, tier-a-5]
1849+
18361850
# Research findings tracked separately in research/findings.yaml

artifacts/verification.yaml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2388,3 +2388,25 @@ artifacts:
23882388
target: REQ-TRACE-TOPOLOGY-001
23892389
- type: satisfies
23902390
target: REQ-TRACE-TOPOLOGY-002
2391+
2392+
- id: TEST-RTA-INTERRUPT-OVERHEAD
2393+
type: feature
2394+
title: Interrupt_Overhead inflates ISR exec_wcet by exactly 1x
2395+
description: >
2396+
Tests in crates/spar-analysis/src/rta.rs verify that
2397+
Spar_Timing::Interrupt_Overhead set on an ISR thread inflates
2398+
its effective exec_wcet by exactly that value (not 2x, since
2399+
the firing pays the cost once). Companion tests verify byte-
2400+
identical RTA output when the property is absent and correct
2401+
composition with Context_Switch_Time on the ISR handler.
2402+
fields:
2403+
method: automated-test
2404+
steps:
2405+
- run: cargo test -p spar-analysis rta::tests::interrupt_overhead_inflates_isr_wcet
2406+
- run: cargo test -p spar-analysis rta::tests::no_interrupt_overhead_byte_identical_to_pre_c1
2407+
- run: cargo test -p spar-analysis rta::tests::interrupt_overhead_combined_with_context_switch_for_handler
2408+
status: passing
2409+
tags: [rta, isr, v093]
2410+
links:
2411+
- type: satisfies
2412+
target: REQ-RTA-010

crates/spar-analysis/src/property_accessors.rs

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,39 @@ pub fn get_interrupt_latency_bound(props: &PropertyMap) -> Option<u64> {
427427
parse_time_value(raw)
428428
}
429429

430+
/// Get `Spar_Timing::Interrupt_Overhead` in picoseconds.
431+
///
432+
/// Per-firing fixed cost the kernel/hardware pays each time an ISR
433+
/// fires — vector dispatch, ISR-context save/restore, EOI ack. Used
434+
/// by RTA to inflate an ISR's effective WCET by exactly 1× this
435+
/// value per firing (contrast with `Context_Switch_Time` which is
436+
/// applied 2× per task dispatch — preempt + resume).
437+
///
438+
/// Looks up in `Spar_Timing` first, then falls back to legacy
439+
/// `SPAR_Properties` and unqualified for compatibility with the
440+
/// v0.8.x advisory pattern in scheduling.rs.
441+
pub fn get_interrupt_overhead(props: &PropertyMap) -> Option<u64> {
442+
// Typed path: Spar_Timing
443+
if let Some(expr) = get_typed_qualified(props, SPAR_TIMING, "Interrupt_Overhead")
444+
&& let Some(ps) = extract_time_ps(expr)
445+
{
446+
return Some(ps);
447+
}
448+
// Typed path: legacy SPAR_Properties
449+
if let Some(expr) = get_typed_qualified(props, "SPAR_Properties", "Interrupt_Overhead")
450+
&& let Some(ps) = extract_time_ps(expr)
451+
{
452+
return Some(ps);
453+
}
454+
455+
// String fallback
456+
let raw = props
457+
.get(SPAR_TIMING, "Interrupt_Overhead")
458+
.or_else(|| props.get("SPAR_Properties", "Interrupt_Overhead"))
459+
.or_else(|| props.get("", "Interrupt_Overhead"))?;
460+
parse_time_value(raw)
461+
}
462+
430463
/// Get `Spar_Timing::Bottom_Half_Server` reference name.
431464
pub fn get_bottom_half_server(props: &PropertyMap) -> Option<String> {
432465
// Typed path
@@ -1363,4 +1396,40 @@ mod tests {
13631396
let props = PropertyMap::new();
13641397
assert_eq!(get_critical_section_blocking(&props), None);
13651398
}
1399+
1400+
// ── Interrupt_Overhead (v0.9.3 Tier A #5 cont.) ──────────────────
1401+
#[test]
1402+
fn interrupt_overhead_parses_us_spar_timing() {
1403+
let props = make_props(&[("Spar_Timing", "Interrupt_Overhead", "50 us")]);
1404+
assert_eq!(get_interrupt_overhead(&props), Some(50_000_000));
1405+
}
1406+
1407+
#[test]
1408+
fn interrupt_overhead_parses_us_legacy_spar_properties() {
1409+
let props = make_props(&[("SPAR_Properties", "Interrupt_Overhead", "10 us")]);
1410+
assert_eq!(get_interrupt_overhead(&props), Some(10_000_000));
1411+
}
1412+
1413+
#[test]
1414+
fn interrupt_overhead_parses_bare() {
1415+
let props = make_props(&[("", "Interrupt_Overhead", "25 us")]);
1416+
assert_eq!(get_interrupt_overhead(&props), Some(25_000_000));
1417+
}
1418+
1419+
#[test]
1420+
fn interrupt_overhead_typed() {
1421+
let props = make_typed_props(
1422+
"Spar_Timing",
1423+
"Interrupt_Overhead",
1424+
"50 us",
1425+
PropertyExpr::Integer(50, Some(Name::new("us"))),
1426+
);
1427+
assert_eq!(get_interrupt_overhead(&props), Some(50_000_000));
1428+
}
1429+
1430+
#[test]
1431+
fn interrupt_overhead_missing_returns_none() {
1432+
let props = PropertyMap::new();
1433+
assert_eq!(get_interrupt_overhead(&props), None);
1434+
}
13661435
}

crates/spar-analysis/src/rta.rs

Lines changed: 223 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,8 @@ use spar_hir_def::item_tree::ComponentCategory;
5454
use crate::property_accessors::{
5555
LockingProtocol, get_bottom_half_server, get_critical_section_blocking, get_dispatch_jitter,
5656
get_dispatch_protocol, get_execution_time, get_execution_time_range,
57-
get_interrupt_latency_bound, get_isr_execution_time_range, get_isr_priority,
58-
get_locking_protocol, get_processor_binding, get_timing_property,
57+
get_interrupt_latency_bound, get_interrupt_overhead, get_isr_execution_time_range,
58+
get_isr_priority, get_locking_protocol, get_processor_binding, get_timing_property,
5959
};
6060
use crate::scheduling_verified::{self, RtaResult};
6161
use crate::{Analysis, AnalysisDiagnostic, Severity, component_path};
@@ -130,10 +130,39 @@ impl Analysis for RtaAnalysis {
130130

131131
// ISR execution: prefer ISR_Execution_Time, else
132132
// Compute_Execution_Time. We take the WCET.
133-
let (isr_bcet, isr_wcet) = get_isr_execution_time_range(props)
133+
let (isr_bcet_base, isr_wcet_base) = get_isr_execution_time_range(props)
134134
.or_else(|| get_execution_time_range(props))
135135
.unwrap_or((0, 0));
136136

137+
// v0.9.3 (Tier A #5 cont.): Interrupt_Overhead inflates
138+
// ISR WCET by 1× per firing — vector dispatch, ISR-
139+
// context save/restore, EOI ack — companion to the
140+
// 2× Context_Switch_Time inflation tasks pay per
141+
// dispatch (REQ-RTA-009 / PR #198). The ISR pays this
142+
// cost once per firing, not twice. Default unset = 0
143+
// (byte-identical to v0.9.2). The cost is folded into
144+
// both the per-CPU utilization term and the IRQ-chain
145+
// budget so the kernel-resident interrupt path is
146+
// accounted for everywhere the ISR's WCET is used.
147+
let interrupt_overhead_ps = get_interrupt_overhead(props).unwrap_or(0);
148+
let isr_wcet = isr_wcet_base.saturating_add(interrupt_overhead_ps);
149+
let isr_bcet = isr_bcet_base.saturating_add(interrupt_overhead_ps);
150+
if interrupt_overhead_ps > 0 && isr_wcet_base > 0 {
151+
diags.push(AnalysisDiagnostic {
152+
severity: Severity::Info,
153+
message: format!(
154+
"IsrOverheadInflation: ISR '{}' WCET {} → {} (added 1 × {} \
155+
Interrupt_Overhead per firing)",
156+
comp.name.as_str(),
157+
format_time(isr_wcet_base),
158+
format_time(isr_wcet),
159+
format_time(interrupt_overhead_ps),
160+
),
161+
path: component_path(instance, idx),
162+
analysis: self.name().to_string(),
163+
});
164+
}
165+
137166
// Only admit ISRs that have enough info to contribute
138167
// a utilization term. Otherwise they just exist and
139168
// may still enable chain diagnostics below.
@@ -1577,6 +1606,197 @@ mod tests {
15771606
);
15781607
}
15791608

1609+
// ── O2 (v0.9.3 Tier A #5 cont.): Interrupt_Overhead inflates ISR WCET ─
1610+
#[test]
1611+
fn interrupt_overhead_inflates_isr_wcet() {
1612+
// With Interrupt_Overhead = 50 us, the ISR's effective WCET is
1613+
// inflated by exactly 50 us per firing (1×, not 2× — contrast
1614+
// with Context_Switch_Time which is applied 2× per task
1615+
// dispatch). Base ISR_Execution_Time = 100 us → effective
1616+
// WCET = 150 us. Diagnostic must mirror OverheadInflation
1617+
// style.
1618+
let (mut b, root, proc) = make_base();
1619+
let dev = add_isr_device(&mut b, root, "irq_src", "2 ms", "100 us", 99);
1620+
b.set_property(dev, "Spar_Timing", "Interrupt_Overhead", "50 us");
1621+
let t1 = b.add_component("t1", ComponentCategory::Thread, Some(proc));
1622+
b.set_children(
1623+
root,
1624+
vec![
1625+
ComponentInstanceIdx::from_raw(la_arena::RawIdx::from_u32(1)),
1626+
proc,
1627+
dev,
1628+
],
1629+
);
1630+
b.set_children(proc, vec![t1]);
1631+
bind_thread(&mut b, t1, "10 ms", "1 ms", Some("10 ms"));
1632+
1633+
let inst = b.build(root);
1634+
let diags = RtaAnalysis.analyze(&inst);
1635+
1636+
let inflation = diags
1637+
.iter()
1638+
.find(|d| {
1639+
d.severity == Severity::Info
1640+
&& d.message.starts_with("IsrOverheadInflation")
1641+
&& d.message.contains("'irq_src'")
1642+
})
1643+
.unwrap_or_else(|| {
1644+
panic!(
1645+
"expected IsrOverheadInflation for irq_src, got: {:#?}",
1646+
diags
1647+
)
1648+
});
1649+
assert!(
1650+
inflation.message.contains("WCET 100.00 us → 150.00 us"),
1651+
"expected WCET 100us → 150us after 1×50us Interrupt_Overhead, got: {}",
1652+
inflation.message,
1653+
);
1654+
assert!(
1655+
inflation.message.contains("1 × 50.00 us"),
1656+
"diagnostic should make the 1× factor explicit, got: {}",
1657+
inflation.message,
1658+
);
1659+
}
1660+
1661+
#[test]
1662+
fn no_interrupt_overhead_byte_identical_to_pre_c1() {
1663+
// Interrupt_Overhead unset = no IsrOverheadInflation diagnostic
1664+
// and pre-v0.9.3 byte-identical RTA output (no inflation in the
1665+
// ISR's exec_wcet term).
1666+
let (mut b, root, proc) = make_base();
1667+
let dev = add_isr_device(&mut b, root, "irq_src", "2 ms", "100 us", 99);
1668+
let _ = dev;
1669+
let t1 = b.add_component("t1", ComponentCategory::Thread, Some(proc));
1670+
b.set_children(
1671+
root,
1672+
vec![
1673+
ComponentInstanceIdx::from_raw(la_arena::RawIdx::from_u32(1)),
1674+
proc,
1675+
dev,
1676+
],
1677+
);
1678+
b.set_children(proc, vec![t1]);
1679+
bind_thread(&mut b, t1, "10 ms", "8 ms", Some("10 ms"));
1680+
1681+
let inst = b.build(root);
1682+
let diags = RtaAnalysis.analyze(&inst);
1683+
1684+
assert!(
1685+
!diags
1686+
.iter()
1687+
.any(|d| d.message.starts_with("IsrOverheadInflation")),
1688+
"no Interrupt_Overhead must not emit IsrOverheadInflation: {:#?}",
1689+
diags,
1690+
);
1691+
1692+
// Same response time as the equivalent test in
1693+
// single_isr_reduces_task_capacity (8.40 ms or 8.50 ms,
1694+
// converged value).
1695+
let info = diags
1696+
.iter()
1697+
.find(|d| d.severity == Severity::Info && d.message.contains("thread 't1'"))
1698+
.expect("thread info present");
1699+
assert!(
1700+
info.message.contains("8.40 ms") || info.message.contains("8.50 ms"),
1701+
"absent Interrupt_Overhead must yield baseline RTA: {}",
1702+
info.message,
1703+
);
1704+
}
1705+
1706+
#[test]
1707+
fn interrupt_overhead_combined_with_context_switch_for_handler() {
1708+
// A Sporadic ISR (device) with Interrupt_Overhead set inflates
1709+
// the ISR exec_wcet by exactly 1× per firing, while the
1710+
// separately-modeled handler thread (Bottom_Half_Server) has
1711+
// Context_Switch_Time set and gets the 2× task-side inflation.
1712+
// Both inflations must apply correctly and independently.
1713+
let (mut b, root, proc) = make_base();
1714+
let dev = b.add_component("irq_dev", ComponentCategory::Device, Some(root));
1715+
b.set_property(dev, "Timing_Properties", "Period", "2 ms");
1716+
b.set_property(dev, "Spar_Timing", "ISR_Execution_Time", "100 us");
1717+
b.set_property(dev, "Spar_Timing", "ISR_Priority", "99");
1718+
b.set_property(dev, "Spar_Timing", "Interrupt_Overhead", "50 us");
1719+
b.set_property(
1720+
dev,
1721+
"Deployment_Properties",
1722+
"Actual_Processor_Binding",
1723+
"reference (cpu1)",
1724+
);
1725+
b.set_property(
1726+
dev,
1727+
"Spar_Timing",
1728+
"Bottom_Half_Server",
1729+
"reference (handler)",
1730+
);
1731+
1732+
let handler = b.add_component("handler", ComponentCategory::Thread, Some(proc));
1733+
b.set_children(
1734+
root,
1735+
vec![
1736+
ComponentInstanceIdx::from_raw(la_arena::RawIdx::from_u32(1)),
1737+
proc,
1738+
dev,
1739+
],
1740+
);
1741+
b.set_children(proc, vec![handler]);
1742+
bind_thread(&mut b, handler, "10 ms", "1 ms", Some("10 ms"));
1743+
b.set_property(
1744+
handler,
1745+
"Thread_Properties",
1746+
"Dispatch_Protocol",
1747+
"Sporadic",
1748+
);
1749+
b.set_property(
1750+
handler,
1751+
"Timing_Properties",
1752+
"Context_Switch_Time",
1753+
"100 us",
1754+
);
1755+
1756+
let inst = b.build(root);
1757+
let diags = RtaAnalysis.analyze(&inst);
1758+
1759+
// 1× Interrupt_Overhead applied to ISR.
1760+
let isr_inflation = diags
1761+
.iter()
1762+
.find(|d| {
1763+
d.severity == Severity::Info
1764+
&& d.message.starts_with("IsrOverheadInflation")
1765+
&& d.message.contains("'irq_dev'")
1766+
})
1767+
.unwrap_or_else(|| {
1768+
panic!(
1769+
"expected IsrOverheadInflation for irq_dev, got: {:#?}",
1770+
diags
1771+
)
1772+
});
1773+
assert!(
1774+
isr_inflation.message.contains("WCET 100.00 us → 150.00 us"),
1775+
"ISR side: 1×50us Interrupt_Overhead → 100us+50us = 150us, got: {}",
1776+
isr_inflation.message,
1777+
);
1778+
1779+
// 2× Context_Switch_Time applied to handler thread.
1780+
let task_inflation = diags
1781+
.iter()
1782+
.find(|d| {
1783+
d.severity == Severity::Info
1784+
&& d.message.starts_with("OverheadInflation")
1785+
&& d.message.contains("'handler'")
1786+
})
1787+
.unwrap_or_else(|| {
1788+
panic!(
1789+
"expected OverheadInflation for handler thread, got: {:#?}",
1790+
diags
1791+
)
1792+
});
1793+
assert!(
1794+
task_inflation.message.contains("WCET 1.00 ms → 1.20 ms"),
1795+
"task side: 2×100us Context_Switch_Time → 1ms+200us = 1.20ms, got: {}",
1796+
task_inflation.message,
1797+
);
1798+
}
1799+
15801800
// ── B1: non-regression — no Locking_Protocol must match v0.7.0 byte-for-byte ─
15811801
#[test]
15821802
fn no_locking_matches_v070() {

0 commit comments

Comments
 (0)