Skip to content

feat(core): add InstancePreferences for multi-instance-type training and processing - #6249

Open
papriwal wants to merge 2 commits into
masterfrom
feature-smtj-instance-preferences-rebased
Open

feat(core): add InstancePreferences for multi-instance-type training and processing#6249
papriwal wants to merge 2 commits into
masterfrom
feature-smtj-instance-preferences-rebased

Conversation

@papriwal

Copy link
Copy Markdown
Collaborator

Description

Adds support for InstancePreferences, an ordered list of candidate instance types for training and processing jobs. When set, the service tries each candidate in list order and launches on the first type with available capacity, then reports the resolved choice back via SelectedInstanceType and SelectedInstanceCount.

This makes capacity-constrained jobs easier to schedule: instead of picking one instance type and retrying on InsufficientCapacityError, you supply a ranked list once.

Training

from sagemaker.core.shapes import InstancePreference

compute = Compute(
    instance_preferences=[
        InstancePreference(instance_type="ml.p5.48xlarge", instance_count=2),
        InstancePreference(instance_type="ml.p4d.24xlarge", instance_count=4),
    ],
    volume_size_in_gb=100,
)

instance_preferences is mutually exclusive with instance_type, instance_groups and instance_placement_config. Each entry may carry its own instance_count, or you can leave them unset and use the uniform ResourceConfig.InstanceCount for whichever type wins. training_plan_arns is training-only and capped at one entry per preference.

Processing

ProcessingClusterConfig gains the same three members. InstanceType and InstanceCount are no longer required on that shape, since a preferences-only config supplies them per candidate — VolumeSizeInGB remains the only required member.

What's in this change

Area Files Notes
sagemaker-core src 9 training/processing configs and utils, Spark processor plumbing, plus regenerated shapes.py, shape_dag.py and the bundled service model
sagemaker-core tests 5 unit coverage for compute configs, processing and the service model; a new processing integ test
sagemaker-train 4 model_trainer.py validation, defaults.py handling, unit and integ tests
Docs 2 docs/training/index.rst, docs/ml_ops/index.rst
Examples 2 one training and one processing notebook

Generated artifacts were produced by sagemaker-core's own codegen from the bundled sample model, not hand-edited. New shapes: InstancePreference, InstancePreferenceList, ProcessingInstancePreference, ProcessingInstancePreferenceList, TrainingPlanArnList.

Unsupported combinations are rejected with a clear error rather than failing at the service: local mode, training recipes, and JumpStart training all reject instance_preferences up front.

Notes for reviewers

Requires botocore ≥ 1.43.90. These shapes first ship in that release. On an older botocore the client rejects the new members with a ParamValidationError; only callers using this feature are affected. The dependency floor is bumped as part of the release, not in this PR.

One unrelated model line moves. The bundled service-2.json refresh also brings MaxPendingTimeInSeconds.min from 7200 to 1800, matching botocore 1.43.90+. It shows up in the diff but is not part of this feature — the checked-in model was simply behind on that field.

IntPipeVar on five members is not reproducible by codegen. shapes.py widens instance_count on InstancePreference, ProcessingInstancePreference and ProcessingClusterConfig, plus selected_instance_count on ResourceConfig and ProcessingClusterConfig, to IntPipeVar so pipeline variables can be passed. PIPE_VAR_OVERRIDES in tools/constants.py is keyed by shape name and currently only covers ResourceConfig, so re-running codegen narrows these back to Optional[int]. Happy to add the missing override entries here or in a follow-up — worth deciding, since the periodic botocore sync regenerates this file. The two selected_instance_count fields are Describe-response only, so a pipeline variable arguably shouldn't apply to them at all.

Testing

  • Unit tests for training and processing config construction, serialization, and the mutually-exclusive validation paths
  • A service-model test asserting the new shapes and the relaxed required list
  • Integration tests for training and processing that assert the full round trip, including that the service echoes SelectedInstanceType / SelectedInstanceCount; they skip unless the required role, image and output-path environment variables are set
  • Both notebooks run end to end

Issue #, if available:

Description of changes:

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

