feat(serve): list/filter jobs, deploy from recommendation row, compare benchmarks, DataFrame views - #6148
feat(serve): list/filter jobs, deploy from recommendation row, compare benchmarks, DataFrame views#6148ZealSV wants to merge 1 commit into
Conversation
…e benchmarks, DataFrame views Adds inference-recommender usability features to sagemaker.serve: - list_benchmarks(endpoint=...) / list_recommendations(model=..., model_package=...): the ListAI* APIs cannot filter by endpoint/model server-side (those fields are on Describe, not the list summary), so filtering is client-side (list -> describe -> match) bounded by max_results. Chosen because it is the only option the boto APIs allow, and a bounded describe fan-out keeps a broad list from being unbounded. - ModelBuilder.deploy(recommendation=<row>): accept a recommendation row (mb.recommendations.best or [i]) and resolve it to the existing spec-name/index selection. Chosen as an additive, back-compat param so callers deploy the best row without hand-copying a magic index; existing index/spec_name kwargs still work. - compare_benchmarks(*results): N-way comparison, first result = baseline, one row per metric x one column per run + a signed delta% column oriented so + is always better. Chosen N-way (not strictly 2-way) because it is barely more code and the delta vs a baseline is the whole point of a comparison utility. - to_dataframe() on every tabular surface (customer-requested DataFrame view): BenchmarkMetrics, BenchmarkResult, BenchmarkComparison, and both recommendation views (_RecommendationView, _RecommendationsView). Each returns a pandas DataFrame mirroring its printed table, but carrying the extra stats (min/max/p95/stddev) the width-limited text tables drop, and keeping numeric values native (deltas numeric, NaN where undefined) instead of preformatted strings. pandas is imported lazily via _require_pandas() so result.py stays dependency-light (its printed tables are stdlib-only); it is present transitively via sagemaker-core in any real install. __str__ and to_dataframe() share ordering/row-building helpers (_ordered_metric_pairs, _row_records, _delta_value) so the printed table and the frame never drift. BenchmarkResult.to_dataframe() raises on a search/sweep result (no single profile). Unit: 45 new tests (11 listing, 5 deploy-row, 12 compare, 17 to_dataframe); full recommender suite 199 pass. Integ: test_ai_inference_recommender_enhancements_integration.py chains rec -> deploy(rows.best) InService -> 2 benchmarks -> compare_benchmarks; verified live (1 passed, 70 min) plus a no-GPU listing-plumbing test.
| ) | ||
| resolved_spec = getattr(recommendation, "recommendation_spec_name", None) | ||
| if resolved_spec is not None: | ||
| recommendation_spec_name = resolved_spec |
There was a problem hiding this comment.
F1 (blocker): this discards the row's index, so deploy(recommendation=row) can provision a different row than the one passed.
The row's _index is read only in the else branch below — when the row has no spec name. Whenever it does have one, recommendation_index keeps its signature default of 0 and the row's true position is never forwarded. _deploy_recommendation then resolves a spec name by taking matches[0] (line 5215).
If two rows share an inference_specification_name, the wrong one is deployed. Reproduced with two rows differing in ModelPackage and instance type:
deploy(recommendation=view[1]) # caller wants pkg/2, ml.p4d.24xlarge x8
WARNING recommendation_spec_name='dup-spec' matched 2 recommendations; deploying the first.
SELECTED -> model-package/g/1 # row 0: ml.g5.2xlarge x1
deploy(recommendation_index=1) # same intent, index path
SELECTED -> model-package/g/2 # correct
rec drives ModelPackage, container, env vars, instance type, instance count and copy count (lines 5228-5363), so all of those come from the wrong row. The endpoint reaches InService on hardware the caller did not choose.
Duplicate spec names are not hypothetical — AIRecommendationModelDetails carries instance_details as a list of instance configurations per spec, so one spec fanning out to N rows is the normal shape. This PR's own TestDeployRecommendationSpecNameMultiMatch asserts the duplicate case, and TestDeployRecommendationRowObject._make_builder uses distinct spec names "A"/"B" — so both halves are tested and never composed, which is why all 199 tests pass.
Worth noting the warning at line 5209 advises "Use recommendation_index to pick a specific one", but recommendation= and recommendation_index= are mutually exclusive (line 5533), so the remedy it suggests is unreachable from the path that emits it.
Suggested direction: forward recommendation_index = getattr(recommendation, "_index", 0) unconditionally and let the index win when a row object was passed, using the spec name only as a fallback. The caller handed you an unambiguous identifier; keeping it makes the feature exact.
| else: | ||
| # The list summary lacks the nested field; hydrate before matching. | ||
| try: | ||
| job.refresh() |
There was a problem hiding this comment.
F2 (major): this is a second Describe per candidate — the iterator already refreshed the object.
sagemaker-core's ResourceIterator.__next__ refreshes every object it yields, gated only on hasattr:
# sagemaker/core/utils/utils.py:464-465
if hasattr(resource_object, "refresh"):
resource_object.refresh()AIBenchmarkJob.refresh and AIRecommendationJob.refresh both exist and neither caches, so objects arriving here are already hydrated. Measured with the real get_all and only the boto client patched, attributing each call by stack frame:
5 candidates -> 10 DescribeAIBenchmarkJob calls
by origin: {'ITERATOR.__next__': 5, '_collect': 5}
This also means the comment above ("The list summary lacks the nested field") no longer describes reality, and the predicate is None path still pays a full Describe per job inside the iterator — so the "hydrate only when filtering" optimisation the module docstring describes does not hold either.
Combined with F3 this doubles an already-unbounded fan-out. Suggested direction: drop this call and note in the docstring that hydration is the iterator's job, or if you want control over it, describe explicitly through the session's client instead (which would also address F5).
| continue | ||
| if predicate(job): | ||
| matches.append(job) | ||
| if len(matches) >= max_results: |
There was a problem hiding this comment.
F3 (major): max_results bounds the number of matches, not the Describe fan-out — contrary to three docstrings.
This breaks on len(matches), so when the predicate matches rarely the loop keeps describing. Measured against _collect directly:
stream of 500 candidates, max_results=2, only the last matches
matches returned : 1
refresh() calls : 500
The module docstring states "max_results bounds that Describe fan-out" (line 22), and both public functions repeat it as "(and, when endpoint is set, on how many are described)" (lines 132-134, 168-170). None of that holds.
scanned (line 90, incremented at line 111) is assigned and never read anywhere — which reads like the scan budget this was meant to have, left unwired. It also sits after the break, so it would under-count by one even if something did read it.
Practical impact: list_benchmarks(endpoint="typo-in-name") on an account with 5k jobs issues 5k Describes with F2 doubling it to ~10k — minutes of latency and near-certain throttling, for a call that returns [].
Suggested direction: bound the scan as well as the results — if scanned >= max_scan: break with max_scan either derived from max_results or its own parameter — and log the scanned count so the cost is visible. If instead the intent is that max_results caps results only, the three docstrings need to say so, and callers need some other way to bound the fan-out.
A unit test asserting refresh.call_count for a non-matching filter would pin this; the current test_max_results_caps_output uses filter-free jobs, so it exercises the one path where the cap and the fan-out coincide.
| """ | ||
| matches: list = [] | ||
| scanned = 0 | ||
| for job in iterator: |
There was a problem hiding this comment.
F4 (major): the first Describe happens on this line, outside the try below — so one inaccessible job aborts the whole listing.
Because ResourceIterator.__next__ refreshes each object as it yields it (sagemaker/core/utils/utils.py:465), the initial Describe for every job executes at this for statement, not at the guarded job.refresh() on line 98. Verified against the real iterator:
RAISED OUT of list_benchmarks: AccessDeniedException ...
File ".../listing.py", line 91, in _collect <- this line
File ".../sagemaker/core/utils/utils.py", line 465, in __next__
File ".../sagemaker/core/resources.py", line 383, in refresh
The remaining jobs are never examined. Control comparison with a plain iter(list) that does not refresh on yield: the bad job is skipped and the others are returned, i.e. the try/except works — but only for iterators that do not hydrate, which the real one does.
So the except Exception on line 99 is structurally unreachable for the failures most likely to occur, while reading as though it makes this function resilient to them. One job the caller lacks DescribeAIBenchmarkJob on anywhere in the account makes list_benchmarks() raise instead of returning the other N−1.
Suggested direction: wrap the iteration itself, e.g. drive it with next() inside the try, so a per-job Describe failure is handled in one place regardless of which layer issued the call.
| status: Optional[str] = None, | ||
| name_contains: Optional[str] = None, | ||
| max_results: int = DEFAULT_MAX_RESULTS, | ||
| sagemaker_session: Optional[Session] = None, |
There was a problem hiding this comment.
F5 (major): passing the type this parameter is annotated with raises a ValidationError.
The annotation resolves to sagemaker.core.helper.session_helper.Session (imported line 28), but get_all is wrapped in pydantic validate_call and requires boto3.session.Session. These are unrelated types — isinstance(Session(), boto3.session.Session) is False. Verified:
>>> list_benchmarks(sagemaker_session=sagemaker.core.helper.session_helper.Session())
ValidationError: 1 validation error for AIBenchmarkJob.get_all
session
Input should be an instance of Session [type=is_instance_of,
input_value=<sagemaker.core.helper.session_helper.Session object ...>]
>>> list_benchmarks(sagemaker_session=boto3.session.Session(region_name="us-west-2"))
accepted
The docstring says only "Optional session; a default is created if omitted", so a reader has nothing to warn them off the annotated type — and sagemaker.Session() is the idiomatic thing to reach for. The default None path works, so this only bites callers who use the parameter at all.
Same on list_recommendations (line 162).
Suggested direction: annotate Optional[boto3.session.Session] and say so in the docstring, or accept both and unwrap a sagemaker Session to its boto_session before handing it to get_all.
Related caveat worth documenting either way: SageMakerClient is a SingletonMeta keyed only on the class, so if the singleton was already built earlier in the process, the session passed here is ignored and the call targets the earlier one.
| "instance_type": getattr(dc, "instance_type", None) if dc else None, | ||
| "instances": getattr(dc, "instance_count", None) if dc else None, | ||
| "copies/inst": (getattr(dc, "copy_count_per_instance", None) if dc else None), | ||
| "container": _short_container_tag(_safe_str(dc, "image_uri")), |
There was a problem hiding this comment.
F10 (minor): this is the one column that carries a display sentinel into the DataFrame.
_safe_str maps None/"" to "-", and _short_container_tag passes "-" straight through, so the record gets the string "-" where every sibling column keeps None. That contradicts this method's own docstring two lines up ("numbers stay numbers, None stays None"). Verified with image_uri=None:
container repr : ['-'] container isna : [False]
spec_name repr : [None] instance_type isna: [True]
Impact is on the frame, not the table: df[df.container.notna()], groupby("container") and value_counts() all treat "no container" as a container named -.
Suggested direction: pass the raw value — _short_container_tag(getattr(dc, "image_uri", None) if dc else None) — and let __str__'s existing "-" if value in (None, "") branch handle display. That branch already exists and already covers this.
|
|
||
|
|
||
| # Real BenchmarkJob / RecommendationJob instances (not SimpleNamespace): list_* | ||
| # reassigns each returned job's __class__ to the subclass — a no-op on a real |
There was a problem hiding this comment.
F11 (minor): this comment is describing why the test cannot exercise the line it is about.
Because every stand-in is constructed as BenchmarkJob/RecommendationJob already, job.__class__ = subclass in _collect is a genuine no-op in all 11 listing tests — deleting that line keeps the suite green. Yet it is the reason _collect takes a subclass parameter at all, and the reason the Returns: docstrings can promise "each with show_result".
The behaviour does work: feeding base AIBenchmarkJob instances through list_benchmarks does yield BenchmarkJob objects with show_result. Nothing guards it.
Suggested direction: build the stand-ins as base AIBenchmarkJob / AIRecommendationJob and assert isinstance(out[0], BenchmarkJob). That gives the retype its first real assertion and matches what get_all actually yields, since it hardcodes resource_cls=AIBenchmarkJob rather than cls.
While you're here: the autouse refresh stub on lines 45-51 uses a lambda. A MagicMock would cost nothing and would make the Describe call count assertable, which is what F3 needs.
| ) | ||
|
|
||
|
|
||
| def test_list_benchmarks_and_recommendations_plumbing(): |
There was a problem hiding this comment.
F12 (minor): this is the only integ test that runs on PR checks, and it triggers the F3 fan-out against a live account.
The slow_test / gpu_intensive markers on line 89-90 belong to the next function, so this one collects into any integ run. sagemaker-serve/tox.ini:66 says gpu_intensive "runs on scheduled CI, not PR checks" — so the e2e test that actually covers deploy-from-row and compare_benchmarks is excluded, while this one is not.
Lines 78-84 then call list_benchmarks(endpoint="no-such-endpoint-<uuid>") and list_recommendations(model="s3://no-such-bucket-<uuid>/model/"). Both are guaranteed zero-match by construction, which per F3 means a full-account Describe sweep of both job types — doubled by F2. Under pytest -n auto that is a reliable way to throttle the whole suite for everyone sharing the account.
The intent is good and the test is cheap in principle; it is the interaction with F3 that makes it expensive. Fixing F3 largely fixes this too.
Separately, the assertion style in the e2e test is worth a look: for job in found: assert ... passes vacuously when found is empty, so a filter regressing to always-False would not be caught. Asserting found is non-empty first — or accepting the raciness and asserting on a job you created — would make it load-bearing.
| try: | ||
| job.refresh() | ||
| except Exception as exc: # pragma: no cover - best-effort hydration | ||
| logger.debug("Skipping %s; could not describe it: %s", job, exc) |
There was a problem hiding this comment.
F13 (minor): a permissions or throttling failure silently shortens the result list, at DEBUG.
except Exception plus logger.debug plus continue means a job the caller cannot describe is dropped with no visible trace at default log level. If the role lacks sagemaker:DescribeAIBenchmarkJob, or the API throttles under the F3 fan-out, every candidate is skipped and list_benchmarks(endpoint=...) returns []. The customer concludes no benchmark ever targeted their endpoint; the on-call has nothing to correlate. Under throttling it presents as "the filter intermittently finds nothing".
This is the same hazard the max_results log on line 105 was added to prevent — the docstring says it "logs when the scan is truncated so a silent cap never reads as 'all matches'" — and the two cases deserve the same treatment.
Suggested direction: logger.warning, and count the skips so the caller can be told "N jobs were skipped because they could not be described". Throttling arguably should not be swallowed at all, since retrying is the right response rather than silently returning a short list.
| install guidance if it is missing. | ||
| """ | ||
| pd = _require_pandas() | ||
| ordered_names = [n for n in sorted(self.all_metrics) if not n.startswith("http_")] |
There was a problem hiding this comment.
F15 (minor): this re-derives the ordering rule that __str__ already implements, so "never drift" is not enforced here.
__str__ (lines 91-94) buckets on name.startswith("http_") in a single pass; these two comprehensions derive the same partition independently. They agree today — verified alpha, zeta, http_a, http_x from both — but by coincidence rather than by construction, and the docstring below asserts "Rows are ordered exactly as the printed table".
BenchmarkResult does this properly 160 lines down: _ordered_metric_pairs() is called by both. Same gap in _recommendation_view.py, where _perf_records() was added as "the rows of the printed table" but __str__ still builds perf_rows inline — and there the values already differ (None/"" in the frame vs "-" in the table).
So the commit message's claim that the shared helpers keep table and frame in step holds for 2 of the 5 surfaces. The "http_" literal is now a live predicate in four places (91, 117, 118, 292).
Suggested direction: give BenchmarkMetrics an _ordered_metric_pairs() equivalent and have both callers use it, and refactor _RecommendationView.__str__ onto _perf_records(). A single parametrised test comparing __str__ row order against to_dataframe().index across all five surfaces would turn the invariant from convention into something enforced.
Adds inference-recommender usability features to sagemaker.serve:
list_benchmarks(endpoint=...) / list_recommendations(model=..., model_package=...): the ListAI* APIs cannot filter by endpoint/model server-side (those fields are on Describe, not the list summary), so filtering is client-side (list -> describe -> match) bounded by max_results. Chosen because it is the only option the boto APIs allow, and a bounded describe fan-out keeps a broad list from being unbounded.
ModelBuilder.deploy(recommendation=): accept a recommendation row (mb.recommendations.best or [i]) and resolve it to the existing spec-name/index selection. Chosen as an additive, back-compat param so callers deploy the best row without hand-copying a magic index; existing index/spec_name kwargs still work.
compare_benchmarks(*results): N-way comparison, first result = baseline, one row per metric x one column per run + a signed delta% column oriented so + is always better. Chosen N-way (not strictly 2-way) because it is barely more code and the delta vs a baseline is the whole point of a comparison utility.
to_dataframe() on every tabular surface (customer-requested DataFrame view): BenchmarkMetrics, BenchmarkResult, BenchmarkComparison, and both recommendation views (_RecommendationView, _RecommendationsView). Each returns a pandas DataFrame mirroring its printed table, but carrying the extra stats (min/max/p95/stddev) the width-limited text tables drop, and keeping numeric values native (deltas numeric, NaN where undefined) instead of preformatted strings. pandas is imported lazily via _require_pandas() so result.py stays dependency-light (its printed tables are stdlib-only); it is present transitively via sagemaker-core in any real install. str and to_dataframe() share ordering/row-building helpers (_ordered_metric_pairs, _row_records, _delta_value) so the printed table and the frame never drift. BenchmarkResult.to_dataframe() raises on a search/sweep result (no single profile).
Unit: 45 new tests (11 listing, 5 deploy-row, 12 compare, 17 to_dataframe); full recommender suite 199 pass. Integ: test_ai_inference_recommender_enhancements_integration.py chains rec -> deploy(rows.best) InService -> 2 benchmarks -> compare_benchmarks; verified live (1 passed, 70 min) plus a no-GPU listing-plumbing test.
Issue #, if available:
Description of changes:
By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.