|
| 1 | +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. |
| 2 | +# |
| 3 | +# Licensed under the Apache License, Version 2.0 (the "License"). You |
| 4 | +# may not use this file except in compliance with the License. A copy of |
| 5 | +# the License is located at |
| 6 | +# |
| 7 | +# http://aws.amazon.com/apache2.0/ |
| 8 | +# |
| 9 | +# or in the "license" file accompanying this file. This file is |
| 10 | +# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF |
| 11 | +# ANY KIND, either express or implied. See the License for the specific |
| 12 | +# language governing permissions and limitations under the License. |
| 13 | +"""Integration test for SFT fine-tuning with serverful Training Jobs (SMTJ). |
| 14 | +
|
| 15 | +Based on: v3-examples/model-customization-examples/sft_finetuning_serverful_smtj.ipynb |
| 16 | +
|
| 17 | +Prerequisites: |
| 18 | + - Active AWS credentials in us-east-1 with permissions to launch SageMaker |
| 19 | + Training Jobs and access the default SageMaker bucket. |
| 20 | +
|
| 21 | +The test is self-sufficient: it uses the account's default SageMaker bucket |
| 22 | +(``sagemaker-{region}-{account_id}``, auto-created by ``Session.default_bucket()``). |
| 23 | +The sample training data in ``tests/data/train/sft_smtj_sample_data.jsonl`` is |
| 24 | +uploaded under the ``sft-smtj-integ/`` prefix if not already present. |
| 25 | +
|
| 26 | +Run with: |
| 27 | + export AWS_DEFAULT_REGION=us-east-1 |
| 28 | + pytest tests/integ/train/test_sft_trainer_serverful_smtj.py -v -s |
| 29 | +""" |
| 30 | +from __future__ import absolute_import |
| 31 | + |
| 32 | +import logging |
| 33 | +import os |
| 34 | +import time |
| 35 | +import random |
| 36 | + |
| 37 | +import pytest |
| 38 | + |
| 39 | +from sagemaker.train import SFTTrainer |
| 40 | +from sagemaker.train.common import TrainingType |
| 41 | +from sagemaker.core.training.configs import TrainingJobCompute |
| 42 | + |
| 43 | +logger = logging.getLogger(__name__) |
| 44 | + |
| 45 | +# Test configuration |
| 46 | +DATA_PREFIX = "sft-smtj-integ" |
| 47 | +DATA_S3_KEY = f"{DATA_PREFIX}/sft_smtj_sample_data.jsonl" |
| 48 | + |
| 49 | +# Local sample data file |
| 50 | +DATA_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "data", "train") |
| 51 | +LOCAL_TRAINING_DATA = os.path.join(DATA_DIR, "sft_smtj_sample_data.jsonl") |
| 52 | + |
| 53 | + |
| 54 | +def _ensure_training_data(s3_client, bucket_name: str) -> str: |
| 55 | + """Upload local training data to S3 if not already present. |
| 56 | +
|
| 57 | + Returns the S3 URI for the training data file. |
| 58 | + """ |
| 59 | + s3_uri = f"s3://{bucket_name}/{DATA_S3_KEY}" |
| 60 | + |
| 61 | + # Check if already exists |
| 62 | + try: |
| 63 | + s3_client.head_object(Bucket=bucket_name, Key=DATA_S3_KEY) |
| 64 | + logger.info(f"Training data already exists at {s3_uri}") |
| 65 | + return s3_uri |
| 66 | + except s3_client.exceptions.ClientError: |
| 67 | + pass |
| 68 | + |
| 69 | + logger.info(f"Uploading training data from {LOCAL_TRAINING_DATA} to {s3_uri}") |
| 70 | + s3_client.upload_file(LOCAL_TRAINING_DATA, bucket_name, DATA_S3_KEY) |
| 71 | + logger.info("Training data uploaded successfully.") |
| 72 | + return s3_uri |
| 73 | + |
| 74 | + |
| 75 | +@pytest.fixture(scope="module") |
| 76 | +def training_resources(sagemaker_session_us_east_1): |
| 77 | + """Ensure training data exists in the account's default SageMaker bucket. |
| 78 | +
|
| 79 | + Uses ``Session.default_bucket()`` (creates ``sagemaker-{region}-{account_id}`` |
| 80 | + if needed), then uploads the local sample data file under the |
| 81 | + ``sft-smtj-integ/`` prefix if not already present. |
| 82 | +
|
| 83 | + Returns a dict with: |
| 84 | + - training_dataset: S3 URI to the training data file |
| 85 | + - s3_output_path: S3 URI for training output |
| 86 | + - bucket_name: the default bucket name |
| 87 | + """ |
| 88 | + bucket_name = sagemaker_session_us_east_1.default_bucket() |
| 89 | + s3_client = sagemaker_session_us_east_1.boto_session.client("s3") |
| 90 | + training_dataset = _ensure_training_data(s3_client, bucket_name) |
| 91 | + |
| 92 | + return { |
| 93 | + "training_dataset": training_dataset, |
| 94 | + "s3_output_path": f"s3://{bucket_name}/{DATA_PREFIX}/output/", |
| 95 | + "bucket_name": bucket_name, |
| 96 | + } |
| 97 | + |
| 98 | + |
| 99 | +@pytest.mark.gpu_intensive |
| 100 | +@pytest.mark.us_east_1 |
| 101 | +def test_sft_trainer_serverful_smtj(sagemaker_session_us_east_1, training_resources): |
| 102 | + """Test SFT fine-tuning on serverful compute with recipe overrides. |
| 103 | +
|
| 104 | + Exercises the full notebook flow: |
| 105 | + 1. Create SFTTrainer with TrainingJobCompute (dedicated instances) |
| 106 | + 2. Apply recipe overrides and verify via get_resolved_recipe() |
| 107 | + 3. Set hyperparameters and submit training job |
| 108 | + 4. Poll until completion |
| 109 | + """ |
| 110 | + unique_id = f"{int(time.time())}-{random.randint(1000, 9999)}" |
| 111 | + |
| 112 | + sft_trainer = SFTTrainer( |
| 113 | + model="amazon.nova-micro-v1", |
| 114 | + training_type=TrainingType.LORA, |
| 115 | + training_dataset=training_resources["training_dataset"], |
| 116 | + s3_output_path=training_resources["s3_output_path"], |
| 117 | + compute=TrainingJobCompute( |
| 118 | + instance_type="ml.g6.48xlarge", |
| 119 | + instance_count=1, |
| 120 | + ), |
| 121 | + sagemaker_session=sagemaker_session_us_east_1, |
| 122 | + overrides={"training_config": {"max_epochs": 1}}, |
| 123 | + base_job_name=f"sft-smtj-integ-{unique_id}", |
| 124 | + ) |
| 125 | + |
| 126 | + # Verify recipe overrides are resolved correctly |
| 127 | + resolved = sft_trainer.get_resolved_recipe() |
| 128 | + training_config = resolved["training_config"] |
| 129 | + logger.info(f"Resolved training_config: {training_config}") |
| 130 | + |
| 131 | + # Nova Micro uses trainer.max_epochs for step control |
| 132 | + assert training_config["trainer"]["max_epochs"] == 1, ( |
| 133 | + f"Expected max_epochs=1, got: {training_config.get('trainer')}" |
| 134 | + ) |
| 135 | + |
| 136 | + # Submit (non-blocking) |
| 137 | + training_job = sft_trainer.train(wait=False) |
| 138 | + assert training_job is not None |
| 139 | + assert training_job.training_job_name is not None |
| 140 | + logger.info(f"Training job submitted: {training_job.training_job_name}") |
| 141 | + |
| 142 | + # Poll for completion |
| 143 | + max_wait_time = 10800 # 3 hour timeout (Nova training can be slow) |
| 144 | + poll_interval = 60 |
| 145 | + start_time = time.time() |
| 146 | + |
| 147 | + while time.time() - start_time < max_wait_time: |
| 148 | + training_job.refresh() |
| 149 | + status = training_job.training_job_status |
| 150 | + |
| 151 | + if status in ["Completed", "Failed", "Stopped"]: |
| 152 | + break |
| 153 | + |
| 154 | + logger.info( |
| 155 | + f"Job {training_job.training_job_name} status: {status} " |
| 156 | + f"({int(time.time() - start_time)}s elapsed)" |
| 157 | + ) |
| 158 | + time.sleep(poll_interval) |
| 159 | + |
| 160 | + assert training_job.training_job_status == "Completed", ( |
| 161 | + f"Training job {training_job.training_job_name} ended with status: " |
| 162 | + f"{training_job.training_job_status}" |
| 163 | + ) |
| 164 | + logger.info(f"Training job completed successfully: {training_job.training_job_name}") |
0 commit comments