Skip to content

Commit cdef5af

Browse files
committed
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>
1 parent 16537fa commit cdef5af

4 files changed

Lines changed: 526 additions & 15 deletions

File tree

README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,13 @@ guidellm benchmark \
174174
**Key parameters:**
175175

176176
- `--profile`: Defines the traffic pattern - options include `synchronous` (sequential requests), `concurrent` (parallel users), `throughput` (maximum capacity), `constant` (fixed requests/sec), `poisson` (randomized requests/sec), or `sweep` (automatic rate exploration)
177-
- `--rate`: The numeric rate value whose meaning depends on profile - for `sweep` it's the number of benchmarks, for `concurrent` it's simultaneous requests, for `constant`/`poisson` it's requests per second
177+
- `--rate`: The numeric rate value whose meaning depends on profile:
178+
- `constant`/`poisson`: requests per second
179+
- `concurrent`: number of simultaneous streams
180+
- `sweep`: number of benchmarks (only first value used)
181+
- `throughput`: max concurrency (only first value used)
182+
183+
For `constant`, `poisson`, and `concurrent`, multiple values can be specified (e.g., `--rate 1 --rate 5 --rate 10`). Values are sorted ascending, and if a failure constraint (over-saturation, errors) triggers at a given rate, remaining higher rates are skipped.
178184
- `--max-seconds`: Maximum duration in seconds for each benchmark run (can also use `--max-requests` to limit by request count instead)
179185

180186
### Dataset Sources

src/guidellm/__main__.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,8 +149,14 @@ def benchmark():
149149
default=BenchmarkGenerativeTextArgs.get_default("rate"),
150150
help=(
151151
"Benchmark rate(s) to test. Meaning depends on profile: "
152-
"sweep=number of benchmarks, concurrent=concurrent requests, "
153-
"async/constant/poisson=requests per second."
152+
"constant/poisson=requests per second, "
153+
"concurrent=number of parallel streams, "
154+
"sweep=number of benchmarks (only first value used), "
155+
"throughput=max concurrency (only first value used). "
156+
"For constant, poisson, and concurrent profiles, multiple values "
157+
"can be specified (e.g., --rate 1 --rate 5 --rate 10), are sorted "
158+
"ascending, and if a failure constraint (over-saturation, errors) "
159+
"triggers at a given rate, higher rates are skipped."
154160
),
155161
)
156162
# Backend configuration

src/guidellm/benchmark/profiles.py

Lines changed: 81 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
)
2828

2929
from guidellm import settings
30+
from guidellm.logger import logger
3031
from guidellm.scheduler import (
3132
AsyncConstantStrategy,
3233
AsyncPoissonStrategy,
@@ -162,6 +163,33 @@ def strategy_types(self) -> list[str]:
162163
"""
163164
return [strat.type_ for strat in self.completed_strategies]
164165

166+
@staticmethod
167+
def _should_stop_escalating(prev_benchmark: Benchmark) -> bool:
168+
"""
169+
Check if a benchmark was terminated by a failure constraint.
170+
171+
Inspects the scheduler state's end_queuing_constraints for any constraint
172+
that used "stop_all" for request processing, which indicates the system
173+
could not handle the load (over-saturation, excessive errors, etc.).
174+
Constraints that use "stop_local" (max duration, max requests) are normal
175+
completions and do not trigger escalation stops.
176+
177+
:param prev_benchmark: Benchmark instance with a scheduler_state attribute
178+
:return: True if a failure constraint was triggered, False otherwise
179+
"""
180+
scheduler_state = getattr(prev_benchmark, "scheduler_state", None)
181+
if scheduler_state is None:
182+
return False
183+
184+
for name, action in scheduler_state.end_queuing_constraints.items():
185+
if action.request_processing == "stop_all":
186+
logger.info(
187+
f"Stopping rate escalation: constraint '{name}' "
188+
f"triggered (request_processing=stop_all)"
189+
)
190+
return True
191+
return False
192+
165193
def strategies_generator(
166194
self,
167195
) -> Generator[
@@ -362,7 +390,17 @@ def resolve_args(
362390
"""
363391
_ = (rate_type, random_seed) # unused
364392
rate = rate if isinstance(rate, list) or rate is None else [rate]
365-
kwargs["streams"] = [int(stream) for stream in rate] if rate else None
393+
if rate:
394+
streams = [int(stream) for stream in rate]
395+
sorted_streams = sorted(streams)
396+
if sorted_streams != streams:
397+
logger.warning(
398+
f"Streams reordered from {streams} to "
399+
f"{sorted_streams} (ascending)"
400+
)
401+
kwargs["streams"] = sorted_streams
402+
else:
403+
kwargs["streams"] = None
366404
return kwargs
367405

368406
@property
@@ -380,15 +418,21 @@ def next_strategy(
380418
"""
381419
Generate concurrent strategy for next stream count.
382420
383-
:param prev_strategy: Previously completed strategy (unused)
384-
:param prev_benchmark: Benchmark results from previous execution (unused)
421+
Stream counts are sorted ascending, so if a previous stream count was
422+
terminated by a failure constraint (over-saturation, errors, etc.), all
423+
remaining higher stream counts are skipped.
424+
425+
:param prev_strategy: Previously completed strategy
426+
:param prev_benchmark: Benchmark results from previous execution
385427
:return: ConcurrentStrategy with next stream count, or None if complete
428+
or failure detected
386429
"""
387-
_ = (prev_strategy, prev_benchmark) # unused
388-
389430
if len(self.completed_strategies) >= len(self.streams):
390431
return None
391432

433+
if prev_benchmark is not None and self._should_stop_escalating(prev_benchmark):
434+
return None
435+
392436
return ConcurrentStrategy(
393437
streams=self.streams[len(self.completed_strategies)],
394438
rampup_duration=self.rampup_duration,
@@ -522,7 +566,13 @@ def resolve_args(
522566
if rate_type in ["constant", "poisson"]
523567
else kwargs.get("strategy_type", "constant")
524568
)
525-
kwargs["rate"] = rate if isinstance(rate, list) else [rate]
569+
rate_list = rate if isinstance(rate, list) else [rate]
570+
sorted_rates = sorted(rate_list)
571+
if sorted_rates != rate_list:
572+
logger.warning(
573+
f"Rates reordered from {rate_list} to {sorted_rates} (ascending)"
574+
)
575+
kwargs["rate"] = sorted_rates
526576
kwargs["random_seed"] = random_seed
527577
return kwargs
528578

@@ -542,17 +592,22 @@ def next_strategy(
542592
"""
543593
Generate async strategy for next configured rate.
544594
545-
:param prev_strategy: Previously completed strategy (unused)
546-
:param prev_benchmark: Benchmark results from previous execution (unused)
595+
Rates are sorted ascending, so if a previous rate was terminated by a
596+
failure constraint (over-saturation, errors, etc.), all remaining higher
597+
rates are skipped.
598+
599+
:param prev_strategy: Previously completed strategy
600+
:param prev_benchmark: Benchmark results from previous execution
547601
:return: AsyncConstantStrategy or AsyncPoissonStrategy for next rate,
548-
or None if all rates completed
602+
or None if all rates completed or failure detected
549603
:raises ValueError: If strategy_type is neither 'constant' nor 'poisson'
550604
"""
551-
_ = (prev_strategy, prev_benchmark) # unused
552-
553605
if len(self.completed_strategies) >= len(self.rate):
554606
return None
555607

608+
if prev_benchmark is not None and self._should_stop_escalating(prev_benchmark):
609+
return None
610+
556611
current_rate = self.rate[len(self.completed_strategies)]
557612

558613
if self.strategy_type == "constant":
@@ -660,7 +715,9 @@ def next_strategy(
660715
Generate next strategy in adaptive sweep sequence.
661716
662717
Executes synchronous and throughput strategies first to measure baseline
663-
rates, then generates interpolated rates for async strategies.
718+
rates, then generates interpolated rates for async strategies. If a
719+
failure constraint is triggered during the async phase, all remaining
720+
higher rates are skipped.
664721
665722
:param prev_strategy: Previously completed strategy instance
666723
:param prev_benchmark: Benchmark results from previous strategy execution
@@ -692,6 +749,18 @@ def next_strategy(
692749
self.sweep_size - 1,
693750
)
694751
)[1:] # don't rerun synchronous
752+
# After throughput, fall through to async rate logic below.
753+
# Don't check escalation since throughput is designed to push
754+
# beyond sustainable load (over-saturation is expected).
755+
756+
# Stop escalation if a failure constraint was triggered.
757+
# The throughput guard above skips this via the != "throughput" check.
758+
# Synchronous never reaches here (returns ThroughputStrategy above).
759+
if (
760+
prev_strategy.type_ != "throughput"
761+
and self._should_stop_escalating(prev_benchmark)
762+
):
763+
return None
695764

696765
next_index = (
697766
len(self.completed_strategies) - 1 - 1

0 commit comments

Comments
 (0)