diff --git a/sagemaker-core/sample/sagemaker/2017-07-24/service-2.json b/sagemaker-core/sample/sagemaker/2017-07-24/service-2.json index ceb4f316dc..6b551c5fd6 100644 --- a/sagemaker-core/sample/sagemaker/2017-07-24/service-2.json +++ b/sagemaker-core/sample/sagemaker/2017-07-24/service-2.json @@ -44132,6 +44132,10 @@ "EvaluatorArn":{ "shape":"EvaluatorArn", "documentation":"

The evaluator Amazon Resource Name (ARN) used as reward function or reward prompt.

" + }, + "SequenceLength":{ + "shape":"SequenceLength", + "documentation":"

The sequence length for the training job.

" } }, "documentation":"

The configuration for the serverless training job.

" @@ -44143,6 +44147,19 @@ "Evaluation" ] }, + "SequenceLength":{ + "type":"string", + "enum":[ + "1K", + "2K", + "4K", + "8K", + "16K", + "32K", + "64K", + "128K" + ] + }, "ServerlessMaxConcurrency":{ "type":"integer", "box":true, diff --git a/sagemaker-core/src/sagemaker/core/resources.py b/sagemaker-core/src/sagemaker/core/resources.py index ff462080bb..322ff2f356 100644 --- a/sagemaker-core/src/sagemaker/core/resources.py +++ b/sagemaker-core/src/sagemaker/core/resources.py @@ -46,7 +46,6 @@ from sagemaker.core.serializers.base import BaseSerializer from sagemaker.core.deserializers.base import BaseDeserializer - logger = get_textual_rich_logger(__name__) diff --git a/sagemaker-core/src/sagemaker/core/shapes/shapes.py b/sagemaker-core/src/sagemaker/core/shapes/shapes.py index 3eb547ec1c..e0e79a5751 100644 --- a/sagemaker-core/src/sagemaker/core/shapes/shapes.py +++ b/sagemaker-core/src/sagemaker/core/shapes/shapes.py @@ -9781,6 +9781,7 @@ class ServerlessJobConfig(Base): peft: The parameter-efficient fine-tuning configuration. evaluation_type: The evaluation job type. Required when serverless job type is Evaluation. evaluator_arn: The evaluator Amazon Resource Name (ARN) used as reward function or reward prompt. + sequence_length: The sequence length for the training job. """ base_model_arn: StrPipeVar @@ -9790,6 +9791,7 @@ class ServerlessJobConfig(Base): peft: Optional[StrPipeVar] = Unassigned() evaluation_type: Optional[StrPipeVar] = Unassigned() evaluator_arn: Optional[StrPipeVar] = Unassigned() + sequence_length: Optional[StrPipeVar] = Unassigned() class MlflowConfig(Base): diff --git a/sagemaker-core/src/sagemaker/core/utils/code_injection/shape_dag.py b/sagemaker-core/src/sagemaker/core/utils/code_injection/shape_dag.py index 61d7a1b223..439fef722d 100644 --- a/sagemaker-core/src/sagemaker/core/utils/code_injection/shape_dag.py +++ b/sagemaker-core/src/sagemaker/core/utils/code_injection/shape_dag.py @@ -16272,6 +16272,7 @@ {"name": "Peft", "shape": "Peft", "type": "string"}, {"name": "EvaluationType", "shape": "EvaluationType", "type": "string"}, {"name": "EvaluatorArn", "shape": "EvaluatorArn", "type": "string"}, + {"name": "SequenceLength", "shape": "SequenceLength", "type": "string"}, ], "type": "structure", }, diff --git a/sagemaker-train/src/sagemaker/train/base_trainer.py b/sagemaker-train/src/sagemaker/train/base_trainer.py index c17199a611..19a266ccfb 100644 --- a/sagemaker-train/src/sagemaker/train/base_trainer.py +++ b/sagemaker-train/src/sagemaker/train/base_trainer.py @@ -568,6 +568,10 @@ def _yaml_safe_default(value): if hp_value is not None: _set_spec_default(override_spec, hp_key, _yaml_safe_default(hp_value)) + # Enforce selected lengths fit the recipe's supported sequence length. + if hasattr(self.hyperparameters, "validate_length_constraints"): + self.hyperparameters.validate_length_constraints() + # Build hyperparameters early to inject into recipe template before runtime. final_hyperparameters = self.hyperparameters.to_dict() _validate_hyperparameter_values(final_hyperparameters) diff --git a/sagemaker-train/src/sagemaker/train/common.py b/sagemaker-train/src/sagemaker/train/common.py index cd6f2c5f6e..be0c301e51 100644 --- a/sagemaker-train/src/sagemaker/train/common.py +++ b/sagemaker-train/src/sagemaker/train/common.py @@ -22,16 +22,60 @@ class CustomizationTechnique(Enum): class FineTuningOptions: """Dynamic class for fine-tuning options with validation.""" - - def __init__(self, options_dict: Dict[str, Any]): + + def __init__(self, options_dict: Dict[str, Any], sequence_length: int = None): self._specs = options_dict.copy() self._user_set = set() self._initialized = False + # Recipe's supported sequence-length ceiling (prompt + response for RL, + # or max_length for SFT/DPO). Used by validate_length_constraints(). + self._sequence_length = sequence_length # Extract default values and set as attributes (no validation during init) for key, spec in options_dict.items(): default_value = spec.get('default') if isinstance(spec, dict) else spec super().__setattr__(key, default_value) self._initialized = True + + # SFT/DPO recipes express the single-example length ceiling as + # "dataset_max_len" (verl and llmft templates). It maps to the recipe's + # sequence-length ceiling, so validation checks it. + _MAX_LENGTH_PARAMS = ("dataset_max_len",) + + def validate_length_constraints(self): + """Enforce that selected lengths fit the recipe's sequence_length. + + For RL recipes: max_prompt_length + max_response_length must not exceed + sequence_length. For SFT/DPO: the single-example length (dataset_max_len) + must not exceed it. Per-field min/max are already enforced on assignment; + this adds the cross-field sum check that a per-field max cannot express. + + Framework-agnostic: gated only on the recipe's sequence_length metadata, + not on the model family. No-op if sequence_length is unknown or the + relevant params are absent. + """ + if self._sequence_length is None: + return + + prompt = getattr(self, "max_prompt_length", None) + response = getattr(self, "max_response_length", None) + if prompt is not None and response is not None: + total = prompt + response + if total > self._sequence_length: + raise ValueError( + f"max_prompt_length ({prompt}) + max_response_length ({response}) " + f"= {total} exceeds the recipe's supported sequence length " + f"({self._sequence_length}). Lower max_prompt_length and/or " + f"max_response_length so their sum is within {self._sequence_length}." + ) + + for param in self._MAX_LENGTH_PARAMS: + max_length = getattr(self, param, None) + if max_length is not None and max_length > self._sequence_length: + raise ValueError( + f"{param} ({max_length}) exceeds the recipe's supported sequence " + f"length ({self._sequence_length}). Set {param} to " + f"{self._sequence_length} or lower." + ) def to_dict(self) -> Dict[str, Any]: """Convert back to dictionary for hyperparameters with string values.""" diff --git a/sagemaker-train/src/sagemaker/train/common_utils/finetune_utils.py b/sagemaker-train/src/sagemaker/train/common_utils/finetune_utils.py index dc40bb981e..980d5dd27d 100644 --- a/sagemaker-train/src/sagemaker/train/common_utils/finetune_utils.py +++ b/sagemaker-train/src/sagemaker/train/common_utils/finetune_utils.py @@ -568,8 +568,28 @@ def _resolve_model_package_arn(model_package) -> Optional[str]: return None +def _parse_sequence_length(value) -> int: + """Parse a sequence length value like '8K', '32K', '128K' into an integer (e.g., 8192).""" + if not value: + return 0 + value = str(value).strip().upper() + if not value.endswith("K"): + raise ValueError( + f"Invalid sequence_length '{value}'. " + f"Expected a value ending in 'K', e.g. '8K' or '128K'." + ) + try: + return int(value[:-1]) * 1024 + except ValueError: + raise ValueError( + f"Invalid sequence_length '{value}'. " + f"Expected a numeric value followed by 'K', e.g. '8K' or '128K'." + ) + + def _get_fine_tuning_options_and_model_arn(model_name: str, customization_technique: str, training_type, sagemaker_session, - hub_name: Optional[str] = None, compute: Optional[Union[HyperPodCompute, TrainingJobCompute]] = None) -> tuple: + sequence_length=None, hub_name: Optional[str] = None, + compute: Optional[Union[HyperPodCompute, TrainingJobCompute]] = None) -> tuple: """Get fine-tuning options and model ARN for given customization technique. Returns: tuple: (FineTuningOptions, model_arn, is_gated_model) @@ -621,6 +641,29 @@ def _get_fine_tuning_options_and_model_arn(model_name: str, customization_techni if not recipes_with_template: raise ValueError(f"No recipes found with {platform_label} for technique: {customization_technique}") + # Filter by SequenceLength before recipe selection if sequence_length is requested. + # Multiple recipes may share the same SequenceLength (e.g. LORA and FULL + # variants); keep every exact match so _select_recipe_by_training_type can + # pick the right one for the requested training type. + if sequence_length: + requested = _parse_sequence_length(sequence_length) + candidates_with_sequence = [r for r in recipes_with_template if r.get("SequenceLength")] + if candidates_with_sequence: + filtered = [r for r in candidates_with_sequence if _parse_sequence_length(r.get("SequenceLength")) == requested] + if filtered: + recipes_with_template = filtered + else: + available = sorted(set(r.get("SequenceLength") for r in candidates_with_sequence)) + raise ValueError( + f"No recipes found with SequenceLength == {sequence_length}. " + f"Available sequence lengths: {available}" + ) + else: + raise ValueError( + f"No recipes found with {platform_label} for technique: {customization_technique},training_type:{training_type}, " + f"and sequence length:{sequence_length}" + ) + # Select recipe based on training type recipe = _select_recipe_by_training_type(recipes_with_template, training_type) @@ -679,10 +722,16 @@ def _get_fine_tuning_options_and_model_arn(model_name: str, customization_techni except Exception as e: logger.debug(f"Could not fetch subscription recipe override_params: {type(e).__name__}: {e}") + # Supported sequence-length ceiling: the recipe's SequenceLength ("K") + # is the single source of truth. Parse it to an int so we can validate that + # max_prompt_length + max_response_length (or max_length) stays within + # what the recipe's hardware can serve. 0 if absent/unparseable (no-op). + sequence_length_ceiling = _parse_sequence_length(recipe.get("SequenceLength")) or None + if options_dict: - return FineTuningOptions(options_dict), model_arn, is_gated_model + return FineTuningOptions(options_dict, sequence_length=sequence_length_ceiling), model_arn, is_gated_model else: - return FineTuningOptions({}), model_arn, is_gated_model + return FineTuningOptions({}, sequence_length=sequence_length_ceiling), model_arn, is_gated_model except Exception as e: logger.debug("Exception getting fine-tuning options: %s", e) @@ -881,7 +930,8 @@ def _resolve_model_and_name(model, sagemaker_session=None): def _create_serverless_config(model_arn, customization_technique, - training_type, accept_eula, evaluator_arn=None, job_type=JOB_TYPE) -> Optional['ServerlessJobConfig']: + training_type, accept_eula, evaluator_arn=None, + sequence_length=None, job_type=JOB_TYPE) -> Optional['ServerlessJobConfig']: """Create serverless job configuration for fine-tuning. Args: @@ -890,6 +940,7 @@ def _create_serverless_config(model_arn, customization_technique, training_type: Training type (TrainingType enum or string) accept_eula: Boolean indicating if EULA is accepted evaluator_arn: Optional evaluator ARN for RLVR/RLAIF + sequence_length: Optional sequence length enum value (e.g., "1K", "2K", "4K", "8K", "16K", "32K", "64K", "128K") job_type: Type of job (default: "FineTuning") Returns: @@ -905,7 +956,8 @@ def _create_serverless_config(model_arn, customization_technique, customization_technique=customization_technique, peft=peft, evaluator_arn=evaluator_arn, - accept_eula=accept_eula + accept_eula=accept_eula, + sequence_length=sequence_length, ) return serverless_config diff --git a/sagemaker-train/src/sagemaker/train/dpo_trainer.py b/sagemaker-train/src/sagemaker/train/dpo_trainer.py index a83d1ed2fe..f187bde8cf 100644 --- a/sagemaker-train/src/sagemaker/train/dpo_trainer.py +++ b/sagemaker-train/src/sagemaker/train/dpo_trainer.py @@ -105,6 +105,10 @@ class DPOTrainer(BaseTrainer): stopping_condition (Optional[StoppingCondition]): The stopping condition to override training runtime limit. If not specified, uses SageMaker service default (24 hours for serverless training). + sequence_length (Optional[str]): + The sequence length for the training job. Valid values are + "1K", "2K", "4K", "8K", "16K", "32K", "64K", "128K". + If not specified, the service will use default recipe selection behavior. is_multimodal (Optional[bool]): Whether the training dataset contains multimodal data. If None (default), auto-detected from the training dataset at train time. @@ -128,6 +132,7 @@ def __init__( networking: Optional[VpcConfig] = None, accept_eula: bool = False, stopping_condition: Optional[StoppingCondition] = None, + sequence_length: Optional[str] = None, recipe: Optional[str] = None, overrides: Optional[dict] = None, is_multimodal: Optional[bool] = None, @@ -164,6 +169,7 @@ def __init__( self.kms_key_id = kms_key_id self.networking = networking self.stopping_condition = stopping_condition + self.sequence_length = sequence_length self._recipe_path = recipe self._overrides = overrides self._recipe_resolver = None @@ -176,8 +182,8 @@ def __init__( self.training_type, self.sagemaker_session or TrainDefaults.get_sagemaker_session( sagemaker_session=self.sagemaker_session - ), + sequence_length=self.sequence_length, compute=self.compute) # Process hyperparameters @@ -292,12 +298,14 @@ def train(self, disable_output_compression=getattr(self, 'disable_output_compression', False), ) - serverless_config = _create_serverless_config(model_arn=self._model_arn, - customization_technique=CustomizationTechnique.DPO.value, - training_type=self.training_type, - accept_eula=self.accept_eula, - job_type=JOB_TYPE - ) + serverless_config = _create_serverless_config( + model_arn=self._model_arn, + customization_technique=CustomizationTechnique.DPO.value, + training_type=self.training_type, + accept_eula=self.accept_eula, + sequence_length=self.sequence_length, + job_type=JOB_TYPE + ) mlflow_config = _create_mlflow_config( sagemaker_session, diff --git a/sagemaker-train/src/sagemaker/train/rlaif_trainer.py b/sagemaker-train/src/sagemaker/train/rlaif_trainer.py index 5077c288d5..95020c1518 100644 --- a/sagemaker-train/src/sagemaker/train/rlaif_trainer.py +++ b/sagemaker-train/src/sagemaker/train/rlaif_trainer.py @@ -117,6 +117,10 @@ class RLAIFTrainer(BaseTrainer): stopping_condition (Optional[StoppingCondition]): The stopping condition to override training runtime limit. If not specified, uses SageMaker service default (24 hours for serverless training). + sequence_length (Optional[str]): + The sequence length for the training job. Valid values are + "1K", "2K", "4K", "8K", "16K", "32K", "64K", "128K". + If not specified, the service will use default recipe selection behavior. is_multimodal (Optional[bool]): Whether the training dataset contains multimodal data. If None (default), auto-detected from the training dataset at train time. @@ -141,6 +145,7 @@ def __init__( networking: Optional[VpcConfig] = None, accept_eula: bool = False, stopping_condition: Optional[StoppingCondition] = None, + sequence_length: Optional[str] = None, is_multimodal: Optional[bool] = None, **kwargs, ): @@ -163,15 +168,17 @@ def __init__( self.kms_key_id = kms_key_id self.networking = networking self.stopping_condition = stopping_condition + self.sequence_length = sequence_length self.is_multimodal = is_multimodal # Initialize fine-tuning options with beta session fallback - self.hyperparameters, self._model_arn, is_gated_model = _get_fine_tuning_options_and_model_arn(self._model_name, - CustomizationTechnique.RLAIF.value, - self.training_type, - self.sagemaker_session or TrainDefaults.get_sagemaker_session( - sagemaker_session=self.sagemaker_session - )) + self.hyperparameters, self._model_arn, is_gated_model = _get_fine_tuning_options_and_model_arn( + self._model_name, + CustomizationTechnique.RLAIF.value, + self.training_type, + self.sagemaker_session or TrainDefaults.get_sagemaker_session(sagemaker_session=self.sagemaker_session), + sequence_length=self.sequence_length + ) # Validate and set EULA acceptance self.accept_eula = _validate_eula_for_gated_model(model, accept_eula, is_gated_model) @@ -256,13 +263,15 @@ def train(self, training_dataset: Optional[Union[str, DataSet]] = None, validati ) evaluator_arn = getattr(self, '_evaluator_arn', None) - serverless_config = _create_serverless_config(model_arn=self._model_arn, - customization_technique=CustomizationTechnique.RLAIF.value, - training_type=self.training_type, - accept_eula=self.accept_eula, - evaluator_arn=evaluator_arn, - job_type=JOB_TYPE - ) + serverless_config = _create_serverless_config( + model_arn=self._model_arn, + customization_technique=CustomizationTechnique.RLAIF.value, + training_type=self.training_type, + accept_eula=self.accept_eula, + evaluator_arn=evaluator_arn, + sequence_length=self.sequence_length, + job_type=JOB_TYPE + ) mlflow_config = _create_mlflow_config( sagemaker_session, @@ -271,6 +280,9 @@ def train(self, training_dataset: Optional[Union[str, DataSet]] = None, validati mlflow_run_name=self.mlflow_run_name, ) + # Enforce prompt + response fit the recipe's supported sequence length. + self.hyperparameters.validate_length_constraints() + final_hyperparameters = self.hyperparameters.to_dict() # Resolve is_multimodal: auto-detect from training dataset if not explicitly set diff --git a/sagemaker-train/src/sagemaker/train/rlvr_trainer.py b/sagemaker-train/src/sagemaker/train/rlvr_trainer.py index f20b54e7b1..8d77aca4e9 100644 --- a/sagemaker-train/src/sagemaker/train/rlvr_trainer.py +++ b/sagemaker-train/src/sagemaker/train/rlvr_trainer.py @@ -132,6 +132,10 @@ class RLVRTrainer(BaseTrainer): stopping_condition (Optional[StoppingCondition]): The stopping condition to override training runtime limit. If not specified, uses SageMaker service default (24 hours for serverless training). + sequence_length (Optional[str]): + The sequence length for the training job. Valid values are + "1K", "2K", "4K", "8K", "16K", "32K", "64K", "128K". + If not specified, the service will use default recipe selection behavior. is_multimodal (Optional[bool]): Whether the training dataset contains multimodal data. If None (default), auto-detected from the training dataset at train time. @@ -159,6 +163,7 @@ def __init__( networking: Optional[VpcConfig] = None, accept_eula: bool = False, stopping_condition: Optional[StoppingCondition] = None, + sequence_length: Optional[str] = None, recipe: Optional[str] = None, overrides: Optional[dict] = None, is_multimodal: Optional[bool] = None, @@ -197,6 +202,7 @@ def __init__( self.kms_key_id = kms_key_id self.networking = networking self.stopping_condition = stopping_condition + self.sequence_length = sequence_length self._recipe_path = recipe self._overrides = overrides self._recipe_resolver = None @@ -211,6 +217,7 @@ def __init__( self.sagemaker_session or TrainDefaults.get_sagemaker_session( sagemaker_session=self.sagemaker_session ), + sequence_length=self.sequence_length, compute=self.compute) # Remove constructor-handled hyperparameters @@ -460,6 +467,7 @@ def train(self, training_dataset: Optional[Union[str, DataSet]] = None, training_type=self.training_type, accept_eula=self.accept_eula, evaluator_arn=evaluator_arn, + sequence_length=self.sequence_length, job_type=JOB_TYPE ) mlflow_config = _create_mlflow_config( @@ -469,6 +477,9 @@ def train(self, training_dataset: Optional[Union[str, DataSet]] = None, mlflow_run_name=self.mlflow_run_name, ) + # Enforce prompt + response fit the recipe's supported sequence length. + self.hyperparameters.validate_length_constraints() + final_hyperparameters = self.hyperparameters.to_dict() # Apply recipe/overrides if provided (overrides > recipe > Hub defaults) diff --git a/sagemaker-train/src/sagemaker/train/sft_trainer.py b/sagemaker-train/src/sagemaker/train/sft_trainer.py index 3f14c348f0..f182835f16 100644 --- a/sagemaker-train/src/sagemaker/train/sft_trainer.py +++ b/sagemaker-train/src/sagemaker/train/sft_trainer.py @@ -116,6 +116,10 @@ class SFTTrainer(BaseTrainer): stopping_condition (Optional[StoppingCondition]): The stopping condition to override training runtime limit. If not specified, uses SageMaker service default (24 hours for serverless training). + sequence_length (Optional[str]): + The sequence length for the training job. Valid values are + "1K", "2K", "4K", "8K", "16K", "32K", "64K", "128K". + If not specified, the service will use default recipe selection behavior. recipe (Optional[str]): Path to a user recipe YAML file (local path or S3 URI). When provided, enables 3-level recipe resolution: Hub defaults < recipe file < overrides dict. @@ -157,6 +161,7 @@ def __init__( networking: Optional[VpcConfig] = None, accept_eula: Optional[bool] = False, stopping_condition: Optional[StoppingCondition] = None, + sequence_length: Optional[str] = None, recipe: Optional[str] = None, overrides: Optional[dict] = None, is_multimodal: Optional[bool] = None, @@ -195,6 +200,7 @@ def __init__( self.kms_key_id = kms_key_id self.networking = networking self.stopping_condition = stopping_condition + self.sequence_length = sequence_length self._recipe_path = recipe self._overrides = overrides self._recipe_resolver = None @@ -209,8 +215,9 @@ def __init__( self.sagemaker_session or TrainDefaults.get_sagemaker_session( sagemaker_session=self.sagemaker_session ), + sequence_length=self.sequence_length, compute=self.compute) - + # Process hyperparameters self._process_hyperparameters() @@ -346,12 +353,14 @@ def train(self, training_dataset: Optional[Union[str, DataSet]] = None, validati disable_output_compression=getattr(self, 'disable_output_compression', False), ) - serverless_config = _create_serverless_config(model_arn=self._model_arn, - customization_technique=CustomizationTechnique.SFT.value, - training_type=self.training_type, - accept_eula=self.accept_eula, - job_type=JOB_TYPE - ) + serverless_config = _create_serverless_config( + model_arn=self._model_arn, + customization_technique=CustomizationTechnique.SFT.value, + training_type=self.training_type, + accept_eula=self.accept_eula, + sequence_length=self.sequence_length, + job_type=JOB_TYPE + ) mlflow_config = _create_mlflow_config( sagemaker_session, mlflow_resource_arn=self.mlflow_resource_arn, diff --git a/sagemaker-train/tests/integ/train/test_sft_trainer_integration.py b/sagemaker-train/tests/integ/train/test_sft_trainer_integration.py index 68446991c4..61d9ff3122 100644 --- a/sagemaker-train/tests/integ/train/test_sft_trainer_integration.py +++ b/sagemaker-train/tests/integ/train/test_sft_trainer_integration.py @@ -135,3 +135,39 @@ def test_sft_trainer_nova_workflow(sagemaker_session_us_east_1): assert training_job.training_job_status == "Completed" assert hasattr(training_job, 'output_model_package_arn') assert training_job.output_model_package_arn is not None + + +# @pytest.mark.gpu_intensive +def test_sft_trainer_lora_with_sequence_length(sagemaker_session): + """Test SFT training workflow with LORA and sequence_length specified.""" + unique_id = f"{int(time.time())}-{random.randint(1000, 9999)}" + + sft_trainer = SFTTrainer( + model="meta-textgeneration-llama-3-2-1b-instruct", + training_type=TrainingType.LORA, + model_package_group="arn:aws:sagemaker:us-west-2:729646638167:model-package-group/sdk-test-finetuned-models", + training_dataset="s3://mc-flows-sdk-testing/input_data/sft/sample_data_256_final.jsonl", + s3_output_path="s3://mc-flows-sdk-testing/output/", + accept_eula=True, + sequence_length="8K", + base_job_name=f"sft-seqlen-integ-{unique_id}", + ) + + training_job = sft_trainer.train(wait=False) + + max_wait_time = 3600 + poll_interval = 30 + start_time = time.time() + + while time.time() - start_time < max_wait_time: + training_job.refresh() + status = training_job.training_job_status + + if status in ["Completed", "Failed", "Stopped"]: + break + + time.sleep(poll_interval) + + assert training_job.training_job_status == "Completed" + assert hasattr(training_job, 'output_model_package_arn') + assert training_job.output_model_package_arn is not None diff --git a/sagemaker-train/tests/unit/train/common_utils/test_finetune_utils.py b/sagemaker-train/tests/unit/train/common_utils/test_finetune_utils.py index 1c5e495f63..02e5e1d22f 100644 --- a/sagemaker-train/tests/unit/train/common_utils/test_finetune_utils.py +++ b/sagemaker-train/tests/unit/train/common_utils/test_finetune_utils.py @@ -31,9 +31,11 @@ _create_mlflow_config, _validate_eula_for_gated_model, _validate_model_region_availability, - _validate_s3_path_exists + _validate_s3_path_exists, + _parse_sequence_length ) from sagemaker.core.resources import ModelPackage, ModelPackageGroup +from sagemaker.core.utils.utils import Unassigned from sagemaker.ai_registry.dataset import DataSet from sagemaker.train.common import TrainingType from sagemaker.train.configs import InputData @@ -632,7 +634,6 @@ def test__convert_input_data_to_channels(self): def test__validate_eula_for_gated_model_with_model_package(self): """Test EULA validation returns True for ModelPackage input""" - from sagemaker.core.resources import ModelPackage model_package = Mock(spec=ModelPackage) result = _validate_eula_for_gated_model(model_package, False, True) @@ -1033,6 +1034,144 @@ def test__get_fine_tuning_options_subscription_enabled_but_not_subscribed(self, assert "max_steps" in options._specs assert "customer_data_percent" not in options._specs + def test__create_serverless_config_with_sequence_length(self): + config = _create_serverless_config("model-arn", "SFT", TrainingType.LORA, accept_eula=True, sequence_length="8K") + + assert config.sequence_length == "8K" + assert config.base_model_arn == "model-arn" + + def test__create_serverless_config_without_sequence_length(self): + config = _create_serverless_config("model-arn", "SFT", TrainingType.LORA, accept_eula=True) + + assert config.sequence_length is None + + def test__parse_sequence_length_with_k_suffix(self): + assert _parse_sequence_length("8K") == 8192 + assert _parse_sequence_length("32K") == 32768 + assert _parse_sequence_length("128K") == 131072 + + def test__parse_sequence_length_with_lowercase(self): + assert _parse_sequence_length("8k") == 8192 + + def test__parse_sequence_length_with_integer(self): + with pytest.raises(ValueError, match="Invalid sequence_length '4096'"): + _parse_sequence_length("4096") + + def test__parse_sequence_length_with_none(self): + assert _parse_sequence_length(None) == 0 + + def test__parse_sequence_length_with_empty(self): + assert _parse_sequence_length("") == 0 + + @patch('sagemaker.train.common_utils.finetune_utils._get_hub_content_metadata') + def test__get_fine_tuning_options_filters_by_exact_sequence_length(self, mock_get_hub_content): + mock_session = Mock() + mock_session.boto_session.region_name = "us-east-1" + mock_s3 = Mock() + mock_s3.get_object.return_value = { + "Body": Mock(read=Mock(return_value=b'{"max_length": {"default": 32768}}')) + } + mock_session.boto_session.client.return_value = mock_s3 + + mock_get_hub_content.return_value = { + 'hub_content_arn': "arn:aws:sagemaker:us-east-1:123456789012:model/test-model", + 'hub_content_document': { + "GatedBucket": False, + "RecipeCollection": [ + { + "CustomizationTechnique": "SFT", + "SmtjRecipeTemplateS3Uri": "s3://bucket/template-4k.json", + "SmtjOverrideParamsS3Uri": "s3://bucket/params-4k.json", + "Peft": True, + "SequenceLength": "4K" + }, + { + "CustomizationTechnique": "SFT", + "SmtjRecipeTemplateS3Uri": "s3://bucket/template-32k.json", + "SmtjOverrideParamsS3Uri": "s3://bucket/params-32k.json", + "Peft": True, + "SequenceLength": "32K" + } + ] + } + } + + result = _get_fine_tuning_options_and_model_arn("test-model", "SFT", "LORA", mock_session, sequence_length="32K") + + assert result is not None + options, model_arn, is_gated_model = result + # Should pick the recipe whose SequenceLength exactly matches the request. + mock_s3.get_object.assert_called_once() + call_args = mock_s3.get_object.call_args[1] + assert "params-32k" in call_args["Key"] + + @patch('sagemaker.train.common_utils.finetune_utils._get_hub_content_metadata') + def test__get_fine_tuning_options_keeps_all_recipes_at_same_sequence_length(self, mock_get_hub_content): + # Multiple recipes share the same SequenceLength (LORA + FULL). Selection + # by training_type must resolve to the LORA one, not an arbitrary match. + mock_session = Mock() + mock_session.boto_session.region_name = "us-east-1" + mock_s3 = Mock() + mock_s3.get_object.return_value = { + "Body": Mock(read=Mock(return_value=b'{"max_length": {"default": 32768}}')) + } + mock_session.boto_session.client.return_value = mock_s3 + + mock_get_hub_content.return_value = { + 'hub_content_arn': "arn:aws:sagemaker:us-east-1:123456789012:model/test-model", + 'hub_content_document': { + "GatedBucket": False, + "RecipeCollection": [ + { + "CustomizationTechnique": "SFT", + "SmtjRecipeTemplateS3Uri": "s3://bucket/template-32k-full.json", + "SmtjOverrideParamsS3Uri": "s3://bucket/params-32k-full.json", + "Peft": False, + "SequenceLength": "32K" + }, + { + "CustomizationTechnique": "SFT", + "SmtjRecipeTemplateS3Uri": "s3://bucket/template-32k-lora.json", + "SmtjOverrideParamsS3Uri": "s3://bucket/params-32k-lora.json", + "Peft": True, + "SequenceLength": "32K" + } + ] + } + } + + result = _get_fine_tuning_options_and_model_arn("test-model", "SFT", "LORA", mock_session, sequence_length="32K") + + assert result is not None + mock_s3.get_object.assert_called_once() + call_args = mock_s3.get_object.call_args[1] + assert "params-32k-lora" in call_args["Key"] + + @patch('sagemaker.train.common_utils.finetune_utils._get_hub_content_metadata') + def test__get_fine_tuning_options_raises_when_no_exact_sequence_length(self, mock_get_hub_content): + mock_session = Mock() + mock_session.boto_session.region_name = "us-east-1" + + mock_get_hub_content.return_value = { + 'hub_content_arn': "arn:aws:sagemaker:us-east-1:123456789012:model/test-model", + 'hub_content_document': { + "GatedBucket": False, + "RecipeCollection": [ + { + "CustomizationTechnique": "SFT", + "SmtjRecipeTemplateS3Uri": "s3://bucket/template-4k.json", + "SmtjOverrideParamsS3Uri": "s3://bucket/params-4k.json", + "Peft": True, + "SequenceLength": "4K" + } + ] + } + } + + # Requesting 128K but only 4K available — no exact match, should raise. + with pytest.raises(ValueError, match="No recipes found with SequenceLength == 128K"): + _get_fine_tuning_options_and_model_arn("test-model", "SFT", "LORA", mock_session, sequence_length="128K") + # =========================================================================== # Hub recipe/image resolution helpers diff --git a/sagemaker-train/tests/unit/train/test_common.py b/sagemaker-train/tests/unit/train/test_common.py index 25d1decbc1..74230cad60 100644 --- a/sagemaker-train/tests/unit/train/test_common.py +++ b/sagemaker-train/tests/unit/train/test_common.py @@ -54,3 +54,78 @@ def test_to_dict_all_none_returns_empty(self): }) result = options.to_dict() assert result == {} + + +import pytest + + +class TestValidateLengthConstraints: + """FineTuningOptions.validate_length_constraints() — sum vs sequence_length.""" + + def _rl_options(self, prompt, response, sequence_length): + # Per-field max defaults to sequence_length; when unknown, omit it so the + # per-field range check is a no-op and only the sum-guard is exercised. + prompt_spec = {"type": "integer", "default": 1024, "min": 512} + response_spec = {"type": "integer", "default": 2048, "min": 100} + if sequence_length is not None: + prompt_spec["max"] = sequence_length + response_spec["max"] = sequence_length + opts = FineTuningOptions( + {"max_prompt_length": prompt_spec, "max_response_length": response_spec}, + sequence_length=sequence_length, + ) + object.__setattr__(opts, "max_prompt_length", prompt) + object.__setattr__(opts, "max_response_length", response) + return opts + + def test_rl_sum_within_sequence_length_passes(self): + opts = self._rl_options(prompt=4096, response=8192, sequence_length=262144) + opts.validate_length_constraints() # no raise + + def test_rl_sum_equal_to_sequence_length_passes(self): + opts = self._rl_options(prompt=100000, response=162144, sequence_length=262144) + opts.validate_length_constraints() # sum == ceiling, allowed + + def test_rl_sum_exceeds_sequence_length_raises(self): + opts = self._rl_options(prompt=200000, response=200000, sequence_length=262144) + with pytest.raises(ValueError) as exc: + opts.validate_length_constraints() + msg = str(exc.value) + assert "400000" in msg and "262144" in msg + + def test_no_sequence_length_is_noop(self): + opts = self._rl_options(prompt=200000, response=200000, sequence_length=None) + opts.validate_length_constraints() # unknown ceiling -> no raise + + # verl/llmft SFT/DPO recipes vend the single-example ceiling as + # "dataset_max_len". Validation must enforce it so the ceiling is not + # silently unenforced for these frameworks. + def test_sft_dataset_max_len_within_sequence_length_passes(self): + opts = FineTuningOptions( + {"dataset_max_len": {"type": "integer", "default": 4096, "min": 4096, "max": 131072}}, + sequence_length=131072, + ) + opts.dataset_max_len = 65536 + opts.validate_length_constraints() # no raise + + def test_sft_dataset_max_len_exceeds_sequence_length_raises(self): + opts = FineTuningOptions( + {"dataset_max_len": {"type": "integer", "default": 4096, "min": 4096, "max": 131072}}, + sequence_length=131072, + ) + # Bypass the per-field max so the sequence-length guard is what raises. + object.__setattr__(opts, "dataset_max_len", 200000) + with pytest.raises(ValueError) as exc: + opts.validate_length_constraints() + msg = str(exc.value) + assert "dataset_max_len" in msg and "131072" in msg + + def test_framework_agnostic_gated_on_sequence_length_metadata_only(self): + # No sequence_length metadata (e.g. a recipe that does not vend it) -> the + # ceiling is unknown and validation is a no-op regardless of param name. + opts = FineTuningOptions( + {"dataset_max_len": {"type": "integer", "default": 4096, "min": 4096}}, + sequence_length=None, + ) + object.__setattr__(opts, "dataset_max_len", 999999) + opts.validate_length_constraints() # unknown ceiling -> no raise diff --git a/sagemaker-train/tests/unit/train/test_dpo_trainer.py b/sagemaker-train/tests/unit/train/test_dpo_trainer.py index e960451196..95a13a2c1c 100644 --- a/sagemaker-train/tests/unit/train/test_dpo_trainer.py +++ b/sagemaker-train/tests/unit/train/test_dpo_trainer.py @@ -509,6 +509,70 @@ def test_train_wait_false_skips_wait(self, mock_training_job_create, mock_model_ mock_wait.assert_not_called() + @patch('sagemaker.train.dpo_trainer._validate_and_resolve_model_package_group') + @patch('sagemaker.train.dpo_trainer._get_fine_tuning_options_and_model_arn') + def test_init_sequence_length_default_none(self, mock_finetuning_options, mock_validate_group): + mock_validate_group.return_value = "test-group" + mock_hyperparams = Mock() + mock_hyperparams.to_dict.return_value = {} + mock_finetuning_options.return_value = (mock_hyperparams, "model-arn", False) + trainer = DPOTrainer(model="test-model", model_package_group="test-group") + assert trainer.sequence_length is None + + @patch('sagemaker.train.dpo_trainer._validate_and_resolve_model_package_group') + @patch('sagemaker.train.dpo_trainer._get_fine_tuning_options_and_model_arn') + def test_init_with_sequence_length(self, mock_finetuning_options, mock_validate_group): + mock_validate_group.return_value = "test-group" + mock_hyperparams = Mock() + mock_hyperparams.to_dict.return_value = {} + mock_finetuning_options.return_value = (mock_hyperparams, "model-arn", False) + trainer = DPOTrainer(model="test-model", model_package_group="test-group", sequence_length="8K") + assert trainer.sequence_length == "8K" + + @patch('sagemaker.train.dpo_trainer._resolve_model_and_name') + @patch('sagemaker.train.dpo_trainer._get_fine_tuning_options_and_model_arn') + @patch('sagemaker.train.dpo_trainer.TrainDefaults.get_role') + @patch('sagemaker.train.dpo_trainer.TrainDefaults.get_sagemaker_session') + @patch('sagemaker.train.dpo_trainer._get_unique_name') + @patch('sagemaker.train.dpo_trainer._validate_and_resolve_model_package_group') + @patch('sagemaker.train.dpo_trainer._create_input_data_config') + @patch('sagemaker.train.dpo_trainer._convert_input_data_to_channels') + @patch('sagemaker.train.dpo_trainer._create_output_config') + @patch('sagemaker.train.dpo_trainer._create_serverless_config') + @patch('sagemaker.train.dpo_trainer._create_mlflow_config') + @patch('sagemaker.train.dpo_trainer._create_model_package_config') + @patch('sagemaker.core.resources.TrainingJob.create') + def test_train_passes_sequence_length_to_serverless_config(self, mock_training_job_create, + mock_model_package_config, mock_mlflow_config, mock_serverless_config, + mock_output_config, mock_convert_channels, mock_input_config, + mock_validate_group, mock_unique_name, mock_get_sagemaker_session, + mock_get_role, mock_get_options, mock_resolve_model): + mock_validate_group.return_value = "test-group" + mock_resolve_model.return_value = ("test-model", "test-model") + mock_get_sagemaker_session.return_value = Mock() + mock_fine_tuning_options = Mock() + mock_fine_tuning_options.to_dict.return_value = {} + mock_get_options.return_value = (mock_fine_tuning_options, "model-arn", False) + mock_get_role.return_value = "test-role" + mock_unique_name.return_value = "test-job-name" + mock_input_config.return_value = [Mock()] + mock_convert_channels.return_value = [Mock()] + mock_output_config.return_value = Mock() + mock_serverless_config.return_value = Mock() + mock_mlflow_config.return_value = Mock() + mock_model_package_config.return_value = Mock() + mock_training_job = Mock() + mock_training_job_create.return_value = mock_training_job + + trainer = DPOTrainer(model="test-model", model_package_group="test-group", + training_dataset="s3://bucket/train", sequence_length="16K") + trainer.train(wait=False) + + mock_serverless_config.assert_called_once() + call_kwargs = mock_serverless_config.call_args[1] + assert call_kwargs["sequence_length"] == "16K" + + class TestDPOTrainerComputeDispatch: """Tests for compute dispatch in DPOTrainer.""" diff --git a/sagemaker-train/tests/unit/train/test_rlaif_trainer.py b/sagemaker-train/tests/unit/train/test_rlaif_trainer.py index a4f16fee42..34edeac298 100644 --- a/sagemaker-train/tests/unit/train/test_rlaif_trainer.py +++ b/sagemaker-train/tests/unit/train/test_rlaif_trainer.py @@ -682,4 +682,70 @@ def test_train_wait_false_skips_wait(self, mock_training_job_create, mock_model_ trainer = RLAIFTrainer(model="test-model", model_package_group="test-group", training_dataset="s3://bucket/train") trainer.train(wait=False, wait_timeout=600) - mock_wait.assert_not_called() \ No newline at end of file + mock_wait.assert_not_called() + + @patch('sagemaker.train.rlaif_trainer._validate_and_resolve_model_package_group') + @patch('sagemaker.train.rlaif_trainer._get_fine_tuning_options_and_model_arn') + def test_init_sequence_length_default_none(self, mock_finetuning_options, mock_validate_group): + mock_validate_group.return_value = "test-group" + mock_hyperparams = Mock() + mock_hyperparams.to_dict.return_value = {} + mock_hyperparams._specs = {} + mock_finetuning_options.return_value = (mock_hyperparams, "model-arn", False) + trainer = RLAIFTrainer(model="test-model", model_package_group="test-group") + assert trainer.sequence_length is None + + @patch('sagemaker.train.rlaif_trainer._validate_and_resolve_model_package_group') + @patch('sagemaker.train.rlaif_trainer._get_fine_tuning_options_and_model_arn') + def test_init_with_sequence_length(self, mock_finetuning_options, mock_validate_group): + mock_validate_group.return_value = "test-group" + mock_hyperparams = Mock() + mock_hyperparams.to_dict.return_value = {} + mock_hyperparams._specs = {} + mock_finetuning_options.return_value = (mock_hyperparams, "model-arn", False) + trainer = RLAIFTrainer(model="test-model", model_package_group="test-group", sequence_length="128K") + assert trainer.sequence_length == "128K" + + @patch('sagemaker.train.rlaif_trainer._resolve_model_and_name') + @patch('sagemaker.train.rlaif_trainer._get_fine_tuning_options_and_model_arn') + @patch('sagemaker.train.rlaif_trainer.TrainDefaults.get_role') + @patch('sagemaker.train.rlaif_trainer.TrainDefaults.get_sagemaker_session') + @patch('sagemaker.train.rlaif_trainer._get_unique_name') + @patch('sagemaker.train.rlaif_trainer._validate_and_resolve_model_package_group') + @patch('sagemaker.train.rlaif_trainer._create_input_data_config') + @patch('sagemaker.train.rlaif_trainer._convert_input_data_to_channels') + @patch('sagemaker.train.rlaif_trainer._create_output_config') + @patch('sagemaker.train.rlaif_trainer._create_serverless_config') + @patch('sagemaker.train.rlaif_trainer._create_mlflow_config') + @patch('sagemaker.train.rlaif_trainer._create_model_package_config') + @patch('sagemaker.core.resources.TrainingJob.create') + def test_train_passes_sequence_length_to_serverless_config(self, mock_training_job_create, + mock_model_package_config, mock_mlflow_config, mock_serverless_config, + mock_output_config, mock_convert_channels, mock_input_config, + mock_validate_group, mock_unique_name, mock_get_sagemaker_session, + mock_get_role, mock_get_options, mock_resolve_model): + mock_validate_group.return_value = "test-group" + mock_resolve_model.return_value = ("test-model", "test-model") + mock_get_sagemaker_session.return_value = Mock() + mock_fine_tuning_options = Mock() + mock_fine_tuning_options.to_dict.return_value = {} + mock_fine_tuning_options._specs = {} + mock_get_options.return_value = (mock_fine_tuning_options, "model-arn", False) + mock_get_role.return_value = "test-role" + mock_unique_name.return_value = "test-job-name" + mock_input_config.return_value = [Mock()] + mock_convert_channels.return_value = [Mock()] + mock_output_config.return_value = Mock() + mock_serverless_config.return_value = Mock() + mock_mlflow_config.return_value = Mock() + mock_model_package_config.return_value = Mock() + mock_training_job = Mock() + mock_training_job_create.return_value = mock_training_job + + trainer = RLAIFTrainer(model="test-model", model_package_group="test-group", + training_dataset="s3://bucket/train", sequence_length="64K") + trainer.train(wait=False) + + mock_serverless_config.assert_called_once() + call_kwargs = mock_serverless_config.call_args[1] + assert call_kwargs["sequence_length"] == "64K" diff --git a/sagemaker-train/tests/unit/train/test_rlvr_trainer.py b/sagemaker-train/tests/unit/train/test_rlvr_trainer.py index 5a6178392b..198b8a50ff 100644 --- a/sagemaker-train/tests/unit/train/test_rlvr_trainer.py +++ b/sagemaker-train/tests/unit/train/test_rlvr_trainer.py @@ -512,6 +512,70 @@ def test_train_wait_false_skips_wait(self, mock_training_job_create, mock_model_ mock_wait.assert_not_called() + @patch('sagemaker.train.rlvr_trainer._validate_and_resolve_model_package_group') + @patch('sagemaker.train.rlvr_trainer._get_fine_tuning_options_and_model_arn') + def test_init_sequence_length_default_none(self, mock_finetuning_options, mock_validate_group): + mock_validate_group.return_value = "test-group" + mock_hyperparams = Mock() + mock_hyperparams.to_dict.return_value = {} + mock_finetuning_options.return_value = (mock_hyperparams, "model-arn", False) + trainer = RLVRTrainer(model="test-model", model_package_group="test-group") + assert trainer.sequence_length is None + + @patch('sagemaker.train.rlvr_trainer._validate_and_resolve_model_package_group') + @patch('sagemaker.train.rlvr_trainer._get_fine_tuning_options_and_model_arn') + def test_init_with_sequence_length(self, mock_finetuning_options, mock_validate_group): + mock_validate_group.return_value = "test-group" + mock_hyperparams = Mock() + mock_hyperparams.to_dict.return_value = {} + mock_finetuning_options.return_value = (mock_hyperparams, "model-arn", False) + trainer = RLVRTrainer(model="test-model", model_package_group="test-group", sequence_length="32K") + assert trainer.sequence_length == "32K" + + @patch('sagemaker.train.rlvr_trainer._resolve_model_and_name') + @patch('sagemaker.train.rlvr_trainer._get_fine_tuning_options_and_model_arn') + @patch('sagemaker.train.rlvr_trainer.TrainDefaults.get_role') + @patch('sagemaker.train.rlvr_trainer.TrainDefaults.get_sagemaker_session') + @patch('sagemaker.train.rlvr_trainer._get_unique_name') + @patch('sagemaker.train.rlvr_trainer._validate_and_resolve_model_package_group') + @patch('sagemaker.train.rlvr_trainer._create_input_data_config') + @patch('sagemaker.train.rlvr_trainer._convert_input_data_to_channels') + @patch('sagemaker.train.rlvr_trainer._create_output_config') + @patch('sagemaker.train.rlvr_trainer._create_serverless_config') + @patch('sagemaker.train.rlvr_trainer._create_mlflow_config') + @patch('sagemaker.train.rlvr_trainer._create_model_package_config') + @patch('sagemaker.core.resources.TrainingJob.create') + def test_train_passes_sequence_length_to_serverless_config(self, mock_training_job_create, + mock_model_package_config, mock_mlflow_config, mock_serverless_config, + mock_output_config, mock_convert_channels, mock_input_config, + mock_validate_group, mock_unique_name, mock_get_sagemaker_session, + mock_get_role, mock_get_options, mock_resolve_model): + mock_validate_group.return_value = "test-group" + mock_resolve_model.return_value = ("test-model", "test-model") + mock_get_sagemaker_session.return_value = Mock() + mock_fine_tuning_options = Mock() + mock_fine_tuning_options.to_dict.return_value = {} + mock_get_options.return_value = (mock_fine_tuning_options, "model-arn", False) + mock_get_role.return_value = "test-role" + mock_unique_name.return_value = "test-job-name" + mock_input_config.return_value = [Mock()] + mock_convert_channels.return_value = [Mock()] + mock_output_config.return_value = Mock() + mock_serverless_config.return_value = Mock() + mock_mlflow_config.return_value = Mock() + mock_model_package_config.return_value = Mock() + mock_training_job = Mock() + mock_training_job_create.return_value = mock_training_job + + trainer = RLVRTrainer(model="test-model", model_package_group="test-group", + training_dataset="s3://bucket/train", sequence_length="4K") + trainer.train(wait=False) + + mock_serverless_config.assert_called_once() + call_kwargs = mock_serverless_config.call_args[1] + assert call_kwargs["sequence_length"] == "4K" + + class TestRLVRTrainerComputeDispatch: """Tests for compute dispatch in RLVRTrainer.""" diff --git a/sagemaker-train/tests/unit/train/test_sft_trainer.py b/sagemaker-train/tests/unit/train/test_sft_trainer.py index 7586a8d7df..cd2010ceda 100644 --- a/sagemaker-train/tests/unit/train/test_sft_trainer.py +++ b/sagemaker-train/tests/unit/train/test_sft_trainer.py @@ -523,6 +523,70 @@ def test_train_wait_false_skips_wait(self, mock_training_job_create, mock_model_ mock_wait.assert_not_called() + @patch('sagemaker.train.sft_trainer._validate_and_resolve_model_package_group') + @patch('sagemaker.train.sft_trainer._get_fine_tuning_options_and_model_arn') + def test_init_sequence_length_default_none(self, mock_finetuning_options, mock_validate_group): + mock_validate_group.return_value = "test-group" + mock_hyperparams = Mock() + mock_hyperparams.to_dict.return_value = {} + mock_finetuning_options.return_value = (mock_hyperparams, "model-arn", False) + trainer = SFTTrainer(model="test-model", model_package_group="test-group") + assert trainer.sequence_length is None + + @patch('sagemaker.train.sft_trainer._validate_and_resolve_model_package_group') + @patch('sagemaker.train.sft_trainer._get_fine_tuning_options_and_model_arn') + def test_init_with_sequence_length(self, mock_finetuning_options, mock_validate_group): + mock_validate_group.return_value = "test-group" + mock_hyperparams = Mock() + mock_hyperparams.to_dict.return_value = {} + mock_finetuning_options.return_value = (mock_hyperparams, "model-arn", False) + trainer = SFTTrainer(model="test-model", model_package_group="test-group", sequence_length="8K") + assert trainer.sequence_length == "8K" + + @patch('sagemaker.train.sft_trainer._resolve_model_and_name') + @patch('sagemaker.train.sft_trainer._get_fine_tuning_options_and_model_arn') + @patch('sagemaker.train.sft_trainer.TrainDefaults.get_role') + @patch('sagemaker.train.sft_trainer.TrainDefaults.get_sagemaker_session') + @patch('sagemaker.train.sft_trainer._get_unique_name') + @patch('sagemaker.train.sft_trainer._validate_and_resolve_model_package_group') + @patch('sagemaker.train.sft_trainer._create_input_data_config') + @patch('sagemaker.train.sft_trainer._convert_input_data_to_channels') + @patch('sagemaker.train.sft_trainer._create_output_config') + @patch('sagemaker.train.sft_trainer._create_serverless_config') + @patch('sagemaker.train.sft_trainer._create_mlflow_config') + @patch('sagemaker.train.sft_trainer._create_model_package_config') + @patch('sagemaker.core.resources.TrainingJob.create') + def test_train_passes_sequence_length_to_serverless_config(self, mock_training_job_create, + mock_model_package_config, mock_mlflow_config, mock_serverless_config, + mock_output_config, mock_convert_channels, mock_input_config, + mock_validate_group, mock_unique_name, mock_get_sagemaker_session, + mock_get_role, mock_get_options, mock_resolve_model): + mock_validate_group.return_value = "test-group" + mock_resolve_model.return_value = ("test-model", "test-model") + mock_get_sagemaker_session.return_value = Mock() + mock_fine_tuning_options = Mock() + mock_fine_tuning_options.to_dict.return_value = {} + mock_get_options.return_value = (mock_fine_tuning_options, "model-arn", False) + mock_get_role.return_value = "test-role" + mock_unique_name.return_value = "test-job-name" + mock_input_config.return_value = [Mock()] + mock_convert_channels.return_value = [Mock()] + mock_output_config.return_value = Mock() + mock_serverless_config.return_value = Mock() + mock_mlflow_config.return_value = Mock() + mock_model_package_config.return_value = Mock() + mock_training_job = Mock() + mock_training_job_create.return_value = mock_training_job + + trainer = SFTTrainer(model="test-model", model_package_group="test-group", + training_dataset="s3://bucket/train", sequence_length="16K") + trainer.train(wait=False) + + mock_serverless_config.assert_called_once() + call_kwargs = mock_serverless_config.call_args[1] + assert call_kwargs["sequence_length"] == "16K" + + class TestSFTTrainerComputeDispatch: """Tests for compute dispatch in SFTTrainer."""