Skip to content

Commit 2c7775e

Browse files
feat(train): Add SequenceLength support for SFT, DPO, RLVR, RLAIF trainers (#5965)
* feat(train): Add SequenceLength support for SFT, DPO, RLVR, RLAIF trainers Add optional sequence_length parameter to all four trainers that enables customers to specify their desired context length for serverless training jobs. The parameter is passed in ServerlessJobConfig for recipe filtering. During trainer initialization, _get_fine_tuning_options_and_model_arn filters recipes by SequenceLength field, picking the smallest recipe with context length >= the requested value. Raises ValueError if no sufficient recipe exists or if recipes lack SequenceLength metadata. Changes: - ServerlessJobConfig: add sequence_length field - _parse_context_length: parse values like '8K' to integers - _get_fine_tuning_options_and_model_arn: filter by SequenceLength - _create_serverless_config: conditionally include sequence_length - SFTTrainer, DPOTrainer, RLVRTrainer, RLAIFTrainer: accept and thread sequence_length through init and train methods - Unit tests for all new functionality * fix: use codegen for SequenceLength shape instead of manual shapes.py edit Add SequenceLength to service-2.json and regenerate shapes.py via codegen (python -m sagemaker.core.tools.codegen) instead of editing shapes.py manually. * fix: correct test imports and mock setup for sequence_length tests * address PR review: sequence_length as recipe pre-filter and simplify config - Move sequence_length filtering above recipe selection to reduce recipes_with_template before existing logic runs - Always pass sequence_length to ServerlessJobConfig (no None guard) * test: add integration test for SFT trainer with sequence_length * Regenerate code via codegen and remove hardcoded path in data_extractor.py * comment out gpu testing flag * fix: reject bare integer inputs in _parse_context_length Bare integers like "128" were silently parsed as token counts (128), causing every recipe to pass the >= filter and silently selecting the shortest recipe instead of failing. Now raises ValueError with an actionable message for any input not ending in 'K'. * Validate customer sequence lengths against the recipe's SequenceLength Enforce that customer-selected lengths fit the recipe's supported sequence length: - RL: max_prompt_length + max_response_length <= SequenceLength - SFT/DPO: max_length (dataset_max_len) <= SequenceLength FineTuningOptions.validate_length_constraints() checks this and is wired into the RL/base trainers. The ceiling comes from the recipe's SequenceLength metadata ('<n>K'), parsed to an int via _parse_context_length; no-op when absent. (Recipes side unified on the SequenceLength metadata field; the former ContextLength field was dropped, so read SequenceLength here.) * Rename context_length to sequence_length; exact-match recipe filter Recipes vend only SequenceLength, so use that name throughout the fine-tuning options and validation path (context_length -> sequence_length, _parse_context_length -> _parse_sequence_length). Filter recipes by exact SequenceLength match and keep all candidates at that length, so _select_recipe_by_training_type picks the right recipe when LORA and FULL variants share the same sequence length. The client sends only descriptors in ServerlessJobConfig (including SequenceLength); the selectable-length cap is derived from the recipe catalog rather than a hardcoded enum. * Enforce SFT/DPO sequence-length ceiling on dataset_max_len validate_length_constraints() checked only the RL prompt+response sum, so the single-example ceiling for SFT/DPO recipes was never enforced. verl and llmft recipes vend that ceiling as dataset_max_len; check it against the recipe's SequenceLength so an over-limit value is rejected client-side. Verified against the recipe catalog: dataset_max_len is the param verl/llmft SFT/DPO recipes actually render (max_context_length appears in Nova overrides but is never rendered into a recipe body; max_length is Nova-RFT only). * test: point sequence_length integ tests at qwen3-5-9b verl recipes The SFT sequence_length test targeted llama-3-2-1b-instruct, which only offers a 4K recipe, so requesting 8K failed recipe selection. Point it at huggingface-vlm-qwen3-5-9b and use 16K, which has a matching verl SFT recipe. Add a matching RLVR sequence_length test on the same model at 8K. --------- Co-authored-by: Mohamed Zeidan <81834882+mohamedzeidan2021@users.noreply.github.com>
1 parent 2936c44 commit 2c7775e

19 files changed

Lines changed: 746 additions & 39 deletions

File tree

‎sagemaker-core/sample/sagemaker/2017-07-24/service-2.json‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44132,6 +44132,10 @@
4413244132
"EvaluatorArn":{
4413344133
"shape":"EvaluatorArn",
4413444134
"documentation":"<p> The evaluator Amazon Resource Name (ARN) used as reward function or reward prompt. </p>"
44135+
},
44136+
"SequenceLength":{
44137+
"shape":"SequenceLength",
44138+
"documentation":"<p> The sequence length for the training job. </p>"
4413544139
}
4413644140
},
4413744141
"documentation":"<p> The configuration for the serverless training job. </p>"
@@ -44143,6 +44147,19 @@
4414344147
"Evaluation"
4414444148
]
4414544149
},
44150+
"SequenceLength":{
44151+
"type":"string",
44152+
"enum":[
44153+
"1K",
44154+
"2K",
44155+
"4K",
44156+
"8K",
44157+
"16K",
44158+
"32K",
44159+
"64K",
44160+
"128K"
44161+
]
44162+
},
4414644163
"ServerlessMaxConcurrency":{
4414744164
"type":"integer",
4414844165
"box":true,

‎sagemaker-core/src/sagemaker/core/resources.py‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,6 @@
4646
from sagemaker.core.serializers.base import BaseSerializer
4747
from sagemaker.core.deserializers.base import BaseDeserializer
4848

49-
5049
logger = get_textual_rich_logger(__name__)
5150

5251

‎sagemaker-core/src/sagemaker/core/shapes/shapes.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9781,6 +9781,7 @@ class ServerlessJobConfig(Base):
97819781
peft: The parameter-efficient fine-tuning configuration.
97829782
evaluation_type: The evaluation job type. Required when serverless job type is Evaluation.
97839783
evaluator_arn: The evaluator Amazon Resource Name (ARN) used as reward function or reward prompt.
9784+
sequence_length: The sequence length for the training job.
97849785
"""
97859786

97869787
base_model_arn: StrPipeVar
@@ -9790,6 +9791,7 @@ class ServerlessJobConfig(Base):
97909791
peft: Optional[StrPipeVar] = Unassigned()
97919792
evaluation_type: Optional[StrPipeVar] = Unassigned()
97929793
evaluator_arn: Optional[StrPipeVar] = Unassigned()
9794+
sequence_length: Optional[StrPipeVar] = Unassigned()
97939795

97949796

97959797
class MlflowConfig(Base):

‎sagemaker-core/src/sagemaker/core/utils/code_injection/shape_dag.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16272,6 +16272,7 @@
1627216272
{"name": "Peft", "shape": "Peft", "type": "string"},
1627316273
{"name": "EvaluationType", "shape": "EvaluationType", "type": "string"},
1627416274
{"name": "EvaluatorArn", "shape": "EvaluatorArn", "type": "string"},
16275+
{"name": "SequenceLength", "shape": "SequenceLength", "type": "string"},
1627516276
],
1627616277
"type": "structure",
1627716278
},

‎sagemaker-train/src/sagemaker/train/base_trainer.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -568,6 +568,10 @@ def _yaml_safe_default(value):
568568
if hp_value is not None:
569569
_set_spec_default(override_spec, hp_key, _yaml_safe_default(hp_value))
570570

571+
# Enforce selected lengths fit the recipe's supported sequence length.
572+
if hasattr(self.hyperparameters, "validate_length_constraints"):
573+
self.hyperparameters.validate_length_constraints()
574+
571575
# Build hyperparameters early to inject into recipe template before runtime.
572576
final_hyperparameters = self.hyperparameters.to_dict()
573577
_validate_hyperparameter_values(final_hyperparameters)

‎sagemaker-train/src/sagemaker/train/common.py‎

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,60 @@ class CustomizationTechnique(Enum):
2222

2323
class FineTuningOptions:
2424
"""Dynamic class for fine-tuning options with validation."""
25-
26-
def __init__(self, options_dict: Dict[str, Any]):
25+
26+
def __init__(self, options_dict: Dict[str, Any], sequence_length: int = None):
2727
self._specs = options_dict.copy()
2828
self._user_set = set()
2929
self._initialized = False
30+
# Recipe's supported sequence-length ceiling (prompt + response for RL,
31+
# or max_length for SFT/DPO). Used by validate_length_constraints().
32+
self._sequence_length = sequence_length
3033
# Extract default values and set as attributes (no validation during init)
3134
for key, spec in options_dict.items():
3235
default_value = spec.get('default') if isinstance(spec, dict) else spec
3336
super().__setattr__(key, default_value)
3437
self._initialized = True
38+
39+
# SFT/DPO recipes express the single-example length ceiling as
40+
# "dataset_max_len" (verl and llmft templates). It maps to the recipe's
41+
# sequence-length ceiling, so validation checks it.
42+
_MAX_LENGTH_PARAMS = ("dataset_max_len",)
43+
44+
def validate_length_constraints(self):
45+
"""Enforce that selected lengths fit the recipe's sequence_length.
46+
47+
For RL recipes: max_prompt_length + max_response_length must not exceed
48+
sequence_length. For SFT/DPO: the single-example length (dataset_max_len)
49+
must not exceed it. Per-field min/max are already enforced on assignment;
50+
this adds the cross-field sum check that a per-field max cannot express.
51+
52+
Framework-agnostic: gated only on the recipe's sequence_length metadata,
53+
not on the model family. No-op if sequence_length is unknown or the
54+
relevant params are absent.
55+
"""
56+
if self._sequence_length is None:
57+
return
58+
59+
prompt = getattr(self, "max_prompt_length", None)
60+
response = getattr(self, "max_response_length", None)
61+
if prompt is not None and response is not None:
62+
total = prompt + response
63+
if total > self._sequence_length:
64+
raise ValueError(
65+
f"max_prompt_length ({prompt}) + max_response_length ({response}) "
66+
f"= {total} exceeds the recipe's supported sequence length "
67+
f"({self._sequence_length}). Lower max_prompt_length and/or "
68+
f"max_response_length so their sum is within {self._sequence_length}."
69+
)
70+
71+
for param in self._MAX_LENGTH_PARAMS:
72+
max_length = getattr(self, param, None)
73+
if max_length is not None and max_length > self._sequence_length:
74+
raise ValueError(
75+
f"{param} ({max_length}) exceeds the recipe's supported sequence "
76+
f"length ({self._sequence_length}). Set {param} to "
77+
f"{self._sequence_length} or lower."
78+
)
3579

3680
def to_dict(self) -> Dict[str, Any]:
3781
"""Convert back to dictionary for hyperparameters with string values."""

‎sagemaker-train/src/sagemaker/train/common_utils/finetune_utils.py‎

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -568,8 +568,28 @@ def _resolve_model_package_arn(model_package) -> Optional[str]:
568568
return None
569569

570570

571+
def _parse_sequence_length(value) -> int:
572+
"""Parse a sequence length value like '8K', '32K', '128K' into an integer (e.g., 8192)."""
573+
if not value:
574+
return 0
575+
value = str(value).strip().upper()
576+
if not value.endswith("K"):
577+
raise ValueError(
578+
f"Invalid sequence_length '{value}'. "
579+
f"Expected a value ending in 'K', e.g. '8K' or '128K'."
580+
)
581+
try:
582+
return int(value[:-1]) * 1024
583+
except ValueError:
584+
raise ValueError(
585+
f"Invalid sequence_length '{value}'. "
586+
f"Expected a numeric value followed by 'K', e.g. '8K' or '128K'."
587+
)
588+
589+
571590
def _get_fine_tuning_options_and_model_arn(model_name: str, customization_technique: str, training_type, sagemaker_session,
572-
hub_name: Optional[str] = None, compute: Optional[Union[HyperPodCompute, TrainingJobCompute]] = None) -> tuple:
591+
sequence_length=None, hub_name: Optional[str] = None,
592+
compute: Optional[Union[HyperPodCompute, TrainingJobCompute]] = None) -> tuple:
573593
"""Get fine-tuning options and model ARN for given customization technique.
574594
Returns:
575595
tuple: (FineTuningOptions, model_arn, is_gated_model)
@@ -621,6 +641,29 @@ def _get_fine_tuning_options_and_model_arn(model_name: str, customization_techni
621641
if not recipes_with_template:
622642
raise ValueError(f"No recipes found with {platform_label} for technique: {customization_technique}")
623643

644+
# Filter by SequenceLength before recipe selection if sequence_length is requested.
645+
# Multiple recipes may share the same SequenceLength (e.g. LORA and FULL
646+
# variants); keep every exact match so _select_recipe_by_training_type can
647+
# pick the right one for the requested training type.
648+
if sequence_length:
649+
requested = _parse_sequence_length(sequence_length)
650+
candidates_with_sequence = [r for r in recipes_with_template if r.get("SequenceLength")]
651+
if candidates_with_sequence:
652+
filtered = [r for r in candidates_with_sequence if _parse_sequence_length(r.get("SequenceLength")) == requested]
653+
if filtered:
654+
recipes_with_template = filtered
655+
else:
656+
available = sorted(set(r.get("SequenceLength") for r in candidates_with_sequence))
657+
raise ValueError(
658+
f"No recipes found with SequenceLength == {sequence_length}. "
659+
f"Available sequence lengths: {available}"
660+
)
661+
else:
662+
raise ValueError(
663+
f"No recipes found with {platform_label} for technique: {customization_technique},training_type:{training_type}, "
664+
f"and sequence length:{sequence_length}"
665+
)
666+
624667
# Select recipe based on training type
625668
recipe = _select_recipe_by_training_type(recipes_with_template, training_type)
626669

@@ -679,10 +722,16 @@ def _get_fine_tuning_options_and_model_arn(model_name: str, customization_techni
679722
except Exception as e:
680723
logger.debug(f"Could not fetch subscription recipe override_params: {type(e).__name__}: {e}")
681724

725+
# Supported sequence-length ceiling: the recipe's SequenceLength ("<n>K")
726+
# is the single source of truth. Parse it to an int so we can validate that
727+
# max_prompt_length + max_response_length (or max_length) stays within
728+
# what the recipe's hardware can serve. 0 if absent/unparseable (no-op).
729+
sequence_length_ceiling = _parse_sequence_length(recipe.get("SequenceLength")) or None
730+
682731
if options_dict:
683-
return FineTuningOptions(options_dict), model_arn, is_gated_model
732+
return FineTuningOptions(options_dict, sequence_length=sequence_length_ceiling), model_arn, is_gated_model
684733
else:
685-
return FineTuningOptions({}), model_arn, is_gated_model
734+
return FineTuningOptions({}, sequence_length=sequence_length_ceiling), model_arn, is_gated_model
686735

687736
except Exception as e:
688737
logger.debug("Exception getting fine-tuning options: %s", e)
@@ -881,7 +930,8 @@ def _resolve_model_and_name(model, sagemaker_session=None):
881930

882931

883932
def _create_serverless_config(model_arn, customization_technique,
884-
training_type, accept_eula, evaluator_arn=None, job_type=JOB_TYPE) -> Optional['ServerlessJobConfig']:
933+
training_type, accept_eula, evaluator_arn=None,
934+
sequence_length=None, job_type=JOB_TYPE) -> Optional['ServerlessJobConfig']:
885935
"""Create serverless job configuration for fine-tuning.
886936
887937
Args:
@@ -890,6 +940,7 @@ def _create_serverless_config(model_arn, customization_technique,
890940
training_type: Training type (TrainingType enum or string)
891941
accept_eula: Boolean indicating if EULA is accepted
892942
evaluator_arn: Optional evaluator ARN for RLVR/RLAIF
943+
sequence_length: Optional sequence length enum value (e.g., "1K", "2K", "4K", "8K", "16K", "32K", "64K", "128K")
893944
job_type: Type of job (default: "FineTuning")
894945
895946
Returns:
@@ -905,7 +956,8 @@ def _create_serverless_config(model_arn, customization_technique,
905956
customization_technique=customization_technique,
906957
peft=peft,
907958
evaluator_arn=evaluator_arn,
908-
accept_eula=accept_eula
959+
accept_eula=accept_eula,
960+
sequence_length=sequence_length,
909961
)
910962

911963
return serverless_config

‎sagemaker-train/src/sagemaker/train/dpo_trainer.py‎

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,10 @@ class DPOTrainer(BaseTrainer):
105105
stopping_condition (Optional[StoppingCondition]):
106106
The stopping condition to override training runtime limit.
107107
If not specified, uses SageMaker service default (24 hours for serverless training).
108+
sequence_length (Optional[str]):
109+
The sequence length for the training job. Valid values are
110+
"1K", "2K", "4K", "8K", "16K", "32K", "64K", "128K".
111+
If not specified, the service will use default recipe selection behavior.
108112
is_multimodal (Optional[bool]):
109113
Whether the training dataset contains multimodal data. If None (default),
110114
auto-detected from the training dataset at train time.
@@ -128,6 +132,7 @@ def __init__(
128132
networking: Optional[VpcConfig] = None,
129133
accept_eula: bool = False,
130134
stopping_condition: Optional[StoppingCondition] = None,
135+
sequence_length: Optional[str] = None,
131136
recipe: Optional[str] = None,
132137
overrides: Optional[dict] = None,
133138
is_multimodal: Optional[bool] = None,
@@ -164,6 +169,7 @@ def __init__(
164169
self.kms_key_id = kms_key_id
165170
self.networking = networking
166171
self.stopping_condition = stopping_condition
172+
self.sequence_length = sequence_length
167173
self._recipe_path = recipe
168174
self._overrides = overrides
169175
self._recipe_resolver = None
@@ -176,8 +182,8 @@ def __init__(
176182
self.training_type,
177183
self.sagemaker_session or TrainDefaults.get_sagemaker_session(
178184
sagemaker_session=self.sagemaker_session
179-
180185
),
186+
sequence_length=self.sequence_length,
181187
compute=self.compute)
182188

183189
# Process hyperparameters
@@ -292,12 +298,14 @@ def train(self,
292298
disable_output_compression=getattr(self, 'disable_output_compression', False),
293299
)
294300

295-
serverless_config = _create_serverless_config(model_arn=self._model_arn,
296-
customization_technique=CustomizationTechnique.DPO.value,
297-
training_type=self.training_type,
298-
accept_eula=self.accept_eula,
299-
job_type=JOB_TYPE
300-
)
301+
serverless_config = _create_serverless_config(
302+
model_arn=self._model_arn,
303+
customization_technique=CustomizationTechnique.DPO.value,
304+
training_type=self.training_type,
305+
accept_eula=self.accept_eula,
306+
sequence_length=self.sequence_length,
307+
job_type=JOB_TYPE
308+
)
301309

302310
mlflow_config = _create_mlflow_config(
303311
sagemaker_session,

‎sagemaker-train/src/sagemaker/train/rlaif_trainer.py‎

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,10 @@ class RLAIFTrainer(BaseTrainer):
117117
stopping_condition (Optional[StoppingCondition]):
118118
The stopping condition to override training runtime limit.
119119
If not specified, uses SageMaker service default (24 hours for serverless training).
120+
sequence_length (Optional[str]):
121+
The sequence length for the training job. Valid values are
122+
"1K", "2K", "4K", "8K", "16K", "32K", "64K", "128K".
123+
If not specified, the service will use default recipe selection behavior.
120124
is_multimodal (Optional[bool]):
121125
Whether the training dataset contains multimodal data. If None (default),
122126
auto-detected from the training dataset at train time.
@@ -141,6 +145,7 @@ def __init__(
141145
networking: Optional[VpcConfig] = None,
142146
accept_eula: bool = False,
143147
stopping_condition: Optional[StoppingCondition] = None,
148+
sequence_length: Optional[str] = None,
144149
is_multimodal: Optional[bool] = None,
145150
**kwargs,
146151
):
@@ -163,15 +168,17 @@ def __init__(
163168
self.kms_key_id = kms_key_id
164169
self.networking = networking
165170
self.stopping_condition = stopping_condition
171+
self.sequence_length = sequence_length
166172
self.is_multimodal = is_multimodal
167173

168174
# Initialize fine-tuning options with beta session fallback
169-
self.hyperparameters, self._model_arn, is_gated_model = _get_fine_tuning_options_and_model_arn(self._model_name,
170-
CustomizationTechnique.RLAIF.value,
171-
self.training_type,
172-
self.sagemaker_session or TrainDefaults.get_sagemaker_session(
173-
sagemaker_session=self.sagemaker_session
174-
))
175+
self.hyperparameters, self._model_arn, is_gated_model = _get_fine_tuning_options_and_model_arn(
176+
self._model_name,
177+
CustomizationTechnique.RLAIF.value,
178+
self.training_type,
179+
self.sagemaker_session or TrainDefaults.get_sagemaker_session(sagemaker_session=self.sagemaker_session),
180+
sequence_length=self.sequence_length
181+
)
175182

176183
# Validate and set EULA acceptance
177184
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
256263
)
257264

258265
evaluator_arn = getattr(self, '_evaluator_arn', None)
259-
serverless_config = _create_serverless_config(model_arn=self._model_arn,
260-
customization_technique=CustomizationTechnique.RLAIF.value,
261-
training_type=self.training_type,
262-
accept_eula=self.accept_eula,
263-
evaluator_arn=evaluator_arn,
264-
job_type=JOB_TYPE
265-
)
266+
serverless_config = _create_serverless_config(
267+
model_arn=self._model_arn,
268+
customization_technique=CustomizationTechnique.RLAIF.value,
269+
training_type=self.training_type,
270+
accept_eula=self.accept_eula,
271+
evaluator_arn=evaluator_arn,
272+
sequence_length=self.sequence_length,
273+
job_type=JOB_TYPE
274+
)
266275

267276
mlflow_config = _create_mlflow_config(
268277
sagemaker_session,
@@ -271,6 +280,9 @@ def train(self, training_dataset: Optional[Union[str, DataSet]] = None, validati
271280
mlflow_run_name=self.mlflow_run_name,
272281
)
273282

283+
# Enforce prompt + response fit the recipe's supported sequence length.
284+
self.hyperparameters.validate_length_constraints()
285+
274286
final_hyperparameters = self.hyperparameters.to_dict()
275287

276288
# Resolve is_multimodal: auto-detect from training dataset if not explicitly set

0 commit comments

Comments
 (0)