Spaces:
Running
Running
Commit ·
e5c4ee4
1
Parent(s): ce2d64b
Deepen InferScale simulation research workflow
Browse files- README.md +29 -1
- app.js +154 -3
- docs/architecture.md +21 -2
- docs/methodology.md +16 -0
- docs/research.md +24 -1
- docs/validation.md +7 -1
- index.html +103 -1
- py/inferscale/__init__.py +5 -1
- py/inferscale/agentic.py +622 -0
- py/inferscale/api.py +8 -0
- pyproject.toml +1 -1
- scripts/release_check.py +30 -3
- src/inferscale/__init__.py +5 -1
- src/inferscale/agentic.py +622 -0
- src/inferscale/api.py +8 -0
- styles.css +10 -0
- tests/test_agentic.py +58 -0
- worker.mjs +1 -0
README.md
CHANGED
|
@@ -36,6 +36,7 @@ The research direction has continued rapidly. Recent work includes GPU-free serv
|
|
| 36 |
- log-normal prompt/output-length distributions
|
| 37 |
- exact **CSV/JSON trace replay** using supplied arrival times and token lengths
|
| 38 |
- shared seeds for controlled A/B experiments
|
|
|
|
| 39 |
|
| 40 |
Generated load is open-loop: arrivals are scheduled independently of response completion so queueing delay remains visible under overload. Trace replay accepts:
|
| 41 |
|
|
@@ -70,6 +71,7 @@ arrival_time,prompt_tokens,output_tokens
|
|
| 70 |
- prefix-cache hit rate / saved prefill work
|
| 71 |
- P/D transfer latency / transfer volume / role utilization
|
| 72 |
- heuristic bottleneck diagnoses with explicit simulator provenance
|
|
|
|
| 73 |
|
| 74 |
## Interactive experiments
|
| 75 |
|
|
@@ -103,6 +105,18 @@ Run a bounded live sweep across scheduler, batch size, prefix caching, and P/D w
|
|
| 103 |
- **performance:** maximize goodput while minimizing p95 TTFT
|
| 104 |
- **efficiency:** maximize goodput per accelerator while minimizing p95 TTFT
|
| 105 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
### Research Studies
|
| 107 |
|
| 108 |
This is the statistical/research layer rather than another configuration dashboard.
|
|
@@ -152,7 +166,7 @@ This makes profile uncertainty visible rather than allowing one uncalibrated pro
|
|
| 152 |
| | | |
|
| 153 |
| +-----------------------+------------------+ |
|
| 154 |
| v |
|
| 155 |
-
|
|
| 156 |
+------------------------------------------------------------------+
|
| 157 |
```
|
| 158 |
|
|
@@ -194,6 +208,20 @@ The link is intentionally simple and explicit. It does not claim to reproduce NC
|
|
| 194 |
|
| 195 |
The research stress test can multiplicatively perturb prefill/decode/transfer timing to expose conclusions that are sensitive to analytical-model error.
|
| 196 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
## Empirical validation hook
|
| 198 |
|
| 199 |
The public project does **not** ship invented "measured" GPU results. Instead, the repository includes an explicit validation path for future real measurements:
|
|
|
|
| 36 |
- log-normal prompt/output-length distributions
|
| 37 |
- exact **CSV/JSON trace replay** using supplied arrival times and token lengths
|
| 38 |
- shared seeds for controlled A/B experiments
|
| 39 |
+
- stateful multi-turn agent sessions with ordered turn dependencies and explicit tool-call gaps
|
| 40 |
|
| 41 |
Generated load is open-loop: arrivals are scheduled independently of response completion so queueing delay remains visible under overload. Trace replay accepts:
|
| 42 |
|
|
|
|
| 71 |
- prefix-cache hit rate / saved prefill work
|
| 72 |
- P/D transfer latency / transfer volume / role utilization
|
| 73 |
- heuristic bottleneck diagnoses with explicit simulator provenance
|
| 74 |
+
- cross-turn KV hit rate, recomputed history tokens, routing locality, HBM GB-seconds, and TTL/pressure evictions for stateful sessions
|
| 75 |
|
| 76 |
## Interactive experiments
|
| 77 |
|
|
|
|
| 105 |
- **performance:** maximize goodput while minimizing p95 TTFT
|
| 106 |
- **efficiency:** maximize goodput per accelerator while minimizing p95 TTFT
|
| 107 |
|
| 108 |
+
### Agent Sessions
|
| 109 |
+
|
| 110 |
+
Model stateful multi-turn programs rather than isolated requests. Each program has ordered LLM turns separated by tool-call gaps. The simulator tracks cross-turn KV residency on multiple replicas, routing locality, TTL expiry, pressure eviction, recomputation after cache loss, and memory-time residency.
|
| 111 |
+
|
| 112 |
+
Three live experiments are included:
|
| 113 |
+
|
| 114 |
+
1. **Session run:** inspect one retention/routing policy on a generated program trace.
|
| 115 |
+
2. **Session Policy Arena:** replay the exact same program trace through stateless least-load, retained least-load, TTL+affinity, and retained+affinity policies.
|
| 116 |
+
3. **TTL frontier:** sweep KV retention time under common random numbers and expose the non-dominated trade-off between p95 turn TTFT and mean resident KV.
|
| 117 |
+
|
| 118 |
+
Agent mode intentionally uses a serial service station per replica. The existing Serving Lab models batching; Agent Sessions isolates program dependencies, cache locality, tool gaps, and state-management decisions so those effects are interpretable rather than conflated.
|
| 119 |
+
|
| 120 |
### Research Studies
|
| 121 |
|
| 122 |
This is the statistical/research layer rather than another configuration dashboard.
|
|
|
|
| 166 |
| | | |
|
| 167 |
| +-----------------------+------------------+ |
|
| 168 |
| v |
|
| 169 |
+
| metrics / search / research / agent sessions |
|
| 170 |
+------------------------------------------------------------------+
|
| 171 |
```
|
| 172 |
|
|
|
|
| 208 |
|
| 209 |
The research stress test can multiplicatively perturb prefill/decode/transfer timing to expose conclusions that are sensitive to analytical-model error.
|
| 210 |
|
| 211 |
+
## Stateful agent-session model
|
| 212 |
+
|
| 213 |
+
Agent-session mode follows a program-level event trace:
|
| 214 |
+
|
| 215 |
+
```text
|
| 216 |
+
session arrival -> turn 1 -> tool gap -> turn 2 -> ... -> completion
|
| 217 |
+
```
|
| 218 |
+
|
| 219 |
+
When a turn becomes ready, it is routed either to the least-loaded replica or to a replica already holding that session's KV state. A cache hit prefills only newly appended tokens; a miss recomputes the accumulated history. KV can be evicted immediately, retained until the session ends, or retained under a TTL. Per-replica capacity uses the same analytical KV-byte model as the request simulator and falls back to LRU-style pressure eviction when the retained working set exceeds the configured budget.
|
| 220 |
+
|
| 221 |
+
The public app reports **HBM GB-seconds** in addition to peak/mean KV occupancy. This makes the cost of keeping state resident during tool gaps visible rather than treating cache hits as free.
|
| 222 |
+
|
| 223 |
+
This module is inspired by recent work on agentic serving, including AGENTSERVESIM's program/tool-gap/session-routing abstraction and CacheTTL's observation that retention across variable tool gaps is a latency-vs-memory decision. It is intentionally smaller and does not claim their measured-system fidelity.
|
| 224 |
+
|
| 225 |
## Empirical validation hook
|
| 226 |
|
| 227 |
The public project does **not** ship invented "measured" GPU results. Instead, the repository includes an explicit validation path for future real measurements:
|
app.js
CHANGED
|
@@ -10,6 +10,9 @@ let lastTopologyRows = [];
|
|
| 10 |
let lastDesignRows = [];
|
| 11 |
let lastPairedStudy = null;
|
| 12 |
let lastRobustStudy = null;
|
|
|
|
|
|
|
|
|
|
| 13 |
let traceRequests = [];
|
| 14 |
|
| 15 |
const COLORS = {
|
|
@@ -32,7 +35,7 @@ worker.addEventListener("message", (event) => {
|
|
| 32 |
if (data.type === "ready") {
|
| 33 |
runtimePill.classList.add("ready");
|
| 34 |
runtimeText.textContent = "Python runtime ready";
|
| 35 |
-
["runBtn", "arenaBtn", "capacityBtn", "topologyBtn", "designBtn", "pairedStudyBtn", "robustStudyBtn"].forEach((id) => { $(id).disabled = false; });
|
| 36 |
syncConditionalControls();
|
| 37 |
window.setTimeout(() => document.body.classList.add("runtime-ready"), 650);
|
| 38 |
return;
|
|
@@ -215,8 +218,12 @@ function destroyChart(name) {
|
|
| 215 |
}
|
| 216 |
|
| 217 |
function chartFileName(card) {
|
| 218 |
-
const cfg = configFromUI();
|
| 219 |
const chartName = card.dataset.chartName || "figure";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
const topology = cfg.topology === "disaggregated_pd"
|
| 221 |
? `pd-${cfg.prefill_workers}p-${cfg.decode_workers}d-${cfg.prefill_accelerator}-${cfg.decode_accelerator}`
|
| 222 |
: `colocated-${cfg.accelerator}`;
|
|
@@ -734,13 +741,157 @@ $("robustStudyBtn").addEventListener("click", async () => {
|
|
| 734 |
alert(`Robustness study failed: ${error.message}`);
|
| 735 |
} finally {
|
| 736 |
button.disabled = false;
|
| 737 |
-
button.textContent = "Stress-test
|
| 738 |
}
|
| 739 |
});
|
| 740 |
$("robustCopyBtn").addEventListener("click", () => { if (lastRobustStudy) copyText(tableText(robustHeaders, robustTableRows(lastRobustStudy)), "Robustness table copied"); });
|
| 741 |
$("robustCsvBtn").addEventListener("click", () => { if (lastRobustStudy) downloadCsv(`inferscale_robustness_${slug(lastRobustStudy.study)}_${stamp()}.csv`, robustHeaders, robustTableRows(lastRobustStudy)); });
|
| 742 |
$("robustJsonBtn").addEventListener("click", () => { if (lastRobustStudy) downloadNamedJson(`inferscale_robustness_${slug(lastRobustStudy.study)}`, lastRobustStudy); });
|
| 743 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 744 |
function normalizeTraceRows(rows) {
|
| 745 |
if (!Array.isArray(rows)) throw new Error("Trace JSON must be an array or contain a requests array");
|
| 746 |
if (rows.length > 10000) throw new Error("Trace replay is limited to 10,000 requests in the browser");
|
|
|
|
| 10 |
let lastDesignRows = [];
|
| 11 |
let lastPairedStudy = null;
|
| 12 |
let lastRobustStudy = null;
|
| 13 |
+
let lastAgentRun = null;
|
| 14 |
+
let lastAgentCompare = null;
|
| 15 |
+
let lastAgentTtl = null;
|
| 16 |
let traceRequests = [];
|
| 17 |
|
| 18 |
const COLORS = {
|
|
|
|
| 35 |
if (data.type === "ready") {
|
| 36 |
runtimePill.classList.add("ready");
|
| 37 |
runtimeText.textContent = "Python runtime ready";
|
| 38 |
+
["runBtn", "arenaBtn", "capacityBtn", "topologyBtn", "designBtn", "pairedStudyBtn", "robustStudyBtn", "agentRunBtn", "agentCompareBtn", "agentTtlBtn"].forEach((id) => { $(id).disabled = false; });
|
| 39 |
syncConditionalControls();
|
| 40 |
window.setTimeout(() => document.body.classList.add("runtime-ready"), 650);
|
| 41 |
return;
|
|
|
|
| 218 |
}
|
| 219 |
|
| 220 |
function chartFileName(card) {
|
|
|
|
| 221 |
const chartName = card.dataset.chartName || "figure";
|
| 222 |
+
if (chartName.startsWith("agent-")) {
|
| 223 |
+
const cfg = agentConfigFromUI();
|
| 224 |
+
return `inferscale_${slug(chartName)}_${slug(cfg.model)}_${slug(cfg.accelerator)}_${slug(cfg.retention_policy)}_${slug(cfg.routing_policy)}_${stamp()}.png`;
|
| 225 |
+
}
|
| 226 |
+
const cfg = configFromUI();
|
| 227 |
const topology = cfg.topology === "disaggregated_pd"
|
| 228 |
? `pd-${cfg.prefill_workers}p-${cfg.decode_workers}d-${cfg.prefill_accelerator}-${cfg.decode_accelerator}`
|
| 229 |
: `colocated-${cfg.accelerator}`;
|
|
|
|
| 741 |
alert(`Robustness study failed: ${error.message}`);
|
| 742 |
} finally {
|
| 743 |
button.disabled = false;
|
| 744 |
+
button.textContent = "Stress-test selected hypothesis";
|
| 745 |
}
|
| 746 |
});
|
| 747 |
$("robustCopyBtn").addEventListener("click", () => { if (lastRobustStudy) copyText(tableText(robustHeaders, robustTableRows(lastRobustStudy)), "Robustness table copied"); });
|
| 748 |
$("robustCsvBtn").addEventListener("click", () => { if (lastRobustStudy) downloadCsv(`inferscale_robustness_${slug(lastRobustStudy.study)}_${stamp()}.csv`, robustHeaders, robustTableRows(lastRobustStudy)); });
|
| 749 |
$("robustJsonBtn").addEventListener("click", () => { if (lastRobustStudy) downloadNamedJson(`inferscale_robustness_${slug(lastRobustStudy.study)}`, lastRobustStudy); });
|
| 750 |
|
| 751 |
+
|
| 752 |
+
function agentConfigFromUI(overrides = {}) {
|
| 753 |
+
return {
|
| 754 |
+
model: $("agentModel").value,
|
| 755 |
+
accelerator: $("agentAccelerator").value,
|
| 756 |
+
quantization: $("agentQuantization").value,
|
| 757 |
+
replicas: num("agentReplicas"),
|
| 758 |
+
session_rate_rps: num("agentRate"),
|
| 759 |
+
duration_s: num("agentDuration"),
|
| 760 |
+
turns_mean: num("agentTurns"),
|
| 761 |
+
turns_cv: num("agentTurnsCv"),
|
| 762 |
+
initial_prompt_tokens_mean: num("agentInitialPrompt"),
|
| 763 |
+
append_tokens_mean: num("agentAppend"),
|
| 764 |
+
output_tokens_mean: num("agentOutput"),
|
| 765 |
+
token_cv: num("agentTokenCv"),
|
| 766 |
+
output_tokens_cv: num("agentTokenCv"),
|
| 767 |
+
tool_gap_mean_s: num("agentToolGap"),
|
| 768 |
+
tool_gap_cv: num("agentToolGapCv"),
|
| 769 |
+
seed: num("agentSeed"),
|
| 770 |
+
retention_policy: $("agentRetention").value,
|
| 771 |
+
routing_policy: $("agentRouting").value,
|
| 772 |
+
kv_ttl_s: num("agentTtl"),
|
| 773 |
+
kv_memory_fraction: num("agentKvFraction"),
|
| 774 |
+
slo_turn_ttft_ms: num("agentTtftSlo"),
|
| 775 |
+
slo_session_e2e_ms: num("agentSessionSlo"),
|
| 776 |
+
...overrides,
|
| 777 |
+
};
|
| 778 |
+
}
|
| 779 |
+
|
| 780 |
+
function renderAgentRun(result) {
|
| 781 |
+
lastAgentRun = result;
|
| 782 |
+
$("agentRunEmpty").classList.add("hidden");
|
| 783 |
+
$("agentRunContent").classList.remove("hidden");
|
| 784 |
+
const l = result.latency;
|
| 785 |
+
const r = result.resource;
|
| 786 |
+
const sm = result.summary;
|
| 787 |
+
$("agentTtft").textContent = `${fmt(l.turn_ttft_ms.p95)} ms`;
|
| 788 |
+
$("agentSessionE2e").textContent = `${fmt(l.session_e2e_ms.p95)} ms`;
|
| 789 |
+
$("agentCacheHit").textContent = pct(r.cross_turn_cache_hit_rate);
|
| 790 |
+
$("agentRecompute").textContent = `${fmt(r.recomputed_history_tokens, 0)} tok`;
|
| 791 |
+
$("agentPeakKv").textContent = `${fmt(r.peak_kv_gb, 3)} GB`;
|
| 792 |
+
$("agentMeanKv").textContent = `${fmt(r.mean_kv_gb, 3)} GB`;
|
| 793 |
+
const state = $("agentRunState");
|
| 794 |
+
state.textContent = `${sm.sessions_completed}/${sm.sessions_generated} sessions`;
|
| 795 |
+
state.className = `tag ${sm.sessions_completed === sm.sessions_generated && sm.turns_failed === 0 ? "good" : "bad"}`;
|
| 796 |
+
$("agentRunSummary").innerHTML = `<strong>${escapeHtml(result.config.retention_policy)} retention + ${escapeHtml(result.config.routing_policy.replaceAll("_", " "))} routing.</strong> ${sm.turns_completed} turns completed across ${sm.sessions_completed} sessions. Cross-turn reuse avoided ${fmt(r.recomputed_history_tokens, 0)} history tokens of recomputation only when state was resident on the selected replica; the reported HBM residency is a simulator-side memory-time accounting metric.`;
|
| 797 |
+
|
| 798 |
+
const timeline = result.timeline || [];
|
| 799 |
+
destroyChart("agentTimeline");
|
| 800 |
+
charts.agentTimeline = new Chart($("agentTimelineChart"), {
|
| 801 |
+
type: "line",
|
| 802 |
+
data: {
|
| 803 |
+
datasets: [
|
| 804 |
+
{ label: "KV GB", data: timeline.map((x) => ({ x: x.time_s, y: x.kv_used_gb })), borderColor: COLORS.green, backgroundColor: COLORS.green, pointRadius: 0, tension: .08, yAxisID: "yKv" },
|
| 805 |
+
{ label: "Busy replicas", data: timeline.map((x) => ({ x: x.time_s, y: x.busy_replicas })), borderColor: COLORS.blue, backgroundColor: COLORS.blue, pointRadius: 0, tension: .08, yAxisID: "yCount" },
|
| 806 |
+
{ label: "Queued turns", data: timeline.map((x) => ({ x: x.time_s, y: x.queued_turns })), borderColor: COLORS.amber, backgroundColor: COLORS.amber, pointRadius: 0, tension: .08, yAxisID: "yCount" },
|
| 807 |
+
],
|
| 808 |
+
},
|
| 809 |
+
options: { ...commonChartOptions(), parsing: false, plugins: { legend: lineLegend() }, scales: { x: { type: "linear", title: { display: true, text: "Virtual time (s)" } }, yCount: { position: "left", beginAtZero: true, title: { display: true, text: "Replicas / queued turns" } }, yKv: { position: "right", beginAtZero: true, grid: { drawOnChartArea: false }, title: { display: true, text: "KV GB" } } } },
|
| 810 |
+
});
|
| 811 |
+
|
| 812 |
+
destroyChart("agentTurn");
|
| 813 |
+
charts.agentTurn = new Chart($("agentTurnChart"), {
|
| 814 |
+
type: "scatter",
|
| 815 |
+
data: { datasets: [{ label: "Turn", data: (result.turns || []).map((x) => ({ x: x.turn_index, y: x.ttft_ms })), backgroundColor: COLORS.blue, pointRadius: 3, pointHoverRadius: 5 }] },
|
| 816 |
+
options: { ...commonChartOptions(), plugins: { legend: { display: false } }, scales: { x: { type: "linear", ticks: { precision: 0 }, title: { display: true, text: "Turn index" } }, y: { beginAtZero: true, title: { display: true, text: "TTFT (ms)" } } } },
|
| 817 |
+
});
|
| 818 |
+
}
|
| 819 |
+
|
| 820 |
+
$("agentRunBtn").addEventListener("click", async () => {
|
| 821 |
+
const button = $("agentRunBtn");
|
| 822 |
+
button.disabled = true;
|
| 823 |
+
button.textContent = "Running stateful simulation...";
|
| 824 |
+
$("agentRunState").textContent = "Running...";
|
| 825 |
+
$("agentRunState").className = "tag neutral";
|
| 826 |
+
try { renderAgentRun(await callPython("agent_simulate", { config: agentConfigFromUI() })); }
|
| 827 |
+
catch (error) { $("agentRunState").textContent = "Error"; $("agentRunState").className = "tag bad"; alert(`Agent session simulation failed: ${error.message}`); }
|
| 828 |
+
finally { button.disabled = false; button.textContent = "Run stateful session simulation"; }
|
| 829 |
+
});
|
| 830 |
+
|
| 831 |
+
const agentCompareHeaders = ["Policy", "p95 turn TTFT", "p95 session E2E", "Cache hit", "Route locality", "Recomputed history", "Mean KV", "Peak KV", "HBM GB-s", "Evictions"];
|
| 832 |
+
function agentCompareTableRows(result) {
|
| 833 |
+
return result.rows.map((r) => [r.label, `${fmt(r.p95_turn_ttft_ms)} ms`, `${fmt(r.p95_session_e2e_ms)} ms`, pct(r.cache_hit_rate), pct(r.routing_locality_rate), `${fmt(r.recomputed_history_tokens, 0)} tok`, `${fmt(r.mean_kv_gb, 3)} GB`, `${fmt(r.peak_kv_gb, 3)} GB`, fmt(r.hbm_gb_seconds, 3), r.pressure_evictions + r.ttl_evictions]);
|
| 834 |
+
}
|
| 835 |
+
function renderAgentCompare(result) {
|
| 836 |
+
lastAgentCompare = result;
|
| 837 |
+
$("agentCompareEmpty").classList.add("hidden");
|
| 838 |
+
$("agentCompareContent").classList.remove("hidden");
|
| 839 |
+
["agentCompareCopy", "agentCompareCsv"].forEach((id) => { $(id).disabled = false; });
|
| 840 |
+
$("agentCompareRows").innerHTML = result.rows.map((r) => `<tr><td>${escapeHtml(r.label)}</td><td>${fmt(r.p95_turn_ttft_ms)} ms</td><td>${fmt(r.p95_session_e2e_ms)} ms</td><td>${pct(r.cache_hit_rate)}</td><td>${pct(r.routing_locality_rate)}</td><td>${fmt(r.recomputed_history_tokens, 0)} tok</td><td>${fmt(r.mean_kv_gb, 3)} GB</td><td>${fmt(r.peak_kv_gb, 3)} GB</td><td>${fmt(r.hbm_gb_seconds, 3)}</td><td>${r.pressure_evictions + r.ttl_evictions}</td></tr>`).join("");
|
| 841 |
+
destroyChart("agentCompare");
|
| 842 |
+
charts.agentCompare = new Chart($("agentCompareChart"), {
|
| 843 |
+
type: "scatter",
|
| 844 |
+
data: { datasets: result.rows.map((r, i) => ({ label: r.label, data: [{ x: r.mean_kv_gb, y: r.p95_turn_ttft_ms }], backgroundColor: [COLORS.gray, COLORS.amber, COLORS.green, COLORS.blue][i], borderColor: [COLORS.gray, COLORS.amber, COLORS.green, COLORS.blue][i], pointRadius: 7, pointHoverRadius: 9 })) },
|
| 845 |
+
options: { ...commonChartOptions(), plugins: { legend: pointLegend() }, scales: { x: { beginAtZero: true, title: { display: true, text: "Mean resident KV (GB)" } }, y: { beginAtZero: true, title: { display: true, text: "p95 turn TTFT (ms)" } } } },
|
| 846 |
+
});
|
| 847 |
+
}
|
| 848 |
+
$("agentCompareBtn").addEventListener("click", async () => {
|
| 849 |
+
const button = $("agentCompareBtn"); button.disabled = true; button.textContent = "Comparing...";
|
| 850 |
+
try { renderAgentCompare(await callPython("agent_compare", { config: agentConfigFromUI() })); }
|
| 851 |
+
catch (error) { alert(`Agent policy comparison failed: ${error.message}`); }
|
| 852 |
+
finally { button.disabled = false; button.textContent = "Compare 4 policies"; }
|
| 853 |
+
});
|
| 854 |
+
$("agentCompareCopy").addEventListener("click", () => { if (lastAgentCompare) copyText(tableText(agentCompareHeaders, agentCompareTableRows(lastAgentCompare)), "Agent policy table copied"); });
|
| 855 |
+
$("agentCompareCsv").addEventListener("click", () => { if (lastAgentCompare) downloadCsv(`inferscale_agent-policy-comparison_${stamp()}.csv`, agentCompareHeaders, agentCompareTableRows(lastAgentCompare)); });
|
| 856 |
+
|
| 857 |
+
const agentTtlHeaders = ["TTL", "Pareto", "p95 turn TTFT", "p95 session E2E", "Cache hit", "Recomputed history", "Mean KV", "HBM GB-s", "TTL evictions", "Pressure evictions"];
|
| 858 |
+
function agentTtlTableRows(result) {
|
| 859 |
+
return result.rows.map((r) => [`${fmt(r.ttl_s, 2)} s`, r.pareto ? "YES" : "NO", `${fmt(r.p95_turn_ttft_ms)} ms`, `${fmt(r.p95_session_e2e_ms)} ms`, pct(r.cache_hit_rate), `${fmt(r.recomputed_history_tokens, 0)} tok`, `${fmt(r.mean_kv_gb, 3)} GB`, fmt(r.hbm_gb_seconds, 3), r.ttl_evictions, r.pressure_evictions]);
|
| 860 |
+
}
|
| 861 |
+
function renderAgentTtl(result) {
|
| 862 |
+
lastAgentTtl = result;
|
| 863 |
+
$("agentTtlEmpty").classList.add("hidden");
|
| 864 |
+
$("agentTtlContent").classList.remove("hidden");
|
| 865 |
+
["agentTtlCopy", "agentTtlCsv"].forEach((id) => { $(id).disabled = false; });
|
| 866 |
+
$("agentTtlRows").innerHTML = result.rows.map((r) => `<tr><td>${fmt(r.ttl_s, 2)} s</td><td class="${r.pareto ? "pass" : ""}">${r.pareto ? "YES" : "NO"}</td><td>${fmt(r.p95_turn_ttft_ms)} ms</td><td>${fmt(r.p95_session_e2e_ms)} ms</td><td>${pct(r.cache_hit_rate)}</td><td>${fmt(r.recomputed_history_tokens, 0)} tok</td><td>${fmt(r.mean_kv_gb, 3)} GB</td><td>${fmt(r.hbm_gb_seconds, 3)}</td><td>${r.ttl_evictions}</td><td>${r.pressure_evictions}</td></tr>`).join("");
|
| 867 |
+
const pareto = result.rows.filter((r) => r.pareto).sort((a, b) => a.mean_kv_gb - b.mean_kv_gb);
|
| 868 |
+
destroyChart("agentTtl");
|
| 869 |
+
charts.agentTtl = new Chart($("agentTtlChart"), {
|
| 870 |
+
type: "scatter",
|
| 871 |
+
data: { datasets: [
|
| 872 |
+
{ label: "TTL candidates", data: result.rows.map((r) => ({ x: r.mean_kv_gb, y: r.p95_turn_ttft_ms, ttl: r.ttl_s })), backgroundColor: COLORS.blue, pointRadius: 5, pointHoverRadius: 7 },
|
| 873 |
+
{ type: "line", label: "Retention frontier", data: pareto.map((r) => ({ x: r.mean_kv_gb, y: r.p95_turn_ttft_ms })), borderColor: COLORS.amber, backgroundColor: COLORS.amber, pointRadius: 5, tension: 0, fill: false },
|
| 874 |
+
] },
|
| 875 |
+
options: { ...commonChartOptions(), plugins: { legend: lineLegend(), tooltip: { callbacks: { afterLabel: (ctx) => ctx.datasetIndex === 0 ? `TTL ${fmt(ctx.raw.ttl, 2)} s` : "" } } }, scales: { x: { beginAtZero: true, title: { display: true, text: "Mean resident KV (GB)" } }, y: { beginAtZero: true, title: { display: true, text: "p95 turn TTFT (ms)" } } } },
|
| 876 |
+
});
|
| 877 |
+
}
|
| 878 |
+
$("agentTtlBtn").addEventListener("click", async () => {
|
| 879 |
+
const button = $("agentTtlBtn"); button.disabled = true; button.textContent = "Sweeping TTL...";
|
| 880 |
+
try { renderAgentTtl(await callPython("agent_ttl_sweep", { config: agentConfigFromUI() })); }
|
| 881 |
+
catch (error) { alert(`TTL sweep failed: ${error.message}`); }
|
| 882 |
+
finally { button.disabled = false; button.textContent = "Run TTL sweep"; }
|
| 883 |
+
});
|
| 884 |
+
$("agentTtlCopy").addEventListener("click", () => { if (lastAgentTtl) copyText(tableText(agentTtlHeaders, agentTtlTableRows(lastAgentTtl)), "TTL sweep copied"); });
|
| 885 |
+
$("agentTtlCsv").addEventListener("click", () => { if (lastAgentTtl) downloadCsv(`inferscale_agent-ttl-frontier_${stamp()}.csv`, agentTtlHeaders, agentTtlTableRows(lastAgentTtl)); });
|
| 886 |
+
|
| 887 |
+
function syncAgentControls() {
|
| 888 |
+
const ttl = $("agentRetention").value === "ttl";
|
| 889 |
+
$("agentTtl").disabled = !ttl;
|
| 890 |
+
$("agentTtlLabel").classList.toggle("field-disabled", !ttl);
|
| 891 |
+
}
|
| 892 |
+
$("agentRetention").addEventListener("change", syncAgentControls);
|
| 893 |
+
syncAgentControls();
|
| 894 |
+
|
| 895 |
function normalizeTraceRows(rows) {
|
| 896 |
if (!Array.isArray(rows)) throw new Error("Trace JSON must be an array or contain a requests array");
|
| 897 |
if (rows.length > 10000) throw new Error("Trace replay is limited to 10,000 requests in the browser");
|
docs/architecture.md
CHANGED
|
@@ -13,8 +13,9 @@ InferScale-Sim separates **serving-system logic**, **analytical latency estimati
|
|
| 13 |
7. `diagnostics.py` converts simulated telemetry into explicit heuristic bottleneck labels.
|
| 14 |
8. `optimizer.py` implements capacity search, scheduler comparison, topology/cache comparison, and Pareto sweeps.
|
| 15 |
9. `research.py` implements paired common-seed A/B studies, bootstrap intervals, and analytical-model sensitivity analysis.
|
| 16 |
-
10. `
|
| 17 |
-
11. `
|
|
|
|
| 18 |
|
| 19 |
## Colocated path
|
| 20 |
|
|
@@ -36,6 +37,24 @@ arrival
|
|
| 36 |
|
| 37 |
The P/D path is a genuine discrete-event loop: prefill workers, decode workers, and the transfer link can overlap in virtual time.
|
| 38 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
## Research path
|
| 40 |
|
| 41 |
```text
|
|
|
|
| 13 |
7. `diagnostics.py` converts simulated telemetry into explicit heuristic bottleneck labels.
|
| 14 |
8. `optimizer.py` implements capacity search, scheduler comparison, topology/cache comparison, and Pareto sweeps.
|
| 15 |
9. `research.py` implements paired common-seed A/B studies, bootstrap intervals, and analytical-model sensitivity analysis.
|
| 16 |
+
10. `agentic.py` models multi-turn programs, tool gaps, session routing, KV retention/TTL eviction, and retention frontiers.
|
| 17 |
+
11. `validation.py` compares predictions against externally supplied measured cases.
|
| 18 |
+
12. `api.py` exposes JSON-like actions to local Python and Pyodide.
|
| 19 |
|
| 20 |
## Colocated path
|
| 21 |
|
|
|
|
| 37 |
|
| 38 |
The P/D path is a genuine discrete-event loop: prefill workers, decode workers, and the transfer link can overlap in virtual time.
|
| 39 |
|
| 40 |
+
|
| 41 |
+
## Stateful agent-session path
|
| 42 |
+
|
| 43 |
+
```text
|
| 44 |
+
session arrival
|
| 45 |
+
-> turn ready
|
| 46 |
+
-> route to replica
|
| 47 |
+
-> [KV hit: append prefill | KV miss: full-history prefill]
|
| 48 |
+
-> decode
|
| 49 |
+
-> retain / TTL / evict KV
|
| 50 |
+
-> tool gap
|
| 51 |
+
-> next turn ready
|
| 52 |
+
-> ...
|
| 53 |
+
-> session complete
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
Each replica is intentionally a serial service station in this mode. This isolates state residency, routing locality, and tool-gap effects from the dynamic-batching questions already covered by the request-level simulators.
|
| 57 |
+
|
| 58 |
## Research path
|
| 59 |
|
| 60 |
```text
|
docs/methodology.md
CHANGED
|
@@ -129,6 +129,22 @@ The robustness study draws shared multiplicative prefill/decode/transfer scales
|
|
| 129 |
|
| 130 |
The perturbation distribution is deliberately described as a **sensitivity analysis**, not a calibrated posterior over real hardware. Its purpose is to identify conclusions that reverse under modest model error.
|
| 131 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
## Empirical validation
|
| 133 |
|
| 134 |
`validation.py` accepts externally measured cases and compares them against simulator predictions. Supported observations include p95 TTFT, p95 E2E, goodput, request throughput, and SLO attainment. The report contains per-case residuals, MAPE, median APE, and maximum APE.
|
|
|
|
| 129 |
|
| 130 |
The perturbation distribution is deliberately described as a **sensitivity analysis**, not a calibrated posterior over real hardware. Its purpose is to identify conclusions that reverse under modest model error.
|
| 131 |
|
| 132 |
+
|
| 133 |
+
## Stateful agent sessions
|
| 134 |
+
|
| 135 |
+
Agent mode is a separate program-level discrete-event model. Session arrivals are open-loop. Each session receives a deterministic number of turns, token increments, outputs, and tool gaps from a seeded trace. A later turn cannot become ready until the prior turn completes and its tool gap elapses.
|
| 136 |
+
|
| 137 |
+
Each simulated replica is a serial analytical service station in this mode. This is deliberate: dynamic batching remains covered by Serving Lab, while Agent Sessions isolates four stateful effects:
|
| 138 |
+
|
| 139 |
+
1. **cross-turn reuse:** a resident KV entry means the next turn prefills only appended tokens;
|
| 140 |
+
2. **tool-gap residency:** retained KV occupies memory while the program waits outside the model;
|
| 141 |
+
3. **routing locality:** session-affinity can preserve reuse but may trade against load balance;
|
| 142 |
+
4. **eviction:** TTL expiry and LRU-style memory-pressure eviction can force full-history recomputation.
|
| 143 |
+
|
| 144 |
+
The cache working set is tracked independently on each replica. The simulator integrates occupancy over virtual time and reports `HBM GB-seconds`, peak KV, mean KV, cache-hit rate, recomputed history tokens, routing-locality rate, and eviction counts.
|
| 145 |
+
|
| 146 |
+
The TTL sweep replays one identical agent-program trace for every TTL and reports the non-dominated frontier minimizing both p95 turn TTFT and mean KV residency. It is a controlled what-if study, not an optimizer over a measured production system.
|
| 147 |
+
|
| 148 |
## Empirical validation
|
| 149 |
|
| 150 |
`validation.py` accepts externally measured cases and compares them against simulator predictions. Supported observations include p95 TTFT, p95 E2E, goodput, request throughput, and SLO attainment. The report contains per-case residuals, MAPE, median APE, and maximum APE.
|
docs/research.md
CHANGED
|
@@ -57,7 +57,7 @@ Source: https://arxiv.org/abs/2605.21312
|
|
| 57 |
|
| 58 |
AgentServeSim extends serving simulation to multi-turn programs with tool-induced gaps, session-aware routing, cache locality, and KV residency. It reports reproducing real-system behavior within 6% across its evaluated metrics while executing on CPUs.
|
| 59 |
|
| 60 |
-
InferScale
|
| 61 |
|
| 62 |
Source: https://arxiv.org/abs/2606.09613
|
| 63 |
|
|
@@ -83,3 +83,26 @@ Source: https://arxiv.org/abs/2608.03741
|
|
| 83 |
## Scope boundary
|
| 84 |
|
| 85 |
InferScale-Sim is not intended to compete with these research systems on fidelity, hardware scale, or runtime compatibility. Its aim is an inspectable Python implementation with a zero-backend interactive interface, explicit uncertainty, controlled experiments, and a path to external empirical validation.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
|
| 58 |
AgentServeSim extends serving simulation to multi-turn programs with tool-induced gaps, session-aware routing, cache locality, and KV residency. It reports reproducing real-system behavior within 6% across its evaluated metrics while executing on CPUs.
|
| 59 |
|
| 60 |
+
InferScale now implements a smaller program-level analogue: session identity, ordered turns, explicit tool gaps, cross-turn KV residency, session-affinity routing, and TTL/pressure eviction. Its per-replica service station is intentionally simpler than AGENTSERVESIM and remains analytically profiled rather than empirically validated.
|
| 61 |
|
| 62 |
Source: https://arxiv.org/abs/2606.09613
|
| 63 |
|
|
|
|
| 83 |
## Scope boundary
|
| 84 |
|
| 85 |
InferScale-Sim is not intended to compete with these research systems on fidelity, hardware scale, or runtime compatibility. Its aim is an inspectable Python implementation with a zero-backend interactive interface, explicit uncertainty, controlled experiments, and a path to external empirical validation.
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
## CacheTTL (2026)
|
| 89 |
+
|
| 90 |
+
CacheTTL studies multi-turn agent scheduling under variable tool-call gaps and treats KV retention as a time-to-live decision: holding state improves future reuse but occupies accelerator memory while the program is waiting on tools.
|
| 91 |
+
|
| 92 |
+
InferScale inspiration: explicit TTL retention, expiry events, memory-time accounting, and a TTL frontier that makes latency versus retained KV visible on a common program trace.
|
| 93 |
+
|
| 94 |
+
Source: https://arxiv.org/abs/2511.02230
|
| 95 |
+
|
| 96 |
+
## AgentSysBench (August 2026)
|
| 97 |
+
|
| 98 |
+
AgentSysBench characterizes agentic applications as long-running, stateful workflows with heterogeneous components, shifting bottlenecks, and idle state that can persist between active steps. The paper reports that non-LLM components can dominate latency in several applications and that state/offloading choices materially affect resource use.
|
| 99 |
+
|
| 100 |
+
InferScale inspiration: model tool gaps and whole-session latency rather than treating every LLM call as an independent request.
|
| 101 |
+
|
| 102 |
+
Source: https://arxiv.org/abs/2608.15127
|
| 103 |
+
|
| 104 |
+
## CacheScout / agent-aware KV management (2026)
|
| 105 |
+
|
| 106 |
+
Recent agent-aware KV work argues that future reuse is governed by program execution semantics rather than recency alone and uses predicted transitions to guide retention/prefetch. InferScale does not implement learned prediction, but its session-affinity and TTL experiments provide an inspectable baseline for studying why agent identity changes cache decisions.
|
| 107 |
+
|
| 108 |
+
Source: https://arxiv.org/abs/2608.14624
|
docs/validation.md
CHANGED
|
@@ -24,6 +24,11 @@ Release checks prevent software, deployment, and provenance mistakes; they do no
|
|
| 24 |
- bootstrap paired-effect intervals
|
| 25 |
- analytical-profile perturbation study
|
| 26 |
- external-measurement validation report generation
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
- ASCII-only public UI labels
|
| 28 |
- chart export controls present
|
| 29 |
- explicit planner worst-repetition and target columns
|
|
@@ -67,6 +72,7 @@ The report provides residuals and aggregate percentage errors. Calibration shoul
|
|
| 67 |
- radix-tree prefix-cache eviction/scheduling
|
| 68 |
- speculative decoding
|
| 69 |
- Attention-FFN disaggregation
|
| 70 |
-
-
|
|
|
|
| 71 |
|
| 72 |
These are explicit scope boundaries, not hidden assumptions.
|
|
|
|
| 24 |
- bootstrap paired-effect intervals
|
| 25 |
- analytical-profile perturbation study
|
| 26 |
- external-measurement validation report generation
|
| 27 |
+
- deterministic stateful agent-session trace generation
|
| 28 |
+
- zero cross-turn hits under immediate eviction
|
| 29 |
+
- KV reuse and reduced recomputation under retention + affinity
|
| 30 |
+
- common-trace four-policy agent comparison
|
| 31 |
+
- TTL latency/memory Pareto frontier
|
| 32 |
- ASCII-only public UI labels
|
| 33 |
- chart export controls present
|
| 34 |
- explicit planner worst-repetition and target columns
|
|
|
|
| 72 |
- radix-tree prefix-cache eviction/scheduling
|
| 73 |
- speculative decoding
|
| 74 |
- Attention-FFN disaggregation
|
| 75 |
+
- production-fidelity agentic serving or dynamic batching inside Agent Sessions
|
| 76 |
+
- learned workflow prediction, proactive prefetch, host/CXL KV tiers, or real tool execution
|
| 77 |
|
| 78 |
These are explicit scope boundaries, not hidden assumptions.
|
index.html
CHANGED
|
@@ -44,6 +44,7 @@
|
|
| 44 |
<button class="tab" data-tab="modern">Modern Serving</button>
|
| 45 |
<button class="tab" data-tab="design">Design Explorer</button>
|
| 46 |
<button class="tab" data-tab="research">Research Studies</button>
|
|
|
|
| 47 |
<button class="tab" data-tab="method">Methodology</button>
|
| 48 |
</nav>
|
| 49 |
|
|
@@ -280,6 +281,7 @@
|
|
| 280 |
<option value="chunked_vs_fcfs">Scheduling: FCFS vs chunked prefill</option>
|
| 281 |
<option value="slo_vs_fcfs">Scheduling: FCFS vs least-slack</option>
|
| 282 |
</select></label>
|
|
|
|
| 283 |
<hr />
|
| 284 |
<div class="section-kicker">Paired Monte Carlo</div>
|
| 285 |
<div class="field-grid two">
|
|
@@ -289,11 +291,12 @@
|
|
| 289 |
<button id="pairedStudyBtn" class="primary" disabled>Run paired study</button>
|
| 290 |
<hr />
|
| 291 |
<div class="section-kicker">Model uncertainty stress test</div>
|
|
|
|
| 292 |
<div class="field-grid two">
|
| 293 |
<label>Samples<input id="robustSamples" type="number" min="4" max="96" value="32" /></label>
|
| 294 |
<label>Latency uncertainty<input id="robustUncertainty" type="number" min="0" max="0.75" step="0.05" value="0.20" /><span class="unit">fraction</span></label>
|
| 295 |
</div>
|
| 296 |
-
<button id="robustStudyBtn" class="secondary research-secondary" disabled>Stress-test
|
| 297 |
</aside>
|
| 298 |
|
| 299 |
<div class="research-results">
|
|
@@ -330,11 +333,110 @@
|
|
| 330 |
</div>
|
| 331 |
</section>
|
| 332 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 333 |
<section id="method" class="tab-panel">
|
| 334 |
<div class="method-grid">
|
| 335 |
<article class="panel prose"><div class="section-kicker">Simulation core</div><h2>What is actually simulated?</h2><p>Requests are generated from deterministic workload distributions and advanced through virtual time. Colocated runs model admission, prefill, paged KV allocation, dynamic decode batches, and completion. P/D runs use separate prefill and decode worker pools plus an explicit serialized KV-transfer link.</p><div class="formula">request -> queue -> prefill -> [KV transfer] -> decode -> completion</div></article>
|
| 336 |
<article class="panel prose"><div class="section-kicker">Prefix reuse</div><h2>Cache without pretending to implement a radix tree</h2><p>The simulator models a single shared prompt prefix with configurable length and reuse fraction. Cache hits avoid redundant prefill work and share one persistent KV allocation. It is deliberately a controlled what-if abstraction, not a claim to reproduce SGLang's full RadixAttention policy.</p></article>
|
| 337 |
<article class="panel prose"><div class="section-kicker">P/D disaggregation</div><h2>Role-specific resources and transfer cost</h2><p>Prefill and decode have separate accelerator profiles and worker counts. Prompt KV state crosses a modeled interconnect before decode admission. The simulator reports role utilization, transfer latency, and transfer pressure so the benefit of isolation can be weighed against data-movement overhead.</p></article>
|
|
|
|
| 338 |
<article class="panel prose"><div class="section-kicker">Design search</div><h2>Pareto, not one magic configuration</h2><p>The Design Explorer reports both a raw-performance frontier and a resource-normalized frontier using goodput per accelerator. That prevents a multi-GPU P/D layout from looking unconditionally better merely because it uses more simulated hardware.</p></article>
|
| 339 |
<article class="panel prose"><div class="section-kicker">Trace methodology</div><h2>Generated load or exact replay</h2><p>Poisson, constant, and bursty workloads are open-loop: arrivals are scheduled independently of response completion, so queueing delay is not hidden by client backpressure. Exact CSV/JSON traces can be replayed with their original arrival times and token lengths.</p></article>
|
| 340 |
<article class="panel prose"><div class="section-kicker">Research protocol</div><h2>Paired conclusions, not one lucky seed</h2><p>Research Studies use common random numbers: baseline and treatment receive identical seeds, reducing workload variance in the paired difference. A bootstrap interval summarizes the repeated effect, while a separate sensitivity study perturbs analytical latency scales to reveal conclusions that depend too strongly on one reference profile.</p></article>
|
|
|
|
| 44 |
<button class="tab" data-tab="modern">Modern Serving</button>
|
| 45 |
<button class="tab" data-tab="design">Design Explorer</button>
|
| 46 |
<button class="tab" data-tab="research">Research Studies</button>
|
| 47 |
+
<button class="tab" data-tab="agent">Agent Sessions</button>
|
| 48 |
<button class="tab" data-tab="method">Methodology</button>
|
| 49 |
</nav>
|
| 50 |
|
|
|
|
| 281 |
<option value="chunked_vs_fcfs">Scheduling: FCFS vs chunked prefill</option>
|
| 282 |
<option value="slo_vs_fcfs">Scheduling: FCFS vs least-slack</option>
|
| 283 |
</select></label>
|
| 284 |
+
<p class="control-help">The selected hypothesis controls both the paired study and the model-uncertainty stress test below.</p>
|
| 285 |
<hr />
|
| 286 |
<div class="section-kicker">Paired Monte Carlo</div>
|
| 287 |
<div class="field-grid two">
|
|
|
|
| 291 |
<button id="pairedStudyBtn" class="primary" disabled>Run paired study</button>
|
| 292 |
<hr />
|
| 293 |
<div class="section-kicker">Model uncertainty stress test</div>
|
| 294 |
+
<p class="control-help">Uses the Hypothesis selected above. For P/D robustness, choose Topology: colocated vs P/D, set the perturbation controls here, then click the button below.</p>
|
| 295 |
<div class="field-grid two">
|
| 296 |
<label>Samples<input id="robustSamples" type="number" min="4" max="96" value="32" /></label>
|
| 297 |
<label>Latency uncertainty<input id="robustUncertainty" type="number" min="0" max="0.75" step="0.05" value="0.20" /><span class="unit">fraction</span></label>
|
| 298 |
</div>
|
| 299 |
+
<button id="robustStudyBtn" class="secondary research-secondary" disabled>Stress-test selected hypothesis</button>
|
| 300 |
</aside>
|
| 301 |
|
| 302 |
<div class="research-results">
|
|
|
|
| 333 |
</div>
|
| 334 |
</section>
|
| 335 |
|
| 336 |
+
|
| 337 |
+
<section id="agent" class="tab-panel">
|
| 338 |
+
<div class="agent-layout">
|
| 339 |
+
<aside class="panel agent-controls">
|
| 340 |
+
<div class="panel-title-row"><div><div class="section-kicker">Stateful workload</div><h2>Agent session protocol</h2></div><span class="tag">Common program trace</span></div>
|
| 341 |
+
<p class="muted">Model multi-turn programs separated by tool calls. Cross-turn KV can be evicted, retained, or held under a TTL, while routing can prioritize load balance or session locality.</p>
|
| 342 |
+
<div class="field-grid two">
|
| 343 |
+
<label>Model<select id="agentModel"><option>Qwen2.5-3B</option><option>Llama-3.1-8B</option><option>Mistral-7B-v0.3</option></select></label>
|
| 344 |
+
<label>Accelerator<select id="agentAccelerator"><option value="L4">NVIDIA L4</option><option value="A10G">NVIDIA A10G</option><option value="A100-40GB">NVIDIA A100 40GB</option></select></label>
|
| 345 |
+
<label>Weight precision<select id="agentQuantization"><option value="fp16">FP16</option><option value="int8" selected>INT8 scenario</option><option value="int4">INT4 scenario</option></select></label>
|
| 346 |
+
<label>Replicas<input id="agentReplicas" type="number" min="1" max="8" value="2" /></label>
|
| 347 |
+
</div>
|
| 348 |
+
<hr />
|
| 349 |
+
<div class="section-kicker">Program arrivals</div>
|
| 350 |
+
<div class="field-grid two">
|
| 351 |
+
<label>Session rate<input id="agentRate" type="number" min="0.01" step="0.05" value="0.20" /><span class="unit">sessions/s</span></label>
|
| 352 |
+
<label>Arrival horizon<input id="agentDuration" type="number" min="5" step="5" value="30" /><span class="unit">s</span></label>
|
| 353 |
+
<label>Mean turns<input id="agentTurns" type="number" min="2" step="0.5" value="4" /></label>
|
| 354 |
+
<label>Turns CV<input id="agentTurnsCv" type="number" min="0" max="1.5" step="0.05" value="0.25" /></label>
|
| 355 |
+
<label>Initial prompt<input id="agentInitialPrompt" type="number" min="32" step="32" value="640" /><span class="unit">tokens</span></label>
|
| 356 |
+
<label>Per-turn append<input id="agentAppend" type="number" min="1" step="16" value="180" /><span class="unit">tokens</span></label>
|
| 357 |
+
<label>Mean output<input id="agentOutput" type="number" min="1" step="8" value="72" /><span class="unit">tokens</span></label>
|
| 358 |
+
<label>Token CV<input id="agentTokenCv" type="number" min="0" max="1.5" step="0.05" value="0.35" /></label>
|
| 359 |
+
<label>Tool gap mean<input id="agentToolGap" type="number" min="0" step="0.25" value="1.5" /><span class="unit">s</span></label>
|
| 360 |
+
<label>Tool gap CV<input id="agentToolGapCv" type="number" min="0" max="2" step="0.05" value="0.75" /></label>
|
| 361 |
+
<label>Seed<input id="agentSeed" type="number" step="1" value="7" /></label>
|
| 362 |
+
</div>
|
| 363 |
+
<hr />
|
| 364 |
+
<div class="section-kicker">State policy</div>
|
| 365 |
+
<div class="field-grid two">
|
| 366 |
+
<label>KV retention<select id="agentRetention"><option value="evict">Evict after every turn</option><option value="ttl" selected>TTL retention</option><option value="retain">Retain until session ends</option></select></label>
|
| 367 |
+
<label>Routing<select id="agentRouting"><option value="least_load">Least-load</option><option value="session_affinity" selected>Session affinity</option></select></label>
|
| 368 |
+
<label id="agentTtlLabel">KV TTL<input id="agentTtl" type="number" min="0" step="0.25" value="3" /><span class="unit">s</span></label>
|
| 369 |
+
<label>KV memory fraction<input id="agentKvFraction" type="number" min="0.05" max="0.98" step="0.05" value="0.85" /><span class="unit">fraction</span></label>
|
| 370 |
+
</div>
|
| 371 |
+
<div class="field-grid two">
|
| 372 |
+
<label>Turn TTFT SLO<input id="agentTtftSlo" type="number" min="1" value="500" /><span class="unit">ms</span></label>
|
| 373 |
+
<label>Session E2E SLO<input id="agentSessionSlo" type="number" min="1000" step="1000" value="30000" /><span class="unit">ms</span></label>
|
| 374 |
+
</div>
|
| 375 |
+
<button id="agentRunBtn" class="primary" disabled>Run stateful session simulation</button>
|
| 376 |
+
</aside>
|
| 377 |
+
|
| 378 |
+
<div class="agent-results">
|
| 379 |
+
<section class="panel research-panel">
|
| 380 |
+
<div class="panel-title-row"><div><div class="section-kicker">Program execution</div><h2>Session run</h2></div><span id="agentRunState" class="tag neutral">Waiting</span></div>
|
| 381 |
+
<div id="agentRunEmpty" class="empty-state small"><h3>No stateful run yet</h3><p>Each session has ordered LLM turns separated by sampled tool gaps. A replica processes one turn at a time in this mode so cache/routing effects remain isolated from the batching experiments in Serving Lab.</p></div>
|
| 382 |
+
<div id="agentRunContent" class="hidden">
|
| 383 |
+
<div class="metric-grid six-agent">
|
| 384 |
+
<div class="metric emphasis"><span>p95 turn TTFT</span><strong id="agentTtft">N/A</strong></div>
|
| 385 |
+
<div class="metric"><span>p95 session E2E</span><strong id="agentSessionE2e">N/A</strong></div>
|
| 386 |
+
<div class="metric"><span>Cross-turn cache hit</span><strong id="agentCacheHit">N/A</strong></div>
|
| 387 |
+
<div class="metric"><span>Recomputed history</span><strong id="agentRecompute">N/A</strong></div>
|
| 388 |
+
<div class="metric"><span>Peak KV</span><strong id="agentPeakKv">N/A</strong></div>
|
| 389 |
+
<div class="metric"><span>Mean resident KV</span><strong id="agentMeanKv">N/A</strong></div>
|
| 390 |
+
</div>
|
| 391 |
+
<div class="study-summary" id="agentRunSummary"></div>
|
| 392 |
+
<div class="chart-grid">
|
| 393 |
+
<div class="chart-card" data-chart-card data-chart-name="agent-session-kv-residency-timeline">
|
| 394 |
+
<div class="chart-head"><div class="chart-title">KV residency and replica activity</div><div class="chart-actions"><button class="chart-download" type="button">Download PNG</button><button class="chart-expand" type="button">Expand</button></div></div>
|
| 395 |
+
<div class="chart-body large"><canvas id="agentTimelineChart"></canvas></div>
|
| 396 |
+
</div>
|
| 397 |
+
<div class="chart-card" data-chart-card data-chart-name="agent-turn-ttft-by-turn-index">
|
| 398 |
+
<div class="chart-head"><div class="chart-title">Turn TTFT across session depth</div><div class="chart-actions"><button class="chart-download" type="button">Download PNG</button><button class="chart-expand" type="button">Expand</button></div></div>
|
| 399 |
+
<div class="chart-body large"><canvas id="agentTurnChart"></canvas></div>
|
| 400 |
+
</div>
|
| 401 |
+
</div>
|
| 402 |
+
</div>
|
| 403 |
+
</section>
|
| 404 |
+
|
| 405 |
+
<section class="panel research-panel">
|
| 406 |
+
<div class="panel-title-row"><div><div class="section-kicker">Controlled policy comparison</div><h2>Session Policy Arena</h2><p class="muted">All candidates replay the identical generated program trace.</p></div><button id="agentCompareBtn" class="primary compact" disabled>Compare 4 policies</button></div>
|
| 407 |
+
<div id="agentCompareEmpty" class="empty-state small"><h3>No policy comparison yet</h3><p>Compare stateless load balancing, retention without affinity, TTL with affinity, and full retention with affinity.</p></div>
|
| 408 |
+
<div id="agentCompareContent" class="hidden">
|
| 409 |
+
<div class="chart-card full" data-chart-card data-chart-name="agent-policy-latency-memory-tradeoff">
|
| 410 |
+
<div class="chart-head"><div class="chart-title">p95 turn TTFT vs mean resident KV</div><div class="chart-actions"><button class="chart-download" type="button">Download PNG</button><button class="chart-expand" type="button">Expand</button></div></div>
|
| 411 |
+
<div class="chart-body research-chart"><canvas id="agentCompareChart"></canvas></div>
|
| 412 |
+
</div>
|
| 413 |
+
<div class="table-toolbar"><span>Common-trace policy results</span><div><button id="agentCompareCopy" class="mini-button" disabled>Copy table</button><button id="agentCompareCsv" class="mini-button" disabled>Download CSV</button></div></div>
|
| 414 |
+
<div class="table-wrap"><table><thead><tr><th>Policy</th><th>p95 turn TTFT</th><th>p95 session E2E</th><th>Cache hit</th><th>Route locality</th><th>Recomputed history</th><th>Mean KV</th><th>Peak KV</th><th>HBM GB-s</th><th>Evictions</th></tr></thead><tbody id="agentCompareRows"></tbody></table></div>
|
| 415 |
+
</div>
|
| 416 |
+
</section>
|
| 417 |
+
|
| 418 |
+
<section class="panel research-panel">
|
| 419 |
+
<div class="panel-title-row"><div><div class="section-kicker">Retention trade-off</div><h2>TTL frontier</h2><p class="muted">Sweep the KV retention horizon on the same program trace and expose the latency vs memory-residency frontier.</p></div><button id="agentTtlBtn" class="primary compact" disabled>Run TTL sweep</button></div>
|
| 420 |
+
<div id="agentTtlEmpty" class="empty-state small"><h3>No TTL sweep yet</h3><p>The sweep varies retention from immediate eviction through long-lived state while keeping session arrivals, turns, tool gaps, and token lengths fixed.</p></div>
|
| 421 |
+
<div id="agentTtlContent" class="hidden">
|
| 422 |
+
<div class="chart-card full" data-chart-card data-chart-name="agent-ttl-retention-frontier">
|
| 423 |
+
<div class="chart-head"><div class="chart-title">Mean resident KV vs p95 turn TTFT</div><div class="chart-actions"><button class="chart-download" type="button">Download PNG</button><button class="chart-expand" type="button">Expand</button></div></div>
|
| 424 |
+
<div class="chart-body research-chart"><canvas id="agentTtlChart"></canvas></div>
|
| 425 |
+
</div>
|
| 426 |
+
<div class="table-toolbar"><span>TTL sweep</span><div><button id="agentTtlCopy" class="mini-button" disabled>Copy table</button><button id="agentTtlCsv" class="mini-button" disabled>Download CSV</button></div></div>
|
| 427 |
+
<div class="table-wrap"><table><thead><tr><th>TTL</th><th>Pareto</th><th>p95 turn TTFT</th><th>p95 session E2E</th><th>Cache hit</th><th>Recomputed history</th><th>Mean KV</th><th>HBM GB-s</th><th>TTL evictions</th><th>Pressure evictions</th></tr></thead><tbody id="agentTtlRows"></tbody></table></div>
|
| 428 |
+
</div>
|
| 429 |
+
</section>
|
| 430 |
+
</div>
|
| 431 |
+
</div>
|
| 432 |
+
</section>
|
| 433 |
+
|
| 434 |
<section id="method" class="tab-panel">
|
| 435 |
<div class="method-grid">
|
| 436 |
<article class="panel prose"><div class="section-kicker">Simulation core</div><h2>What is actually simulated?</h2><p>Requests are generated from deterministic workload distributions and advanced through virtual time. Colocated runs model admission, prefill, paged KV allocation, dynamic decode batches, and completion. P/D runs use separate prefill and decode worker pools plus an explicit serialized KV-transfer link.</p><div class="formula">request -> queue -> prefill -> [KV transfer] -> decode -> completion</div></article>
|
| 437 |
<article class="panel prose"><div class="section-kicker">Prefix reuse</div><h2>Cache without pretending to implement a radix tree</h2><p>The simulator models a single shared prompt prefix with configurable length and reuse fraction. Cache hits avoid redundant prefill work and share one persistent KV allocation. It is deliberately a controlled what-if abstraction, not a claim to reproduce SGLang's full RadixAttention policy.</p></article>
|
| 438 |
<article class="panel prose"><div class="section-kicker">P/D disaggregation</div><h2>Role-specific resources and transfer cost</h2><p>Prefill and decode have separate accelerator profiles and worker counts. Prompt KV state crosses a modeled interconnect before decode admission. The simulator reports role utilization, transfer latency, and transfer pressure so the benefit of isolation can be weighed against data-movement overhead.</p></article>
|
| 439 |
+
<article class="panel prose"><div class="section-kicker">Stateful agent sessions</div><h2>Tool gaps turn KV into a residency decision</h2><p>Agent Sessions preserves program identity and turn order, materializes tool-induced gaps, and tracks whether cross-turn KV remains resident, expires under a TTL, or is recomputed after eviction. Session-affinity routing can trade load balance for cache locality. The per-replica service model is intentionally serial in this mode so those state-management effects are not confounded with batching.</p></article>
|
| 440 |
<article class="panel prose"><div class="section-kicker">Design search</div><h2>Pareto, not one magic configuration</h2><p>The Design Explorer reports both a raw-performance frontier and a resource-normalized frontier using goodput per accelerator. That prevents a multi-GPU P/D layout from looking unconditionally better merely because it uses more simulated hardware.</p></article>
|
| 441 |
<article class="panel prose"><div class="section-kicker">Trace methodology</div><h2>Generated load or exact replay</h2><p>Poisson, constant, and bursty workloads are open-loop: arrivals are scheduled independently of response completion, so queueing delay is not hidden by client backpressure. Exact CSV/JSON traces can be replayed with their original arrival times and token lengths.</p></article>
|
| 442 |
<article class="panel prose"><div class="section-kicker">Research protocol</div><h2>Paired conclusions, not one lucky seed</h2><p>Research Studies use common random numbers: baseline and treatment receive identical seeds, reducing workload variance in the paired difference. A bootstrap interval summarizes the repeated effect, while a separate sensitivity study perturbs analytical latency scales to reveal conclusions that depend too strongly on one reference profile.</p></article>
|
py/inferscale/__init__.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 1 |
from .api import execute, metadata
|
| 2 |
from .models import SimulationConfig
|
| 3 |
from .optimizer import capacity_search, compare_schedulers, compare_topologies, design_space_search
|
|
@@ -7,6 +8,7 @@ from .validation import validate_cases
|
|
| 7 |
|
| 8 |
__all__ = [
|
| 9 |
"SimulationConfig",
|
|
|
|
| 10 |
"capacity_search",
|
| 11 |
"compare_schedulers",
|
| 12 |
"compare_topologies",
|
|
@@ -15,10 +17,12 @@ __all__ = [
|
|
| 15 |
"metadata",
|
| 16 |
"paired_study",
|
| 17 |
"robustness_study",
|
|
|
|
| 18 |
"run_simulation",
|
|
|
|
| 19 |
"validate_cases",
|
| 20 |
]
|
| 21 |
|
| 22 |
# Internal package metadata only; the public project intentionally avoids
|
| 23 |
# release/version branding in the interface and documentation.
|
| 24 |
-
__version__ = "0.
|
|
|
|
| 1 |
+
from .agentic import compare_agent_policies, run_agent_session_simulation, ttl_retention_sweep
|
| 2 |
from .api import execute, metadata
|
| 3 |
from .models import SimulationConfig
|
| 4 |
from .optimizer import capacity_search, compare_schedulers, compare_topologies, design_space_search
|
|
|
|
| 8 |
|
| 9 |
__all__ = [
|
| 10 |
"SimulationConfig",
|
| 11 |
+
"compare_agent_policies",
|
| 12 |
"capacity_search",
|
| 13 |
"compare_schedulers",
|
| 14 |
"compare_topologies",
|
|
|
|
| 17 |
"metadata",
|
| 18 |
"paired_study",
|
| 19 |
"robustness_study",
|
| 20 |
+
"run_agent_session_simulation",
|
| 21 |
"run_simulation",
|
| 22 |
+
"ttl_retention_sweep",
|
| 23 |
"validate_cases",
|
| 24 |
]
|
| 25 |
|
| 26 |
# Internal package metadata only; the public project intentionally avoids
|
| 27 |
# release/version branding in the interface and documentation.
|
| 28 |
+
__version__ = "0.5.0"
|
py/inferscale/agentic.py
ADDED
|
@@ -0,0 +1,622 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import heapq
|
| 4 |
+
import math
|
| 5 |
+
import random
|
| 6 |
+
from dataclasses import asdict, dataclass
|
| 7 |
+
from statistics import mean, median
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
from .latency import AnalyticalLatencyModel
|
| 11 |
+
from .metrics import percentile
|
| 12 |
+
from .profiles import get_accelerator, get_model
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
ROUTING_POLICIES = {"least_load", "session_affinity"}
|
| 16 |
+
RETENTION_POLICIES = {"evict", "retain", "ttl"}
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@dataclass
|
| 20 |
+
class AgentSessionConfig:
|
| 21 |
+
model: str = "Qwen2.5-3B"
|
| 22 |
+
accelerator: str = "L4"
|
| 23 |
+
quantization: str = "int8"
|
| 24 |
+
seed: int = 7
|
| 25 |
+
duration_s: float = 30.0
|
| 26 |
+
session_rate_rps: float = 0.45
|
| 27 |
+
replicas: int = 2
|
| 28 |
+
turns_mean: float = 4.0
|
| 29 |
+
turns_cv: float = 0.25
|
| 30 |
+
initial_prompt_tokens_mean: int = 640
|
| 31 |
+
append_tokens_mean: int = 180
|
| 32 |
+
token_cv: float = 0.35
|
| 33 |
+
output_tokens_mean: int = 72
|
| 34 |
+
output_tokens_cv: float = 0.45
|
| 35 |
+
tool_gap_mean_s: float = 1.5
|
| 36 |
+
tool_gap_cv: float = 0.75
|
| 37 |
+
retention_policy: str = "ttl"
|
| 38 |
+
kv_ttl_s: float = 3.0
|
| 39 |
+
routing_policy: str = "session_affinity"
|
| 40 |
+
kv_memory_fraction: float = 0.85
|
| 41 |
+
slo_turn_ttft_ms: float = 500.0
|
| 42 |
+
slo_session_e2e_ms: float = 30000.0
|
| 43 |
+
timeline_points: int = 240
|
| 44 |
+
|
| 45 |
+
@classmethod
|
| 46 |
+
def from_dict(cls, data: dict[str, Any]) -> "AgentSessionConfig":
|
| 47 |
+
allowed = cls.__dataclass_fields__.keys()
|
| 48 |
+
cfg = cls(**{k: data[k] for k in allowed if k in data})
|
| 49 |
+
if cfg.routing_policy not in ROUTING_POLICIES:
|
| 50 |
+
raise ValueError(f"Unsupported agent routing policy: {cfg.routing_policy}")
|
| 51 |
+
if cfg.retention_policy not in RETENTION_POLICIES:
|
| 52 |
+
raise ValueError(f"Unsupported KV retention policy: {cfg.retention_policy}")
|
| 53 |
+
cfg.replicas = max(1, min(int(cfg.replicas), 8))
|
| 54 |
+
cfg.session_rate_rps = max(float(cfg.session_rate_rps), 0.01)
|
| 55 |
+
cfg.duration_s = max(float(cfg.duration_s), 1.0)
|
| 56 |
+
cfg.kv_ttl_s = max(float(cfg.kv_ttl_s), 0.0)
|
| 57 |
+
cfg.kv_memory_fraction = min(max(float(cfg.kv_memory_fraction), 0.05), 0.98)
|
| 58 |
+
return cfg
|
| 59 |
+
|
| 60 |
+
def to_dict(self) -> dict[str, Any]:
|
| 61 |
+
return asdict(self)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
@dataclass
|
| 65 |
+
class TurnSpec:
|
| 66 |
+
session_id: int
|
| 67 |
+
turn_index: int
|
| 68 |
+
append_tokens: int
|
| 69 |
+
output_tokens: int
|
| 70 |
+
tool_gap_after_s: float
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
@dataclass
|
| 74 |
+
class SessionSpec:
|
| 75 |
+
session_id: int
|
| 76 |
+
arrival_time: float
|
| 77 |
+
initial_prompt_tokens: int
|
| 78 |
+
turns: list[TurnSpec]
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
@dataclass
|
| 82 |
+
class CacheEntry:
|
| 83 |
+
session_id: int
|
| 84 |
+
tokens: int
|
| 85 |
+
size_gb: float
|
| 86 |
+
last_access: float
|
| 87 |
+
expiry_time: float | None
|
| 88 |
+
generation: int
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
@dataclass
|
| 92 |
+
class ReplicaState:
|
| 93 |
+
replica_id: int
|
| 94 |
+
busy: bool = False
|
| 95 |
+
busy_until: float = 0.0
|
| 96 |
+
queue: list[tuple[int, TurnSpec, float]] | None = None
|
| 97 |
+
cache: dict[int, CacheEntry] | None = None
|
| 98 |
+
current_session: int | None = None
|
| 99 |
+
|
| 100 |
+
def __post_init__(self) -> None:
|
| 101 |
+
if self.queue is None:
|
| 102 |
+
self.queue = []
|
| 103 |
+
if self.cache is None:
|
| 104 |
+
self.cache = {}
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
@dataclass
|
| 108 |
+
class SessionRuntime:
|
| 109 |
+
spec: SessionSpec
|
| 110 |
+
context_tokens: int
|
| 111 |
+
completed_turns: int = 0
|
| 112 |
+
completion_time: float | None = None
|
| 113 |
+
last_replica: int | None = None
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def _sample_positive_lognormal(rng: random.Random, mean_value: float, cv: float, minimum: float) -> float:
|
| 117 |
+
mean_value = max(float(mean_value), minimum)
|
| 118 |
+
cv = max(float(cv), 0.0)
|
| 119 |
+
if mean_value <= 0.0:
|
| 120 |
+
return minimum
|
| 121 |
+
if cv <= 1e-12:
|
| 122 |
+
return mean_value
|
| 123 |
+
sigma2 = math.log1p(cv * cv)
|
| 124 |
+
sigma = math.sqrt(sigma2)
|
| 125 |
+
mu = math.log(mean_value) - sigma2 / 2.0
|
| 126 |
+
return max(minimum, rng.lognormvariate(mu, sigma))
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def _sample_int(rng: random.Random, mean_value: float, cv: float, minimum: int = 1) -> int:
|
| 130 |
+
return max(minimum, int(round(_sample_positive_lognormal(rng, mean_value, cv, minimum))))
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def generate_agent_sessions(cfg: AgentSessionConfig) -> list[SessionSpec]:
|
| 134 |
+
"""Generate a deterministic multi-turn program trace.
|
| 135 |
+
|
| 136 |
+
All policies consume this same trace when the seed/config are unchanged. Tool
|
| 137 |
+
gaps are sampled up front so policy comparisons use common random numbers.
|
| 138 |
+
"""
|
| 139 |
+
rng = random.Random(cfg.seed ^ 0xA63E17)
|
| 140 |
+
sessions: list[SessionSpec] = []
|
| 141 |
+
now = 0.0
|
| 142 |
+
sid = 0
|
| 143 |
+
while True:
|
| 144 |
+
now += rng.expovariate(cfg.session_rate_rps)
|
| 145 |
+
if now > cfg.duration_s:
|
| 146 |
+
break
|
| 147 |
+
turns = max(2, _sample_int(rng, cfg.turns_mean, cfg.turns_cv, 2))
|
| 148 |
+
initial = _sample_int(rng, cfg.initial_prompt_tokens_mean, cfg.token_cv, 32)
|
| 149 |
+
specs = []
|
| 150 |
+
for turn_idx in range(turns):
|
| 151 |
+
append = 0 if turn_idx == 0 else _sample_int(rng, cfg.append_tokens_mean, cfg.token_cv, 8)
|
| 152 |
+
output = _sample_int(rng, cfg.output_tokens_mean, cfg.output_tokens_cv, 1)
|
| 153 |
+
gap = 0.0 if turn_idx == turns - 1 else _sample_positive_lognormal(
|
| 154 |
+
rng, cfg.tool_gap_mean_s, cfg.tool_gap_cv, 0.0
|
| 155 |
+
)
|
| 156 |
+
specs.append(TurnSpec(sid, turn_idx, append, output, gap))
|
| 157 |
+
sessions.append(SessionSpec(sid, now, initial, specs))
|
| 158 |
+
sid += 1
|
| 159 |
+
return sessions
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
class AgentSessionSimulator:
|
| 163 |
+
"""Discrete-event simulator for stateful multi-turn serving.
|
| 164 |
+
|
| 165 |
+
The module deliberately isolates session locality / KV-retention effects from
|
| 166 |
+
dynamic batching. Each replica is a serial analytical service station; the
|
| 167 |
+
existing Serving Lab remains the place to study continuous batching. This
|
| 168 |
+
separation keeps the agent experiment interpretable while still modelling
|
| 169 |
+
session dependencies, tool gaps, routing, memory pressure, and cross-turn KV.
|
| 170 |
+
"""
|
| 171 |
+
|
| 172 |
+
def __init__(self, cfg: AgentSessionConfig, sessions: list[SessionSpec] | None = None):
|
| 173 |
+
self.cfg = cfg
|
| 174 |
+
self.model = get_model(cfg.model)
|
| 175 |
+
self.accelerator = get_accelerator(cfg.accelerator)
|
| 176 |
+
self.latency = AnalyticalLatencyModel(self.model, self.accelerator, cfg.quantization)
|
| 177 |
+
remaining = max(0.0, self.accelerator.vram_gb - self.latency.model_weight_gb - 1.2)
|
| 178 |
+
self.kv_capacity_gb = remaining * cfg.kv_memory_fraction
|
| 179 |
+
self.replicas = [ReplicaState(i) for i in range(cfg.replicas)]
|
| 180 |
+
specs = sessions if sessions is not None else generate_agent_sessions(cfg)
|
| 181 |
+
self.sessions = {s.session_id: SessionRuntime(s, s.initial_prompt_tokens) for s in specs}
|
| 182 |
+
self.events: list[tuple[float, int, str, tuple[Any, ...]]] = []
|
| 183 |
+
self.event_seq = 0
|
| 184 |
+
self.now = 0.0
|
| 185 |
+
self.turn_rows: list[dict[str, Any]] = []
|
| 186 |
+
self.timeline: list[dict[str, Any]] = []
|
| 187 |
+
self.peak_kv_gb = 0.0
|
| 188 |
+
self.hbm_gb_seconds = 0.0
|
| 189 |
+
self.last_memory_time = 0.0
|
| 190 |
+
self.pressure_evictions = 0
|
| 191 |
+
self.ttl_evictions = 0
|
| 192 |
+
self.recompute_tokens = 0
|
| 193 |
+
self.cache_hits = 0
|
| 194 |
+
self.cache_eligible_turns = 0
|
| 195 |
+
self.affinity_routes = 0
|
| 196 |
+
self.route_opportunities = 0
|
| 197 |
+
self.failed_turns = 0
|
| 198 |
+
self.tool_gap_total_s = 0.0
|
| 199 |
+
for session in specs:
|
| 200 |
+
if session.turns:
|
| 201 |
+
self._push(session.arrival_time, "turn_ready", session.session_id, 0)
|
| 202 |
+
|
| 203 |
+
def _push(self, when: float, kind: str, *payload: Any) -> None:
|
| 204 |
+
self.event_seq += 1
|
| 205 |
+
heapq.heappush(self.events, (float(when), self.event_seq, kind, tuple(payload)))
|
| 206 |
+
|
| 207 |
+
def _used_gb(self) -> float:
|
| 208 |
+
return sum(entry.size_gb for replica in self.replicas for entry in replica.cache.values())
|
| 209 |
+
|
| 210 |
+
def _integrate_memory(self, new_time: float) -> None:
|
| 211 |
+
new_time = max(float(new_time), self.last_memory_time)
|
| 212 |
+
used = self._used_gb()
|
| 213 |
+
self.hbm_gb_seconds += used * (new_time - self.last_memory_time)
|
| 214 |
+
self.last_memory_time = new_time
|
| 215 |
+
self.peak_kv_gb = max(self.peak_kv_gb, used)
|
| 216 |
+
|
| 217 |
+
def _record_timeline(self) -> None:
|
| 218 |
+
max_points = max(int(self.cfg.timeline_points), 40)
|
| 219 |
+
if self.timeline and self.now - self.timeline[-1]["time_s"] < max(self.cfg.duration_s / max_points, 0.05):
|
| 220 |
+
return
|
| 221 |
+
self.timeline.append(
|
| 222 |
+
{
|
| 223 |
+
"time_s": self.now,
|
| 224 |
+
"queued_turns": sum(len(r.queue) for r in self.replicas),
|
| 225 |
+
"busy_replicas": sum(1 for r in self.replicas if r.busy),
|
| 226 |
+
"kv_used_gb": self._used_gb(),
|
| 227 |
+
"resident_sessions": sum(len(r.cache) for r in self.replicas),
|
| 228 |
+
}
|
| 229 |
+
)
|
| 230 |
+
if len(self.timeline) > max_points * 2:
|
| 231 |
+
self.timeline = self.timeline[::2]
|
| 232 |
+
|
| 233 |
+
def _entry(self, replica: ReplicaState, session_id: int) -> CacheEntry | None:
|
| 234 |
+
return replica.cache.get(session_id)
|
| 235 |
+
|
| 236 |
+
def _cache_replica(self, session_id: int) -> ReplicaState | None:
|
| 237 |
+
for replica in self.replicas:
|
| 238 |
+
if session_id in replica.cache:
|
| 239 |
+
return replica
|
| 240 |
+
return None
|
| 241 |
+
|
| 242 |
+
def _route(self, session_id: int) -> ReplicaState:
|
| 243 |
+
cached = self._cache_replica(session_id)
|
| 244 |
+
if cached is not None:
|
| 245 |
+
self.route_opportunities += 1
|
| 246 |
+
if self.cfg.routing_policy == "session_affinity" and cached is not None:
|
| 247 |
+
self.affinity_routes += 1
|
| 248 |
+
return cached
|
| 249 |
+
# Approximate least-finish-time routing using queued work count then busy horizon.
|
| 250 |
+
return min(self.replicas, key=lambda r: (len(r.queue), max(r.busy_until, self.now), r.replica_id))
|
| 251 |
+
|
| 252 |
+
def _remove_cache(self, replica: ReplicaState, session_id: int, reason: str) -> None:
|
| 253 |
+
if session_id not in replica.cache:
|
| 254 |
+
return
|
| 255 |
+
self._integrate_memory(self.now)
|
| 256 |
+
del replica.cache[session_id]
|
| 257 |
+
if reason == "pressure":
|
| 258 |
+
self.pressure_evictions += 1
|
| 259 |
+
elif reason == "ttl":
|
| 260 |
+
self.ttl_evictions += 1
|
| 261 |
+
self._record_timeline()
|
| 262 |
+
|
| 263 |
+
def _ensure_capacity(self, replica: ReplicaState, session_id: int, target_gb: float) -> bool:
|
| 264 |
+
current = replica.cache.get(session_id)
|
| 265 |
+
current_gb = current.size_gb if current else 0.0
|
| 266 |
+
additional = max(0.0, target_gb - current_gb)
|
| 267 |
+
if additional <= 1e-12:
|
| 268 |
+
return True
|
| 269 |
+
# Capacity is per replica; evict least-recently-used inactive session KV.
|
| 270 |
+
while sum(e.size_gb for e in replica.cache.values()) + additional > self.kv_capacity_gb + 1e-12:
|
| 271 |
+
victims = [e for sid, e in replica.cache.items() if sid != session_id]
|
| 272 |
+
if not victims:
|
| 273 |
+
return False
|
| 274 |
+
victim = min(victims, key=lambda e: (e.last_access, e.session_id))
|
| 275 |
+
self._remove_cache(replica, victim.session_id, "pressure")
|
| 276 |
+
return True
|
| 277 |
+
|
| 278 |
+
def _put_cache(self, replica: ReplicaState, session_id: int, tokens: int, keep: bool) -> bool:
|
| 279 |
+
size_gb = tokens * self.latency.kv_bytes_per_token() / 1e9
|
| 280 |
+
if size_gb > self.kv_capacity_gb + 1e-12:
|
| 281 |
+
return False
|
| 282 |
+
if not self._ensure_capacity(replica, session_id, size_gb):
|
| 283 |
+
return False
|
| 284 |
+
self._integrate_memory(self.now)
|
| 285 |
+
previous = replica.cache.get(session_id)
|
| 286 |
+
generation = (previous.generation + 1) if previous else 1
|
| 287 |
+
expiry: float | None = None
|
| 288 |
+
if keep and self.cfg.retention_policy == "ttl":
|
| 289 |
+
expiry = self.now + self.cfg.kv_ttl_s
|
| 290 |
+
replica.cache[session_id] = CacheEntry(session_id, tokens, size_gb, self.now, expiry, generation)
|
| 291 |
+
self.peak_kv_gb = max(self.peak_kv_gb, self._used_gb())
|
| 292 |
+
if expiry is not None:
|
| 293 |
+
self._push(expiry, "cache_expire", replica.replica_id, session_id, generation)
|
| 294 |
+
self._record_timeline()
|
| 295 |
+
return True
|
| 296 |
+
|
| 297 |
+
def _start_next(self, replica: ReplicaState) -> None:
|
| 298 |
+
if replica.busy or not replica.queue:
|
| 299 |
+
return
|
| 300 |
+
session_id, turn, ready_time = replica.queue.pop(0)
|
| 301 |
+
runtime = self.sessions[session_id]
|
| 302 |
+
cache = self._entry(replica, session_id)
|
| 303 |
+
hit = turn.turn_index > 0 and cache is not None
|
| 304 |
+
if turn.turn_index > 0:
|
| 305 |
+
self.cache_eligible_turns += 1
|
| 306 |
+
if hit:
|
| 307 |
+
self.cache_hits += 1
|
| 308 |
+
prefill_tokens = max(turn.append_tokens, 1)
|
| 309 |
+
else:
|
| 310 |
+
prefill_tokens = max(runtime.context_tokens + turn.append_tokens, 1)
|
| 311 |
+
if turn.turn_index > 0:
|
| 312 |
+
self.recompute_tokens += runtime.context_tokens
|
| 313 |
+
|
| 314 |
+
context_before_decode = runtime.context_tokens + turn.append_tokens
|
| 315 |
+
projected_tokens = context_before_decode + turn.output_tokens
|
| 316 |
+
projected_gb = projected_tokens * self.latency.kv_bytes_per_token() / 1e9
|
| 317 |
+
if not self._ensure_capacity(replica, session_id, projected_gb):
|
| 318 |
+
self.failed_turns += 1
|
| 319 |
+
self._push(self.now, "turn_failed", session_id, turn.turn_index, replica.replica_id, ready_time)
|
| 320 |
+
self._start_next(replica)
|
| 321 |
+
return
|
| 322 |
+
|
| 323 |
+
# Model the current turn's KV as resident while it executes, even when
|
| 324 |
+
# the selected policy will evict it immediately after the turn.
|
| 325 |
+
if not self._put_cache(replica, session_id, projected_tokens, keep=False):
|
| 326 |
+
self.failed_turns += 1
|
| 327 |
+
self._push(self.now, "turn_failed", session_id, turn.turn_index, replica.replica_id, ready_time)
|
| 328 |
+
self._start_next(replica)
|
| 329 |
+
return
|
| 330 |
+
|
| 331 |
+
prefill_s = self.latency.prefill_seconds([prefill_tokens])
|
| 332 |
+
first_decode_s = self.latency.decode_step_seconds([context_before_decode])
|
| 333 |
+
midpoint = context_before_decode + max(turn.output_tokens // 2, 1)
|
| 334 |
+
decode_step_s = self.latency.decode_step_seconds([midpoint])
|
| 335 |
+
service_s = prefill_s + turn.output_tokens * decode_step_s
|
| 336 |
+
ttft_s = (self.now - ready_time) + prefill_s + first_decode_s
|
| 337 |
+
|
| 338 |
+
replica.busy = True
|
| 339 |
+
replica.current_session = session_id
|
| 340 |
+
replica.busy_until = self.now + service_s
|
| 341 |
+
self._push(
|
| 342 |
+
replica.busy_until,
|
| 343 |
+
"turn_complete",
|
| 344 |
+
session_id,
|
| 345 |
+
turn.turn_index,
|
| 346 |
+
replica.replica_id,
|
| 347 |
+
ready_time,
|
| 348 |
+
hit,
|
| 349 |
+
prefill_tokens,
|
| 350 |
+
ttft_s,
|
| 351 |
+
service_s,
|
| 352 |
+
projected_tokens,
|
| 353 |
+
)
|
| 354 |
+
|
| 355 |
+
def _finish_turn(
|
| 356 |
+
self,
|
| 357 |
+
session_id: int,
|
| 358 |
+
turn_index: int,
|
| 359 |
+
replica_id: int,
|
| 360 |
+
ready_time: float,
|
| 361 |
+
cache_hit: bool,
|
| 362 |
+
prefill_tokens: int,
|
| 363 |
+
ttft_s: float,
|
| 364 |
+
service_s: float,
|
| 365 |
+
projected_tokens: int,
|
| 366 |
+
) -> None:
|
| 367 |
+
replica = self.replicas[replica_id]
|
| 368 |
+
runtime = self.sessions[session_id]
|
| 369 |
+
turn = runtime.spec.turns[turn_index]
|
| 370 |
+
runtime.context_tokens = projected_tokens
|
| 371 |
+
runtime.completed_turns += 1
|
| 372 |
+
runtime.last_replica = replica_id
|
| 373 |
+
replica.busy = False
|
| 374 |
+
replica.current_session = None
|
| 375 |
+
replica.busy_until = self.now
|
| 376 |
+
|
| 377 |
+
is_final = turn_index == len(runtime.spec.turns) - 1
|
| 378 |
+
keep = (not is_final) and self.cfg.retention_policy != "evict"
|
| 379 |
+
if keep:
|
| 380 |
+
self._put_cache(replica, session_id, projected_tokens, keep=True)
|
| 381 |
+
else:
|
| 382 |
+
self._remove_cache(replica, session_id, "complete")
|
| 383 |
+
|
| 384 |
+
e2e_ms = (self.now - ready_time) * 1000.0
|
| 385 |
+
self.turn_rows.append(
|
| 386 |
+
{
|
| 387 |
+
"session_id": session_id,
|
| 388 |
+
"turn_index": turn_index + 1,
|
| 389 |
+
"replica": replica_id,
|
| 390 |
+
"ready_time": ready_time,
|
| 391 |
+
"completion_time": self.now,
|
| 392 |
+
"cache_hit": cache_hit,
|
| 393 |
+
"prefill_tokens": prefill_tokens,
|
| 394 |
+
"context_tokens_after": projected_tokens,
|
| 395 |
+
"output_tokens": turn.output_tokens,
|
| 396 |
+
"ttft_ms": ttft_s * 1000.0,
|
| 397 |
+
"e2e_ms": e2e_ms,
|
| 398 |
+
"queue_ms": max(0.0, e2e_ms - service_s * 1000.0),
|
| 399 |
+
}
|
| 400 |
+
)
|
| 401 |
+
|
| 402 |
+
if is_final:
|
| 403 |
+
runtime.completion_time = self.now
|
| 404 |
+
else:
|
| 405 |
+
self.tool_gap_total_s += turn.tool_gap_after_s
|
| 406 |
+
self._push(self.now + turn.tool_gap_after_s, "turn_ready", session_id, turn_index + 1)
|
| 407 |
+
self._start_next(replica)
|
| 408 |
+
|
| 409 |
+
def run(self) -> dict[str, Any]:
|
| 410 |
+
self._record_timeline()
|
| 411 |
+
while self.events:
|
| 412 |
+
event_time, _, kind, payload = heapq.heappop(self.events)
|
| 413 |
+
self._integrate_memory(event_time)
|
| 414 |
+
self.now = event_time
|
| 415 |
+
if kind == "turn_ready":
|
| 416 |
+
session_id, turn_index = int(payload[0]), int(payload[1])
|
| 417 |
+
turn = self.sessions[session_id].spec.turns[turn_index]
|
| 418 |
+
replica = self._route(session_id)
|
| 419 |
+
replica.queue.append((session_id, turn, self.now))
|
| 420 |
+
self._start_next(replica)
|
| 421 |
+
elif kind == "turn_complete":
|
| 422 |
+
self._finish_turn(
|
| 423 |
+
int(payload[0]), int(payload[1]), int(payload[2]), float(payload[3]), bool(payload[4]),
|
| 424 |
+
int(payload[5]), float(payload[6]), float(payload[7]), int(payload[8]),
|
| 425 |
+
)
|
| 426 |
+
elif kind == "cache_expire":
|
| 427 |
+
replica_id, session_id, generation = map(int, payload)
|
| 428 |
+
replica = self.replicas[replica_id]
|
| 429 |
+
entry = replica.cache.get(session_id)
|
| 430 |
+
if entry is not None and entry.generation == generation and entry.expiry_time is not None and entry.expiry_time <= self.now + 1e-12:
|
| 431 |
+
self._remove_cache(replica, session_id, "ttl")
|
| 432 |
+
elif kind == "turn_failed":
|
| 433 |
+
session_id, turn_index, replica_id, ready_time = int(payload[0]), int(payload[1]), int(payload[2]), float(payload[3])
|
| 434 |
+
self.turn_rows.append({
|
| 435 |
+
"session_id": session_id, "turn_index": turn_index + 1, "replica": replica_id,
|
| 436 |
+
"ready_time": ready_time, "completion_time": self.now, "cache_hit": False,
|
| 437 |
+
"prefill_tokens": 0, "context_tokens_after": self.sessions[session_id].context_tokens,
|
| 438 |
+
"output_tokens": 0, "ttft_ms": 0.0, "e2e_ms": 0.0, "queue_ms": 0.0, "failed": True,
|
| 439 |
+
})
|
| 440 |
+
self._record_timeline()
|
| 441 |
+
|
| 442 |
+
self._integrate_memory(self.now)
|
| 443 |
+
self._record_timeline()
|
| 444 |
+
completed_sessions = [s for s in self.sessions.values() if s.completion_time is not None]
|
| 445 |
+
successful_turns = [row for row in self.turn_rows if not row.get("failed")]
|
| 446 |
+
ttfts = [float(row["ttft_ms"]) for row in successful_turns]
|
| 447 |
+
turn_e2e = [float(row["e2e_ms"]) for row in successful_turns]
|
| 448 |
+
session_e2e = [
|
| 449 |
+
(s.completion_time - s.spec.arrival_time) * 1000.0
|
| 450 |
+
for s in completed_sessions
|
| 451 |
+
if s.completion_time is not None
|
| 452 |
+
]
|
| 453 |
+
session_slo = sum(1 for v in session_e2e if v <= self.cfg.slo_session_e2e_ms)
|
| 454 |
+
turn_slo = sum(1 for v in ttfts if v <= self.cfg.slo_turn_ttft_ms)
|
| 455 |
+
completion_horizon = max([s.completion_time or 0.0 for s in self.sessions.values()] + [self.cfg.duration_s, 1e-9])
|
| 456 |
+
mean_kv_gb = self.hbm_gb_seconds / completion_horizon
|
| 457 |
+
summary = {
|
| 458 |
+
"sessions_generated": len(self.sessions),
|
| 459 |
+
"sessions_completed": len(completed_sessions),
|
| 460 |
+
"turns_completed": len(successful_turns),
|
| 461 |
+
"turns_failed": self.failed_turns,
|
| 462 |
+
"session_throughput_rps": len(completed_sessions) / completion_horizon,
|
| 463 |
+
"turn_throughput_rps": len(successful_turns) / completion_horizon,
|
| 464 |
+
"turn_ttft_slo_attainment": turn_slo / len(successful_turns) if successful_turns else 0.0,
|
| 465 |
+
"session_slo_attainment": session_slo / len(completed_sessions) if completed_sessions else 0.0,
|
| 466 |
+
"simulated_makespan_s": completion_horizon,
|
| 467 |
+
}
|
| 468 |
+
latency = {
|
| 469 |
+
"turn_ttft_ms": {"p50": percentile(ttfts, 0.50), "p95": percentile(ttfts, 0.95), "p99": percentile(ttfts, 0.99)},
|
| 470 |
+
"turn_e2e_ms": {"p50": percentile(turn_e2e, 0.50), "p95": percentile(turn_e2e, 0.95), "p99": percentile(turn_e2e, 0.99)},
|
| 471 |
+
"session_e2e_ms": {"p50": percentile(session_e2e, 0.50), "p95": percentile(session_e2e, 0.95), "p99": percentile(session_e2e, 0.99)},
|
| 472 |
+
}
|
| 473 |
+
resource = {
|
| 474 |
+
"replicas": self.cfg.replicas,
|
| 475 |
+
"kv_capacity_gb_per_replica": self.kv_capacity_gb,
|
| 476 |
+
"peak_kv_gb": self.peak_kv_gb,
|
| 477 |
+
"mean_kv_gb": mean_kv_gb,
|
| 478 |
+
"hbm_gb_seconds": self.hbm_gb_seconds,
|
| 479 |
+
"cross_turn_cache_hits": self.cache_hits,
|
| 480 |
+
"cross_turn_cache_eligible": self.cache_eligible_turns,
|
| 481 |
+
"cross_turn_cache_hit_rate": self.cache_hits / self.cache_eligible_turns if self.cache_eligible_turns else 0.0,
|
| 482 |
+
"routing_locality_rate": self.affinity_routes / self.route_opportunities if self.route_opportunities else 0.0,
|
| 483 |
+
"recomputed_history_tokens": self.recompute_tokens,
|
| 484 |
+
"pressure_evictions": self.pressure_evictions,
|
| 485 |
+
"ttl_evictions": self.ttl_evictions,
|
| 486 |
+
"tool_gap_total_s": self.tool_gap_total_s,
|
| 487 |
+
}
|
| 488 |
+
return {
|
| 489 |
+
"config": self.cfg.to_dict(),
|
| 490 |
+
"provenance": {
|
| 491 |
+
"simulator": "InferScale-Sim",
|
| 492 |
+
"mode": "stateful-agent-session-simulation",
|
| 493 |
+
"latency_profile_type": "analytical-reference",
|
| 494 |
+
"agent_service_model": "serial-per-replica-reference",
|
| 495 |
+
"warning": "Agent-session mode isolates routing/KV-retention effects and does not model dynamic batching within each replica.",
|
| 496 |
+
},
|
| 497 |
+
"summary": summary,
|
| 498 |
+
"latency": latency,
|
| 499 |
+
"resource": resource,
|
| 500 |
+
"turns": successful_turns[:3000],
|
| 501 |
+
"sessions": [
|
| 502 |
+
{
|
| 503 |
+
"session_id": s.spec.session_id,
|
| 504 |
+
"arrival_time": s.spec.arrival_time,
|
| 505 |
+
"turns": len(s.spec.turns),
|
| 506 |
+
"completion_time": s.completion_time,
|
| 507 |
+
"e2e_ms": (s.completion_time - s.spec.arrival_time) * 1000.0 if s.completion_time is not None else None,
|
| 508 |
+
}
|
| 509 |
+
for s in list(self.sessions.values())[:1000]
|
| 510 |
+
],
|
| 511 |
+
"timeline": self.timeline,
|
| 512 |
+
}
|
| 513 |
+
|
| 514 |
+
|
| 515 |
+
def run_agent_session_simulation(config: dict[str, Any], sessions: list[SessionSpec] | None = None) -> dict[str, Any]:
|
| 516 |
+
cfg = AgentSessionConfig.from_dict(config)
|
| 517 |
+
return AgentSessionSimulator(cfg, sessions=sessions).run()
|
| 518 |
+
|
| 519 |
+
|
| 520 |
+
def _same_trace(cfg: AgentSessionConfig) -> list[SessionSpec]:
|
| 521 |
+
return generate_agent_sessions(cfg)
|
| 522 |
+
|
| 523 |
+
|
| 524 |
+
def compare_agent_policies(config: dict[str, Any]) -> dict[str, Any]:
|
| 525 |
+
base = AgentSessionConfig.from_dict(config)
|
| 526 |
+
trace = _same_trace(base)
|
| 527 |
+
policies = [
|
| 528 |
+
("Stateless / least-load", "evict", "least_load", 0.0),
|
| 529 |
+
("Retain / least-load", "retain", "least_load", base.kv_ttl_s),
|
| 530 |
+
("TTL / affinity", "ttl", "session_affinity", base.kv_ttl_s),
|
| 531 |
+
("Retain / affinity", "retain", "session_affinity", base.kv_ttl_s),
|
| 532 |
+
]
|
| 533 |
+
rows = []
|
| 534 |
+
results = []
|
| 535 |
+
for label, retention, routing, ttl in policies:
|
| 536 |
+
cfg = AgentSessionConfig.from_dict(base.to_dict())
|
| 537 |
+
cfg.retention_policy = retention
|
| 538 |
+
cfg.routing_policy = routing
|
| 539 |
+
cfg.kv_ttl_s = ttl
|
| 540 |
+
result = run_agent_session_simulation(cfg.to_dict(), trace)
|
| 541 |
+
results.append(result)
|
| 542 |
+
rows.append(
|
| 543 |
+
{
|
| 544 |
+
"label": label,
|
| 545 |
+
"retention": retention,
|
| 546 |
+
"routing": routing,
|
| 547 |
+
"p95_turn_ttft_ms": result["latency"]["turn_ttft_ms"]["p95"],
|
| 548 |
+
"p95_session_e2e_ms": result["latency"]["session_e2e_ms"]["p95"],
|
| 549 |
+
"cache_hit_rate": result["resource"]["cross_turn_cache_hit_rate"],
|
| 550 |
+
"routing_locality_rate": result["resource"]["routing_locality_rate"],
|
| 551 |
+
"recomputed_history_tokens": result["resource"]["recomputed_history_tokens"],
|
| 552 |
+
"peak_kv_gb": result["resource"]["peak_kv_gb"],
|
| 553 |
+
"mean_kv_gb": result["resource"]["mean_kv_gb"],
|
| 554 |
+
"hbm_gb_seconds": result["resource"]["hbm_gb_seconds"],
|
| 555 |
+
"pressure_evictions": result["resource"]["pressure_evictions"],
|
| 556 |
+
"ttl_evictions": result["resource"]["ttl_evictions"],
|
| 557 |
+
"sessions_completed": result["summary"]["sessions_completed"],
|
| 558 |
+
}
|
| 559 |
+
)
|
| 560 |
+
return {"protocol": "common-agent-program-trace", "candidate_count": len(rows), "rows": rows, "results": results}
|
| 561 |
+
|
| 562 |
+
|
| 563 |
+
def _pareto(rows: list[dict[str, Any]], x: str, y: str) -> set[int]:
|
| 564 |
+
# Both x and y are minimized.
|
| 565 |
+
front: set[int] = set()
|
| 566 |
+
for idx, row in enumerate(rows):
|
| 567 |
+
dominated = False
|
| 568 |
+
for jdx, other in enumerate(rows):
|
| 569 |
+
if idx == jdx:
|
| 570 |
+
continue
|
| 571 |
+
if other[x] <= row[x] and other[y] <= row[y] and (other[x] < row[x] or other[y] < row[y]):
|
| 572 |
+
dominated = True
|
| 573 |
+
break
|
| 574 |
+
if not dominated:
|
| 575 |
+
front.add(idx)
|
| 576 |
+
return front
|
| 577 |
+
|
| 578 |
+
|
| 579 |
+
def ttl_retention_sweep(config: dict[str, Any], ttl_values: list[float] | None = None) -> dict[str, Any]:
|
| 580 |
+
base = AgentSessionConfig.from_dict(config)
|
| 581 |
+
base.retention_policy = "ttl"
|
| 582 |
+
base.routing_policy = "session_affinity"
|
| 583 |
+
trace = _same_trace(base)
|
| 584 |
+
values = ttl_values or [0.0, 0.25, 0.5, 1.0, 2.0, 3.0, 5.0, 8.0, 13.0]
|
| 585 |
+
cleaned = sorted({max(0.0, min(float(v), 120.0)) for v in values})
|
| 586 |
+
rows = []
|
| 587 |
+
for ttl in cleaned:
|
| 588 |
+
cfg = AgentSessionConfig.from_dict(base.to_dict())
|
| 589 |
+
cfg.kv_ttl_s = ttl
|
| 590 |
+
result = run_agent_session_simulation(cfg.to_dict(), trace)
|
| 591 |
+
rows.append(
|
| 592 |
+
{
|
| 593 |
+
"ttl_s": ttl,
|
| 594 |
+
"p95_turn_ttft_ms": result["latency"]["turn_ttft_ms"]["p95"],
|
| 595 |
+
"p95_session_e2e_ms": result["latency"]["session_e2e_ms"]["p95"],
|
| 596 |
+
"cache_hit_rate": result["resource"]["cross_turn_cache_hit_rate"],
|
| 597 |
+
"recomputed_history_tokens": result["resource"]["recomputed_history_tokens"],
|
| 598 |
+
"mean_kv_gb": result["resource"]["mean_kv_gb"],
|
| 599 |
+
"hbm_gb_seconds": result["resource"]["hbm_gb_seconds"],
|
| 600 |
+
"pressure_evictions": result["resource"]["pressure_evictions"],
|
| 601 |
+
"ttl_evictions": result["resource"]["ttl_evictions"],
|
| 602 |
+
}
|
| 603 |
+
)
|
| 604 |
+
front = _pareto(rows, "p95_turn_ttft_ms", "mean_kv_gb")
|
| 605 |
+
# Collapse numerically equivalent frontier points to the shortest TTL. A
|
| 606 |
+
# longer retention horizon with indistinguishable latency/residency is not a
|
| 607 |
+
# distinct engineering trade-off.
|
| 608 |
+
unique_front: set[int] = set()
|
| 609 |
+
seen: set[tuple[float, float]] = set()
|
| 610 |
+
for idx in sorted(front, key=lambda i: rows[i]["ttl_s"]):
|
| 611 |
+
key = (round(rows[idx]["p95_turn_ttft_ms"], 6), round(rows[idx]["mean_kv_gb"], 6))
|
| 612 |
+
if key not in seen:
|
| 613 |
+
unique_front.add(idx)
|
| 614 |
+
seen.add(key)
|
| 615 |
+
for idx, row in enumerate(rows):
|
| 616 |
+
row["pareto"] = idx in unique_front
|
| 617 |
+
return {
|
| 618 |
+
"protocol": "common-agent-program-trace",
|
| 619 |
+
"objective": "minimize-p95-turn-ttft-and-mean-kv-residency",
|
| 620 |
+
"rows": rows,
|
| 621 |
+
"pareto_count": len(unique_front),
|
| 622 |
+
}
|
py/inferscale/api.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
| 3 |
from .optimizer import capacity_search, compare_schedulers, compare_topologies, design_space_search
|
| 4 |
from .profiles import ACCELERATORS, MODELS
|
| 5 |
from .research import STUDIES, paired_study, robustness_study
|
|
@@ -14,6 +15,7 @@ def metadata() -> dict:
|
|
| 14 |
"topologies": ["colocated", "disaggregated_pd"],
|
| 15 |
"research_studies": STUDIES,
|
| 16 |
"profile_type": "analytical-reference",
|
|
|
|
| 17 |
}
|
| 18 |
|
| 19 |
|
|
@@ -47,6 +49,12 @@ def execute(action: str, payload: dict) -> dict:
|
|
| 47 |
repetitions=int(payload.get("repetitions", 12)),
|
| 48 |
bootstrap_samples=int(payload.get("bootstrap_samples", 500)),
|
| 49 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
if action == "robustness_study":
|
| 51 |
config = payload.get("config", payload)
|
| 52 |
return robustness_study(
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
from .agentic import compare_agent_policies, run_agent_session_simulation, ttl_retention_sweep
|
| 4 |
from .optimizer import capacity_search, compare_schedulers, compare_topologies, design_space_search
|
| 5 |
from .profiles import ACCELERATORS, MODELS
|
| 6 |
from .research import STUDIES, paired_study, robustness_study
|
|
|
|
| 15 |
"topologies": ["colocated", "disaggregated_pd"],
|
| 16 |
"research_studies": STUDIES,
|
| 17 |
"profile_type": "analytical-reference",
|
| 18 |
+
"agentic_modes": ["session_simulation", "policy_compare", "ttl_sweep"],
|
| 19 |
}
|
| 20 |
|
| 21 |
|
|
|
|
| 49 |
repetitions=int(payload.get("repetitions", 12)),
|
| 50 |
bootstrap_samples=int(payload.get("bootstrap_samples", 500)),
|
| 51 |
)
|
| 52 |
+
if action == "agent_simulate":
|
| 53 |
+
return run_agent_session_simulation(payload.get("config", payload))
|
| 54 |
+
if action == "agent_compare":
|
| 55 |
+
return compare_agent_policies(payload.get("config", payload))
|
| 56 |
+
if action == "agent_ttl_sweep":
|
| 57 |
+
return ttl_retention_sweep(payload.get("config", payload), payload.get("ttl_values"))
|
| 58 |
if action == "robustness_study":
|
| 59 |
config = payload.get("config", payload)
|
| 60 |
return robustness_study(
|
pyproject.toml
CHANGED
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
| 4 |
|
| 5 |
[project]
|
| 6 |
name = "inferscale-sim"
|
| 7 |
-
version = "0.
|
| 8 |
description = "Interactive LLM serving simulator and SLO capacity planner"
|
| 9 |
readme = "README.md"
|
| 10 |
requires-python = ">=3.10"
|
|
|
|
| 4 |
|
| 5 |
[project]
|
| 6 |
name = "inferscale-sim"
|
| 7 |
+
version = "0.5.0"
|
| 8 |
description = "Interactive LLM serving simulator and SLO capacity planner"
|
| 9 |
readme = "README.md"
|
| 10 |
requires-python = ">=3.10"
|
scripts/release_check.py
CHANGED
|
@@ -13,8 +13,11 @@ sys.path.insert(0, str(SRC))
|
|
| 13 |
inferscale = importlib.import_module("inferscale")
|
| 14 |
internal_version = inferscale.__version__
|
| 15 |
design_space_search = inferscale.design_space_search
|
|
|
|
| 16 |
paired_study = inferscale.paired_study
|
| 17 |
robustness_study = inferscale.robustness_study
|
|
|
|
|
|
|
| 18 |
run_simulation = inferscale.run_simulation
|
| 19 |
validate_cases = inferscale.validate_cases
|
| 20 |
|
|
@@ -31,8 +34,8 @@ else:
|
|
| 31 |
|
| 32 |
if "sdk: static" not in README:
|
| 33 |
errors.append("README metadata must use sdk: static")
|
| 34 |
-
if internal_version != "0.
|
| 35 |
-
errors.append(f"internal package version is {internal_version}; expected 0.
|
| 36 |
|
| 37 |
# Public-facing release/version branding is intentionally absent. Model names
|
| 38 |
# such as Mistral-7B-v0.3 are allowed; project headings/badges are not.
|
|
@@ -71,7 +74,7 @@ if "Download PNG" not in index or ".chart-download" not in app:
|
|
| 71 |
errors.append("chart PNG export controls are missing")
|
| 72 |
if "Worst repetition" not in index or "Target" not in index:
|
| 73 |
errors.append("capacity evidence columns are missing")
|
| 74 |
-
for expected in ["Trace replay", "Research Studies", "Run paired study", "Stress-test
|
| 75 |
if expected not in index:
|
| 76 |
errors.append(f"UI is missing research/trace feature: {expected}")
|
| 77 |
|
|
@@ -164,6 +167,27 @@ try:
|
|
| 164 |
except Exception as exc: # pragma: no cover
|
| 165 |
errors.append(f"robustness study smoke test raised: {exc}")
|
| 166 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
try:
|
| 168 |
validation = validate_cases([{
|
| 169 |
"name": "release-fixture",
|
|
@@ -190,5 +214,8 @@ print(f"P/D transfer p95: {pd['resource']['p95_transfer_ms']:.3f} ms")
|
|
| 190 |
print(f"Design candidates: {design['candidate_count']}")
|
| 191 |
print(f"Paired-study metrics: {len(paired['metrics'])}")
|
| 192 |
print(f"Robustness perturbations: {len(robust['rows'])}")
|
|
|
|
|
|
|
|
|
|
| 193 |
print(f"Validation observations: {validation['observation_count']}")
|
| 194 |
print(f"Profile provenance: {smoke['provenance']['latency_profile_type']}")
|
|
|
|
| 13 |
inferscale = importlib.import_module("inferscale")
|
| 14 |
internal_version = inferscale.__version__
|
| 15 |
design_space_search = inferscale.design_space_search
|
| 16 |
+
compare_agent_policies = inferscale.compare_agent_policies
|
| 17 |
paired_study = inferscale.paired_study
|
| 18 |
robustness_study = inferscale.robustness_study
|
| 19 |
+
run_agent_session_simulation = inferscale.run_agent_session_simulation
|
| 20 |
+
ttl_retention_sweep = inferscale.ttl_retention_sweep
|
| 21 |
run_simulation = inferscale.run_simulation
|
| 22 |
validate_cases = inferscale.validate_cases
|
| 23 |
|
|
|
|
| 34 |
|
| 35 |
if "sdk: static" not in README:
|
| 36 |
errors.append("README metadata must use sdk: static")
|
| 37 |
+
if internal_version != "0.5.0":
|
| 38 |
+
errors.append(f"internal package version is {internal_version}; expected 0.5.0")
|
| 39 |
|
| 40 |
# Public-facing release/version branding is intentionally absent. Model names
|
| 41 |
# such as Mistral-7B-v0.3 are allowed; project headings/badges are not.
|
|
|
|
| 74 |
errors.append("chart PNG export controls are missing")
|
| 75 |
if "Worst repetition" not in index or "Target" not in index:
|
| 76 |
errors.append("capacity evidence columns are missing")
|
| 77 |
+
for expected in ["Trace replay", "Research Studies", "Run paired study", "Stress-test selected hypothesis", "Agent Sessions", "Session Policy Arena", "TTL frontier"]:
|
| 78 |
if expected not in index:
|
| 79 |
errors.append(f"UI is missing research/trace feature: {expected}")
|
| 80 |
|
|
|
|
| 167 |
except Exception as exc: # pragma: no cover
|
| 168 |
errors.append(f"robustness study smoke test raised: {exc}")
|
| 169 |
|
| 170 |
+
|
| 171 |
+
try:
|
| 172 |
+
agent_cfg = {
|
| 173 |
+
"model": "Qwen2.5-3B", "accelerator": "L4", "quantization": "int8",
|
| 174 |
+
"duration_s": 12, "session_rate_rps": 0.25, "replicas": 2, "seed": 7,
|
| 175 |
+
"retention_policy": "ttl", "routing_policy": "session_affinity", "kv_ttl_s": 3,
|
| 176 |
+
}
|
| 177 |
+
agent = run_agent_session_simulation(agent_cfg)
|
| 178 |
+
if agent["provenance"].get("mode") != "stateful-agent-session-simulation":
|
| 179 |
+
errors.append("agent-session provenance guard is missing")
|
| 180 |
+
if agent["summary"].get("turns_completed", 0) <= 0:
|
| 181 |
+
errors.append("agent-session smoke test completed zero turns")
|
| 182 |
+
agent_compare = compare_agent_policies(agent_cfg)
|
| 183 |
+
if agent_compare.get("candidate_count") != 4:
|
| 184 |
+
errors.append("agent policy arena did not return four candidates")
|
| 185 |
+
agent_ttl = ttl_retention_sweep(agent_cfg, [0, 1, 3])
|
| 186 |
+
if len(agent_ttl.get("rows", [])) != 3 or agent_ttl.get("pareto_count", 0) < 1:
|
| 187 |
+
errors.append("agent TTL frontier smoke test is incomplete")
|
| 188 |
+
except Exception as exc: # pragma: no cover
|
| 189 |
+
errors.append(f"agent-session smoke test raised: {exc}")
|
| 190 |
+
|
| 191 |
try:
|
| 192 |
validation = validate_cases([{
|
| 193 |
"name": "release-fixture",
|
|
|
|
| 214 |
print(f"Design candidates: {design['candidate_count']}")
|
| 215 |
print(f"Paired-study metrics: {len(paired['metrics'])}")
|
| 216 |
print(f"Robustness perturbations: {len(robust['rows'])}")
|
| 217 |
+
print(f"Agent turns: {agent['summary']['turns_completed']}")
|
| 218 |
+
print(f"Agent policy candidates: {agent_compare['candidate_count']}")
|
| 219 |
+
print(f"Agent TTL candidates: {len(agent_ttl['rows'])}")
|
| 220 |
print(f"Validation observations: {validation['observation_count']}")
|
| 221 |
print(f"Profile provenance: {smoke['provenance']['latency_profile_type']}")
|
src/inferscale/__init__.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 1 |
from .api import execute, metadata
|
| 2 |
from .models import SimulationConfig
|
| 3 |
from .optimizer import capacity_search, compare_schedulers, compare_topologies, design_space_search
|
|
@@ -7,6 +8,7 @@ from .validation import validate_cases
|
|
| 7 |
|
| 8 |
__all__ = [
|
| 9 |
"SimulationConfig",
|
|
|
|
| 10 |
"capacity_search",
|
| 11 |
"compare_schedulers",
|
| 12 |
"compare_topologies",
|
|
@@ -15,10 +17,12 @@ __all__ = [
|
|
| 15 |
"metadata",
|
| 16 |
"paired_study",
|
| 17 |
"robustness_study",
|
|
|
|
| 18 |
"run_simulation",
|
|
|
|
| 19 |
"validate_cases",
|
| 20 |
]
|
| 21 |
|
| 22 |
# Internal package metadata only; the public project intentionally avoids
|
| 23 |
# release/version branding in the interface and documentation.
|
| 24 |
-
__version__ = "0.
|
|
|
|
| 1 |
+
from .agentic import compare_agent_policies, run_agent_session_simulation, ttl_retention_sweep
|
| 2 |
from .api import execute, metadata
|
| 3 |
from .models import SimulationConfig
|
| 4 |
from .optimizer import capacity_search, compare_schedulers, compare_topologies, design_space_search
|
|
|
|
| 8 |
|
| 9 |
__all__ = [
|
| 10 |
"SimulationConfig",
|
| 11 |
+
"compare_agent_policies",
|
| 12 |
"capacity_search",
|
| 13 |
"compare_schedulers",
|
| 14 |
"compare_topologies",
|
|
|
|
| 17 |
"metadata",
|
| 18 |
"paired_study",
|
| 19 |
"robustness_study",
|
| 20 |
+
"run_agent_session_simulation",
|
| 21 |
"run_simulation",
|
| 22 |
+
"ttl_retention_sweep",
|
| 23 |
"validate_cases",
|
| 24 |
]
|
| 25 |
|
| 26 |
# Internal package metadata only; the public project intentionally avoids
|
| 27 |
# release/version branding in the interface and documentation.
|
| 28 |
+
__version__ = "0.5.0"
|
src/inferscale/agentic.py
ADDED
|
@@ -0,0 +1,622 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import heapq
|
| 4 |
+
import math
|
| 5 |
+
import random
|
| 6 |
+
from dataclasses import asdict, dataclass
|
| 7 |
+
from statistics import mean, median
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
from .latency import AnalyticalLatencyModel
|
| 11 |
+
from .metrics import percentile
|
| 12 |
+
from .profiles import get_accelerator, get_model
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
ROUTING_POLICIES = {"least_load", "session_affinity"}
|
| 16 |
+
RETENTION_POLICIES = {"evict", "retain", "ttl"}
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@dataclass
|
| 20 |
+
class AgentSessionConfig:
|
| 21 |
+
model: str = "Qwen2.5-3B"
|
| 22 |
+
accelerator: str = "L4"
|
| 23 |
+
quantization: str = "int8"
|
| 24 |
+
seed: int = 7
|
| 25 |
+
duration_s: float = 30.0
|
| 26 |
+
session_rate_rps: float = 0.45
|
| 27 |
+
replicas: int = 2
|
| 28 |
+
turns_mean: float = 4.0
|
| 29 |
+
turns_cv: float = 0.25
|
| 30 |
+
initial_prompt_tokens_mean: int = 640
|
| 31 |
+
append_tokens_mean: int = 180
|
| 32 |
+
token_cv: float = 0.35
|
| 33 |
+
output_tokens_mean: int = 72
|
| 34 |
+
output_tokens_cv: float = 0.45
|
| 35 |
+
tool_gap_mean_s: float = 1.5
|
| 36 |
+
tool_gap_cv: float = 0.75
|
| 37 |
+
retention_policy: str = "ttl"
|
| 38 |
+
kv_ttl_s: float = 3.0
|
| 39 |
+
routing_policy: str = "session_affinity"
|
| 40 |
+
kv_memory_fraction: float = 0.85
|
| 41 |
+
slo_turn_ttft_ms: float = 500.0
|
| 42 |
+
slo_session_e2e_ms: float = 30000.0
|
| 43 |
+
timeline_points: int = 240
|
| 44 |
+
|
| 45 |
+
@classmethod
|
| 46 |
+
def from_dict(cls, data: dict[str, Any]) -> "AgentSessionConfig":
|
| 47 |
+
allowed = cls.__dataclass_fields__.keys()
|
| 48 |
+
cfg = cls(**{k: data[k] for k in allowed if k in data})
|
| 49 |
+
if cfg.routing_policy not in ROUTING_POLICIES:
|
| 50 |
+
raise ValueError(f"Unsupported agent routing policy: {cfg.routing_policy}")
|
| 51 |
+
if cfg.retention_policy not in RETENTION_POLICIES:
|
| 52 |
+
raise ValueError(f"Unsupported KV retention policy: {cfg.retention_policy}")
|
| 53 |
+
cfg.replicas = max(1, min(int(cfg.replicas), 8))
|
| 54 |
+
cfg.session_rate_rps = max(float(cfg.session_rate_rps), 0.01)
|
| 55 |
+
cfg.duration_s = max(float(cfg.duration_s), 1.0)
|
| 56 |
+
cfg.kv_ttl_s = max(float(cfg.kv_ttl_s), 0.0)
|
| 57 |
+
cfg.kv_memory_fraction = min(max(float(cfg.kv_memory_fraction), 0.05), 0.98)
|
| 58 |
+
return cfg
|
| 59 |
+
|
| 60 |
+
def to_dict(self) -> dict[str, Any]:
|
| 61 |
+
return asdict(self)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
@dataclass
|
| 65 |
+
class TurnSpec:
|
| 66 |
+
session_id: int
|
| 67 |
+
turn_index: int
|
| 68 |
+
append_tokens: int
|
| 69 |
+
output_tokens: int
|
| 70 |
+
tool_gap_after_s: float
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
@dataclass
|
| 74 |
+
class SessionSpec:
|
| 75 |
+
session_id: int
|
| 76 |
+
arrival_time: float
|
| 77 |
+
initial_prompt_tokens: int
|
| 78 |
+
turns: list[TurnSpec]
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
@dataclass
|
| 82 |
+
class CacheEntry:
|
| 83 |
+
session_id: int
|
| 84 |
+
tokens: int
|
| 85 |
+
size_gb: float
|
| 86 |
+
last_access: float
|
| 87 |
+
expiry_time: float | None
|
| 88 |
+
generation: int
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
@dataclass
|
| 92 |
+
class ReplicaState:
|
| 93 |
+
replica_id: int
|
| 94 |
+
busy: bool = False
|
| 95 |
+
busy_until: float = 0.0
|
| 96 |
+
queue: list[tuple[int, TurnSpec, float]] | None = None
|
| 97 |
+
cache: dict[int, CacheEntry] | None = None
|
| 98 |
+
current_session: int | None = None
|
| 99 |
+
|
| 100 |
+
def __post_init__(self) -> None:
|
| 101 |
+
if self.queue is None:
|
| 102 |
+
self.queue = []
|
| 103 |
+
if self.cache is None:
|
| 104 |
+
self.cache = {}
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
@dataclass
|
| 108 |
+
class SessionRuntime:
|
| 109 |
+
spec: SessionSpec
|
| 110 |
+
context_tokens: int
|
| 111 |
+
completed_turns: int = 0
|
| 112 |
+
completion_time: float | None = None
|
| 113 |
+
last_replica: int | None = None
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def _sample_positive_lognormal(rng: random.Random, mean_value: float, cv: float, minimum: float) -> float:
|
| 117 |
+
mean_value = max(float(mean_value), minimum)
|
| 118 |
+
cv = max(float(cv), 0.0)
|
| 119 |
+
if mean_value <= 0.0:
|
| 120 |
+
return minimum
|
| 121 |
+
if cv <= 1e-12:
|
| 122 |
+
return mean_value
|
| 123 |
+
sigma2 = math.log1p(cv * cv)
|
| 124 |
+
sigma = math.sqrt(sigma2)
|
| 125 |
+
mu = math.log(mean_value) - sigma2 / 2.0
|
| 126 |
+
return max(minimum, rng.lognormvariate(mu, sigma))
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def _sample_int(rng: random.Random, mean_value: float, cv: float, minimum: int = 1) -> int:
|
| 130 |
+
return max(minimum, int(round(_sample_positive_lognormal(rng, mean_value, cv, minimum))))
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def generate_agent_sessions(cfg: AgentSessionConfig) -> list[SessionSpec]:
|
| 134 |
+
"""Generate a deterministic multi-turn program trace.
|
| 135 |
+
|
| 136 |
+
All policies consume this same trace when the seed/config are unchanged. Tool
|
| 137 |
+
gaps are sampled up front so policy comparisons use common random numbers.
|
| 138 |
+
"""
|
| 139 |
+
rng = random.Random(cfg.seed ^ 0xA63E17)
|
| 140 |
+
sessions: list[SessionSpec] = []
|
| 141 |
+
now = 0.0
|
| 142 |
+
sid = 0
|
| 143 |
+
while True:
|
| 144 |
+
now += rng.expovariate(cfg.session_rate_rps)
|
| 145 |
+
if now > cfg.duration_s:
|
| 146 |
+
break
|
| 147 |
+
turns = max(2, _sample_int(rng, cfg.turns_mean, cfg.turns_cv, 2))
|
| 148 |
+
initial = _sample_int(rng, cfg.initial_prompt_tokens_mean, cfg.token_cv, 32)
|
| 149 |
+
specs = []
|
| 150 |
+
for turn_idx in range(turns):
|
| 151 |
+
append = 0 if turn_idx == 0 else _sample_int(rng, cfg.append_tokens_mean, cfg.token_cv, 8)
|
| 152 |
+
output = _sample_int(rng, cfg.output_tokens_mean, cfg.output_tokens_cv, 1)
|
| 153 |
+
gap = 0.0 if turn_idx == turns - 1 else _sample_positive_lognormal(
|
| 154 |
+
rng, cfg.tool_gap_mean_s, cfg.tool_gap_cv, 0.0
|
| 155 |
+
)
|
| 156 |
+
specs.append(TurnSpec(sid, turn_idx, append, output, gap))
|
| 157 |
+
sessions.append(SessionSpec(sid, now, initial, specs))
|
| 158 |
+
sid += 1
|
| 159 |
+
return sessions
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
class AgentSessionSimulator:
|
| 163 |
+
"""Discrete-event simulator for stateful multi-turn serving.
|
| 164 |
+
|
| 165 |
+
The module deliberately isolates session locality / KV-retention effects from
|
| 166 |
+
dynamic batching. Each replica is a serial analytical service station; the
|
| 167 |
+
existing Serving Lab remains the place to study continuous batching. This
|
| 168 |
+
separation keeps the agent experiment interpretable while still modelling
|
| 169 |
+
session dependencies, tool gaps, routing, memory pressure, and cross-turn KV.
|
| 170 |
+
"""
|
| 171 |
+
|
| 172 |
+
def __init__(self, cfg: AgentSessionConfig, sessions: list[SessionSpec] | None = None):
|
| 173 |
+
self.cfg = cfg
|
| 174 |
+
self.model = get_model(cfg.model)
|
| 175 |
+
self.accelerator = get_accelerator(cfg.accelerator)
|
| 176 |
+
self.latency = AnalyticalLatencyModel(self.model, self.accelerator, cfg.quantization)
|
| 177 |
+
remaining = max(0.0, self.accelerator.vram_gb - self.latency.model_weight_gb - 1.2)
|
| 178 |
+
self.kv_capacity_gb = remaining * cfg.kv_memory_fraction
|
| 179 |
+
self.replicas = [ReplicaState(i) for i in range(cfg.replicas)]
|
| 180 |
+
specs = sessions if sessions is not None else generate_agent_sessions(cfg)
|
| 181 |
+
self.sessions = {s.session_id: SessionRuntime(s, s.initial_prompt_tokens) for s in specs}
|
| 182 |
+
self.events: list[tuple[float, int, str, tuple[Any, ...]]] = []
|
| 183 |
+
self.event_seq = 0
|
| 184 |
+
self.now = 0.0
|
| 185 |
+
self.turn_rows: list[dict[str, Any]] = []
|
| 186 |
+
self.timeline: list[dict[str, Any]] = []
|
| 187 |
+
self.peak_kv_gb = 0.0
|
| 188 |
+
self.hbm_gb_seconds = 0.0
|
| 189 |
+
self.last_memory_time = 0.0
|
| 190 |
+
self.pressure_evictions = 0
|
| 191 |
+
self.ttl_evictions = 0
|
| 192 |
+
self.recompute_tokens = 0
|
| 193 |
+
self.cache_hits = 0
|
| 194 |
+
self.cache_eligible_turns = 0
|
| 195 |
+
self.affinity_routes = 0
|
| 196 |
+
self.route_opportunities = 0
|
| 197 |
+
self.failed_turns = 0
|
| 198 |
+
self.tool_gap_total_s = 0.0
|
| 199 |
+
for session in specs:
|
| 200 |
+
if session.turns:
|
| 201 |
+
self._push(session.arrival_time, "turn_ready", session.session_id, 0)
|
| 202 |
+
|
| 203 |
+
def _push(self, when: float, kind: str, *payload: Any) -> None:
|
| 204 |
+
self.event_seq += 1
|
| 205 |
+
heapq.heappush(self.events, (float(when), self.event_seq, kind, tuple(payload)))
|
| 206 |
+
|
| 207 |
+
def _used_gb(self) -> float:
|
| 208 |
+
return sum(entry.size_gb for replica in self.replicas for entry in replica.cache.values())
|
| 209 |
+
|
| 210 |
+
def _integrate_memory(self, new_time: float) -> None:
|
| 211 |
+
new_time = max(float(new_time), self.last_memory_time)
|
| 212 |
+
used = self._used_gb()
|
| 213 |
+
self.hbm_gb_seconds += used * (new_time - self.last_memory_time)
|
| 214 |
+
self.last_memory_time = new_time
|
| 215 |
+
self.peak_kv_gb = max(self.peak_kv_gb, used)
|
| 216 |
+
|
| 217 |
+
def _record_timeline(self) -> None:
|
| 218 |
+
max_points = max(int(self.cfg.timeline_points), 40)
|
| 219 |
+
if self.timeline and self.now - self.timeline[-1]["time_s"] < max(self.cfg.duration_s / max_points, 0.05):
|
| 220 |
+
return
|
| 221 |
+
self.timeline.append(
|
| 222 |
+
{
|
| 223 |
+
"time_s": self.now,
|
| 224 |
+
"queued_turns": sum(len(r.queue) for r in self.replicas),
|
| 225 |
+
"busy_replicas": sum(1 for r in self.replicas if r.busy),
|
| 226 |
+
"kv_used_gb": self._used_gb(),
|
| 227 |
+
"resident_sessions": sum(len(r.cache) for r in self.replicas),
|
| 228 |
+
}
|
| 229 |
+
)
|
| 230 |
+
if len(self.timeline) > max_points * 2:
|
| 231 |
+
self.timeline = self.timeline[::2]
|
| 232 |
+
|
| 233 |
+
def _entry(self, replica: ReplicaState, session_id: int) -> CacheEntry | None:
|
| 234 |
+
return replica.cache.get(session_id)
|
| 235 |
+
|
| 236 |
+
def _cache_replica(self, session_id: int) -> ReplicaState | None:
|
| 237 |
+
for replica in self.replicas:
|
| 238 |
+
if session_id in replica.cache:
|
| 239 |
+
return replica
|
| 240 |
+
return None
|
| 241 |
+
|
| 242 |
+
def _route(self, session_id: int) -> ReplicaState:
|
| 243 |
+
cached = self._cache_replica(session_id)
|
| 244 |
+
if cached is not None:
|
| 245 |
+
self.route_opportunities += 1
|
| 246 |
+
if self.cfg.routing_policy == "session_affinity" and cached is not None:
|
| 247 |
+
self.affinity_routes += 1
|
| 248 |
+
return cached
|
| 249 |
+
# Approximate least-finish-time routing using queued work count then busy horizon.
|
| 250 |
+
return min(self.replicas, key=lambda r: (len(r.queue), max(r.busy_until, self.now), r.replica_id))
|
| 251 |
+
|
| 252 |
+
def _remove_cache(self, replica: ReplicaState, session_id: int, reason: str) -> None:
|
| 253 |
+
if session_id not in replica.cache:
|
| 254 |
+
return
|
| 255 |
+
self._integrate_memory(self.now)
|
| 256 |
+
del replica.cache[session_id]
|
| 257 |
+
if reason == "pressure":
|
| 258 |
+
self.pressure_evictions += 1
|
| 259 |
+
elif reason == "ttl":
|
| 260 |
+
self.ttl_evictions += 1
|
| 261 |
+
self._record_timeline()
|
| 262 |
+
|
| 263 |
+
def _ensure_capacity(self, replica: ReplicaState, session_id: int, target_gb: float) -> bool:
|
| 264 |
+
current = replica.cache.get(session_id)
|
| 265 |
+
current_gb = current.size_gb if current else 0.0
|
| 266 |
+
additional = max(0.0, target_gb - current_gb)
|
| 267 |
+
if additional <= 1e-12:
|
| 268 |
+
return True
|
| 269 |
+
# Capacity is per replica; evict least-recently-used inactive session KV.
|
| 270 |
+
while sum(e.size_gb for e in replica.cache.values()) + additional > self.kv_capacity_gb + 1e-12:
|
| 271 |
+
victims = [e for sid, e in replica.cache.items() if sid != session_id]
|
| 272 |
+
if not victims:
|
| 273 |
+
return False
|
| 274 |
+
victim = min(victims, key=lambda e: (e.last_access, e.session_id))
|
| 275 |
+
self._remove_cache(replica, victim.session_id, "pressure")
|
| 276 |
+
return True
|
| 277 |
+
|
| 278 |
+
def _put_cache(self, replica: ReplicaState, session_id: int, tokens: int, keep: bool) -> bool:
|
| 279 |
+
size_gb = tokens * self.latency.kv_bytes_per_token() / 1e9
|
| 280 |
+
if size_gb > self.kv_capacity_gb + 1e-12:
|
| 281 |
+
return False
|
| 282 |
+
if not self._ensure_capacity(replica, session_id, size_gb):
|
| 283 |
+
return False
|
| 284 |
+
self._integrate_memory(self.now)
|
| 285 |
+
previous = replica.cache.get(session_id)
|
| 286 |
+
generation = (previous.generation + 1) if previous else 1
|
| 287 |
+
expiry: float | None = None
|
| 288 |
+
if keep and self.cfg.retention_policy == "ttl":
|
| 289 |
+
expiry = self.now + self.cfg.kv_ttl_s
|
| 290 |
+
replica.cache[session_id] = CacheEntry(session_id, tokens, size_gb, self.now, expiry, generation)
|
| 291 |
+
self.peak_kv_gb = max(self.peak_kv_gb, self._used_gb())
|
| 292 |
+
if expiry is not None:
|
| 293 |
+
self._push(expiry, "cache_expire", replica.replica_id, session_id, generation)
|
| 294 |
+
self._record_timeline()
|
| 295 |
+
return True
|
| 296 |
+
|
| 297 |
+
def _start_next(self, replica: ReplicaState) -> None:
|
| 298 |
+
if replica.busy or not replica.queue:
|
| 299 |
+
return
|
| 300 |
+
session_id, turn, ready_time = replica.queue.pop(0)
|
| 301 |
+
runtime = self.sessions[session_id]
|
| 302 |
+
cache = self._entry(replica, session_id)
|
| 303 |
+
hit = turn.turn_index > 0 and cache is not None
|
| 304 |
+
if turn.turn_index > 0:
|
| 305 |
+
self.cache_eligible_turns += 1
|
| 306 |
+
if hit:
|
| 307 |
+
self.cache_hits += 1
|
| 308 |
+
prefill_tokens = max(turn.append_tokens, 1)
|
| 309 |
+
else:
|
| 310 |
+
prefill_tokens = max(runtime.context_tokens + turn.append_tokens, 1)
|
| 311 |
+
if turn.turn_index > 0:
|
| 312 |
+
self.recompute_tokens += runtime.context_tokens
|
| 313 |
+
|
| 314 |
+
context_before_decode = runtime.context_tokens + turn.append_tokens
|
| 315 |
+
projected_tokens = context_before_decode + turn.output_tokens
|
| 316 |
+
projected_gb = projected_tokens * self.latency.kv_bytes_per_token() / 1e9
|
| 317 |
+
if not self._ensure_capacity(replica, session_id, projected_gb):
|
| 318 |
+
self.failed_turns += 1
|
| 319 |
+
self._push(self.now, "turn_failed", session_id, turn.turn_index, replica.replica_id, ready_time)
|
| 320 |
+
self._start_next(replica)
|
| 321 |
+
return
|
| 322 |
+
|
| 323 |
+
# Model the current turn's KV as resident while it executes, even when
|
| 324 |
+
# the selected policy will evict it immediately after the turn.
|
| 325 |
+
if not self._put_cache(replica, session_id, projected_tokens, keep=False):
|
| 326 |
+
self.failed_turns += 1
|
| 327 |
+
self._push(self.now, "turn_failed", session_id, turn.turn_index, replica.replica_id, ready_time)
|
| 328 |
+
self._start_next(replica)
|
| 329 |
+
return
|
| 330 |
+
|
| 331 |
+
prefill_s = self.latency.prefill_seconds([prefill_tokens])
|
| 332 |
+
first_decode_s = self.latency.decode_step_seconds([context_before_decode])
|
| 333 |
+
midpoint = context_before_decode + max(turn.output_tokens // 2, 1)
|
| 334 |
+
decode_step_s = self.latency.decode_step_seconds([midpoint])
|
| 335 |
+
service_s = prefill_s + turn.output_tokens * decode_step_s
|
| 336 |
+
ttft_s = (self.now - ready_time) + prefill_s + first_decode_s
|
| 337 |
+
|
| 338 |
+
replica.busy = True
|
| 339 |
+
replica.current_session = session_id
|
| 340 |
+
replica.busy_until = self.now + service_s
|
| 341 |
+
self._push(
|
| 342 |
+
replica.busy_until,
|
| 343 |
+
"turn_complete",
|
| 344 |
+
session_id,
|
| 345 |
+
turn.turn_index,
|
| 346 |
+
replica.replica_id,
|
| 347 |
+
ready_time,
|
| 348 |
+
hit,
|
| 349 |
+
prefill_tokens,
|
| 350 |
+
ttft_s,
|
| 351 |
+
service_s,
|
| 352 |
+
projected_tokens,
|
| 353 |
+
)
|
| 354 |
+
|
| 355 |
+
def _finish_turn(
|
| 356 |
+
self,
|
| 357 |
+
session_id: int,
|
| 358 |
+
turn_index: int,
|
| 359 |
+
replica_id: int,
|
| 360 |
+
ready_time: float,
|
| 361 |
+
cache_hit: bool,
|
| 362 |
+
prefill_tokens: int,
|
| 363 |
+
ttft_s: float,
|
| 364 |
+
service_s: float,
|
| 365 |
+
projected_tokens: int,
|
| 366 |
+
) -> None:
|
| 367 |
+
replica = self.replicas[replica_id]
|
| 368 |
+
runtime = self.sessions[session_id]
|
| 369 |
+
turn = runtime.spec.turns[turn_index]
|
| 370 |
+
runtime.context_tokens = projected_tokens
|
| 371 |
+
runtime.completed_turns += 1
|
| 372 |
+
runtime.last_replica = replica_id
|
| 373 |
+
replica.busy = False
|
| 374 |
+
replica.current_session = None
|
| 375 |
+
replica.busy_until = self.now
|
| 376 |
+
|
| 377 |
+
is_final = turn_index == len(runtime.spec.turns) - 1
|
| 378 |
+
keep = (not is_final) and self.cfg.retention_policy != "evict"
|
| 379 |
+
if keep:
|
| 380 |
+
self._put_cache(replica, session_id, projected_tokens, keep=True)
|
| 381 |
+
else:
|
| 382 |
+
self._remove_cache(replica, session_id, "complete")
|
| 383 |
+
|
| 384 |
+
e2e_ms = (self.now - ready_time) * 1000.0
|
| 385 |
+
self.turn_rows.append(
|
| 386 |
+
{
|
| 387 |
+
"session_id": session_id,
|
| 388 |
+
"turn_index": turn_index + 1,
|
| 389 |
+
"replica": replica_id,
|
| 390 |
+
"ready_time": ready_time,
|
| 391 |
+
"completion_time": self.now,
|
| 392 |
+
"cache_hit": cache_hit,
|
| 393 |
+
"prefill_tokens": prefill_tokens,
|
| 394 |
+
"context_tokens_after": projected_tokens,
|
| 395 |
+
"output_tokens": turn.output_tokens,
|
| 396 |
+
"ttft_ms": ttft_s * 1000.0,
|
| 397 |
+
"e2e_ms": e2e_ms,
|
| 398 |
+
"queue_ms": max(0.0, e2e_ms - service_s * 1000.0),
|
| 399 |
+
}
|
| 400 |
+
)
|
| 401 |
+
|
| 402 |
+
if is_final:
|
| 403 |
+
runtime.completion_time = self.now
|
| 404 |
+
else:
|
| 405 |
+
self.tool_gap_total_s += turn.tool_gap_after_s
|
| 406 |
+
self._push(self.now + turn.tool_gap_after_s, "turn_ready", session_id, turn_index + 1)
|
| 407 |
+
self._start_next(replica)
|
| 408 |
+
|
| 409 |
+
def run(self) -> dict[str, Any]:
|
| 410 |
+
self._record_timeline()
|
| 411 |
+
while self.events:
|
| 412 |
+
event_time, _, kind, payload = heapq.heappop(self.events)
|
| 413 |
+
self._integrate_memory(event_time)
|
| 414 |
+
self.now = event_time
|
| 415 |
+
if kind == "turn_ready":
|
| 416 |
+
session_id, turn_index = int(payload[0]), int(payload[1])
|
| 417 |
+
turn = self.sessions[session_id].spec.turns[turn_index]
|
| 418 |
+
replica = self._route(session_id)
|
| 419 |
+
replica.queue.append((session_id, turn, self.now))
|
| 420 |
+
self._start_next(replica)
|
| 421 |
+
elif kind == "turn_complete":
|
| 422 |
+
self._finish_turn(
|
| 423 |
+
int(payload[0]), int(payload[1]), int(payload[2]), float(payload[3]), bool(payload[4]),
|
| 424 |
+
int(payload[5]), float(payload[6]), float(payload[7]), int(payload[8]),
|
| 425 |
+
)
|
| 426 |
+
elif kind == "cache_expire":
|
| 427 |
+
replica_id, session_id, generation = map(int, payload)
|
| 428 |
+
replica = self.replicas[replica_id]
|
| 429 |
+
entry = replica.cache.get(session_id)
|
| 430 |
+
if entry is not None and entry.generation == generation and entry.expiry_time is not None and entry.expiry_time <= self.now + 1e-12:
|
| 431 |
+
self._remove_cache(replica, session_id, "ttl")
|
| 432 |
+
elif kind == "turn_failed":
|
| 433 |
+
session_id, turn_index, replica_id, ready_time = int(payload[0]), int(payload[1]), int(payload[2]), float(payload[3])
|
| 434 |
+
self.turn_rows.append({
|
| 435 |
+
"session_id": session_id, "turn_index": turn_index + 1, "replica": replica_id,
|
| 436 |
+
"ready_time": ready_time, "completion_time": self.now, "cache_hit": False,
|
| 437 |
+
"prefill_tokens": 0, "context_tokens_after": self.sessions[session_id].context_tokens,
|
| 438 |
+
"output_tokens": 0, "ttft_ms": 0.0, "e2e_ms": 0.0, "queue_ms": 0.0, "failed": True,
|
| 439 |
+
})
|
| 440 |
+
self._record_timeline()
|
| 441 |
+
|
| 442 |
+
self._integrate_memory(self.now)
|
| 443 |
+
self._record_timeline()
|
| 444 |
+
completed_sessions = [s for s in self.sessions.values() if s.completion_time is not None]
|
| 445 |
+
successful_turns = [row for row in self.turn_rows if not row.get("failed")]
|
| 446 |
+
ttfts = [float(row["ttft_ms"]) for row in successful_turns]
|
| 447 |
+
turn_e2e = [float(row["e2e_ms"]) for row in successful_turns]
|
| 448 |
+
session_e2e = [
|
| 449 |
+
(s.completion_time - s.spec.arrival_time) * 1000.0
|
| 450 |
+
for s in completed_sessions
|
| 451 |
+
if s.completion_time is not None
|
| 452 |
+
]
|
| 453 |
+
session_slo = sum(1 for v in session_e2e if v <= self.cfg.slo_session_e2e_ms)
|
| 454 |
+
turn_slo = sum(1 for v in ttfts if v <= self.cfg.slo_turn_ttft_ms)
|
| 455 |
+
completion_horizon = max([s.completion_time or 0.0 for s in self.sessions.values()] + [self.cfg.duration_s, 1e-9])
|
| 456 |
+
mean_kv_gb = self.hbm_gb_seconds / completion_horizon
|
| 457 |
+
summary = {
|
| 458 |
+
"sessions_generated": len(self.sessions),
|
| 459 |
+
"sessions_completed": len(completed_sessions),
|
| 460 |
+
"turns_completed": len(successful_turns),
|
| 461 |
+
"turns_failed": self.failed_turns,
|
| 462 |
+
"session_throughput_rps": len(completed_sessions) / completion_horizon,
|
| 463 |
+
"turn_throughput_rps": len(successful_turns) / completion_horizon,
|
| 464 |
+
"turn_ttft_slo_attainment": turn_slo / len(successful_turns) if successful_turns else 0.0,
|
| 465 |
+
"session_slo_attainment": session_slo / len(completed_sessions) if completed_sessions else 0.0,
|
| 466 |
+
"simulated_makespan_s": completion_horizon,
|
| 467 |
+
}
|
| 468 |
+
latency = {
|
| 469 |
+
"turn_ttft_ms": {"p50": percentile(ttfts, 0.50), "p95": percentile(ttfts, 0.95), "p99": percentile(ttfts, 0.99)},
|
| 470 |
+
"turn_e2e_ms": {"p50": percentile(turn_e2e, 0.50), "p95": percentile(turn_e2e, 0.95), "p99": percentile(turn_e2e, 0.99)},
|
| 471 |
+
"session_e2e_ms": {"p50": percentile(session_e2e, 0.50), "p95": percentile(session_e2e, 0.95), "p99": percentile(session_e2e, 0.99)},
|
| 472 |
+
}
|
| 473 |
+
resource = {
|
| 474 |
+
"replicas": self.cfg.replicas,
|
| 475 |
+
"kv_capacity_gb_per_replica": self.kv_capacity_gb,
|
| 476 |
+
"peak_kv_gb": self.peak_kv_gb,
|
| 477 |
+
"mean_kv_gb": mean_kv_gb,
|
| 478 |
+
"hbm_gb_seconds": self.hbm_gb_seconds,
|
| 479 |
+
"cross_turn_cache_hits": self.cache_hits,
|
| 480 |
+
"cross_turn_cache_eligible": self.cache_eligible_turns,
|
| 481 |
+
"cross_turn_cache_hit_rate": self.cache_hits / self.cache_eligible_turns if self.cache_eligible_turns else 0.0,
|
| 482 |
+
"routing_locality_rate": self.affinity_routes / self.route_opportunities if self.route_opportunities else 0.0,
|
| 483 |
+
"recomputed_history_tokens": self.recompute_tokens,
|
| 484 |
+
"pressure_evictions": self.pressure_evictions,
|
| 485 |
+
"ttl_evictions": self.ttl_evictions,
|
| 486 |
+
"tool_gap_total_s": self.tool_gap_total_s,
|
| 487 |
+
}
|
| 488 |
+
return {
|
| 489 |
+
"config": self.cfg.to_dict(),
|
| 490 |
+
"provenance": {
|
| 491 |
+
"simulator": "InferScale-Sim",
|
| 492 |
+
"mode": "stateful-agent-session-simulation",
|
| 493 |
+
"latency_profile_type": "analytical-reference",
|
| 494 |
+
"agent_service_model": "serial-per-replica-reference",
|
| 495 |
+
"warning": "Agent-session mode isolates routing/KV-retention effects and does not model dynamic batching within each replica.",
|
| 496 |
+
},
|
| 497 |
+
"summary": summary,
|
| 498 |
+
"latency": latency,
|
| 499 |
+
"resource": resource,
|
| 500 |
+
"turns": successful_turns[:3000],
|
| 501 |
+
"sessions": [
|
| 502 |
+
{
|
| 503 |
+
"session_id": s.spec.session_id,
|
| 504 |
+
"arrival_time": s.spec.arrival_time,
|
| 505 |
+
"turns": len(s.spec.turns),
|
| 506 |
+
"completion_time": s.completion_time,
|
| 507 |
+
"e2e_ms": (s.completion_time - s.spec.arrival_time) * 1000.0 if s.completion_time is not None else None,
|
| 508 |
+
}
|
| 509 |
+
for s in list(self.sessions.values())[:1000]
|
| 510 |
+
],
|
| 511 |
+
"timeline": self.timeline,
|
| 512 |
+
}
|
| 513 |
+
|
| 514 |
+
|
| 515 |
+
def run_agent_session_simulation(config: dict[str, Any], sessions: list[SessionSpec] | None = None) -> dict[str, Any]:
|
| 516 |
+
cfg = AgentSessionConfig.from_dict(config)
|
| 517 |
+
return AgentSessionSimulator(cfg, sessions=sessions).run()
|
| 518 |
+
|
| 519 |
+
|
| 520 |
+
def _same_trace(cfg: AgentSessionConfig) -> list[SessionSpec]:
|
| 521 |
+
return generate_agent_sessions(cfg)
|
| 522 |
+
|
| 523 |
+
|
| 524 |
+
def compare_agent_policies(config: dict[str, Any]) -> dict[str, Any]:
|
| 525 |
+
base = AgentSessionConfig.from_dict(config)
|
| 526 |
+
trace = _same_trace(base)
|
| 527 |
+
policies = [
|
| 528 |
+
("Stateless / least-load", "evict", "least_load", 0.0),
|
| 529 |
+
("Retain / least-load", "retain", "least_load", base.kv_ttl_s),
|
| 530 |
+
("TTL / affinity", "ttl", "session_affinity", base.kv_ttl_s),
|
| 531 |
+
("Retain / affinity", "retain", "session_affinity", base.kv_ttl_s),
|
| 532 |
+
]
|
| 533 |
+
rows = []
|
| 534 |
+
results = []
|
| 535 |
+
for label, retention, routing, ttl in policies:
|
| 536 |
+
cfg = AgentSessionConfig.from_dict(base.to_dict())
|
| 537 |
+
cfg.retention_policy = retention
|
| 538 |
+
cfg.routing_policy = routing
|
| 539 |
+
cfg.kv_ttl_s = ttl
|
| 540 |
+
result = run_agent_session_simulation(cfg.to_dict(), trace)
|
| 541 |
+
results.append(result)
|
| 542 |
+
rows.append(
|
| 543 |
+
{
|
| 544 |
+
"label": label,
|
| 545 |
+
"retention": retention,
|
| 546 |
+
"routing": routing,
|
| 547 |
+
"p95_turn_ttft_ms": result["latency"]["turn_ttft_ms"]["p95"],
|
| 548 |
+
"p95_session_e2e_ms": result["latency"]["session_e2e_ms"]["p95"],
|
| 549 |
+
"cache_hit_rate": result["resource"]["cross_turn_cache_hit_rate"],
|
| 550 |
+
"routing_locality_rate": result["resource"]["routing_locality_rate"],
|
| 551 |
+
"recomputed_history_tokens": result["resource"]["recomputed_history_tokens"],
|
| 552 |
+
"peak_kv_gb": result["resource"]["peak_kv_gb"],
|
| 553 |
+
"mean_kv_gb": result["resource"]["mean_kv_gb"],
|
| 554 |
+
"hbm_gb_seconds": result["resource"]["hbm_gb_seconds"],
|
| 555 |
+
"pressure_evictions": result["resource"]["pressure_evictions"],
|
| 556 |
+
"ttl_evictions": result["resource"]["ttl_evictions"],
|
| 557 |
+
"sessions_completed": result["summary"]["sessions_completed"],
|
| 558 |
+
}
|
| 559 |
+
)
|
| 560 |
+
return {"protocol": "common-agent-program-trace", "candidate_count": len(rows), "rows": rows, "results": results}
|
| 561 |
+
|
| 562 |
+
|
| 563 |
+
def _pareto(rows: list[dict[str, Any]], x: str, y: str) -> set[int]:
|
| 564 |
+
# Both x and y are minimized.
|
| 565 |
+
front: set[int] = set()
|
| 566 |
+
for idx, row in enumerate(rows):
|
| 567 |
+
dominated = False
|
| 568 |
+
for jdx, other in enumerate(rows):
|
| 569 |
+
if idx == jdx:
|
| 570 |
+
continue
|
| 571 |
+
if other[x] <= row[x] and other[y] <= row[y] and (other[x] < row[x] or other[y] < row[y]):
|
| 572 |
+
dominated = True
|
| 573 |
+
break
|
| 574 |
+
if not dominated:
|
| 575 |
+
front.add(idx)
|
| 576 |
+
return front
|
| 577 |
+
|
| 578 |
+
|
| 579 |
+
def ttl_retention_sweep(config: dict[str, Any], ttl_values: list[float] | None = None) -> dict[str, Any]:
|
| 580 |
+
base = AgentSessionConfig.from_dict(config)
|
| 581 |
+
base.retention_policy = "ttl"
|
| 582 |
+
base.routing_policy = "session_affinity"
|
| 583 |
+
trace = _same_trace(base)
|
| 584 |
+
values = ttl_values or [0.0, 0.25, 0.5, 1.0, 2.0, 3.0, 5.0, 8.0, 13.0]
|
| 585 |
+
cleaned = sorted({max(0.0, min(float(v), 120.0)) for v in values})
|
| 586 |
+
rows = []
|
| 587 |
+
for ttl in cleaned:
|
| 588 |
+
cfg = AgentSessionConfig.from_dict(base.to_dict())
|
| 589 |
+
cfg.kv_ttl_s = ttl
|
| 590 |
+
result = run_agent_session_simulation(cfg.to_dict(), trace)
|
| 591 |
+
rows.append(
|
| 592 |
+
{
|
| 593 |
+
"ttl_s": ttl,
|
| 594 |
+
"p95_turn_ttft_ms": result["latency"]["turn_ttft_ms"]["p95"],
|
| 595 |
+
"p95_session_e2e_ms": result["latency"]["session_e2e_ms"]["p95"],
|
| 596 |
+
"cache_hit_rate": result["resource"]["cross_turn_cache_hit_rate"],
|
| 597 |
+
"recomputed_history_tokens": result["resource"]["recomputed_history_tokens"],
|
| 598 |
+
"mean_kv_gb": result["resource"]["mean_kv_gb"],
|
| 599 |
+
"hbm_gb_seconds": result["resource"]["hbm_gb_seconds"],
|
| 600 |
+
"pressure_evictions": result["resource"]["pressure_evictions"],
|
| 601 |
+
"ttl_evictions": result["resource"]["ttl_evictions"],
|
| 602 |
+
}
|
| 603 |
+
)
|
| 604 |
+
front = _pareto(rows, "p95_turn_ttft_ms", "mean_kv_gb")
|
| 605 |
+
# Collapse numerically equivalent frontier points to the shortest TTL. A
|
| 606 |
+
# longer retention horizon with indistinguishable latency/residency is not a
|
| 607 |
+
# distinct engineering trade-off.
|
| 608 |
+
unique_front: set[int] = set()
|
| 609 |
+
seen: set[tuple[float, float]] = set()
|
| 610 |
+
for idx in sorted(front, key=lambda i: rows[i]["ttl_s"]):
|
| 611 |
+
key = (round(rows[idx]["p95_turn_ttft_ms"], 6), round(rows[idx]["mean_kv_gb"], 6))
|
| 612 |
+
if key not in seen:
|
| 613 |
+
unique_front.add(idx)
|
| 614 |
+
seen.add(key)
|
| 615 |
+
for idx, row in enumerate(rows):
|
| 616 |
+
row["pareto"] = idx in unique_front
|
| 617 |
+
return {
|
| 618 |
+
"protocol": "common-agent-program-trace",
|
| 619 |
+
"objective": "minimize-p95-turn-ttft-and-mean-kv-residency",
|
| 620 |
+
"rows": rows,
|
| 621 |
+
"pareto_count": len(unique_front),
|
| 622 |
+
}
|
src/inferscale/api.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
| 3 |
from .optimizer import capacity_search, compare_schedulers, compare_topologies, design_space_search
|
| 4 |
from .profiles import ACCELERATORS, MODELS
|
| 5 |
from .research import STUDIES, paired_study, robustness_study
|
|
@@ -14,6 +15,7 @@ def metadata() -> dict:
|
|
| 14 |
"topologies": ["colocated", "disaggregated_pd"],
|
| 15 |
"research_studies": STUDIES,
|
| 16 |
"profile_type": "analytical-reference",
|
|
|
|
| 17 |
}
|
| 18 |
|
| 19 |
|
|
@@ -47,6 +49,12 @@ def execute(action: str, payload: dict) -> dict:
|
|
| 47 |
repetitions=int(payload.get("repetitions", 12)),
|
| 48 |
bootstrap_samples=int(payload.get("bootstrap_samples", 500)),
|
| 49 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
if action == "robustness_study":
|
| 51 |
config = payload.get("config", payload)
|
| 52 |
return robustness_study(
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
from .agentic import compare_agent_policies, run_agent_session_simulation, ttl_retention_sweep
|
| 4 |
from .optimizer import capacity_search, compare_schedulers, compare_topologies, design_space_search
|
| 5 |
from .profiles import ACCELERATORS, MODELS
|
| 6 |
from .research import STUDIES, paired_study, robustness_study
|
|
|
|
| 15 |
"topologies": ["colocated", "disaggregated_pd"],
|
| 16 |
"research_studies": STUDIES,
|
| 17 |
"profile_type": "analytical-reference",
|
| 18 |
+
"agentic_modes": ["session_simulation", "policy_compare", "ttl_sweep"],
|
| 19 |
}
|
| 20 |
|
| 21 |
|
|
|
|
| 49 |
repetitions=int(payload.get("repetitions", 12)),
|
| 50 |
bootstrap_samples=int(payload.get("bootstrap_samples", 500)),
|
| 51 |
)
|
| 52 |
+
if action == "agent_simulate":
|
| 53 |
+
return run_agent_session_simulation(payload.get("config", payload))
|
| 54 |
+
if action == "agent_compare":
|
| 55 |
+
return compare_agent_policies(payload.get("config", payload))
|
| 56 |
+
if action == "agent_ttl_sweep":
|
| 57 |
+
return ttl_retention_sweep(payload.get("config", payload), payload.get("ttl_values"))
|
| 58 |
if action == "robustness_study":
|
| 59 |
config = payload.get("config", payload)
|
| 60 |
return robustness_study(
|
styles.css
CHANGED
|
@@ -312,3 +312,13 @@ input[type="file"]::file-selector-button {
|
|
| 312 |
.study-summary strong { color: #e1e7ee; }
|
| 313 |
.research-chart { height: 310px; }
|
| 314 |
@media (max-width: 1100px) { .research-grid { grid-template-columns: 1fr; } .research-controls { position: static; } }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 312 |
.study-summary strong { color: #e1e7ee; }
|
| 313 |
.research-chart { height: 310px; }
|
| 314 |
@media (max-width: 1100px) { .research-grid { grid-template-columns: 1fr; } .research-controls { position: static; } }
|
| 315 |
+
|
| 316 |
+
/* Stateful agent-session experiments */
|
| 317 |
+
.agent-layout { display: grid; grid-template-columns: 420px minmax(0, 1fr); gap: 14px; align-items: start; }
|
| 318 |
+
.agent-controls { padding: 20px; position: sticky; top: 16px; }
|
| 319 |
+
.agent-results { display: grid; gap: 14px; min-width: 0; }
|
| 320 |
+
.metric-grid.six-agent { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
| 321 |
+
@media (max-width: 1250px) { .metric-grid.six-agent { grid-template-columns: repeat(3, 1fr); } }
|
| 322 |
+
@media (max-width: 1100px) { .agent-layout { grid-template-columns: 1fr; } .agent-controls { position: static; } }
|
| 323 |
+
@media (max-width: 720px) { .metric-grid.six-agent { grid-template-columns: 1fr 1fr; } }
|
| 324 |
+
.field-disabled { opacity: .55; }
|
tests/test_agentic.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from inferscale.agentic import compare_agent_policies, run_agent_session_simulation, ttl_retention_sweep
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
BASE = {
|
| 5 |
+
"model": "Qwen2.5-3B",
|
| 6 |
+
"accelerator": "L4",
|
| 7 |
+
"quantization": "int8",
|
| 8 |
+
"duration_s": 30,
|
| 9 |
+
"session_rate_rps": 0.20,
|
| 10 |
+
"replicas": 2,
|
| 11 |
+
"turns_mean": 4,
|
| 12 |
+
"tool_gap_mean_s": 1.5,
|
| 13 |
+
"seed": 7,
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def test_agent_session_run_is_deterministic():
|
| 18 |
+
first = run_agent_session_simulation(BASE | {"retention_policy": "ttl", "routing_policy": "session_affinity", "kv_ttl_s": 3})
|
| 19 |
+
second = run_agent_session_simulation(BASE | {"retention_policy": "ttl", "routing_policy": "session_affinity", "kv_ttl_s": 3})
|
| 20 |
+
assert first["summary"] == second["summary"]
|
| 21 |
+
assert first["latency"] == second["latency"]
|
| 22 |
+
assert first["resource"] == second["resource"]
|
| 23 |
+
assert first["provenance"]["mode"] == "stateful-agent-session-simulation"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def test_evict_policy_has_no_cross_turn_hits():
|
| 27 |
+
result = run_agent_session_simulation(BASE | {"retention_policy": "evict", "routing_policy": "least_load"})
|
| 28 |
+
assert result["resource"]["cross_turn_cache_hit_rate"] == 0
|
| 29 |
+
assert result["resource"]["recomputed_history_tokens"] > 0
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def test_retention_with_affinity_reuses_state():
|
| 33 |
+
baseline = run_agent_session_simulation(BASE | {"retention_policy": "evict", "routing_policy": "least_load"})
|
| 34 |
+
retained = run_agent_session_simulation(BASE | {"retention_policy": "retain", "routing_policy": "session_affinity"})
|
| 35 |
+
assert retained["resource"]["cross_turn_cache_hit_rate"] > baseline["resource"]["cross_turn_cache_hit_rate"]
|
| 36 |
+
assert retained["resource"]["recomputed_history_tokens"] < baseline["resource"]["recomputed_history_tokens"]
|
| 37 |
+
assert retained["resource"]["mean_kv_gb"] >= baseline["resource"]["mean_kv_gb"]
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def test_policy_compare_uses_four_common_trace_candidates():
|
| 41 |
+
result = compare_agent_policies(BASE | {"kv_ttl_s": 3})
|
| 42 |
+
assert result["protocol"] == "common-agent-program-trace"
|
| 43 |
+
assert result["candidate_count"] == 4
|
| 44 |
+
assert {row["label"] for row in result["rows"]} == {
|
| 45 |
+
"Stateless / least-load",
|
| 46 |
+
"Retain / least-load",
|
| 47 |
+
"TTL / affinity",
|
| 48 |
+
"Retain / affinity",
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def test_ttl_sweep_reports_latency_memory_frontier():
|
| 53 |
+
result = ttl_retention_sweep(BASE, [0, 1, 3, 8])
|
| 54 |
+
assert result["protocol"] == "common-agent-program-trace"
|
| 55 |
+
assert result["objective"] == "minimize-p95-turn-ttft-and-mean-kv-residency"
|
| 56 |
+
assert result["pareto_count"] >= 1
|
| 57 |
+
assert len(result["rows"]) == 4
|
| 58 |
+
assert any(row["pareto"] for row in result["rows"])
|
worker.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { loadPyodide } from "https://cdn.jsdelivr.net/pyodide/v314.0.5/full/pyod
|
|
| 2 |
|
| 3 |
const MODULES = [
|
| 4 |
"__init__.py",
|
|
|
|
| 5 |
"api.py",
|
| 6 |
"diagnostics.py",
|
| 7 |
"disaggregated.py",
|
|
|
|
| 2 |
|
| 3 |
const MODULES = [
|
| 4 |
"__init__.py",
|
| 5 |
+
"agentic.py",
|
| 6 |
"api.py",
|
| 7 |
"diagnostics.py",
|
| 8 |
"disaggregated.py",
|