Skip to content

Commit 2a6a74e

Browse files
committed
feat(serve): add SageMaker GenAI inference benchmarking and recommendation
1 parent 196a302 commit 2a6a74e

6 files changed

Lines changed: 205 additions & 38 deletions

File tree

‎sagemaker-serve/VERSION‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.13.0
1+
1.13.1

‎sagemaker-serve/src/sagemaker/serve/ai_inference_recommender/_recommendation_view.py‎

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ def __repr__(self) -> str:
197197
f"copy_count_per_instance: {_safe_str(dc, 'copy_count_per_instance')}",
198198
f"image_uri: {_safe_str(dc, 'image_uri')}",
199199
f"model_package: {_safe_str(md, 'model_package_arn')}",
200-
f"inference_spec_name: {_safe_str(md, 'inference_specification_name')}",
200+
f"recommendation_spec_name: {_safe_str(md, 'inference_specification_name')}",
201201
]
202202

203203
env_vars = getattr(dc, "environment_variables", None) if dc else None
@@ -250,3 +250,65 @@ def _safe_float(obj, attr):
250250
return float(value) if value is not None else None
251251
except (TypeError, ValueError):
252252
return None
253+
254+
255+
class _RecommendationsView(list):
256+
"""List of recommendation rows with a comparative ``__repr__`` and a ``.best`` shortcut."""
257+
258+
@property
259+
def best(self):
260+
"""The top-ranked recommendation row."""
261+
if not self:
262+
raise IndexError(
263+
"No recommendations available. The AIRecommendationJob may "
264+
"still be running, or it may have failed before producing "
265+
"recommendations."
266+
)
267+
return self[0]
268+
269+
def __repr__(self) -> str:
270+
if not self:
271+
return "Recommendations[0] (no rows)"
272+
273+
rows = []
274+
for view in self:
275+
md = getattr(view.raw, "model_details", None)
276+
dc = getattr(view.raw, "deployment_configuration", None)
277+
ep = view.expected_performance
278+
rows.append([
279+
f"[{view._index}]",
280+
_safe_str(md, "inference_specification_name"),
281+
_safe_str(dc, "instance_type"),
282+
_safe_str(dc, "instance_count"),
283+
_fmt_number(_get_metric_stat(ep, "request_throughput", "avg")),
284+
_fmt_number(_get_metric_stat(ep, "output_token_throughput", "avg")),
285+
_fmt_number(_get_metric_stat(ep, "request_latency", "p50")),
286+
_fmt_number(_get_metric_stat(ep, "request_latency", "p90")),
287+
_fmt_number(_get_metric_stat(ep, "request_latency", "p99")),
288+
_fmt_number(_get_metric_stat(ep, "time_to_first_token", "p50")),
289+
_fmt_number(_get_metric_stat(ep, "inter_token_latency", "p50")),
290+
])
291+
292+
table = _format_table(
293+
headers=[
294+
"idx", "spec_name", "instance_type", "cnt",
295+
"req/s", "tok/s",
296+
"lat_p50", "lat_p90", "lat_p99",
297+
"ttft_p50", "itl_p50",
298+
],
299+
rows=rows,
300+
)
301+
302+
return (
303+
f"Recommendations[{len(self)}] (.best = top row; index by [N] for full detail)\n"
304+
f"{table}\n"
305+
f"lat/ttft/itl in ms; req/s = requests/sec; tok/s = output tokens/sec"
306+
)
307+
308+
309+
def _get_metric_stat(ep_view, metric_name: str, stat: str):
310+
try:
311+
metric = getattr(ep_view, metric_name)
312+
except AttributeError:
313+
return None
314+
return getattr(metric, stat, None)

‎sagemaker-serve/src/sagemaker/serve/model_builder.py‎

