406 lines
16 KiB
Python
406 lines
16 KiB
Python
"""Unit tests for multi-teacher model server support.
|
|
|
|
Tests cover:
|
|
- parse_teacher_model_server: single URL / JSON parsing + non-empty & non-overlapping tag validation
|
|
- route_samples_to_teachers: single-teacher (all samples) and multi-teacher tag routing + fail-fast
|
|
- fetch_teacher_parsed_by_routing: teacher-count-agnostic fetch + scatter back to sample order
|
|
- expand_advantage_to_per_token: scalar teacher KL coefficient (all teachers share --teacher_kl_coef)
|
|
- streaming multi-dataset load: auto-injected ``dataset`` column + routing parity with non-streaming
|
|
"""
|
|
import json
|
|
import os
|
|
import tempfile
|
|
import torch
|
|
import unittest
|
|
from typing import List, Optional
|
|
|
|
from swift.dataset import load_dataset
|
|
from swift.rl_core.advantage import expand_advantage_to_per_token
|
|
from swift.rl_core.data import OnPolicySample
|
|
from swift.rlhf_trainers.gkd_helpers import (TeacherServerConfig, fetch_teacher_parsed_by_routing,
|
|
parse_teacher_model_server, route_samples_to_teachers)
|
|
|
|
|
|
def _make_sample(tag: Optional[str] = None) -> OnPolicySample:
|
|
extra = {}
|
|
if tag is not None:
|
|
extra['dataset'] = tag
|
|
return OnPolicySample(messages=[], extra=extra)
|
|
|
|
|
|
class TestParseTeacherModelServer(unittest.TestCase):
|
|
|
|
def test_parse_none(self):
|
|
self.assertIsNone(parse_teacher_model_server(None))
|
|
|
|
def test_parse_single_url(self):
|
|
result = parse_teacher_model_server('http://localhost:8000')
|
|
self.assertEqual(len(result), 1)
|
|
self.assertEqual(result[0].url, 'http://localhost:8000')
|
|
self.assertEqual(result[0].tags, [])
|
|
|
|
def test_parse_multi_json(self):
|
|
config = json.dumps([
|
|
{
|
|
'url': 'http://localhost:8000',
|
|
'tags': ['math']
|
|
},
|
|
{
|
|
'url': 'http://localhost:8001',
|
|
'tags': ['code']
|
|
},
|
|
])
|
|
result = parse_teacher_model_server(config)
|
|
self.assertEqual(len(result), 2)
|
|
self.assertEqual(result[0].tags, ['math'])
|
|
self.assertEqual(result[1].tags, ['code'])
|
|
|
|
def test_parse_empty_json_list(self):
|
|
with self.assertRaises(ValueError):
|
|
parse_teacher_model_server('[]')
|
|
|
|
def test_parse_missing_url(self):
|
|
with self.assertRaises(ValueError):
|
|
parse_teacher_model_server(json.dumps([{'tags': ['math']}]))
|
|
|
|
def test_parse_invalid_tags(self):
|
|
config = json.dumps([{'url': 'http://localhost:8000', 'tags': 'math'}])
|
|
with self.assertRaises(ValueError):
|
|
parse_teacher_model_server(config)
|
|
|
|
def test_parse_multi_empty_tags_rejected(self):
|
|
"""With multiple teachers, empty tags are rejected (each sample needs exactly one teacher)."""
|
|
config = json.dumps([
|
|
{
|
|
'url': 'http://localhost:8000',
|
|
'tags': ['math']
|
|
},
|
|
{
|
|
'url': 'http://localhost:8001',
|
|
'tags': []
|
|
},
|
|
])
|
|
with self.assertRaises(ValueError):
|
|
parse_teacher_model_server(config)
|
|
|
|
def test_parse_overlapping_tags_rejected(self):
|
|
"""A tag may not appear in more than one teacher."""
|
|
config = json.dumps([
|
|
{
|
|
'url': 'http://localhost:8000',
|
|
'tags': ['math', 'shared']
|
|
},
|
|
{
|
|
'url': 'http://localhost:8001',
|
|
'tags': ['shared', 'code']
|
|
},
|
|
])
|
|
with self.assertRaises(ValueError):
|
|
parse_teacher_model_server(config)
|
|
|
|
def test_parse_non_dict_entry_rejected(self):
|
|
"""A non-dict list entry is rejected instead of raising AttributeError."""
|
|
with self.assertRaises(ValueError):
|
|
parse_teacher_model_server(json.dumps(['http://localhost:8000']))
|
|
|
|
def test_parse_tags_coerced_to_str(self):
|
|
"""Non-string tags are normalized to str so they match get_tag's str output."""
|
|
config = json.dumps([
|
|
{
|
|
'url': 'http://localhost:8000',
|
|
'tags': [1]
|
|
},
|
|
{
|
|
'url': 'http://localhost:8001',
|
|
'tags': [2]
|
|
},
|
|
])
|
|
result = parse_teacher_model_server(config)
|
|
self.assertEqual(result[0].tags, ['1'])
|
|
self.assertEqual(result[1].tags, ['2'])
|
|
|
|
|
|
class TestRouteSamplesToTeachers(unittest.TestCase):
|
|
|
|
def test_route_single_teacher_all_samples(self):
|
|
"""Single teacher (empty tags) handles all samples, tags ignored."""
|
|
samples = [_make_sample('math'), _make_sample(), _make_sample('code')]
|
|
configs = [TeacherServerConfig(url='http://t0', tags=[])]
|
|
routing = route_samples_to_teachers(samples, configs)
|
|
self.assertEqual(routing[0], [0, 1, 2])
|
|
|
|
def test_route_one_to_one(self):
|
|
samples = [_make_sample('math'), _make_sample('code'), _make_sample('math')]
|
|
configs = [
|
|
TeacherServerConfig(url='http://t0', tags=['math']),
|
|
TeacherServerConfig(url='http://t1', tags=['code']),
|
|
]
|
|
routing = route_samples_to_teachers(samples, configs)
|
|
self.assertEqual(routing[0], [0, 2])
|
|
self.assertEqual(routing[1], [1])
|
|
|
|
def test_route_unmatched_fails_fast(self):
|
|
samples = [_make_sample('unknown')]
|
|
configs = [
|
|
TeacherServerConfig(url='http://t0', tags=['math']),
|
|
TeacherServerConfig(url='http://t1', tags=['code']),
|
|
]
|
|
with self.assertRaises(ValueError):
|
|
route_samples_to_teachers(samples, configs)
|
|
|
|
def test_get_tag_reads_tag_key(self):
|
|
# OnPolicySample.get_tag keys off exactly tag_key (default 'dataset'); no column fallback.
|
|
self.assertEqual(_make_sample('math').get_tag(), 'math')
|
|
sample = OnPolicySample(messages=[], extra={'domain': 'code'})
|
|
self.assertEqual(sample.get_tag(tag_key='domain'), 'code')
|
|
self.assertIsNone(sample.get_tag()) # default 'dataset' key absent -> None
|
|
|
|
def test_get_tag_no_tag_returns_none(self):
|
|
self.assertIsNone(_make_sample().get_tag())
|
|
|
|
|
|
class TestFetchTeacherParsedByRouting(unittest.TestCase):
|
|
|
|
def test_multi_teacher_scatter_back(self):
|
|
samples = [_make_sample('math'), _make_sample('code'), _make_sample('math')]
|
|
configs = [
|
|
TeacherServerConfig(url='http://t0', tags=['math']),
|
|
TeacherServerConfig(url='http://t1', tags=['code']),
|
|
]
|
|
requests = ['r0', 'r1', 'r2']
|
|
seen = {}
|
|
|
|
def fetch_fn(subset_reqs, client):
|
|
seen[client.url] = list(subset_reqs)
|
|
return [f'{client.url}:{r}' for r in subset_reqs]
|
|
|
|
parsed = fetch_teacher_parsed_by_routing(samples, requests, configs, configs, fetch_fn=fetch_fn)
|
|
self.assertEqual(parsed, ['http://t0:r0', 'http://t1:r1', 'http://t0:r2'])
|
|
self.assertEqual(seen['http://t0'], ['r0', 'r2'])
|
|
self.assertEqual(seen['http://t1'], ['r1'])
|
|
|
|
def test_single_teacher_one_fetch(self):
|
|
"""Single teacher: one fetch over all requests in original order."""
|
|
samples = [_make_sample(), _make_sample(), _make_sample()]
|
|
configs = [TeacherServerConfig(url='http://t0', tags=[])]
|
|
requests = ['r0', 'r1', 'r2']
|
|
calls = []
|
|
|
|
def fetch_fn(subset_reqs, client):
|
|
calls.append(list(subset_reqs))
|
|
return [f'p:{r}' for r in subset_reqs]
|
|
|
|
parsed = fetch_teacher_parsed_by_routing(samples, requests, configs, configs, fetch_fn=fetch_fn)
|
|
self.assertEqual(parsed, ['p:r0', 'p:r1', 'p:r2'])
|
|
self.assertEqual(calls, [['r0', 'r1', 'r2']]) # exactly one fetch
|
|
|
|
def test_empty_subset_teacher_still_visited(self):
|
|
"""Every teacher index is visited even with an empty subset (collective ordering / no
|
|
per-rank skip that would desync the DP gather/broadcast)."""
|
|
samples = [_make_sample('math'), _make_sample('math')] # nothing routes to 'code'
|
|
configs = [
|
|
TeacherServerConfig(url='http://t0', tags=['math']),
|
|
TeacherServerConfig(url='http://t1', tags=['code']),
|
|
]
|
|
requests = ['r0', 'r1']
|
|
visited = []
|
|
|
|
def fetch_fn(subset_reqs, client):
|
|
visited.append((client.url, list(subset_reqs)))
|
|
return [f'{client.url}:{r}' for r in subset_reqs]
|
|
|
|
parsed = fetch_teacher_parsed_by_routing(samples, requests, configs, configs, fetch_fn=fetch_fn)
|
|
self.assertEqual(parsed, ['http://t0:r0', 'http://t0:r1'])
|
|
# both teachers visited, in order, including the empty one
|
|
self.assertEqual(visited, [('http://t0', ['r0', 'r1']), ('http://t1', [])])
|
|
|
|
def test_phase_split_concurrent_scatter(self):
|
|
"""3-phase (gather/infer/scatter) form scatters back in original sample order, and only
|
|
the main process runs infer (concurrently across teachers)."""
|
|
samples = [_make_sample('math'), _make_sample('code'), _make_sample('math')]
|
|
configs = [
|
|
TeacherServerConfig(url='http://t0', tags=['math']),
|
|
TeacherServerConfig(url='http://t1', tags=['code']),
|
|
]
|
|
requests = ['r0', 'r1', 'r2']
|
|
|
|
def gather_fn(subset_reqs):
|
|
return list(subset_reqs)
|
|
|
|
def infer_fn(handle, client):
|
|
return [f'{client.url}:{r}' for r in handle]
|
|
|
|
def scatter_fn(handle, parsed_global):
|
|
return parsed_global
|
|
|
|
parsed = fetch_teacher_parsed_by_routing(
|
|
samples,
|
|
requests,
|
|
configs,
|
|
configs,
|
|
gather_fn=gather_fn,
|
|
infer_fn=infer_fn,
|
|
scatter_fn=scatter_fn,
|
|
is_main_process=True)
|
|
self.assertEqual(parsed, ['http://t0:r0', 'http://t1:r1', 'http://t0:r2'])
|
|
|
|
def test_phase_split_non_main_no_infer(self):
|
|
"""On non-main ranks infer is skipped; scatter still runs for every teacher (receives the
|
|
broadcast in real distributed runs). Here scatter returns the per-teacher subset unchanged."""
|
|
samples = [_make_sample('math'), _make_sample('code')]
|
|
configs = [
|
|
TeacherServerConfig(url='http://t0', tags=['math']),
|
|
TeacherServerConfig(url='http://t1', tags=['code']),
|
|
]
|
|
requests = ['r0', 'r1']
|
|
infer_called = []
|
|
scatter_calls = []
|
|
|
|
def gather_fn(subset_reqs):
|
|
return list(subset_reqs)
|
|
|
|
def infer_fn(handle, client):
|
|
infer_called.append(client)
|
|
return [f'x:{r}' for r in handle]
|
|
|
|
def scatter_fn(handle, parsed_global):
|
|
scatter_calls.append(parsed_global)
|
|
return [f's:{r}' for r in handle] # mimic broadcast-then-slice back to local subset
|
|
|
|
parsed = fetch_teacher_parsed_by_routing(
|
|
samples,
|
|
requests,
|
|
configs,
|
|
configs,
|
|
gather_fn=gather_fn,
|
|
infer_fn=infer_fn,
|
|
scatter_fn=scatter_fn,
|
|
is_main_process=False)
|
|
self.assertEqual(infer_called, []) # infer never runs off the main process
|
|
self.assertEqual(scatter_calls, [None, None]) # parsed_global is None on non-main
|
|
self.assertEqual(parsed, ['s:r0', 's:r1'])
|
|
|
|
|
|
class TestExpandAdvantageScalarCoef(unittest.TestCase):
|
|
|
|
def test_scalar_coef(self):
|
|
B, T = 2, 4
|
|
result = expand_advantage_to_per_token(
|
|
torch.tensor([1.0, 1.0]),
|
|
torch.ones(B, T),
|
|
teacher_per_token_logps=torch.tensor([[2.0] * T, [3.0] * T]),
|
|
policy_per_token_logps=torch.tensor([[1.0] * T, [1.0] * T]),
|
|
teacher_kl_coef=0.5,
|
|
)
|
|
# base(1) + 0.5 * (2-1) = 1.5; base(1) + 0.5 * (3-1) = 2.0
|
|
torch.testing.assert_close(result[0], torch.ones(T) * 1.5)
|
|
torch.testing.assert_close(result[1], torch.ones(T) * 2.0)
|
|
|
|
def test_no_teacher(self):
|
|
B, T = 2, 4
|
|
result = expand_advantage_to_per_token(torch.tensor([1.0, 2.0]), torch.ones(B, T))
|
|
torch.testing.assert_close(result[0], torch.ones(T) * 1.0)
|
|
torch.testing.assert_close(result[1], torch.ones(T) * 2.0)
|
|
|
|
def test_zero_coef_no_injection(self):
|
|
B, T = 1, 4
|
|
result = expand_advantage_to_per_token(
|
|
torch.tensor([1.0]),
|
|
torch.ones(B, T),
|
|
teacher_per_token_logps=torch.tensor([[2.0] * T]),
|
|
policy_per_token_logps=torch.tensor([[1.0] * T]),
|
|
teacher_kl_coef=0.0,
|
|
)
|
|
torch.testing.assert_close(result[0], torch.ones(T) * 1.0)
|
|
|
|
|
|
class TestStreamingDatasetMultiTeacherRouting(unittest.TestCase):
|
|
"""End-to-end: streaming load_dataset injects ``dataset`` tags that multi-teacher routing consumes."""
|
|
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
cls.tmpdir = tempfile.mkdtemp()
|
|
cls.paths: List[str] = []
|
|
for name, n_rows in (('math.jsonl', 3), ('code.jsonl', 2)):
|
|
path = os.path.join(cls.tmpdir, name)
|
|
with open(path, 'w', encoding='utf-8') as f:
|
|
for i in range(n_rows):
|
|
row = {'messages': [{'role': 'user', 'content': f'{name}-{i}'}]}
|
|
f.write(json.dumps(row, ensure_ascii=False) + '\n')
|
|
cls.paths.append(path)
|
|
|
|
def _teacher_configs(self) -> List[TeacherServerConfig]:
|
|
return [
|
|
TeacherServerConfig(url='http://t0', tags=[self.paths[0]]),
|
|
TeacherServerConfig(url='http://t1', tags=[self.paths[1]]),
|
|
]
|
|
|
|
def _load_samples(self, *, streaming: bool, interleave_prob: Optional[List[float]] = None) -> List[OnPolicySample]:
|
|
kwargs = dict(datasets=self.paths, streaming=streaming, split_dataset_ratio=0.)
|
|
if interleave_prob is not None:
|
|
kwargs['interleave_prob'] = interleave_prob
|
|
kwargs['stopping_strategy'] = 'all_exhausted'
|
|
train, _ = load_dataset(**kwargs)
|
|
return [OnPolicySample.from_row(row) for row in train]
|
|
|
|
def test_streaming_injected_tags_route_correctly(self):
|
|
samples = self._load_samples(streaming=True)
|
|
self.assertEqual(len(samples), 5)
|
|
for sample in samples:
|
|
self.assertIn(sample.extra.get('dataset'), self.paths)
|
|
|
|
routing = route_samples_to_teachers(samples, self._teacher_configs())
|
|
math_indices = [i for i, s in enumerate(samples) if s.extra['dataset'] == self.paths[0]]
|
|
code_indices = [i for i, s in enumerate(samples) if s.extra['dataset'] == self.paths[1]]
|
|
self.assertEqual(routing[0], math_indices)
|
|
self.assertEqual(routing[1], code_indices)
|
|
self.assertEqual(len(math_indices), 3)
|
|
self.assertEqual(len(code_indices), 2)
|
|
|
|
def test_streaming_fetch_by_routing(self):
|
|
samples = self._load_samples(streaming=True)
|
|
configs = self._teacher_configs()
|
|
requests = [f'r{i}' for i in range(len(samples))]
|
|
seen = {}
|
|
|
|
def fetch_fn(subset_reqs, client):
|
|
seen[client.url] = list(subset_reqs)
|
|
return [f'{client.url}:{r}' for r in subset_reqs]
|
|
|
|
parsed = fetch_teacher_parsed_by_routing(samples, requests, configs, configs, fetch_fn=fetch_fn)
|
|
for i, sample in enumerate(samples):
|
|
tag = sample.extra['dataset']
|
|
expected_url = 'http://t0' if tag == self.paths[0] else 'http://t1'
|
|
self.assertEqual(parsed[i], f'{expected_url}:r{i}')
|
|
self.assertEqual(len(seen['http://t0']), 3)
|
|
self.assertEqual(len(seen['http://t1']), 2)
|
|
|
|
def test_streaming_routing_matches_non_streaming(self):
|
|
stream_samples = self._load_samples(streaming=True)
|
|
static_samples = self._load_samples(streaming=False)
|
|
configs = self._teacher_configs()
|
|
self.assertEqual(
|
|
route_samples_to_teachers(stream_samples, configs),
|
|
route_samples_to_teachers(static_samples, configs),
|
|
)
|
|
stream_tags = [s.extra['dataset'] for s in stream_samples]
|
|
static_tags = [s.extra['dataset'] for s in static_samples]
|
|
self.assertEqual(stream_tags, static_tags)
|
|
|
|
def test_streaming_interleave_preserves_per_dataset_tags(self):
|
|
samples = self._load_samples(streaming=True, interleave_prob=[0.5, 0.5])
|
|
self.assertGreater(len(samples), 0)
|
|
for sample in samples:
|
|
self.assertIn(sample.extra.get('dataset'), self.paths)
|
|
|
|
routing = route_samples_to_teachers(samples, self._teacher_configs())
|
|
self.assertEqual(sorted(routing[0] + routing[1]), list(range(len(samples))))
|
|
math_count = sum(1 for s in samples if s.extra['dataset'] == self.paths[0])
|
|
code_count = sum(1 for s in samples if s.extra['dataset'] == self.paths[1])
|
|
self.assertEqual(len(routing[0]), math_count)
|
|
self.assertEqual(len(routing[1]), code_count)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|