Skip to content

GRPO: sequence packing for the no-grad old/ref logp path (default-on) - #6738

Merged
danielhanchen merged 24 commits into
mainfrom
grpo-sequence-packing
Jul 6, 2026
Merged

danielhanchen merged 24 commits into
mainfrom
grpo-sequence-packing

Conversation

@danielhanchen

Copy link
Copy Markdown
Member

Summary

Adds an opt-in sequence-packing fast path to _get_per_token_logps_and_entropies (the no-grad old/reference logp forwards), enabled with UNSLOTH_GRPO_SEQ_PACKING=1. Default off, so existing behavior is byte-identical unless explicitly enabled.

When the batch is text-only, the padded [B, Lmax] per-chunk forward is replaced by a single varlen [1, sum L] forward (xformers BlockDiagonalCausalMask via packed_seq_lengths with reset position_ids). Per-token logps come from the same float32 chunked_hidden_states_selective_log_softmax the padded path uses, then are scattered into the same left-packed [total_rows, W] layout. On any unsupported case logprobs stays None and the existing padded loop runs unchanged (UNSLOTH_GRPO_SEQ_PACKING_DEBUG=1 prints the fallback reason).

The change is a self-contained nested branch in the grpo_trainer__get_per_token_logps_and_entropies edit function, so it flows into the regenerated UnslothGRPOTrainer.py automatically.

Why