Lines changed: 25 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4298,25 +4298,32 @@ def generate_deployment_recommendations(
42984298
return job
42994299

43004300
@property
4301-
def recommendations(self) -> List:
4301+
def recommendations(self):
43024302
"""Recommendation rows from the most recent ``generate_deployment_recommendations`` call.
43034303
4304-
Returns an empty list if no recommendation job has been run, or if
4305-
the job has not produced recommendations yet (e.g., still
4306-
``InProgress``). Each row is a read-only view that pretty-prints when
4307-
``repr()``'d; field access forwards to the underlying service shape
4308-
(e.g., ``rec.deployment_configuration.instance_type``). Deploy via
4309-
``self.deploy(recommendation_index=N)``.
4304+
Returns a list-like view; ``repr()`` renders a comparative table across
4305+
rows, and ``.best`` is a shortcut for the top-ranked row. Each row
4306+
forwards attribute access to the underlying service shape (e.g.,
4307+
``rec.deployment_configuration.instance_type``).
4308+
4309+
Deploy via ``self.deploy(recommendation_index=N)`` or
4310+
``self.deploy(recommendation_spec_name="...")``.
4311+
4312+
Empty if no recommendation job has been run, or if the job has not
4313+
produced recommendations yet.
43104314
"""
43114315
from sagemaker.serve.ai_inference_recommender._recommendation_view import (
43124316
_RecommendationView,
4317+
_RecommendationsView,
43134318
)
43144319

43154320
job = getattr(self, "_recommendation_job", None)
43164321
if job is None:
4317-
return []
4322+
return _RecommendationsView()
43184323
rows = list(job.recommendations or [])
4319-
return [_RecommendationView(row, index=i) for i, row in enumerate(rows)]
4324+
return _RecommendationsView(
4325+
_RecommendationView(row, index=i) for i, row in enumerate(rows)
4326+
)
43204327

43214328
@classmethod
43224329
def from_recommendation_job(
@@ -4331,7 +4338,7 @@ def from_recommendation_job(
43314338
process didn't run (e.g. a job kicked off in a notebook last week,
43324339
or owned by a teammate). After construction, call
43334340
:meth:`deploy` with ``recommendation_index=`` or
4334-
``inference_spec_name=`` as you would after ``generate_deployment_recommendations``.
4341+
``recommendation_spec_name=`` as you would after ``generate_deployment_recommendations``.
43354342
43364343
Args:
43374344
recommendation_job: Either the name of an existing
@@ -4364,7 +4371,7 @@ def _deploy_recommendation(
43644371
self,
43654372
*,
43664373
recommendation_index: int,
4367-
inference_spec_name: Optional[str],
4374+
recommendation_spec_name: Optional[str],
43684375
endpoint_name: Optional[str],
43694376
model_name: Optional[str],
43704377
endpoint_config_name: Optional[str],
@@ -4381,7 +4388,7 @@ def _deploy_recommendation(
43814388
We approve the package (rec jobs publish it un-approved) and create
43824389
Model + EndpointConfig + Endpoint from it.
43834390
4384-
Row selection: ``inference_spec_name`` wins over
4391+
Row selection: ``recommendation_spec_name`` wins over
43854392
``recommendation_index`` when both are given.
43864393
"""
43874394
import time as _time
@@ -4412,11 +4419,11 @@ def _deploy_recommendation(
44124419
f"{'Job failed: ' + str(failure_reason) if status == 'Failed' else 'Call job.wait() before deploy.'}"
44134420
)
44144421

4415-
if inference_spec_name is not None:
4422+
if recommendation_spec_name is not None:
44164423
matches = [
44174424
row for row in rows
44184425
if getattr(getattr(row, "model_details", None), "inference_specification_name", None)
4419-
== inference_spec_name
4426+
== recommendation_spec_name
44204427
]
44214428
if not matches:
44224429
available = sorted({
@@ -4427,8 +4434,8 @@ def _deploy_recommendation(
44274434
if name
44284435
})
44294436
raise ValueError(
4430-
f"No recommendation row matches inference_spec_name="
4431-
f"{inference_spec_name!r}. "
4437+
f"No recommendation row matches recommendation_spec_name="
4438+
f"{recommendation_spec_name!r}. "
44324439
f"Available: {available or '(none reported)'}."
44334440
)
44344441
rec = matches[0]
@@ -4536,7 +4543,7 @@ def deploy(
45364543
# was called previously, or when this builder was hydrated via
45374544
# ModelBuilder.from_recommendation_job(...)).
45384545
recommendation_index: int = 0,
4539-
inference_spec_name: Optional[str] = None,
4546+
recommendation_spec_name: Optional[str] = None,
45404547
role: Optional[str] = None,
45414548
model_name: Optional[str] = None,
45424549
endpoint_config_name: Optional[str] = None,
@@ -4601,7 +4608,7 @@ def deploy(
46014608
if getattr(self, "_recommendation_job", None) is not None:
46024609
return self._deploy_recommendation(
46034610
recommendation_index=recommendation_index,
4604-
inference_spec_name=inference_spec_name,
4611+
recommendation_spec_name=recommendation_spec_name,
46054612
endpoint_name=endpoint_name,
46064613
model_name=model_name,
46074614
endpoint_config_name=endpoint_config_name,

‎sagemaker-serve/tests/integ/test_ai_inference_recommender_integration.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,10 +173,15 @@ def test_recommendation_workflow_end_to_end():
173173
assert rows, (
174174
"ModelBuilder.recommendations is empty after generate_deployment_recommendations"
175175
)
176-
top = rows[0]
176+
top = rows.best
177+
assert top is rows[0], "rows.best should equal rows[0]"
177178
assert getattr(getattr(top, "model_details", None), "model_package_arn", None), (
178179
f"Top recommendation has no ModelPackageArn. Raw: {top}"
179180
)
181+
# Comparative table __repr__ should include both row indices and column headers.
182+
rec_table = repr(rows)
183+
assert f"Recommendations[{len(rows)}]" in rec_table
184+
assert "spec_name" in rec_table and "req/s" in rec_table
180185
logger.info("Got %d recommendations; deploying top one", len(rows))
181186

182187
rec_endpoint = model_builder.deploy(

‎sagemaker-serve/tests/unit/test_ai_inference_recommender/test_jobs.py‎

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
)
2525
from sagemaker.serve.ai_inference_recommender._recommendation_view import (
2626
_RecommendationView,
27+
_RecommendationsView,
2728
)
2829

2930

@@ -128,6 +129,11 @@ def test_repr_renders_config_and_perf_table(self):
128129
assert "ml.g6.12xlarge" in text
129130
assert "model-package/p/1" in text
130131
assert "my-spec" in text
132+
# The label uses the new "recommendation_spec_name" naming, not the
133+
# legacy "inference_spec_name" (which collides with the model-package
134+
# InferenceSpecification concept).
135+
assert "recommendation_spec_name:" in text
136+
assert "inference_spec_name:" not in text
131137
# Env vars
132138
assert "MY_VAR_A = 128" in text
133139
# Expected performance table
@@ -152,3 +158,90 @@ def test_handles_missing_optional_fields(self):
152158
assert "Recommendation[0]" in text
153159
assert "instance_type: -" in text
154160
assert "model_package: -" in text
161+
162+
163+
def _fake_two_recommendations():
164+
"""Two rows that differ enough that the comparative table can render them distinctly."""
165+
row_a = SimpleNamespace(
166+
model_details=SimpleNamespace(
167+
model_package_arn="arn:.../p/A",
168+
inference_specification_name="high-otps-on-g5-2xlarge",
169+
),
170+
deployment_configuration=SimpleNamespace(
171+
instance_type="ml.g5.2xlarge",
172+
instance_count=1,
173+
copy_count_per_instance=1,
174+
image_uri="example/image:1",
175+
environment_variables={},
176+
),
177+
expected_performance=[
178+
SimpleNamespace(metric="RequestThroughput", stat="avg", value=152.94, unit="Requests/Second"),
179+
SimpleNamespace(metric="OutputTokenThroughput", stat="avg", value=4893.7, unit="Tokens/Second"),
180+
SimpleNamespace(metric="RequestLatency", stat="p50", value=402.6, unit="Milliseconds"),
181+
SimpleNamespace(metric="RequestLatency", stat="p99", value=481.6, unit="Milliseconds"),
182+
],
183+
)
184+
row_b = SimpleNamespace(
185+
model_details=SimpleNamespace(
186+
model_package_arn="arn:.../p/B",
187+
inference_specification_name="high-otps-on-g5-2xlarge-1",
188+
),
189+
deployment_configuration=SimpleNamespace(
190+
instance_type="ml.g5.2xlarge",
191+
instance_count=1,
192+
copy_count_per_instance=1,
193+
image_uri="example/image:2",
194+
environment_variables={},
195+
),
196+
expected_performance=[
197+
SimpleNamespace(metric="RequestThroughput", stat="avg", value=151.6, unit="Requests/Second"),
198+
SimpleNamespace(metric="OutputTokenThroughput", stat="avg", value=4851.1, unit="Tokens/Second"),
199+
SimpleNamespace(metric="RequestLatency", stat="p50", value=425.2, unit="Milliseconds"),
200+
SimpleNamespace(metric="RequestLatency", stat="p99", value=474.6, unit="Milliseconds"),
201+
],
202+
)
203+
return [row_a, row_b]
204+
205+
206+
class TestRecommendationsView:
207+
def _make(self, rows=None):
208+
rows = rows if rows is not None else _fake_two_recommendations()
209+
return _RecommendationsView(
210+
_RecommendationView(row, index=i) for i, row in enumerate(rows)
211+
)
212+
213+
def test_behaves_like_list(self):
214+
view = self._make()
215+
assert len(view) == 2
216+
assert isinstance(view[0], _RecommendationView)
217+
# iteration
218+
names = [getattr(getattr(r.raw, "model_details", None), "inference_specification_name", None) for r in view]
219+
assert names == ["high-otps-on-g5-2xlarge", "high-otps-on-g5-2xlarge-1"]
220+
221+
def test_best_returns_first_row(self):
222+
view = self._make()
223+
assert view.best is view[0]
224+
225+
def test_best_on_empty_raises_indexerror_with_helpful_message(self):
226+
view = _RecommendationsView()
227+
with pytest.raises(IndexError, match="No recommendations available"):
228+
_ = view.best
229+
230+
def test_repr_renders_comparative_table_with_both_rows(self):
231+
text = repr(self._make())
232+
# Header / framing
233+
assert "Recommendations[2]" in text
234+
# Both spec names land in the table
235+
assert "high-otps-on-g5-2xlarge" in text
236+
assert "high-otps-on-g5-2xlarge-1" in text
237+
# Column headers
238+
for column in ("idx", "spec_name", "instance_type", "req/s", "lat_p99"):
239+
assert column in text
240+
# The two rows have different throughput values that should both render
241+
assert "152.9" in text or "152.94" in text
242+
assert "151.6" in text or "151.60" in text
243+
244+
def test_repr_when_empty(self):
245+
text = repr(_RecommendationsView())
246+
# Should not crash and should clearly indicate emptiness
247+
assert "Recommendations[0]" in text or "no rows" in text

0 commit comments

Comments
 (0)