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

Deepen InferScale simulation research workflow

Browse files
README.md CHANGED
@@ -71,7 +71,7 @@ arrival_time,prompt_tokens,output_tokens
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
 
@@ -109,11 +109,14 @@ Run a bounded live sweep across scheduler, batch size, prefix caching, and P/D w
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
 
@@ -216,12 +219,26 @@ Agent-session mode follows a program-level event trace:
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:
 
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/host GB-seconds, TTL/pressure evictions, and explicit host-tier transfer latency for stateful sessions
75
 
76
  ## Interactive experiments
77
 
 
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
+ Six 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 simple stateless/retention baselines.
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
+ 4. **Agent Memory Lab:** compare HBM retention, bounded affinity, host-memory offload, and an explicitly labeled oracle gap-aware tiering upper bound.
118
+ 5. **Finite-HBM budget stress:** derive per-replica KV budgets from the trace's unconstrained working set and rerun tiering policies under deliberate memory pressure.
119
+ 6. **Affinity Frontier:** sweep the queue-delay slack that bounded-affinity routing is willing to pay for cache locality.
120
 
121
  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.
122
 
 
219
  session arrival -> turn 1 -> tool gap -> turn 2 -> ... -> completion
220
  ```
221
 
222
+ 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, retained under a TTL, or moved to a modeled host-memory tier. Host offload/restore pays an explicit bandwidth + base-latency transfer cost. A bounded-affinity router only follows cached state while the estimated queue imbalance stays under a configurable slack; this avoids treating cache locality as unconditionally preferable. 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.
223
 
224
+ The public app reports **HBM GB-seconds** and **host GB-seconds** in addition to peak/mean occupancy and transfer volume. This makes both retaining and offloading state visible rather than treating cache hits or a host tier as free.
225
 
226
  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.
227
 
228
+ ### Host-tier offload and bounded affinity
229
+
230
+ For `offload` retention, idle session KV is moved from the modeled HBM tier into finite host memory. The reference transfer model is explicit:
231
+
232
+ ```text
233
+ host_transfer = base_latency + KV_size / host_bandwidth
234
+ ```
235
+
236
+ If the next agent turn returns before offload has completed, the remaining transfer time becomes exposed before restore. Host residency, offloaded/restored bytes, and p95 transfer latency are reported separately from HBM residency.
237
+
238
+ `bounded_affinity` is a transparent middle ground between strict session affinity and least-load routing. InferScale follows the replica holding the session KV only while its estimated extra queue penalty is below `affinity_slack_ms`. This is meant to study the locality/load-balance tension, not reproduce a particular production scheduler.
239
+
240
+ `gap_aware` tiering uses the **realized simulated tool gap** to retain short-gap state in HBM and offload longer-gap state. Because a real serving system does not know the future perfectly, InferScale labels this policy as an **oracle upper bound** rather than a deployable predictor.
241
+
242
  ## Empirical validation hook
243
 
244
  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
@@ -13,6 +13,9 @@ let lastRobustStudy = null;
13
  let lastAgentRun = null;
14
  let lastAgentCompare = null;
15
  let lastAgentTtl = null;
 
 
 
16
  let traceRequests = [];
17
 
18
  const COLORS = {
@@ -22,6 +25,7 @@ const COLORS = {
22
  green: "#69c99a",
23
  amber: "#dfb966",
24
  red: "#df7d89",
 
25
  gray: "#667384",
26
  grid: "rgba(140,155,175,.14)",
27
  };
@@ -35,7 +39,7 @@ worker.addEventListener("message", (event) => {
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;
@@ -771,6 +775,11 @@ function agentConfigFromUI(overrides = {}) {
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,
@@ -786,14 +795,16 @@ function renderAgentRun(result) {
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");
@@ -801,7 +812,8 @@ function renderAgentRun(result) {
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
  ],
@@ -884,12 +896,112 @@ $("agentTtlBtn").addEventListener("click", async () => {
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) {
 
13
  let lastAgentRun = null;
14
  let lastAgentCompare = null;
15
  let lastAgentTtl = null;
16
+ let lastAgentMemory = null;
17
+ let lastAgentBudget = null;
18
+ let lastAgentAffinity = null;
19
  let traceRequests = [];
20
 
21
  const COLORS = {
 
25
  green: "#69c99a",
26
  amber: "#dfb966",
27
  red: "#df7d89",
28
+ purple: "#9b8cff",
29
  gray: "#667384",
30
  grid: "rgba(140,155,175,.14)",
31
  };
 
39
  if (data.type === "ready") {
40
  runtimePill.classList.add("ready");
41
  runtimeText.textContent = "Python runtime ready";
42
+ ["runBtn", "arenaBtn", "capacityBtn", "topologyBtn", "designBtn", "pairedStudyBtn", "robustStudyBtn", "agentRunBtn", "agentCompareBtn", "agentTtlBtn", "agentMemoryCompareBtn", "agentBudgetBtn", "agentAffinityBtn"].forEach((id) => { $(id).disabled = false; });
43
  syncConditionalControls();
44
  window.setTimeout(() => document.body.classList.add("runtime-ready"), 650);
45
  return;
 
775
  routing_policy: $("agentRouting").value,
776
  kv_ttl_s: num("agentTtl"),
777
  kv_memory_fraction: num("agentKvFraction"),
778
+ affinity_slack_ms: num("agentAffinitySlack"),
779
+ gap_aware_threshold_s: num("agentGapThreshold"),
780
+ host_memory_gb: num("agentHostMemory"),
781
+ host_bandwidth_gbps: num("agentHostBandwidth"),
782
+ host_transfer_base_ms: num("agentHostBase"),
783
  slo_turn_ttft_ms: num("agentTtftSlo"),
784
  slo_session_e2e_ms: num("agentSessionSlo"),
785
  ...overrides,
 
795
  const sm = result.summary;
796
  $("agentTtft").textContent = `${fmt(l.turn_ttft_ms.p95)} ms`;
797
  $("agentSessionE2e").textContent = `${fmt(l.session_e2e_ms.p95)} ms`;
798
+ $("agentSessionSloValue").textContent = pct(sm.session_slo_attainment);
799
  $("agentCacheHit").textContent = pct(r.cross_turn_cache_hit_rate);
800
  $("agentRecompute").textContent = `${fmt(r.recomputed_history_tokens, 0)} tok`;
801
  $("agentPeakKv").textContent = `${fmt(r.peak_kv_gb, 3)} GB`;
802
+ $("agentHostMeanKv").textContent = `${fmt(r.mean_host_kv_gb, 3)} GB`;
803
+ $("agentHostTransfer").textContent = `${fmt(r.p95_host_transfer_ms)} ms`;
804
  const state = $("agentRunState");
805
  state.textContent = `${sm.sessions_completed}/${sm.sessions_generated} sessions`;
806
  state.className = `tag ${sm.sessions_completed === sm.sessions_generated && sm.turns_failed === 0 ? "good" : "bad"}`;
807
+ $("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. Reuse hit ${pct(r.cross_turn_cache_hit_rate)} of eligible turns; ${pct(r.host_cache_hit_rate)} came from the host tier. ${fmt(r.recomputed_history_tokens, 0)} history tokens were recomputed. HBM and host residency are simulator-side memory-time accounting metrics.`;
808
 
809
  const timeline = result.timeline || [];
810
  destroyChart("agentTimeline");
 
812
  type: "line",
813
  data: {
814
  datasets: [
815
+ { label: "HBM 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" },
816
+ { label: "Host KV GB", data: timeline.map((x) => ({ x: x.time_s, y: x.host_kv_gb || 0 })), borderColor: COLORS.steel, backgroundColor: COLORS.steel, pointRadius: 0, borderDash: [6, 4], tension: .08, yAxisID: "yKv" },
817
  { 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" },
818
  { 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" },
819
  ],
 
896
  $("agentTtlCopy").addEventListener("click", () => { if (lastAgentTtl) copyText(tableText(agentTtlHeaders, agentTtlTableRows(lastAgentTtl)), "TTL sweep copied"); });
897
  $("agentTtlCsv").addEventListener("click", () => { if (lastAgentTtl) downloadCsv(`inferscale_agent-ttl-frontier_${stamp()}.csv`, agentTtlHeaders, agentTtlTableRows(lastAgentTtl)); });
898
 
899
+
900
+ const agentMemoryHeaders = ["Policy", "p95 turn TTFT", "p95 session E2E", "Session SLO", "Reuse", "HBM hit", "Host hit", "Mean HBM", "Mean host", "Transfer p95", "Recomputed"];
901
+ function agentMemoryTableRows(result) {
902
+ return result.rows.map((r) => [r.label, `${fmt(r.p95_turn_ttft_ms)} ms`, `${fmt(r.p95_session_e2e_ms)} ms`, pct(r.session_slo_attainment), pct(r.cache_hit_rate), pct(r.hbm_hit_rate), pct(r.host_hit_rate), `${fmt(r.mean_hbm_gb, 3)} GB`, `${fmt(r.mean_host_gb, 3)} GB`, `${fmt(r.p95_host_transfer_ms)} ms`, `${fmt(r.recomputed_history_tokens, 0)} tok`]);
903
+ }
904
+ function renderAgentMemory(result) {
905
+ lastAgentMemory = result;
906
+ $("agentMemoryEmpty").classList.add("hidden");
907
+ $("agentMemoryContent").classList.remove("hidden");
908
+ $("agentMemoryCompareBlock").classList.remove("hidden");
909
+ ["agentMemoryCopy", "agentMemoryCsv"].forEach((id) => { $(id).disabled = false; });
910
+ $("agentMemoryRows").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.session_slo_attainment)}</td><td>${pct(r.cache_hit_rate)}</td><td>${pct(r.hbm_hit_rate)}</td><td>${pct(r.host_hit_rate)}</td><td>${fmt(r.mean_hbm_gb, 3)} GB</td><td>${fmt(r.mean_host_gb, 3)} GB</td><td>${fmt(r.p95_host_transfer_ms)} ms</td><td>${fmt(r.recomputed_history_tokens, 0)} tok</td></tr>`).join("");
911
+ const palette = [COLORS.gray, COLORS.amber, COLORS.green, COLORS.blue, COLORS.purple];
912
+ destroyChart("agentMemory");
913
+ charts.agentMemory = new Chart($("agentMemoryChart"), {
914
+ type: "scatter",
915
+ data: { datasets: result.rows.map((r, i) => ({ label: r.label, data: [{ x: r.mean_hbm_gb, y: r.p95_turn_ttft_ms }], backgroundColor: palette[i], borderColor: palette[i], pointRadius: 7, pointHoverRadius: 9 })) },
916
+ options: { ...commonChartOptions(), plugins: { legend: pointLegend() }, scales: { x: { beginAtZero: true, title: { display: true, text: "Mean HBM KV (GB)" } }, y: { beginAtZero: true, title: { display: true, text: "p95 turn TTFT (ms)" } } } },
917
+ });
918
+ }
919
+ $("agentMemoryCompareBtn").addEventListener("click", async () => {
920
+ const button = $("agentMemoryCompareBtn"); button.disabled = true; button.textContent = "Comparing policies...";
921
+ try { renderAgentMemory(await callPython("agent_memory_compare", { config: agentConfigFromUI() })); }
922
+ catch (error) { alert(`Memory policy comparison failed: ${error.message}`); }
923
+ finally { button.disabled = false; button.textContent = "Compare memory policies"; }
924
+ });
925
+ $("agentMemoryCopy").addEventListener("click", () => { if (lastAgentMemory) copyText(tableText(agentMemoryHeaders, agentMemoryTableRows(lastAgentMemory)), "Memory-policy table copied"); });
926
+ $("agentMemoryCsv").addEventListener("click", () => { if (lastAgentMemory) downloadCsv(`inferscale_agent-memory-policies_${stamp()}.csv`, agentMemoryHeaders, agentMemoryTableRows(lastAgentMemory)); });
927
+
928
+ const agentBudgetHeaders = ["Policy", "Budget / replica", "Reference x", "p95 TTFT", "Session SLO", "Reuse", "Mean HBM", "Mean host", "Pressure evictions", "Failed turns"];
929
+ function agentBudgetTableRows(result) {
930
+ return result.rows.map((r) => [r.policy, `${fmt(r.budget_gb_per_replica, 4)} GB`, `${fmt(r.budget_multiplier, 2)}x`, `${fmt(r.p95_turn_ttft_ms)} ms`, pct(r.session_slo_attainment), pct(r.cache_hit_rate), `${fmt(r.mean_hbm_gb, 4)} GB`, `${fmt(r.mean_host_gb, 4)} GB`, r.pressure_evictions, r.turns_failed]);
931
+ }
932
+ function renderAgentBudget(result) {
933
+ lastAgentBudget = result;
934
+ $("agentMemoryEmpty").classList.add("hidden");
935
+ $("agentMemoryContent").classList.remove("hidden");
936
+ $("agentBudgetBlock").classList.remove("hidden");
937
+ ["agentBudgetCopy", "agentBudgetCsv"].forEach((id) => { $(id).disabled = false; });
938
+ $("agentBudgetCaption").textContent = `HBM budget sweep (reference peak ${fmt(result.reference_peak_replica_kv_gb, 4)} GB / replica)`;
939
+ $("agentBudgetRows").innerHTML = result.rows.map((r) => `<tr><td>${escapeHtml(r.policy)}</td><td>${fmt(r.budget_gb_per_replica, 4)} GB</td><td>${fmt(r.budget_multiplier, 2)}x</td><td>${fmt(r.p95_turn_ttft_ms)} ms</td><td>${pct(r.session_slo_attainment)}</td><td>${pct(r.cache_hit_rate)}</td><td>${fmt(r.mean_hbm_gb, 4)} GB</td><td>${fmt(r.mean_host_gb, 4)} GB</td><td>${r.pressure_evictions}</td><td>${r.turns_failed}</td></tr>`).join("");
940
+ const policies = [...new Set(result.rows.map((r) => r.policy))];
941
+ const palette = [COLORS.green, COLORS.blue, COLORS.purple];
942
+ destroyChart("agentBudget");
943
+ charts.agentBudget = new Chart($("agentBudgetChart"), {
944
+ type: "line",
945
+ data: { datasets: policies.map((policy, i) => ({ label: policy, data: result.rows.filter((r) => r.policy === policy).sort((a, b) => a.budget_gb_per_replica - b.budget_gb_per_replica).map((r) => ({ x: r.budget_gb_per_replica, y: r.p95_turn_ttft_ms })), borderColor: palette[i], backgroundColor: palette[i], pointRadius: 4, tension: .12, fill: false })) },
946
+ options: { ...commonChartOptions(), parsing: false, plugins: { legend: lineLegend() }, scales: { x: { type: "linear", beginAtZero: true, title: { display: true, text: "HBM KV budget per replica (GB)" } }, y: { beginAtZero: true, title: { display: true, text: "p95 turn TTFT (ms)" } } } },
947
+ });
948
+ }
949
+ $("agentBudgetBtn").addEventListener("click", async () => {
950
+ const button = $("agentBudgetBtn"); button.disabled = true; button.textContent = "Stressing HBM budget...";
951
+ try { renderAgentBudget(await callPython("agent_memory_sweep", { config: agentConfigFromUI() })); }
952
+ catch (error) { alert(`HBM budget study failed: ${error.message}`); }
953
+ finally { button.disabled = false; button.textContent = "Stress HBM budget"; }
954
+ });
955
+ $("agentBudgetCopy").addEventListener("click", () => { if (lastAgentBudget) copyText(tableText(agentBudgetHeaders, agentBudgetTableRows(lastAgentBudget)), "HBM budget table copied"); });
956
+ $("agentBudgetCsv").addEventListener("click", () => { if (lastAgentBudget) downloadCsv(`inferscale_agent-hbm-budget_${stamp()}.csv`, agentBudgetHeaders, agentBudgetTableRows(lastAgentBudget)); });
957
+
958
+ const agentAffinityHeaders = ["Affinity slack", "p95 turn TTFT", "p95 session E2E", "Route locality", "Cache hit", "Recomputed", "Session SLO", "Pressure evictions"];
959
+ function agentAffinityTableRows(result) {
960
+ return result.rows.map((r) => [`${fmt(r.affinity_slack_ms, 0)} ms`, `${fmt(r.p95_turn_ttft_ms)} ms`, `${fmt(r.p95_session_e2e_ms)} ms`, pct(r.routing_locality_rate), pct(r.cache_hit_rate), `${fmt(r.recomputed_history_tokens, 0)} tok`, pct(r.session_slo_attainment), r.pressure_evictions]);
961
+ }
962
+ function renderAgentAffinity(result) {
963
+ lastAgentAffinity = result;
964
+ $("agentAffinityEmpty").classList.add("hidden");
965
+ $("agentAffinityContent").classList.remove("hidden");
966
+ ["agentAffinityCopy", "agentAffinityCsv"].forEach((id) => { $(id).disabled = false; });
967
+ $("agentAffinityRows").innerHTML = result.rows.map((r) => `<tr><td>${fmt(r.affinity_slack_ms, 0)} ms</td><td>${fmt(r.p95_turn_ttft_ms)} ms</td><td>${fmt(r.p95_session_e2e_ms)} ms</td><td>${pct(r.routing_locality_rate)}</td><td>${pct(r.cache_hit_rate)}</td><td>${fmt(r.recomputed_history_tokens, 0)} tok</td><td>${pct(r.session_slo_attainment)}</td><td>${r.pressure_evictions}</td></tr>`).join("");
968
+ destroyChart("agentAffinity");
969
+ charts.agentAffinity = new Chart($("agentAffinityChart"), {
970
+ type: "line",
971
+ data: { datasets: [
972
+ { label: "p95 session E2E", data: result.rows.map((r) => ({ x: r.affinity_slack_ms, y: r.p95_session_e2e_ms })), borderColor: COLORS.blue, backgroundColor: COLORS.blue, pointRadius: 4, tension: .12, yAxisID: "yLatency", fill: false },
973
+ { label: "Routing locality", data: result.rows.map((r) => ({ x: r.affinity_slack_ms, y: r.routing_locality_rate * 100 })), borderColor: COLORS.green, backgroundColor: COLORS.green, pointRadius: 4, tension: .12, yAxisID: "yLocality", fill: false },
974
+ ] },
975
+ options: { ...commonChartOptions(), parsing: false, plugins: { legend: lineLegend() }, scales: { x: { type: "linear", beginAtZero: true, title: { display: true, text: "Affinity slack (ms)" } }, yLatency: { position: "left", beginAtZero: true, title: { display: true, text: "p95 session E2E (ms)" } }, yLocality: { position: "right", min: 0, max: 100, grid: { drawOnChartArea: false }, title: { display: true, text: "Routing locality (%)" } } } },
976
+ });
977
+ }
978
+ $("agentAffinityBtn").addEventListener("click", async () => {
979
+ const button = $("agentAffinityBtn"); button.disabled = true; button.textContent = "Sweeping affinity...";
980
+ try { renderAgentAffinity(await callPython("agent_affinity_sweep", { config: agentConfigFromUI() })); }
981
+ catch (error) { alert(`Affinity sweep failed: ${error.message}`); }
982
+ finally { button.disabled = false; button.textContent = "Run affinity sweep"; }
983
+ });
984
+ $("agentAffinityCopy").addEventListener("click", () => { if (lastAgentAffinity) copyText(tableText(agentAffinityHeaders, agentAffinityTableRows(lastAgentAffinity)), "Affinity sweep copied"); });
985
+ $("agentAffinityCsv").addEventListener("click", () => { if (lastAgentAffinity) downloadCsv(`inferscale_agent-affinity-frontier_${stamp()}.csv`, agentAffinityHeaders, agentAffinityTableRows(lastAgentAffinity)); });
986
+
987
  function syncAgentControls() {
988
+ const retention = $("agentRetention").value;
989
+ const routing = $("agentRouting").value;
990
+ const ttl = retention === "ttl";
991
+ const hostTier = retention === "offload" || retention === "gap_aware";
992
+ const gapAware = retention === "gap_aware";
993
+ const bounded = routing === "bounded_affinity";
994
  $("agentTtl").disabled = !ttl;
995
  $("agentTtlLabel").classList.toggle("field-disabled", !ttl);
996
+ $("agentAffinitySlack").disabled = !bounded;
997
+ $("agentAffinitySlackLabel").classList.toggle("field-disabled", !bounded);
998
+ $("agentGapThreshold").disabled = !gapAware;
999
+ $("agentGapThresholdLabel").classList.toggle("field-disabled", !gapAware);
1000
+ ["agentHostMemory", "agentHostBandwidth", "agentHostBase"].forEach((id) => { $(id).disabled = !hostTier; });
1001
+ ["agentHostMemoryLabel", "agentHostBandwidthLabel", "agentHostBaseLabel"].forEach((id) => { $(id).classList.toggle("field-disabled", !hostTier); });
1002
  }
1003
  $("agentRetention").addEventListener("change", syncAgentControls);
1004
+ $("agentRouting").addEventListener("change", syncAgentControls);
1005
  syncAgentControls();
1006
 
1007
  function normalizeTraceRows(rows) {
docs/architecture.md CHANGED
@@ -85,3 +85,21 @@ The simulator therefore runs away from the browser main thread and uses the same
85
  - `kv_bytes_per_token()`
86
 
87
  without rewriting workload generation, scheduling, cache logic, P/D orchestration, SLO metrics, or research protocols.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  - `kv_bytes_per_token()`
86
 
87
  without rewriting workload generation, scheduling, cache logic, P/D orchestration, SLO metrics, or research protocols.
88
+
89
+ ## Agent memory tiering
90
+
91
+ Stateful Agent Sessions has an additional memory path that is independent from the stateless/P-D simulator:
92
+
93
+ ```text
94
+ turn completes
95
+ |
96
+ +-- retain HBM --------------------------+
97
+ | |
98
+ +-- TTL -> expire / pressure evict | next turn
99
+ | |
100
+ +-- host offload -> host KV -> restore --+
101
+ | |
102
+ +-- evict -> history recomputation ------+
103
+ ```
104
+
105
+ A global host tier models capacity, residency, offload/restore volume, and transfer latency. A bounded-affinity router can trade cached-replica locality against estimated queue imbalance.
docs/methodology.md CHANGED
@@ -150,3 +150,36 @@ The TTL sweep replays one identical agent-program trace for every TTL and report
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.
151
 
152
  No measured benchmark values are bundled as truth with the project.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.
151
 
152
  No measured benchmark values are bundled as truth with the project.
153
+
154
+ ## Stateful host-tier KV model
155
+
156
+ Agent-session experiments can move idle cross-turn KV from the modeled HBM tier to a host-memory tier. Transfer time is represented as:
157
+
158
+ ```text
159
+ transfer_time = base_latency + KV_size / host_bandwidth
160
+ ```
161
+
162
+ Offload begins when an LLM turn completes and can overlap the simulated tool gap. If the next turn becomes ready before offload completion, the remaining offload time is exposed before restore. Restore is serialized with the turn service model in this reference implementation. Host capacity is finite and uses LRU-style pressure eviction.
163
+
164
+ This is a deliberately transparent what-if model, not a PCIe/NVLink/NIXL/DMA simulator. Its purpose is to compare three costs under the same program trace:
165
+
166
+ - recompute history after eviction;
167
+ - retain KV in scarce HBM during tool gaps;
168
+ - offload KV and pay data movement on reuse.
169
+
170
+ ## Bounded-affinity routing
171
+
172
+ Strict session affinity always routes a turn to the replica that already holds its HBM KV. Least-load routing ignores locality. Bounded affinity interpolates between them:
173
+
174
+ 1. find the least-loaded replica;
175
+ 2. find the replica holding the session KV, if any;
176
+ 3. estimate the extra queue/busy-horizon penalty of following locality;
177
+ 4. keep affinity only if that penalty is at most `affinity_slack_ms`.
178
+
179
+ The estimator is intentionally simple and labeled as such. The Affinity Frontier sweeps the slack on one common program trace to show where additional locality stops being worth the load imbalance.
180
+
181
+ ## Finite-HBM budget stress
182
+
183
+ The memory-budget experiment first runs a full-retention reference trace and records the unconstrained peak per-replica KV working set. It then expresses stress budgets as multiples of that trace-specific peak rather than arbitrary fractions of total accelerator VRAM. This makes the experiment meaningful even for small models whose default VRAM headroom would otherwise be far larger than the generated KV working set.
184
+
185
+ Failed turns and incomplete sessions count against SLO attainment; latency percentiles remain conditional on turns/sessions that actually complete and are accompanied by failure counts in the experiment table.
docs/research.md CHANGED
@@ -106,3 +106,19 @@ Source: https://arxiv.org/abs/2608.15127
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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
109
+
110
+ ## IdleKV / tool-call idle-window offloading (June 2026)
111
+
112
+ Recent agent-serving work explicitly exploits tool-call idle windows to move KV state out of scarce GPU memory, because agent tool gaps can be long enough to hide part of the offload cost. InferScale models a smaller version of this idea with an explicit host-memory tier, offload/restore bandwidth, base transfer latency, host residency, and exposed restore delay when the state is needed again.
113
+
114
+ The model is intentionally not packet- or DMA-level: it is a transparent what-if abstraction for comparing recomputation, HBM retention, and host offload.
115
+
116
+ Source: https://arxiv.org/abs/2606.00866
117
+
118
+ ## SMetric / locality-aware scheduling under load (July 2026)
119
+
120
+ SMetric reports that aggressively routing agent turns to replicas that already cache their KV can overload a subset of replicas and leave others underused. This motivates treating cache locality and load balancing as a joint scheduling problem rather than using strict affinity everywhere.
121
+
122
+ InferScale's **bounded-affinity** policy is a deliberately simple baseline: it follows the cached replica only while its estimated queue penalty remains within a configurable slack. The Affinity Frontier then sweeps that slack on one common trace.
123
+
124
+ Source: https://arxiv.org/abs/2607.08565
index.html CHANGED
@@ -296,7 +296,7 @@
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">
@@ -363,10 +363,15 @@
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>
@@ -380,13 +385,15 @@
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">
@@ -427,6 +434,42 @@
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>
@@ -436,11 +479,11 @@
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>
443
- <article class="panel prose wide-method"><div class="section-kicker">Research lineage</div><h2>Research lineage and scope</h2><p>Vidur established the value of simulation for avoiding expensive deployment sweeps. Recent systems have pushed toward heterogeneous and disaggregated serving, communication-aware modeling, stateful workloads, trace replay, and SLA-dependent design-space exploration. InferScale-Sim remains intentionally smaller and inspectable, with paired experiments and sensitivity analysis built into the workflow.</p><div class="paper-grid"><div><strong>Vidur / 2024</strong><span>Predictive profiling, workload-aware serving simulation, configuration search.</span></div><div><strong>TokenSim / 2025</strong><span>Extensible scheduling and memory-management simulation.</span></div><div><strong>Revati / 2026</strong><span>GPU-free time-warp emulation of serving control logic.</span></div><div><strong>LLMServingSim 2.0 / 2026</strong><span>Heterogeneous and disaggregated infrastructure, memory and communication.</span></div><div><strong>Frontier / May 2026</strong><span>P/D disaggregation, runtime optimizations, stateful workloads, Pareto exploration.</span></div><div><strong>HeteroPanacea / Aug 2026</strong><span>Heterogeneous stage specialization motivates resource-aware P/D comparison.</span></div><div><strong>Vanguard / Jun 2026</strong><span>Open-loop replay avoids coordinated omission when studying latency under load.</span></div><div><strong>AgentServeSim / Jun 2026</strong><span>Stateful multi-turn serving motivates future session-aware workload modeling.</span></div><div><strong>SGLang / RadixAttention</strong><span>Automatic shared-prefix KV reuse motivates the controlled cache scenario.</span></div></div></article>
444
  </div>
445
  </section>
446
  </main>
 
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="primary" disabled>Stress-test selected hypothesis</button>
300
  </aside>
301
 
302
  <div class="research-results">
 
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><option value="offload">Offload idle KV to host</option><option value="gap_aware">Oracle gap-aware HBM / host tiering</option></select></label>
367
+ <label>Routing<select id="agentRouting"><option value="least_load">Least-load</option><option value="session_affinity" selected>Strict session affinity</option><option value="bounded_affinity">Bounded 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.01" max="0.98" step="0.05" value="0.85" /><span class="unit">fraction</span></label>
370
+ <label id="agentAffinitySlackLabel">Affinity slack<input id="agentAffinitySlack" type="number" min="0" step="25" value="150" /><span class="unit">ms</span></label>
371
+ <label id="agentGapThresholdLabel">Gap-aware HBM threshold<input id="agentGapThreshold" type="number" min="0" step="0.25" value="1.5" /><span class="unit">s</span></label>
372
+ <label id="agentHostMemoryLabel">Host KV capacity<input id="agentHostMemory" type="number" min="0" step="4" value="32" /><span class="unit">GB</span></label>
373
+ <label id="agentHostBandwidthLabel">Host transfer bandwidth<input id="agentHostBandwidth" type="number" min="0.1" step="1" value="32" /><span class="unit">GB/s</span></label>
374
+ <label id="agentHostBaseLabel">Transfer base latency<input id="agentHostBase" type="number" min="0" step="0.05" value="0.15" /><span class="unit">ms</span></label>
375
  </div>
376
  <div class="field-grid two">
377
  <label>Turn TTFT SLO<input id="agentTtftSlo" type="number" min="1" value="500" /><span class="unit">ms</span></label>
 
385
  <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>
386
  <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>
387
  <div id="agentRunContent" class="hidden">
388
+ <div class="metric-grid eight-agent">
389
  <div class="metric emphasis"><span>p95 turn TTFT</span><strong id="agentTtft">N/A</strong></div>
390
  <div class="metric"><span>p95 session E2E</span><strong id="agentSessionE2e">N/A</strong></div>
391
+ <div class="metric"><span>Session SLO</span><strong id="agentSessionSloValue">N/A</strong></div>
392
+ <div class="metric"><span>Cross-turn reuse</span><strong id="agentCacheHit">N/A</strong></div>
393
  <div class="metric"><span>Recomputed history</span><strong id="agentRecompute">N/A</strong></div>
394
+ <div class="metric"><span>Peak HBM KV</span><strong id="agentPeakKv">N/A</strong></div>
395
+ <div class="metric"><span>Mean host KV</span><strong id="agentHostMeanKv">N/A</strong></div>
396
+ <div class="metric"><span>p95 host transfer</span><strong id="agentHostTransfer">N/A</strong></div>
397
  </div>
398
  <div class="study-summary" id="agentRunSummary"></div>
399
  <div class="chart-grid">
 
434
  <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>
435
  </div>
436
  </section>
437
+ <section class="panel research-panel">
438
+ <div class="panel-title-row"><div><div class="section-kicker">Tiered-memory experiment</div><h2>Agent Memory Lab</h2><p class="muted">Replay one program trace across stateless, TTL, bounded-affinity, host-offload, and gap-aware policies, then deliberately shrink the per-replica HBM KV budget to expose eviction pressure.</p></div><div class="stacked-actions"><button id="agentMemoryCompareBtn" class="primary compact" disabled>Compare memory policies</button><button id="agentBudgetBtn" class="primary compact" disabled>Stress HBM budget</button></div></div>
439
+ <div id="agentMemoryEmpty" class="empty-state small"><h3>No tiered-memory experiment yet</h3><p>Host offload preserves reusable KV outside HBM and pays an explicit transfer cost on restore. Gap-aware tiering is reported as an oracle upper bound because it uses the realized simulated tool gap.</p></div>
440
+ <div id="agentMemoryContent" class="hidden">
441
+ <div id="agentMemoryCompareBlock" class="hidden">
442
+ <div class="chart-card full" data-chart-card data-chart-name="agent-memory-policy-tradeoff">
443
+ <div class="chart-head"><div class="chart-title">Mean HBM residency 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>
444
+ <div class="chart-body research-chart"><canvas id="agentMemoryChart"></canvas></div>
445
+ </div>
446
+ <div class="table-toolbar"><span>Memory-policy comparison</span><div><button id="agentMemoryCopy" class="mini-button" disabled>Copy table</button><button id="agentMemoryCsv" class="mini-button" disabled>Download CSV</button></div></div>
447
+ <div class="table-wrap"><table><thead><tr><th>Policy</th><th>p95 turn TTFT</th><th>p95 session E2E</th><th>Session SLO</th><th>Reuse</th><th>HBM hit</th><th>Host hit</th><th>Mean HBM</th><th>Mean host</th><th>Transfer p95</th><th>Recomputed</th></tr></thead><tbody id="agentMemoryRows"></tbody></table></div>
448
+ </div>
449
+ <div id="agentBudgetBlock" class="hidden experiment-subsection">
450
+ <div class="chart-card full" data-chart-card data-chart-name="agent-hbm-budget-stress">
451
+ <div class="chart-head"><div class="chart-title">Finite HBM budget stress</div><div class="chart-actions"><button class="chart-download" type="button">Download PNG</button><button class="chart-expand" type="button">Expand</button></div></div>
452
+ <div class="chart-body research-chart"><canvas id="agentBudgetChart"></canvas></div>
453
+ </div>
454
+ <div class="table-toolbar"><span id="agentBudgetCaption">HBM budget sweep</span><div><button id="agentBudgetCopy" class="mini-button" disabled>Copy table</button><button id="agentBudgetCsv" class="mini-button" disabled>Download CSV</button></div></div>
455
+ <div class="table-wrap"><table><thead><tr><th>Policy</th><th>Budget / replica</th><th>Reference x</th><th>p95 TTFT</th><th>Session SLO</th><th>Reuse</th><th>Mean HBM</th><th>Mean host</th><th>Pressure evictions</th><th>Failed turns</th></tr></thead><tbody id="agentBudgetRows"></tbody></table></div>
456
+ </div>
457
+ </div>
458
+ </section>
459
+
460
+ <section class="panel research-panel">
461
+ <div class="panel-title-row"><div><div class="section-kicker">Locality vs balance</div><h2>Affinity Frontier</h2><p class="muted">Bounded affinity keeps a session on its cached replica only while the estimated queue penalty stays within a configurable slack. Sweep that slack on one common program trace.</p></div><button id="agentAffinityBtn" class="primary compact" disabled>Run affinity sweep</button></div>
462
+ <div id="agentAffinityEmpty" class="empty-state small"><h3>No routing sweep yet</h3><p>Low slack behaves closer to least-load routing; high slack increasingly prioritizes KV locality. This exposes when cache reuse begins to overload a hot replica.</p></div>
463
+ <div id="agentAffinityContent" class="hidden">
464
+ <div class="chart-card full" data-chart-card data-chart-name="agent-affinity-routing-frontier">
465
+ <div class="chart-head"><div class="chart-title">Routing slack vs latency and locality</div><div class="chart-actions"><button class="chart-download" type="button">Download PNG</button><button class="chart-expand" type="button">Expand</button></div></div>
466
+ <div class="chart-body research-chart"><canvas id="agentAffinityChart"></canvas></div>
467
+ </div>
468
+ <div class="table-toolbar"><span>Bounded-affinity sweep</span><div><button id="agentAffinityCopy" class="mini-button" disabled>Copy table</button><button id="agentAffinityCsv" class="mini-button" disabled>Download CSV</button></div></div>
469
+ <div class="table-wrap"><table><thead><tr><th>Affinity slack</th><th>p95 turn TTFT</th><th>p95 session E2E</th><th>Route locality</th><th>Cache hit</th><th>Recomputed</th><th>Session SLO</th><th>Pressure evictions</th></tr></thead><tbody id="agentAffinityRows"></tbody></table></div>
470
+ </div>
471
+ </section>
472
+
473
  </div>
474
  </div>
475
  </section>
 
479
  <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>
480
  <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>
481
  <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>
482
+ <article class="panel prose"><div class="section-kicker">Stateful agent sessions</div><h2>Tool gaps turn KV into a residency and routing decision</h2><p>Agent Sessions preserves program identity and turn order, materializes tool-induced gaps, and tracks HBM retention, TTL expiry, host-memory offload/restore, recomputation, and pressure eviction. Strict affinity, least-load, and bounded-affinity routing expose the tension between cache locality and hot-replica queueing. The per-replica service model remains intentionally serial so state-management effects are not confounded with dynamic batching.</p></article>
483
  <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>
484
  <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>
485
  <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>
486
+ <article class="panel prose wide-method"><div class="section-kicker">Research lineage</div><h2>Research lineage and scope</h2><p>Vidur established the value of simulation for avoiding expensive deployment sweeps. Recent systems have pushed toward heterogeneous and disaggregated serving, communication-aware modeling, stateful workloads, trace replay, and SLA-dependent design-space exploration. InferScale-Sim remains intentionally smaller and inspectable, with paired experiments and sensitivity analysis built into the workflow.</p><div class="paper-grid"><div><strong>Vidur / 2024</strong><span>Predictive profiling, workload-aware serving simulation, configuration search.</span></div><div><strong>TokenSim / 2025</strong><span>Extensible scheduling and memory-management simulation.</span></div><div><strong>Revati / 2026</strong><span>GPU-free time-warp emulation of serving control logic.</span></div><div><strong>LLMServingSim 2.0 / 2026</strong><span>Heterogeneous and disaggregated infrastructure, memory and communication.</span></div><div><strong>Frontier / May 2026</strong><span>P/D disaggregation, runtime optimizations, stateful workloads, Pareto exploration.</span></div><div><strong>HeteroPanacea / Aug 2026</strong><span>Heterogeneous stage specialization motivates resource-aware P/D comparison.</span></div><div><strong>Vanguard / Jun 2026</strong><span>Open-loop replay avoids coordinated omission when studying latency under load.</span></div><div><strong>AgentServeSim / Jun 2026</strong><span>Stateful multi-turn serving motivates session-aware workload modeling.</span></div><div><strong>IdleKV / Jun 2026</strong><span>Tool-call idle windows motivate explicit HBM-to-host KV offload experiments.</span></div><div><strong>SMetric / Jul 2026</strong><span>Cache-local routing can overload hot replicas, motivating bounded affinity.</span></div><div><strong>SGLang / RadixAttention</strong><span>Automatic shared-prefix KV reuse motivates the controlled cache scenario.</span></div></div></article>
487
  </div>
488
  </section>
489
  </main>
py/inferscale/__init__.py CHANGED
@@ -1,4 +1,11 @@
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,6 +15,9 @@ from .validation import validate_cases
8
 
9
  __all__ = [
10
  "SimulationConfig",
 
 
 
11
  "compare_agent_policies",
12
  "capacity_search",
13
  "compare_schedulers",
@@ -25,4 +35,4 @@ __all__ = [
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"
 
1
+ from .agentic import (
2
+ agent_affinity_sweep,
3
+ agent_memory_budget_sweep,
4
+ compare_agent_memory_policies,
5
+ compare_agent_policies,
6
+ run_agent_session_simulation,
7
+ ttl_retention_sweep,
8
+ )
9
  from .api import execute, metadata
10
  from .models import SimulationConfig
11
  from .optimizer import capacity_search, compare_schedulers, compare_topologies, design_space_search
 
15
 
16
  __all__ = [
17
  "SimulationConfig",
18
+ "agent_affinity_sweep",
19
+ "agent_memory_budget_sweep",
20
+ "compare_agent_memory_policies",
21
  "compare_agent_policies",
22
  "capacity_search",
23
  "compare_schedulers",
 
35
 
36
  # Internal package metadata only; the public project intentionally avoids
37
  # release/version branding in the interface and documentation.
38
+ __version__ = "0.6.0"
py/inferscale/agentic.py CHANGED
@@ -12,8 +12,8 @@ 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
@@ -38,6 +38,12 @@ class AgentSessionConfig:
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
@@ -54,7 +60,13 @@ class AgentSessionConfig:
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]:
@@ -86,6 +98,8 @@ class CacheEntry:
86
  last_access: float
87
  expiry_time: float | None
88
  generation: int
 
 
89
 
90
 
91
  @dataclass
@@ -175,8 +189,14 @@ class AgentSessionSimulator:
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, ...]]] = []
@@ -185,10 +205,18 @@ class AgentSessionSimulator:
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
@@ -207,12 +235,23 @@ class AgentSessionSimulator:
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)
@@ -224,7 +263,9 @@ class AgentSessionSimulator:
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:
@@ -239,15 +280,28 @@ class AgentSessionSimulator:
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:
@@ -260,6 +314,73 @@ class AgentSessionSimulator:
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
@@ -289,6 +410,10 @@ class AgentSessionSimulator:
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()
@@ -300,16 +425,29 @@ class AgentSessionSimulator:
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
@@ -332,8 +470,8 @@ class AgentSessionSimulator:
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
@@ -345,7 +483,9 @@ class AgentSessionSimulator:
345
  turn.turn_index,
346
  replica.replica_id,
347
  ready_time,
348
- hit,
 
 
349
  prefill_tokens,
350
  ttft_s,
351
  service_s,
@@ -359,6 +499,8 @@ class AgentSessionSimulator:
359
  replica_id: int,
360
  ready_time: float,
361
  cache_hit: bool,
 
 
362
  prefill_tokens: int,
363
  ttft_s: float,
364
  service_s: float,
@@ -375,11 +517,31 @@ class AgentSessionSimulator:
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(
@@ -390,6 +552,9 @@ class AgentSessionSimulator:
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,
@@ -421,7 +586,7 @@ class AgentSessionSimulator:
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)
@@ -452,17 +617,21 @@ class AgentSessionSimulator:
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 = {
@@ -470,19 +639,33 @@ class AgentSessionSimulator:
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 {
@@ -492,6 +675,8 @@ class AgentSessionSimulator:
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,
@@ -620,3 +805,155 @@ def ttl_retention_sweep(config: dict[str, Any], ttl_values: list[float] | None =
620
  "rows": rows,
621
  "pareto_count": len(unique_front),
622
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  from .profiles import get_accelerator, get_model
13
 
14
 
15
+ ROUTING_POLICIES = {"least_load", "session_affinity", "bounded_affinity"}
16
+ RETENTION_POLICIES = {"evict", "retain", "ttl", "offload", "gap_aware"}
17
 
18
 
19
  @dataclass
 
38
  kv_ttl_s: float = 3.0
39
  routing_policy: str = "session_affinity"
40
  kv_memory_fraction: float = 0.85
41
+ kv_capacity_override_gb: float = 0.0
42
+ host_memory_gb: float = 32.0
43
+ host_bandwidth_gbps: float = 32.0
44
+ host_transfer_base_ms: float = 0.15
45
+ affinity_slack_ms: float = 150.0
46
+ gap_aware_threshold_s: float = 1.5
47
  slo_turn_ttft_ms: float = 500.0
48
  slo_session_e2e_ms: float = 30000.0
49
  timeline_points: int = 240
 
60
  cfg.session_rate_rps = max(float(cfg.session_rate_rps), 0.01)
61
  cfg.duration_s = max(float(cfg.duration_s), 1.0)
62
  cfg.kv_ttl_s = max(float(cfg.kv_ttl_s), 0.0)
63
+ cfg.kv_memory_fraction = min(max(float(cfg.kv_memory_fraction), 0.01), 0.98)
64
+ cfg.kv_capacity_override_gb = max(float(cfg.kv_capacity_override_gb), 0.0)
65
+ cfg.host_memory_gb = max(float(cfg.host_memory_gb), 0.0)
66
+ cfg.host_bandwidth_gbps = max(float(cfg.host_bandwidth_gbps), 0.1)
67
+ cfg.host_transfer_base_ms = max(float(cfg.host_transfer_base_ms), 0.0)
68
+ cfg.affinity_slack_ms = max(float(cfg.affinity_slack_ms), 0.0)
69
+ cfg.gap_aware_threshold_s = max(float(cfg.gap_aware_threshold_s), 0.0)
70
  return cfg
71
 
72
  def to_dict(self) -> dict[str, Any]:
 
98
  last_access: float
99
  expiry_time: float | None
100
  generation: int
101
+ source_replica: int | None = None
102
+ available_time: float = 0.0
103
 
104
 
105
  @dataclass
 
189
  self.accelerator = get_accelerator(cfg.accelerator)
190
  self.latency = AnalyticalLatencyModel(self.model, self.accelerator, cfg.quantization)
191
  remaining = max(0.0, self.accelerator.vram_gb - self.latency.model_weight_gb - 1.2)
192
+ automatic_capacity = remaining * cfg.kv_memory_fraction
193
+ self.kv_capacity_gb = (
194
+ min(automatic_capacity, cfg.kv_capacity_override_gb)
195
+ if cfg.kv_capacity_override_gb > 0.0
196
+ else automatic_capacity
197
+ )
198
  self.replicas = [ReplicaState(i) for i in range(cfg.replicas)]
199
+ self.host_cache: dict[int, CacheEntry] = {}
200
  specs = sessions if sessions is not None else generate_agent_sessions(cfg)
201
  self.sessions = {s.session_id: SessionRuntime(s, s.initial_prompt_tokens) for s in specs}
202
  self.events: list[tuple[float, int, str, tuple[Any, ...]]] = []
 
205
  self.turn_rows: list[dict[str, Any]] = []
206
  self.timeline: list[dict[str, Any]] = []
207
  self.peak_kv_gb = 0.0
208
+ self.peak_replica_kv_gb = 0.0
209
  self.hbm_gb_seconds = 0.0
210
+ self.host_gb_seconds = 0.0
211
+ self.peak_host_gb = 0.0
212
  self.last_memory_time = 0.0
213
  self.pressure_evictions = 0
214
  self.ttl_evictions = 0
215
+ self.host_pressure_evictions = 0
216
+ self.host_cache_hits = 0
217
+ self.offload_bytes = 0.0
218
+ self.restore_bytes = 0.0
219
+ self.host_transfer_latencies_ms: list[float] = []
220
  self.recompute_tokens = 0
221
  self.cache_hits = 0
222
  self.cache_eligible_turns = 0
 
235
  def _used_gb(self) -> float:
236
  return sum(entry.size_gb for replica in self.replicas for entry in replica.cache.values())
237
 
238
+ def _used_host_gb(self) -> float:
239
+ return sum(entry.size_gb for entry in self.host_cache.values())
240
+
241
  def _integrate_memory(self, new_time: float) -> None:
242
  new_time = max(float(new_time), self.last_memory_time)
243
+ elapsed = new_time - self.last_memory_time
244
  used = self._used_gb()
245
+ host_used = self._used_host_gb()
246
+ self.hbm_gb_seconds += used * elapsed
247
+ self.host_gb_seconds += host_used * elapsed
248
  self.last_memory_time = new_time
249
  self.peak_kv_gb = max(self.peak_kv_gb, used)
250
+ self.peak_replica_kv_gb = max(
251
+ self.peak_replica_kv_gb,
252
+ max((sum(entry.size_gb for entry in replica.cache.values()) for replica in self.replicas), default=0.0),
253
+ )
254
+ self.peak_host_gb = max(self.peak_host_gb, host_used)
255
 
256
  def _record_timeline(self) -> None:
257
  max_points = max(int(self.cfg.timeline_points), 40)
 
263
  "queued_turns": sum(len(r.queue) for r in self.replicas),
264
  "busy_replicas": sum(1 for r in self.replicas if r.busy),
265
  "kv_used_gb": self._used_gb(),
266
+ "host_kv_gb": self._used_host_gb(),
267
  "resident_sessions": sum(len(r.cache) for r in self.replicas),
268
+ "host_sessions": len(self.host_cache),
269
  }
270
  )
271
  if len(self.timeline) > max_points * 2:
 
280
  return replica
281
  return None
282
 
283
+ def _replica_load_key(self, replica: ReplicaState) -> tuple[int, float, int]:
284
+ return (len(replica.queue), max(replica.busy_until, self.now), replica.replica_id)
285
+
286
  def _route(self, session_id: int) -> ReplicaState:
287
  cached = self._cache_replica(session_id)
288
  if cached is not None:
289
  self.route_opportunities += 1
290
+ least_loaded = min(self.replicas, key=self._replica_load_key)
291
  if self.cfg.routing_policy == "session_affinity" and cached is not None:
292
  self.affinity_routes += 1
293
  return cached
294
+ if self.cfg.routing_policy == "bounded_affinity" and cached is not None:
295
+ cached_ready = max(cached.busy_until, self.now)
296
+ best_ready = max(least_loaded.busy_until, self.now)
297
+ queue_gap = max(0, len(cached.queue) - len(least_loaded.queue))
298
+ # Queue length is converted to a modest reference penalty because
299
+ # exact queued service time is intentionally not known at routing time.
300
+ estimated_extra_ms = max(0.0, cached_ready - best_ready) * 1000.0 + queue_gap * 75.0
301
+ if estimated_extra_ms <= self.cfg.affinity_slack_ms:
302
+ self.affinity_routes += 1
303
+ return cached
304
+ return least_loaded
305
 
306
  def _remove_cache(self, replica: ReplicaState, session_id: int, reason: str) -> None:
307
  if session_id not in replica.cache:
 
314
  self.ttl_evictions += 1
315
  self._record_timeline()
316
 
317
+ def _host_transfer_seconds(self, size_gb: float) -> float:
318
+ return self.cfg.host_transfer_base_ms / 1000.0 + size_gb / self.cfg.host_bandwidth_gbps
319
+
320
+ def _remove_host_cache(self, session_id: int) -> None:
321
+ if session_id not in self.host_cache:
322
+ return
323
+ self._integrate_memory(self.now)
324
+ del self.host_cache[session_id]
325
+ self._record_timeline()
326
+
327
+ def _ensure_host_capacity(self, session_id: int, target_gb: float) -> bool:
328
+ if target_gb > self.cfg.host_memory_gb + 1e-12:
329
+ return False
330
+ while self._used_host_gb() + target_gb > self.cfg.host_memory_gb + 1e-12:
331
+ victims = [entry for sid, entry in self.host_cache.items() if sid != session_id]
332
+ if not victims:
333
+ return False
334
+ victim = min(victims, key=lambda e: (e.last_access, e.session_id))
335
+ self._integrate_memory(self.now)
336
+ del self.host_cache[victim.session_id]
337
+ self.host_pressure_evictions += 1
338
+ return True
339
+
340
+ def _offload_cache(self, replica: ReplicaState, session_id: int, tokens: int) -> bool:
341
+ size_gb = tokens * self.latency.kv_bytes_per_token() / 1e9
342
+ if self.cfg.host_memory_gb <= 0.0 or not self._ensure_host_capacity(session_id, size_gb):
343
+ self._remove_cache(replica, session_id, "complete")
344
+ return False
345
+ transfer_s = self._host_transfer_seconds(size_gb)
346
+ self._integrate_memory(self.now)
347
+ replica.cache.pop(session_id, None)
348
+ self.host_cache[session_id] = CacheEntry(
349
+ session_id=session_id,
350
+ tokens=tokens,
351
+ size_gb=size_gb,
352
+ last_access=self.now,
353
+ expiry_time=None,
354
+ generation=1,
355
+ source_replica=replica.replica_id,
356
+ available_time=self.now + transfer_s,
357
+ )
358
+ self.offload_bytes += size_gb * 1e9
359
+ self.host_transfer_latencies_ms.append(transfer_s * 1000.0)
360
+ self.peak_host_gb = max(self.peak_host_gb, self._used_host_gb())
361
+ self._record_timeline()
362
+ return True
363
+
364
+ def _restore_host_entry(self, replica: ReplicaState, session_id: int) -> tuple[bool, float]:
365
+ entry = self.host_cache.get(session_id)
366
+ if entry is None:
367
+ return False, 0.0
368
+ if not self._ensure_capacity(replica, session_id, entry.size_gb):
369
+ return False, 0.0
370
+ wait_s = max(0.0, entry.available_time - self.now)
371
+ copy_s = self._host_transfer_seconds(entry.size_gb)
372
+ # The host entry remains resident while an unfinished offload or restore
373
+ # is exposed. Account for that residency explicitly because the event
374
+ # clock advances only when the turn completes.
375
+ self.host_gb_seconds += entry.size_gb * (wait_s + copy_s)
376
+ self._integrate_memory(self.now)
377
+ del self.host_cache[session_id]
378
+ self.restore_bytes += entry.size_gb * 1e9
379
+ self.host_transfer_latencies_ms.append((wait_s + copy_s) * 1000.0)
380
+ self.host_cache_hits += 1
381
+ self._record_timeline()
382
+ return True, wait_s + copy_s
383
+
384
  def _ensure_capacity(self, replica: ReplicaState, session_id: int, target_gb: float) -> bool:
385
  current = replica.cache.get(session_id)
386
  current_gb = current.size_gb if current else 0.0
 
410
  expiry = self.now + self.cfg.kv_ttl_s
411
  replica.cache[session_id] = CacheEntry(session_id, tokens, size_gb, self.now, expiry, generation)
412
  self.peak_kv_gb = max(self.peak_kv_gb, self._used_gb())
413
+ self.peak_replica_kv_gb = max(
414
+ self.peak_replica_kv_gb,
415
+ sum(entry.size_gb for entry in replica.cache.values()),
416
+ )
417
  if expiry is not None:
418
  self._push(expiry, "cache_expire", replica.replica_id, session_id, generation)
419
  self._record_timeline()
 
425
  session_id, turn, ready_time = replica.queue.pop(0)
426
  runtime = self.sessions[session_id]
427
  cache = self._entry(replica, session_id)
428
+ hbm_hit = turn.turn_index > 0 and cache is not None
429
+ host_hit = False
430
+ restore_s = 0.0
431
  if turn.turn_index > 0:
432
  self.cache_eligible_turns += 1
433
+ if hbm_hit:
434
  self.cache_hits += 1
435
  prefill_tokens = max(turn.append_tokens, 1)
436
+ cache_source = "hbm"
437
+ elif turn.turn_index > 0 and session_id in self.host_cache:
438
+ host_hit, restore_s = self._restore_host_entry(replica, session_id)
439
+ if host_hit:
440
+ prefill_tokens = max(turn.append_tokens, 1)
441
+ cache_source = "host"
442
+ else:
443
+ prefill_tokens = max(runtime.context_tokens + turn.append_tokens, 1)
444
+ self.recompute_tokens += runtime.context_tokens
445
+ cache_source = "miss"
446
  else:
447
  prefill_tokens = max(runtime.context_tokens + turn.append_tokens, 1)
448
  if turn.turn_index > 0:
449
  self.recompute_tokens += runtime.context_tokens
450
+ cache_source = "miss"
451
 
452
  context_before_decode = runtime.context_tokens + turn.append_tokens
453
  projected_tokens = context_before_decode + turn.output_tokens
 
470
  first_decode_s = self.latency.decode_step_seconds([context_before_decode])
471
  midpoint = context_before_decode + max(turn.output_tokens // 2, 1)
472
  decode_step_s = self.latency.decode_step_seconds([midpoint])
473
+ service_s = restore_s + prefill_s + turn.output_tokens * decode_step_s
474
+ ttft_s = (self.now - ready_time) + restore_s + prefill_s + first_decode_s
475
 
476
  replica.busy = True
477
  replica.current_session = session_id
 
483
  turn.turn_index,
484
  replica.replica_id,
485
  ready_time,
486
+ hbm_hit or host_hit,
487
+ cache_source,
488
+ restore_s,
489
  prefill_tokens,
490
  ttft_s,
491
  service_s,
 
499
  replica_id: int,
500
  ready_time: float,
501
  cache_hit: bool,
502
+ cache_source: str,
503
+ restore_s: float,
504
  prefill_tokens: int,
505
  ttft_s: float,
506
  service_s: float,
 
517
  replica.busy_until = self.now
518
 
519
  is_final = turn_index == len(runtime.spec.turns) - 1
520
+ state_action = "evict"
521
+ if is_final:
522
+ self._remove_cache(replica, session_id, "complete")
523
+ self._remove_host_cache(session_id)
524
+ elif self.cfg.retention_policy == "evict":
525
  self._remove_cache(replica, session_id, "complete")
526
+ self._remove_host_cache(session_id)
527
+ elif self.cfg.retention_policy in {"retain", "ttl"}:
528
+ self._put_cache(replica, session_id, projected_tokens, keep=True)
529
+ state_action = "retain_hbm"
530
+ elif self.cfg.retention_policy == "offload":
531
+ if self._offload_cache(replica, session_id, projected_tokens):
532
+ state_action = "offload_host"
533
+ else:
534
+ state_action = "evict_host_full"
535
+ elif self.cfg.retention_policy == "gap_aware":
536
+ # This is intentionally an oracle upper-bound policy: the simulated
537
+ # tool gap is already known from the generated program trace.
538
+ if turn.tool_gap_after_s <= self.cfg.gap_aware_threshold_s:
539
+ self._put_cache(replica, session_id, projected_tokens, keep=True)
540
+ state_action = "retain_hbm_short_gap"
541
+ elif self._offload_cache(replica, session_id, projected_tokens):
542
+ state_action = "offload_host_long_gap"
543
+ else:
544
+ state_action = "evict_host_full"
545
 
546
  e2e_ms = (self.now - ready_time) * 1000.0
547
  self.turn_rows.append(
 
552
  "ready_time": ready_time,
553
  "completion_time": self.now,
554
  "cache_hit": cache_hit,
555
+ "cache_source": cache_source,
556
+ "restore_ms": restore_s * 1000.0,
557
+ "state_action": state_action,
558
  "prefill_tokens": prefill_tokens,
559
  "context_tokens_after": projected_tokens,
560
  "output_tokens": turn.output_tokens,
 
586
  elif kind == "turn_complete":
587
  self._finish_turn(
588
  int(payload[0]), int(payload[1]), int(payload[2]), float(payload[3]), bool(payload[4]),
589
+ str(payload[5]), float(payload[6]), int(payload[7]), float(payload[8]), float(payload[9]), int(payload[10]),
590
  )
591
  elif kind == "cache_expire":
592
  replica_id, session_id, generation = map(int, payload)
 
617
  ]
618
  session_slo = sum(1 for v in session_e2e if v <= self.cfg.slo_session_e2e_ms)
619
  turn_slo = sum(1 for v in ttfts if v <= self.cfg.slo_turn_ttft_ms)
620
+ turns_generated = sum(len(s.spec.turns) for s in self.sessions.values())
621
  completion_horizon = max([s.completion_time or 0.0 for s in self.sessions.values()] + [self.cfg.duration_s, 1e-9])
622
  mean_kv_gb = self.hbm_gb_seconds / completion_horizon
623
  summary = {
624
  "sessions_generated": len(self.sessions),
625
  "sessions_completed": len(completed_sessions),
626
+ "turns_generated": turns_generated,
627
  "turns_completed": len(successful_turns),
628
  "turns_failed": self.failed_turns,
629
+ "session_completion_rate": len(completed_sessions) / len(self.sessions) if self.sessions else 0.0,
630
+ "turn_completion_rate": len(successful_turns) / turns_generated if turns_generated else 0.0,
631
  "session_throughput_rps": len(completed_sessions) / completion_horizon,
632
  "turn_throughput_rps": len(successful_turns) / completion_horizon,
633
+ "turn_ttft_slo_attainment": turn_slo / turns_generated if turns_generated else 0.0,
634
+ "session_slo_attainment": session_slo / len(self.sessions) if self.sessions else 0.0,
635
  "simulated_makespan_s": completion_horizon,
636
  }
637
  latency = {
 
639
  "turn_e2e_ms": {"p50": percentile(turn_e2e, 0.50), "p95": percentile(turn_e2e, 0.95), "p99": percentile(turn_e2e, 0.99)},
640
  "session_e2e_ms": {"p50": percentile(session_e2e, 0.50), "p95": percentile(session_e2e, 0.95), "p99": percentile(session_e2e, 0.99)},
641
  }
642
+ host_restore_p95_ms = percentile(self.host_transfer_latencies_ms, 0.95)
643
+ total_reuse_hits = self.cache_hits + self.host_cache_hits
644
  resource = {
645
  "replicas": self.cfg.replicas,
646
  "kv_capacity_gb_per_replica": self.kv_capacity_gb,
647
  "peak_kv_gb": self.peak_kv_gb,
648
+ "peak_replica_kv_gb": self.peak_replica_kv_gb,
649
  "mean_kv_gb": mean_kv_gb,
650
  "hbm_gb_seconds": self.hbm_gb_seconds,
651
+ "peak_host_kv_gb": self.peak_host_gb,
652
+ "mean_host_kv_gb": self.host_gb_seconds / completion_horizon,
653
+ "host_gb_seconds": self.host_gb_seconds,
654
+ "cross_turn_cache_hits": total_reuse_hits,
655
+ "hbm_cache_hits": self.cache_hits,
656
+ "host_cache_hits": self.host_cache_hits,
657
  "cross_turn_cache_eligible": self.cache_eligible_turns,
658
+ "cross_turn_cache_hit_rate": total_reuse_hits / self.cache_eligible_turns if self.cache_eligible_turns else 0.0,
659
+ "hbm_cache_hit_rate": self.cache_hits / self.cache_eligible_turns if self.cache_eligible_turns else 0.0,
660
+ "host_cache_hit_rate": self.host_cache_hits / self.cache_eligible_turns if self.cache_eligible_turns else 0.0,
661
  "routing_locality_rate": self.affinity_routes / self.route_opportunities if self.route_opportunities else 0.0,
662
  "recomputed_history_tokens": self.recompute_tokens,
663
  "pressure_evictions": self.pressure_evictions,
664
  "ttl_evictions": self.ttl_evictions,
665
+ "host_pressure_evictions": self.host_pressure_evictions,
666
+ "offloaded_gb": self.offload_bytes / 1e9,
667
+ "restored_gb": self.restore_bytes / 1e9,
668
+ "p95_host_transfer_ms": host_restore_p95_ms,
669
  "tool_gap_total_s": self.tool_gap_total_s,
670
  }
671
  return {
 
675
  "mode": "stateful-agent-session-simulation",
676
  "latency_profile_type": "analytical-reference",
677
  "agent_service_model": "serial-per-replica-reference",
678
+ "host_tier_model": "serialized-reference-transfer",
679
+ "gap_aware_policy": "oracle-upper-bound" if self.cfg.retention_policy == "gap_aware" else "not-active",
680
  "warning": "Agent-session mode isolates routing/KV-retention effects and does not model dynamic batching within each replica.",
681
  },
682
  "summary": summary,
 
805
  "rows": rows,
806
  "pareto_count": len(unique_front),
807
  }
808
+
809
+
810
+ def compare_agent_memory_policies(config: dict[str, Any]) -> dict[str, Any]:
811
+ """Compare cache-residency and routing strategies on one common agent trace."""
812
+ base = AgentSessionConfig.from_dict(config)
813
+ trace = _same_trace(base)
814
+ policies = [
815
+ ("Stateless / least-load", "evict", "least_load"),
816
+ ("TTL / strict affinity", "ttl", "session_affinity"),
817
+ ("TTL / bounded affinity", "ttl", "bounded_affinity"),
818
+ ("Host offload / bounded affinity", "offload", "bounded_affinity"),
819
+ ("Gap-aware tiering / bounded affinity", "gap_aware", "bounded_affinity"),
820
+ ]
821
+ rows: list[dict[str, Any]] = []
822
+ results: list[dict[str, Any]] = []
823
+ for label, retention, routing in policies:
824
+ cfg = AgentSessionConfig.from_dict(base.to_dict())
825
+ cfg.retention_policy = retention
826
+ cfg.routing_policy = routing
827
+ result = run_agent_session_simulation(cfg.to_dict(), trace)
828
+ results.append(result)
829
+ rows.append(
830
+ {
831
+ "label": label,
832
+ "retention": retention,
833
+ "routing": routing,
834
+ "p95_turn_ttft_ms": result["latency"]["turn_ttft_ms"]["p95"],
835
+ "p95_session_e2e_ms": result["latency"]["session_e2e_ms"]["p95"],
836
+ "turn_slo_attainment": result["summary"]["turn_ttft_slo_attainment"],
837
+ "session_slo_attainment": result["summary"]["session_slo_attainment"],
838
+ "cache_hit_rate": result["resource"]["cross_turn_cache_hit_rate"],
839
+ "hbm_hit_rate": result["resource"]["hbm_cache_hit_rate"],
840
+ "host_hit_rate": result["resource"]["host_cache_hit_rate"],
841
+ "routing_locality_rate": result["resource"]["routing_locality_rate"],
842
+ "recomputed_history_tokens": result["resource"]["recomputed_history_tokens"],
843
+ "mean_hbm_gb": result["resource"]["mean_kv_gb"],
844
+ "mean_host_gb": result["resource"]["mean_host_kv_gb"],
845
+ "hbm_gb_seconds": result["resource"]["hbm_gb_seconds"],
846
+ "host_gb_seconds": result["resource"]["host_gb_seconds"],
847
+ "p95_host_transfer_ms": result["resource"]["p95_host_transfer_ms"],
848
+ "offloaded_gb": result["resource"]["offloaded_gb"],
849
+ "pressure_evictions": result["resource"]["pressure_evictions"],
850
+ "host_pressure_evictions": result["resource"]["host_pressure_evictions"],
851
+ "turns_failed": result["summary"]["turns_failed"],
852
+ }
853
+ )
854
+ return {
855
+ "protocol": "common-agent-program-trace",
856
+ "study": "agent-memory-tiering",
857
+ "candidate_count": len(rows),
858
+ "rows": rows,
859
+ "results": results,
860
+ "note": "Gap-aware tiering uses realized simulated tool gaps and is an oracle upper bound, not a deployable predictor.",
861
+ }
862
+
863
+
864
+ def agent_memory_budget_sweep(
865
+ config: dict[str, Any], budget_multipliers: list[float] | None = None
866
+ ) -> dict[str, Any]:
867
+ """Stress state policies under finite per-replica HBM KV budgets.
868
+
869
+ Budgets are derived from the unconstrained peak working set of the exact same
870
+ program trace, avoiding arbitrary fractions of total GPU VRAM that would be
871
+ too loose for small models.
872
+ """
873
+ base = AgentSessionConfig.from_dict(config)
874
+ trace = _same_trace(base)
875
+
876
+ reference_cfg = AgentSessionConfig.from_dict(base.to_dict())
877
+ reference_cfg.retention_policy = "retain"
878
+ reference_cfg.routing_policy = "session_affinity"
879
+ reference_cfg.kv_capacity_override_gb = 0.0
880
+ reference = run_agent_session_simulation(reference_cfg.to_dict(), trace)
881
+ reference_peak = max(float(reference["resource"]["peak_replica_kv_gb"]), 0.002)
882
+
883
+ multipliers = budget_multipliers or [0.35, 0.50, 0.75, 1.00, 1.50]
884
+ cleaned = sorted({max(0.10, min(float(v), 3.0)) for v in multipliers})
885
+ policies = [
886
+ ("TTL / bounded affinity", "ttl", "bounded_affinity"),
887
+ ("Host offload / bounded affinity", "offload", "bounded_affinity"),
888
+ ("Gap-aware tiering / bounded affinity", "gap_aware", "bounded_affinity"),
889
+ ]
890
+
891
+ rows: list[dict[str, Any]] = []
892
+ for multiplier in cleaned:
893
+ budget = reference_peak * multiplier
894
+ for label, retention, routing in policies:
895
+ cfg = AgentSessionConfig.from_dict(base.to_dict())
896
+ cfg.retention_policy = retention
897
+ cfg.routing_policy = routing
898
+ cfg.kv_capacity_override_gb = budget
899
+ result = run_agent_session_simulation(cfg.to_dict(), trace)
900
+ rows.append(
901
+ {
902
+ "policy": label,
903
+ "budget_multiplier": multiplier,
904
+ "budget_gb_per_replica": budget,
905
+ "p95_turn_ttft_ms": result["latency"]["turn_ttft_ms"]["p95"],
906
+ "p95_session_e2e_ms": result["latency"]["session_e2e_ms"]["p95"],
907
+ "turn_slo_attainment": result["summary"]["turn_ttft_slo_attainment"],
908
+ "session_slo_attainment": result["summary"]["session_slo_attainment"],
909
+ "cache_hit_rate": result["resource"]["cross_turn_cache_hit_rate"],
910
+ "recomputed_history_tokens": result["resource"]["recomputed_history_tokens"],
911
+ "mean_hbm_gb": result["resource"]["mean_kv_gb"],
912
+ "mean_host_gb": result["resource"]["mean_host_kv_gb"],
913
+ "p95_host_transfer_ms": result["resource"]["p95_host_transfer_ms"],
914
+ "pressure_evictions": result["resource"]["pressure_evictions"],
915
+ "host_pressure_evictions": result["resource"]["host_pressure_evictions"],
916
+ "turns_failed": result["summary"]["turns_failed"],
917
+ }
918
+ )
919
+ return {
920
+ "protocol": "common-agent-program-trace",
921
+ "study": "finite-hbm-budget-stress",
922
+ "reference_peak_replica_kv_gb": reference_peak,
923
+ "rows": rows,
924
+ "policy_count": len(policies),
925
+ "budget_count": len(cleaned),
926
+ }
927
+
928
+
929
+ def agent_affinity_sweep(config: dict[str, Any], slack_values_ms: list[float] | None = None) -> dict[str, Any]:
930
+ """Sweep how much queue imbalance the router tolerates for KV locality."""
931
+ base = AgentSessionConfig.from_dict(config)
932
+ base.retention_policy = "ttl"
933
+ base.routing_policy = "bounded_affinity"
934
+ trace = _same_trace(base)
935
+ values = slack_values_ms or [0.0, 25.0, 75.0, 150.0, 300.0, 600.0, 1200.0]
936
+ cleaned = sorted({max(0.0, min(float(v), 5000.0)) for v in values})
937
+ rows: list[dict[str, Any]] = []
938
+ for slack in cleaned:
939
+ cfg = AgentSessionConfig.from_dict(base.to_dict())
940
+ cfg.affinity_slack_ms = slack
941
+ result = run_agent_session_simulation(cfg.to_dict(), trace)
942
+ rows.append(
943
+ {
944
+ "affinity_slack_ms": slack,
945
+ "p95_turn_ttft_ms": result["latency"]["turn_ttft_ms"]["p95"],
946
+ "p95_session_e2e_ms": result["latency"]["session_e2e_ms"]["p95"],
947
+ "cache_hit_rate": result["resource"]["cross_turn_cache_hit_rate"],
948
+ "routing_locality_rate": result["resource"]["routing_locality_rate"],
949
+ "recomputed_history_tokens": result["resource"]["recomputed_history_tokens"],
950
+ "turn_slo_attainment": result["summary"]["turn_ttft_slo_attainment"],
951
+ "session_slo_attainment": result["summary"]["session_slo_attainment"],
952
+ "pressure_evictions": result["resource"]["pressure_evictions"],
953
+ }
954
+ )
955
+ return {
956
+ "protocol": "common-agent-program-trace",
957
+ "study": "bounded-affinity-routing-frontier",
958
+ "rows": rows,
959
+ }
py/inferscale/api.py CHANGED
@@ -1,6 +1,13 @@
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,7 +22,10 @@ def metadata() -> dict:
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
 
@@ -55,6 +65,12 @@ def execute(action: str, payload: dict) -> dict:
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(
 
1
  from __future__ import annotations
2
 
3
+ from .agentic import (
4
+ agent_affinity_sweep,
5
+ agent_memory_budget_sweep,
6
+ compare_agent_memory_policies,
7
+ compare_agent_policies,
8
+ run_agent_session_simulation,
9
+ ttl_retention_sweep,
10
+ )
11
  from .optimizer import capacity_search, compare_schedulers, compare_topologies, design_space_search
12
  from .profiles import ACCELERATORS, MODELS
13
  from .research import STUDIES, paired_study, robustness_study
 
22
  "topologies": ["colocated", "disaggregated_pd"],
23
  "research_studies": STUDIES,
24
  "profile_type": "analytical-reference",
25
+ "agentic_modes": [
26
+ "session_simulation", "policy_compare", "ttl_sweep",
27
+ "memory_policy_compare", "memory_budget_sweep", "affinity_sweep",
28
+ ],
29
  }
30
 
31
 
 
65
  return compare_agent_policies(payload.get("config", payload))
66
  if action == "agent_ttl_sweep":
67
  return ttl_retention_sweep(payload.get("config", payload), payload.get("ttl_values"))
68
+ if action == "agent_memory_compare":
69
+ return compare_agent_memory_policies(payload.get("config", payload))
70
+ if action == "agent_memory_sweep":
71
+ return agent_memory_budget_sweep(payload.get("config", payload), payload.get("budget_multipliers"))
72
+ if action == "agent_affinity_sweep":
73
+ return agent_affinity_sweep(payload.get("config", payload), payload.get("slack_values_ms"))
74
  if action == "robustness_study":
75
  config = payload.get("config", payload)
76
  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.5.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.6.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
@@ -14,6 +14,9 @@ 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
@@ -34,8 +37,8 @@ else:
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,7 +77,7 @@ if "Download PNG" not in index or ".chart-download" not in app:
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
 
@@ -185,6 +188,15 @@ try:
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
 
@@ -217,5 +229,8 @@ 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']}")
 
14
  internal_version = inferscale.__version__
15
  design_space_search = inferscale.design_space_search
16
  compare_agent_policies = inferscale.compare_agent_policies
17
+ compare_agent_memory_policies = inferscale.compare_agent_memory_policies
18
+ agent_memory_budget_sweep = inferscale.agent_memory_budget_sweep
19
+ agent_affinity_sweep = inferscale.agent_affinity_sweep
20
  paired_study = inferscale.paired_study
21
  robustness_study = inferscale.robustness_study
22
  run_agent_session_simulation = inferscale.run_agent_session_simulation
 
37
 
38
  if "sdk: static" not in README:
39
  errors.append("README metadata must use sdk: static")
40
+ if internal_version != "0.6.0":
41
+ errors.append(f"internal package version is {internal_version}; expected 0.6.0")
42
 
43
  # Public-facing release/version branding is intentionally absent. Model names
44
  # such as Mistral-7B-v0.3 are allowed; project headings/badges are not.
 
77
  errors.append("chart PNG export controls are missing")
78
  if "Worst repetition" not in index or "Target" not in index:
79
  errors.append("capacity evidence columns are missing")
80
+ for expected in ["Trace replay", "Research Studies", "Run paired study", "Stress-test selected hypothesis", "Agent Sessions", "Session Policy Arena", "TTL frontier", "Agent Memory Lab", "Affinity Frontier", "Stress HBM budget"]:
81
  if expected not in index:
82
  errors.append(f"UI is missing research/trace feature: {expected}")
83
 
 
188
  agent_ttl = ttl_retention_sweep(agent_cfg, [0, 1, 3])
189
  if len(agent_ttl.get("rows", [])) != 3 or agent_ttl.get("pareto_count", 0) < 1:
190
  errors.append("agent TTL frontier smoke test is incomplete")
191
+ agent_memory = compare_agent_memory_policies(agent_cfg | {"host_memory_gb": 4})
192
+ if agent_memory.get("candidate_count") != 5 or not any(row.get("host_hit_rate", 0) > 0 for row in agent_memory.get("rows", [])):
193
+ errors.append("agent tiered-memory comparison is incomplete")
194
+ agent_budget = agent_memory_budget_sweep(agent_cfg | {"host_memory_gb": 4}, [0.5, 1.0])
195
+ if len(agent_budget.get("rows", [])) != 6:
196
+ errors.append("finite HBM budget study is incomplete")
197
+ agent_affinity = agent_affinity_sweep(agent_cfg, [0, 150, 600])
198
+ if len(agent_affinity.get("rows", [])) != 3:
199
+ errors.append("bounded-affinity sweep is incomplete")
200
  except Exception as exc: # pragma: no cover
201
  errors.append(f"agent-session smoke test raised: {exc}")
202
 
 
229
  print(f"Agent turns: {agent['summary']['turns_completed']}")
230
  print(f"Agent policy candidates: {agent_compare['candidate_count']}")
231
  print(f"Agent TTL candidates: {len(agent_ttl['rows'])}")
232
+ print(f"Agent memory policies: {agent_memory['candidate_count']}")
233
+ print(f"HBM budget study rows: {len(agent_budget['rows'])}")
234
+ print(f"Affinity sweep points: {len(agent_affinity['rows'])}")
235
  print(f"Validation observations: {validation['observation_count']}")
236
  print(f"Profile provenance: {smoke['provenance']['latency_profile_type']}")
src/inferscale/__init__.py CHANGED
@@ -1,4 +1,11 @@
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,6 +15,9 @@ from .validation import validate_cases
8
 
9
  __all__ = [
10
  "SimulationConfig",
 
 
 
11
  "compare_agent_policies",
12
  "capacity_search",
13
  "compare_schedulers",
@@ -25,4 +35,4 @@ __all__ = [
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"
 
1
+ from .agentic import (
2
+ agent_affinity_sweep,
3
+ agent_memory_budget_sweep,
4
+ compare_agent_memory_policies,
5
+ compare_agent_policies,
6
+ run_agent_session_simulation,
7
+ ttl_retention_sweep,
8
+ )
9
  from .api import execute, metadata
10
  from .models import SimulationConfig
11
  from .optimizer import capacity_search, compare_schedulers, compare_topologies, design_space_search
 
15
 
16
  __all__ = [
17
  "SimulationConfig",
18
+ "agent_affinity_sweep",
19
+ "agent_memory_budget_sweep",
20
+ "compare_agent_memory_policies",
21
  "compare_agent_policies",
22
  "capacity_search",
23
  "compare_schedulers",
 
35
 
36
  # Internal package metadata only; the public project intentionally avoids
37
  # release/version branding in the interface and documentation.
38
+ __version__ = "0.6.0"
src/inferscale/agentic.py CHANGED
@@ -12,8 +12,8 @@ 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
@@ -38,6 +38,12 @@ class AgentSessionConfig:
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
@@ -54,7 +60,13 @@ class AgentSessionConfig:
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]:
@@ -86,6 +98,8 @@ class CacheEntry:
86
  last_access: float
87
  expiry_time: float | None
88
  generation: int
 
 
89
 
90
 
91
  @dataclass
@@ -175,8 +189,14 @@ class AgentSessionSimulator:
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, ...]]] = []
@@ -185,10 +205,18 @@ class AgentSessionSimulator:
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
@@ -207,12 +235,23 @@ class AgentSessionSimulator:
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)
@@ -224,7 +263,9 @@ class AgentSessionSimulator:
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:
@@ -239,15 +280,28 @@ class AgentSessionSimulator:
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:
@@ -260,6 +314,73 @@ class AgentSessionSimulator:
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
@@ -289,6 +410,10 @@ class AgentSessionSimulator:
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()
@@ -300,16 +425,29 @@ class AgentSessionSimulator:
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
@@ -332,8 +470,8 @@ class AgentSessionSimulator:
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
@@ -345,7 +483,9 @@ class AgentSessionSimulator:
345
  turn.turn_index,
346
  replica.replica_id,
347
  ready_time,
348
- hit,
 
 
349
  prefill_tokens,
350
  ttft_s,
351
  service_s,
@@ -359,6 +499,8 @@ class AgentSessionSimulator:
359
  replica_id: int,
360
  ready_time: float,
361
  cache_hit: bool,
 
 
362
  prefill_tokens: int,
363
  ttft_s: float,
364
  service_s: float,
@@ -375,11 +517,31 @@ class AgentSessionSimulator:
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(
@@ -390,6 +552,9 @@ class AgentSessionSimulator:
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,
@@ -421,7 +586,7 @@ class AgentSessionSimulator:
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)
@@ -452,17 +617,21 @@ class AgentSessionSimulator:
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 = {
@@ -470,19 +639,33 @@ class AgentSessionSimulator:
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 {
@@ -492,6 +675,8 @@ class AgentSessionSimulator:
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,
@@ -620,3 +805,155 @@ def ttl_retention_sweep(config: dict[str, Any], ttl_values: list[float] | None =
620
  "rows": rows,
621
  "pareto_count": len(unique_front),
622
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  from .profiles import get_accelerator, get_model
13
 
14
 
15
+ ROUTING_POLICIES = {"least_load", "session_affinity", "bounded_affinity"}
16
+ RETENTION_POLICIES = {"evict", "retain", "ttl", "offload", "gap_aware"}
17
 
18
 
19
  @dataclass
 
38
  kv_ttl_s: float = 3.0
39
  routing_policy: str = "session_affinity"
40
  kv_memory_fraction: float = 0.85
41
+ kv_capacity_override_gb: float = 0.0
42
+ host_memory_gb: float = 32.0
43
+ host_bandwidth_gbps: float = 32.0
44
+ host_transfer_base_ms: float = 0.15
45
+ affinity_slack_ms: float = 150.0
46
+ gap_aware_threshold_s: float = 1.5
47
  slo_turn_ttft_ms: float = 500.0
48
  slo_session_e2e_ms: float = 30000.0
49
  timeline_points: int = 240
 
60
  cfg.session_rate_rps = max(float(cfg.session_rate_rps), 0.01)
61
  cfg.duration_s = max(float(cfg.duration_s), 1.0)
62
  cfg.kv_ttl_s = max(float(cfg.kv_ttl_s), 0.0)
63
+ cfg.kv_memory_fraction = min(max(float(cfg.kv_memory_fraction), 0.01), 0.98)
64
+ cfg.kv_capacity_override_gb = max(float(cfg.kv_capacity_override_gb), 0.0)
65
+ cfg.host_memory_gb = max(float(cfg.host_memory_gb), 0.0)
66
+ cfg.host_bandwidth_gbps = max(float(cfg.host_bandwidth_gbps), 0.1)
67
+ cfg.host_transfer_base_ms = max(float(cfg.host_transfer_base_ms), 0.0)
68
+ cfg.affinity_slack_ms = max(float(cfg.affinity_slack_ms), 0.0)
69
+ cfg.gap_aware_threshold_s = max(float(cfg.gap_aware_threshold_s), 0.0)
70
  return cfg
71
 
72
  def to_dict(self) -> dict[str, Any]:
 
98
  last_access: float
99
  expiry_time: float | None
100
  generation: int
101
+ source_replica: int | None = None
102
+ available_time: float = 0.0
103
 
104
 
105
  @dataclass
 
189
  self.accelerator = get_accelerator(cfg.accelerator)
190
  self.latency = AnalyticalLatencyModel(self.model, self.accelerator, cfg.quantization)
191
  remaining = max(0.0, self.accelerator.vram_gb - self.latency.model_weight_gb - 1.2)
192
+ automatic_capacity = remaining * cfg.kv_memory_fraction
193
+ self.kv_capacity_gb = (
194
+ min(automatic_capacity, cfg.kv_capacity_override_gb)
195
+ if cfg.kv_capacity_override_gb > 0.0
196
+ else automatic_capacity
197
+ )
198
  self.replicas = [ReplicaState(i) for i in range(cfg.replicas)]
199
+ self.host_cache: dict[int, CacheEntry] = {}
200
  specs = sessions if sessions is not None else generate_agent_sessions(cfg)
201
  self.sessions = {s.session_id: SessionRuntime(s, s.initial_prompt_tokens) for s in specs}
202
  self.events: list[tuple[float, int, str, tuple[Any, ...]]] = []
 
205
  self.turn_rows: list[dict[str, Any]] = []
206
  self.timeline: list[dict[str, Any]] = []
207
  self.peak_kv_gb = 0.0
208
+ self.peak_replica_kv_gb = 0.0
209
  self.hbm_gb_seconds = 0.0
210
+ self.host_gb_seconds = 0.0
211
+ self.peak_host_gb = 0.0
212
  self.last_memory_time = 0.0
213
  self.pressure_evictions = 0
214
  self.ttl_evictions = 0
215
+ self.host_pressure_evictions = 0
216
+ self.host_cache_hits = 0
217
+ self.offload_bytes = 0.0
218
+ self.restore_bytes = 0.0
219
+ self.host_transfer_latencies_ms: list[float] = []
220
  self.recompute_tokens = 0
221
  self.cache_hits = 0
222
  self.cache_eligible_turns = 0
 
235
  def _used_gb(self) -> float:
236
  return sum(entry.size_gb for replica in self.replicas for entry in replica.cache.values())
237
 
238
+ def _used_host_gb(self) -> float:
239
+ return sum(entry.size_gb for entry in self.host_cache.values())
240
+
241
  def _integrate_memory(self, new_time: float) -> None:
242
  new_time = max(float(new_time), self.last_memory_time)
243
+ elapsed = new_time - self.last_memory_time
244
  used = self._used_gb()
245
+ host_used = self._used_host_gb()
246
+ self.hbm_gb_seconds += used * elapsed
247
+ self.host_gb_seconds += host_used * elapsed
248
  self.last_memory_time = new_time
249
  self.peak_kv_gb = max(self.peak_kv_gb, used)
250
+ self.peak_replica_kv_gb = max(
251
+ self.peak_replica_kv_gb,
252
+ max((sum(entry.size_gb for entry in replica.cache.values()) for replica in self.replicas), default=0.0),
253
+ )
254
+ self.peak_host_gb = max(self.peak_host_gb, host_used)
255
 
256
  def _record_timeline(self) -> None:
257
  max_points = max(int(self.cfg.timeline_points), 40)
 
263
  "queued_turns": sum(len(r.queue) for r in self.replicas),
264
  "busy_replicas": sum(1 for r in self.replicas if r.busy),
265
  "kv_used_gb": self._used_gb(),
266
+ "host_kv_gb": self._used_host_gb(),
267
  "resident_sessions": sum(len(r.cache) for r in self.replicas),
268
+ "host_sessions": len(self.host_cache),
269
  }
270
  )
271
  if len(self.timeline) > max_points * 2:
 
280
  return replica
281
  return None
282
 
283
+ def _replica_load_key(self, replica: ReplicaState) -> tuple[int, float, int]:
284
+ return (len(replica.queue), max(replica.busy_until, self.now), replica.replica_id)
285
+
286
  def _route(self, session_id: int) -> ReplicaState:
287
  cached = self._cache_replica(session_id)
288
  if cached is not None:
289
  self.route_opportunities += 1
290
+ least_loaded = min(self.replicas, key=self._replica_load_key)
291
  if self.cfg.routing_policy == "session_affinity" and cached is not None:
292
  self.affinity_routes += 1
293
  return cached
294
+ if self.cfg.routing_policy == "bounded_affinity" and cached is not None:
295
+ cached_ready = max(cached.busy_until, self.now)
296
+ best_ready = max(least_loaded.busy_until, self.now)
297
+ queue_gap = max(0, len(cached.queue) - len(least_loaded.queue))
298
+ # Queue length is converted to a modest reference penalty because
299
+ # exact queued service time is intentionally not known at routing time.
300
+ estimated_extra_ms = max(0.0, cached_ready - best_ready) * 1000.0 + queue_gap * 75.0
301
+ if estimated_extra_ms <= self.cfg.affinity_slack_ms:
302
+ self.affinity_routes += 1
303
+ return cached
304
+ return least_loaded
305
 
306
  def _remove_cache(self, replica: ReplicaState, session_id: int, reason: str) -> None:
307
  if session_id not in replica.cache:
 
314
  self.ttl_evictions += 1
315
  self._record_timeline()
316
 
317
+ def _host_transfer_seconds(self, size_gb: float) -> float:
318
+ return self.cfg.host_transfer_base_ms / 1000.0 + size_gb / self.cfg.host_bandwidth_gbps
319
+
320
+ def _remove_host_cache(self, session_id: int) -> None:
321
+ if session_id not in self.host_cache:
322
+ return
323
+ self._integrate_memory(self.now)
324
+ del self.host_cache[session_id]
325
+ self._record_timeline()
326
+
327
+ def _ensure_host_capacity(self, session_id: int, target_gb: float) -> bool:
328
+ if target_gb > self.cfg.host_memory_gb + 1e-12:
329
+ return False
330
+ while self._used_host_gb() + target_gb > self.cfg.host_memory_gb + 1e-12:
331
+ victims = [entry for sid, entry in self.host_cache.items() if sid != session_id]
332
+ if not victims:
333
+ return False
334
+ victim = min(victims, key=lambda e: (e.last_access, e.session_id))
335
+ self._integrate_memory(self.now)
336
+ del self.host_cache[victim.session_id]
337
+ self.host_pressure_evictions += 1
338
+ return True
339
+
340
+ def _offload_cache(self, replica: ReplicaState, session_id: int, tokens: int) -> bool:
341
+ size_gb = tokens * self.latency.kv_bytes_per_token() / 1e9
342
+ if self.cfg.host_memory_gb <= 0.0 or not self._ensure_host_capacity(session_id, size_gb):
343
+ self._remove_cache(replica, session_id, "complete")
344
+ return False
345
+ transfer_s = self._host_transfer_seconds(size_gb)
346
+ self._integrate_memory(self.now)
347
+ replica.cache.pop(session_id, None)
348
+ self.host_cache[session_id] = CacheEntry(
349
+ session_id=session_id,
350
+ tokens=tokens,
351
+ size_gb=size_gb,
352
+ last_access=self.now,
353
+ expiry_time=None,
354
+ generation=1,
355
+ source_replica=replica.replica_id,
356
+ available_time=self.now + transfer_s,
357
+ )
358
+ self.offload_bytes += size_gb * 1e9
359
+ self.host_transfer_latencies_ms.append(transfer_s * 1000.0)
360
+ self.peak_host_gb = max(self.peak_host_gb, self._used_host_gb())
361
+ self._record_timeline()
362
+ return True
363
+
364
+ def _restore_host_entry(self, replica: ReplicaState, session_id: int) -> tuple[bool, float]:
365
+ entry = self.host_cache.get(session_id)
366
+ if entry is None:
367
+ return False, 0.0
368
+ if not self._ensure_capacity(replica, session_id, entry.size_gb):
369
+ return False, 0.0
370
+ wait_s = max(0.0, entry.available_time - self.now)
371
+ copy_s = self._host_transfer_seconds(entry.size_gb)
372
+ # The host entry remains resident while an unfinished offload or restore
373
+ # is exposed. Account for that residency explicitly because the event
374
+ # clock advances only when the turn completes.
375
+ self.host_gb_seconds += entry.size_gb * (wait_s + copy_s)
376
+ self._integrate_memory(self.now)
377
+ del self.host_cache[session_id]
378
+ self.restore_bytes += entry.size_gb * 1e9
379
+ self.host_transfer_latencies_ms.append((wait_s + copy_s) * 1000.0)
380
+ self.host_cache_hits += 1
381
+ self._record_timeline()
382
+ return True, wait_s + copy_s
383
+
384
  def _ensure_capacity(self, replica: ReplicaState, session_id: int, target_gb: float) -> bool:
385
  current = replica.cache.get(session_id)
386
  current_gb = current.size_gb if current else 0.0
 
410
  expiry = self.now + self.cfg.kv_ttl_s
411
  replica.cache[session_id] = CacheEntry(session_id, tokens, size_gb, self.now, expiry, generation)
412
  self.peak_kv_gb = max(self.peak_kv_gb, self._used_gb())
413
+ self.peak_replica_kv_gb = max(
414
+ self.peak_replica_kv_gb,
415
+ sum(entry.size_gb for entry in replica.cache.values()),
416
+ )
417
  if expiry is not None:
418
  self._push(expiry, "cache_expire", replica.replica_id, session_id, generation)
419
  self._record_timeline()
 
425
  session_id, turn, ready_time = replica.queue.pop(0)
426
  runtime = self.sessions[session_id]
427
  cache = self._entry(replica, session_id)
428
+ hbm_hit = turn.turn_index > 0 and cache is not None
429
+ host_hit = False
430
+ restore_s = 0.0
431
  if turn.turn_index > 0:
432
  self.cache_eligible_turns += 1
433
+ if hbm_hit:
434
  self.cache_hits += 1
435
  prefill_tokens = max(turn.append_tokens, 1)
436
+ cache_source = "hbm"
437
+ elif turn.turn_index > 0 and session_id in self.host_cache:
438
+ host_hit, restore_s = self._restore_host_entry(replica, session_id)
439
+ if host_hit:
440
+ prefill_tokens = max(turn.append_tokens, 1)
441
+ cache_source = "host"
442
+ else:
443
+ prefill_tokens = max(runtime.context_tokens + turn.append_tokens, 1)
444
+ self.recompute_tokens += runtime.context_tokens
445
+ cache_source = "miss"
446
  else:
447
  prefill_tokens = max(runtime.context_tokens + turn.append_tokens, 1)
448
  if turn.turn_index > 0:
449
  self.recompute_tokens += runtime.context_tokens
450
+ cache_source = "miss"
451
 
452
  context_before_decode = runtime.context_tokens + turn.append_tokens
453
  projected_tokens = context_before_decode + turn.output_tokens
 
470
  first_decode_s = self.latency.decode_step_seconds([context_before_decode])
471
  midpoint = context_before_decode + max(turn.output_tokens // 2, 1)
472
  decode_step_s = self.latency.decode_step_seconds([midpoint])
473
+ service_s = restore_s + prefill_s + turn.output_tokens * decode_step_s
474
+ ttft_s = (self.now - ready_time) + restore_s + prefill_s + first_decode_s
475
 
476
  replica.busy = True
477
  replica.current_session = session_id
 
483
  turn.turn_index,
484
  replica.replica_id,
485
  ready_time,
486
+ hbm_hit or host_hit,
487
+ cache_source,
488
+ restore_s,
489
  prefill_tokens,
490
  ttft_s,
491
  service_s,
 
499
  replica_id: int,
500
  ready_time: float,
501
  cache_hit: bool,
502
+ cache_source: str,
503
+ restore_s: float,
504
  prefill_tokens: int,
505
  ttft_s: float,
506
  service_s: float,
 
517
  replica.busy_until = self.now
518
 
519
  is_final = turn_index == len(runtime.spec.turns) - 1
520
+ state_action = "evict"
521
+ if is_final:
522
+ self._remove_cache(replica, session_id, "complete")
523
+ self._remove_host_cache(session_id)
524
+ elif self.cfg.retention_policy == "evict":
525
  self._remove_cache(replica, session_id, "complete")
526
+ self._remove_host_cache(session_id)
527
+ elif self.cfg.retention_policy in {"retain", "ttl"}:
528
+ self._put_cache(replica, session_id, projected_tokens, keep=True)
529
+ state_action = "retain_hbm"
530
+ elif self.cfg.retention_policy == "offload":
531
+ if self._offload_cache(replica, session_id, projected_tokens):
532
+ state_action = "offload_host"
533
+ else:
534
+ state_action = "evict_host_full"
535
+ elif self.cfg.retention_policy == "gap_aware":
536
+ # This is intentionally an oracle upper-bound policy: the simulated
537
+ # tool gap is already known from the generated program trace.
538
+ if turn.tool_gap_after_s <= self.cfg.gap_aware_threshold_s:
539
+ self._put_cache(replica, session_id, projected_tokens, keep=True)
540
+ state_action = "retain_hbm_short_gap"
541
+ elif self._offload_cache(replica, session_id, projected_tokens):
542
+ state_action = "offload_host_long_gap"
543
+ else:
544
+ state_action = "evict_host_full"
545
 
546
  e2e_ms = (self.now - ready_time) * 1000.0
547
  self.turn_rows.append(
 
552
  "ready_time": ready_time,
553
  "completion_time": self.now,
554
  "cache_hit": cache_hit,
555
+ "cache_source": cache_source,
556
+ "restore_ms": restore_s * 1000.0,
557
+ "state_action": state_action,
558
  "prefill_tokens": prefill_tokens,
559
  "context_tokens_after": projected_tokens,
560
  "output_tokens": turn.output_tokens,
 
586
  elif kind == "turn_complete":
587
  self._finish_turn(
588
  int(payload[0]), int(payload[1]), int(payload[2]), float(payload[3]), bool(payload[4]),
589
+ str(payload[5]), float(payload[6]), int(payload[7]), float(payload[8]), float(payload[9]), int(payload[10]),
590
  )
591
  elif kind == "cache_expire":
592
  replica_id, session_id, generation = map(int, payload)
 
617
  ]
618
  session_slo = sum(1 for v in session_e2e if v <= self.cfg.slo_session_e2e_ms)
619
  turn_slo = sum(1 for v in ttfts if v <= self.cfg.slo_turn_ttft_ms)
620
+ turns_generated = sum(len(s.spec.turns) for s in self.sessions.values())
621
  completion_horizon = max([s.completion_time or 0.0 for s in self.sessions.values()] + [self.cfg.duration_s, 1e-9])
622
  mean_kv_gb = self.hbm_gb_seconds / completion_horizon
623
  summary = {
624
  "sessions_generated": len(self.sessions),
625
  "sessions_completed": len(completed_sessions),
626
+ "turns_generated": turns_generated,
627
  "turns_completed": len(successful_turns),
628
  "turns_failed": self.failed_turns,
629
+ "session_completion_rate": len(completed_sessions) / len(self.sessions) if self.sessions else 0.0,
630
+ "turn_completion_rate": len(successful_turns) / turns_generated if turns_generated else 0.0,
631
  "session_throughput_rps": len(completed_sessions) / completion_horizon,
632
  "turn_throughput_rps": len(successful_turns) / completion_horizon,
633
+ "turn_ttft_slo_attainment": turn_slo / turns_generated if turns_generated else 0.0,
634
+ "session_slo_attainment": session_slo / len(self.sessions) if self.sessions else 0.0,
635
  "simulated_makespan_s": completion_horizon,
636
  }
637
  latency = {
 
639
  "turn_e2e_ms": {"p50": percentile(turn_e2e, 0.50), "p95": percentile(turn_e2e, 0.95), "p99": percentile(turn_e2e, 0.99)},
640
  "session_e2e_ms": {"p50": percentile(session_e2e, 0.50), "p95": percentile(session_e2e, 0.95), "p99": percentile(session_e2e, 0.99)},
641
  }
642
+ host_restore_p95_ms = percentile(self.host_transfer_latencies_ms, 0.95)
643
+ total_reuse_hits = self.cache_hits + self.host_cache_hits
644
  resource = {
645
  "replicas": self.cfg.replicas,
646
  "kv_capacity_gb_per_replica": self.kv_capacity_gb,
647
  "peak_kv_gb": self.peak_kv_gb,
648
+ "peak_replica_kv_gb": self.peak_replica_kv_gb,
649
  "mean_kv_gb": mean_kv_gb,
650
  "hbm_gb_seconds": self.hbm_gb_seconds,
651
+ "peak_host_kv_gb": self.peak_host_gb,
652
+ "mean_host_kv_gb": self.host_gb_seconds / completion_horizon,
653
+ "host_gb_seconds": self.host_gb_seconds,
654
+ "cross_turn_cache_hits": total_reuse_hits,
655
+ "hbm_cache_hits": self.cache_hits,
656
+ "host_cache_hits": self.host_cache_hits,
657
  "cross_turn_cache_eligible": self.cache_eligible_turns,
658
+ "cross_turn_cache_hit_rate": total_reuse_hits / self.cache_eligible_turns if self.cache_eligible_turns else 0.0,
659
+ "hbm_cache_hit_rate": self.cache_hits / self.cache_eligible_turns if self.cache_eligible_turns else 0.0,
660
+ "host_cache_hit_rate": self.host_cache_hits / self.cache_eligible_turns if self.cache_eligible_turns else 0.0,
661
  "routing_locality_rate": self.affinity_routes / self.route_opportunities if self.route_opportunities else 0.0,
662
  "recomputed_history_tokens": self.recompute_tokens,
663
  "pressure_evictions": self.pressure_evictions,
664
  "ttl_evictions": self.ttl_evictions,
665
+ "host_pressure_evictions": self.host_pressure_evictions,
666
+ "offloaded_gb": self.offload_bytes / 1e9,
667
+ "restored_gb": self.restore_bytes / 1e9,
668
+ "p95_host_transfer_ms": host_restore_p95_ms,
669
  "tool_gap_total_s": self.tool_gap_total_s,
670
  }
671
  return {
 
675
  "mode": "stateful-agent-session-simulation",
676
  "latency_profile_type": "analytical-reference",
677
  "agent_service_model": "serial-per-replica-reference",
678
+ "host_tier_model": "serialized-reference-transfer",
679
+ "gap_aware_policy": "oracle-upper-bound" if self.cfg.retention_policy == "gap_aware" else "not-active",
680
  "warning": "Agent-session mode isolates routing/KV-retention effects and does not model dynamic batching within each replica.",
681
  },
682
  "summary": summary,
 
805
  "rows": rows,
806
  "pareto_count": len(unique_front),
807
  }
808
+
809
+
810
+ def compare_agent_memory_policies(config: dict[str, Any]) -> dict[str, Any]:
811
+ """Compare cache-residency and routing strategies on one common agent trace."""
812
+ base = AgentSessionConfig.from_dict(config)
813
+ trace = _same_trace(base)
814
+ policies = [
815
+ ("Stateless / least-load", "evict", "least_load"),
816
+ ("TTL / strict affinity", "ttl", "session_affinity"),
817
+ ("TTL / bounded affinity", "ttl", "bounded_affinity"),
818
+ ("Host offload / bounded affinity", "offload", "bounded_affinity"),
819
+ ("Gap-aware tiering / bounded affinity", "gap_aware", "bounded_affinity"),
820
+ ]
821
+ rows: list[dict[str, Any]] = []
822
+ results: list[dict[str, Any]] = []
823
+ for label, retention, routing in policies:
824
+ cfg = AgentSessionConfig.from_dict(base.to_dict())
825
+ cfg.retention_policy = retention
826
+ cfg.routing_policy = routing
827
+ result = run_agent_session_simulation(cfg.to_dict(), trace)
828
+ results.append(result)
829
+ rows.append(
830
+ {
831
+ "label": label,
832
+ "retention": retention,
833
+ "routing": routing,
834
+ "p95_turn_ttft_ms": result["latency"]["turn_ttft_ms"]["p95"],
835
+ "p95_session_e2e_ms": result["latency"]["session_e2e_ms"]["p95"],
836
+ "turn_slo_attainment": result["summary"]["turn_ttft_slo_attainment"],
837
+ "session_slo_attainment": result["summary"]["session_slo_attainment"],
838
+ "cache_hit_rate": result["resource"]["cross_turn_cache_hit_rate"],
839
+ "hbm_hit_rate": result["resource"]["hbm_cache_hit_rate"],
840
+ "host_hit_rate": result["resource"]["host_cache_hit_rate"],
841
+ "routing_locality_rate": result["resource"]["routing_locality_rate"],
842
+ "recomputed_history_tokens": result["resource"]["recomputed_history_tokens"],
843
+ "mean_hbm_gb": result["resource"]["mean_kv_gb"],
844
+ "mean_host_gb": result["resource"]["mean_host_kv_gb"],
845
+ "hbm_gb_seconds": result["resource"]["hbm_gb_seconds"],
846
+ "host_gb_seconds": result["resource"]["host_gb_seconds"],
847
+ "p95_host_transfer_ms": result["resource"]["p95_host_transfer_ms"],
848
+ "offloaded_gb": result["resource"]["offloaded_gb"],
849
+ "pressure_evictions": result["resource"]["pressure_evictions"],
850
+ "host_pressure_evictions": result["resource"]["host_pressure_evictions"],
851
+ "turns_failed": result["summary"]["turns_failed"],
852
+ }
853
+ )
854
+ return {
855
+ "protocol": "common-agent-program-trace",
856
+ "study": "agent-memory-tiering",
857
+ "candidate_count": len(rows),
858
+ "rows": rows,
859
+ "results": results,
860
+ "note": "Gap-aware tiering uses realized simulated tool gaps and is an oracle upper bound, not a deployable predictor.",
861
+ }
862
+
863
+
864
+ def agent_memory_budget_sweep(
865
+ config: dict[str, Any], budget_multipliers: list[float] | None = None
866
+ ) -> dict[str, Any]:
867
+ """Stress state policies under finite per-replica HBM KV budgets.
868
+
869
+ Budgets are derived from the unconstrained peak working set of the exact same
870
+ program trace, avoiding arbitrary fractions of total GPU VRAM that would be
871
+ too loose for small models.
872
+ """
873
+ base = AgentSessionConfig.from_dict(config)
874
+ trace = _same_trace(base)
875
+
876
+ reference_cfg = AgentSessionConfig.from_dict(base.to_dict())
877
+ reference_cfg.retention_policy = "retain"
878
+ reference_cfg.routing_policy = "session_affinity"
879
+ reference_cfg.kv_capacity_override_gb = 0.0
880
+ reference = run_agent_session_simulation(reference_cfg.to_dict(), trace)
881
+ reference_peak = max(float(reference["resource"]["peak_replica_kv_gb"]), 0.002)
882
+
883
+ multipliers = budget_multipliers or [0.35, 0.50, 0.75, 1.00, 1.50]
884
+ cleaned = sorted({max(0.10, min(float(v), 3.0)) for v in multipliers})
885
+ policies = [
886
+ ("TTL / bounded affinity", "ttl", "bounded_affinity"),
887
+ ("Host offload / bounded affinity", "offload", "bounded_affinity"),
888
+ ("Gap-aware tiering / bounded affinity", "gap_aware", "bounded_affinity"),
889
+ ]
890
+
891
+ rows: list[dict[str, Any]] = []
892
+ for multiplier in cleaned:
893
+ budget = reference_peak * multiplier
894
+ for label, retention, routing in policies:
895
+ cfg = AgentSessionConfig.from_dict(base.to_dict())
896
+ cfg.retention_policy = retention
897
+ cfg.routing_policy = routing
898
+ cfg.kv_capacity_override_gb = budget
899
+ result = run_agent_session_simulation(cfg.to_dict(), trace)
900
+ rows.append(
901
+ {
902
+ "policy": label,
903
+ "budget_multiplier": multiplier,
904
+ "budget_gb_per_replica": budget,
905
+ "p95_turn_ttft_ms": result["latency"]["turn_ttft_ms"]["p95"],
906
+ "p95_session_e2e_ms": result["latency"]["session_e2e_ms"]["p95"],
907
+ "turn_slo_attainment": result["summary"]["turn_ttft_slo_attainment"],
908
+ "session_slo_attainment": result["summary"]["session_slo_attainment"],
909
+ "cache_hit_rate": result["resource"]["cross_turn_cache_hit_rate"],
910
+ "recomputed_history_tokens": result["resource"]["recomputed_history_tokens"],
911
+ "mean_hbm_gb": result["resource"]["mean_kv_gb"],
912
+ "mean_host_gb": result["resource"]["mean_host_kv_gb"],
913
+ "p95_host_transfer_ms": result["resource"]["p95_host_transfer_ms"],
914
+ "pressure_evictions": result["resource"]["pressure_evictions"],
915
+ "host_pressure_evictions": result["resource"]["host_pressure_evictions"],
916
+ "turns_failed": result["summary"]["turns_failed"],
917
+ }
918
+ )
919
+ return {
920
+ "protocol": "common-agent-program-trace",
921
+ "study": "finite-hbm-budget-stress",
922
+ "reference_peak_replica_kv_gb": reference_peak,
923
+ "rows": rows,
924
+ "policy_count": len(policies),
925
+ "budget_count": len(cleaned),
926
+ }
927
+
928
+
929
+ def agent_affinity_sweep(config: dict[str, Any], slack_values_ms: list[float] | None = None) -> dict[str, Any]:
930
+ """Sweep how much queue imbalance the router tolerates for KV locality."""
931
+ base = AgentSessionConfig.from_dict(config)
932
+ base.retention_policy = "ttl"
933
+ base.routing_policy = "bounded_affinity"
934
+ trace = _same_trace(base)
935
+ values = slack_values_ms or [0.0, 25.0, 75.0, 150.0, 300.0, 600.0, 1200.0]
936
+ cleaned = sorted({max(0.0, min(float(v), 5000.0)) for v in values})
937
+ rows: list[dict[str, Any]] = []
938
+ for slack in cleaned:
939
+ cfg = AgentSessionConfig.from_dict(base.to_dict())
940
+ cfg.affinity_slack_ms = slack
941
+ result = run_agent_session_simulation(cfg.to_dict(), trace)
942
+ rows.append(
943
+ {
944
+ "affinity_slack_ms": slack,
945
+ "p95_turn_ttft_ms": result["latency"]["turn_ttft_ms"]["p95"],
946
+ "p95_session_e2e_ms": result["latency"]["session_e2e_ms"]["p95"],
947
+ "cache_hit_rate": result["resource"]["cross_turn_cache_hit_rate"],
948
+ "routing_locality_rate": result["resource"]["routing_locality_rate"],
949
+ "recomputed_history_tokens": result["resource"]["recomputed_history_tokens"],
950
+ "turn_slo_attainment": result["summary"]["turn_ttft_slo_attainment"],
951
+ "session_slo_attainment": result["summary"]["session_slo_attainment"],
952
+ "pressure_evictions": result["resource"]["pressure_evictions"],
953
+ }
954
+ )
955
+ return {
956
+ "protocol": "common-agent-program-trace",
957
+ "study": "bounded-affinity-routing-frontier",
958
+ "rows": rows,
959
+ }
src/inferscale/api.py CHANGED
@@ -1,6 +1,13 @@
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,7 +22,10 @@ def metadata() -> dict:
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
 
@@ -55,6 +65,12 @@ def execute(action: str, payload: dict) -> dict:
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(
 
1
  from __future__ import annotations
2
 
3
+ from .agentic import (
4
+ agent_affinity_sweep,
5
+ agent_memory_budget_sweep,
6
+ compare_agent_memory_policies,
7
+ compare_agent_policies,
8
+ run_agent_session_simulation,
9
+ ttl_retention_sweep,
10
+ )
11
  from .optimizer import capacity_search, compare_schedulers, compare_topologies, design_space_search
12
  from .profiles import ACCELERATORS, MODELS
13
  from .research import STUDIES, paired_study, robustness_study
 
22
  "topologies": ["colocated", "disaggregated_pd"],
23
  "research_studies": STUDIES,
24
  "profile_type": "analytical-reference",
25
+ "agentic_modes": [
26
+ "session_simulation", "policy_compare", "ttl_sweep",
27
+ "memory_policy_compare", "memory_budget_sweep", "affinity_sweep",
28
+ ],
29
  }
30
 
31
 
 
65
  return compare_agent_policies(payload.get("config", payload))
66
  if action == "agent_ttl_sweep":
67
  return ttl_retention_sweep(payload.get("config", payload), payload.get("ttl_values"))
68
+ if action == "agent_memory_compare":
69
+ return compare_agent_memory_policies(payload.get("config", payload))
70
+ if action == "agent_memory_sweep":
71
+ return agent_memory_budget_sweep(payload.get("config", payload), payload.get("budget_multipliers"))
72
+ if action == "agent_affinity_sweep":
73
+ return agent_affinity_sweep(payload.get("config", payload), payload.get("slack_values_ms"))
74
  if action == "robustness_study":
75
  config = payload.get("config", payload)
76
  return robustness_study(
styles.css CHANGED
@@ -322,3 +322,9 @@ input[type="file"]::file-selector-button {
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; }
 
 
 
 
 
 
 
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; }
325
+ .metric-grid.eight-agent { grid-template-columns: repeat(4, minmax(0, 1fr)); }
326
+ .stacked-actions { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
327
+ .stacked-actions .compact { min-width: 168px; }
328
+ @media (max-width: 1250px) { .metric-grid.eight-agent { grid-template-columns: repeat(2, 1fr); } }
329
+ @media (max-width: 720px) { .metric-grid.eight-agent { grid-template-columns: 1fr 1fr; } .stacked-actions { width: 100%; } .stacked-actions .compact { width: 100% !important; } }
330
+ .experiment-subsection { margin-top: 18px; padding-top: 18px; border-top: 1px solid var(--line); }
tests/test_agentic.py CHANGED
@@ -56,3 +56,49 @@ def test_ttl_sweep_reports_latency_memory_frontier():
56
  assert result["pareto_count"] >= 1
57
  assert len(result["rows"]) == 4
58
  assert any(row["pareto"] for row in result["rows"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  assert result["pareto_count"] >= 1
57
  assert len(result["rows"]) == 4
58
  assert any(row["pareto"] for row in result["rows"])
59
+
60
+
61
+ def test_host_offload_reuses_state_without_hbm_retention():
62
+ from inferscale.agentic import compare_agent_memory_policies
63
+
64
+ result = compare_agent_memory_policies(BASE | {"host_memory_gb": 4, "host_bandwidth_gbps": 32})
65
+ rows = {row["label"]: row for row in result["rows"]}
66
+ offload = rows["Host offload / bounded affinity"]
67
+ stateless = rows["Stateless / least-load"]
68
+ assert offload["host_hit_rate"] > 0
69
+ assert offload["offloaded_gb"] > 0
70
+ assert offload["recomputed_history_tokens"] < stateless["recomputed_history_tokens"]
71
+
72
+
73
+ def test_gap_aware_policy_is_marked_as_oracle_upper_bound():
74
+ result = run_agent_session_simulation(
75
+ BASE
76
+ | {
77
+ "retention_policy": "gap_aware",
78
+ "routing_policy": "bounded_affinity",
79
+ "gap_aware_threshold_s": 1.0,
80
+ "host_memory_gb": 4,
81
+ }
82
+ )
83
+ assert result["provenance"]["gap_aware_policy"] == "oracle-upper-bound"
84
+ assert result["resource"]["cross_turn_cache_hit_rate"] > 0
85
+
86
+
87
+ def test_memory_budget_sweep_uses_common_trace_and_finite_budgets():
88
+ from inferscale.agentic import agent_memory_budget_sweep
89
+
90
+ result = agent_memory_budget_sweep(BASE, [0.5, 1.0])
91
+ assert result["protocol"] == "common-agent-program-trace"
92
+ assert result["budget_count"] == 2
93
+ assert result["policy_count"] == 3
94
+ assert len(result["rows"]) == 6
95
+ assert all(row["budget_gb_per_replica"] > 0 for row in result["rows"])
96
+
97
+
98
+ def test_bounded_affinity_sweep_reports_locality_frontier_inputs():
99
+ from inferscale.agentic import agent_affinity_sweep
100
+
101
+ result = agent_affinity_sweep(BASE, [0, 100, 500])
102
+ assert result["study"] == "bounded-affinity-routing-frontier"
103
+ assert [row["affinity_slack_ms"] for row in result["rows"]] == [0.0, 100.0, 500.0]
104
+ assert all(0 <= row["routing_locality_rate"] <= 1 for row in result["rows"])