-
Notifications
You must be signed in to change notification settings - Fork 1.3k
fix(serve): remove IC data-source collapse hack from recommendation deploy #6101
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+286
−121
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
81bee88
fix(serve): remove IC data-source collapse hack from recommendation d…
ZealSV aa6f339
Merge branch 'master' into remove-ic-sdkt-hack
ZealSV db82399
Merge branch 'master' into remove-ic-sdkt-hack
ZealSV 39365fd
test(serve): integ test deploying an SD/KT model as an Inference Comp…
ZealSV d632b80
Merge branch 'master' into remove-ic-sdkt-hack
ZealSV 3786b9b
test(serve): fix SD/KT IC integ test build path and trim docstring
ZealSV 8c98238
test(serve): poll IC to terminal state before asserting + harden tear…
ZealSV File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
251 changes: 251 additions & 0 deletions
251
sagemaker-serve/tests/integ/test_ai_inference_recommender_sdkt_ic_integration.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,251 @@ | ||
| # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. | ||
| # | ||
| # Licensed under the Apache License, Version 2.0 (the "License"). You | ||
| # may not use this file except in compliance with the License. A copy of | ||
| # the License is located at | ||
| # | ||
| # http://aws.amazon.com/apache2.0/ | ||
| # | ||
| # or in the "license" file accompanying this file. This file is | ||
| # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF | ||
| # ANY KIND, either express or implied. See the License for the specific | ||
| # language governing permissions and limitations under the License. | ||
| """End-to-end: deploy a speculative-decoding / kernel-tuning model as an | ||
| Inference Component via ``ModelBuilder``.""" | ||
| from __future__ import absolute_import | ||
|
|
||
| import logging | ||
| import time | ||
| import uuid | ||
|
|
||
| import pytest | ||
|
|
||
| from sagemaker.core.enums import EndpointType | ||
| from sagemaker.core.helper.session_helper import Session, get_execution_role | ||
| from sagemaker.core.inference_config import ResourceRequirements | ||
| from sagemaker.core.resources import Endpoint, EndpointConfig, InferenceComponent, Model | ||
| from sagemaker.serve.model_builder import ModelBuilder | ||
| from sagemaker.core.training.configs import Compute | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| # Small, ungated, chat-templated model. A GPU instance is required by the | ||
| # JumpStart vLLM/LMI container (not by the model size); a single-GPU g4dn.xlarge | ||
| # (T4) is ample for a 0.6B model. | ||
| MODEL_ID = "huggingface-reasoning-qwen3-06b" | ||
| INSTANCE_TYPE = "ml.g4dn.xlarge" | ||
| # Right-sized for a 0.6B on a single GPU; an oversized request overflows the | ||
| # host and the IC never leaves Creating. | ||
| IC_MIN_MEMORY_MB = 4096 | ||
| IC_NUM_ACCELERATORS = 1 | ||
| # Upper bound for the Inference Component to reach a terminal state. | ||
| _IC_TIMEOUT_S = 40 * 60 | ||
|
|
||
|
|
||
| def _additional_model_data_sources(s3_uri): | ||
| """The AdditionalModelDataSources shape an SD/KT optimized model carries: | ||
| base weights + a draft/tuned channel. The same readable artifact backs both | ||
| — the IC deploy path's acceptance of the *shape* is what is under test, not | ||
| the specific weights.""" | ||
|
|
||
| def _src(uri): | ||
| return { | ||
| "S3DataSource": { | ||
| "S3Uri": uri, | ||
| "S3DataType": "S3Prefix", | ||
| "CompressionType": "None", | ||
| } | ||
| } | ||
|
|
||
| return [ | ||
| {"ChannelName": "base_model", **_src(s3_uri)}, | ||
| {"ChannelName": "draft_model", **_src(s3_uri)}, | ||
| ] | ||
|
|
||
|
|
||
| @pytest.mark.slow_test | ||
| @pytest.mark.gpu_intensive | ||
| def test_deploy_sdkt_model_as_inference_component(): | ||
| """A model carrying SD/KT AdditionalModelDataSources deploys as an | ||
| Inference Component and reaches InService via ``ModelBuilder.deploy``.""" | ||
| logger.info("Starting SD/KT deploy-as-Inference-Component integration test...") | ||
|
|
||
| unique_id = f"{int(time.time())}-{uuid.uuid4().hex[:8]}" | ||
| role = get_execution_role(sagemaker_session=Session()) | ||
| src_model_name = f"air-sdkt-src-{unique_id}" | ||
| ic_model_name = f"air-sdkt-icmodel-{unique_id}" | ||
| endpoint_name = f"air-sdkt-ic-ep-{unique_id}" | ||
| ic_name = f"air-sdkt-ic-{unique_id}" | ||
|
|
||
| from sagemaker.core.jumpstart.configs import JumpStartConfig | ||
|
|
||
| def _jumpstart_builder(): | ||
| return ModelBuilder.from_jumpstart_config( | ||
| jumpstart_config=JumpStartConfig(model_id=MODEL_ID), | ||
| compute=Compute(instance_type=INSTANCE_TYPE), | ||
| role_arn=role, | ||
| ) | ||
|
|
||
| source_model = None | ||
| endpoint = None | ||
|
|
||
| try: | ||
| # First build resolves the JumpStart container + a readable weights | ||
| # artifact for this account/region; read its S3 URI to back the SD/KT | ||
| # channels (JumpStart populates the artifact during build, not before). | ||
| source_model = _jumpstart_builder().build(model_name=src_model_name) | ||
| src_primary = getattr(Model.get(model_name=src_model_name), "primary_container", None) | ||
| base_s3 = _extract_s3_uri(src_primary) | ||
| assert base_s3, f"Could not resolve a readable S3 artifact for {MODEL_ID}" | ||
| logger.info("Resolved base artifact: %s", base_s3) | ||
|
|
||
| # Second build carries the SD/KT additional data sources (base + draft | ||
| # channels) onto the model, then deploys it as an Inference Component: | ||
| # inference_config=ResourceRequirements routes deploy() to the | ||
| # INFERENCE_COMPONENT_BASED path. Both channels point at the same | ||
| # readable artifact — the deploy-time contract under test is the | ||
| # multi-source SHAPE, not the specific optimized weights. | ||
| ic_mb = _jumpstart_builder() | ||
| ic_mb.additional_model_data_sources = _additional_model_data_sources(base_s3) | ||
| ic_mb.build(model_name=ic_model_name) | ||
|
|
||
| endpoint = ic_mb.deploy( | ||
| endpoint_name=endpoint_name, | ||
| inference_config=ResourceRequirements( | ||
| requests={ | ||
| "num_accelerators": IC_NUM_ACCELERATORS, | ||
| "memory": IC_MIN_MEMORY_MB, | ||
| "copies": 1, | ||
| }, | ||
| ), | ||
| inference_component_name=ic_name, | ||
| instance_type=INSTANCE_TYPE, | ||
| initial_instance_count=1, | ||
| wait=True, | ||
| ) | ||
| logger.info("Deploy returned; endpoint=%s ic=%s", endpoint_name, ic_name) | ||
|
|
||
| # deploy(wait=True) waits for the endpoint, but the Inference Component | ||
| # is created with wait=False and finishes shortly after — poll it to a | ||
| # terminal state before asserting. | ||
| ic = _wait_for_ic_terminal(ic_name) | ||
|
|
||
| # The Inference Component — referencing a model with base + draft | ||
| # AdditionalModelDataSources — must be InService. This is the assertion | ||
| # that turns red if hosting rejects the multi-source shape at | ||
| # create/deploy time. | ||
| assert ic.inference_component_status == "InService", ( | ||
| f"Inference Component {ic_name} did not reach InService: " | ||
| f"{ic.inference_component_status} / " | ||
| f"{getattr(ic, 'failure_reason', None)}" | ||
| ) | ||
|
|
||
| # And the model the IC references still carries the SD/KT additional | ||
| # sources — i.e. we validated the multi-source path, not a silently | ||
| # collapsed single-source one. | ||
| referenced = ic.specification.model_name | ||
| deployed_model = Model.get(model_name=referenced) | ||
| channels = _additional_channel_names(deployed_model) | ||
| assert {"base_model", "draft_model"}.issubset(channels), ( | ||
| f"IC model {referenced} lost its SD/KT additional sources " | ||
| f"(channels={channels}); the deploy path must not collapse them." | ||
| ) | ||
| logger.info("SD/KT model InService on IC with channels: %s", channels) | ||
|
|
||
| finally: | ||
| # An IC still in Creating cannot be deleted (the API rejects it), which | ||
| # would strand the IC and its GPU endpoint. Wait for it to leave | ||
| # Creating first, then delete and confirm it is gone before the endpoint. | ||
| try: | ||
| _wait_for_ic_terminal(ic_name) | ||
| except Exception as exc: | ||
| logger.warning("Could not resolve IC %s before teardown: %s", ic_name, exc) | ||
| _delete_quietly( | ||
| lambda: InferenceComponent.get(inference_component_name=ic_name), | ||
| f"InferenceComponent {ic_name}", | ||
| wait_gone=True, | ||
| ) | ||
| _delete_quietly( | ||
| lambda: Endpoint.get(endpoint_name=endpoint_name), | ||
| f"Endpoint {endpoint_name}", | ||
| ) | ||
| _delete_quietly( | ||
| lambda: EndpointConfig.get(endpoint_config_name=endpoint_name), | ||
| f"EndpointConfig {endpoint_name}", | ||
| ) | ||
| _delete_quietly( | ||
| lambda: Model.get(model_name=ic_model_name), | ||
| f"Model {ic_model_name}", | ||
| ) | ||
| if source_model: | ||
| _delete_quietly(lambda: source_model, f"Model {src_model_name}") | ||
|
|
||
|
|
||
| def _wait_for_ic_terminal(ic_name, timeout_s=_IC_TIMEOUT_S): | ||
| """Poll an Inference Component until it leaves ``Creating``/``Updating``. | ||
|
|
||
| Returns the resource in its terminal state (``InService`` / ``Failed``); | ||
| on timeout, returns the last-observed resource so the caller's assertion | ||
| reports the stuck status rather than this helper masking it. | ||
| """ | ||
| waited = 0 | ||
| ic = InferenceComponent.get(inference_component_name=ic_name) | ||
| while getattr(ic, "inference_component_status", None) in ("Creating", "Updating"): | ||
| if waited >= timeout_s: | ||
| break | ||
| time.sleep(20) | ||
| waited += 20 | ||
| ic = InferenceComponent.get(inference_component_name=ic_name) | ||
| return ic | ||
|
|
||
|
|
||
| def _extract_s3_uri(container): | ||
| """Pull the S3 artifact URI off a resolved container (ModelDataSource or the | ||
| legacy ModelDataUrl), tolerating the resource-object attribute shape.""" | ||
| if container is None: | ||
| return None | ||
| mds = getattr(container, "model_data_source", None) | ||
| if mds is not None: | ||
| s3 = getattr(mds, "s3_data_source", None) | ||
| if s3 is not None and getattr(s3, "s3_uri", None): | ||
| return s3.s3_uri | ||
| return getattr(container, "model_data_url", None) | ||
|
|
||
|
|
||
| def _additional_channel_names(model): | ||
| """Return the set of AdditionalModelDataSources channel names on a model.""" | ||
| primary = getattr(model, "primary_container", None) or getattr( | ||
| model, "containers", [None] | ||
| )[0] | ||
| if primary is None: | ||
| return set() | ||
| sources = getattr(primary, "additional_model_data_sources", None) or [] | ||
| names = set() | ||
| for s in sources: | ||
| name = getattr(s, "channel_name", None) | ||
| if name is None and isinstance(s, dict): | ||
| name = s.get("ChannelName") | ||
| if name: | ||
| names.add(name) | ||
| return names | ||
|
|
||
|
|
||
| def _delete_quietly(resource_factory, label, wait_gone=False): | ||
| """Best-effort delete; log and continue on any failure. When ``wait_gone``, | ||
| block until the resource is gone (an endpoint can't be deleted while it | ||
| still hosts an Inference Component).""" | ||
| try: | ||
| resource = resource_factory() | ||
| resource.delete() | ||
| if wait_gone: | ||
| waited = 0 | ||
| while waited < 10 * 60: | ||
| try: | ||
| resource_factory() | ||
| time.sleep(20) | ||
| waited += 20 | ||
| except Exception: | ||
| break | ||
| logger.info("Deleted %s", label) | ||
| except Exception as exc: | ||
| logger.warning("Failed to delete %s: %s", label, exc) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.