Skip to content

[multi-lora] 1/n operation backend: explicit training with Tinker compatibility - #2273

Open
yushengsu-thu wants to merge 94 commits into
mainfrom
tinker-compatible-backend
Open

[multi-lora] 1/n operation backend: explicit training with Tinker compatibility#2273
yushengsu-thu wants to merge 94 commits into
mainfrom
tinker-compatible-backend

Conversation

@yushengsu-thu

@yushengsu-thu yushengsu-thu commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Summary

Replaces the old dataset-driven Multi-LoRA path with a service-only, client-driven operation backend. Multiple clients share one Megatron base model through fixed LoRA slots and explicitly control forward/backward, optimizer, checkpoint, and sampler-publication boundaries.

This PR provides the backend and Ray-side operation contract. Stacked PR #2346 adds the official tinker==0.24.1 JSON/REST frontend, and #2365 adds the complete client-owned SFT/RL guide.

Reviewer

Tinker SDK client
    │
    │ JSON/REST translation (#2346; not part of this PR)
    ▼
TinkerFrontendHTTPServer (#2346)
    │ calls the backend inside the head-node controller actor
    ▼
MultiLoraOperationController
    └── MultiLoraOperationBackend
        ├── AdapterRegistry
        │   └── fixed slot for (name, registration_id)
        ├── OperationLedger
        │   └── ordering, retries, gaps, ACK/backpressure, and fencing
        ├── FixedSlotResidency
        │   └── claim-and-bind → immutable BatchExecutionLease
        ├── GradientWindowTracker
        └── RouterInferenceAdmin

train_multi_lora_operations.py
    ├── DATA: forward / forward_backward
    │   └── MultiLoraOperationBatchFn
    │       ├── claim one whole client batch per ready registration
    │       ├── coalesce only the same operation kind
    │       ├── acquire one BatchExecutionLease
    │       ├── RolloutManager conversion + zero-weight DP padding
    │       └── Megatron forward[/backward]
    │           └── per-operation results → commit or poison
    │
    └── CONTROL
        ├── optim_step
        │   └── run_optim_controls(ParameterExecutor)
        │       ├── MultiLoraParameterExecutor               [wired]
        │       │   ├── validate registration + physical slot from lease
        │       │   ├── discard a poisoned gradient window
        │       │   └── slot-sorted, per-call Adam step
        │       │       ├── all-rank non-finite veto
        │       │       └── norm-blind veto + gradient cleanup
        │       │
        │       └── FullParameterExecutor                    [seam only;
        │                                                     not wired]
        │
        └── save_state / load_state / save_weights_for_sampler
            └── Multi-LoRA trainer handlers
                ├── adapter + optimizer state shards
                ├── ownership/shape-fenced restore
                └── ActorGroupWeightUpdater → SGLang
                    └── publish completes only after weights are live

MultiLoraParameterExecutor is deliberately narrower than the whole backend: it owns optimizer step/discard for lease-bound adapter slots. Data conversion and forward/backward live in MultiLoraOperationBatchFn; checkpoint and publication remain Multi-LoRA trainer controls. create_rollout_components() also exposes separate InferenceControllerPort and RolloutExecutorPort roles. Today they are two adapters over the same combined RolloutManager; the physical controller/executor split remains future integration work.

Operation semantics

Supported operations are:

  • forward
  • forward_backward
  • optim_step
  • save_weights_for_sampler
  • save_state
  • load_state

Important invariants:

  • Operations execute in strict ordinal order per registration, while out-of-order arrival is gap-buffered.
  • Identical retries are idempotent; reused identities with different content are conflicts.
  • (name, registration_id) prevents stale handles from targeting a re-registered adapter with the same display name.
  • Claim-and-bind produces one immutable execution lease, which trainer ranks validate before physical mutation.
  • forward_backward calls accumulate one client-owned gradient window. A failed batch poisons that window, and the next optim_step discards it instead of applying partial gradients.
  • Results remain available until ACK; capacity pressure rejects new work instead of evicting terminal results.
  • Sampler publication completes only after staged weights are live.

Sampling, scoring, rewards, advantages, batch scheduling, and SDK Datum
construction remain client-owned.

Full-parameter seam

The reusable seam is intentionally narrower than the complete backend:

  • BatchExecutionLease
  • TrainerResidencyPort
  • ParameterExecutor
  • run_optim_controls

FullParameterExecutor implements singleton whole-model Adam step/discard
behavior against a stock Megatron optimizer. It is not connected to launch
configuration, registration, data conversion, forward/backward execution,
checkpointing, controller routing, or sampler publication. This PR therefore
does not claim working full-parameter SFT/RL or full-parameter GPU/E2E support.

Validation

Current head 7189b1e54 has:

  • 29 successful GitHub checks, 2 expected skips, and no failures or
    pending checks.
  • CPU test shards, pre-commit, and CodeQL.
  • 2/4/8-GPU H200, 8-GPU H100, and 4-GPU MI350 CI coverage.
  • Development-time 2×H200 acceptance covering Qwen SFT/RL, GPT-OSS
    multi-adapter training/checkpointing, sampler publication, restore,
    registration fencing, and slot reuse.

Limits

  • Text-only, 1-D shifted targets with cross_entropy,
    importance_sampling, or ppo.
  • Fixed adapter slots; no eviction, idle-slot GC, or per-tenant quota.
  • Megatron backend with Adam-family per-slot optimization.
  • Disaggregated execution only; pipeline parallelism must be 1 and
    qkv_format must be thd.
  • Latest-only sampler publication; no immutable version-pinned snapshots.
  • Restore rejects incompatible world topology, LoRA shape, or per-rank
    optimizer ownership.
  • Recovery of work lost after claim during downstream process death is not
    guaranteed; executor-side reconciliation remains future work.

Stack and dependencies

0821 liveness fixes: capped tolerance for consecutive generate failures (--multi-lora-max-consecutive-generate-failures), a claimed-operation TTL backstop that unblocks orphaned CLAIMED queue heads (--tinker-operation-claimed-ttl), and FAILED child runtimes self-healing to IDLE after a cooldown.

  • 0821: fixed the publish-path function-local import stranded by the api_backends regrouping (update_weight .../mixin.pyapi_backends.multi_lora.model) + tests/fast/test_import_integrity.py static import-integrity regression; 2-GPU mini-loop re-verified publish+sample (PASS).

  • 0822 test cleanup: launch-unreachable multi-LoRA tests removed (stamped-slot fallback deleted from production, now a loud ValueError), duplicate trim test dropped, sglang abort regression moved to its own PR [multi-lora] 1/n - 7, test: pin update_weight_version abort_all_requests=False regression #2713, RL-quality harness now exits non-zero on aborted loops.

@yushengsu-thu yushengsu-thu changed the title tinker-backend-draft: tinker-compatible operation backend for multi-LoRA [WIP] tinker-backend-draft: tinker-compatible operation backend for multi-LoRA Aug 8, 2026
@yushengsu-thu
yushengsu-thu force-pushed the tinker-compatible-backend branch 6 times, most recently from f2e60eb to 7346ed0 Compare August 8, 2026 20:32
@yushengsu-thu
yushengsu-thu marked this pull request as ready for review August 10, 2026 03:55
…ate serialization

Sample gains optional per-token float channels (loss_weights, advantages)
for client-supplied training data: response-aligned like loss_mask, merged
across turns like the OPD lists (zeros over injected observation spans),
carried on the wire as float32 typed_ragged, CP-sliced like
rollout_log_probs. The binary int32 loss_masks stay untouched.

miles/backends/megatron_utils/tinker_backend/checkpoint.py holds the slot
training-state serialization for the tinker-compatible backend: bf16
adapter weights + positional per-child optimizer state (fp32 masters, Adam
moments, step counters), per-rank atomic shards, rank-0 manifest committed
after a barrier, optional manifest ttl_seconds, and named immutable states
at states/{tag}. Loading fences on format, world topology, and LoRA
rank/alpha shape — never on the display name, so a new registration may
restore another run's state (create-from-checkpoint).

Provenance: #2242 data-channel and checkpoint commits, renamed to the
tinker namespace, minus swap-in/out (they belong to the residency layer).
@yushengsu-thu
yushengsu-thu force-pushed the tinker-compatible-backend branch from d8ec792 to a3d8038 Compare August 10, 2026 04:38
@yushengsu-thu yushengsu-thu changed the title [WIP] tinker-backend-draft: tinker-compatible operation backend for multi-LoRA [multi-lora] tinker-backend-draft: tinker-compatible operation backend for multi-LoRA Aug 10, 2026
@yushengsu-thu yushengsu-thu changed the title [multi-lora] tinker-backend-draft: tinker-compatible operation backend for multi-LoRA [multi-lora] tinker-backend: tinker-compatible operation backend for multi-LoRA Aug 10, 2026
@yushengsu-thu
yushengsu-thu force-pushed the tinker-compatible-backend branch 2 times, most recently from f442377 to f969743 Compare August 10, 2026 23:13
Fixed residency: a registration binds the lowest free slot for its whole
life or queues behind a full pool (bootstrap drains the queue at
retirement); there is no eviction, no bind-at-selection, and therefore no
reservation transactions — tenancy changes only on the driver-sequenced
register/deregister path. Pins mark slots whose state is immovable
(dirty-grads: accumulated gradients no checkpoint carries).

The run lifecycle is PENDING -> READY -> RETIRING -> CLEANUP -> COMPLETED,
where READY comes from the trainer finishing the slot load — never from a
weight publish: serving is a separate axis (serving_version stays 0 until
save_weights_for_sampler) and record_weight_update no longer promotes.
commit_tinker_step advances the per-run step clock, releases the dirty
pin, and honors the optional client-set num_step bound; set_step
repositions the baseline for state resume.

AdapterRunConfig is the client-driven minimum: rank (server ceiling
--lora-rank), optional save/num_step/metadata; alpha is server-resolved
and never client-settable.

Provenance: #2137 slot pool/registry reworked for fixed residency and
readiness/serving decoupling; #2242 tinker lifecycle methods.
…tries, strict execution order

One registration is strictly serialized: an operation is claimable only
when every earlier ordinal has ARRIVED and reached a terminal state, which
carries the client's per-model ordering end to end and keeps an optim_step
from ever overtaking its forward_backward batches.

Arrival may be out of order — the tinker SDK deliberately posts the first
chunk of a large forward_backward last — so operations buffer by ordinal
(consecutive from 1 per registration) and a gap below the head blocks all
claims until it fills. NOTE: this reorder buffer moves to the tinker
frontend when one lands.

Retries are fingerprinted (sha256 over kind + canonical payload):
re-enqueueing a known operation_id with identical content returns the
original operation; different content is a conflict error, never silently
swallowed. Cancel applies to QUEUED only and the cancelled ordinal still
counts for contiguity; retirement fences open operations; terminal results
are retained until acked (enqueue backpressure — mapped to HTTP 429 — is
the capacity knob, never result eviction).

Provenance: #2242 operation ledger + the arrival/fingerprint upgrades from
the design review.
The gap-timeout sweep only terminalizes never-arrived QUEUED ordinals;
an orphaned CLAIMED head (e.g. a restarted rollout executor whose
in-memory runtimes vanished after claiming) blocked its registration's
queue forever with no timeout, starving the adapter until deregister.

The ledger now stamps claimed_at (monotonic) on the QUEUED->CLAIMED
transition, and the backend's sweep heartbeat (control claims,
operation_view, service_info) terminal-fails over-age CLAIMED
operations with a typed server error naming the operation and its age,
routed through fail_tinker_batch — the existing idempotent finalizer
that fails only still-CLAIMED operations and releases a batch lease in
its finally. --tinker-operation-claimed-ttl configures the TTL
(default 1800s: generous because legitimate train steps hold CLAIMED
for minutes; <= 0 disables). complete_control_operations now skips
already-terminal operations so a late completion racing the sweep is
ignored instead of crashing the driver.
A transient child claim failure parked the runtime in FAILED forever:
the launch pass only targeted IDLE, so the adapter never claimed again
and starved until deregister. The runtime now records last_failure and
the launch pass flips FAILED back to IDLE once a fixed 5s cooldown
elapses, so one bad claim round costs one cooldown instead of the
registration.
ray.exceptions.RayTaskError.__str__ reads traceback_str; without it the
driver's tolerated-failure logging path blew up inside the test fake
instead of exercising the cap logic.
…structure

The api_backends regrouping moved megatron_utils/multi_lora under
api_backends/, but the function-local import in the distributed weight-push
mixin still targeted the old layout. save_weights_for_sampler crashed at
runtime on the multi-LoRA publish path while every CPU gate stayed green,
because the import only executes inside _send_one_multi_lora_adapter.
Walk the restructured namespaces (api_backends, ray/multi_lora,
rollout/multi_lora, and the frontend package where present) and import
every module, then AST-resolve every miles.* import site in miles/ and
examples/ — module-level and function-local alike — against the source
tree. Function-local imports on the publish path never execute under CPU
gates, so a rename that strands one is invisible until a GPU run; this
makes the whole class fail fast in tests/fast.
The importlib leg of the import-integrity test pulled every miles.* target
under update_weight/, and one of them imports mooncake at module level —
absent on hosted CPU CI (and the gate venv). A missing non-miles module is
an environment gap, not the stale-layout regression this test pins, so only
a miles-module ModuleNotFoundError fails now; the static AST leg still
verifies every import site unconditionally.
parse_adapter has no production caller anywhere in the stack (its only
reference was its own round-trip test); every sibling in identity.py is
production-wired. Found by the zombie-CI audit.
…ombiner contract test runs

The D12 contract test (test_metrics_contract.py::test_sdk_combiner_merges_our_chunked_metrics)
importorskips on the tinker wheel; without the pin the backend CI never
exercised it. Matches the frontend branch's existing pin.
…le tests

Multi-LoRA requires --tinker-backend at launch and the tinker rollout fn
always attaches the batch execution lease, so the else-branch that trusted
per-sample stamped slots (and the two tests exercising it: stamped-slot
fallback, non-tinker heterogeneous reward normalization) can never run in
production. Convert now fails loudly when an adapter-stamped batch arrives
without a lease, a new guard test pins that, and the one legacy-channel
test that stamped adapters incidentally now uses plain samples as
production legacy batches do.
test_rollout_data_conversion.py::test_unaligned_input_is_trimmed_to_multiple
already pins the same trim-to-multiple behavior on the same production
branch; keeping a second copy in the padding suite adds maintenance cost
without coverage.
The abort_all_requests=False behavior was introduced on main (#2589), not
by this stack, so its regression test belongs in a standalone test-only PR
against main rather than riding the tinker backend; the file returns to
its main-tree content.
_thread_main records a dead loop in run.error and the harness previously
still exited 0 after printing the summary, so a wrapper (or a human
checking $?) would read an aborted run as a pass; the summary and CSVs
still land first, then the process fails if any loop aborted.

@yushengsu-thu yushengsu-thu left a comment

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.

clean

Comment thread miles/backends/megatron_utils/api_backends/multi_lora/executor.py Outdated
Comment thread miles/backends/megatron_utils/api_backends/multi_lora/optimizer.py Outdated
Comment thread miles/backends/megatron_utils/api_backends/multi_lora/optimizer.py Outdated
Comment thread miles/backends/megatron_utils/api_backends/multi_lora/optimizer.py Outdated
Comment thread miles/ray/multi_lora/inference_admin.py Outdated
Comment thread miles/utils/arguments.py Outdated
Comment thread miles/utils/arguments.py Outdated
Comment thread miles/utils/arguments.py Outdated
Comment thread miles/utils/multi_lora.py Outdated
Comment thread miles/utils/multi_lora.py Outdated
@yushengsu-thu yushengsu-thu added the run-ci-lora-native Run native (raw-mode) LoRA plugin e2e tests label Aug 23, 2026
All 35 sites flagged by review 5003387723 on #2273: each multi-line
comment, docstring, assert/error message, or argparse help string is
now a single line keeping the load-bearing invariant; no behavior
change (test-matched substrings preserved).
@yushengsu-thu

Copy link
Copy Markdown
Collaborator Author

@claude review

Comment thread miles/ray/multi_lora/operations.py
Comment thread docker/Dockerfile
Unacked terminal results of a retired registration lived in the ledger
forever (probe: 50 dead registrations retained 14.1 MB, monotonic).
fence() now strips request payloads (fingerprints keep retry identity,
results stay pollable), and evicting a COMPLETED record from the
registry ring fires drop_tenant, purging the tenant's queue and by_id
entries. The eviction slice also clamps at zero so under-cap rings no
longer evict early. A dropped operation polls as None; the frontend
already maps missing operations to typed tombstones.
…uted sync

LoRA sync sends only adapter tensors and never refills base weights;
opening the session anyway makes begin/end_weight_update restore and
re-pack the quantized base buffers with nothing loaded in between,
corrupting the frozen base (reproduced on Kimi-K2.5 W4A16, TP8).
Also re-adds the update_weight_version abort_all_requests=False wire
pin so main #2589's no-abort behavior cannot silently regress.
Absorbed from closed PRs #2715 and #2713.
Move the import-integrity checks out of the fast suite and into
tests/ci/verify_source_resolution.py, which every CPU and GPU CI job
runs before pytest: statically resolve every miles-internal import
site (including function-local ones) across miles/ and examples/,
walk the optional namespaces in full when present, and import the
update_weight lazy-import targets. Failures raise RuntimeError with
the offending file:line and import target.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

run-ci-lora run-ci-lora-native Run native (raw-mode) LoRA plugin e2e tests run-ci-megatron

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant