405 lines
17 KiB
Python
405 lines
17 KiB
Python
# Copyright (c) ModelScope Contributors. All rights reserved.
|
|
import torch
|
|
from types import SimpleNamespace
|
|
|
|
from swift.ray.megatron.gkd_trainer import GKDTrainer
|
|
from swift.ray.megatron.megatron_worker import MegatronWorker
|
|
from swift.rlhf_trainers.gkd_loss import TeacherOutput, extract_active
|
|
|
|
_collate = MegatronWorker._collate_teacher_outputs
|
|
|
|
|
|
def _topk_to(seq_len, k, fill):
|
|
"""A per-sample teacher topk tensor shaped [1, seq_len, k]."""
|
|
return TeacherOutput(
|
|
topk_logprobs=torch.full((1, seq_len, k), float(fill)),
|
|
topk_indices=torch.zeros((1, seq_len, k), dtype=torch.long),
|
|
)
|
|
|
|
|
|
def test_collate_padding_free_concat_and_offbyone_pad():
|
|
"""padding_free: concat per-sample along seq dim, then pad to target_seq_len.
|
|
|
|
This is the off-by-one fix: the student collation pads the concatenated
|
|
sequence to a multiple via get_padding_to (SP), so the teacher (built from raw
|
|
per-sample lengths) can be a few tokens short and must be padded to match.
|
|
"""
|
|
k = 4
|
|
samples = [_topk_to(3, k, -1.0), _topk_to(5, k, -2.0)] # raw total = 8
|
|
target = 10 # student SP-padded length (8 -> 10)
|
|
out = _collate(samples, device='cpu', padding_free=True, target_seq_len=target)
|
|
assert out.topk_logprobs.shape == (1, target, k), out.topk_logprobs.shape
|
|
assert out.topk_indices.shape == (1, target, k)
|
|
# real tokens 0..7 keep their values; padded tail 8..9 is -inf / 0
|
|
assert torch.isinf(out.topk_logprobs[0, 8:, :]).all()
|
|
assert (out.topk_indices[0, 8:, :] == 0).all()
|
|
assert not torch.isinf(out.topk_logprobs[0, :8, :]).any()
|
|
|
|
|
|
def test_collate_padding_free_offbyone_single_token():
|
|
"""The exact failure mode that deadlocked T3: total length odd, padded +1."""
|
|
k = 2
|
|
samples = [_topk_to(8277, k, -1.0)] # one micro-batch sample, raw len 8277 (odd)
|
|
out = _collate(samples, device='cpu', padding_free=True, target_seq_len=8278)
|
|
assert out.topk_logprobs.shape == (1, 8278, k)
|
|
assert torch.isinf(out.topk_logprobs[0, 8277:, :]).all()
|
|
|
|
|
|
def test_collate_padding_free_drops_empty_placeholder():
|
|
"""colocated path emits [1, full, k] for sample 0 and empty [0, ...] for the
|
|
rest of a micro-batch; empties must be dropped before the seq-dim concat."""
|
|
k = 3
|
|
full = _topk_to(6, k, -1.0)
|
|
empty = TeacherOutput(
|
|
topk_logprobs=torch.full((0, 6, k), float('-inf')),
|
|
topk_indices=torch.zeros((0, 6, k), dtype=torch.long),
|
|
)
|
|
out = _collate([full, empty], device='cpu', padding_free=True, target_seq_len=6)
|
|
assert out.topk_logprobs.shape == (1, 6, k)
|
|
|
|
|
|
def test_collate_non_padding_free_stacks_on_batch_dim():
|
|
"""non padding_free: per-sample tensors padded to target then stacked on dim 0."""
|
|
k = 4
|
|
samples = [_topk_to(3, k, -1.0), _topk_to(5, k, -2.0)]
|
|
out = _collate(samples, device='cpu', padding_free=False, target_seq_len=5)
|
|
assert out.topk_logprobs.shape == (2, 5, k), out.topk_logprobs.shape
|
|
# sample 0 padded from 3 -> 5
|
|
assert torch.isinf(out.topk_logprobs[0, 3:, :]).all()
|
|
assert not torch.isinf(out.topk_logprobs[1]).any()
|
|
|
|
|
|
def test_collate_opsd_keeps_teacher_length_not_student_target():
|
|
"""OPSD: teacher scores a different prompt, so its length differs from the
|
|
student. The collation must KEEP the teacher length (ignore target_seq_len)
|
|
and concat labels (extract_active aligns by mask, not position)."""
|
|
k = 3
|
|
t_total = 7 # teacher (opsd) sequence length
|
|
full = TeacherOutput(
|
|
topk_logprobs=torch.full((1, t_total, k), -1.0),
|
|
topk_indices=torch.zeros((1, t_total, k), dtype=torch.long),
|
|
labels=torch.full((1, t_total), 5, dtype=torch.long),
|
|
)
|
|
empty = TeacherOutput() # padding_free placeholder for the rest of the micro-batch
|
|
# target_seq_len is the *student* length (e.g. 12) — must be ignored for OPSD.
|
|
out = _collate([full, empty], device='cpu', padding_free=True, target_seq_len=12, is_opsd=True)
|
|
assert out.topk_logprobs.shape == (1, t_total, k), out.topk_logprobs.shape
|
|
assert out.labels.shape == (1, t_total)
|
|
assert (out.labels == 5).all()
|
|
|
|
|
|
def test_build_per_sample_teacher_output_uses_raw_input_length():
|
|
"""Standalone teacher outputs are built from each sample's RAW (un-CP-padded) input
|
|
length. Because these raw per-sample token-logprobs cannot be CP-sharded to match the
|
|
student, CP>1 with standalone teacher replicas is rejected by a fail-fast in
|
|
GKDTrainer._collate_for_workers_gkd (use a colocated teacher_model for CP>1).
|
|
This test guards the raw-length contract that the CP>1 fail-fast depends on.
|
|
"""
|
|
k = 3
|
|
raw_len = 5
|
|
lps = [[-1.0] * k for _ in range(raw_len)]
|
|
ixs = [[0] * k for _ in range(raw_len)]
|
|
encoded = {'input_ids': list(range(raw_len))}
|
|
out = GKDTrainer._build_per_sample_teacher_output((lps, ixs), encoded, topk=k)
|
|
assert out.topk_logprobs.shape == (1, raw_len, k), out.topk_logprobs.shape
|
|
assert out.topk_indices.shape == (1, raw_len, k)
|
|
assert out.labels is None
|
|
|
|
|
|
def test_extract_active_opsd_aligns_by_mask_across_lengths():
|
|
"""OPSD: teacher and student have different sequence lengths; extract_active
|
|
selects response positions by their own masks and requires equal counts."""
|
|
V, k = 8, 3
|
|
# student: length 5, 2 response positions (indices 3,4)
|
|
s_logits = torch.randn(1, 5, V)
|
|
s_labels = torch.tensor([[-100, -100, -100, 1, 2]])
|
|
# teacher (opsd): length 7, 2 response positions (indices 5,6) — same count
|
|
t = TeacherOutput(
|
|
topk_logprobs=torch.randn(1, 7, k),
|
|
topk_indices=torch.zeros((1, 7, k), dtype=torch.long),
|
|
labels=torch.tensor([[-100, -100, -100, -100, -100, 1, 2]]),
|
|
)
|
|
s_act, t_act, n = extract_active(s_logits, t, s_labels)
|
|
assert int(n) == 2
|
|
assert s_act.shape == (2, V)
|
|
assert t_act.topk_logprobs.shape == (2, k)
|
|
|
|
|
|
def test_extract_active_opsd_count_mismatch_raises():
|
|
s_logits = torch.randn(1, 5, 8)
|
|
s_labels = torch.tensor([[-100, -100, -100, 1, 2]]) # 2 response tokens
|
|
t = TeacherOutput(
|
|
topk_logprobs=torch.randn(1, 6, 3),
|
|
topk_indices=torch.zeros((1, 6, 3), dtype=torch.long),
|
|
labels=torch.tensor([[-100, -100, -100, -100, -100, 9]]), # 1 token
|
|
)
|
|
try:
|
|
extract_active(s_logits, t, s_labels)
|
|
raise AssertionError('expected an assertion on OPSD count mismatch')
|
|
except AssertionError as e:
|
|
assert 'OPSD' in str(e) or 'mismatch' in str(e) or 'count' in str(e).lower()
|
|
|
|
|
|
def test_extract_active_non_opsd_uses_student_labels():
|
|
"""Non-OPSD: teacher_output.labels is None (Ray GKD non-OPSD path).
|
|
|
|
The student label mask should apply to both student and teacher.
|
|
This is the Critical #1 regression test: before the fix, extract_active
|
|
crashed with TypeError on ``None != -100``.
|
|
"""
|
|
V, k = 8, 3
|
|
# student: length 5, 3 response positions (indices 2,3,4)
|
|
s_logits = torch.randn(1, 5, V)
|
|
s_labels = torch.tensor([[-100, -100, 1, 2, 3]])
|
|
# teacher (non-OPSD): same length, labels=None
|
|
t = TeacherOutput(
|
|
topk_logprobs=torch.randn(1, 5, k),
|
|
topk_indices=torch.zeros((1, 5, k), dtype=torch.long),
|
|
labels=None,
|
|
)
|
|
s_act, t_act, n = extract_active(s_logits, t, s_labels)
|
|
assert int(n) == 3
|
|
assert s_act.shape == (3, V)
|
|
assert t_act.topk_logprobs.shape == (3, k)
|
|
|
|
|
|
def test_extract_active_non_opsd_full_logits():
|
|
"""Non-OPSD with full-vocab teacher (no topk): labels=None path.
|
|
|
|
Verifies that the student mask is used for both student and teacher
|
|
when teacher_output.labels is None.
|
|
"""
|
|
V = 8
|
|
s_logits = torch.randn(1, 4, V)
|
|
s_labels = torch.tensor([[-100, 1, 2, 3]])
|
|
t = TeacherOutput(
|
|
full_logits=torch.randn(1, 4, V),
|
|
labels=None,
|
|
)
|
|
s_act, t_act, n = extract_active(s_logits, t, s_labels)
|
|
assert int(n) == 3
|
|
assert s_act.shape == (3, V)
|
|
assert t_act.full_logits.shape == (3, V)
|
|
|
|
|
|
def test_megatron_assemble_teacher_outputs_api_topk_rolls_labels():
|
|
"""Megatron Teacher API + topk: ``_assemble_teacher_outputs`` must roll teacher
|
|
labels by -1 so the invariant 'teacher_output.labels is pre-shifted before
|
|
extract_active' holds on the API path too (the local-teacher path gets shifted
|
|
labels from _prepare_batch). assemble_teacher_output returns the RAW labels, so
|
|
the trainer applies the shift; without it the API path would feed unshifted
|
|
teacher labels against shifted student labels -> silent KL/JSD misalignment.
|
|
"""
|
|
try:
|
|
from swift.megatron.trainers.gkd_trainer import MegatronGKDTrainer
|
|
except Exception as e: # noqa: megatron-core not installed in this env
|
|
print(f'SKIP test_megatron_assemble_teacher_outputs_api_topk_rolls_labels: {e}')
|
|
return
|
|
|
|
k = 3
|
|
# raw (unshifted) labels: prompt=-100, response at positions 2,3,4
|
|
raw_labels = torch.tensor([[-100, -100, 11, 22, 33]])
|
|
seq_len = raw_labels.shape[-1]
|
|
# parsed teacher topk: one (logprobs, indices) row per response token (len+1 cu)
|
|
parsed = [([[-1.0] * k] * (seq_len - 1), [[0] * k] * (seq_len - 1))]
|
|
teacher_model_inputs = {
|
|
'input_ids': torch.zeros((1, seq_len), dtype=torch.long),
|
|
'labels': raw_labels.clone(),
|
|
'attention_mask': torch.ones((1, seq_len), dtype=torch.long),
|
|
}
|
|
encoded_batch = {'_teacher_parsed': parsed, 'teacher_model_inputs': teacher_model_inputs}
|
|
|
|
stub = SimpleNamespace(gkd_logits_topk=k, template=SimpleNamespace(padding_free=False), device=torch.device('cpu'))
|
|
MegatronGKDTrainer._assemble_teacher_outputs(stub, [encoded_batch])
|
|
|
|
teacher_out = encoded_batch['teacher_output']
|
|
assert torch.equal(teacher_out.labels, torch.roll(raw_labels, shifts=-1, dims=-1))
|
|
assert teacher_out.topk_logprobs.shape == (1, seq_len, k)
|
|
|
|
# The shifted teacher labels must align with shifted student labels in extract_active.
|
|
s_labels = torch.roll(raw_labels, shifts=-1, dims=-1)
|
|
s_logits = torch.randn(1, seq_len, 8)
|
|
s_act, t_act, n = extract_active(s_logits, teacher_out, s_labels)
|
|
assert int(n) == 3
|
|
assert t_act.topk_logprobs.shape == (3, k)
|
|
|
|
|
|
def test_example_yaml_config_contracts():
|
|
"""Config-contract regression for the standardized example yamls.
|
|
|
|
- teacher replicas (standalone) must declare vllm_engine_kwargs.max_logprobs
|
|
>= gkd_logits_topk, else vLLM rejects the prompt_logprobs request.
|
|
- the standalone teacher group serves a real teacher checkpoint (model override).
|
|
"""
|
|
import os
|
|
import yaml
|
|
base = os.path.join(os.path.dirname(__file__), '..', '..', 'examples', 'ray', 'gkd')
|
|
|
|
cfg = yaml.safe_load(open(os.path.join(base, 'rollout_colocate_teacher_standalone.yaml')))
|
|
topk = cfg['gkd_logits_topk']
|
|
teacher = cfg['teacher']
|
|
max_logprobs = teacher['vllm_engine_kwargs']['max_logprobs']
|
|
assert max_logprobs >= topk, f'teacher max_logprobs {max_logprobs} < gkd_logits_topk {topk}'
|
|
assert teacher.get('model'), 'standalone teacher group must override `model`'
|
|
|
|
# colocate / separate examples keep max_length & max_completion_length consistent
|
|
for name in ('rollout_colocate_teacher_colocate.yaml', 'rollout_separate_teacher_colocate.yaml'):
|
|
c = yaml.safe_load(open(os.path.join(base, name)))
|
|
assert c['max_length'] > 0 and c['max_completion_length'] > 0
|
|
|
|
|
|
def test_ray_gkd_prepare_multi_turn_initializes_scheduler():
|
|
"""Verify that GKDTrainer._prepare_multi_turn() initializes the scheduler.
|
|
|
|
This tests the fix that adds _prepare_multi_turn() to Ray GKD trainer
|
|
(previously only GRPO had it). We mock the args and check that
|
|
_multi_turn_scheduler is set correctly.
|
|
"""
|
|
from types import SimpleNamespace
|
|
|
|
from swift.rollout.multi_turn import MathTipsScheduler
|
|
|
|
# Create a minimal mock trainer instance (bypass __init__)
|
|
trainer = GKDTrainer.__new__(GKDTrainer)
|
|
trainer.args = SimpleNamespace(
|
|
multi_turn_scheduler='math_tip_trick',
|
|
max_turns=2,
|
|
gym_env=None,
|
|
)
|
|
|
|
# Call _prepare_multi_turn directly
|
|
trainer._prepare_multi_turn()
|
|
|
|
assert trainer._multi_turn_scheduler is not None, 'Scheduler should be initialized'
|
|
assert isinstance(trainer._multi_turn_scheduler,
|
|
MathTipsScheduler), (f'Expected MathTipsScheduler, got {type(trainer._multi_turn_scheduler)}')
|
|
assert trainer._max_turns == 2
|
|
assert trainer._enable_server_multi_turn is False
|
|
|
|
|
|
def test_ray_gkd_prepare_multi_turn_none_when_not_configured():
|
|
"""Verify that _prepare_multi_turn() leaves scheduler as None when not configured."""
|
|
from types import SimpleNamespace
|
|
|
|
trainer = GKDTrainer.__new__(GKDTrainer)
|
|
trainer.args = SimpleNamespace(
|
|
multi_turn_scheduler=None,
|
|
max_turns=None,
|
|
gym_env=None,
|
|
)
|
|
|
|
trainer._prepare_multi_turn()
|
|
|
|
assert trainer._multi_turn_scheduler is None
|
|
assert trainer._enable_server_multi_turn is False
|
|
|
|
|
|
def test_ray_gkd_prepare_multi_turn_unknown_scheduler_raises():
|
|
"""Unknown scheduler name should raise ValueError."""
|
|
from types import SimpleNamespace
|
|
|
|
trainer = GKDTrainer.__new__(GKDTrainer)
|
|
trainer.args = SimpleNamespace(
|
|
multi_turn_scheduler='nonexistent_scheduler',
|
|
max_turns=3,
|
|
gym_env=None,
|
|
)
|
|
|
|
try:
|
|
trainer._prepare_multi_turn()
|
|
assert False, 'Should have raised ValueError for unknown scheduler'
|
|
except ValueError as e:
|
|
assert 'nonexistent_scheduler' in str(e)
|
|
|
|
|
|
def test_ray_gkd_generate_uses_multi_turn_scheduler():
|
|
"""Verify that _generate() dispatches to run_multi_turn when scheduler is set.
|
|
|
|
We mock _distribute_to_replicas to return canned responses, then check
|
|
that the output structure matches multi-turn format (response_token_ids
|
|
accumulated across turns).
|
|
"""
|
|
from types import SimpleNamespace
|
|
|
|
from swift.infer_engine.protocol import ChatCompletionResponse, ChatCompletionResponseChoice, Message
|
|
from swift.rollout.multi_turn import MathTipsScheduler
|
|
|
|
# Create a minimal mock trainer
|
|
trainer = GKDTrainer.__new__(GKDTrainer)
|
|
trainer.args = SimpleNamespace(
|
|
max_completion_length=128,
|
|
temperature=1.0,
|
|
top_p=1.0,
|
|
top_k=80,
|
|
stop_words=[],
|
|
)
|
|
trainer._multi_turn_scheduler = None # start with no scheduler
|
|
trainer._enable_server_multi_turn = False
|
|
trainer._max_turns = 1
|
|
|
|
# Mock _distribute_to_replicas to return canned responses
|
|
call_count = [0]
|
|
|
|
def mock_distribute(requests, request_config):
|
|
call_count[0] += 1
|
|
responses = []
|
|
for req in requests:
|
|
choice = ChatCompletionResponseChoice(
|
|
index=0,
|
|
message=Message(role='assistant', content='The answer is 4.'),
|
|
finish_reason='stop',
|
|
token_ids=[1, 2, 3, 4, 5],
|
|
)
|
|
resp = ChatCompletionResponse(choices=[choice])
|
|
responses.append(resp)
|
|
return responses
|
|
|
|
trainer._distribute_to_replicas = mock_distribute
|
|
|
|
# Test 1: Without scheduler (single-turn path)
|
|
from swift.rl_core.data import GKDSample
|
|
sample = GKDSample(messages=[{'role': 'user', 'content': 'What is 2+2?'}])
|
|
outputs = trainer._generate([sample])
|
|
assert len(outputs) == 1
|
|
assert call_count[0] == 1 # single call
|
|
|
|
# Test 2: With scheduler (multi-turn path)
|
|
# Use a scheduler that always finishes after 1 turn (so we don't loop forever)
|
|
trainer._multi_turn_scheduler = MathTipsScheduler(max_turns=1)
|
|
# MathTipsScheduler needs solution in data_dict, mock it
|
|
sample2 = GKDSample(messages=[{'role': 'user', 'content': 'What is 2+2?'}])
|
|
sample2.extra['solution'] = '4'
|
|
sample2.request_id = 'test-req-1'
|
|
call_count[0] = 0 # reset
|
|
|
|
# The scheduler's infer_engine is None; we need to mock the inference
|
|
# Instead, verify that the multi-turn path is taken by checking call_count
|
|
# We mock on_trajectory_start to be a no-op
|
|
import asyncio
|
|
trainer._multi_turn_scheduler.on_trajectory_start = lambda reqs: asyncio.coroutine(lambda: None)()
|
|
|
|
try:
|
|
outputs = trainer._generate([sample2])
|
|
# Multi-turn path should have called _distribute_to_replicas at least once
|
|
assert call_count[0] >= 1, f'Expected at least 1 call, got {call_count[0]}'
|
|
except Exception:
|
|
# Multi-turn with mock may fail in scheduler.step(), but the key assertion
|
|
# is that _distribute_to_replicas was called (multi-turn path was taken)
|
|
assert call_count[0] >= 1, f'Multi-turn path not taken, call_count={call_count[0]}'
|
|
|
|
|
|
if __name__ == '__main__':
|
|
fns = [v for k, v in sorted(globals().items()) if k.startswith('test_') and callable(v)]
|
|
failed = 0
|
|
for fn in fns:
|
|
try:
|
|
fn()
|
|
print(f'PASS {fn.__name__}')
|
|
except Exception as e: # noqa
|
|
failed += 1
|
|
import traceback
|
|
print(f'FAIL {fn.__name__}: {e}')
|
|
traceback.print_exc()
|
|
print(f'\n{len(fns) - failed}/{len(fns)} passed')
|
|
raise SystemExit(1 if failed else 0)
|