Skip to content

feat(weight_transfer): add trainer-inference weight sync with M2N and 2-D TP sharding#481

Open
ganeshku1 wants to merge 1 commit into
mainfrom
ganeshku/weight-transfer-trainer-inference-sync
Open

feat(weight_transfer): add trainer-inference weight sync with M2N and 2-D TP sharding#481
ganeshku1 wants to merge 1 commit into
mainfrom
ganeshku/weight-transfer-trainer-inference-sync

Conversation

@ganeshku1

@ganeshku1 ganeshku1 commented Jul 5, 2026

Copy link
Copy Markdown
Member

Summary

  • Adds a modular weight_transfer sub-package for trainer → inference weight sync with two transfer modes: PULL (inference workers read from trainer) and PUSH (trainer writes to inference workers)
  • Implements 2-D tile sharding for TP × FSDP hybrid trainers: row-parallel layers (o_proj, down_proj) are column-sharded so each TP rank holds a contiguous [R, C/tp] tile
  • Adds M2N many-to-many transport via the MX server as a barrier coordinator: all workers register simultaneously, the server routes once, each worker gets its descriptor slice
  • Includes LocalPlanner (pure-math, client-side) and ServerPlanner (offloads routing to MX WeightSyncService in Rust) with automatic fallback

Tests

88 tests, 0 GPU required for CI (pure-Python stubs):

File Tests Coverage
test_rl_weight_sync.py 14 RL loop lifecycle, GRPO multi-worker, MoE EP, M2nPlanner fallback
test_e2e_nccl_m2n.py 22 M2N descriptor accounting, wire-protocol roundtrip, TP=2 routing
test_e2e_primerl.py 22 Full bake→resolve→route pipeline, PrimeRL sync lifecycle
tests/gpu/test_e2e_primerl_real_model.py 17 Real Qwen/Qwen2.5-0.5B (290 params, 988 MB bfloat16), 2× CUDA GPU

Real-model GPU tests (tests/gpu/, skipped in CI, run on devdesktop with 2× RTX 6000 Ada):

  • FSDP: byte-exact transfer of all 290 params
  • TP=2: 48 row-parallel params col-sharded across both agents, verified byte-exact
  • 3-step RL loop: noise injection → sync → verify → plan invalidate

Test plan

  • python -m pytest tests/test_rl_weight_sync.py tests/test_e2e_nccl_m2n.py tests/test_e2e_primerl.py — 58 tests, no GPU
  • python -m pytest tests/gpu/test_e2e_primerl_real_model.py — 17 tests, requires 2× CUDA GPUs
  • CI Python Tests check passes

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added trainer-to-inference weight sync support, including live weight updates, coordinated routing, and multi-worker plan handling.
    • Added support for both pull-based and push-based sync flows, with caching and automatic plan refresh when weights change.
    • Expanded configuration options for sync timeouts and server/Redis endpoints.
  • Bug Fixes

    • Improved routing for both row-sharded and 2D-sharded model weights to better match tensor-parallel layouts.
  • Tests

    • Added broader end-to-end coverage for routing, sync behavior, and descriptor serialization.

@copy-pr-bot

copy-pr-bot Bot commented Jul 5, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

… 2-D TP sharding

- Protocol types: TrainerShard, TrainerTable, TrainerTensor, ResolvedRegion
- 2-D tile sharding (TP x FSDP) with col_end=-1 for row-only backward compat
- route_regions() pure-math router for RDMA descriptor generation
- LocalPlanner and M2nPlanner with server coordination and fallback
- PullRole and PushRole for bake/resolve/plan/execute lifecycle
- NixlExecutor and M2nExecutor for RDMA transport
- BakeRecorder / LazyWeight for op chain recording
- MoEAdapter for stacked expert weight expansion
- 58 pure-Python e2e tests; 17 real-model GPU tests (Qwen2.5-0.5B)

Signed-off-by: Ganesh Kudleppanavar <ganeshku@nvidia.com>
@coderabbitai

coderabbitai Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This PR adds a full trainer-inference weight synchronization system using NIXL RDMA. It introduces protocol types, lazy-weight baking/resolution, region routers, Local/Server/M2N planners, Pull/Push sync roles, transport executors, a new gRPC WeightSyncService (proto + Rust server implementation), client load-strategy integration, documentation, and extensive tests.

Changes

Trainer-Inference Weight Sync

Layer / File(s) Summary
Proto schema and Rust service wiring
modelexpress_common/proto/weight_sync.proto, modelexpress_common/build.rs, modelexpress_common/src/lib.rs, modelexpress_server/src/lib.rs, modelexpress_server/src/server.rs, modelexpress_server/src/weight_sync.rs
Defines WeightSyncService RPCs/messages and wires generated protobuf code into the crates and server binary.
Rust server routing and service impl
modelexpress_server/src/weight_sync/router.rs, modelexpress_server/src/weight_sync/service.rs
Implements pure-math region routing (route_regions, route_all_workers) and WeightSyncServiceImpl with plan caching and M2N barrier coordination.
Python protocol types, ops, serialization
.../weight_transfer/protocol/types.py, .../protocol/ops.py, .../protocol/serialization.py, .../protocol/__init__.py, .../weight_transfer/__init__.py, .../protocol/test_weight_transfer.py
Defines dataclasses for trainer/inference tables, shards, descriptors, and JSON encode/decode functions with re-exports and tests.
LazyWeight baking and resolver
.../weight_transfer/engine/lazy.py, .../engine/base.py, .../engine/__init__.py, .../planner/resolver.py
Captures weight copies during model load via LazyWeight/BakeRecorder, and resolves op chains into ResolvedRegion.
Region routing and MoE adapter
.../weight_transfer/protocol/router.py, .../planner/router.py, .../adapters/*
Converts ResolvedRegion into RDMA descriptors for row-only and 2D tile sharding, and adapts MoE stacked-expert weights.
Planners: Local/Server/M2N
.../weight_transfer/planner/{base,local,server,m2n_planner,__init__}.py, tests/test_m2n.py
Implements planner abstraction with caching, server delegation, and coordinated M2N barrier flow with fallback.
Sync roles and NIXL transport
.../weight_transfer/roles/*, .../transport/*
Implements PullRole/PushRole lifecycles and NixlExecutor/M2nExecutor RDMA READ/WRITE execution.
Client TrainerPullStrategy integration
.../envs.py, .../load_strategy/__init__.py, .../load_strategy/trainer_pull_strategy.py
Adds env vars and a load strategy that fetches the trainer table and drives PullRole.
Docs and test suites
docs/ARCHITECTURE.md, pyproject.toml, tests/*
Documents the protocol/planner semantics and adds broad E2E/integration and GPU test coverage.

Estimated code review effort: 5 (Critical) | ~150 minutes

Poem

A trainer hops with weights in tow,
RDMA whiskers twitch and glow,
🐇 Plans get cached, then pulled with care,
M2N barriers wait, then share,
Through burrows deep the bytes now flow —
Hop hop, sync complete, off we go!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: trainer-inference weight sync with M2N and 2-D TP sharding.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@ganeshku1
ganeshku1 force-pushed the ganeshku/weight-transfer-trainer-inference-sync branch from c41d4b0 to 9fb93e3 Compare July 5, 2026 04:39

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 20

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
modelexpress_client/python/modelexpress/load_strategy/__init__.py (1)

64-76: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Gate TrainerPullStrategy behind an explicit opt-in

is_available() only checks NIXL/RDMA/mx_client, but load() always waits up to MX_TRAINER_SYNC_TIMEOUT for a TrainerTable. MX_WEIGHT_SYNC_SERVER only changes the planner; it does not stop this strategy from being tried. In ordinary RDMA deployments that satisfy those checks, startup can stall for up to 300s before falling through to RdmaStrategy.

Gate this on an explicit trainer-pull signal such as MX_TRAINER_TABLE_KEY or MX_WEIGHT_SYNC_SERVER so non-trainer-pull loads skip it immediately.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@modelexpress_client/python/modelexpress/load_strategy/__init__.py` around
lines 64 - 76, TrainerPullStrategy is being selected too broadly because
load_strategy.__init__ always includes it when is_available() passes, causing
non-trainer-pull deployments to wait in TrainerPullStrategy.load() for
MX_TRAINER_SYNC_TIMEOUT. Update the strategy selection in the load strategy
initialization so TrainerPullStrategy is only added when an explicit
trainer-pull signal is present, such as MX_TRAINER_TABLE_KEY or
MX_WEIGHT_SYNC_SERVER. Keep RdmaStrategy and the other fallbacks unchanged so
ordinary RDMA startup skips TrainerPullStrategy immediately.
♻️ Duplicate comments (1)
modelexpress_client/python/modelexpress/weight_transfer/transport/nixl_executor.py (1)

94-104: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Same handle-leak pattern as M2nExecutor.execute.

agent.transfer(handle) at Line 102 can raise after make_prepped_xfer already allocated the handle, before it's appended to handles for release — same issue flagged in transport/m2n_executor.py.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@modelexpress_client/python/modelexpress/weight_transfer/transport/nixl_executor.py`
around lines 94 - 104, The transfer path in NixlExecutor has the same
handle-leak risk as M2nExecutor.execute: if agent.transfer(handle) fails after
agent.make_prepped_xfer() succeeds, the newly created handle is never recorded
for cleanup. Update NixlExecutor.execute so the handle is protected immediately
after creation, either by appending it to handles before calling transfer or by
using a try/finally around agent.transfer(handle) that guarantees release
tracking even on failure. Use the make_prepped_xfer, transfer, and handles logic
in NixlExecutor.execute to locate the fix.
🧹 Nitpick comments (14)
modelexpress_client/python/pyproject.toml (1)

68-73: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

gpu marker defined but unused.

tests/gpu/test_e2e_primerl_real_model.py gates itself with pytest.mark.skipif(not _TWO_GPUS, ...) only — it never applies @pytest.mark.gpu. As-is, the marker can't be used for CI selection (pytest -m "not gpu"), and skipif already causes a module-level skip only after import, whereas the marker would let CI deselect it upfront.

♻️ Suggested fix
-_TWO_GPUS = torch.cuda.is_available() and torch.cuda.device_count() >= 2
-pytestmark = pytest.mark.skipif(not _TWO_GPUS, reason="requires 2 CUDA GPUs")
+_TWO_GPUS = torch.cuda.is_available() and torch.cuda.device_count() >= 2
+pytestmark = [
+    pytest.mark.gpu,
+    pytest.mark.skipif(not _TWO_GPUS, reason="requires 2 CUDA GPUs"),
+]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@modelexpress_client/python/pyproject.toml` around lines 68 - 73, The gpu
marker is declared in the pytest configuration but never applied to the GPU E2E
test, so CI cannot select or exclude it by marker. Update
tests/gpu/test_e2e_primerl_real_model.py to add `@pytest.mark.gpu` to the relevant
test/class/module alongside the existing skipif gate, using the gpu marker name
defined in pyproject.toml so pytest -m selection works as intended.
modelexpress_client/python/tests/gpu/test_e2e_primerl_real_model.py (2)

22-26: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Hardcoded, environment-specific libcudart path.

The first candidate path (/usr/local/python-3.12.6/lib/python3.12/site-packages/nvidia/cuda_runtime/lib/libcudart.so.12) is tied to one specific Python/CUDA install layout. The function does fall back to a bare libcudart.so.12 and skips gracefully if none load, so this is low risk, but consider dropping the overly-specific first path (or deriving it from torch.version.cuda/ctypes.util.find_library) to avoid stale references as environments change.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@modelexpress_client/python/tests/gpu/test_e2e_primerl_real_model.py` around
lines 22 - 26, The hardcoded first entry in the _LIBCUDART_PATHS list is too
environment-specific and should be removed or replaced with a dynamic lookup.
Update the test setup around _LIBCUDART_PATHS in test_e2e_primerl_real_model.py
to prefer a derived libcudart location using something like
ctypes.util.find_library or values inferred from torch.version.cuda, while
keeping the existing fallback and graceful-skip behavior if no library is found.

188-193: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Blind except Exception: continue silently drops unresolvable regions without logging.

If resolve_chain_region fails for a parameter, that parameter is silently excluded from the transfer plan with no diagnostic, making failures harder to root-cause (static analysis flags this as S112/BLE001).

♻️ Suggested fix
+        import logging
+        logger = logging.getLogger(__name__)
         try:
             offset, rshape, stride = resolve_chain_region(
                 copy.op_chain, torch.Size(copy.dst_shape), copy.dst_dtype
             )
-        except Exception:
+        except Exception:
+            logger.warning("Failed to resolve region for %s", copy.src_name, exc_info=True)
             continue
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@modelexpress_client/python/tests/gpu/test_e2e_primerl_real_model.py` around
lines 188 - 193, The blind exception handling around resolve_chain_region in the
parameter copy loop silently skips parameters without any diagnostics. Update
the try/except in test_e2e_primerl_real_model to catch the failure in a more
explicit way and emit a warning or debug log that includes the
parameter/op_chain context before continuing, so unresolved regions are still
visible when the transfer plan is built.

Source: Linters/SAST tools

modelexpress_client/python/tests/test_e2e_primerl.py (1)

193-199: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Tautological test — doesn't exercise PullRole.sync_and_post_process.

This test only calls a bare MagicMock() and checks its own call count; it can't detect a regression in the actual integration path where PullRole.sync_and_post_process() invokes self._adapter.post_pull_hook(model) after sync().

Consider building a minimal PullRole with mocked planner/executor/adapter and asserting adapter.post_pull_hook.call_count after real sync_and_post_process() calls, so the test verifies actual wiring rather than mock bookkeeping.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@modelexpress_client/python/tests/test_e2e_primerl.py` around lines 193 - 199,
The current test is tautological because it only increments a MagicMock and
never exercises PullRole.sync_and_post_process(). Update
test_post_pull_hook_called_per_sync to construct a minimal PullRole with mocked
planner, executor, and adapter, then call sync_and_post_process() and assert
adapter.post_pull_hook.call_count so the test verifies the real post-pull wiring
after sync rather than mock self-counting.
modelexpress_client/python/modelexpress/weight_transfer/protocol/ops.py (1)

11-27: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

OpSpec is frozen but not actually hashable.

kwargs: dict makes the auto-generated __hash__ (from frozen=True) raise TypeError on hash(dict) if anyone ever hashes an OpSpec/OpChain (e.g., as a cache/memoization key). Freezing normally implies hashability; this silently violates that contract.

🔧 Proposed fix
-from dataclasses import dataclass
+from dataclasses import dataclass, field
@@
     name: str
     args: tuple
-    kwargs: dict
+    kwargs: dict = field(hash=False)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@modelexpress_client/python/modelexpress/weight_transfer/protocol/ops.py`
around lines 11 - 27, `OpSpec` is marked frozen but still contains an unhashable
`dict` in `kwargs`, which breaks the expected hashability of the dataclass.
Update `OpSpec` in `ops.py` so its state is normalized into immutable, hashable
forms in `__post_init__` and/or provide a custom `__hash__` that hashes a stable
representation of `name`, `args`, and `kwargs`; keep `OpChain` compatibility in
mind if it relies on hashing these specs.
modelexpress_client/python/modelexpress/weight_transfer/protocol/router.py (1)

149-193: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Consider asserting src/dst element-count parity in _zip_src_dst.

If src_triples and dst_runs ever have mismatched total element counts (e.g., a bug upstream in resolve_copies), the loop silently stops once either iterator is exhausted, dropping the remainder without any error - a silent partial-copy in a weight-sync path is hard to detect and could produce a subtly wrong model.

🛡️ Proposed defensive check
     while cur_shard is not None and dst_rem > 0:
         ...
     return descriptors
+    # After the loop, both sides should be fully consumed.
+    # if cur_shard is not None or dst_rem > 0:
+    #     raise ValueError("src/dst element run totals do not match")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@modelexpress_client/python/modelexpress/weight_transfer/protocol/router.py`
around lines 149 - 193, Add a defensive parity check in _zip_src_dst so source
and destination element totals must match before building RdmaDescriptor
entries. Compute/validate the total counts for src_triples and dst_runs, and
raise an explicit error if they differ instead of allowing the while loop to
stop early and drop leftover elements. Keep the fix localized to _zip_src_dst in
router.py so upstream bugs in resolve_copies fail fast with a clear message.
modelexpress_client/python/modelexpress/weight_transfer/adapters/__init__.py (1)

7-7: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Sort __all__ (Ruff RUF022).

♻️ Suggested fix
-__all__ = ["ModelAdapter", "MoEAdapter"]
+__all__ = ["MoEAdapter", "ModelAdapter"]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@modelexpress_client/python/modelexpress/weight_transfer/adapters/__init__.py`
at line 7, The __all__ export list in the adapters package is not sorted and
triggers Ruff RUF022; update the __all__ definition in the adapters __init__
module so the exported names are ordered consistently, keeping the existing
symbols ModelAdapter and MoEAdapter but arranging them in sorted order.

Source: Linters/SAST tools

modelexpress_client/python/modelexpress/weight_transfer/__init__.py (1)

65-91: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Sort __all__ (Ruff RUF022).

♻️ Suggested ordering
 __all__ = [
-    # protocol
-    "OpSpec",
-    "OpChain",
-    "TrainerShard",
-    "TrainerTensor",
-    "TrainerTable",
-    "InferenceShard",
-    "InferenceTable",
-    "ResolvedRegion",
-    "RdmaDescriptor",
-    "SyncMode",
-    "encode_trainer_table",
-    "decode_trainer_table",
-    "encode_inference_table",
-    "decode_inference_table",
-    # engine
-    "BakeRecorder",
-    "LazyWeight",
-    "RecordedCopy",
-    "WeightLoaderAdapter",
-    # roles
-    "PullRole",
-    "PushRole",
-    # adapters
-    "MoEAdapter",
+    "BakeRecorder",
+    "InferenceShard",
+    "InferenceTable",
+    "LazyWeight",
+    "MoEAdapter",
+    "OpChain",
+    "OpSpec",
+    "PullRole",
+    "PushRole",
+    "RdmaDescriptor",
+    "RecordedCopy",
+    "ResolvedRegion",
+    "SyncMode",
+    "TrainerShard",
+    "TrainerTable",
+    "TrainerTensor",
+    "WeightLoaderAdapter",
+    "decode_inference_table",
+    "decode_trainer_table",
+    "encode_inference_table",
+    "encode_trainer_table",
 ]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@modelexpress_client/python/modelexpress/weight_transfer/__init__.py` around
lines 65 - 91, The __all__ list in weight_transfer/__init__.py is not sorted and
triggers Ruff RUF022. Reorder the exported symbols in __all__ into a consistent
sorted order, keeping the existing grouped sections if needed, and update the
list near the module-level __all__ definition so the import export order is
alphabetical and lint-compliant.

Source: Linters/SAST tools

modelexpress_client/python/modelexpress/weight_transfer/adapters/moe.py (2)

24-27: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Mutable class-level default dict is shared across instances.

_default_proj_map is a mutable dict class attribute; self._projection_map = projection_map or self._default_proj_map aliases the shared class dict when no override is passed. Any in-place mutation of self._projection_map on one instance would leak to every other instance (and to MoEAdapter._default_proj_map itself). Currently nothing mutates it, but it's a latent footgun flagged by Ruff (RUF012).

🛡️ Suggested fix
-        self._projection_map = projection_map or self._default_proj_map
+        self._projection_map = dict(projection_map) if projection_map else dict(self._default_proj_map)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@modelexpress_client/python/modelexpress/weight_transfer/adapters/moe.py`
around lines 24 - 27, The shared mutable default mapping in MoEAdapter should
not be reused directly across instances. Update the initialization path in the
class that defines _default_proj_map so self._projection_map gets its own copy
when no projection_map is provided, instead of aliasing the class attribute.
Keep the existing symbols (_default_proj_map, __init__, and _projection_map) so
the fix is localized and avoids cross-instance mutation.

Source: Linters/SAST tools


43-67: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

No validation that num_experts matches the stacked tensor's leading dimension.

adapt_lazy_weights blindly iterates range(self._num_experts) and builds a __getitem__ op for each index, regardless of lazy.shape[0]. If num_experts is misconfigured relative to the actual stacked tensor (e.g., trainer checkpoint has a different expert count), this silently constructs out-of-range indexing ops that only fail much later when the op chain is replayed against the real tensor (apply_chain/resolve_chain_region), far from the root cause.

🛡️ Suggested guard
             prefix, proj_key = m.group(1), m.group(2)
             hf_proj = self._projection_map.get(proj_key)
             if hf_proj is None:
                 yield hf_name, lazy
                 continue
+            if lazy.shape[0] != self._num_experts:
+                raise ValueError(
+                    f"{hf_name}: stacked dim {lazy.shape[0]} != configured num_experts {self._num_experts}"
+                )
             import torch
             for j in range(self._num_experts):
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@modelexpress_client/python/modelexpress/weight_transfer/adapters/moe.py`
around lines 43 - 67, The adapt_lazy_weights method in moe.py should validate
that the configured num_experts matches the leading dimension of the stacked
LazyWeight before yielding per-expert entries. Add a guard near the existing
stacked-tensor handling in adapt_lazy_weights that compares self._num_experts
with lazy.shape[0] (or the equivalent expert dimension) and raises a clear error
if they differ. Keep the fix localized to adapt_lazy_weights and use the
existing LazyWeight, self._stacked_pattern, and self._projection_map flow so
invalid __getitem__ op_chain construction is prevented early.
modelexpress_client/python/modelexpress/weight_transfer/protocol/test_weight_transfer.py (1)

59-59: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Unused unpacked variables (offset, stride) flagged by Ruff.

🧹 Suggested fix
-        offset, shape, stride = resolve_chain_region(
+        offset, shape, _stride = resolve_chain_region(
             (), torch.Size([4, 8]), torch.float32
         )
...
-        offset, shape, stride = resolve_chain_region(
+        offset, shape, _stride = resolve_chain_region(
             chain, torch.Size([8, 16]), torch.bfloat16
         )
...
-        offset, shape, stride = resolve_chain_region(
+        _offset, shape, _stride = resolve_chain_region(
             chain, torch.Size([4, 8]), torch.float32
         )

Also applies to: 67-67, 76-76

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@modelexpress_client/python/modelexpress/weight_transfer/protocol/test_weight_transfer.py`
at line 59, Ruff is flagging unused unpacked values in the weight transfer tests
where resolve_chain_region is called and only shape is used. Update the affected
test cases in test_weight_transfer.py to avoid binding offset and stride when
they are not needed, either by unpacking only the required value or by assigning
the extra values to placeholders, and apply the same cleanup to all
resolve_chain_region usages in the referenced tests.

Source: Linters/SAST tools

modelexpress_client/python/modelexpress/weight_transfer/engine/__init__.py (1)

7-7: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Sort __all__ (Ruff RUF022).

♻️ Suggested fix
-__all__ = ["WeightLoaderAdapter", "BakeRecorder", "LazyWeight", "RecordedCopy", "bake_model"]
+__all__ = ["BakeRecorder", "LazyWeight", "RecordedCopy", "WeightLoaderAdapter", "bake_model"]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@modelexpress_client/python/modelexpress/weight_transfer/engine/__init__.py`
at line 7, The __all__ export list in the engine package is unsorted and
triggers Ruff RUF022. Update the __all__ declaration in the module that defines
WeightLoaderAdapter, BakeRecorder, LazyWeight, RecordedCopy, and bake_model so
the exported names are ordered consistently in alphabetical order.

Source: Linters/SAST tools

modelexpress_client/python/modelexpress/weight_transfer/engine/lazy.py (1)

109-127: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicated copy_/__copy__ recording logic between __torch_dispatch__ and __torch_function__.

The exact same RecordedCopy construction and guard conditions are repeated in both dispatch paths. Any future change to RecordedCopy fields or guard conditions must be kept in sync manually in two places.

♻️ Suggested extraction
+def _maybe_record_copy(lazy: "LazyWeight", dst: Any) -> None:
+    if (
+        _BAKE_STACK
+        and dst is not None
+        and isinstance(dst, torch.Tensor)
+        and not isinstance(dst, LazyWeight)
+        and dst.device.type != "meta"
+    ):
+        _BAKE_STACK[-1].record(RecordedCopy(
+            src_name=lazy._lazy_name,
+            op_chain=lazy._lazy_chain,
+            dst_addr=dst.data_ptr(),
+            dst_shape=tuple(dst.shape),
+            dst_stride=tuple(dst.stride()),
+            dst_dtype=dst.dtype,
+            dst_device_id=dst.device.index or 0,
+        ))

Then call _maybe_record_copy(lazy, dst) from both copy_ branches.

Also applies to: 163-181

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@modelexpress_client/python/modelexpress/weight_transfer/engine/lazy.py`
around lines 109 - 127, The `copy_`/`__copy__` recording logic in `lazy.py` is
duplicated across both dispatch paths, so extract the shared guard and
`RecordedCopy` निर्माण into a helper such as `_maybe_record_copy(lazy, dst)` and
call it from both `__torch_dispatch__` and `__torch_function__`; keep the helper
responsible for the `_BAKE_STACK` checks, `LazyWeight`/meta-device filtering,
and `RecordedCopy` field population so future changes only happen in one place.
modelexpress_client/python/modelexpress/envs.py (1)

197-201: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

MX_TRAINER_SYNC_TIMEOUT is overloaded for two different timeout semantics.

Per the downstream consumer, this single value drives both the TrainerTable discovery wait loop and the PullRole's RDMA sync_timeout: "sync_timeout=float(envs.MX_TRAINER_SYNC_TIMEOUT)". Tuning one use case (e.g., shortening table-discovery wait) inadvertently changes the RDMA transfer timeout too, which could cause legitimate large transfers to time out.

Consider splitting into MX_TRAINER_TABLE_TIMEOUT and MX_TRAINER_SYNC_TIMEOUT for independent tuning.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@modelexpress_client/python/modelexpress/envs.py` around lines 197 - 201,
`MX_TRAINER_SYNC_TIMEOUT` in the env mapping is being used for two different
timeout behaviors, so split the shared setting into separate env vars for
discovery and sync. Update `envs.py` to introduce distinct keys such as
`MX_TRAINER_TABLE_TIMEOUT` for the trainer table wait loop and keep
`MX_TRAINER_SYNC_TIMEOUT` for the RDMA transfer path, and adjust the downstream
consumers in `TrainerTable` and `PullRole` so each reads the appropriate value
instead of sharing one timeout.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@modelexpress_client/python/modelexpress/load_strategy/trainer_pull_strategy.py`:
- Around line 226-231: The post-pull weight processing in post_pull_hook is
currently swallowed by a warning, so sync_and_post_process can succeed even when
process_weights_after_loading fails. Update post_pull_hook to surface that
failure to the caller by re-raising the exception or otherwise returning an
error signal, and ensure PullRole.sync_and_post_process handles that failure
path instead of treating the sync as successful; keep the logging in place, but
do not let the exception be silently ignored.
- Around line 190-200: The Redis fallback in the load path can block
indefinitely because `trainer_pull_strategy.py`’s Redis client creation and
`get` call do not enforce any timeout. Update the `_fetch_table` Redis branch to
pass explicit `socket_connect_timeout` and `socket_timeout` values when calling
`redis_lib.from_url`, and keep the failure path returning `None` so the
`MX_TRAINER_SYNC_TIMEOUT` retry loop is still effective. Use the existing
`redis_url`, `ctx.global_rank`, and `logger.debug` flow to locate and adjust the
Redis fallback logic.
- Around line 88-92: Preserve the original exception chain in the trainer-table
fetch failure path: in `TrainerPullStrategy`’s table-loading logic where
`_fetch_table(ctx)` is wrapped by `try/except Exception as e`, re-raise
`StrategyFailed` with `from e` so the original traceback is retained. Keep the
existing log message, and ensure the `StrategyFailed` raised from this block
carries the original exception context for easier debugging.

In `@modelexpress_client/python/modelexpress/weight_transfer/engine/lazy.py`:
- Around line 129-144: The exception handler in lazy weight transfer is
swallowing failures from func(*meta_args, **kwargs) and returning a plain meta
tensor with the original lazy.shape, which breaks op-chain tracking and can
silently drop weights. Update the fallback in LazyWeight.__torch_function__ /
the wrapped op path to avoid fabricating a torch.Tensor; instead re-raise the
exception or log the failure and re-raise, consistent with the safer handling
already used in __torch_function__ below. Keep the returned value as a
LazyWeight only on the successful path so subsequent ops and copy_() still
recognize the tracked weight.
- Around line 94-99: The docstring in the lazy tensor fallback is misleading
because it frames __torch_dispatch__ as a PyTorch 2.9-only requirement, which
conflicts with the package’s torch>=2.6.0 support. Update the documentation on
the __torch_dispatch__ / _make_wrapper_subclass fallback in lazy.py to describe
the behavior and why the ATen-level path exists, rather than tying it to a
specific torch version gate.

In `@modelexpress_client/python/modelexpress/weight_transfer/planner/resolver.py`:
- Around line 43-76: `region_elem_runs` is generating one run per element in
`_collect`, even when the innermost dimension is contiguous, which explodes
tuple creation for large tensors. Update the `_collect` logic in
`region_elem_runs` to fast-path the last dimension when `stride[dim] == 1` by
appending a single `(base, shape[dim])` run instead of per-element `(offset, 1)`
entries, while preserving the existing recursive behavior for non-contiguous
cases and the final merge pass.

In `@modelexpress_client/python/modelexpress/weight_transfer/planner/router.py`:
- Around line 1-234: This module duplicates the routing implementation from
protocol/router.py, so replace the local copy with a thin re-export of
route_regions from that module. Update planner.router to preserve the same
public API used by planner/local.py and tests, while removing the duplicated
helper implementations like _split_run_2d and _zip_src_dst. Keep the module
importable without changing call sites.

In `@modelexpress_client/python/modelexpress/weight_transfer/planner/server.py`:
- Around line 44-63: The server build path in ServerPlanner.build is using
payload field names that do not match the WeightSyncService proto, so the
request/response won’t interoperate correctly. Update the server-side request
construction in _build_via_server to use the BuildPlanRequest fields model_key
and regions, and read descriptors from the GetPlanResponse instead of
regions_payload/descriptors_payload. Keep the fallback behavior intact, but make
the server path align with WeightSyncService so it can return descriptors
successfully.

In
`@modelexpress_client/python/modelexpress/weight_transfer/protocol/__init__.py`:
- Around line 4-45: The package root for modelexpress.weight_transfer.protocol
is missing the M2N protocol exports, so importers cannot access the full API
from this module. Update the protocol package initializer to re-export
M2nDescriptor and the encode_m2n_descriptors / decode_m2n_descriptors functions
alongside the existing OpSpec, types, and serialization helpers, and add them to
__all__ so package-root imports expose the complete protocol surface.

In `@modelexpress_client/python/modelexpress/weight_transfer/roles/pull.py`:
- Around line 86-98: The PullRole refresh path is leaking NIXL remote-agent
registrations because initialize() rebuilds remote_agents and a new NixlExecutor
without releasing the previously registered agents, and teardown() never cleans
them up. Update PullRole.initialize(), refresh(), and/or teardown() to
explicitly unregister or reuse the existing table.agents entries via
_nixl_manager before creating a new NixlExecutor, so stale remote-agent state
does not accumulate across reshard cycles.

In `@modelexpress_client/python/modelexpress/weight_transfer/roles/push.py`:
- Around line 106-125: The missing-parameter branch in push.py is under-logged
compared with the size-mismatch branch, so update the shard handling in the push
flow to treat both as a meaningful sync problem. In the loop over
inference_table.shards inside the push logic, raise the log level for the
local_params miss from debug to warning (or equivalent) and add an aggregate
validation after the loop in the same method/class to detect when not all shards
were mapped. Use the existing identifiers inference_table.shards, local_params,
and the push method on the trainer role to locate and adjust the logging and
validation consistently.
- Around line 50-92: The push plan built in `PushRole.initialize()` caches RDMA
descriptors using parameter pointers that can change under FSDP2/`fully_shard`,
so `PushRole.sync()` may use stale addresses. Update `PushRole.sync()` to
rebuild the descriptors or refresh each descriptor’s `src_addr` from the current
model parameters before calling `NixlExecutor.execute()`, using the existing
`PushRole`, `_build_push_plan()`, and `_executor` flow as the main entry points.

In
`@modelexpress_client/python/modelexpress/weight_transfer/transport/m2n_executor.py`:
- Around line 44-130: `M2nExecutor.execute` duplicates nearly all of
`NixlExecutor.execute`, so shared transfer setup, polling, timeout, release, and
synchronize behavior can drift. Extract the common execute workflow into a
shared helper or base class in the transport layer, and have `M2nExecutor` and
`NixlExecutor` delegate to it while only supplying the path-specific pieces such
as grouping key, transfer operation, and descriptor-to-dlist mapping.
- Around line 93-104: The transfer handle can leak if `agent.transfer(handle)`
fails after `agent.make_prepped_xfer(...)` has already allocated it. Update
`m2n_executor` so the handle is registered for cleanup before calling
`agent.transfer(handle)`, or wrap the transfer in a `try`/`finally` that
guarantees release on failure; keep the fix localized around the `handles` list
and the `transfer(handle)` call site.

In `@modelexpress_client/python/tests/gpu/test_e2e_primerl_real_model.py`:
- Line 44: Remove the hardcoded developer checkout path from the module import
setup in test_e2e_primerl_real_model.py; the unconditional sys.path.insert(0,
...) should not alter import resolution for everyone. Update the test to rely on
the installed modelexpress package or a test-local path setup/fixture if needed,
and ensure any path manipulation happens only in the test environment and does
not take precedence over the real package.
- Around line 38-42: Gate the throughput assertions in _cuda_memcpy-based test
flow on peer-access availability, since the current cudaMemcpyDefault path
between GPU 0 and 1 can fall back to host staging without peer access enabled.
Update the test in test_e2e_primerl_real_model.py to explicitly check whether
peer access between the devices is supported and only run the >5 GB/s throughput
checks when that capability is present; otherwise skip or relax the benchmark
assertions while keeping _cuda_memcpy unchanged.

In `@modelexpress_server/src/weight_sync/router.rs`:
- Around line 125-127: The unpack_runs helper currently assumes every chunk has
two elements, so an odd-length payload can panic when indexing c[1]. Update
unpack_runs in router.rs to validate the input length before unpacking and
handle malformed data safely, returning an error or rejecting the request
instead of crashing; if this function is used by the weight sync request flow,
make sure the caller around unpack_runs propagates a Status for invalid client
payloads.
- Around line 16-25: `TrainerShard` and `split_run` in the Rust router only
handle row ranges, so column-sharded tensors are matched and addressed
incorrectly. Extend `TrainerShard` to carry the same column range metadata as
the Python router (`col_start`/`col_end`), then update `split_run` to match
shards using both row and column bounds when computing the target `agent_index`
and `src_addr`; if column-sharded layouts are not supported, add an explicit
validation/error path instead of falling back to the first row match.

In `@modelexpress_server/src/weight_sync/service.rs`:
- Around line 108-122: The invalidation in publish_trainer_table only removes
entries from plan_key_to_id, leaving orphaned CachedPlan values behind in
state.plans. Update publish_trainer_table to collect the plan IDs removed by the
retain call on plan_key_to_id, then use those IDs to delete the matching
CachedPlan entries from plans as part of the same invalidation path. Keep the
fix localized to publish_trainer_table and the state fields plan_key_to_id and
plans so both caches stay in sync.
- Around line 160-199: The build_plan flow in WeightSyncService has a
check-then-insert race between the initial state.read() lookup of plan_key_to_id
and the later state.write() insert, which can create duplicate plans for the
same req.plan_key. Fix it by rechecking the cache under the write lock or by
moving the lookup and insert into a single locked critical section in
build_plan, so only one plan_id is created and stored for a given plan_key. Use
the existing plan_key_to_id and plans fields, along with build_plan and
invalidate_plan, to keep the cached plan mapping consistent and idempotent.

---

Outside diff comments:
In `@modelexpress_client/python/modelexpress/load_strategy/__init__.py`:
- Around line 64-76: TrainerPullStrategy is being selected too broadly because
load_strategy.__init__ always includes it when is_available() passes, causing
non-trainer-pull deployments to wait in TrainerPullStrategy.load() for
MX_TRAINER_SYNC_TIMEOUT. Update the strategy selection in the load strategy
initialization so TrainerPullStrategy is only added when an explicit
trainer-pull signal is present, such as MX_TRAINER_TABLE_KEY or
MX_WEIGHT_SYNC_SERVER. Keep RdmaStrategy and the other fallbacks unchanged so
ordinary RDMA startup skips TrainerPullStrategy immediately.

---

Duplicate comments:
In
`@modelexpress_client/python/modelexpress/weight_transfer/transport/nixl_executor.py`:
- Around line 94-104: The transfer path in NixlExecutor has the same handle-leak
risk as M2nExecutor.execute: if agent.transfer(handle) fails after
agent.make_prepped_xfer() succeeds, the newly created handle is never recorded
for cleanup. Update NixlExecutor.execute so the handle is protected immediately
after creation, either by appending it to handles before calling transfer or by
using a try/finally around agent.transfer(handle) that guarantees release
tracking even on failure. Use the make_prepped_xfer, transfer, and handles logic
in NixlExecutor.execute to locate the fix.

---

Nitpick comments:
In `@modelexpress_client/python/modelexpress/envs.py`:
- Around line 197-201: `MX_TRAINER_SYNC_TIMEOUT` in the env mapping is being
used for two different timeout behaviors, so split the shared setting into
separate env vars for discovery and sync. Update `envs.py` to introduce distinct
keys such as `MX_TRAINER_TABLE_TIMEOUT` for the trainer table wait loop and keep
`MX_TRAINER_SYNC_TIMEOUT` for the RDMA transfer path, and adjust the downstream
consumers in `TrainerTable` and `PullRole` so each reads the appropriate value
instead of sharing one timeout.

In `@modelexpress_client/python/modelexpress/weight_transfer/__init__.py`:
- Around line 65-91: The __all__ list in weight_transfer/__init__.py is not
sorted and triggers Ruff RUF022. Reorder the exported symbols in __all__ into a
consistent sorted order, keeping the existing grouped sections if needed, and
update the list near the module-level __all__ definition so the import export
order is alphabetical and lint-compliant.

In
`@modelexpress_client/python/modelexpress/weight_transfer/adapters/__init__.py`:
- Line 7: The __all__ export list in the adapters package is not sorted and
triggers Ruff RUF022; update the __all__ definition in the adapters __init__
module so the exported names are ordered consistently, keeping the existing
symbols ModelAdapter and MoEAdapter but arranging them in sorted order.

In `@modelexpress_client/python/modelexpress/weight_transfer/adapters/moe.py`:
- Around line 24-27: The shared mutable default mapping in MoEAdapter should not
be reused directly across instances. Update the initialization path in the class
that defines _default_proj_map so self._projection_map gets its own copy when no
projection_map is provided, instead of aliasing the class attribute. Keep the
existing symbols (_default_proj_map, __init__, and _projection_map) so the fix
is localized and avoids cross-instance mutation.
- Around line 43-67: The adapt_lazy_weights method in moe.py should validate
that the configured num_experts matches the leading dimension of the stacked
LazyWeight before yielding per-expert entries. Add a guard near the existing
stacked-tensor handling in adapt_lazy_weights that compares self._num_experts
with lazy.shape[0] (or the equivalent expert dimension) and raises a clear error
if they differ. Keep the fix localized to adapt_lazy_weights and use the
existing LazyWeight, self._stacked_pattern, and self._projection_map flow so
invalid __getitem__ op_chain construction is prevented early.

In `@modelexpress_client/python/modelexpress/weight_transfer/engine/__init__.py`:
- Line 7: The __all__ export list in the engine package is unsorted and triggers
Ruff RUF022. Update the __all__ declaration in the module that defines
WeightLoaderAdapter, BakeRecorder, LazyWeight, RecordedCopy, and bake_model so
the exported names are ordered consistently in alphabetical order.

In `@modelexpress_client/python/modelexpress/weight_transfer/engine/lazy.py`:
- Around line 109-127: The `copy_`/`__copy__` recording logic in `lazy.py` is
duplicated across both dispatch paths, so extract the shared guard and
`RecordedCopy` निर्माण into a helper such as `_maybe_record_copy(lazy, dst)` and
call it from both `__torch_dispatch__` and `__torch_function__`; keep the helper
responsible for the `_BAKE_STACK` checks, `LazyWeight`/meta-device filtering,
and `RecordedCopy` field population so future changes only happen in one place.

In `@modelexpress_client/python/modelexpress/weight_transfer/protocol/ops.py`:
- Around line 11-27: `OpSpec` is marked frozen but still contains an unhashable
`dict` in `kwargs`, which breaks the expected hashability of the dataclass.
Update `OpSpec` in `ops.py` so its state is normalized into immutable, hashable
forms in `__post_init__` and/or provide a custom `__hash__` that hashes a stable
representation of `name`, `args`, and `kwargs`; keep `OpChain` compatibility in
mind if it relies on hashing these specs.

In `@modelexpress_client/python/modelexpress/weight_transfer/protocol/router.py`:
- Around line 149-193: Add a defensive parity check in _zip_src_dst so source
and destination element totals must match before building RdmaDescriptor
entries. Compute/validate the total counts for src_triples and dst_runs, and
raise an explicit error if they differ instead of allowing the while loop to
stop early and drop leftover elements. Keep the fix localized to _zip_src_dst in
router.py so upstream bugs in resolve_copies fail fast with a clear message.

In
`@modelexpress_client/python/modelexpress/weight_transfer/protocol/test_weight_transfer.py`:
- Line 59: Ruff is flagging unused unpacked values in the weight transfer tests
where resolve_chain_region is called and only shape is used. Update the affected
test cases in test_weight_transfer.py to avoid binding offset and stride when
they are not needed, either by unpacking only the required value or by assigning
the extra values to placeholders, and apply the same cleanup to all
resolve_chain_region usages in the referenced tests.

In `@modelexpress_client/python/pyproject.toml`:
- Around line 68-73: The gpu marker is declared in the pytest configuration but
never applied to the GPU E2E test, so CI cannot select or exclude it by marker.
Update tests/gpu/test_e2e_primerl_real_model.py to add `@pytest.mark.gpu` to the
relevant test/class/module alongside the existing skipif gate, using the gpu
marker name defined in pyproject.toml so pytest -m selection works as intended.

In `@modelexpress_client/python/tests/gpu/test_e2e_primerl_real_model.py`:
- Around line 22-26: The hardcoded first entry in the _LIBCUDART_PATHS list is
too environment-specific and should be removed or replaced with a dynamic
lookup. Update the test setup around _LIBCUDART_PATHS in
test_e2e_primerl_real_model.py to prefer a derived libcudart location using
something like ctypes.util.find_library or values inferred from
torch.version.cuda, while keeping the existing fallback and graceful-skip
behavior if no library is found.
- Around line 188-193: The blind exception handling around resolve_chain_region
in the parameter copy loop silently skips parameters without any diagnostics.
Update the try/except in test_e2e_primerl_real_model to catch the failure in a
more explicit way and emit a warning or debug log that includes the
parameter/op_chain context before continuing, so unresolved regions are still
visible when the transfer plan is built.

In `@modelexpress_client/python/tests/test_e2e_primerl.py`:
- Around line 193-199: The current test is tautological because it only
increments a MagicMock and never exercises PullRole.sync_and_post_process().
Update test_post_pull_hook_called_per_sync to construct a minimal PullRole with
mocked planner, executor, and adapter, then call sync_and_post_process() and
assert adapter.post_pull_hook.call_count so the test verifies the real post-pull
wiring after sync rather than mock self-counting.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: db68ddd6-8d69-4069-95f0-c681e5303985

📥 Commits

Reviewing files that changed from the base of the PR and between d62561b and c41d4b0.

📒 Files selected for processing (47)
  • docs/ARCHITECTURE.md
  • modelexpress_client/python/modelexpress/envs.py
  • modelexpress_client/python/modelexpress/load_strategy/__init__.py
  • modelexpress_client/python/modelexpress/load_strategy/trainer_pull_strategy.py
  • modelexpress_client/python/modelexpress/weight_transfer/__init__.py
  • modelexpress_client/python/modelexpress/weight_transfer/adapters/__init__.py
  • modelexpress_client/python/modelexpress/weight_transfer/adapters/base.py
  • modelexpress_client/python/modelexpress/weight_transfer/adapters/moe.py
  • modelexpress_client/python/modelexpress/weight_transfer/engine/__init__.py
  • modelexpress_client/python/modelexpress/weight_transfer/engine/base.py
  • modelexpress_client/python/modelexpress/weight_transfer/engine/lazy.py
  • modelexpress_client/python/modelexpress/weight_transfer/planner/__init__.py
  • modelexpress_client/python/modelexpress/weight_transfer/planner/base.py
  • modelexpress_client/python/modelexpress/weight_transfer/planner/local.py
  • modelexpress_client/python/modelexpress/weight_transfer/planner/m2n_planner.py
  • modelexpress_client/python/modelexpress/weight_transfer/planner/resolver.py
  • modelexpress_client/python/modelexpress/weight_transfer/planner/router.py
  • modelexpress_client/python/modelexpress/weight_transfer/planner/server.py
  • modelexpress_client/python/modelexpress/weight_transfer/protocol/__init__.py
  • modelexpress_client/python/modelexpress/weight_transfer/protocol/ops.py
  • modelexpress_client/python/modelexpress/weight_transfer/protocol/router.py
  • modelexpress_client/python/modelexpress/weight_transfer/protocol/serialization.py
  • modelexpress_client/python/modelexpress/weight_transfer/protocol/test_weight_transfer.py
  • modelexpress_client/python/modelexpress/weight_transfer/protocol/types.py
  • modelexpress_client/python/modelexpress/weight_transfer/roles/__init__.py
  • modelexpress_client/python/modelexpress/weight_transfer/roles/base.py
  • modelexpress_client/python/modelexpress/weight_transfer/roles/pull.py
  • modelexpress_client/python/modelexpress/weight_transfer/roles/push.py
  • modelexpress_client/python/modelexpress/weight_transfer/transport/__init__.py
  • modelexpress_client/python/modelexpress/weight_transfer/transport/m2n_executor.py
  • modelexpress_client/python/modelexpress/weight_transfer/transport/nixl_executor.py
  • modelexpress_client/python/pyproject.toml
  • modelexpress_client/python/tests/gpu/conftest.py
  • modelexpress_client/python/tests/gpu/test_e2e_primerl_real_model.py
  • modelexpress_client/python/tests/test_e2e_nccl_m2n.py
  • modelexpress_client/python/tests/test_e2e_primerl.py
  • modelexpress_client/python/tests/test_m2n.py
  • modelexpress_client/python/tests/test_rl_weight_sync.py
  • modelexpress_client/python/tests/test_weight_transfer.py
  • modelexpress_common/build.rs
  • modelexpress_common/proto/weight_sync.proto
  • modelexpress_common/src/lib.rs
  • modelexpress_server/src/lib.rs
  • modelexpress_server/src/server.rs
  • modelexpress_server/src/weight_sync.rs
  • modelexpress_server/src/weight_sync/router.rs
  • modelexpress_server/src/weight_sync/service.rs

Comment on lines +88 to +92
try:
table = self._fetch_table(ctx)
except Exception as e:
logger.info("[Worker %d] TrainerTable not available: %s", ctx.global_rank, e)
raise StrategyFailed(f"TrainerTable not available: {e}", mutated=False)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Preserve exception chain when re-raising as StrategyFailed.

raise StrategyFailed(...) inside the except Exception as e: block drops the original traceback. Use from e for easier debugging of trainer-table fetch failures.

🔧 Proposed fix
         except Exception as e:
             logger.info("[Worker %d] TrainerTable not available: %s", ctx.global_rank, e)
-            raise StrategyFailed(f"TrainerTable not available: {e}", mutated=False)
+            raise StrategyFailed(f"TrainerTable not available: {e}", mutated=False) from e
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try:
table = self._fetch_table(ctx)
except Exception as e:
logger.info("[Worker %d] TrainerTable not available: %s", ctx.global_rank, e)
raise StrategyFailed(f"TrainerTable not available: {e}", mutated=False)
try:
table = self._fetch_table(ctx)
except Exception as e:
logger.info("[Worker %d] TrainerTable not available: %s", ctx.global_rank, e)
raise StrategyFailed(f"TrainerTable not available: {e}", mutated=False) from e
🧰 Tools
🪛 Ruff (0.15.20)

[warning] 90-90: Do not catch blind exception: Exception

(BLE001)


[warning] 92-92: Within an except clause, raise exceptions with raise ... from err or raise ... from None to distinguish them from errors in exception handling

(B904)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@modelexpress_client/python/modelexpress/load_strategy/trainer_pull_strategy.py`
around lines 88 - 92, Preserve the original exception chain in the trainer-table
fetch failure path: in `TrainerPullStrategy`’s table-loading logic where
`_fetch_table(ctx)` is wrapped by `try/except Exception as e`, re-raise
`StrategyFailed` with `from e` so the original traceback is retained. Keep the
existing log message, and ensure the `StrategyFailed` raised from this block
carries the original exception context for easier debugging.

Source: Linters/SAST tools

Comment on lines +190 to +200
redis_url = envs.MX_REDIS_URL
try:
import redis as redis_lib
r = redis_lib.from_url(redis_url)
return r.get(key)
except ImportError:
logger.debug("[Worker %d] redis-py not installed", ctx.global_rank)
except Exception as e:
logger.debug("[Worker %d] Redis GET failed: %s", ctx.global_rank, e)

return None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Redis fallback call has no timeout — can hang the load path indefinitely.

redis_lib.from_url(redis_url) and .get(key) are called with no socket_timeout/socket_connect_timeout. If Redis is unreachable but not actively refusing connections, this call can block forever, silently defeating the MX_TRAINER_SYNC_TIMEOUT-bounded retry loop in _fetch_table.

🔧 Proposed fix
         try:
             import redis as redis_lib
-            r = redis_lib.from_url(redis_url)
+            r = redis_lib.from_url(
+                redis_url,
+                socket_connect_timeout=5.0,
+                socket_timeout=5.0,
+            )
             return r.get(key)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
redis_url = envs.MX_REDIS_URL
try:
import redis as redis_lib
r = redis_lib.from_url(redis_url)
return r.get(key)
except ImportError:
logger.debug("[Worker %d] redis-py not installed", ctx.global_rank)
except Exception as e:
logger.debug("[Worker %d] Redis GET failed: %s", ctx.global_rank, e)
return None
redis_url = envs.MX_REDIS_URL
try:
import redis as redis_lib
r = redis_lib.from_url(
redis_url,
socket_connect_timeout=5.0,
socket_timeout=5.0,
)
return r.get(key)
except ImportError:
logger.debug("[Worker %d] redis-py not installed", ctx.global_rank)
except Exception as e:
logger.debug("[Worker %d] Redis GET failed: %s", ctx.global_rank, e)
return None
🧰 Tools
🪛 Ruff (0.15.20)

[warning] 197-197: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@modelexpress_client/python/modelexpress/load_strategy/trainer_pull_strategy.py`
around lines 190 - 200, The Redis fallback in the load path can block
indefinitely because `trainer_pull_strategy.py`’s Redis client creation and
`get` call do not enforce any timeout. Update the `_fetch_table` Redis branch to
pass explicit `socket_connect_timeout` and `socket_timeout` values when calling
`redis_lib.from_url`, and keep the failure path returning `None` so the
`MX_TRAINER_SYNC_TIMEOUT` retry loop is still effective. Use the existing
`redis_url`, `ctx.global_rank`, and `logger.debug` flow to locate and adjust the
Redis fallback logic.

Comment on lines +226 to +231
def post_pull_hook(self, model: Any) -> None:
if self._ctx.adapter is not None and hasattr(self._ctx.adapter, "process_weights_after_loading"):
try:
self._ctx.adapter.process_weights_after_loading(model)
except Exception as e:
logger.warning("post_pull_hook failed: %s", e)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Silent failure of post-pull weight post-processing.

process_weights_after_loading failures (e.g. FP8 repack) are caught and only logged as a warning; sync_and_post_process in PullRole will return as if sync succeeded even though weights may not have been correctly post-processed, risking silent incorrect inference results.

🔧 Proposed fix
     def post_pull_hook(self, model: Any) -> None:
         if self._ctx.adapter is not None and hasattr(self._ctx.adapter, "process_weights_after_loading"):
-            try:
-                self._ctx.adapter.process_weights_after_loading(model)
-            except Exception as e:
-                logger.warning("post_pull_hook failed: %s", e)
+            self._ctx.adapter.process_weights_after_loading(model)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def post_pull_hook(self, model: Any) -> None:
if self._ctx.adapter is not None and hasattr(self._ctx.adapter, "process_weights_after_loading"):
try:
self._ctx.adapter.process_weights_after_loading(model)
except Exception as e:
logger.warning("post_pull_hook failed: %s", e)
def post_pull_hook(self, model: Any) -> None:
if self._ctx.adapter is not None and hasattr(self._ctx.adapter, "process_weights_after_loading"):
self._ctx.adapter.process_weights_after_loading(model)
🧰 Tools
🪛 Ruff (0.15.20)

[warning] 230-230: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@modelexpress_client/python/modelexpress/load_strategy/trainer_pull_strategy.py`
around lines 226 - 231, The post-pull weight processing in post_pull_hook is
currently swallowed by a warning, so sync_and_post_process can succeed even when
process_weights_after_loading fails. Update post_pull_hook to surface that
failure to the caller by re-raising the exception or otherwise returning an
error signal, and ensure PullRole.sync_and_post_process handles that failure
path instead of treating the sync as successful; keep the logging in place, but
do not let the exception be silently ignored.

Comment on lines +94 to +99
"""ATen-level fallback required by PyTorch ≥2.9 for _make_wrapper_subclass.

Primary interception lives in __torch_function__ (Python-level calls
like narrow/view/copy_). copy_ is also handled here so both paths
work; all other ops proxy through meta tensors for correct output shape.
"""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

What PyTorch version introduced a requirement for __torch_dispatch__ alongside __torch_function__ when using _make_wrapper_subclass?

💡 Result:

There is no single PyTorch version that introduced a formal, mandatory requirement to use torch_dispatch alongside torch_function when using _make_wrapper_subclass. Rather, the use of torch_dispatch with _make_wrapper_subclass is a functional necessity for creating custom tensor subclasses that are intended to be performant, functional, or compatible with modern PyTorch features like torch.compile and functorch [1][2]. _make_wrapper_subclass, introduced around PyTorch 1.10 (PR #65340) [3], creates a tensor without immediate backing storage to support custom, "wrapper" subclasses that manage their own data or behavior [4][3]. Because these tensors lack standard storage, they are generally incompatible with standard dispatch paths. To ensure these subclasses function correctly across the PyTorch ecosystem—particularly for tracing and graph-based operations—implementers must provide a torch_dispatch override to define how operations are handled, as standard torch_function is often insufficient for deep integration into the PyTorch dispatcher [1][2]. In modern PyTorch (notably for compatibility with PT2/torch.compile), subclasses created with _make_wrapper_subclass are expected to implement not only torch_dispatch but also tensor_flatten and tensor_unflatten to be considered traceable [2]. While torch_function can still be used for high-level Python overrides, it is increasingly treated as a legacy or auxiliary mechanism compared to torch_dispatch for low-level subclass behavior [2][5][6]. Consequently, many developers explicitly disable torch_function in these subclasses (using torch._C._disabled_torch_function_impl) to force exclusive reliance on torch_dispatch [7][8].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== torch version references =="
rg -n --hidden --glob '!**/.git/**' 'torch[^0-9]*2\.[0-9]+\.[0-9]+' .

echo
echo "== candidate dependency files =="
git ls-files | rg '(^|/)(pyproject\.toml|requirements(\.txt)?|setup\.py|setup\.cfg|Pipfile|poetry\.lock|uv\.lock|conda\.ya?ml|environment\.ya?ml)$'

echo
echo "== target file excerpt =="
sed -n '70,120p' modelexpress_client/python/modelexpress/weight_transfer/engine/lazy.py

Repository: ai-dynamo/modelexpress

Length of output: 17131


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== repo root files mentioning torch version pin or constraint =="
rg -n --hidden --glob '!**/.git/**' '\btorch\b|PyTorch|pytorch' \
  modelexpress_client/python \
  pyproject.toml requirements.txt requirements*.txt setup.py setup.cfg Pipfile poetry.lock uv.lock \
  2>/dev/null || true

Repository: ai-dynamo/modelexpress

Length of output: 50378


Clarify the torch version note
__torch_dispatch__ isn’t a PyTorch 2.9-only requirement for _make_wrapper_subclass; this package already declares torch>=2.6.0, so the docstring should describe the fallback behavior instead of a version gate.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@modelexpress_client/python/modelexpress/weight_transfer/engine/lazy.py`
around lines 94 - 99, The docstring in the lazy tensor fallback is misleading
because it frames __torch_dispatch__ as a PyTorch 2.9-only requirement, which
conflicts with the package’s torch>=2.6.0 support. Update the documentation on
the __torch_dispatch__ / _make_wrapper_subclass fallback in lazy.py to describe
the behavior and why the ATen-level path exists, rather than tying it to a
specific torch version gate.

Comment on lines +129 to +144
try:
meta_args = tuple(
torch.empty(a.shape, dtype=a.dtype, device="meta")
if isinstance(a, LazyWeight) else a
for a in args
)
out = func(*meta_args, **kwargs)
if isinstance(out, torch.Tensor):
new_op = OpSpec(name=func_name, args=args[1:], kwargs=kwargs)
return LazyWeight(
lazy._lazy_name, out.shape, out.dtype,
lazy._lazy_chain + (new_op,),
)
return out
except Exception:
return torch.empty(lazy.shape, dtype=lazy.dtype, device="meta")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Broad exception swallow returns a wrong-shaped, untracked tensor instead of failing loudly.

When func(*meta_args, **kwargs) raises, the fallback returns torch.empty(lazy.shape, ...) — the tensor's original (pre-op) shape, not the actual result shape, and it is a plain torch.Tensor, not a LazyWeight. This silently breaks op-chain tracking: any subsequent op/copy_() on this value will no longer be recognized as a LazyWeight, so the corresponding weight is silently dropped from the bake pass instead of raising a visible error. Given this drives which RDMA regions get synchronized to inference workers, a silently dropped/mis-shaped weight is a serious, hard-to-diagnose correctness bug.

Consider re-raising (or logging + re-raising) instead of fabricating a fallback tensor, mirroring the safer except Exception: return super().__torch_function__(...) behavior used a few lines below in __torch_function__.

🧰 Tools
🪛 Ruff (0.15.20)

[warning] 140-140: Consider (*lazy._lazy_chain, new_op) instead of concatenation

Replace with (*lazy._lazy_chain, new_op)

(RUF005)


[warning] 143-143: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@modelexpress_client/python/modelexpress/weight_transfer/engine/lazy.py`
around lines 129 - 144, The exception handler in lazy weight transfer is
swallowing failures from func(*meta_args, **kwargs) and returning a plain meta
tensor with the original lazy.shape, which breaks op-chain tracking and can
silently drop weights. Update the fallback in LazyWeight.__torch_function__ /
the wrapped op path to avoid fabricating a torch.Tensor; instead re-raise the
exception or log the failure and re-raise, consistent with the safer handling
already used in __torch_function__ below. Keep the returned value as a
LazyWeight only on the successful path so subsequent ops and copy_() still
recognize the tracked weight.

raise RuntimeError(f"cudaMemcpy failed rc={rc}")


sys.path.insert(0, os.path.expanduser("~/workspace/modelexpress/modelexpress_client/python"))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Remove developer-specific hardcoded path from sys.path.

sys.path.insert(0, os.path.expanduser("~/workspace/modelexpress/modelexpress_client/python")) hardcodes one author's local checkout location. It runs unconditionally at module import (even in CI runs where GPUs — and thus this test module's skip condition — mean it's still collected), and inserting at index 0 means it takes priority over any properly installed modelexpress package, risking tests silently running against a stale/different copy of the code on any machine where that path happens to exist.

🐛 Suggested fix
-sys.path.insert(0, os.path.expanduser("~/workspace/modelexpress/modelexpress_client/python"))
-
 from modelexpress.weight_transfer.engine.lazy import BakeRecorder, LazyWeight
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
sys.path.insert(0, os.path.expanduser("~/workspace/modelexpress/modelexpress_client/python"))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@modelexpress_client/python/tests/gpu/test_e2e_primerl_real_model.py` at line
44, Remove the hardcoded developer checkout path from the module import setup in
test_e2e_primerl_real_model.py; the unconditional sys.path.insert(0, ...) should
not alter import resolution for everyone. Update the test to rely on the
installed modelexpress package or a test-local path setup/fixture if needed, and
ensure any path manipulation happens only in the test environment and does not
take precedence over the real package.

Comment on lines +16 to +25
/// Trainer shard descriptor, mirroring `protocol.types.TrainerShard`.
#[derive(Debug, Clone, Deserialize)]
pub struct TrainerShard {
pub agent_index: u32,
pub row_start: i64,
pub row_end: i64,
pub device_addr: u64,
pub row_bytes: i64,
pub device_id: i32,
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🔴 Critical | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm the Python router models column sharding that the Rust mirror lacks.
fd -t f 'router.py' modelexpress_client | while read -r f; do
  echo "=== $f ==="
  rg -nP -C2 'col_start|col_end|elems_per_row|row_start|def route_regions' "$f"
done
# Confirm TrainerShard field set on the Python side.
rg -nP -C1 'class TrainerShard|col_start|col_end' modelexpress_client/python/modelexpress/weight_transfer/protocol/types.py

Repository: ai-dynamo/modelexpress

Length of output: 7404


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the Rust router and related types/routing logic.
sed -n '1,260p' modelexpress_server/src/weight_sync/router.rs | cat -n

printf '\n=== related Rust protocol/type definitions ===\n'
rg -n -C 3 'TrainerShard|TrainerTensor|split_run|route_regions|row_start|row_end|device_addr|device_id' modelexpress_server/src -g '*.rs'

Repository: ai-dynamo/modelexpress

Length of output: 33056


Support column-sharded tensors in the Rust router modelexpress_server/src/weight_sync/router.rs:16-25,130-162TrainerShard only tracks row_start/row_end, and split_run matches shards by row only. For 2-D/column-sharded tensors, that picks the first row-matching shard and computes the wrong agent_index/src_addr. Mirror the Python router’s col_start/col_end handling here, or reject non-row-only layouts explicitly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@modelexpress_server/src/weight_sync/router.rs` around lines 16 - 25,
`TrainerShard` and `split_run` in the Rust router only handle row ranges, so
column-sharded tensors are matched and addressed incorrectly. Extend
`TrainerShard` to carry the same column range metadata as the Python router
(`col_start`/`col_end`), then update `split_run` to match shards using both row
and column bounds when computing the target `agent_index` and `src_addr`; if
column-sharded layouts are not supported, add an explicit validation/error path
instead of falling back to the first row match.

Comment on lines +125 to +127
fn unpack_runs(flat: &[i64]) -> Vec<(i64, i64)> {
flat.chunks(2).map(|c| (c[0], c[1])).collect()
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

unpack_runs panics on odd-length input.

chunks(2) yields a final 1-element slice when flat.len() is odd, and c[1] then indexes out of bounds, panicking the request handler. Element-run payloads should always be pairs, but this input arrives over the wire from a client, so a malformed request would crash the task rather than return a Status.

🛡️ Proposed guard
-fn unpack_runs(flat: &[i64]) -> Vec<(i64, i64)> {
-    flat.chunks(2).map(|c| (c[0], c[1])).collect()
-}
+fn unpack_runs(flat: &[i64]) -> Vec<(i64, i64)> {
+    flat.chunks_exact(2).map(|c| (c[0], c[1])).collect()
+}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn unpack_runs(flat: &[i64]) -> Vec<(i64, i64)> {
flat.chunks(2).map(|c| (c[0], c[1])).collect()
}
fn unpack_runs(flat: &[i64]) -> Vec<(i64, i64)> {
flat.chunks_exact(2).map(|c| (c[0], c[1])).collect()
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@modelexpress_server/src/weight_sync/router.rs` around lines 125 - 127, The
unpack_runs helper currently assumes every chunk has two elements, so an
odd-length payload can panic when indexing c[1]. Update unpack_runs in router.rs
to validate the input length before unpacking and handle malformed data safely,
returning an error or rejecting the request instead of crashing; if this
function is used by the weight sync request flow, make sure the caller around
unpack_runs propagates a Status for invalid client payloads.

Comment on lines +108 to +122
async fn publish_trainer_table(
&self,
request: Request<PublishTrainerTableRequest>,
) -> Result<Response<PublishTrainerTableResponse>, Status> {
let req = request.into_inner();
let mut state = self.state.write().await;
state
.trainer_tables
.insert(req.model_key.clone(), req.table_payload);
// Invalidate any cached plans that used this model's table
state
.plan_key_to_id
.retain(|k, _| !k.starts_with(&req.model_key));
Ok(Response::new(PublishTrainerTableResponse { ok: true }))
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Memory leak: invalidating a trainer table drops plan_key_to_id mappings but never removes the underlying plans.

retain prunes plan_key_to_id, yet the corresponding CachedPlan entries in state.plans are never removed, so they leak. Since publish_trainer_table is called "once per broadcast step", orphaned plans accumulate unbounded across training. Collect the removed plan_ids and drop them from plans too.

🔧 Proposed fix
-        // Invalidate any cached plans that used this model's table
-        state
-            .plan_key_to_id
-            .retain(|k, _| !k.starts_with(&req.model_key));
+        // Invalidate any cached plans that used this model's table
+        let stale_ids: Vec<String> = state
+            .plan_key_to_id
+            .iter()
+            .filter(|(k, _)| k.starts_with(&req.model_key))
+            .map(|(_, id)| id.clone())
+            .collect();
+        state
+            .plan_key_to_id
+            .retain(|k, _| !k.starts_with(&req.model_key));
+        for id in stale_ids {
+            state.plans.remove(&id);
+        }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async fn publish_trainer_table(
&self,
request: Request<PublishTrainerTableRequest>,
) -> Result<Response<PublishTrainerTableResponse>, Status> {
let req = request.into_inner();
let mut state = self.state.write().await;
state
.trainer_tables
.insert(req.model_key.clone(), req.table_payload);
// Invalidate any cached plans that used this model's table
state
.plan_key_to_id
.retain(|k, _| !k.starts_with(&req.model_key));
Ok(Response::new(PublishTrainerTableResponse { ok: true }))
}
async fn publish_trainer_table(
&self,
request: Request<PublishTrainerTableRequest>,
) -> Result<Response<PublishTrainerTableResponse>, Status> {
let req = request.into_inner();
let mut state = self.state.write().await;
state
.trainer_tables
.insert(req.model_key.clone(), req.table_payload);
// Invalidate any cached plans that used this model's table
let stale_ids: Vec<String> = state
.plan_key_to_id
.iter()
.filter(|(k, _)| k.starts_with(&req.model_key))
.map(|(_, id)| id.clone())
.collect();
state
.plan_key_to_id
.retain(|k, _| !k.starts_with(&req.model_key));
for id in stale_ids {
state.plans.remove(&id);
}
Ok(Response::new(PublishTrainerTableResponse { ok: true }))
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@modelexpress_server/src/weight_sync/service.rs` around lines 108 - 122, The
invalidation in publish_trainer_table only removes entries from plan_key_to_id,
leaving orphaned CachedPlan values behind in state.plans. Update
publish_trainer_table to collect the plan IDs removed by the retain call on
plan_key_to_id, then use those IDs to delete the matching CachedPlan entries
from plans as part of the same invalidation path. Keep the fix localized to
publish_trainer_table and the state fields plan_key_to_id and plans so both
caches stay in sync.

Comment on lines +160 to +199
// Reuse an existing plan for this plan_key if one was already built.
{
let state = self.state.read().await;
if let Some(plan_id) = state.plan_key_to_id.get(&req.plan_key) {
return Ok(Response::new(BuildPlanResponse {
plan_id: plan_id.clone(),
}));
}
}

// Fetch the TrainerTable and deserialize it.
let table_bytes = {
let state = self.state.read().await;
state
.trainer_tables
.get(&req.model_key)
.cloned()
.ok_or_else(|| {
Status::not_found(format!(
"TrainerTable not found for model_key {:?}",
req.model_key
))
})?
};

let table: TrainerTableJson = serde_json::from_slice(&table_bytes)
.map_err(|e| Status::internal(format!("Failed to decode TrainerTable: {e}")))?;

// Route the resolved regions in Rust (no torch).
let descriptors = route_regions(&req.regions, &table);

let plan_id = Uuid::new_v4().to_string();
let mut state = self.state.write().await;
state
.plans
.insert(plan_id.clone(), CachedPlan { descriptors });
state.plan_key_to_id.insert(req.plan_key, plan_id.clone());

Ok(Response::new(BuildPlanResponse { plan_id }))
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the relevant file and locate the build_plan implementation and state types.
ast-grep outline modelexpress_server/src/weight_sync/service.rs --view expanded

printf '\n--- build_plan references ---\n'
rg -n "build_plan|plan_key_to_id|CachedPlan|trainer_tables|plans" modelexpress_server/src/weight_sync/service.rs

printf '\n--- surrounding lines ---\n'
sed -n '120,230p' modelexpress_server/src/weight_sync/service.rs

Repository: ai-dynamo/modelexpress

Length of output: 7002


build_plan still has a race between the cache check and insert.

Two concurrent requests with the same plan_key can both miss the read-locked lookup, both route, and both insert different plan_ids; the later write overwrites plan_key_to_id, leaving the earlier CachedPlan unreachable from invalidate_plan. Recheck under the write lock before inserting, or keep the lookup/insert path under one lock, so the endpoint stays idempotent.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@modelexpress_server/src/weight_sync/service.rs` around lines 160 - 199, The
build_plan flow in WeightSyncService has a check-then-insert race between the
initial state.read() lookup of plan_key_to_id and the later state.write()
insert, which can create duplicate plans for the same req.plan_key. Fix it by
rechecking the cache under the write lock or by moving the lookup and insert
into a single locked critical section in build_plan, so only one plan_id is
created and stored for a given plan_key. Use the existing plan_key_to_id and
plans fields, along with build_plan and invalidate_plan, to keep the cached plan
mapping consistent and idempotent.

@nicolasnoble nicolasnoble left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Went through this properly. Inline comments are the code-level stuff, roughly blocker-first. Two I'd really want fixed before this merges: the M2N barrier has no timeout or reaper, so one inference worker that never registers hangs the whole collective and leaks the session, and the pull refit writes into live param memory with no rollback, so a mid-sync failure leaves the engine serving mixed old/new weights.

Few housekeeping things that don't need inline threads:

  • protocol/test_weight_transfer.py is byte-identical to tests/test_weight_transfer.py and lives inside the package, so include=["modelexpress*"] ships it in the wheel and the suite double-counts it. Drop the copy, pin testpaths.
  • tests/test_e2e_nccl_m2n.py is named for NCCL e2e transport but it's fully mocked, no NCCL and no transport. Rename or drop, especially now that #497 has real ones.
  • protocol/router.py is a dead byte-identical copy of planner/router.py, and _shard_width looks unused.
  • The new WeightLoaderAdapter + _CtxEngineAdapter duplicate EngineAdapter without adding a capability it lacks - could fold in.
  • The four new env vars (MX_TRAINER_TABLE_KEY, MX_TRAINER_SYNC_TIMEOUT, MX_REDIS_URL, MX_WEIGHT_SYNC_SERVER) aren't in docs/DEPLOYMENT.md.

Happy to talk through any of it.

// ---------------------------------------------------------------------------

fn unpack_runs(flat: &[i64]) -> Vec<(i64, i64)> {
flat.chunks(2).map(|c| (c[0], c[1])).collect()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

chunks(2) yields a 1-element final chunk on odd-length input, so c[1] panics, and this comes straight off the wire. chunks_exact(2), or validate even length and return invalid_argument.

Ok(Response::new(InvalidatePlanResponse { ok: true }))
}

async fn register_m2n_worker(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No timeout or reaper on this barrier. If a worker never registers, everyone else polls forever and the pending entry leaks. The p2p service has a reaper; this one doesn't. Can we stamp entries with a creation time and reap stale ones?

},
);

if entry.registrations.len() < entry.total_workers as usize {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

total_workers is an int32 off the wire; a negative value casts to a huge usize and the barrier can never fire. A late worker also gets a plan_id but no slice and polls forever. Reject total_workers <= 0 and unknown-rank late registrations.

}

let count = src_rem.min(dst_rem);
let src_addr = shard.device_addr + (src_rel * element_size) as u64;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This address math runs on unvalidated wire ints, so it overflow-panics in debug and wraps in release into a bad descriptor. checked_add/checked_mul and reject on overflow.

return result

def update_weights(self, ctx: LoadContext) -> None:
"""Execute a weight sync using the pre-built static plan.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No rollback here. sync() writes into live param memory, so if it throws partway the model's left with mixed old/new weights and keeps serving. There's a rollback() on the strategy but nothing calls it on this path. Stage into scratch and commit atomically, or force a reinit on any sync failure?


runs: list[tuple[int, int]] = []

def _collect(base: int, dim: int) -> None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Builds one tuple per element before merging; a contiguous lm_head/embedding is hundreds of millions of tuples in a Python loop. Worth a contiguous fast path so it doesn't OOM on real tensors.

"""Run model.load_weights() with LazyWeights and return recorded copies."""
recorder = BakeRecorder()
with recorder:
if hasattr(model, "load_weights"):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

When the model has no load_weights() this returns [] and the whole sync reports success having moved nothing, so the engine serves uninitialized weights. Should raise on a zero-byte plan.

sum(d.nbytes for d in self._descriptors) / 1e9,
)

remote_agents: dict[int, str] = {}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

add_remote_agent runs every initialize() but teardown() never deregisters, so a trainer that reshards repeatedly accumulates registrations. Track and remove them? (Same on the push side, push.py ~L75.)

logger = logging.getLogger("modelexpress.strategy_trainer_pull")


def _use_server_planner() -> bool:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

bool(os.environ.get("MX_WEIGHT_SYNC_SERVER", "")) treats =0 and =false as enabled, since any non-empty string is truthy. Parse through the envs registry with real truthy semantics.

}

/// Shared server state (plan cache + raw table blobs).
struct WeightSyncState {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

These maps are insert-only with no TTL and superseded plans get orphaned, so it's a slow leak over a long run. Evict on retarget/invalidate.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants