Spaces:
Running
Running
Commit ·
94910ac
1
Parent(s): e5c4ee4
Deepen InferScale simulation research workflow
Browse files- README.md +22 -5
- app.js +117 -5
- docs/architecture.md +18 -0
- docs/methodology.md +33 -0
- docs/research.md +16 -0
- index.html +53 -10
- py/inferscale/__init__.py +12 -2
- py/inferscale/agentic.py +358 -21
- py/inferscale/api.py +18 -2
- pyproject.toml +1 -1
- scripts/release_check.py +18 -3
- src/inferscale/__init__.py +12 -2
- src/inferscale/agentic.py +358 -21
- src/inferscale/api.py +18 -2
- styles.css +6 -0
- tests/test_agentic.py +46 -0
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,
|
| 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 |
-
|
| 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
|
| 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,
|
| 220 |
|
| 221 |
-
The public app reports **HBM GB-seconds** in addition to peak/mean
|
| 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 |
-
$("
|
|
|
|
| 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.
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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="
|
| 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>
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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>
|
|
|
|
| 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
|
|
|
|
| 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
|
| 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
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
|
|
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 250 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 304 |
if turn.turn_index > 0:
|
| 305 |
self.cache_eligible_turns += 1
|
| 306 |
-
if
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 379 |
-
if
|
| 380 |
-
self.
|
| 381 |
-
|
|
|
|
| 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 |
-
|
| 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 /
|
| 465 |
-
"session_slo_attainment": session_slo / len(
|
| 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 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 480 |
"cross_turn_cache_eligible": self.cache_eligible_turns,
|
| 481 |
-
"cross_turn_cache_hit_rate":
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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": [
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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.
|
| 38 |
-
errors.append(f"internal package version is {internal_version}; expected 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
|
|
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 250 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 304 |
if turn.turn_index > 0:
|
| 305 |
self.cache_eligible_turns += 1
|
| 306 |
-
if
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 379 |
-
if
|
| 380 |
-
self.
|
| 381 |
-
|
|
|
|
| 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 |
-
|
| 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 /
|
| 465 |
-
"session_slo_attainment": session_slo / len(
|
| 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 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 480 |
"cross_turn_cache_eligible": self.cache_eligible_turns,
|
| 481 |
-
"cross_turn_cache_hit_rate":
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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": [
|
|
|
|
|
|
|
|
|
|
| 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"])
|