change(train): gate deep integ tests behind gpu_intensive, add shallow submit-then-stop suite - #6176
Conversation
…w submit-then-stop suite Replaces the CodeBuild integ suite for sagemaker-train on the PR gate with a faster selection that keeps meaningful server-side coverage. CreateTrainingJob returns a TrainingJobArn only after the request has cleared every synchronous server-side gate: public-model shape validation, SigV4, sagemaker:CreateTrainingJob authorization (including condition keys), iam:PassRole on the execution role, the training backend's synchronous request validators, its role-assuming validators (which make real S3/ECR/FSx calls as the customer), post-validator business logic (training-plan capacity, routing, recipe filtering) and the final conditional write that rejects duplicate job names. So "the ARN came back" proves the SDK-shaped payload was accepted as sent and the caller held the permissions needed to submit it -- without paying for a training run. Adds tests/integ/train/shallow (70 tests) built on that: submit, assert the ARN, stop immediately. Covers ModelTrainer (payload shaping, source-code packaging, input channels, compute, networking, checkpointing/spot), the recipe trainers (SFT/DPO/RLVR/RLAIF, serverless and serverful), recipe customization (overrides, explicit recipe files, sequence_length, DataMixingConfig), and the non-training job types (HyperParameterTuningJob, AgentRFT Job). Includes negative tests so the suite cannot pass merely because some ARN came back. Marks the 19 previously-unmarked tests that submit a job and wait for it with gpu_intensive, so they continue running on the scheduled CI-health workflows instead of the PR gate. Widens that marker's description: despite the name it gates anything consuming real training capacity, including serverless and CPU-instance jobs. The PR job now runs the whole tests/integ/train tree with -m "not gpu_intensive and not us_east_1" rather than only shallow/, which keeps the ~170 client-side tests (recipe resolution, data utils, dry-run, log streaming) on the gate -- they make no service call and were never the expensive part. Net: 191 of 251 tests on the PR gate, none of which waits for a training job. This is a deliberate scope reduction: training *behaviour* (artifacts, metrics, convergence) is no longer asserted on the PR gate. A regression that breaks training itself -- a bad entry script, a broken container command -- will pass here and be caught by the scheduled suites.
…ter first real AWS run Verified against AWS in account 729646638167 (us-west-2): * test_unassumable_role_is_rejected: ModelTrainer.__init__ validates the role via iam:SimulatePrincipalPolicy, so a bad role raises RoleValidationError at construction and never reaches CreateTrainingJob. Assert around the constructor instead of around train(). * test_cpt_trainer_is_accepted: CPTTrainer takes no training_type, and its compute is HyperPodCompute-only, so it cannot use the shared _trainer helper. WIP: 2 further real failures still to fix (RLAIF compute, tuner job-name collision). See SHALLOW_TEST_RUN_STATE.md.
Ran the suite against account 729646638167 (us-west-2) with PYTHONPATH pointed at
this clone, and fixed every failure it surfaced. All were wrong assumptions in
the tests, not service problems:
* conftest: add a session-scoped bundled_service_model fixture setting
AWS_DATA_PATH to sagemaker-core/sample. The public botocore model has no
ServerlessJobConfig.SequenceLength, so sequence_length requests were rejected
client-side before reaching the service. Mirrors the existing
setup_aws_data_path fixture in test_recipe_override_integration.py.
* harness: unique_name() now takes max_length. Tuning job names are capped at 32
characters, not the 63 allowed for training jobs, and the service enforces it:
Value '...' at 'hyperParameterTuningJobName' failed to satisfy constraint:
Member must have length less than or equal to 32
* tuner tests: submit under an explicit job_name via a _tuning() context manager.
The tuner derives its default name from the training image plus a
second-granularity timestamp and ignores base_job_name, so two tuner tests in
the same second collided with ResourceInUse.
* RLAIF: excluded from TestServerfulSubmission. RLAIFTrainer has no compute
parameter, so it has no serverful path. Still covered by every serverless case.
* CPT: marked gpu_intensive and skipped unless SHALLOW_HYPERPOD_CLUSTER is set.
CPT refuses to submit without HyperPod compute, and HyperPod targets a
pre-provisioned cluster rather than CreateTrainingJob.
* sequence_length / training_type: narrowed to the values the recipe catalogue
actually offers for this model ('4K' only; no serverless recipe for FULL).
Both left parametrized so more values can be added against a model that
supports them, rather than dropping the distinction.
Result: 62 passed, 0 failed, 5m18s serial (~5s/test).
Cost model confirmed empirically rather than assumed: across 100 jobs created by
these runs, every one ended Stopped and every BillableTimeInSeconds was null.
Jobs are torn down while still in Starting/Pending, before instances become
billable.
|
Claude finished @jam-jee's task in 2m 51s —— View job ReviewThorough, well-documented PR. The rationale (submit-then-stop as a cheap proxy for "the service accepted this payload and the caller was authorized") is sound, the negative tests are the right instinct, and the README/docstrings are unusually good. Comments below, none blocking.
1. Security / isolation change worth flagging to approvers —
|
…t have shallow coverage
Addresses two review points.
1. Only mark deep tests that this suite actually replaces.
Reverts gpu_intensive from 9 tests that had no shallow counterpart, so the PR gate
no longer loses coverage with nothing replacing it:
* all 8 evaluator tests (benchmark, custom scorer, inspect_ai, llm_as_judge x2,
llmaj_custom_model) -- evaluate() is a different API surface returning pipeline
executions, and this suite has no coverage for it
* test_notifications.py -- asserts EventBridge/SNS side effects, not submission
10 marks remain, each with a named shallow equivalent documented in the suite
README. The rule is written down there: do not mark a deep test unless a shallow
test covers the same path.
2. One file per trainer, matching the existing deep-suite layout.
test_recipe_trainers_submission.py -> test_{sft,dpo,rlvr,rlaif,cpt}_trainer.py
test_recipe_customization_submission.py (recipe cases folded into rlvr/sft;
Nova data mixing to its own file)
test_other_job_types_submission.py -> test_tuner.py,
test_multi_turn_rl_trainer.py
test_model_trainer_submission.py -> test_model_trainer.py
The "recipe_*" names described how the SDK groups these internally rather than
what a reader looks for; the shallow counterpart of a given deep test is now
obvious from the filename.
recipe_cases.py holds the cases every recipe trainer shares. Each per-trainer
class subclasses RecipeTrainerCases and sets TRAINER, so a new trainer is a
two-line file, and per-trainer deviations are declared rather than duplicated:
EXTRA_KWARGS (RLAIF's reward model), SUPPORTS_SERVERFUL=False (RLAIF takes no
compute), SUPPORTS_TRAINING_TYPE=False (CPT has no LoRA/full split). Not named
test_* so pytest does not collect the base class.
Inheriting the shared cases also widened coverage: DPO and RLAIF now get the full
set (output path, dataset override, both negative cases) rather than only the
three they had as parametrized entries. 80 tests total, 69 on the PR gate.
Verified against AWS (account 729646638167, us-west-2): 68 passed, 1 skipped,
0 failed in 6m59s. The skip is RLAIF's serverful case, reporting
"RLAIFTrainer takes no compute argument".
|
Claude finished @jam-jee's task in 2m 10s —— View job ReviewRe-review after the latest push. The suite has been restructured into one file per trainer since the first automated review, so a few of that review's line references (e.g. Review checklist
1. Trust-boundary change worth calling out to approvers —
|
… has an equivalent
Previous commits only audited the marks this PR added. This audits all 46
gpu_intensive tests in tests/integ/train -- including those already marked on
master -- and adds the missing shallow counterparts.
Added (were gaps):
* MLflow, in RecipeTrainerCases so all four recipe trainers get it. Every
*_complete_workflow deep test configures MLflow, so without this their
shallow counterparts missed that half of the payload. Two forms:
experiment/run names (always runs) and mlflow_resource_arn (skips if the
account has no app).
* RLVR reward functions, all three forms the deep suite covers: hub-content
ARN, Lambda ARN (auto-creates an Evaluator), and a pre-created Evaluator
object.
* RLAIF reward_prompt as a hub-content ARN rather than a Builtin.* name, and
continued fine-tuning from a model-package ARN.
* Nova SFT and Nova RLVR, in test_nova_trainers.py. Nova needs a different
recipe family, region and account, so it cannot share RecipeTrainerCases;
marked us_east_1.
Two real constraints the AWS run surfaced, both now recorded in comments:
* The reward-function tests cannot use this suite's generic chat-format
fixture. Before submitting, the SDK *invokes* the reward function over
sample records and fails if they do not score ("GSM8k scoring failed").
They now use the same dataset as the deep RLVR suite, via a dedicated
reward_scored_data_uri fixture.
* list_mlflow_apps is not a paginatable operation, so the fixture calls it
directly instead of via get_paginator.
Also fixed a ScopeMismatch: the three new lookup fixtures were session-scoped
but depend on the parent conftest's module-scoped sagemaker_session.
All three new fixtures (mlflow_arn, reward_lambda_arn, reward_evaluator) only
look resources up and skip when absent. The deep suite's equivalents create
them -- IAM roles, Lambdas, MLflow apps, registry entries -- which is a durable
side effect a fast PR-gate suite should not have.
Still uncovered, documented in the suite README with the reason: the 11 evaluator
tests (evaluate() is a different API surface returning pipeline executions) and
the 3 HyperPod tests (submit to a pre-provisioned cluster, not
CreateTrainingJob). Neither is newly marked by this PR, so no coverage is lost;
the evaluator gap is the clearest follow-up.
97 tests total, 82 on the PR gate. Verified against AWS (729646638167,
us-west-2): 81 passed, 1 skipped, 0 failed in 7m04s. The skip is RLAIF's
serverful case, which reports its own reason.
|
Claude finished @jam-jee's task in 1m 31s —— View job ReviewRe-reviewed the current tree after the latest push (whole diff read from Checklist
1.
|
…arameters, Nova serverful Three remaining gpu_intensive tests had no shallow counterpart: * test_sft_trainer_serverful_smtj.py (override half) -> SFT test_recipe_overrides_are_accepted. Asserts both halves: the merge reached the rendered recipe (client-side, exact) and the resulting payload is still accepted (recipe filtering runs after the request validators, so a bad merge only surfaces at submission). Verified against AWS: overrides are written flat under training_config but land nested under training_args, and the recipe default for this model is 5 -- so asserting 1 proves the override applied rather than coinciding with the default. * test_rlvr_trainer_nemotron_with_kl_and_recipe -> RLVR test_kl_and_clipping_hyperparameters. These are separate recipe fields rather than one flag, so the existing max_epochs-only test did not prove they serialize. * test_sft_trainer_serverful_smtj.py (Nova half) -> Nova TestNovaServerfulSubmission. Distinct from the shared serverful case: Nova model, Nova recipe family, Nova-only instance type, us-east-1. Accepts the override under either trainer.max_epochs or training_args.max_epochs, since recipe families nest epoch control differently -- so the test fails on a lost override rather than on a recipe-layout difference. Verified against a real account (us-west-2): 83 passed, 1 skipped, 0 failed in 5m20s. The skip reports its own reason (RLAIFTrainer takes no compute argument).
The five us_east_1 shallow tests referenced resources hardcoded to one test
account and had therefore never actually executed. Verified: from 729646638167,
`aws s3 ls s3://sagemaker-us-east-1-784379639078/input_data/sft-nova/` returns
AccessDenied.
Derive everything from the calling account instead, the way
test_sft_trainer_serverful_smtj.py::training_resources already does:
* nova_sft_data_uri -- uploads the Nova-shaped sample data the deep suite
already ships (tests/data/train/sft_smtj_sample_data.jsonl) to the caller's
own bucket. Cannot reuse nova_train_data_uri: Nova SFT records carry a
schemaVersion the generic chat-format fixture lacks.
* nova_rlvr_data_uri -- copies the GSM8k-shaped dataset the us-west-2 RLVR
tests use into the us-east-1 bucket. A copy rather than a reference because
an S3 input must be in the job's region.
* nova_output_path -- default_bucket() rather than a named bucket.
* nova_reward_function_arn -- resolves the hub content in the caller's own
account, look-up-and-skip like the other reward fixtures.
Two service-verified region constraints drove this:
* the model package group must be in the job's region -- passing the us-west-2
MODEL_PACKAGE_GROUP ARN is rejected with "Model package group ARN region
'us-west-2' does not match expected region 'us-east-1'". Added
NOVA_MODEL_PACKAGE_GROUP (a bare name) alongside it in recipe_cases so the
two Nova files cannot drift.
* likewise for S3 inputs, hence the RLVR copy above.
The Nova RLVR case sets skip_reward_validation=True. The SDK invokes the reward
function over sample records before submitting; the function registered under
that name in this account returns a shape the verifier rejects ("Each output
must include 'id', 'aggregate_reward_score'"), so the test would assert
per-account hub contents rather than this payload. The verifier is already
covered against a known-compatible function by the three us-west-2
reward-function cases; what is unique here is the Nova recipe family and region.
Also register gpu_intensive and us_east_1 in pyproject.toml. They were declared
only in tox.ini, but pytest reads its config from pyproject.toml, so both were
unregistered at runtime. That matters here: the PR gate selects with
-m "not gpu_intensive and not us_east_1", so a typo'd marker name would silently
put an expensive deep test back on the gate instead of warning.
Verified against a real account: 5 passed in 47s, all five for the first time.
Every job ended Stopped with BillableTimeInSeconds null, so the cost model holds
in us-east-1 as well.
A full gate run showed the shallow suite is not what makes this job slow. Measured (us-west-2, -n 8 --dist loadfile): 201 of 204 tests finished in ~7 minutes, then three evaluator tests held the run open for another 40+ before being killed. Five evaluator tests are not marked gpu_intensive and each blocks on execution.wait(..., timeout=14400) -- a 4-hour ceiling, ~33 minutes per execution in practice: test_benchmark_evaluator.py::test_benchmark_evaluation_full_flow (no marks) test_custom_scorer_evaluator.py::test_custom_scorer_evaluation_full_flow (xdist_group) test_llm_as_judge_evaluator.py::test_llm_as_judge_evaluation_full_flow (no marks) test_llm_as_judge_base_model_fix.py::test_base_model_evaluation_uses_correct_weights (serial) test_llm_as_judge_base_model_fix.py::test_base_model_false_still_works (serial) They run on master's gate too, so this PR does not add them -- but it does not fix them either, and they now dominate the job's wall clock. Deliberately NOT marking them here: unlike every other gpu_intensive test they have no shallow counterpart, so marking would remove coverage, which is what the rule this PR establishes forbids. Correct order is to add evaluator support to the harness first, then mark. Documented in the suite README so the next person does not have to rediscover it by watching a run stall at 95%. Also flags test_local_model_trainer.py in the workflow: it runs real containers, so it needs Docker and pulls pytorch-training:2.0.0-cpu-py310 (2.3 GB compressed, verified via ECR). That is fine on GitHub-hosted Ubuntu runners, which preinstall Docker, and the ECR read is already covered by the role the shallow tests use -- but it is the slowest non-evaluator thing on the gate and the only step with a disk-space floor, so the note says what to deselect first if the job ever goes flaky on runner capacity.
|
Claude finished @jam-jee's task in 1m 35s —— View job ReviewRe-reviewed the current tree after the latest push (full diff read from
1.
|
…ngside it Restores integ-tests to its master definition -- sagemaker-train is back in the matrix, byte-identical to master -- and makes fast-integ-tests additive rather than a replacement. The deep tests still come off the gate, just not by removing the job. The CodeBuild project's buildspec already selects -m "not gpu_intensive and not us_east_1" (verified by reading the live project), so the marks added earlier in this PR are what deselect them. No workflow edit was needed for that. Keeping the CodeBuild job also keeps things the shallow job cannot cover: * the whole tests/integ tree, so the ~170 client-side tests (recipe resolution, data utils, dry-run, log streaming) run without this job repeating them; * test_local_model_trainer.py, which needs a Docker daemon. CodeBuild runs start-dockerd with privilegedMode, which is a better home for it than a GitHub runner pulling a 2.3 GB image -- so the reviewer caveat about that is dropped as moot; * the serial/parallel split the buildspec does for rate-limited tests. fast-integ-tests is therefore scoped to tests/integ/train/shallow only. Widening it would duplicate the client-side tests and double the training jobs this suite creates. It stays a separate job rather than folding into the buildspec because the buildspec is CDK-managed outside this repo, and because a shallow failure then reports as its own check. Corrects a claim in the previous comment: the shallow suite does carry gpu_intensive tests -- 11 of them, the CPT and MTRL classes, which need a HyperPod cluster and an agent runtime. With us_east_1 that is 16 deselected, so 84 of 100 run here. The comment now lists both groups and why. Verified against a real account: 83 passed, 1 skipped, 0 failed in 3m15s (the skip self-reports: RLAIFTrainer takes no compute argument). Faster than the 5m20s measured with the client-side tests bundled in. Every job ended Stopped with BillableTimeInSeconds null; no leaked jobs.
|
Claude finished @jam-jee's task in 2m 11s —— View job ReviewRe-reviewed the current tree from the full diff. This remains a well-scoped, unusually well-documented change — the submit-then-stop rationale is sound, the negative tests are the right instinct, and the README's "what this does NOT catch" section is honest. Five prior automated reviews covered the standing items; I confirm which still stand and add one new finding (#2) they missed. Nothing blocks merge.
Checklist
1.
|
The shallow suite creates a training job per test. That puts it against two
different quotas in two different units:
* serverless (the default recipe-trainer path, no explicit compute) is
bounded by "Maximum number of concurrent model customization serverless
jobs per Region" -- a count of jobs, currently 20;
* serverful (an explicit Compute/TrainingJobCompute: the ModelTrainer tests,
the tuner, test_explicit_compute_is_accepted) is bounded by the
per-instance-type quota, e.g. "ml.m5.large for training job usage" -- a
count of instances.
A slot is one concurrent job; a serverful job also takes one per instance, so a
single cap holds the suite inside both quotas without the harness needing to
know which kind of job a given test produces.
Hold the slot until the job is terminal, not until stop() returns
This is the subtle part, and the first cut got it wrong. The service counts a
job against the concurrency quota from CreateTrainingJob until the job reaches
Completed/Failed/Stopped -- NOT until StopTrainingJob returns. Measured against
the service, stop() returns in a few seconds but the job takes ~1-3 min to
actually drain (the reservation is torn down without ever becoming billable).
Releasing the slot at stop() therefore bounded nothing: with the cap at 10 and
8 workers, each slot recycled ~20x inside a single job's counted lifetime, the
suite peaked at ~37 concurrent jobs, and it tripped ResourceLimitExceeded at a
utilization of 21 against the limit of 20. _wait_until_terminal closes that gap
by holding the slot across the drain, so the cap bounds what the service
actually counts. With the fix, live counted concurrency stayed at 4-5 against a
cap of 10 for the whole run.
The cost is runtime: holding to terminal makes the suite's floor roughly
(#jobs * drain) / cap. At ~83 jobs, a ~75s median drain and cap 10 that is
~8-13 min, versus ~2 min if slots released early -- but that fast run is the one
that breaches the quota. This is the batches-of-10 behaviour: at most 10 jobs
counted at once.
Mechanism
job_slots() in harness.py, held by submitted() and assert_rejected() until the
job is terminal. Slots are O_EXCL-created files under a run-keyed temp directory;
xdist workers are separate processes, so an in-process semaphore would bound
nothing. Keyed on PYTEST_XDIST_TESTRUNUID (falling back to the parent pid) so two
concurrent local runs get separate budgets rather than deadlocking, and a stale
directory from a killed run is never mistaken for live slots.
Details that matter:
* both waits proceed with a warning rather than failing -- acquiring a slot
waits up to 900s, _wait_until_terminal up to 300s -- since the cap is a
courtesy to the quota, not an assertion about the SDK, and a leaked slot or
stuck drain should mean a slower run rather than a red build;
* status is read per job type (training_job_status / job_status /
hyper_parameter_tuning_job_status), since the SDK is not consistent, and a
job that exposes no status releases its slot immediately rather than hanging;
* a request larger than the cap is clamped, so a single test cannot deadlock
against itself;
* enforced in the harness rather than per test, so a new test is capped by
default instead of by remembering to opt in.
Default 10, overridable via SHALLOW_MAX_CONCURRENT_JOBS; 0 disables gating for a
single-worker debugging run. Set explicitly in the workflow so the ceiling is
visible at the call site rather than only in a Python default.
Verified
* Slot mechanism holds under contention: 12 processes x 4 iterations against
cap=3, observed peak exactly 3, never 4; slots released on the happy path,
on exception, and with correct multi-slot accounting; cap=0 takes none; an
oversized request clamps without deadlocking.
* Terminal-hold bounds what the service counts: a multi-process simulation
where each job stays "counted" past stop() peaked at exactly the cap (3)
with 10 workers, versus the pre-fix design that would have peaked far higher.
* _wait_until_terminal waits through non-terminal states, releases on
terminal, honours each job type's status attribute, and returns rather than
hanging on None / a read error / a timeout.
* Full suite green with the fix: 83 passed, 1 skipped in 810s (13:30), zero
ResourceLimitExceeded, live counted concurrency 4-5 throughout, account
fully drained afterward.
|
Claude finished @jam-jee's task in 2m 7s —— View job ReviewRe-reviewed the current tree from the full diff (
Checklist
1.
|
| # Additive: runs the shallow (submit-then-stop) suite for sagemaker-train | ||
| # alongside the existing integ-tests job above, which is unchanged. | ||
| # | ||
| # Why a separate job rather than folding this into the CodeBuild suite: this |
There was a problem hiding this comment.
We will move this job to codebuild after initial POC and outcome.
This is a public repo, so the comments should not name internal test accounts. Every reference was explanatory -- "the deep test hardcodes a bucket in account X, which other accounts cannot read" -- and the point it makes is that the bucket belongs to *one specific account*, not which account that is. Reworded to say that instead, keeping each rationale (and the verified AccessDenied finding) intact. Comments and docs only; no functional change. Test resource ARNs still name the account they actually live in, since resolving them is what the tests do, and that already matches the convention in the surrounding suite.
|
|
||
|
|
||
| @contextmanager | ||
| def _tuning(tuner, job_name): |
There was a problem hiding this comment.
Tuner path escapes the concurrency cap. _tuning() calls tuner.tune() directly (no job_slots(), no _wait_until_terminal()), yet the README, the DEFAULT_MAX_CONCURRENT_JOBS note, and _requested_slots all describe the tuner as inside the cap. Peak overshoot is small (both tuners are max_jobs=1), but it's the release-before-terminal
pattern the cap note blames for the prior ~37-concurrent breach. Wrap in with job_slots(...) and drain after stop_tuning_job(), or document the exemption.
| # would otherwise silently put an expensive deep test back on the gate instead | ||
| # of warning. | ||
| "gpu_intensive: marks a test that consumes real training capacity (scheduled CI, not PR checks); see tests/integ/train/shallow", | ||
| "us_east_1: marks a test that must run in us-east-1 (Nova); runs in the us-east-1 integ job", |
There was a problem hiding this comment.
Instead of using us_east_1 as the test marker. We should use a more contextual name talking about the tests being related to nova?
There was a problem hiding this comment.
yes, these are existing markers, need to reword these. We will be removing most of the markers and merging them together.
| # | ||
| # A bare name resolves in whichever region the session is in. Shared here rather | ||
| # than duplicated per Nova file so the two cannot drift apart. | ||
| NOVA_MODEL_PACKAGE_GROUP = "sdk-test-finetuned-models" |
There was a problem hiding this comment.
recipe_cases.py pins MODEL_PACKAGE_GROUP to an account ARN while the Nova path uses the bare NOVA_MODEL_PACKAGE_GROUP; prefer the bare name in both.
There was a problem hiding this comment.
nice,sounds good, updated.
|
|
||
|
|
||
| @pytest.fixture(scope="module") | ||
| def reward_scored_data_uri(): |
There was a problem hiding this comment.
Should be named reward_scored_dataset_uri?
| # Public DLC, present in every commercial region we test in. Using a real image | ||
| # matters: the backend's role-assuming validators resolve the training image | ||
| # against ECR, so a bogus URI would fail for the wrong reason. | ||
| CPU_IMAGE = "763104351884.dkr.ecr.us-west-2.amazonaws.com/pytorch-training:2.0.0-cpu-py310" |
There was a problem hiding this comment.
If this CPU_IMAGE is present in every commercial region would be beneficial to be region agnostic here. Reduces the blast radius of mishaps.
There was a problem hiding this comment.
Good point, will update it.
|
Claude finished @jam-jee's task in 1m 35s —— View job ReviewRe-reviewed the current tree from the full diff ( Checklist
1. Dead imports in
|
Review feedback: recipe_cases.py pinned MODEL_PACKAGE_GROUP to a full ARN while the Nova path used a bare NOVA_MODEL_PACKAGE_GROUP, for the same group. The bare name is the better form on both paths, so the two constants collapse into one. The SDK accepts either -- _resolve_model_package_group_arn() returns an ARN unchanged and otherwise resolves a name via ModelPackageGroup.get() against the *session's* region -- so a name is region- and account-portable where an ARN pins both. Pinning the region is what forced the split in the first place: passing the us-west-2 ARN to a us-east-1 Nova job is rejected with "Model package group ARN region 'us-west-2' does not match expected region 'us-east-1'". One name serves both regions and drops a hardcoded account ID from a public repo. Verified: the bare name resolves to the same ARN via DescribeModelPackageGroup in us-west-2, and the us-west-2 recipe path still submits -- SFT, DPO and RLVR minimal-request tests pass against the service (3 passed). Collection unchanged at 100 tests.
|
Agreed, and done in Checking the SDK, either form works: Verified: the bare name resolves to the same ARN via |
`CPU_IMAGE` hardcoded a us-west-2 URI in the public DLC account. Replace it with `cpu_image(sagemaker_session)`, which resolves the same image in the session's own region through `image_uris.retrieve` -- the resolver the SDK's framework estimators already use, so this is the supported mapping rather than a reconstruction of it. The registry account is not constant, which is what makes the hardcoded form actually wrong rather than merely untidy: it is 763104351884 across the commercial regions but 442386744353 in GovCloud and 727897471807 in China (on .com.cn). A pinned URI is unusable outside one partition, and it fails as an ECR error from the backend's role-assuming validators, which reads like a test bug rather than a hardcoded constant. A function rather than a constant because it needs the session's region; all three call sites already had a session in scope. Verified against AWS: reproduces the previously hardcoded URI byte-for-byte in us-west-2, and returns the correct in-region host (and per-partition registry) in us-east-1, eu-west-1, ap-northeast-1, us-gov-west-1 and cn-north-1. The affected tests pass on a real account -- 10 passed in 88s, covering the ModelTrainer helper, the raw TrainingJob.create path, and the tuner.
`_tuning()` submitted via `tuner.tune()` without acquiring slots, because a tuning job is stopped through `tuner.stop_tuning_job()` rather than `stop_quietly` and so never went through `submitted()`. Meanwhile the `DEFAULT_MAX_CONCURRENT_JOBS` note, the README quota table and `_requested_slots` all described the tuner as being inside the cap. It wasn't. Wrap it in `job_slots()` and drain after stopping. Slots are sized from the tuner's `max_parallel_jobs`, not a compute block: a tuning job occupies instance quota through the child training jobs it launches, which is also why `_requested_slots` cannot size this and `_tuning()` requests its own. The drain matters for the same reason it does elsewhere -- `stop_tuning_job()` returns while the job is still `Stopping` and its children are still tearing down, so releasing there is the release-before-terminal pattern that caused the ~37-concurrent breach. `_STATUS_ATTRS` already carried `hyper_parameter_tuning_job_status`, so the waiter handled this job type already; nothing ever called it with one. Renamed `_wait_until_terminal` -> `wait_until_terminal`. A test module outside the harness now needs it, and no other test imports a private name from there. Real impact today is small and worth saying so: both tuner tests are `max_parallel_jobs=1`, so this is 1 slot each. It is wired up because the cost is one context manager and the failure mode otherwise is silent -- a future test raising `max_parallel_jobs` would consume capacity outside a cap that still claimed to bound it. Verified: 3 unit scenarios (slots held through tune -> stop -> drain and released after; a failing test body still stops and releases; a missing or None `max_parallel_jobs` yields 1 slot, never an unbounded 0), plus a real run -- 2 passed in 28s, both jobs logging "reached Stopped; releasing slot" with no drain timeout. Docs corrected in all three places that overclaimed.
…R cases a reward signal (#6207) * fix(train): give shallow RLVR cases the reward signal RLVR requires RLVRTrainer.train() refuses to submit unless custom_reward_function was passed or hyperparameters.preset_reward_function is set. TestRLVRTrainerSubmission inherits the shared cases from RecipeTrainerCases, which pass neither -- they are about recipe rendering and dataset handling, not reward configuration -- so 14 of the class's 17 tests failed: 12 raising the ValueError, and the two negative cases failing with "rejected, but not for the expected reason" because the reward error preempted the S3 validation error they assert on. Set the preset in a build() override rather than repeating it in each test, and skip it when the test supplies its own custom_reward_function so the three reward-function variants still exercise exactly what they name. "prime_code" is one of the values the recipe's preset_reward_function enum accepts ('', gsm8k, prime_code, prime_math) and is what the deep suite pairs with an ordinary training dataset on this same model. This was not a regression from a later change to sagemaker-train. The guard landed in #6181 on 2026-08-14, five days before the shallow suite merged (#6176), and rlvr_trainer.py is unchanged since. The suite had simply never run in CI: the fast-integ-tests job could not check out fork PR code, and because pull_request_target runs the base branch's workflow it could not have run on #6176 itself either. Verified against us-west-2 in the SDK test account: 14 passed in 94s, each submitting and immediately stopping a real training job. --- X-AI-Prompt: Fix the failing shallow sagemaker-train RLVR integ tests, which were being rejected at submission for a missing reward signal X-AI-Tool: claude-code * ci: run fast-integ-tests in CodeBuild instead of on the runner Replaces the runner-based shallow suite with a CodeBuild invocation, so the suite gates fork PRs -- which is nearly all of them. The job stopped working when actions/checkout began refusing to place fork PR code in a pull_request_target job. That refusal is correct: the runner holds the base repo's GITHUB_TOKEN and assumes CI_AWS_ROLE_ARN, so a fork could edit conftest.py and read those credentials out. On a public repo, overriding it with allow-unsafe-pr-checkout would be a live credential-exfiltration path. Guarding the job to same-repo PRs would stop the failure, but 59 of the last 60 merged PRs here are from forks, so that leaves ~2% coverage. This is the real fix: start CodeBuild with source-version-override, exactly as the codestyle-doc-tests, unit-tests and integ-tests jobs already do. The build never sees the runner's token, secrets or default-branch cache, so no same-repo guard is needed. Its own project rather than folding into sagemaker-train-integ-tests, so a shallow failure stays distinguishable from a deep-suite failure and runs concurrently with it rather than queueing behind it. Dropped the upload-artifact step: the JUnit XML no longer exists on the runner, and results are in the CodeBuild logs. Tradeoff recorded in both the workflow comment and the suite README: the pytest selection now lives in createCIShallowIntegBuildSpec in SageMakerMLFPySDKInfraCDK, so changing how the suite is invoked is no longer reviewable in a PR to this repo. Adding a test file under shallow/ is still picked up automatically. The project sagemaker-python-sdk-ci-sagemaker-train-fast-integ-tests is deployed, so the job resolves on merge. --- X-AI-Prompt: Instead of the GitHub runner, run the sagemaker-train shallow integ suite in CodeBuild like the other CI workflows, so fork PRs are gated after actions/checkout began refusing fork PR code in pull_request_target X-AI-Tool: claude-code

What this changes
Adds a fast server-side acceptance suite for
sagemaker-trainto the PR gate, and marks the deep tests that submit a job and wait for it so the existing gate stops running them.The existing
integ-testsCodeBuild job is unchanged and still runs. This PR adds a second job next to it rather than replacing it.CreateTrainingJobreturns aTrainingJobArnonly after the request has cleared every synchronous server-side gate: public-model shape validation, SigV4,sagemaker:CreateTrainingJobauthorization (including condition keys),iam:PassRoleon the execution role, the training backend's synchronous request validators, its role-assuming validators (which make real S3/ECR/FSx calls as the customer), post-validator business logic (training-plan capacity, routing, recipe filtering), and the final conditional write that rejects duplicate job names.So "the ARN came back" proves the SDK-shaped payload was accepted as sent, and the caller held the permissions needed to submit it — without paying for a training run. The job is stopped immediately.
New:
tests/integ/train/shallow(100 tests, 84 on the PR gate)One file per trainer, mirroring the existing deep-suite layout so the shallow counterpart of any deep test is obvious:
test_model_trainer.pytest_model_trainer.pytest_sft_trainer.pytest_sft_trainer_integration.pytest_dpo_trainer.pytest_dpo_trainer_integration.pytest_rlvr_trainer.pytest_rlvr_trainer_integration.pytest_rlaif_trainer.pytest_rlaif_trainer_integration.pytest_cpt_trainer.pytest_cpt_hyperpod.pytest_multi_turn_rl_trainer.pytest_multi_turn_rl_trainer_integration.pytest_tuner.pytest_tuner_distributed.pytest_nova_data_mixing.pytest_sft_trainer_data_mixing_integration.pytest_nova_trainers.py::test_sft_trainer_nova_workflow,::test_rlvr_trainer_nova_workflow,test_sft_trainer_serverful_smtj.pyharness.pyprovidessubmitted()/assert_submitted()/assert_rejected(): forceswait=False, resolves the submitted job across the four attribute names trainers use for it, and stops the job in afinallyso a failed assertion still cleans up.recipe_cases.pyholds the cases every recipe trainer shares. Each per-trainer class subclassesRecipeTrainerCasesand setsTRAINER, so adding a trainer is a two-line file and per-trainer deviations are declared rather than duplicated —EXTRA_KWARGS(RLAIF's reward model),SUPPORTS_SERVERFUL = False(RLAIF takes nocompute),SUPPORTS_TRAINING_TYPE = False(CPT has no LoRA/full split).Negative tests are included deliberately: without them the suite would stay green even if the SDK started sending a permissive-but-wrong payload.
Not every trainer creates a
TrainingJob—HyperparameterTunercreates aHyperParameterTuningJobandMultiTurnRLTrainercreates an AgentRFTJob— soassert_submittedtakes the expected ARN resource segment.Coverage of every
gpu_intensivetestThe rule: a deep test belongs off the PR gate only if this suite covers the same code path. There are 46
gpu_intensivetests intests/integ/train. All are accounted for:Covered by this suite —
test_model_trainer.py(8: tar source, py/sh entry, MPI, torchrun, HP json/yaml, custom driver), SFT (complete workflow incl. MLflow, validation dataset, sequence length, Nova, serverful SMTJ), DPO (both), RLAIF (complete workflow, reward-prompt ARN, continued fine-tuning), RLVR (complete workflow, all three reward-function forms, recipe+overrides, sequence length, Nova), tuner (sm_driverschannel), MTRL (3, needs prerequisites), CPT HyperPod (needs a cluster), Nova data mixing.The full test-by-test mapping is in
tests/integ/train/shallow/README.md.MLflow is worth calling out: every
*_complete_workflowdeep test configures it, soRecipeTrainerCasescovers both forms — experiment/run names (always runs) andmlflow_resource_arn(skips when the account has no app).Not covered, and why:
test_benchmark_evaluator.py,test_custom_scorer_evaluator.py,test_mtrl_evaluator_3p_agent.py,test_mtrl_trainer_integration.py) —evaluate()is a different API surface returning pipeline executions rather than jobs, so it needs its own harness support. Alreadygpu_intensiveon master, so this PR loses no coverage there. Clearest follow-up, and per "What bounded theinteg-testsjob" below it is also what would let the six newly marked evaluator tests be covered rather than merely deselected.CreateTrainingJob.test_cpt_trainer.pyis written in the shallow style and activates whenSHALLOW_HYPERPOD_CLUSTERis set.This PR newly marks 16 tests, in two groups:
test_model_trainer.py,test_sft_trainer_lora_with_sequence_length, andtest_tuner_includes_sm_drivers_channel. These satisfy the rule above outright.integ-testsjob" below. These do not have a shallow counterpart yet; the reasoning for marking them anyway, and what coverage that costs, is spelled out there and in the suite README rather than glossed over.Everything else listed above was already marked on master.
Deliberately not marked:
test_notifications.py(EventBridge/SNS side effects) andtest_local_model_trainer.py(no service call, but it does run real containers).The rule is documented in the suite README so a future change cannot silently erode the gate.
tox.ini: widened thegpu_intensivedescription. Despite the name it gates anything consuming real training capacity, including serverless and CPU-instance jobs.Fixtures that look up rather than create
mlflow_arn,reward_lambda_arnandreward_evaluatoronly look their resources up and skip when absent. The deep suite's equivalents create them — IAM roles, Lambdas, MLflow apps, registry entries — which is a durable side effect a fast PR-gate suite should not have.Workflow change
Two things, and the first is smaller than it sounds:
1.
integ-testsis untouched. The job definition is byte-identical to master —sagemaker-trainis still in its matrix and still runs the full CodeBuild suite. What changes is what that suite selects: its buildspec already filters-m "not gpu_intensive and not us_east_1", so the marks this PR adds are what take the expensive tests off the gate. No workflow edit was needed for that, and no client-side coverage is lost — the CodeBuild job runs the wholetests/integtree, so the ~170 tests that make no service call keep running exactly as before.2. New
fast-integ-testsjob, scoped totests/integ/train/shallowonly, running directly on the runner:84 of the suite's 100 tests; the 16 deselected are the 5
us_east_1Nova cases (this job holds us-west-2 credentials only — they run ininteg-tests-us-east-1) and the 11gpu_intensiveCPT/MTRL cases, which are written in the shallow style but need a HyperPod cluster and an agent runtime respectively.Deliberately not widened to the whole
tests/integ/traintree: the CodeBuild job already covers the client-side tests, so widening would duplicate them and double the training jobs this suite creates. Separate job rather than folded into the buildspec because the buildspec is CDK-managed outside this repo, while this selection is reviewable in the PR that changes it — and because a shallow failure then reports as its own check, distinguishable at a glance from a deep-suite failure.Concurrency cap (
SHALLOW_MAX_CONCURRENT_JOBS)Creating a job per test puts the suite against the training-job concurrency quota — specifically "Maximum number of concurrent model customization serverless jobs per Region" (20 in the test account), which counts serverless jobs; the default recipe-trainer path (
compute=None) submits serverless. Serverful jobs (ModelTrainer, the tuner,test_explicit_compute_is_accepted) are instead bounded by the per-instance-type quota, counted in instances. A slot is one concurrent job, plus one per additional instance for a serverful job, so a single cap (default 10) is safe against both.The subtle part — and I got it wrong at first. The service counts a job against the quota from
CreateTrainingJobuntil it reachesCompleted/Failed/Stopped, not untilStopTrainingJobreturns. Those are far apart:stop()returns in a few seconds, but the job takes ~1–3 min to actually drain (measured; the reservation is torn down without ever becoming billable). My first cut released the slot atstop(), which bounded nothing — with cap 10 and 8 workers each slot recycled ~20× inside one job's counted lifetime, the suite peaked at ~37 concurrent jobs and trippedResourceLimitExceededat utilization 21 against the limit of 20.The fix holds the slot until the job is terminal (
_wait_until_terminal, polling the job's status per type). That makes the cap bound what the service actually counts — this is the "batches of 10" behaviour: at most 10 jobs counted at once. Cost is runtime: the floor becomes roughly(#jobs × drain) / cap, so ~13 min at cap 10 versus ~2 min for the (quota-breaching) early-release version. Both waits degrade to a logged warning rather than a failure, since the cap is a courtesy to the quota, not an assertion about the SDK. Enforced in the harness, so a new test is capped by default.SHALLOW_MAX_CONCURRENT_JOBS=0disables it for single-worker debugging.Verification
Run against a real account. The shallow suite alone (us-west-2):
Zero
ResourceLimitExceeded, and live counted concurrency held at 4–5 against the cap of 10 for the whole run (account fully drained afterward). The 13:30 is the cost of holding each slot until its job is terminal — see the concurrency-cap section; the earlier ~3m33s figure was the early-release version that breached the quota. TheSimulatePrincipalPolicymemoization described below still applies underneath.The skip is RLAIF's serverful case, which reports its own reason:
RLAIFTrainer takes no compute argument.The five
us_east_1tests, run separately with us-east-1 credentials:Those five had never executed before this PR — see the bug list below.
Also run: the whole
tests/integ/traintree under the same selection the CodeBuild job uses (-m "not gpu_intensive and not us_east_1",-n 8 --dist loadfile) — 198 passed, 9 skipped, 0 failed. Skips all report their own prerequisite: Docker absent locally (5), RLAIF's serverful case, and three pre-existingskip/prerequisite cases. That run predates the marker changes below, so it includes the five us-west-2 evaluator tests now deselected; it is the "before" measurement, and the runtime caveat in the next section is what came out of it.Marker selection after this PR, verified by collection: 266 of 342 collected, 76 deselected, none of the six newly marked evaluator tests among them, and no
PytestUnknownMarkWarningremaining.What bounded the
integ-testsjob (pre-existing, now fixed here)Not the shallow suite, and not the new job —
fast-integ-testsis the 5-minute one. This is about the CodeBuild job, and it is worth stating plainly because I got it wrong earlier in this PR's own description.CI proved it on this branch. From the gate's own run, the serial pass durations:
88 minutes for two tests, against the project's 180-minute build timeout, and they were the entire tail — everything else in that pass finished in under 92s. Six tests block on
execution.wait(..., timeout=14400), a 4-hour ceiling each, and none of them was marked:test_llm_as_judge_base_model_fix.py::test_base_model_evaluation_uses_correct_weightsserialgpu_intensive(class)test_llm_as_judge_base_model_fix.py::test_base_model_false_still_worksserialgpu_intensive(class)test_benchmark_evaluator.py::test_benchmark_evaluation_full_flowgpu_intensivetest_custom_scorer_evaluator.py::test_custom_scorer_evaluation_full_flowxdist_groupgpu_intensivetest_llm_as_judge_evaluator.py::test_llm_as_judge_evaluation_full_flowgpu_intensivetest_llmaj_custom_model.py::TestLLMAJCustomModelIntegration@pytest.mark.slow— unregistered, silent no-opgpu_intensiveThe last row was a genuine mismarking: the registered name is
slow_test, so@pytest.mark.slowdid nothing and only raised aPytestUnknownMarkWarning.us_east_1already kept it off the us-west-2 gate, so marking it changes nothing there; it now stays off the us-east-1 job too.An earlier revision of this description said I deliberately would not mark these, on the grounds that they have no shallow counterpart. The 88-minute measurement changed my mind, and it is a real trade rather than a free win, so here is what it costs. Three of the files are marked per-test and keep their constructor/validation tests on the gate — that is where SDK-side regressions surface. The two class-level ones leave nothing behind, and what the gate stops checking there is that a submitted evaluation pipeline is accepted and succeeds. Their already-marked siblings (
test_benchmark_evaluation_base_model_only,test_custom_scorer_base_model_only) show this was already the established call for pipeline-waiting tests — these six were unmarked by omission, not by decision. Shallowevaluate()coverage is the follow-up that closes the gap properly.fast-integ-testsexisting as its own check is the other half of the answer: it returns in minutes regardless of what the deep suite is doing.Also fixed:
SimulatePrincipalPolicythrottlingThe same CI run failed four shallow tests, and they were not test defects:
All four pass locally in isolation and under a local
-n 36run. EveryModelTrainerconstruction callsTrainDefaults.get_role→resolve_and_validate_role, which paginatesSimulatePrincipalPolicyover ~20 action names against a low, account-wide TPS limit. CodeBuild runs the whole tree under-n auto(~36 workers on a 2XLARGE) — 188 trainer constructions, enough to exhaust even the adaptive 10-attempt budget the existing retry fixture grants. The cause is volume, not burstiness, so more retries would not have helped.Fixed with a
_memoize_role_validationautouse session fixture: each distinct(role, role_type, region)is validated once per xdist worker instead of once per test. Measured with an instrumentedbotocore_make_api_call— 3 trainers → 3 calls unpatched, 10 trainers → 1 call memoized. Exceptions are cached alongside successes so a bad role still fails (test_unassumable_role_is_rejectedstill passes), and teardown restores any caller now holding the memoized function rather than only the ones the fixture explicitly patched, since a module imported after the patch binds it at its own import time.Cost model measured, not assumed. Across ~110 training jobs created by these runs, in both us-west-2 and us-east-1, every one ended
Stoppedand everyBillableTimeInSecondswasnull— jobs are torn down while still inStarting/Pending, before instances become billable.Real bugs the AWS runs found in these tests (all wrong assumptions on my part, not service problems), each now fixed with the evidence recorded in a comment:
ServerlessJobConfig.SequenceLength, so those requests failed client-side before reaching the service — fixed with anAWS_DATA_PATHfixture mirroring the one intest_recipe_override_integration.pybase_job_name, so two tuner tests in the same second collided withResourceInUseModelTrainer.__init__validates the execution role, so a bad role is rejected at construction and never reachesCreateTrainingJobRLAIFTrainertakes nocompute;CPTTrainertakes notraining_typeand requires HyperPod4Ksequence length and no serverless recipe forTrainingType.FULLGSM8k scoring failedlist_mlflow_appsis not a paginatable operationus_east_1tests had never actually run. They referenced a bucket hardcoded to one test account (s3://sagemaker-us-east-1-784379639078/...), which other accounts cannot read —AccessDeniedonListObjectsV2from 729646638167. They now derive every path fromdefault_bucket()and resolve the reward function from the caller's own hub, the waytest_sft_trainer_serverful_smtj.py::training_resourcesalready does. All five pass.Model package group ARN region 'us-west-2' does not match expected region 'us-east-1', so the Nova files use a bare name that resolves per-sessiongpu_intensiveandus_east_1were declared only intox.ini, but pytest reads its config frompyproject.tomlhere, so both markers were unregistered at runtime. That matters when the gate selects with-m "not gpu_intensive and not us_east_1": a typo'd marker name would silently put an expensive deep test back on the gate instead of warning. Now registered where pytest reads them.What this deliberately does NOT cover
Training behaviour: no model artifacts, no metrics, no container logs, no convergence.
A regression that breaks training itself — a bad entry script, a broken container command, a distributed-launch bug — will pass this gate and be caught by the scheduled suites instead. That is the accepted trade for the runtime and cost reduction, and it is stated plainly in
tests/integ/train/shallow/README.md.Also out of scope for this pattern: HyperPod (submits to a pre-provisioned cluster, not
CreateTrainingJob) and local container mode (test_local_model_trainer.py— no service call, but it runs real containers, and it stays on the CodeBuild job which already has Docker).Known remaining gap: evaluator
evaluate()submissions. Same pattern applies — assert the pipeline execution ARN comes back without waiting for the pipeline to finish — but it is a distinct API surface, so it is left for a follow-up. Until then the gate checks that evaluators construct and validate correctly, but not that a submitted pipeline is accepted.Note for reviewers
Because this workflow triggers on
pull_request_target, CI runs the workflow definition from the base branch, not this PR's. So thefast-integ-testsjob added here does not appear in this PR's own checks; it starts running once merged. The test selection was verified locally (numbers above); the job definition itself is unproven in CI.integ-tests (sagemaker-train)does run on this PR, as it will after merge, and it runs this branch's test code — which is how the 88-minute evaluator tail and theSimulatePrincipalPolicythrottling were both found. What it runs against is master's workflow definition, so the new job's absence from these checks is expected; the marker changes, being in the test files themselves, are exercised on the next run of this PR.