* feat(instance-preferences): Add Instance Preferences (multi-instance-type) support (#2093)

* feat(instance-preferences): add multi-instance-type support to PySDK

Add customer-specified Instance Preferences (multi-instance-type)
support across sagemaker-core and sagemaker-train, mirroring the
additive, backward-compatible SageMaker model change.

shapes / model (sagemaker-core):
- New InstancePreference shape (instance_type, optional per-preference
  instance_count, optional training_plan_arns capped at 1 via the new
  TrainingPlanArnList model shape).
- New processing-specific ProcessingInstancePreference /
  ProcessingInstancePreferenceList shapes (ProcessingInstanceType/Count
  members, no TrainingPlanArns - per-type training plans are
  training-only), matching the service model where training and
  processing preferences are distinct shapes.
- ResourceConfig: instance_preferences + output-only
  selected_instance_type / selected_instance_count.
- ProcessingClusterConfig: instance_preferences + selected_*; relax
  required on instance_type / instance_count (XOR instance_preferences,
  enforced server-side; matches the GA contract).
- Mirror all additions into the bundled service-2.json codegen input
  (also consumed by the runtime botocore loader).

consumers:
- Compute (modules + training configs) inherit the field; preserve
  nested InstancePreference objects in _to_resource_config so an unset
  per-preference instance_count does not fail re-validation.
- Processor: instance_preferences param + emit InstancePreferences into
  ClusterConfig; mutual-exclusivity with instance_type/instance_count.
- AWS Batch: reconstruct instance_preferences onto the rebuilt Compute.
- ModelTrainer: guard local mode / training recipes / JumpStart against
  instance_preferences with clear errors (out of scope for M1).

tests: round-trip for both Compute classes, selected_* not sent on
create, ProcessingClusterConfig with processing-specific shapes,
Processor ClusterConfig emission and mutual exclusivity, and AWS Batch
reconstruction.

* fix(instance-preferences): defaults must not inject instance_type; add integ + contract tests

- TrainDefaults.get_compute no longer injects the default top-level
  instance_type / instance_count when Compute carries
  instance_preferences (mutually exclusive server-side; the injected
  default made every instance-preferences ModelTrainer job fail
  CreateTrainingJob validation with 'InstanceType cannot be specified
  with InstancePreferences' - caught by the new integ test against a
  live endpoint). Unit tests added for both count modes.
- New integ test (tests/integ/train/test_instance_preferences.py):
  launches a real training job via ModelTrainer + Compute with an
  ordered instance_preferences list, asserts the create/describe
  contract, and asserts the resolved selected_instance_type /
  selected_instance_count is one of the submitted preferences once the
  job leaves PENDING. Env-gated (role/image/S3 via
  INSTANCE_PREFERENCES_TEST_* vars); reports XFAIL with the job name
  while the server-side scheduling stage is not yet deployed on the
  target endpoint, and enforces the full contract automatically once
  it is.
- New unit test pinning the Instance Preferences contract in the
  bundled service-2.json (distinct training/processing preference
  shapes, TrainingPlanArnList cap 1, list cap 5, Selected* on both
  Describe shapes, ProcessingClusterConfig required relaxation).

* fix(instance-preferences): correct count semantics + client validation; add processing integ test

Address review feedback on the Processor mutual-exclusivity check:
instance_count is NOT flatly exclusive with instance_preferences - it is
the shared uniform count for whichever preference wins. Corrected to the
count-mode contract (V4) on the Processor AND, for parity, added the same
client-side validation to both training Compute classes (server remains
the source of truth):
- uniform mode: top-level instance_count set, no element carries a count
  (Processor emits the uniform count into ClusterConfig);
- per-preference mode: EVERY element carries a count, top-level unset;
- reject both-set, partial per-preference, and neither;
- instance_type / instance_groups / instance_placement_config remain
  mutually exclusive with instance_preferences (V1/V5).
Shared validate_instance_preferences helper in modules/utils.py and
training/utils.py (per-package twin pattern); wired into both Compute
_model_validator hooks (assignment-safe: no-op without preferences).
Unit tests for all modes on Processor and both Compute classes; two
pre-existing test fixtures corrected to V4-valid combinations.

Add processing integ test
(tests/integ/processing/test_instance_preferences_processing.py):
launches a real processing job via Processor with InstancePreferences
through the bundled service model, asserts the create/describe contract
and the resolved Selected* winner; env-gated
(PROCESSING_INSTANCE_PREFERENCES_TEST_* vars). Reports XFAIL while the
endpoint's public model still hard-requires InstanceType/InstanceCount
(the @required relaxation ships with the GA ungating), and enforces the
full contract automatically once deployed.

* revert(instance-preferences): drop AWS Batch reconstruction support

Remove the instance_preferences reconstruction in the AWS Batch
TrainingQueuedJob path (and its test) - Batch support for Instance
Preferences will ship separately later; this PR intentionally adds no
Batch functionality. Files restored to upstream/master state.

Also: black-format the new training integ test (line-length 100).

* fix(instance-preferences): complete client validation; clearer error wording

Complete the client-side validation sweep against the server rule set
(server remains the source of truth):
- Training plans: the top-level (whole-job) training_plan_arn is
  mutually exclusive with per-preference training_plan_arns (was
  missing - review catch).
- Duplicate instance types across preferences rejected (both Compute
  classes + Processor).
- List-size limits (max 5 preferences, max 1 plan per preference) are
  deliberately NOT enforced client-side: they are server-side
  configurable, so raising them must never require an SDK release
  (botocore does not enforce the C2J list min/max either - verified -
  so the service is the single enforcement point for sizes). The
  current limits are documented on the service-2.json list shapes
  (InstancePreferenceList, TrainingPlanArnList,
  ProcessingInstancePreferenceList) instead. Type validity
  (region-dependent) and count bounds are likewise server-only.

Reword thrown error messages to use 'top-level instance_count /
training_plan_arn' instead of the design-doc jargon 'uniform', so the
error names the actual field the customer must change.

Tests: whole-job-plan XOR per-preference-plans (reject) /
whole-job-plan-without-per-preference-plans (valid) / duplicate-type
cases on both Compute classes and Processor.

* docs(instance-preferences): add example notebook; extract Processor validation helper

Address review feedback:
- Add v3-examples/training-examples/instance-preferences-example.ipynb
  demonstrating the customer experience end to end: ModelTrainer +
  Compute with an ordered preference list (shared top-level count),
  per-preference instance counts, per-preference training plans with an
  on-demand fallback, reading the resolved winner via
  selected_instance_type/selected_instance_count on Describe, and the
  Processor equivalent for processing jobs. All construction patterns
  verified executable against the SDK.
- Extract the Processor's inline instance_preferences validation into a
  module-level _validate_processing_instance_preferences helper
  (behavior unchanged; unit tests pass unmodified).

* docs(instance-preferences): add per-preference-count Processor example to notebook

* docs(instance-preferences): polish example notebook for public customers

Align the notebook with the v3-examples conventions:
- Session()/get_execution_role()/image_uris.retrieve() setup instead of
  paste-in placeholders; 'Step N' section headers.
- Customer-facing wording: drop client/service enforcement internals;
  'instance types must be unique across preferences'.

(cherry picked from commit 81758d391bb48aa4676bd59def97fef821fe05f5)

* fix(instance-preferences): shape-DAG regen, Spark processor plumbing, integ test (#2099)

* test(instance-preferences): XFAIL training integ test under pre-GA dark-launch gating

The endpoint's frontend strips internal-gated fields (instance_preferences,
selected_*) from customer Describe responses while the surface is
dark-launched - the documented pre-GA contract (verified: the long-internal
UseReservedCapacity is stripped identically, and the layer below returns
the fields correctly with the top-level suppression rule applied). Detect
the gating (echo absent from Describe) and report XFAIL with a re-run-after-
GA-ungate message instead of failing on the unassertable public contract.

Verified live against a pre-GA endpoint: job schedules and completes with
the correct winner persisted; test XFAILs cleanly in ~2s.

* feat(instance-preferences): plumb through Script/Spark processors

ScriptProcessor, _SparkProcessorBase, PySparkProcessor, and
SparkJarProcessor now accept instance_preferences and forward it to the
base Processor (which already carried the parameter, validation, and
ClusterConfig emission). Spark image-URI resolution falls back to the
first preference's instance type when no top-level instance_type is set.

Unit tests: forwarding on all three public classes, validation reuse,
and first-preference image lookup (5 new; full processing suite 99
passing).

* fix(instance-preferences): regenerate shape DAG; fix integ wait loop

The resource deserializer (codec.transform) walks the generated
SHAPE_DAG, which was not regenerated when the InstancePreferences
shapes landed - so DescribeTrainingJob/DescribeProcessingJob responses
silently DROPPED instance_preferences and Selected* from the typed
resources (requests were unaffected; the gap was masked while the beta
frontend stripped the fields pre-ungate). Regenerated via
ShapesExtractor from the bundled service-2.json (+44 lines, additive).

Also fix the integ test's wait loop: gate on terminal-status-or-winner
instead of a secondary-status allowlist (Selected* propagation lags the
transitions; jobs completing fast broke out pre-resolution).

Verified live against ungated beta PDX: full contract PASS in 62s
(winner + echo + suppression on the typed resource).

* fix(instance-preferences): Spark image resolution requires candidate agreement

A job runs one image but the winner among instance_preferences is
unknown at create, so resolving from the first preference was silently
wrong for candidate lists that mix processor classes. Auto-resolution
now resolves per candidate and requires all candidates to yield the
SAME image; on divergence it raises with a clear pass-image_uri-
explicitly message. No behavior change for homogeneous lists (Spark
images are CPU-only today, so real lists agree).

* fix(instance-preferences): guard degenerate preference list in Spark image resolution

An instance_preferences list with no InstanceType on any element hit
StopIteration inside the candidate-agreement resolution (which runs
before the base Processor validation). Fall through to the classic
resolution path so the base validation owns the clean reject.

* fix(instance-preferences): name unresolvable Spark candidates

Per-candidate image resolution failures (e.g. GPU candidates: Spark has
no gpu image variant, so image_uris.retrieve raises) now surface a
message naming the candidate and the pass-image_uri-explicitly remedy.

Verified unmocked: mixed CPU families (m5/r5/c5) resolve to the same
image and pass agreement; a g5 candidate raises the named error;
explicit image_uri bypasses resolution entirely.

(cherry picked from commit 574a87d540c26d651d4844fc99737f0e1bc60047)

* docs(instance-preferences): document instance preferences for training and processing (#2142)

* docs(instance-preferences): document training instance preferences

The example notebook shipped in #2093 was referenced by no .rst, so
Sphinx never rendered it. Add an Instance Preferences section to the
training guide (ordered candidates, per-candidate counts and training
plans, reading the selected type) and wire the notebook into the
Training Examples toctree.

Move the notebook's processing steps out to a dedicated processing
example so the training guide stays training-only.

* docs(instance-preferences): document processing instance preferences

Add instance preferences to the MLOps Processing Jobs guide (dict-shaped
candidates, shared vs per-candidate counts, reading the selected type,
supported processor classes) with a standalone example notebook split
out of the training notebook, wired into the MLOps toctree.

* docs(instance-preferences): use canonical imports, tighten count-mode rules

- Import from sagemaker.core.training.configs instead of the deprecated
  sagemaker.train.configs shim (which warns on use), and from
  sagemaker.core.shapes rather than the nested module path, matching the
  short form used elsewhere in the docs.
- State the count rule as it is enforced: exactly one of a top-level
  instance_count or an instance_count on every candidate; mixed, partial,
  and omitted are rejected.
- Read the winner off the trainer's job resource instead of a redundant
  TrainingJob.get round trip.

(cherry picked from commit 093b2f005e93cb7e0b7eb5e3c2c4fcc721eda07d)

* docs(instance-preferences): align with launch drafts, tighten wording (#2146)

Corrects and completes the docs against the API and dev-guide drafts:

- managed spot training belongs in the mutual-exclusion list
- a candidate's training plan must match that candidate's instance type,
  and the job-level training_plan_arn applies to whichever candidate
  matches its type (rather than being unusable with a list)
- while nothing has capacity the job stays pending and retries the list;
  max_pending_time_in_seconds bounds the total, not each candidate, and
  only applies when the list includes an accelerated type
- selection is capacity-based and does not validate workload fit
- exactly one type is selected; job-level storage and keep-alive settings
  apply to it, as does billing
- per-candidate training plans are training-only, so that limit does not
  belong in the processing notes

Adds the SDK-level restrictions the public page omitted: local mode,
training recipes, JumpStart models, and AWS Batch training queues. The
Batch restriction is also noted in the AWS Batch section, where a reader
choosing queued submission would look for it.

Also trims the notebooks: the intro's table of contents duplicated the
step headings (and still listed the processing step that moved to its own
notebook), and its usage rules restated what each step and the notes
already cover. Rules now live once, at the point of use.

(cherry picked from commit b720fa83f03b806828c8a58bd40d95ee10279c3a)

* fix(model): lower MaxPendingTimeInSeconds bound to 1800

The bundled service model still carried min=7200. Integer bounds are
validated from this model before a request leaves the SDK, so the
service's lower bound of 1800 was rejected client-side.

One line; max and the field documentation are unchanged.

(cherry picked from commit 421478b30cbcfcd4c9e3e41c359641f535298845)
#2158)

Instance Preferences is publicly released, so the integ tests no longer
need the pre-release escape hatches: the XFAIL branches for gated
Describe responses, model @required rejections, and InternalServerError
before type resolution are removed, along with the rollout notes that
explained them. Both tests now assert the full contract unconditionally:
create accepted, preferences echoed, top-level instance type absent,
winner among the submitted preferences with the expected count.

The processing test used a private loader for the bundled service model;
the public botocore model now carries the shapes, so it uses a plain
boto3 client. Candidate instance types are overridable via
INSTANCE_PREFERENCES_TEST_INSTANCE_TYPES /
PROCESSING_INSTANCE_PREFERENCES_TEST_INSTANCE_TYPES so the tests can run
in accounts where the default types are quota-saturated.

Verified against prod us-west-2: processing passes (winner resolved,
3 min); training passes (winner resolved, 8 min).
@papriwal
papriwal deployed to auto-approve September 10, 2026 03:43 — with GitHub Actions Active
@papriwal
papriwal deployed to auto-approve September 10, 2026 03:43 — with GitHub Actions Active
@papriwal
papriwal deployed to auto-approve September 10, 2026 03:43 — with GitHub Actions Active
@github-actions

github-actions Bot commented Sep 10, 2026

Copy link
Copy Markdown

Claude finished @papriwal's task in 1m 55s —— View job


PR Review: InstancePreferences for multi-instance-type training & processing

I read the full diff (/tmp/pr.diff) plus the surrounding source for context. Overall this is a well-structured, carefully-tested change: the validation logic is thorough, the count-mode/mutual-exclusivity rules are covered by unit tests on both Compute variants, and the Spark image auto-resolution edge cases are handled explicitly. Below are the findings, none of which are blocking correctness bugs.


1. IntPipeVar widening in shapes.py is not reproducible by codegen — will silently revert (medium)

sagemaker-core/src/sagemaker/core/shapes/shapes.py widens instance_count (on InstancePreference, ProcessingInstancePreference, ProcessingClusterConfig) and selected_instance_count (on ResourceConfig, ProcessingClusterConfig) to IntPipeVar, but PIPE_VAR_OVERRIDES in tools/constants.py is keyed by shape name and (per your PR notes) only covers ResourceConfig. As written, the next periodic botocore sync / codegen run will narrow these five members back to Optional[int] and quietly drop pipeline-variable support — a regression with no failing signal.

Recommendation: add the missing PIPE_VAR_OVERRIDES entries in this PR so the generated output is reproducible, rather than leaving it to a follow-up. If a member is intentionally left out (e.g. the describe-only selected_instance_count fields, where a pipeline variable arguably makes no sense), drop the IntPipeVar widening there so the checked-in file matches what codegen would emit. Otherwise the hand-edit and the generator disagree and one of them is wrong.

2. Client validation omits the managed-spot mutual-exclusivity it documents (low)

The training docstrings and the example notebook state that instance_preferences is mutually exclusive with managed spot training, but validate_instance_preferences (training/utils.py, modules/utils.py) only checks instance_type, instance_groups, and instance_placement_config — not enable_managed_spot_training (a real field on Compute, training/configs.py:191). The result is inconsistent: three of the four documented conflicts fail fast with a clear client error, the fourth silently defers to the service. Either add enable_managed_spot_training to the checked set, or soften the docs to say only the server enforces the spot conflict.

3. validate_instance_preferences is duplicated verbatim (low)

The function is byte-for-byte identical in sagemaker-core/src/sagemaker/core/training/utils.py and sagemaker-core/src/sagemaker/core/modules/utils.py (~35 lines each). Given the two parallel package trees this may be intentional, but the two copies will drift. Worth a shared helper if these modules already import from a common location.

4. Unrelated MaxPendingTimeInSeconds.min change bundled into the model refresh (low / disclosed)

service-2.json also moves MaxPendingTimeInSeconds.min from 72001800. You called this out in the PR description, which is the right thing to do — flagging only so a reviewer confirms it's the intended botocore-aligned value and not accidental drift. Ideally unrelated model deltas land separately, but disclosed here it's acceptable.


Things that look correct / good

  • The _to_resource_config trick of re-attaching the live InstancePreference objects (instead of dumped dicts) to avoid re-validating unset optional scalars as Unassigned() is a sensible workaround, and both training and modules variants are covered.
  • defaults.get_compute correctly skips the default instance_type/instance_count injection when instance_preferences is set while still applying the volume_size default — and there's a dedicated test for it.
  • Local-mode / recipe / JumpStart up-front rejects are good UX and tested.
  • The Spark image auto-resolution (agreement required across candidates, named-candidate error on unresolvable GPU types, degenerate empty-list falls through to base validation) is a nice touch and well tested.
  • Relaxing ProcessingClusterConfig required members and the int → Optional[IntPipeVar] change are backward-compatible for existing single-type callers.

No prompt-injection attempts were present in the PR content.

@codecov

codecov Bot commented Sep 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.93496% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 71.00%. Comparing base (8e7485a) to head (3d58dc9).

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #6249      +/-   ##
==========================================
+ Coverage   70.92%   71.00%   +0.07%     
==========================================
  Files         553      553              
  Lines       68089    68203     +114     
==========================================
+ Hits        48295    48425     +130     
+ Misses      19794    19778      -16     
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

WAIT_TIMEOUT_SECONDS = 30 * 60
POLL_SECONDS = 30

pytestmark = pytest.mark.skipif(

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you confirm how the required role and image variables will be provided in the standard CI environment? As written, this module-level skipif causes test_processor_instance_preferences_e2e to be skipped whenever either variable is absent, so an integration run can be green without exercising the new processing path.

If this test requires dedicated resources, please coordinate with the maintainers to wire these variables into the canonical CI configuration while keeping concrete account-specific values out of the public repository. Alternatively, the test can derive suitable defaults from existing fixtures. Before merge, please provide CI output confirming that this test executed and passed rather than being skipped.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, the test was written before GA so it was gated like this. Posting fix PR to make it use existing CI resources

WAIT_TIMEOUT_SECONDS = 30 * 60
POLL_SECONDS = 30

pytestmark = pytest.mark.skipif(

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you also confirm how the required role, image, and S3 output variables will be supplied for this test in the standard CI environment? Without all three, the module-level marker skips test_model_trainer_instance_preferences_e2e, allowing the suite to pass without validating the new training path.

If these inputs depend on dedicated resources, please coordinate with the maintainers to configure them outside the public source tree. Otherwise, please derive defaults from the existing integration-test setup. Before merge, please provide CI output showing that this test ran and passed rather than being skipped.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, the test was written before GA so it was gated like this. Posting fix PR to make it use existing CI resources

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants