-
Notifications
You must be signed in to change notification settings - Fork 572
fix: MTP CP-aware roll, segment-aware roll, and synthetic packing support #4623
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,141 @@ | ||
| # Design Doc: MTP + CP + Packing Combined Fix | ||
|
|
||
| ## Summary | ||
|
|
||
| Fixes 3 correctness issues when combining Multi-Token Prediction (MTP), | ||
| Context Parallelism (AG-CP), and Packing. 4 files changed. | ||
|
|
||
| ## Motivation | ||
|
|
||
| | # | Issue | Impact | | ||
| | --- | -------------------------------------- | ------------------------------------------------------ | | ||
| | 1 | `jnp.roll` does not cross CP ranks | MTP shift-left loses cross-rank right-neighbor tokens | | ||
| | 2 | `roll_and_mask` is segment-unaware | Packing causes cross-document target leakage in MTP | | ||
| | 3 | Synthetic data has no real segment IDs | Cannot test CP + packing combination on synthetic data | | ||
|
|
||
| ## Design | ||
|
|
||
| ### 1. CP-Aware Left Shift (`multi_token_prediction.py`) | ||
|
|
||
| `jnp.roll(x, -1, axis=1)` only sees the local shard. With CP the sequence | ||
| is split across ranks, so the "right neighbor" of rank k's last token lives | ||
| on rank k+1 — unreachable by `jnp.roll`. | ||
|
|
||
| ``` | ||
| CP=2: rank0=[a0,a1,a2,a3], rank1=[a4,a5,a6,a7] | ||
| jnp.roll(rank0) → [a1,a2,a3,0] ← missing a4 | ||
| ``` | ||
|
|
||
| New function `_shift_left_one_cp_aware` uses `jax.lax.ppermute` in a | ||
| backward ring: | ||
|
|
||
| - Rank r sends its `first_token` to rank r-1 | ||
| - Rank r receives rank r+1's `first_token`, places it in `last_position` | ||
| - Rank cp_size-1 fills `last_position` with 0 (sequence end) | ||
| - Degrades to `jnp.roll` when CP=1 or no `"context"` axis — zero overhead | ||
|
|
||
| `roll_and_mask(shift=-1)` now delegates to `_shift_left_one_cp_aware`. | ||
|
|
||
| ### 2. Segment-Aware Roll (`multi_token_prediction.py`) | ||
|
|
||
| MTP shifts input_ids/target_ids/target_mask/position_ids left by one each | ||
| iteration to predict the "next token". Under packing, "next" may cross a | ||
| document boundary: | ||
|
|
||
| ``` | ||
| Doc A: [a0,a1,a2,a3] | Doc B: [b0,b1,b2,b3] | ||
| seg: [1, 1, 1, 1, 2, 2, 2, 2] | ||
| roll: [a1,a2,a3,b0, b1,b2,b3, 0] | ||
| ↑ cross-doc, must not participate in loss | ||
| ``` | ||
|
|
||
| New function `roll_and_mask_by_segment(x, segment_ids)`: | ||
|
|
||
| - Shifts both `x` and `segment_ids` via `_shift_left_one_cp_aware` | ||
| - `seg_current != seg_next` → document boundary → mask to 0 | ||
| - `seg_current == 0` → padding position → mask to 0 | ||
| - `segment_ids is None` → degrades to `roll_and_mask` | ||
|
|
||
| All rolling variables in `MultiTokenPredictionBlock.__call__` now use | ||
| `roll_and_mask_by_segment`. | ||
|
|
||
| **`segment_ids` itself uses `roll_and_mask` (not `by_segment`)**: avoids | ||
| a boundary-masked 0 being misinterpreted as a padding position in the next | ||
| iteration. | ||
|
|
||
| **`decoder_segment_ids` is not rolled**: passed to each MTP layer with the | ||
| original value. Self-attention hidden states maintain the original document | ||
| identity — positions 0-3 remain Doc A's hidden states; no synchronized | ||
| rolling is needed. | ||
|
|
||
| ### 3. Synthetic Data with Packed Segment IDs (`synthetic_data_processing.py`) | ||
|
|
||
| Upstream `base.yml` defaults to `packing: true`, but synthetic data's | ||
| `segment_ids` are always all-ones (no document boundaries). This means: | ||
|
|
||
| - `roll_and_mask_by_segment` acts identically to `roll_and_mask` on synthetic | ||
| data — segment boundary logic is untested | ||
| - `train_utils.py` outright rejects the `synthetic + packing + CP` combination | ||
|
|
||
| New function `_make_packed_segment_ids`: each row is split into 2..N | ||
| randomly-sized segments with sequential integer IDs starting from 1 | ||
| (0 = padding). Called in `__init__` when `packing=True`; old behavior | ||
| preserved when `packing=False`. | ||
|
|
||
| `train_utils.py` removes the `ValueError` guard against | ||
| `synthetic + packing + CP` — synthetic data now has real segment boundaries, | ||
| making the combination valid. | ||
|
|
||
| ### 4. Unit Tests (`multi_token_prediction_test.py`) | ||
|
|
||
| New `TestRollAndMaskBySegment`, 8 cases: | ||
|
|
||
| | Test case | Coverage | | ||
| | ------------------------------------------------- | ---------------------------------- | | ||
| | `test_no_segment_ids_falls_back_to_roll_and_mask` | seg=None → roll_and_mask | | ||
| | `test_single_segment_no_boundaries` | Single segment, only tail masked | | ||
| | `test_two_segments_masks_boundary` | Two segments, boundary masked | | ||
| | `test_padding_segment_masked` | seg=0 padding mask | | ||
| | `test_three_segments_boundaries` | Three-segment boundaries | | ||
| | `test_2d_tensor_shape_preserved` | 2D shape preserved | | ||
| | `test_3d_tensor_shape_preserved` | 3D (embedding) shape + mask verify | | ||
| | `test_shift_not_minus_one_raises` | shift≠-1 rejected | | ||
|
|
||
| ## Files Changed | ||
|
|
||
| | File | Change | +/− | | ||
| | --------------------------------------------- | -------------------------------------------------------------- | :---------: | | ||
| | `layers/multi_token_prediction.py` | `_shift_left_one_cp_aware`, `roll_and_mask_by_segment`, wiring | +108/−4 | | ||
| | `input_pipeline/synthetic_data_processing.py` | `_make_packed_segment_ids` with real boundaries | +43/−2 | | ||
| | `utils/train_utils.py` | Remove synthetic+packing+CP guard | −5 | | ||
| | `tests/unit/multi_token_prediction_test.py` | 17 new tests (segment + CP + packed_ids) | +336/−2 | | ||
| | **Total** | | **+492/−8** | | ||
|
|
||
| ## Backward Compatibility | ||
|
|
||
| - `_shift_left_one_cp_aware`: degrades to `jnp.roll` when CP=1 or no | ||
| `"context"` axis — zero overhead | ||
| - `roll_and_mask_by_segment`: degrades to `roll_and_mask` when | ||
| `segment_ids=None` | ||
| - `roll_and_mask(shift=-1)`: equivalent to the original `jnp.roll` path | ||
| when CP is off | ||
| - `synthetic_data_processing`: segment IDs remain `jnp.ones` when | ||
| `packing=False` | ||
| - `train_utils.py` guard removal does not affect non-synthetic scenarios | ||
|
|
||
| ## Verification (2026-07-17, TPU v6e-4) | ||
|
|
||
| | # | Configuration | main_model_loss | mtp_loss | | ||
| | --- | -------------------- | :-------------: | :-----------: | | ||
| | 1 | MTP + packing | 12.262 → 11.975 | 1.218 ~ 1.227 | | ||
| | 2 | MTP + CP=2 | 12.262 → 11.975 | 1.218 ~ 1.227 | | ||
| | 3 | MTP + CP=2 + packing | 12.261 → 11.961 | 1.216 ~ 1.228 | | ||
|
|
||
| All three loss curves are consistent, verifying `_shift_left_one_cp_aware` | ||
| and `roll_and_mask_by_segment` correctness under all combinations. | ||
|
|
||
| ```bash | ||
| # Unit tests | ||
| python3 -m unittest tests.unit/multi_token_prediction_test -v | ||
| # Ran 12 tests in 16.5s — OK | ||
| ``` |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -45,9 +45,53 @@ class mtp_acceptance(nnx.Intermediate): # pylint: disable=invalid-name | |
| """Variable type for storing MTP acceptance predictions -> 'mtp_acceptance' collection.""" | ||
|
|
||
|
|
||
| def _shift_left_one_cp_aware(x: jnp.ndarray, axis_name: str = "context") -> jnp.ndarray: | ||
| """Left-shift x by 1 along axis=1, pulling the next token across CP ranks. | ||
|
|
||
| Standard ``jnp.roll(x, -1, axis=1)`` only rolls WITHIN a local shard. Under | ||
| context parallelism the sequence is split across the ``axis_name`` mesh | ||
| axis, so the last token of rank r should receive the first token of rank | ||
| r+1. We use ``jax.lax.ppermute`` to fetch that token and stitch it in. | ||
|
|
||
| Rank cp_size - 1 has no successor; its last position receives 0 (matching | ||
| the no-CP semantics where position T-1 is always masked out). | ||
|
|
||
| Falls back to a plain local roll when ``axis_name`` is not in scope (no | ||
| shard_map / no CP) or when CP size is 1. | ||
|
|
||
| Args: | ||
| x: Input array with sequence dim at axis=1. | ||
| axis_name: Mesh axis along which the sequence is sharded. | ||
|
|
||
| Returns: | ||
| Array shaped like x, left-shifted by 1 across CP boundaries. | ||
| """ | ||
| local_rolled = jnp.roll(x, -1, axis=1) | ||
| local_rolled = local_rolled.at[:, -1:, ...].set(0) | ||
|
|
||
| try: | ||
| cp_size = jax.lax.psum(1, axis_name=axis_name) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is a very interesting way to collect cp_size. I suggest using
|
||
| except NameError: | ||
| return local_rolled | ||
| if cp_size == 1: | ||
| return local_rolled | ||
|
|
||
| cp_rank = jax.lax.axis_index(axis_name) | ||
| first_token = jax.lax.dynamic_slice_in_dim(x, 0, 1, axis=1) | ||
| # Backward ring: rank r sends first_token to rank r-1. | ||
| perm = [(r, (r - 1) % cp_size) for r in range(cp_size)] | ||
| next_first = jax.lax.ppermute(first_token, axis_name=axis_name, perm=perm) | ||
| next_first = jnp.where(cp_rank == cp_size - 1, jnp.zeros_like(next_first), next_first) | ||
| return local_rolled.at[:, -1:, ...].set(next_first) | ||
|
|
||
|
|
||
| def roll_and_mask(x: jnp.ndarray, shift: int = -1) -> jnp.ndarray: | ||
| """Performs a leftward roll on sequence axis and masks invalid positions. | ||
|
|
||
| When ``shift=-1``, the roll is CP-aware: it pulls the next token across | ||
| CP rank boundaries via ``_shift_left_one_cp_aware`` rather than wrapping | ||
| locally. | ||
|
|
||
| Args: | ||
| x: Input array of shape [batch, seq_len, ...]. | ||
| shift: Number of positions to shift left. | ||
|
|
@@ -57,9 +101,53 @@ def roll_and_mask(x: jnp.ndarray, shift: int = -1) -> jnp.ndarray: | |
| """ | ||
| if shift == 0: | ||
| return x | ||
| if shift == -1: | ||
| return _shift_left_one_cp_aware(x) | ||
| return jnp.roll(x, shift, axis=1).at[:, shift:, ...].set(0) | ||
|
|
||
|
|
||
| def roll_and_mask_by_segment(x: jnp.ndarray, segment_ids: jnp.ndarray | None, shift: int = -1) -> jnp.ndarray: | ||
| """Rolls sequence left within document boundaries defined by segment_ids. | ||
|
|
||
| For each position, if the next position belongs to a different segment (or | ||
| is the last position), the rolled value is zeroed out instead of wrapping | ||
| around from the next document. | ||
|
|
||
| When ``segment_ids`` is None, this behaves like ``roll_and_mask``, only | ||
| zeroing the last position. | ||
|
|
||
| Args: | ||
| x: Input array of shape [batch, seq_len, ...]. | ||
| segment_ids: Integer segment IDs of shape [batch, seq_len], or None. | ||
| Same segment ID = same document. 0 = padding/EOD. | ||
| If None, falls back to simple roll_and_mask behavior. | ||
| shift: Number of positions to shift left (must be -1). | ||
|
|
||
| Returns: | ||
| Rolled array with cross-boundary and tail positions zeroed. | ||
| """ | ||
| assert shift == -1, f"roll_and_mask_by_segment only supports shift=-1, got {shift}" | ||
|
|
||
| if segment_ids is None: | ||
| return roll_and_mask(x, shift) | ||
|
|
||
| rolled = _shift_left_one_cp_aware(x) | ||
|
|
||
| seg_current = segment_ids | ||
| seg_next = _shift_left_one_cp_aware(segment_ids) | ||
|
|
||
| # A position is a boundary if: | ||
| # 1. current segment != next segment (document boundary), OR | ||
| # 2. current segment == 0 (padding/EOD position) | ||
| is_boundary = (seg_current != seg_next) | (seg_current == 0) | ||
|
|
||
| mask = is_boundary | ||
| for _ in range(x.ndim - 2): | ||
| mask = jnp.expand_dims(mask, axis=-1) | ||
|
|
||
| return jnp.where(mask, 0, rolled) | ||
|
|
||
|
|
||
| class MultiTokenPredictionLayer(nnx.Module): | ||
| """Multi-Token Prediction layer: normalize, concatenate, project, and transform. | ||
|
|
||
|
|
@@ -272,17 +360,26 @@ def __call__( | |
| rolled_target_ids = target_ids | ||
| rolled_target_mask = target_mask | ||
| rolled_position_id = position_ids | ||
| # Track segment boundaries for segment-aware rolling. | ||
| # decoder_segment_ids itself is NOT rolled when passed to each MTP layer -- | ||
| # the hidden state maintains original doc identity through properly masked | ||
| # self-attention. Only the rolling variables need segment-aware shift to | ||
| # avoid cross-document target leakage. | ||
| rolled_segment_ids = decoder_segment_ids | ||
|
|
||
| mtp_losses_list = [] | ||
| mtp_weights_list = [] | ||
| mtp_preds_list = [] | ||
| mtp_masks_list = [] | ||
|
|
||
| for k in range(1, cfg.mtp_num_layers + 1): | ||
| rolled_input_ids = roll_and_mask(rolled_input_ids) | ||
| rolled_target_ids = roll_and_mask(rolled_target_ids) | ||
| rolled_target_mask = roll_and_mask(rolled_target_mask) | ||
| rolled_position_id = roll_and_mask(rolled_position_id) | ||
| rolled_input_ids = roll_and_mask_by_segment(rolled_input_ids, rolled_segment_ids) | ||
| rolled_target_ids = roll_and_mask_by_segment(rolled_target_ids, rolled_segment_ids) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is rolled_segment_ids aligned with rolled_target_ids here? target_ids is already shifted by 1 so after this roll the actual target is 2 tokens ahead? segment IDs passed to the helper seem to track only 1 token |
||
| rolled_target_mask = roll_and_mask_by_segment(rolled_target_mask, rolled_segment_ids) | ||
| rolled_position_id = roll_and_mask_by_segment(rolled_position_id, rolled_segment_ids) | ||
| # Roll segment_ids itself for the next iteration (using plain roll). | ||
| if rolled_segment_ids is not None: | ||
| rolled_segment_ids = roll_and_mask(rolled_segment_ids) | ||
|
|
||
| target_token_embedding = self.decoder._apply_embedding( | ||
| shared_embedding, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would this still work with load balancing on? If not perhaps we should handle it differently or reject LB?