Skip to content

Commit 634455d

Browse files
ushaketcursoragent
andcommitted
Add cross-rate early exit for multi-rate benchmark profiles
When running multiple rates (constant, poisson, concurrent profiles) or sweeping, stop escalating to higher rates if a failure constraint (over-saturation, max errors, error rate) triggers at a lower rate. - Sort rates/streams ascending in AsyncProfile and ConcurrentProfile - Add _should_stop_escalating() on base Profile class using stop_all as the failure signal (vs stop_local for normal completions) - Skip failure check after throughput phase in SweepProfile since over-saturation is expected at maximum load - Log warning when rate order is changed by sorting - Update CLI help and README with multi-rate documentation - Add comprehensive unit tests for all profile types Signed-off-by: Uri Shaket <ushaket@redhat.com> Co-authored-by: Cursor <cursoragent@cursor.com> Signed-off-by: Uri Shaket <ushaket@redhat.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 6a666e6 commit 634455d

6 files changed

Lines changed: 535 additions & 11 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ guidellm run \
180180
**Key parameters:**
181181

182182
- `--profile kind=<type>`: Defines the traffic pattern — `synchronous`, `concurrent`, `throughput`, `constant`, `poisson`, or `sweep`
183-
- `--profile kind=constant,rate=10`: For `constant`/`poisson`, set requests per second in the profile config; for `concurrent`, use `streams=`; for `throughput`, use `max_concurrency=`
183+
- `--profile kind=constant,rate=10`: For `constant`/`poisson`, set requests per second in the profile config; for `concurrent`, use `streams=`; for `throughput`, use `max_concurrency=`. For `constant`, `poisson`, and `concurrent`, multiple rates can be specified as a JSON list (e.g., `rate=[1,5,10]`). Rates are sorted ascending, and if a failure constraint (over-saturation, errors) triggers at a given rate, remaining higher rates are skipped.
184184
- `--constraint kind=max_duration,seconds=<seconds>` or `--constraint kind=max_requests,count=<count>`: Limit each strategy by time or request count
185185

186186
### Dataset Sources

src/guidellm/benchmark/profiles/asynchronous.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from pydantic import Field, PositiveFloat, PositiveInt, field_validator
1010

1111
from guidellm.benchmark.schemas import ProfileArgs
12+
from guidellm.logger import logger
1213
from guidellm.scheduler import (
1314
AsyncConstantStrategy,
1415
AsyncPoissonStrategy,
@@ -56,7 +57,12 @@ def _coerce_rate_to_list(
5657
if not value:
5758
raise ValueError("rate requires at least one value")
5859
if isinstance(value, list | tuple):
59-
return value
60+
sorted_rates = sorted(value)
61+
if list(sorted_rates) != list(value):
62+
logger.warning(
63+
f"Rates reordered from {list(value)} to {sorted_rates} (ascending)"
64+
)
65+
return sorted_rates
6066
if isinstance(value, int | float):
6167
return [value]
6268
raise ValueError(
@@ -107,17 +113,24 @@ def next_strategy(
107113
"""
108114
Generate async strategy for next configured rate.
109115
110-
:param prev_strategy: Previously completed strategy (unused)
111-
:param prev_benchmark: Benchmark results from previous execution (unused)
116+
Rates are sorted ascending, so if a previous rate was terminated by a
117+
failure constraint (over-saturation, errors, etc.), all remaining higher
118+
rates are skipped.
119+
120+
:param prev_strategy: Previously completed strategy
121+
:param prev_benchmark: Benchmark results from previous execution
112122
:return: AsyncConstantStrategy or AsyncPoissonStrategy for next rate,
113-
or None if all rates completed
123+
or None if all rates completed or failure detected
114124
:raises ValueError: If strategy_type is neither 'constant' nor 'poisson'
115125
"""
116-
_ = (prev_strategy, prev_benchmark) # unused
126+
_ = prev_strategy
117127

118128
if len(self.completed_strategies) >= len(self.args.rate):
119129
return None
120130

131+
if prev_benchmark is not None and self._should_stop_escalating(prev_benchmark):
132+
return None
133+
121134
current_rate = self.args.rate[len(self.completed_strategies)]
122135

123136
if self._strategy_type == "constant":

src/guidellm/benchmark/profiles/concurrent.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from pydantic import Field, PositiveInt, field_validator
1010

1111
from guidellm.benchmark.schemas import ProfileArgs
12+
from guidellm.logger import logger
1213
from guidellm.scheduler import (
1314
ConcurrentStrategy,
1415
ConstraintInitializer,
@@ -48,7 +49,13 @@ def _coerce_streams_to_list(cls, value: Any) -> Any:
4849
if not value:
4950
raise ValueError("streams requires at least one value")
5051
if isinstance(value, list | tuple):
51-
return [int(stream) for stream in value]
52+
streams = [int(stream) for stream in value]
53+
sorted_streams = sorted(streams)
54+
if sorted_streams != streams:
55+
logger.warning(
56+
f"Streams reordered from {streams} to {sorted_streams} (ascending)"
57+
)
58+
return sorted_streams
5259
if isinstance(value, int | float):
5360
return [int(value)]
5461
raise ValueError(
@@ -93,15 +100,23 @@ def next_strategy(
93100
"""
94101
Generate concurrent strategy for next stream count.
95102
96-
:param prev_strategy: Previously completed strategy (unused)
97-
:param prev_benchmark: Benchmark results from previous execution (unused)
103+
Stream counts are sorted ascending, so if a previous stream count was
104+
terminated by a failure constraint (over-saturation, errors, etc.), all
105+
remaining higher stream counts are skipped.
106+
107+
:param prev_strategy: Previously completed strategy
108+
:param prev_benchmark: Benchmark results from previous execution
98109
:return: ConcurrentStrategy with next stream count, or None if complete
110+
or failure detected
99111
"""
100-
_ = (prev_strategy, prev_benchmark) # unused
112+
_ = prev_strategy
101113

102114
if len(self.completed_strategies) >= len(self.args.streams):
103115
return None
104116

117+
if prev_benchmark is not None and self._should_stop_escalating(prev_benchmark):
118+
return None
119+
105120
return ConcurrentStrategy(
106121
streams=self.args.streams[len(self.completed_strategies)],
107122
rampup_duration=self.args.rampup_duration,

src/guidellm/benchmark/profiles/profile.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from typing import Any
1010

1111
from guidellm.benchmark.schemas import Benchmark, ProfileArgs
12+
from guidellm.logger import logger
1213
from guidellm.scheduler import (
1314
Constraint,
1415
ConstraintInitializer,
@@ -115,6 +116,33 @@ def strategy_types(self) -> list[str]:
115116
"""
116117
return [strat.type_ for strat in self.completed_strategies]
117118

119+
@staticmethod
120+
def _should_stop_escalating(prev_benchmark: Benchmark) -> bool:
121+
"""
122+
Check if a benchmark was terminated by a failure constraint.
123+
124+
Inspects the scheduler state's end_queuing_constraints for any constraint
125+
that used "stop_all" for request processing, which indicates the system
126+
could not handle the load (over-saturation, excessive errors, etc.).
127+
Constraints that use "stop_local" (max duration, max requests) are normal
128+
completions and do not trigger escalation stops.
129+
130+
:param prev_benchmark: Benchmark instance with a scheduler_state attribute
131+
:return: True if a failure constraint was triggered, False otherwise
132+
"""
133+
scheduler_state = getattr(prev_benchmark, "scheduler_state", None)
134+
if scheduler_state is None:
135+
return False
136+
137+
for name, action in scheduler_state.end_queuing_constraints.items():
138+
if action.request_processing == "stop_all":
139+
logger.info(
140+
f"Stopping rate escalation: constraint '{name}' "
141+
f"triggered (request_processing=stop_all)"
142+
)
143+
return True
144+
return False
145+
118146
def strategies_generator(
119147
self,
120148
) -> Generator[

src/guidellm/benchmark/profiles/sweep.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,9 @@ def next_strategy(
9797
Generate next strategy in adaptive sweep sequence.
9898
9999
Executes synchronous and throughput strategies first to measure baseline
100-
rates, then generates interpolated rates for async strategies.
100+
rates, then generates interpolated rates for async strategies. If a
101+
failure constraint is triggered during the async phase, all remaining
102+
higher rates are skipped.
101103
102104
:param prev_strategy: Previously completed strategy instance
103105
:param prev_benchmark: Benchmark results from previous strategy execution
@@ -129,6 +131,18 @@ def next_strategy(
129131
self.args.sweep_size - 1,
130132
)
131133
)[1:] # don't rerun synchronous
134+
# After throughput, fall through to async rate logic below.
135+
# Don't check escalation since throughput is designed to push
136+
# beyond sustainable load (over-saturation is expected).
137+
138+
# Stop escalation if a failure constraint was triggered.
139+
# The throughput guard above skips this via the != "throughput" check.
140+
# Synchronous never reaches here (returns ThroughputStrategy above).
141+
if (
142+
prev_strategy.type_ != "throughput"
143+
and self._should_stop_escalating(prev_benchmark) # type: ignore[arg-type]
144+
):
145+
return None
132146

133147
next_index = (
134148
len(self.completed_strategies) - 1 - 1

0 commit comments

Comments
 (0)