Skip to content

Commit 08ec068

Browse files
committed
feat(agentic): add deterministic warmup request budgets
Port the per-lane warmup quota and robust warmup-to-profiling handoff onto the AgentX v1.0 lineage. Signed-off-by: Cam Quilici <cjquilici@gmail.com>
1 parent 381758a commit 08ec068

18 files changed

Lines changed: 543 additions & 83 deletions

docs/cli-options.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -934,7 +934,12 @@ The maximum duration in seconds for the warmup phase. If not set, it will use th
934934

935935
#### `--agentic-cache-warmup-duration` `<float>`
936936

937-
Additional agentic replay warmup duration in seconds. After the normal snapshot warmup drains, AIPerf continues the live trajectories without recorded idle delays and with one-token outputs, then drains and resumes profiling from the resulting trajectory state using each live stream's residual next-turn delay.
937+
Additional agentic replay warmup duration in seconds. After the normal snapshot warmup drains, AIPerf continues the live trajectories without recorded idle delays and with one-token outputs, then drains and resumes profiling from the resulting trajectory state using each live stream's residual next-turn delay. Mutually exclusive with --warmup-requests-per-lane.
938+
<br/>_Constraints: > 0_
939+
940+
#### `--warmup-requests-per-lane` `<int>`
941+
942+
Deterministic agentic cache-pressure warmup request budget per concurrency lane. For example, 10 with concurrency 16 caps warmup at 160 wire requests, including initial snapshot priming. Mutually exclusive with --agentic-cache-warmup-duration.
938943
<br/>_Constraints: > 0_
939944

940945
#### `--num-warmup-sessions` `<int>`

docs/tutorials/agentx-mvp.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,13 @@ limited to one output token. When the duration expires, it stops issuing new
266266
requests, drains requests already on the wire, snapshots each live root,
267267
subagent, and unresolved join, and starts profiling from that exact state.
268268

269+
For repeatable warmup depth, use
270+
`--warmup-requests-per-lane REQUESTS` instead. Each concurrency lane issues
271+
exactly that many warmup requests, including snapshot priming. For example,
272+
16 lanes with 10 requests per lane produce 160 warmup requests.
273+
274+
The duration and per-lane request options are mutually exclusive.
275+
269276
These requests remain part of `warmup`, so they are excluded from exported
270277
request metrics. The option is disabled by default.
271278

src/aiperf/common/config/loadgen_config.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -503,14 +503,31 @@ def parse_concurrency_list(
503503
"After the normal snapshot warmup drains, AIPerf continues the live "
504504
"trajectories without recorded idle delays and with one-token outputs, "
505505
"then drains and resumes profiling from the resulting trajectory state "
506-
"using each live stream's residual next-turn delay.",
506+
"using each live stream's residual next-turn delay. Mutually exclusive "
507+
"with --warmup-requests-per-lane.",
507508
),
508509
CLIParameter(
509510
name=("--agentic-cache-warmup-duration",),
510511
group=Groups.LOAD_GENERATOR,
511512
),
512513
] = None
513514

515+
warmup_requests_per_lane: Annotated[
516+
int | None,
517+
Field(
518+
gt=0,
519+
description="Deterministic agentic cache-pressure warmup request "
520+
"budget per concurrency lane. For example, 10 with concurrency 16 "
521+
"caps warmup at 160 wire requests, including initial snapshot "
522+
"priming. Mutually exclusive with "
523+
"--agentic-cache-warmup-duration.",
524+
),
525+
CLIParameter(
526+
name=("--warmup-requests-per-lane",),
527+
group=Groups.LOAD_GENERATOR,
528+
),
529+
] = None
530+
514531
warmup_num_sessions: Annotated[
515532
int | None,
516533
Field(
@@ -917,6 +934,7 @@ def disable_warmup(self) -> None:
917934
self.warmup_request_count = None
918935
self.warmup_duration = None
919936
self.agentic_cache_warmup_duration = None
937+
self.warmup_requests_per_lane = None
920938
self.warmup_num_sessions = None
921939

922940
# Warmup load parameters

src/aiperf/common/config/user_config.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1215,12 +1215,18 @@ def validate_warmup_grace_period(self) -> Self:
12151215
@model_validator(mode="after")
12161216
def validate_agentic_cache_warmup(self) -> Self:
12171217
"""Restrict accelerated cache warmup to agentic replay."""
1218-
if self.loadgen.agentic_cache_warmup_duration is None:
1218+
has_duration = self.loadgen.agentic_cache_warmup_duration is not None
1219+
has_request_budget = self.loadgen.warmup_requests_per_lane is not None
1220+
if not has_duration and not has_request_budget:
12191221
return self
1222+
if has_duration and has_request_budget:
1223+
raise ValueError(
1224+
"--warmup-requests-per-lane and "
1225+
"--agentic-cache-warmup-duration are mutually exclusive."
1226+
)
12201227
if self.timing_mode != TimingMode.AGENTIC_REPLAY:
12211228
raise ValueError(
1222-
"--agentic-cache-warmup-duration requires the agentic_replay "
1223-
"timing mode."
1229+
"agentic cache warmup requires the agentic_replay timing mode."
12241230
)
12251231
return self
12261232

src/aiperf/credit/callback_handler.py

Lines changed: 43 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -280,8 +280,10 @@ async def _handle_warmup_failure(
280280
return
281281
self._warmup_abort_triggered = True
282282
_logger.warning(
283-
lambda: f"Terminal warmup failure for trace {credit.conversation_id}; "
284-
f"aborting run early (broadcasting ProfileCancelCommand)."
283+
lambda: (
284+
f"Terminal warmup failure for trace {credit.conversation_id}; "
285+
f"aborting run early (broadcasting ProfileCancelCommand)."
286+
)
285287
)
286288
try:
287289
await self._on_warmup_abort()
@@ -316,16 +318,20 @@ async def on_credit_return(
316318
handler = self._phase_handlers.get(phase)
317319
if not handler:
318320
_logger.debug(
319-
lambda: f"Credit return for unregistered phase {phase}, "
320-
f"credit_id={credit.id}, worker={worker_id}"
321+
lambda: (
322+
f"Credit return for unregistered phase {phase}, "
323+
f"credit_id={credit.id}, worker={worker_id}"
324+
)
321325
)
322326
return
323327

324328
# Late arrivals after phase complete are logged but don't affect counts
325329
if handler.lifecycle.is_complete:
326330
_logger.warning(
327-
lambda: f"Credit return after phase {phase} complete, "
328-
f"credit_id={credit.id}, worker={worker_id}"
331+
lambda: (
332+
f"Credit return after phase {phase} complete, "
333+
f"credit_id={credit.id}, worker={worker_id}"
334+
)
329335
)
330336
return
331337

@@ -399,9 +405,11 @@ async def on_credit_return(
399405
)
400406
except Exception as exc: # noqa: BLE001
401407
_logger.warning(
402-
lambda exc=exc: f"BranchOrchestrator child-completion "
403-
f"hook failed for x_correlation_id="
404-
f"{credit.x_correlation_id}: {exc}"
408+
lambda exc=exc: (
409+
f"BranchOrchestrator child-completion "
410+
f"hook failed for x_correlation_id="
411+
f"{credit.x_correlation_id}: {exc}"
412+
)
405413
)
406414

407415
observe_credit_return = getattr(handler.strategy, "observe_credit_return", None)
@@ -450,23 +458,26 @@ async def on_credit_return(
450458
# behind ``can_send_child_turn`` instead — the phase-level
451459
# sending-complete flag is driven by root sampling exhaustion, not
452460
# by DAG work, but the global ``--request-count`` cap still
453-
# applies. When the cap blocks a non-final child continuation, we
454-
# notify the orchestrator (``on_child_stopped``) so the parent's
455-
# join still drains instead of deadlocking on a child whose
456-
# remaining turns will never be issued. Final-turn child returns
457-
# are always passed through (the strategy is a no-op for them, but
458-
# observer hooks still need to fire).
461+
# applies. In terminal phases, a blocked non-final child notifies the
462+
# orchestrator (``on_child_stopped``) so the parent's join can drain.
463+
# Strategies requesting stopped returns (such as quota warmup) instead
464+
# preserve resumable children across a phase handoff. Final-turn child
465+
# returns are always passed through.
466+
wants_stopped_returns = (
467+
getattr(handler.strategy, "wants_returns_after_sending_complete", False)
468+
is True
469+
)
459470
is_child = credit.agent_depth > 0
460471
if not is_child:
461-
wants_stopped_returns = (
462-
getattr(handler.strategy, "wants_returns_after_sending_complete", False)
463-
is True
464-
)
465472
if handler.stop_checker.can_send_any_turn() or wants_stopped_returns:
466473
await handler.strategy.handle_credit_return(
467474
credit, error=credit_return.error
468475
)
469-
elif credit.is_final_turn or handler.stop_checker.can_send_child_turn():
476+
elif (
477+
credit.is_final_turn
478+
or handler.stop_checker.can_send_child_turn()
479+
or wants_stopped_returns
480+
):
470481
await handler.strategy.handle_credit_return(
471482
credit, error=credit_return.error
472483
)
@@ -477,9 +488,11 @@ async def on_credit_return(
477488
)
478489
except Exception as exc: # noqa: BLE001
479490
_logger.warning(
480-
lambda exc=exc: f"BranchOrchestrator on_child_stopped "
481-
f"hook failed for x_correlation_id="
482-
f"{credit.x_correlation_id}: {exc}"
491+
lambda exc=exc: (
492+
f"BranchOrchestrator on_child_stopped "
493+
f"hook failed for x_correlation_id="
494+
f"{credit.x_correlation_id}: {exc}"
495+
)
483496
)
484497

485498
# WARMUP terminal-failure accumulation + live early-abort (agentic replay).
@@ -587,7 +600,9 @@ def _release_slots_for_return(
587600
in_flight = handler.progress.in_flight_sessions
588601
if in_flight > 0:
589602
_logger.debug(
590-
lambda: f"Releasing {in_flight} in-flight session slots for phase {phase}"
603+
lambda: (
604+
f"Releasing {in_flight} in-flight session slots for phase {phase}"
605+
)
591606
)
592607
for _ in range(in_flight):
593608
concurrency.release_session_slot(phase)
@@ -611,8 +626,10 @@ async def on_first_token(self, first_token: FirstToken) -> None:
611626

612627
if not handler:
613628
_logger.debug(
614-
lambda: f"TTFT for unregistered phase {phase}, "
615-
f"credit_id={first_token.credit_id}"
629+
lambda: (
630+
f"TTFT for unregistered phase {phase}, "
631+
f"credit_id={first_token.credit_id}"
632+
)
616633
)
617634
return
618635

src/aiperf/credit/issuer.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from __future__ import annotations
1616

1717
import time
18+
from collections.abc import Callable
1819
from typing import TYPE_CHECKING
1920

2021
from msgspec.structs import replace as _struct_replace
@@ -113,6 +114,15 @@ def __init__(
113114
self._issuing_stopped = False
114115
self.replay_gate = ReplayIssueGate(replay_barrier)
115116
self._max_tokens_override: int | None = None
117+
self._turn_admission: Callable[[TurnToSend], bool] | None = None
118+
119+
def set_turn_admission(self, callback: Callable[[TurnToSend], bool]) -> None:
120+
"""Install a synchronous final admission check for every turn."""
121+
self._turn_admission = callback
122+
123+
def _is_turn_admitted(self, turn: TurnToSend) -> bool:
124+
"""Run the optional final admission check."""
125+
return self._turn_admission is None or self._turn_admission(turn)
116126

117127
def set_max_tokens_override(self, max_tokens: int | None) -> None:
118128
"""Override generation length for every subsequently issued credit."""
@@ -268,7 +278,6 @@ async def _issue_credit_ready(self, turn: TurnToSend) -> bool:
268278
)
269279
if not acquired:
270280
return False
271-
self._open_session_tree(turn)
272281

273282
# Prefill concurrency: one slot per request, released when TTFT arrives.
274283
# Limits concurrent prompt processing which is the GPU-intensive phase.
@@ -281,6 +290,15 @@ async def _issue_credit_ready(self, turn: TurnToSend) -> bool:
281290
self._concurrency_manager.release_session_slot(self._phase)
282291
return False
283292

293+
if not self._is_turn_admitted(turn):
294+
self._concurrency_manager.release_prefill_slot(self._phase)
295+
if needs_session_slot:
296+
self._concurrency_manager.release_session_slot(self._phase)
297+
return False
298+
299+
if needs_session_slot:
300+
self._open_session_tree(turn)
301+
284302
# Slots acquired - proceed with credit issuance
285303
return await self._issue_credit_internal(turn)
286304

@@ -322,7 +340,6 @@ async def try_issue_credit(self, turn: TurnToSend) -> bool | None:
322340
)
323341
if not acquired:
324342
return None # No slot - credit not issued
325-
self._open_session_tree(turn)
326343

327344
acquired = self._concurrency_manager.try_acquire_prefill_slot(
328345
self._phase, can_proceed_fn
@@ -333,6 +350,15 @@ async def try_issue_credit(self, turn: TurnToSend) -> bool | None:
333350
self._concurrency_manager.release_session_slot(self._phase)
334351
return None # No slot - credit not issued
335352

353+
if not self._is_turn_admitted(turn):
354+
self._concurrency_manager.release_prefill_slot(self._phase)
355+
if needs_session_slot:
356+
self._concurrency_manager.release_session_slot(self._phase)
357+
return False
358+
359+
if needs_session_slot:
360+
self._open_session_tree(turn)
361+
336362
return await self._issue_credit_internal(turn)
337363

338364
async def _issue_credit_internal(self, turn: TurnToSend) -> bool:
@@ -442,6 +468,9 @@ async def _dispatch_child_turn_ready(self, turn: TurnToSend) -> bool:
442468
self._phase, can_proceed_fn
443469
):
444470
return False
471+
if not self._is_turn_admitted(turn):
472+
self._concurrency_manager.release_prefill_slot(self._phase)
473+
return False
445474
if turn.counts_toward_phase_target:
446475
turn = _struct_replace(turn, counts_toward_phase_target=False)
447476
await self._issue_credit_internal(turn)

src/aiperf/dataset/loader/weka_synth_buf.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -442,7 +442,7 @@ def _assert_trailing_user(self) -> None:
442442
def turn_delta(self) -> TurnDelta:
443443
"""Compute the raw_messages to emit for the just-completed turn.
444444
445-
Three cases:
445+
Four cases:
446446
1. First call after ``init_turn_0`` (``_emitted_segment_count == 0``):
447447
emit ALL current segments, ``reset_context=False``. This is
448448
turn 0's baseline state.
@@ -452,6 +452,10 @@ def turn_delta(self) -> TurnDelta:
452452
3. Disturbance touched a previously-emitted segment (index
453453
``< _emitted_segment_count``): emit ALL current segments,
454454
``reset_context=True``.
455+
4. An equal-context retry appended no segments: re-emit ALL current
456+
segments with ``reset_context=True``. An empty delta would
457+
otherwise render as an invalid empty user message instead of the
458+
recorded repeated request.
455459
456460
Updates ``_emitted_segment_count`` to ``len(self._segments)`` on
457461
return. Clears ``_last_disturbance_at`` to ``None``.
@@ -460,9 +464,16 @@ def turn_delta(self) -> TurnDelta:
460464
self._last_disturbance_at is not None
461465
and self._last_disturbance_at < self._emitted_segment_count
462466
)
463-
if self._emitted_segment_count == 0 or disturbed_emitted:
467+
unchanged_retry = (
468+
self._emitted_segment_count > 0
469+
and self._emitted_segment_count == len(self._segments)
470+
and not disturbed_emitted
471+
)
472+
if self._emitted_segment_count == 0 or disturbed_emitted or unchanged_retry:
464473
source = self._segments
465-
reset = self._emitted_segment_count != 0 and disturbed_emitted
474+
reset = self._emitted_segment_count != 0 and (
475+
disturbed_emitted or unchanged_retry
476+
)
466477
else:
467478
source = self._segments[self._emitted_segment_count :]
468479
reset = False

src/aiperf/timing/config.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,13 @@ class CreditPhaseConfig(AIPerfBaseModel):
221221
description="Duration of the accelerated cache-pressure substage for "
222222
"agentic replay warmup.",
223223
)
224+
warmup_requests_per_lane: int | None = Field(
225+
default=None,
226+
gt=0,
227+
description="Deterministic cache-pressure warmup wire-request budget "
228+
"per live agentic replay lane. Mutually exclusive with "
229+
"agentic_cache_warmup_duration_sec.",
230+
)
224231

225232

226233
def _agentic_warmup_grace_period(loadgen: LoadGeneratorConfig) -> float | None:
@@ -241,12 +248,25 @@ def _agentic_warmup_grace_period(loadgen: LoadGeneratorConfig) -> float | None:
241248

242249
def _build_agentic_warmup_config(loadgen: LoadGeneratorConfig) -> CreditPhaseConfig:
243250
cache_warmup_duration = loadgen.agentic_cache_warmup_duration
251+
requests_per_lane = loadgen.warmup_requests_per_lane
252+
cache_warmup_request_cap = (
253+
loadgen.concurrency * requests_per_lane
254+
if loadgen.concurrency is not None and requests_per_lane is not None
255+
else None
256+
)
257+
if cache_warmup_request_cap is not None:
258+
total_expected_requests = cache_warmup_request_cap
259+
elif cache_warmup_duration is not None:
260+
total_expected_requests = None
261+
else:
262+
total_expected_requests = loadgen.concurrency
244263
return CreditPhaseConfig(
245264
phase=CreditPhase.WARMUP,
246265
timing_mode=TimingMode.AGENTIC_REPLAY,
247-
total_expected_requests=(
248-
None if cache_warmup_duration is not None else loadgen.concurrency
249-
),
266+
# Duration mode is strategy-terminated by its timer. Count mode uses
267+
# the generic request-count stop condition as a global backstop while
268+
# the agentic strategy independently enforces each lane's quota.
269+
total_expected_requests=total_expected_requests,
250270
expected_duration_sec=None,
251271
expected_num_sessions=None,
252272
concurrency=loadgen.concurrency,
@@ -257,6 +277,7 @@ def _build_agentic_warmup_config(loadgen: LoadGeneratorConfig) -> CreditPhaseCon
257277
seamless=False,
258278
grace_period_sec=_agentic_warmup_grace_period(loadgen),
259279
agentic_cache_warmup_duration_sec=cache_warmup_duration,
280+
warmup_requests_per_lane=requests_per_lane,
260281
)
261282

262283

0 commit comments

Comments
 (0)