ArchitSharma commited on
Commit
e5c4ee4
·
1 Parent(s): ce2d64b

Deepen InferScale simulation research workflow

Browse files
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
- | metrics / search / research |
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 conclusion";
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. `validation.py` compares predictions against externally supplied measured cases.
17
- 11. `api.py` exposes JSON-like actions to local Python and Pyodide.
 
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 uses this as a roadmap boundary: exact trace replay exists today, while session identity, tool gaps, and cross-turn residency are explicit future work rather than being approximated silently.
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
- - multi-turn/agentic session fidelity
 
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 conclusion</button>
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.4.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.4.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.4.0":
35
- errors.append(f"internal package version is {internal_version}; expected 0.4.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 conclusion"]:
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.4.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",