Skip to content

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

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

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

Conversation

@papriwal

@papriwal papriwal commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Caution

DO NOT MERGE

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).
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown

Claude finished @papriwal's task in 3m 18s —— View job


Code review: feat(core): add InstancePreferences for multi-instance-type training and processing

  • Read the full PR diff
  • Verify validation logic (training + processing count/exclusivity rules)
  • Check imports (Unassigned, List/Dict/Union), serialization of output-only fields
  • Cross-check Spark image-resolution branch and defaults handling
  • Post review

Overall this is a clean, well-structured, and unusually well-tested change. The mutually-exclusive/count-mode validation is mirrored consistently across the training and processing planes, the output-only selected_* fields are correctly kept out of create requests, and the unsupported-combination guards (local mode / recipes / JumpStart) fail fast with clear messages. I verified the two things most likely to bite:

  • Unassigned is imported in both modules/utils.py and training/utils.py, so the new validators won't NameError.
  • Output-only selected_instance_type / selected_instance_count are stripped before the API call. The modules-path _to_resource_config keeps None values in filtered_dict (unlike the training path, which drops them with and v is not None), but serialize()/_serialize_shape drop None/Unassigned, so the create request never sends them. No functional bug — just an asymmetry between the two _to_resource_config implementations that's a little surprising to read.

No correctness bugs found. A few minor, non-blocking observations:

1. Codegen drift on the IntPipeVar widening (most actionable)

You already flagged this in the PR description, and I agree it's the one thing worth resolving in-PR rather than deferring. shapes.py widens instance_count (on InstancePreference, ProcessingInstancePreference, ProcessingClusterConfig) and selected_instance_count to IntPipeVar, but PIPE_VAR_OVERRIDES in tools/constants.py only keys ResourceConfig. The next periodic botocore sync will regenerate shapes.py and silently narrow these back to Optional[int], breaking pipeline-variable callers with no test catching it. Recommend adding the override entries here (and, per your own note, dropping the two Describe-only selected_instance_count fields from the widening since a pipeline variable can't meaningfully apply to a response-only field).

2. Docs/notebooks describe client-side rejections that aren't enforced client-side

  • docs/training/index.rst lists instance_preferences as mutually exclusive with managed spot training, but validate_instance_preferences only checks instance_type / instance_groups / instance_placement_config — not enable_managed_spot_training. Similarly the training docs say AWS Batch queues reject it, with no client guard.
  • The processing notebook says "Not supported with: local mode and FrameworkProcessor", but Processor has no local-mode guard, whereas the training path added explicit ModelTrainer guards for local/recipe/JumpStart.

None of these are bugs (the service is the source of truth and will reject), but the enforcement is asymmetric with the training side and the docs read as if the SDK validates them. Either add the matching client-side checks or soften the wording to "rejected by the service."

3. Public-API asymmetry: typed objects vs. raw dicts

Training takes List[InstancePreference] (typed pydantic models) while processing takes List[Dict[str, Union[str,int]]] with PascalCase keys ({"InstanceType": ..., "InstanceCount": ...}). This mirrors the existing Processor style, so it's a defensible choice, but it's an inconsistency users will notice and there's no shared validation helper between the two validate/_validate_processing_... functions (near-duplicated logic). Worth a comment in the docstrings pointing users at the dict shape (the processing docstring does this — good).

Minor

  • test_pyspark_degenerate_preferences_reach_base_validation passes instance_preferences=[{}]; the resulting error is the count-mode message ("every element must set its own InstanceCount") rather than a clear "missing InstanceType" one. A dedicated "each preference requires InstanceType" check would give a friendlier message, though the server also enforces it.
  • The unrelated MaxPendingTimeInSeconds.min 7200 → 1800 change and the botocore ≥ 1.43.90 floor are well-called-out in the description; just make sure the dependency floor bump actually lands with the release as noted, since older botocore raises ParamValidationError on these new members.

Nice work — the test coverage (round-trip, per-preference vs. uniform count, Spark image agreement/divergence, service-model pinning, defaults handling) is thorough and the docstrings explaining the count modes are excellent.
· branch feature-smtj-instance-preferences-latest

@codecov

codecov Bot commented Sep 9, 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 70.75%. Comparing base (10ada9b) to head (6fc2324).
⚠️ Report is 30 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #6246      +/-   ##
==========================================
+ Coverage   70.68%   70.75%   +0.07%     
==========================================
  Files         553      553              
  Lines       67891    68005     +114     
==========================================
+ Hits        47990    48120     +130     
+ Misses      19901    19885      -16     
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

},
"documentation":"<p>Defines an instance group for heterogeneous cluster training. When requesting a training job using the <a href=\"https://docs.aws.amazon.com/sagemaker/latest/APIReference/API_CreateTrainingJob.html\">CreateTrainingJob</a> API, you can configure multiple instance groups .</p>"
},
"InstancePreference":{

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

not sure if we need to update service-2.json manually? It would be auto-updated based on boto spec?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This was just updated manually to enable local testing before Trebuchet release. This should be a no-op now that these fields are already released in the public spec?

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.

5 participants