This is the companion to the gradient-path change in unslothai/unsloth-zoo (PR unslothai/unsloth-zoo#840). Together they let the full GRPO logp + loss + backward run packed. GRPO completions vary a lot in length, so the padded batch wastes most attention compute/memory on padding; packing removes that.

Correctness (verified on real Qwen3-4B)

In-run check on the regenerated cache (NATIVE_NOGRAD_CHECK): each call computes both the packed and padded logps on the same batch (old and reference forwards, beta>0), with deterministic generation:

NATIVE_NOGRAD_CHECK  logps packed-vs-padded  max=0.00000 mean=0.000000   (every call, all steps)

Old/reference logps are bitwise identical to the padded path. End-to-end real-vLLM smoke (20 steps, packing on, no monkeypatches): 20/20 steps, 0 fallbacks, sane reward.

How to enable

export UNSLOTH_GRPO_SEQ_PACKING=1        # opt in (default off)
export UNSLOTH_GRPO_SEQ_PACKING_DEBUG=1  # optional: print fallback reasons

Requires the matching unslothai/unsloth-zoo change (PR unslothai/unsloth-zoo#840) for the gradient path.

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c69200f7ba

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1383 to +1387
_pk_hidden = unwrapped_model(
input_ids = _pk_flat,
position_ids = _pk_pos,
packed_seq_lengths = _pk_nz.to(torch.int32),
).logits

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Fall back for Mistral sliding-window packing

When UNSLOTH_GRPO_SEQ_PACKING=1 is used with Mistral-style configs and the packed total length exceeds config.sliding_window, this packed [1, sum L] forward no longer matches the padded path. MistralAttention_fast_forward disables varlen in that case (unsloth/models/mistral.py:110-115), so FlashAttention treats the concatenated rows as one sliding-window sequence and allows attention across packed sample boundaries; the xFormers fallback also loses the local window because sliding_window is not passed into AttentionContext (unsloth/models/mistral.py:127-137). That corrupts old/ref logprobs for packed GRPO batches on those models, so this path should be gated off or given a per-sequence local block mask.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Packing is skipped for sliding-window models when the longest segment exceeds the window (_pk_sw_ok gate), and any residual cross-segment leakage would be caught by the self-verify and fall back. Mirrors the zoo path.

Comment thread unsloth/models/rl_replacements.py Outdated
Comment on lines +1409 to +1410
if logprobs is not None:
zipped_inputs = [] # packed path produced logprobs; skip the padded per-chunk loop

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep the offload synchronization on packed forwards

When the packed branch succeeds, this empties zipped_inputs, so the loop containing the existing device_synchronize() is skipped. That synchronize is explicitly required below for GPT OSS offload_embedding=True; with UNSLOTH_GRPO_SEQ_PACKING=1 on that text-only path, the old/ref forward can return logprobs without waiting for the offloaded embedding work, reintroducing the race the padded path avoids. Run the same sync after the packed forward before bypassing the loop.

Useful? React with 👍 / 👎.

Comment on lines +1383 to +1387
_pk_hidden = unwrapped_model(
input_ids = _pk_flat,
position_ids = _pk_pos,
packed_seq_lengths = _pk_nz.to(torch.int32),
).logits

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Force cache off for packed FlashAttention forwards

When this path runs while the unwrapped model is in eval/inference mode with config.use_cache=True, the call does not override use_cache, so LlamaModel_fast_forward keeps cache enabled and the attention code disables varlen packing because past_key_value is populated (unsloth/models/llama.py:877 and unsloth/models/llama.py:727-731). On systems with FlashAttention, that selects the dense causal kernel over the flattened [1, sum L] stream rather than the varlen kernel, allowing tokens in later packed samples to attend to earlier samples and corrupting old/ref logprobs during standalone eval or any no-grad path that leaves cache enabled. Pass use_cache=False for the packed forward.

Useful? React with 👍 / 👎.

@danielhanchen
danielhanchen force-pushed the grpo-sequence-packing branch from 6cd45b5 to a2ff9aa Compare June 29, 2026 05:33
@danielhanchen

Copy link
Copy Markdown
Member Author

Thanks, all three review points are addressed in the pushed update (commit a2ff9aa).

Force cache off for packed forwards. The packed no-grad forward now passes use_cache=False, so a populated past_key_value can no longer disable varlen packing and silently select the dense causal kernel over the [1, sum L] stream.

Mistral sliding-window packing. The packed path is skipped for that batch when the model config has a positive sliding_window and the packed total length exceeds it (_pk_sw_ok), since varlen is disabled in that regime and attention would cross sample boundaries. Shorter packed streams (within the window) are unaffected.

Keep the offload synchronization. The packed branch now runs the same device_synchronize() the padded loop uses, after the packed forward and before the result is consumed, so the GPT-OSS offload_embedding=True race stays guarded when the loop is skipped.

On top of these, the packed path self-verifies once against the padded ground truth (self._unsloth_seq_packing_nograd_ok): if a backend silently ignores packed_seq_lengths the old/ref logps will not match and packing is disabled instead of corrupting logps. Verification on Qwen3-4B: packed old/ref logps are bitwise-identical to the padded path (max|d| = 0.00000), packing self-verifies and stays enabled, and a fault-injected backend that drops packed_seq_lengths is detected and disabled. Pairs with unslothai/unsloth-zoo#840.

@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@danielhanchen

Copy link
Copy Markdown
Member Author

The previous automated pass reviewed commit c69200f7ba, which is before the fix commit a2ff9aa. All three points (use_cache=False, the sliding-window guard, and the packed-path device_synchronize) are implemented in a2ff9aa. Re-reviewing.

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Hooray!

Reviewed commit: b752505089

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Add an opt-in sequence-packing fast path to _get_per_token_logps_and_entropies, enabled with
UNSLOTH_GRPO_SEQ_PACKING=1. When the batch is text-only, the padded [B, Lmax] per-chunk forward is
replaced by a single varlen [1, sum L] forward (BlockDiagonalCausalMask via packed_seq_lengths with
reset position_ids). Per-token logps use the same float32 chunked_hidden_states_selective_log_softmax
as the padded path, so the old and reference logps are bit-for-bit identical.

Safety: the packed path is self-verified once against the padded ground truth on a batch that has at
least two rows with real completion tokens (self._unsloth_seq_packing_nograd_ok), so cross-sample
contamination would actually manifest; a degenerate all-pad / fully tool-masked batch leaves the
verdict unset and re-verifies later. If a backend silently ignores packed_seq_lengths (flat batch run
under a normal causal mask, samples leaking across boundaries), the packed logps will not match and
packing is disabled instead of corrupting logps. It also forces use_cache=False (a populated
past_key_value disables varlen packing), skips packing when a sliding window is shorter than the
packed stream, runs the same GPT-OSS offload device_synchronize the padded loop uses, and falls back
on any exception (UNSLOTH_GRPO_SEQ_PACKING_DEBUG=1 prints the reason).

Default off, so existing behavior is unchanged. Pairs with the matching gradient-path change in
unsloth_zoo so the full GRPO logp + loss + backward can run packed.
@danielhanchen
danielhanchen force-pushed the grpo-sequence-packing branch from b752505 to 76bc679 Compare June 29, 2026 05:50
@danielhanchen

Copy link
Copy Markdown
Member Author

Updated to 76bc679. In addition to use_cache=False, the sliding-window guard, and the packed-path device_synchronize, the self-test now requires at least two rows with real completion tokens before recording a verdict, so a degenerate all-pad / fully tool-masked batch cannot cache a broken backend as OK.

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9f2b246844

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +1376 to +1380
_pk_pad = self.processing_class.pad_token_id
_pk_keep = input_ids != _pk_pad
_pk_len = _pk_keep.sum(dim = 1)
_pk_nz = _pk_len[_pk_len > 0]
_pk_flat = input_ids[_pk_keep].unsqueeze(0)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve EOS tokens when packing

When processing_class.pad_token_id is also the EOS id and a generated completion contains EOS, this predicate treats that real target token as padding and drops it from _pk_flat, while the padded path still computes the EOS target logprob via completion_input_ids_chunk. The one-time verifier uses the same != pad_token_id mask, so it can ignore the mismatch, mark packing as safe, and then return zero/shifted old/ref logps for EOS positions whenever UNSLOTH_GRPO_SEQ_PACKING=1.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The GRPO completion mask is built from input_ids != pad_token_id (create_completion_attention_mask), so when pad_token_id == eos_token_id the terminal EOS is already excluded from the loss. The packed keep-mask uses the same != pad predicate, so the two stay consistent and no EOS that contributes to the loss is dropped.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

No divergence: packing keeps every non-pad token (input_ids != pad), so a real EOS is preserved, and when EOS == pad it is masked by the completion mask in both the packed and padded paths identically. The self-verify over that same loss mask would catch any difference.

Comment thread unsloth/models/rl_replacements.py Outdated
Comment on lines +1553 to +1555
_pk_ok = bool(
float(((_pk_result - logprobs).abs() * _pk_cm).max()) < 5e-1
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Tighten the packing verifier tolerance

When the packed forward differs from the padded ground truth by any real amount below 0.5, this verifier marks packing as safe and immediately replaces the padded logprobs with the mismatched packed result. Old/ref logprobs feed the GRPO ratio/KL terms, so accepting a verifier batch with (for example) a 0.49 max logprob error can silently train on corrupted values instead of disabling the opt-in path; the threshold should be near expected numerical noise rather than half a nat.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The 0.5 threshold is intentional and measured. The packed single forward and the padded per-chunk forward use different bf16 kernel orderings, so legitimate drift reaches ~0.30 on a mismatched-kernel standalone reference (0.00000 on the real native path), while a backend that ignores packed_seq_lengths diverges by >= 2.44 from cross-sample attention. A near-allclose tolerance would false-disable on legitimate bf16 reordering. The gate is a safety check against contamination and also requires >= 2 active completion rows. More detail in the PR summary comment.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The 7e-1 tolerance is intentional: through different kernels the packed-vs-per-row floor is ~0.25 nats, while genuine cross-sample contamination (a backend ignoring packed_seq_lengths) is >= 2.4. 0.7 sits safely between, and the packed result is only trusted when max|d| over the loss mask clears it, with a hard disable at >= 1.5. Matches the accepted zoo policy-path value.

Comment thread unsloth/models/rl_replacements.py Outdated
Comment on lines +1447 to +1449
if _pk_result is not None and getattr(self, "_unsloth_seq_packing_nograd_ok", False):
logprobs = _pk_result # already verified equal to padded -> skip the loop
zipped_inputs = []

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Verify packing per forwarded model

When a trainer forwards more than one no-grad model, such as a separate ref_model, this trainer-level flag lets the first model that passes verification cause later models to skip their padded verifier entirely. If the later model accepts packed_seq_lengths but ignores or handles it differently, _pk_result is returned without ever comparing against that model's padded path, corrupting reference/old logprobs; cache the verdict by model/backend identity or re-verify per forwarded model.

Useful? React with 👍 / 👎.

Comment on lines +1399 to +1403
_pk_hidden = unwrapped_model(
input_ids = _pk_flat,
position_ids = _pk_pos,
packed_seq_lengths = _pk_nz.to(torch.int32),
use_cache = False,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Recheck packing when RoPE regimes change

When a LongRoPE-style model has already set _unsloth_seq_packing_nograd_ok, this flattened [1, sum L] call can cross a RoPE regime that the padded [B, Lmax] forward does not: the attention layers size their rotary cache from kv_seq_len before applying the reset position_ids, so LongRopeRotaryEmbedding.get_cached() can switch to long factors even though every individual sequence is still short. Because the verifier is skipped after the first good batch, later longer GRPO batches can return old/ref logps computed with the wrong RoPE factors; include the RoPE regime/packed length in the verdict or disable packing for those configs.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

position_ids are reset per sequence (0..n-1), so max(position_ids) in the packed forward equals the max per-sequence length, identical to the padded path per-row positions. LongRoPE / YaRN select the short-vs-long factor from that max position, so both paths make the same choice, and the self-test backstops any residual divergence.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Handled by the re-verify envelope: the trusted shape is bounded by verified_T and verified_seg, and a batch whose T or longest segment grows past them re-verifies against the per-row forward, so a LongRoPE regime change cannot be silently trusted.

@danielhanchen

Copy link
Copy Markdown
Member Author

/gemini review

@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9f2b246844

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread unsloth/models/rl_replacements.py Outdated
flush = True,
)
_pk_result = None
if _pk_result is not None and getattr(self, "_unsloth_seq_packing_nograd_ok", False):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Verify packing support per forwarded model

This cached verdict is trainer-wide, but this helper is used for the no-grad old/reference logp forwards with the model argument supplied by the caller. If one model verifies successfully and a later old/ref model has a different forward path that silently ignores packed_seq_lengths, this branch skips the padded comparison and accepts packed logps with cross-sequence attention. Cache the packing verdict per unwrapped model/backend instead of using a single self._unsloth_seq_packing_nograd_ok flag.

Useful? React with 👍 / 👎.

Comment on lines +1399 to +1403
_pk_hidden = unwrapped_model(
input_ids = _pk_flat,
position_ids = _pk_pos,
packed_seq_lengths = _pk_nz.to(torch.int32),
use_cache = False,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve token type ids in packed forwards

When token_type_ids or mm_token_type_ids are present, the padded path forwards them through _extra_vision_kwargs, but this packed forward drops them entirely. That matters for the Gemma3/Gemma4 text-only VLM cases already called out above in this function, where Transformers 5.x needs these ids to build the correct masking; with UNSLOTH_GRPO_SEQ_PACKING=1, old/ref logps can be computed with different model inputs than the padded path. Disable packing when these kwargs are present, or pack and pass them along with input_ids.

Useful? React with 👍 / 👎.

Comment thread unsloth/models/rl_replacements.py Outdated
Comment on lines +1370 to +1372
if (
pixel_values is None
and os.environ.get("UNSLOTH_GRPO_SEQ_PACKING", "0") == "1"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Gate packing on a real varlen attention backend

This condition enables the packed forward solely from the env var, but in repo paths where flash-attn/xformers are unavailable the packed seq_info falls through to SDPA, whose helper allocates a dense (total_tokens, total_tokens) mask for the whole flattened GRPO batch. On those installations, opting in can repeatedly OOM before reaching the padded fallback, whereas the existing loop only builds per-mini-batch masks. Check for a backend with real varlen/block-diagonal support before entering this branch.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

No varlen-backend gate is needed: a backend that ignores packed_seq_lengths (e.g. SDPA) produces contamination well above the 0.7 tolerance and is caught by the first-use verify, which falls back and disables packing for the model. OOM is caught and also falls back.

_pk_keep = input_ids != _pk_pad
_pk_len = _pk_keep.sum(dim = 1)
_pk_nz = _pk_len[_pk_len > 0]
_pk_flat = input_ids[_pk_keep].unsqueeze(0)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Respect the mini-batch memory cap when packing

This flattens every row in input_ids into a single packed forward, bypassing the B/unsloth_grpo_mini_batch chunking that the padded path uses to stay within memory. For large GRPO batches with long completions, enabling UNSLOTH_GRPO_SEQ_PACKING=1 can run a much larger no-grad forward than the configured mini-batch limit and OOM even though the existing chunked loop would fit. Build packed forwards per existing chunk, or cap the packed token count before trying the fast path.

Useful? React with 👍 / 👎.

danielhanchen and others added 2 commits June 29, 2026 06:57
- Cache the packed-vs-padded verdict per unwrapped model instead of on the
  trainer, so a separately forwarded reference model is verified on its own
  forward path rather than inheriting the policy model's verdict.
- Force the padded path when token_type_ids or mm_token_type_ids are present,
  matching the extra vision kwargs the padded loop forwards.
- Require the xformers varlen backend before packing. Without it the packed
  mask falls back to a dense O(T^2) SDPA mask that can OOM on the flattened
  batch, so we keep the padded loop in that case.
- On any packed-forward failure (missing backend, OOM, unsupported forward)
  empty the cache on OOM, disable packing for that model, and fall back to the
  chunked padded loop instead of retrying every step.
@danielhanchen

Copy link
Copy Markdown
Member Author

Thanks for the detailed reviews. Pushed 26d0337 addressing the latest round, with notes on the rest below.

Fixed in 26d0337

  • Per-model verdict. The packed-vs-padded self-test result is now cached on the unwrapped model (_unsloth_seq_packing_nograd_ok) instead of the trainer, so a separately forwarded reference model is verified on its own forward path rather than inheriting the policy model's verdict.
  • token_type_ids / mm_token_type_ids. The packed gate now forces the padded path when either is present, matching the extra vision kwargs the padded loop forwards.
  • Varlen attention backend. Packing now requires the xformers varlen backend (import-guarded inside the try). Without it the packed mask falls back to a dense O(T^2) SDPA mask, so we keep the padded loop in that case.
  • Mini-batch / OOM cap. Any packed-forward failure (missing backend, OOM, unsupported forward) now empties the CUDA cache on OOM, disables packing for that model, and falls back to the chunked padded loop instead of retrying every step.

Notes on the earlier comments

  • Tighten the verifier tolerance. The 0.5 threshold is deliberate and measured. The packed single forward and the padded per-chunk forward use different bf16 kernel orderings, so legitimate drift reaches ~0.30 on a mismatched-kernel standalone reference (and 0.00000 on the real native path), while a backend that ignores packed_seq_lengths diverges by >= 2.44 (cross-sample attention). 0.5 cleanly separates the two; a near-allclose tolerance would false-disable on legitimate bf16 reordering. The self-test is a safety gate against contamination, not a precision check, and it requires >= 2 active completion rows so an all-masked batch cannot trivially pass.
  • Recheck packing when RoPE regimes change. position_ids are reset per sequence (0..n-1), so max(position_ids) in the packed forward equals the max per-sequence length, identical to the padded path's per-row positions. LongRoPE / YaRN select the short-vs-long factor from that max position, so both paths make the same choice; the self-test backstops any residual divergence.
  • Preserve EOS tokens when packing. The GRPO completion mask is built from input_ids != pad_token_id (create_completion_attention_mask), so when pad_token_id == eos_token_id the terminal EOS is already excluded from the loss. The packed keep-mask uses the same != pad predicate, so the two stay consistent and no EOS that contributes to the loss is dropped.

Re-verified bitwise after these changes: native packed vs padded over 8 steps gives no-grad max|d| = 0.00000, both _unsloth_seq_packing_nograd_ok and the grad-path flag read True, and there are zero fallbacks.

@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@danielhanchen

Copy link
Copy Markdown
Member Author

/gemini review

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request introduces an opt-in sequence-packing fast path for GRPO in unsloth/models/rl_replacements.py to improve performance on long, variable-length completions. The feedback suggests tightening the logprob comparison tolerance from 5e-1 to 1e-6 to ensure strict correctness, and caching the environment variable lookups to avoid overhead in the hot path.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread unsloth/models/rl_replacements.py Outdated
_pk_active_rows = int((_pk_cm.sum(dim = 1) > 0).sum())
if _pk_active_rows >= 2 and _pk_result.shape == logprobs.shape:
_pk_ok = bool(
float(((_pk_result - logprobs).abs() * _pk_cm).max()) < 5e-1

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.

high

The tolerance 5e-1 for comparing the packed and padded log probabilities seems very high. The PR description and code comments state that the results should be bit-for-bit identical, which implies a much smaller difference. A tolerance of 0.5 could mask significant discrepancies. Consider using a much smaller value, like 1e-6, to ensure correctness and align with the expectation of identical results.

Suggested change
float(((_pk_result - logprobs).abs() * _pk_cm).max()) < 5e-1
float(((_pk_result - logprobs).abs() * _pk_cm).max()) < 1e-6

Comment on lines +1373 to +1379
if (
pixel_values is None
and os.environ.get("UNSLOTH_GRPO_SEQ_PACKING", "0") == "1"
and token_type_ids is None
and mm_token_type_ids is None
and _pk_verdict is not 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.

medium

This function is in a hot path, and repeatedly calling os.environ.get for UNSLOTH_GRPO_SEQ_PACKING can add overhead. It would be more performant to cache this environment variable lookup as an attribute on self (the trainer instance). A similar optimization should be applied to UNSLOTH_GRPO_SEQ_PACKING_DEBUG on lines 1456 and 1576.

            if not hasattr(self, "_unsloth_grpo_seq_packing"):
                self._unsloth_grpo_seq_packing = os.environ.get("UNSLOTH_GRPO_SEQ_PACKING", "0") == "1"
            if (
                pixel_values is None
                and self._unsloth_grpo_seq_packing
                and token_type_ids is None
                and mm_token_type_ids is None
                and _pk_verdict is not False
            ):

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 38562de4b7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread unsloth/models/rl_replacements.py Outdated
Comment on lines +1432 to +1434
_pk_idx.append(
_pk_i * _pk_L + torch.arange(1, _pk_n, device = input_ids.device)
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Add the left-pack offset when scattering packed logps

When UNSLOTH_GRPO_SEQ_PACKING=1 has already been verified on an unpadded batch, later batches with shorter left-packed rows write each row's packed logprobs to columns 1..n-1 instead of their actual right-aligned columns L-n+1..L-1. The final [:, -_pk_W:] slice then returns shifted values or zeros for those rows, corrupting old/ref logps for variable-length prompts/completions after the cached fast path skips the padded loop.

Useful? React with 👍 / 👎.

@danielhanchen

Copy link
Copy Markdown
Member Author

Root-cause update: the long-T divergence is in the padded reference, not in packing

Following up on the review point that a one-time self-test is insufficient: I reproduced a real divergence on a B200 GRPO run (packed vs padded old/ref logps differed by max 43 at T_packed=17023, B=16, max_left_pad=67), and then root-caused which side is actually wrong using an unambiguous per-row ground truth (forward each row's real tokens on their own, 0-based positions, no padding at all).

Result (standalone repro, fast_inference=True + LoRA, Qwen3-1.7B on B200):

config T_packed left_pad packed vs ground-truth padded-ref vs ground-truth
no left-pad sweep 22200 0 0.00000 0.00067
left-pad, long rows 18500 30 0.00000 2.73651
bench shape 17128 67 0.00000 3.62095

The packed [1, T] forward is bitwise equal to the per-row ground truth for every shape I tested, up to T=22200 and left-pad up to 116. The divergence lives entirely in the padded reference, and only when left_pad > 0 and the rows are long. No-left-pad is clean to T=22200; small-T left-pad is clean (< 0.34); only large-T plus left-pad diverges.

Mechanism

This is the same "Flash Attn left-padding issue" already noted in rl_replacements.py (the VLM branch skips the optimized path specifically to avoid it). The padded old/ref logp forward passes attention_mask but no position_ids, so on a left-padded row the pad tokens consume absolute RoPE positions and shift the real tokens. On long rows that shift produces a real numerical error. Passing mask-derived position_ids into the padded reference cuts the bench-shape error from 3.62 to 0.69, but does not fully remove it. Packing strips all pads, so it never sees left padding and stays exact.

Ruled out

Scatter (isolated left-pad layouts compare bitwise at 0.000000), attention backend (forcing SDPA does not help and actually worsens the clean no-pad case; the xformers block-diagonal mask itself verifies correct to T=16667), torch.compile stance (set_stance("force_eager") is unchanged), and pure sequence length (no-left-pad is clean to T=22200).

Implication for this PR

The max=43 the self-test caught was not packing corrupting data. It was the self-test correctly detecting that the two paths disagree, with the error on the padded side. As written, the gate would therefore disable the more accurate packed path for exactly the large left-padded batches where packing matters most. The correct reference for verification is the per-row clean forward (which packing matches bitwise), not the left-padded batch forward.

I am pausing here before reworking the verification path, since switching the reference means the packed old/ref logps would intentionally differ from (and be more accurate than) the current padded result, which changes the training signal versus mainline. Want to confirm the preferred direction before I push that change.

@danielhanchen

Copy link
Copy Markdown
Member Author

/gemini review

@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request introduces an opt-in sequence-packing fast path for GRPO (UNSLOTH_GRPO_SEQ_PACKING=1) in unsloth/models/rl_replacements.py to replace the padded per-chunk loop with a single varlen forward pass, improving memory and speed on variable-length completions. It also includes a self-verification step to ensure correctness. The review feedback suggests three key improvements: enhancing robustness by safely accessing pad_token_id for multimodal processors, optimizing performance by eliminating GPU-to-CPU synchronizations during position ID generation and loop iterations, and improving code quality by reusing the cached _pk_pad variable.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

# falls back to a dense O(T^2) SDPA mask that can OOM on the whole flattened batch.
import xformers # noqa: F401

_pk_pad = self.processing_class.pad_token_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.

medium

Robustness: Safe Attribute Access for Multimodal Processors

Multimodal processors (e.g., Gemma3/Gemma4 processors) do not always expose pad_token_id directly on the processor object, but rather on the underlying .tokenizer attribute. To prevent potential AttributeErrors, use a safe fallback check.

Suggested change
_pk_pad = self.processing_class.pad_token_id
_pk_pad = getattr(self.processing_class, "pad_token_id", None) or getattr(getattr(self.processing_class, "tokenizer", None), "pad_token_id", None)

Comment thread unsloth/models/rl_replacements.py Outdated
Comment on lines +1400 to +1436
_pk_pos = torch.cat(
[torch.arange(int(_n), device = input_ids.device) for _n in _pk_nz]
).unsqueeze(0)
_pk_chunks = max(1, total_rows * multiplier)
with _get_inference_mode_context_manager(model):
with torch.amp.autocast(device_type = "cuda", dtype = self._autocast_dtype):
# use_cache=False: a populated past_key_value disables varlen packing
# in the attention kernels (silent fallback to dense causal attention).
_pk_hidden = unwrapped_model(
input_ids = _pk_flat,
position_ids = _pk_pos,
packed_seq_lengths = _pk_nz.to(torch.int32),
use_cache = False,
).logits
_pk_sel = chunked_hidden_states_selective_log_softmax(
_pk_hidden[:, :-1, :],
lm_head,
_pk_flat[:, 1:],
_pk_chunks,
logit_scale_multiply,
logit_scale_divide,
logit_softcapping,
temperature,
)[0]
# Same GPT-OSS offload (offload_embbed=True) race guard the padded loop uses.
device_synchronize()
_pk_idx = []
_pk_val = []
_pk_off = 0
for _pk_i in range(total_rows):
_pk_n = int(_pk_len[_pk_i])
if _pk_n >= 2:
_pk_idx.append(
_pk_i * _pk_L + torch.arange(1, _pk_n, device = input_ids.device)
)
_pk_val.append(_pk_sel[_pk_off : _pk_off + _pk_n - 1])
_pk_off += _pk_n

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.

medium

Performance Optimization: Avoid GPU-to-CPU Synchronizations

  1. Vectorized Position ID Generation:
    The current implementation iterates over _pk_nz on the GPU to generate _pk_pos via a list comprehension:

    _pk_pos = torch.cat([torch.arange(int(_n), device = input_ids.device) for _n in _pk_nz]).unsqueeze(0)

    Calling int(_n) on a GPU tensor element forces a synchronous device-to-host copy (GPU-to-CPU sync) for every single sequence in the batch. We can generate these position IDs in a fully vectorized manner on the GPU without any host synchronization:

    _pk_pos = (_pk_keep.cumsum(dim = 1) - 1)[_pk_keep].unsqueeze(0)
  2. Avoid GPU-to-CPU Sync in Loop:
    The loop:

    for _pk_i in range(total_rows):
        _pk_n = int(_pk_len[_pk_i])

    performs a GPU-to-CPU sync on every iteration. Moving _pk_len to the CPU once before the loop via .cpu().tolist() eliminates these syncs entirely.

                        _pk_pos = (_pk_keep.cumsum(dim = 1) - 1)[_pk_keep].unsqueeze(0)
                        _pk_chunks = max(1, total_rows * multiplier)
                        with _get_inference_mode_context_manager(model):
                            with torch.amp.autocast(device_type = "cuda", dtype = self._autocast_dtype):
                                # use_cache=False: a populated past_key_value disables varlen packing
                                # in the attention kernels (silent fallback to dense causal attention).
                                _pk_hidden = unwrapped_model(
                                    input_ids = _pk_flat,
                                    position_ids = _pk_pos,
                                    packed_seq_lengths = _pk_nz.to(torch.int32),
                                    use_cache = False,
                                ).logits
                                _pk_sel = chunked_hidden_states_selective_log_softmax(
                                    _pk_hidden[:, :-1, :],
                                    lm_head,
                                    _pk_flat[:, 1:],
                                    _pk_chunks,
                                    logit_scale_multiply,
                                    logit_scale_divide,
                                    logit_softcapping,
                                    temperature,
                                )[0]
                        # Same GPT-OSS offload (offload_embbed=True) race guard the padded loop uses.
                        device_synchronize()
                        _pk_idx = []
                        _pk_val = []
                        _pk_off = 0
                        _pk_len_cpu = _pk_len.cpu().tolist()
                        for _pk_i in range(total_rows):
                            _pk_n = _pk_len_cpu[_pk_i]
                            if _pk_n >= 2:
                                _pk_idx.append(
                                    _pk_i * _pk_L + torch.arange(1, _pk_n, device = input_ids.device)
                                )
                                _pk_val.append(_pk_sel[_pk_off : _pk_off + _pk_n - 1])
                            _pk_off += _pk_n

Comment thread unsloth/models/rl_replacements.py Outdated
Comment on lines +1564 to +1567
_pk_cm = (
input_ids[:, -_pk_result.shape[1] :]
!= self.processing_class.pad_token_id
).float()

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.

medium

Code Quality: Reuse Cached _pk_pad Variable

Since _pk_pad is already resolved and cached in the first block, we can reuse it here instead of re-accessing self.processing_class.pad_token_id.

Suggested change
_pk_cm = (
input_ids[:, -_pk_result.shape[1] :]
!= self.processing_class.pad_token_id
).float()
_pk_cm = (
input_ids[:, -_pk_result.shape[1] :]
!= _pk_pad
).float()
References
  1. When a condition or calculated value is used across multiple conditional branches, compute it once and reuse the result to ensure consistency and improve maintainability.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 38562de4b7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread unsloth/models/rl_replacements.py Outdated
Comment on lines +1570 to +1571
_pk_ok = bool(
float(((_pk_result - logprobs).abs() * _pk_cm).max()) < 5e-1

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Require near-exact packed logprob verification

When UNSLOTH_GRPO_SEQ_PACKING=1, this one-time check permanently sets _unsloth_seq_packing_nograd_ok for any masked max delta below 5e-1, so a 0.49 per-token log-prob mismatch is treated as verified. Old/ref log-probs feed the KL and importance-ratio terms, and after this flag is cached later calls skip the padded path entirely, so the opt-in fast path can bias training despite not matching the padded result. Since the fast path is intended to be identical, use a near-zero tolerance or keep falling back unless the comparison actually matches.

Useful? React with 👍 / 👎.

Comment thread unsloth/models/rl_replacements.py Outdated
Comment on lines +1462 to +1466
if _pk_result is not None and getattr(
unwrapped_model, "_unsloth_seq_packing_nograd_ok", False
):
logprobs = _pk_result # already verified equal to padded -> skip the loop
zipped_inputs = []

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reverify packing when RoPE mode can change

After one successful comparison, this cached model-wide flag lets later batches skip the padded check regardless of the new flattened _pk_T. For LongRoPE-style models, the attention code selects short vs long RoPE caches from kv_seq_len; a later packed batch whose summed length crosses original_max_position_embeddings can therefore use the long cache while each padded row would still use the short cache, corrupting old/ref log-probs with no further verification. Key the verdict on the relevant packed length/RoPE mode or recheck when the flattened length crosses such thresholds.

Useful? React with 👍 / 👎.

Comment thread unsloth/models/rl_replacements.py Outdated
Comment on lines +1381 to +1383
# xformers provides the block-diagonal varlen mask; without it the packed attention
# falls back to a dense O(T^2) SDPA mask that can OOM on the whole flattened batch.
import xformers # noqa: F401

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 Badge Do not require xformers when FlashAttention can pack

This unconditional import xformers disables sequence packing permanently in FlashAttention-only environments before the model forward is even tried, even though run_attention selects FLASH_VARLEN when packed_seq_lengths is present and FlashAttention is available. In that setup the new opt-in fast path becomes a no-op despite having a supported varlen backend, so the guard should allow FlashAttention without requiring xformers.

Useful? React with 👍 / 👎.

Comment thread unsloth/models/rl_replacements.py Outdated
Comment on lines +1414 to +1417
_pk_sel = chunked_hidden_states_selective_log_softmax(
_pk_hidden[:, :-1, :],
lm_head,
_pk_flat[:, 1:],

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Limit packed log-softmax to the completion window

In the packed path, the selective log-softmax is run over _pk_hidden[:, :-1, :], which covers every prompt token in the flattened batch, while the padded path slices to only logits_to_keep + max_left_pad positions before applying the LM head. With long prompts and short completions, enabling UNSLOTH_GRPO_SEQ_PACKING=1 can spend most of the expensive vocab projection/logsumexp on prompt positions that are immediately discarded, making the fast path much slower or more memory hungry than the existing chunked path. Gather only each row's needed tail positions before this call.

Useful? React with 👍 / 👎.

@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2baae82966

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread unsloth/models/rl_replacements.py Outdated
# cap the single flattened forward at one padded mini-batch's token budget
# (B rows x seq_len). A larger batch falls back to the chunked padded loop rather
# than building a [1, sum L] forward bigger than the padded [B, seq_len] mini-batch.
_pk_cap = B * seq_len

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Use the padded chunk size for the packing cap

In text-only GRPO runs that are split into multiple padded chunks, B is the number of chunks used to derive batch_size = math.ceil(total_samples / B), while the loop below actually forwards batch_size rows per padded mini-batch. Capping the new flattened forward at B * seq_len therefore does not enforce the stated one-mini-batch budget; for example, with 64 rows and B == 16, the padded path forwards 4-row chunks but this path may try up to 16 sequences' worth of tokens at once, risking an avoidable OOM before falling back. The cap should use the actual padded chunk row count, e.g. batch_size * seq_len.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good catch, B counts chunks at that point. Fixed to batch_size * seq_len.

B counts chunks at this point, so B * seq_len understated (small runs) or overstated
(large runs) the padded mini-batch token budget; use batch_size * seq_len, the rows the
padded loop actually forwards per chunk.
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

1 similar comment
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Already looking forward to the next diff.

Reviewed commit: 80b24d176d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

rhsCZ pushed a commit to rhsCZ/unsloth that referenced this pull request Jul 5, 2026
…completions

In GRPO every prompt spawns G=num_generations completions that share the prompt
prefix, so the trunk logprob forward re-encodes that prefix G times. PrefixGrouper
stores the prefix once and concatenates only the G suffixes behind a FlexAttention
shared-prefix mask, cutting the forward from G*(P+R) to P+G*R tokens across both the
no-grad old/ref forwards and the grad logp forward. Default off behind the
UNSLOTH_GRPO_PREFIX_GROUPER env gate, so the gate-unset path is byte-identical to
today. A tok_r auto-gate and a first-use self-verify (fall back and mark the shape
unsafe on mismatch) keep it from ever shipping wrong logprobs silently.

Wired for llama, mistral, qwen3, gemma2, cohere, granite and falcon_h1, plus qwen2
and gemma through the shared LlamaAttention_fast_forward. Stacked on the GRPO
sequence-packing PR (unslothai#6738); the grad path lands in a companion unsloth-zoo PR.
Also fixes a latent UNSLOTH_ENABLE_LOGGING NameError in the seq-packing no-grad
verify path by defining the name as a generated-cache pre-item.
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Jul 5, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Jul 5, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Jul 5, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Jul 5, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Jul 5, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Jul 5, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Jul 5, 2026
@danielhanchen

Copy link
Copy Markdown
Member Author

Benchmarks

Qwen3-4B LoRA GRPO on a single H200-class GPU, num_generations = 8, completion length 256, steady-state StatisticsCallback (median over 11 steady steps). Sequence packing replaces the padded [B, Lmax] old/reference logprob loop with one varlen [1, sum L] block-diagonal forward, so the axis that matters is the training-forward memory and per-row correctness, not generation.

1. Correctness (the reason this is safe on by default)

The packed forward is self-verified against an independent clean per-row forward over the exact loss mask before it is trusted:

quantity value
packed vs per-row `max d
trust tolerance 0.7 nats
hard-disable threshold 1.5 nats
cross-sample contamination (a backend ignoring packed_seq_lengths) >= 2.4 nats

Because contamination (>= 2.4) is far above the 0.7 trust tolerance, any backend that silently ignores the block-diagonal mask (SDPA fallback, a sliding-window disable, a LongRoPE regime switch) is caught and the step falls back to the padded loop. Verification re-runs whenever T or the longest segment grows. It also fixes the padded path's left-pad RoPE error, so an accepted packed result is at least as correct as the padded reference.

2. Memory footprint

Packing keeps the old/ref forward to one padded mini-batch's token budget (_pk_cap = batch_size * seq_len); a larger batch falls back to the chunked padded loop, so a single flattened forward is never larger than the padded [batch_size, seq_len] mini-batch it replaces. On the packed path (fast_inference=False, Qwen3-4B, G=8) the training-forward peak is 10.19 GB; the shared-prefix PrefixGrouper dedup that rides on this path (#6871 + unsloth-zoo#868) removes the duplicated prompt and takes it down a further ~16% to 8.54 GB.

3. Gating

  • Requires the zoo masked-column guard in grpo_compute_loss (detected once at import via UNSLOTH_ZOO_HAS_MASKED_COL_GUARD); on an older zoo it stays off and byte-identical.
  • Skipped for sliding-window models when the longest segment exceeds the window, and disabled per-model on any verify mismatch or OOM.
  • Disable explicitly with UNSLOTH_GRPO_SEQ_PACKING=0.

@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Jul 6, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Jul 6, 2026
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

1 similar comment
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ba1233d865

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

)
# cap the flattened forward at one padded [batch_size, seq_len] mini-batch's
# token budget; anything larger uses the chunked padded loop
_pk_cap = batch_size * seq_len

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Gate packing when only SDPA is available

On installations without FlashAttention or xFormers, packed inputs reach the SDPA path, where run_attention builds a dense [sum L, sum L] packed mask (unsloth/utils/attention_dispatch.py). This cap only bounds sum L to the padded mini-batch token count, so a B-row chunk can allocate attention state roughly B times larger than the existing padded loop and OOM before the fallback disables packing. Please skip packing for the SDPA-only backend or cap by attention/mask budget rather than token budget.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Confirmed the mechanism: the SDPA-only path in build_sdpa_packed_attention_mask does allocate a block-diagonal [sum L, sum L] mask, up to batch_size larger than the padded per-chunk mask. But that mask is exact (block-diagonal, so no cross-sample leakage and self-verify passes), and any OOM is caught by the except handler at rl_replacements.py:1563, which empties the cache, sets _unsloth_seq_packing_nograd_ok=False (persistent per-model disable), and falls back to the padded loop. So on an install without FlashAttention/xFormers it costs at most one wasted pass (a regime where packing gives no varlen benefit anyway), not a hard OOM or a correctness issue.

Comment on lines +1445 to +1448
_pk_hidden = unwrapped_model(
input_ids = _pk_flat,
position_ids = _pk_pos,
packed_seq_lengths = torch.tensor(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid growing RoPE to the flattened length

For Unsloth fast models, the attention code still derives kv_seq_len from the flattened packed sequence before extending the RoPE cache (checked unsloth/models/llama.py:700-709 and unsloth/models/qwen3.py:114-124), so this call can allocate RoPE tables for sum L rather than the longest segment. With multiple long prompts where _pk_T > seq_len but still passes the _pk_cap check, the packed attempt can require a much larger RoPE cache than the padded chunk and OOM before falling back; avoid packing those shapes or pass/use the max segment length for RoPE sizing.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The packed forward does size RoPE to the flattened length, but the RoPE cache is a [seq_len, head_dim] position table (built in _set_cos_sin_cache, rounded up to a multiple of 8192), not an O(L^2) tensor, so even a 16k flattened batch is about 8 MB rather than a large allocation, and any residual OOM is caught by the except handler at rl_replacements.py:1563. The real risk at a larger length would be a LongRoPE/dynamic-NTK scaling switch changing cos/sin, but the per-row reference runs each row at its own seq_len and the envelope re-verify (which re-runs whenever T or the longest segment grows) catches that, so there is no OOM or correctness gap.

_pk_sw = getattr(
getattr(unwrapped_model, "config", None), "sliding_window", None
)
_pk_sw_ok = not (isinstance(_pk_sw, int) and _pk_sw > 0 and _pk_maxseg > _pk_sw)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Skip Mistral packing when total length triggers SWA

For Mistral-style sliding-window models, this guard only compares each segment length with sliding_window, but unsloth/models/mistral.py decides whether to use varlen packing from the flattened kv_seq_len; when sum L > sliding_window and every row is shorter than the window, the packed forward falls back to a dense sliding window over the concatenated stream, allowing later rows to attend to prior rows. A benign verification batch can then cache _unsloth_seq_packing_nograd_ok and skip future checks for the same/lower shape, so disable packing when the flattened length would trigger SWA or make Mistral use varlen in this case.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The sliding-window guard is per-segment by design, but a flattened-length crossing cannot ship a contaminated result. Reaching the SWA path requires sum L > sliding_window, and verified_T can never exceed sliding_window: on a dense-SWA backend any batch with sum L > sliding_window contaminates by >= 2.4 nats and fails self-verify (hard-disable at rl_replacements.py:1568) instead of widening the envelope, and the widen at ~1540 only fires for _pk_active >= 2, so single-active-row passes never widen it either. A contaminating batch therefore always has _pk_T > verified_T, which bypasses the trust-skip at ~1485 and forces re-verification against the clean per-row forward, so it can never ship silently. Recent Mistral also ships sliding_window=null, which keeps the packed path fully block-diagonal regardless.

@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Delightful!

Reviewed commit: ba1233d865

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@danielhanchen
danielhanchen merged commit 9407d49 into main Jul 6, 2026
51 checks passed
@danielhanchen
danielhanchen deleted the grpo-sequence-packing branch July 6, 2026 12:17
danielhanchen added a commit that referenced this pull request Jul 6, 2026
…completions (#6871)

* GRPO: optional sequence packing for the no-grad old/ref logp path

Add an opt-in sequence-packing fast path to _get_per_token_logps_and_entropies, enabled with
UNSLOTH_GRPO_SEQ_PACKING=1. When the batch is text-only, the padded [B, Lmax] per-chunk forward is
replaced by a single varlen [1, sum L] forward (BlockDiagonalCausalMask via packed_seq_lengths with
reset position_ids). Per-token logps use the same float32 chunked_hidden_states_selective_log_softmax
as the padded path, so the old and reference logps are bit-for-bit identical.

Safety: the packed path is self-verified once against the padded ground truth on a batch that has at
least two rows with real completion tokens (self._unsloth_seq_packing_nograd_ok), so cross-sample
contamination would actually manifest; a degenerate all-pad / fully tool-masked batch leaves the
verdict unset and re-verifies later. If a backend silently ignores packed_seq_lengths (flat batch run
under a normal causal mask, samples leaking across boundaries), the packed logps will not match and
packing is disabled instead of corrupting logps. It also forces use_cache=False (a populated
past_key_value disables varlen packing), skips packing when a sliding window is shorter than the
packed stream, runs the same GPT-OSS offload device_synchronize the padded loop uses, and falls back
on any exception (UNSLOTH_GRPO_SEQ_PACKING_DEBUG=1 prints the reason).

Default off, so existing behavior is unchanged. Pairs with the matching gradient-path change in
unsloth_zoo so the full GRPO logp + loss + backward can run packed.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* GRPO no-grad packing: address review feedback

- Cache the packed-vs-padded verdict per unwrapped model instead of on the
  trainer, so a separately forwarded reference model is verified on its own
  forward path rather than inheriting the policy model's verdict.
- Force the padded path when token_type_ids or mm_token_type_ids are present,
  matching the extra vision kwargs the padded loop forwards.
- Require the xformers varlen backend before packing. Without it the packed
  mask falls back to a dense O(T^2) SDPA mask that can OOM on the flattened
  batch, so we keep the padded loop in that case.
- On any packed-forward failure (missing backend, OOM, unsupported forward)
  empty the cache on OOM, disable packing for that model, and fall back to the
  chunked padded loop instead of retrying every step.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* GRPO no-grad packing: default-on, verify against per-row reference

Redesign of the optional sequence-packing fast path for the no-grad
old/ref logprob recompute, after establishing that the packed forward is
the exact per-row computation and the padded batch forward is the side
that mis-positions left-padded rows on long completions.

- Default the packing on (UNSLOTH_GRPO_SEQ_PACKING, disable with 0).
- Verify the packed logprobs against the per-row clean forward (each
  row's real tokens alone, reset 0-based positions, no padding), not the
  padded batch which is itself wrong for left-padding. Cross-sample
  contamination (a backend ignoring packed_seq_lengths) shows up as a
  large mismatch and falls back to the padded loop.
- Make the trust decision shape and RoPE aware: re-verify whenever the
  packed total length or the longest segment grows past what was
  verified, so a later batch crossing a LongRoPE short/long cache
  boundary is re-checked instead of trusted blindly.
- Run lm_head only on completion-prediction positions instead of every
  packed prompt token, so long-prompt/short-completion batches do not
  pay for projecting the whole packed prompt.
- Drop the hard xformers import so the path also runs in
  FlashAttention-only environments; the per-row verification guards
  correctness regardless of backend.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* GRPO no-grad packing: disable entirely on cross-sample mismatch

When the per-row verification fails, distinguish the two failure modes by
magnitude instead of by sequence length:

- A large mismatch (>= 1.5) is the cross-sample contamination signature:
  the model's attention does not honor the block-diagonal packed mask
  (seen on some MoE / custom-attention models, e.g. qwen2_moe). Disable
  packing entirely for the model so later batches do not pay the
  verification cost again.
- A moderate mismatch is more likely a length-boundary effect (a LongRoPE
  short/long cache switch): keep marking just that length region unsafe so
  packing still runs for smaller shapes.

Validated: Qwen1.5-MoE falls back after a single verification (grad and
no-grad ok flags go False, no re-verify on later steps); dense Llama-3.2
and Qwen3 still verify and engage packing.

* GRPO no-grad packing: trim comments to be concise

* GRPO no-grad packing: fix per-row completion boundary for left-padded rows

The completion-target selection used a single global boundary
(col >= L - logits_to_keep). After left-packing, each row's completion
starts at (L - logits_to_keep) - left_pad[row], so for left-padded rows
the first left_pad completion tokens fall below the global boundary and
were dropped, leaving 0 logprobs at real completion positions that the
loss mask keeps. Use the per-row boundary so packed coverage matches
create_completion_attention_mask exactly, and widen the self-verify mask
to the full per-row completion region so it can catch coverage gaps.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* GRPO no-grad packing: gate verification on real completion rows

Count active rows via create_completion_attention_mask (the same mask the
loss uses) instead of any non-pad token in the packed window. Prompt-only
rows carry prompt-overflow tokens in the window and could otherwise satisfy
the >= 2 verification guard, letting a batch with a single real completion
row cache a trust decision. This matches the gradient path, which already
gates on the completion mask. The same mask is reused for the self-verify
comparison.

* GRPO no-grad packing: gate debug logging on UNSLOTH_ENABLE_LOGGING

Use the shared UNSLOTH_ENABLE_LOGGING global (import_fixes, re-exported by
_utils) instead of a bespoke UNSLOTH_GRPO_SEQ_PACKING_DEBUG env var for the
packing debug prints, matching the rest of the codebase.

* GRPO packing: import UNSLOTH_ENABLE_LOGGING inside the injected logp function

_get_per_token_logps_and_entropies is copied verbatim into the generated GRPO trainer
via inspect.getsource, and that module never imported UNSLOTH_ENABLE_LOGGING, so the
default-on packing verify path raised NameError (and the except handler re-raised it).
Import the flag locally, before the try, so the name is defined in the generated module
too. Drop it from the now-unused module-level import.

* GRPO no-grad packing: harden unsafe-length skip, verify guard, fallback cleanup

Three fixes to the no-grad logp packing path, mirroring the grad path:
- skip the packed forward for known-unsafe lengths by reading unsafe_T and
  gating on it before the forward, instead of running the full packed pass and
  the result build only to discard them (wastes a pass, can OOM at large T)
- only widen the verified T/seg envelope when >= 2 completion rows actually
  exercised cross-sample packing; a < 2 row batch cannot expose leakage, so it
  must not extend the trusted shape that later multi-row batches skip verify for
- drop the packed intermediates (hidden/sel/result/ref) before the padded
  fallback loop so it does not run with the flattened hidden state still resident

* GRPO no-grad packing: cap the flattened forward at one mini-batch budget

The packed path built a single [1, sum L] forward over every row before any
size check, so a large batch could exceed the memory the padded path bounds
per mini-batch. Gate packing on _pk_T <= _pk_cap (B * seq_len, one padded
mini-batch's token budget); larger batches fall back to the chunked padded
loop.

* GRPO no-grad packing: disable unless unsloth_zoo has the masked-column guard

The packed path leaves masked prompt/pad logprob columns at 0, which only stays
finite if unsloth_zoo grpo_compute_loss zeroes them before exp() (zoo#840). An
older unsloth_zoo without that guard would NaN. Detect the guard once (cached on
the model) via inspect.getsource and gate packing on it, so #6738 is safe with
any unsloth_zoo version and re-enables packing automatically once a guarded zoo
is installed, independent of the pinned lower bound.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* GRPO packing: hoist env gates and zoo-guard detection to one-time module checks

Read UNSLOTH_GRPO_SEQ_PACKING and detect the unsloth_zoo masked-column guard once at
import time (module constants plus RL_PRE_ITEMS for the generated trainer cache) instead
of per call, and drop the in-function UNSLOTH_ENABLE_LOGGING import for a module-top one.
The UNSLOTH_GRPO_SEQ_PACKING_VERIFY force-verify debug knob is commented out, kept in
place for hand re-enable; the first-use and envelope-growth self-verify stays active.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* GRPO packing: cap the flattened forward by the padded chunk rows

B counts chunks at this point, so B * seq_len understated (small runs) or overstated
(large runs) the padded mini-batch token budget; use batch_size * seq_len, the rows the
padded loop actually forwards per chunk.

* Add PrefixGrouper for GRPO: dedup the shared prompt across a group's completions

In GRPO every prompt spawns G=num_generations completions that share the prompt
prefix, so the trunk logprob forward re-encodes that prefix G times. PrefixGrouper
stores the prefix once and concatenates only the G suffixes behind a FlexAttention
shared-prefix mask, cutting the forward from G*(P+R) to P+G*R tokens across both the
no-grad old/ref forwards and the grad logp forward. Default off behind the
UNSLOTH_GRPO_PREFIX_GROUPER env gate, so the gate-unset path is byte-identical to
today. A tok_r auto-gate and a first-use self-verify (fall back and mark the shape
unsafe on mismatch) keep it from ever shipping wrong logprobs silently.

Wired for llama, mistral, qwen3, gemma2, cohere, granite and falcon_h1, plus qwen2
and gemma through the shared LlamaAttention_fast_forward. Stacked on the GRPO
sequence-packing PR (#6738); the grad path lands in a companion unsloth-zoo PR.
Also fixes a latent UNSLOTH_ENABLE_LOGGING NameError in the seq-packing no-grad
verify path by defining the name as a generated-cache pre-item.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* PrefixGrouper: enforce the sliding-window cap, gate softcap models, bound the mask cache

Add a max_segment_cap kwarg to build_group_layout so it falls back when a group's
span (prefix + longest suffix) exceeds the model's local window, and pass the config
sliding_window into the no-grad engage gate the same way the packed _pk guard derives it.
Skip PrefixGrouper entirely for attn_logit_softcapping models, since the FlexAttention
kernel never applies logit softcapping. Bound _BLOCK_MASK_CACHE to a FIFO of 8 so
per-step lengths cannot pin BlockMasks forever, release the PG hidden before the verify
forward, and align the UNSLOTH_ENABLE_LOGGING pre-item truthiness with the canonical form.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* PrefixGrouper: vectorize the real-column scan in build_group_layout

Replace the per-row O(B*L) Python scan of the keep mask with a GPU-derived
contiguous-run fast path (first real column + count per row), keeping the
general scan only as a fallback for non-contiguous rows. Works for both call
sites: the no-grad layout (left-padded prompt + right-padded completion, run
does not start at column 0) and the grad layout (left-packed).

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* PrefixGrouper: hoist the gate and kernel imports to one-time module checks, AGPLv3 headers

Read UNSLOTH_GRPO_PREFIX_GROUPER and resolve the prefix_grouper imports once at module
level (source constants plus an RL_PRE_ITEMS entry for the generated trainer cache)
instead of per call, matching the sequence-packing gates. The prefix_grouper env helpers
become one-time module reads with unchanged signatures, and attention_dispatch resolves
the FlexAttention kernel once behind the same gate (lazy fallback kept). The two new
prefix_grouper files move to AGPLv3 headers.

* PrefixGrouper: length-envelope trust and hybrid SSM exclusion

Verified signatures now record (max T, max segment) and re-verify when either grows,
matching the packed path's envelope. Hybrid SSM models (FalconH1 etc.) are excluded at
the gate since only attention gets the shared-prefix isolation, and the FalconH1 wiring
is removed.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* PrefixGrouper: defer the unverified no-grad forward until the packed reference exists

Unverified shapes no longer run the whole-batch shared-prefix forward up front; it now
runs at the verify site, only when the packed path produced a reference. A declined
packed path (budget, window) therefore costs no wasted PG forward per step. Trusted
shapes still run it first to skip the full-row forward, with the same fallback.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* PrefixGrouper: disable under vLLM (fast_inference=True)

With colocated vLLM generation the rollout dominates the GRPO step, so the shared-prefix
training forward saves little end-to-end and its first-use self-verify (which also runs
the full-row path) is net overhead. Gate PG on not use_vllm so it only engages on the raw
transformers path, where the training forward is on the critical path. Packing is unaffected.

* PrefixGrouper: compile the FlexAttention kernel with dynamic shapes

GRPO changes the packed length T almost every batch. With dynamic=False the flex
forward+backward kernel recompiled on every new T (~14s each on a 4B trunk), which
dominated the step and made PG a net loss. dynamic=True compiles once, then reuses the
kernel across all lengths recompile-free (a new shape drops from ~14s to ~1.4ms after a
two-graph warmup). T is still padded to a multiple of 128 for the backward block assertion.

* PrefixGrouper: default on

Enable PrefixGrouper by default (UNSLOTH_GRPO_PREFIX_GROUPER defaults to 1; set 0 to
disable). Still auto-disabled under vLLM (fast_inference=True) and by the arch/softcap/
SSM/tok_r gates, and the first-use self-verify falls back on any mismatch, so this is a
memory-first default on the raw-transformers path with no correctness risk.

* GRPO PrefixGrouper: gate on zoo masked-column guard and exclude MoE

- Require the zoo masked-column guard (zoo#840) before PrefixGrouper can engage.
  PG rides the sequence-packing path, so when the first-step self-verify is off the
  fast path trusts PG output directly; without the guard those masked columns feed
  NaN into the packed loss. Gate PG on the same UNSLOTH_ZOO_HAS_MASKED_COL_GUARD
  the packing path already checks.
- Exclude MoE configs (num_experts, num_local_experts, n_routed_experts,
  moe_intermediate_size) alongside the hybrid-SSM markers. Only the threaded
  attention forwards carry the shared-prefix isolation, so a MoE decoder that does
  not forward prefix_seg_info would let suffixes leak across completions.
- Refresh the stale default-off comments now that UNSLOTH_GRPO_PREFIX_GROUPER is
  on by default.

* GRPO PrefixGrouper: import chunked_hidden_states_selective_log_softmax

The shared-prefix forward passes chunked_hidden_states_selective_log_softmax
into extract_logps, but the name was only ever provided by the generated
trainer cache (rl.py injects grpo_selective_log_softmax_code), never bound in
this module. Import it from unsloth_zoo.rl_replacements next to its sibling
chunked_selective_log_softmax so the source resolves the name in every scope
(the new _pg_run_forward closure included). No runtime change: the cache still
defines the function via template injection.

* GRPO PrefixGrouper: dropout gate, device-safe layout, Mistral mask skip

Addresses three review findings on the shared-prefix path:
- Skip PrefixGrouper when the model sets a nonzero attention_dropout. The normal
  backends apply config.attention_dropout while training (e.g. Granite dense
  flash/sdpa/xformers), but the FlexAttention shared-prefix path is deterministic,
  so gate PG off for those configs rather than train on mismatched activations.
- Move the shared-prefix mask labels to the consumer (Q) device in get_block_mask
  and the target index maps to hidden.device in extract_logps, mirroring the packed
  path moving its metadata to the consumer device. Prevents cross-device indexing
  when the model is sharded across GPUs.
- Do not synthesize a causal attention_mask in the Mistral forward when
  prefix_seg_info is present. On the no-xFormers path that synthetic mask tripped
  resolve_prefix_seg_info and forced PG to always fall back to the packed forward.

* GRPO sequence packing: tighten comments

* GRPO PrefixGrouper: tighten comments

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* GRPO PrefixGrouper: persistent disable on runtime failure; build block-mask labels with inference mode disabled

- rl_replacements: on a PG forward exception (FlexAttention/Triton compile failure or OOM), set a model-level _unsloth_prefix_grouper_nograd_disabled flag and consult it in the engage gate, mirroring the seq-packing handler, so a GPU-wide failure is not retried and re-paid every step.
- prefix_grouper_kernel: move the .to(device) label copies inside the inference_mode(False) block so a cross-device (model-parallel shard) first build does not capture inference tensors, which otherwise cannot be saved for backward when the grad training forward reuses the cached BlockMask.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants