Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -203,3 +203,35 @@ override_4p1d_dep2_dep16_c1228:
max-num-seqs: 18
max-num-batched-tokens: 180
compilation-config: '{"cudagraph_mode":"FULL_DECODE_ONLY","max_cudagraph_capture_size":108,"cudagraph_capture_sizes":[6,12,18,24,30,36,42,48,54,60,66,72,78,84,90,96,102,108]}'

override_10p1d_dep2_dep16_c6656:
name: "dsv41flash-fp4-gb300-dynamo-vllm-agentx-disagg-10p1d-dep2-dep16-c6656"
benchmark:
env:
AIPERF_RUNTIME_PATCH: /infmax-workspace/infx/bench/agentic/patches/52-spawn-worker-pinning.patch
AIPERF_METRICS_LIST_BACKEND: tdigest
roles:
prefill:
nodes: 5
workers: 10
decode:
args:
max-num-seqs: 132
max-num-batched-tokens: 1320
compilation-config: '{"cudagraph_mode":"FULL_DECODE_ONLY","max_cudagraph_capture_size":792,"cudagraph_capture_sizes":[6,12,18,24,30,36,42,48,54,60,66,72,78,84,90,96,102,108,114,120,126,132,138,144,150,156,162,168,174,180,186,192,198,204,210,216,222,228,234,240,246,252,258,264,270,276,282,288,294,300,306,312,318,324,330,336,342,348,354,360,366,372,378,384,390,396,402,408,414,420,426,432,438,444,450,456,462,468,474,480,486,492,498,504,510,516,522,528,534,540,546,552,558,564,570,576,582,588,594,600,606,612,618,624,630,636,642,648,654,660,666,672,678,684,690,696,702,708,714,720,726,732,738,744,750,756,762,768,774,780,786,792]}'

override_8p1d_dep2_dep16_c5120:
name: "dsv41flash-fp4-gb300-dynamo-vllm-agentx-disagg-8p1d-dep2-dep16-c5120"
benchmark:
env:
AIPERF_RUNTIME_PATCH: /infmax-workspace/infx/bench/agentic/patches/52-spawn-worker-pinning.patch
AIPERF_METRICS_LIST_BACKEND: tdigest
roles:
prefill:
nodes: 4
workers: 8
decode:
args:
max-num-seqs: 70
max-num-batched-tokens: 700
compilation-config: '{"cudagraph_mode":"FULL_DECODE_ONLY","max_cudagraph_capture_size":420,"cudagraph_capture_sizes":[6,12,18,24,30,36,42,48,54,60,66,72,78,84,90,96,102,108,114,120,126,132,138,144,150,156,162,168,174,180,186,192,198,204,210,216,222,228,234,240,246,252,258,264,270,276,282,288,294,300,306,312,318,324,330,336,342,348,354,360,366,372,378,384,390,396,402,408,414,420]}'
30 changes: 30 additions & 0 deletions inferencex-e2e/configs/nvidia-master.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9408,6 +9408,36 @@ dsv41flash-fp4-gb300-dynamo-vllm-agentic-dspark-disagg:
tp: 16
ep: 16
dp-attn: true
- spec-decoding: mtp
kv-offloading: dram
kv-offload-backend: { name: vllm-simple }
srt-recipe: disagg-variants.yaml:override_8p1d_dep2_dep16_c5120
conc-list: [5120]
prefill:
num-worker: 8
tp: 2
ep: 2
dp-attn: true
decode:
num-worker: 1
tp: 16
ep: 16
dp-attn: true
- spec-decoding: mtp
kv-offloading: dram
kv-offload-backend: { name: vllm-simple }
srt-recipe: disagg-variants.yaml:override_10p1d_dep2_dep16_c6656
conc-list: [6656]
prefill:
num-worker: 10
tp: 2
ep: 2
dp-attn: true
decode:
num-worker: 1
tp: 16
ep: 16
dp-attn: true

# One serving GPU, stock ARM64 release, native AgentX client and proportional host cache.
qwen3.8next-fp4-gb300-sglang-agentic-mtp:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
diff --git a/src/aiperf/credit/sticky_router.py b/src/aiperf/credit/sticky_router.py
index 973c024be1..b67df8674f 100644
--- a/src/aiperf/credit/sticky_router.py
+++ b/src/aiperf/credit/sticky_router.py
@@ -22,7 +22,7 @@
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Protocol, runtime_checkable

-from aiperf.common.enums import CommAddress
+from aiperf.common.enums import CommAddress, ConversationBranchMode
from aiperf.common.mixins import CommunicationMixin
from aiperf.common.protocols import (
StreamingPullClientProtocol,
@@ -49,9 +49,9 @@

@dataclass(slots=True)
class _StickyEntry:
- """Sticky-routing state for a root correlation id.
+ """Sticky-routing state for a root or SPAWN-child correlation id.

- Tracks which worker owns the session and a refcount so DAG children that
+ Tracks which worker owns the session and a refcount so FORK children that
pin themselves to the parent's worker can keep the entry alive past the
parent's own final turn. ``parent_final_seen`` records whether the owning
session has finished its final turn; the entry is popped only once both
@@ -75,8 +75,10 @@ class WorkerLoad:

Note on active_sessions:
- active_sessions and active_session_ids only represent the number of sticky sessions assigned
- to the worker, which inherently means that it only tracks sessions with MORE turns left. This is
- because sticky sessions are only created when more than 1 turn exists, and are removed when SENDING the final turn.
+ to the worker. An entry is held from the session's first non-final turn (or a final turn that
+ forks) until it is released: when SENDING the final turn, or, for a forking final turn, when its
+ FORK children release it.
+ - Roots and SPAWN children each count as a session; FORK children share their parent's.
"""

worker_id: str
@@ -189,6 +191,9 @@ class StickyCreditRouter(CommunicationMixin):
- First turn → least-loaded worker (creates sticky session).
- Subsequent turns → same worker via sticky session lookup.
- Final turn → cleanup sticky session.
+ - FORK children → parent's worker via the parent's refcounted entry.
+ - SPAWN children → parent's worker if its entry is live (else least-loaded),
+ then pinned under their own id until their final turn.

Load Balancing:
- Least-loaded worker selection for new sessions using fair load balancing
@@ -282,11 +287,11 @@ def __init__(
Callable[[FirstToken], Awaitable[None]] | None
) = None

- # Sticky sessions: routing_key -> _StickyEntry
- # Routes all turns of a conversation (and DAG children pinned to it) to the
- # same worker. Required because workers cache UserSession state by
- # x_correlation_id. The routing key is ``parent_correlation_id or
- # x_correlation_id`` so FORK-mode children co-locate with their parent.
+ # Sticky sessions: owner correlation id -> _StickyEntry
+ # Routes all turns of a session (and FORK children pinned to it) to the
+ # same worker, because workers cache UserSession state by
+ # x_correlation_id. Entries are keyed by the owning root's or SPAWN
+ # child's own id; FORK children route via their parent's key.
self._sticky_sessions: dict[str, _StickyEntry] = {}

self._cancellation_pending: bool = False
@@ -361,9 +366,25 @@ async def send_credit(self, credit: Credit) -> None:
if not credit.x_correlation_id:
raise RuntimeError("x_correlation_id must be set in Credit")

- # DAG children pin to their parent's worker; otherwise pin to self.
- routing_key = credit.parent_correlation_id or credit.x_correlation_id
- sticky_entry = self._sticky_sessions.get(routing_key)
+ # A SPAWN child pins under its own id from its first turn, so it keeps
+ # one worker even when the parent has no entry (parent finished, or
+ # gated or rootless at replay start). FORK children route via the
+ # parent's entry; roots pin to self.
+ is_final = credit.is_final_turn
+ is_dag_child = credit.parent_correlation_id is not None
+ # Identity check: CaseInsensitiveStrEnum.__eq__ is costly on this hot
+ # path, and every Credit is built with enum members.
+ is_spawn_child = (
+ is_dag_child and credit.branch_mode is ConversationBranchMode.SPAWN
+ )
+ own_key = credit.x_correlation_id
+ own_entry = self._sticky_sessions.get(own_key) if is_spawn_child else None
+ if own_entry is not None:
+ routing_key = own_key
+ sticky_entry = own_entry
+ else:
+ routing_key = credit.parent_correlation_id or own_key
+ sticky_entry = self._sticky_sessions.get(routing_key)
sticky_worker_id = sticky_entry.worker_id if sticky_entry is not None else None

# Use existing sticky session if worker still valid
@@ -404,23 +425,15 @@ async def send_credit(self, credit: Credit) -> None:
worker_id = best_worker_id

# Create or rebind the sticky entry for non-final turns; also create
- # it when the final turn declares DAG spawns so the orchestrator's
+ # it when the final turn declares FORK children so the orchestrator's
# register_child_routing can find it.
#
- # DAG branch-children (parent_correlation_id set — FORK or SPAWN)
- # must not auto-create when the parent's sticky entry is already
- # gone. The auto-create path would mint a fresh entry keyed by the
- # parent's id, bumping ``active_sessions`` with no path to evict it
- # (final-turn eviction is gated on parent_correlation_id is None;
- # release_child_routing only decrements an existing entry). That
- # leaks active_sessions and biases load balancing. When the parent
- # entry still exists, both FORK and SPAWN children co-locate via
- # the sticky hit above (routing_key = parent_correlation_id).
- # SPAWN differs only in refcount: the orchestrator does not call
- # register_child_routing for SPAWN. When the parent entry is gone,
- # children fall through to least-loaded without minting a leak.
- is_dag_child = credit.parent_correlation_id is not None
- if not credit.is_final_turn or credit.has_forks:
+ # DAG children never mint an entry keyed by the parent's id: nothing
+ # would release it (final-turn eviction is keyed by the owner's own
+ # id; release_child_routing only decrements an existing entry), so
+ # it would leak active_sessions and bias load balancing. SPAWN
+ # children get their own entry below instead.
+ if not is_final or credit.has_forks:
if sticky_entry is None and not is_dag_child:
sticky_entry = _StickyEntry(worker_id=worker_id)
self._sticky_sessions[routing_key] = sticky_entry
@@ -436,20 +449,29 @@ async def send_credit(self, credit: Credit) -> None:
load.active_sessions += 1
load.active_session_ids.add(routing_key)

- # Owning session's final turn: mark parent_final_seen and decrement the
- # reservation. DAG children never touch the parent entry (managed via
- # release_child_routing). If this turn has DAG spawns, leave the entry
+ if is_spawn_child and own_entry is None and (not is_final or credit.has_forks):
+ own_entry = self._sticky_sessions[own_key] = _StickyEntry(
+ worker_id=worker_id
+ )
+ load = self._workers[worker_id]
+ load.active_sessions += 1
+ load.active_session_ids.add(own_key)
+
+ # Owning session's final turn (a root or SPAWN child, keyed by its own
+ # id): mark parent_final_seen and decrement the reservation. FORK
+ # children never touch the parent entry (managed via
+ # release_child_routing). If this turn forks, leave the entry
# in place so register_child_routing lands on the same _StickyEntry.
- if credit.is_final_turn and credit.parent_correlation_id is None:
- entry = sticky_entry or self._sticky_sessions.get(routing_key)
+ if is_final and (not is_dag_child or is_spawn_child):
+ entry = own_entry if is_spawn_child else sticky_entry
if entry is not None:
entry.parent_final_seen = True
entry.ref_count -= 1
if entry.ref_count <= 0 and not credit.has_forks:
- self._sticky_sessions.pop(routing_key, None)
+ self._sticky_sessions.pop(own_key, None)
load = self._workers[worker_id]
load.active_sessions -= 1
- load.active_session_ids.discard(routing_key)
+ load.active_session_ids.discard(own_key)

self._track_credit_sent(worker_id, credit.id)

@@ -499,9 +521,9 @@ def mark_credits_complete(self) -> None:
def register_child_routing(self, parent_correlation_id: str) -> None:
"""Increment the sticky-routing refcount for a parent's entry.

- Called by ``BranchOrchestrator`` before dispatching each DAG child so
+ Called by ``BranchOrchestrator`` before dispatching each FORK child so
the parent's sticky entry survives past its own final turn until every
- descendant child session has terminated. If the parent has no active
+ FORK child has terminated. If the parent has no active
sticky entry we log a warning and continue without raising - the
child will route via least-loaded selection rather than co-locating
with the parent's worker, losing prefix-cache locality but not
@@ -518,7 +540,7 @@ def register_child_routing(self, parent_correlation_id: str) -> None:
)

def release_child_routing(self, parent_correlation_id: str) -> None:
- """Decrement the sticky-routing refcount when a DAG child terminates.
+ """Decrement the sticky-routing refcount when a FORK child terminates.

Called by ``BranchOrchestrator`` when a child session reaches a leaf
or errors out. If the refcount reaches zero and the parent's own final
7 changes: 7 additions & 0 deletions inferencex-e2e/infx/bench/agentic/patches/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# AgentX runtime patches

Recipes opt into a patch using `benchmark.env.AIPERF_RUNTIME_PATCH`. The AgentX bootstrap applies it to the isolated PyPI installation before starting the client. Source paths use the upstream `src/aiperf` layout (`patch -p2`). Unselected recipes use the unmodified release.

| Patch | Source | Fix |
| --- | --- | --- |
| `52-spawn-worker-pinning.patch` | [agentx-harness #52](https://github.com/SemiAnalysisAI/agentx-harness/pull/52), commit `b5d7652b50aceb9e0c4ca77daf9ae8f472ac77fd` | Pin each SPAWN child to one client worker to prevent stale session copies accumulating across workers. Includes the runtime routing change; documentation-only changes and upstream tests are omitted. |
15 changes: 15 additions & 0 deletions inferencex-e2e/infx/bench/agentic/venv.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import hashlib
import os
import shutil
import sys
Expand Down Expand Up @@ -79,8 +80,22 @@ def bootstrap(runtime: Runtime) -> int:
)
if not (_executable(runtime.agentx) and _executable(runtime.hf)):
raise BenchError(f"isolated AgentX environment is incomplete at {runtime.venv}")
if patch := os.environ.get("AIPERF_RUNTIME_PATCH"):
apply_patch(runtime, Path(patch))
return 0


def apply_patch(runtime: Runtime, patch: Path) -> None:
"""Apply a recipe-selected source patch to the isolated installed harness."""
digest = hashlib.sha256(patch.read_bytes()).hexdigest()
site_packages = runtime.venv / "lib" / f"python{PYTHON_VERSION}" / "site-packages"
print(f"Applying AgentX runtime patch {patch.name} sha256={digest}", flush=True)
if proc.call(
["patch", "--batch", "--forward", "--fuzz=0", "-p2", "-i", str(patch.resolve())],
cwd=site_packages,
):
raise BenchError(f"AgentX runtime patch failed: {patch}")


def _executable(path: Path) -> bool:
return path.is_file() and os.access(path, os.X_OK)
23 changes: 22 additions & 1 deletion inferencex-e2e/infx/tests/bench/test_agentic_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

import pytest

from infx.bench.agentic.venv import Runtime, bootstrap
from infx.bench.agentic.venv import Runtime, apply_patch, bootstrap
from infx.bench.env import BenchError
from infx.tests.bench.stubs import executable

Expand Down Expand Up @@ -123,3 +123,24 @@ def test_unusable_runtime_fails_with_the_reason(tmp_path, tools, monkeypatch, fa

with pytest.raises(BenchError, match=message):
bootstrap(Runtime(tmp_path / "runtime"))


def test_runtime_patch_changes_installed_code_and_rejects_mismatch(tmp_path, tools):
runtime = Runtime(tmp_path / "runtime")
site = runtime.venv / "lib" / "python3.11" / "site-packages"
site.mkdir(parents=True)
installed = site / "example.py"
installed.write_text("value = 1\n")
patch = tmp_path / "example.patch"
patch.write_text(
"diff --git a/src/example.py b/src/example.py\n"
"--- a/src/example.py\n+++ b/src/example.py\n"
"@@ -1 +1 @@\n-value = 1\n+value = 2\n"
)

apply_patch(runtime, patch)

assert installed.read_text() == "value = 2\n"
assert not (tmp_path / "forbidden").exists()
with pytest.raises(BenchError, match="runtime patch failed"):
apply_patch(runtime, patch)
10 changes: 10 additions & 0 deletions inferencex-e2e/perf-changelog.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9823,3 +9823,13 @@
- "Use master-config recipe selectors and composed AgentX fragments; preserve the dedicated frontend/client node and the c2884 and c1228 tuning."
- "Backport the vLLM Python sidecar reasoning-parser fix through the shared patch mechanism while retaining the main srt-slurm pin."
pr-link: https://github.com/SemiAnalysisAI/InferenceX/pull/3941

- config-keys:
- dsv41flash-fp4-gb300-dynamo-vllm-agentic-dspark-disagg
scenario-type:
- agentic-coding
description:
- "Append the GB300 DeepSeek-V4.1-Flash Dynamo + vLLM AgentX 8P1D c5120 and 10P1D c6656 points to the existing curve."
- "Use AgentX 1.0.9 with the SPAWN worker-pinning patch and t-digest timing storage only for these new high-concurrency points."
pr-link: https://github.com/SemiAnalysisAI/InferenceX/pull/4074
append-only: true
Loading