diff --git a/cookbook/client/server/transformer/diagnostics/compare_dsv4_4layer_logits.py b/cookbook/client/server/transformer/diagnostics/compare_dsv4_4layer_logits.py new file mode 100755 index 000000000..58facf908 --- /dev/null +++ b/cookbook/client/server/transformer/diagnostics/compare_dsv4_4layer_logits.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Numerically compare two last-token-logit files produced by the DSV4 probe.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +import torch + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('reference', type=Path) + parser.add_argument('candidate', type=Path) + parser.add_argument('--rtol', type=float, default=1e-2) + parser.add_argument('--atol', type=float, default=2e-2) + parser.add_argument('--output', type=Path) + return parser.parse_args() + + +def load_logits(path: Path) -> tuple[str, list[int], torch.Tensor]: + payload = torch.load(path, map_location='cpu', weights_only=True) + return str(payload.get('mode', path.stem)), list(payload['input_ids']), payload['last_logits'].float() + + +def main() -> None: + args = parse_args() + reference_mode, reference_ids, reference = load_logits(args.reference) + candidate_mode, candidate_ids, candidate = load_logits(args.candidate) + if reference_ids != candidate_ids: + raise RuntimeError(f'Input IDs differ: {reference_ids} != {candidate_ids}') + if reference.shape != candidate.shape: + raise RuntimeError(f'Logit shapes differ: {tuple(reference.shape)} != {tuple(candidate.shape)}') + + difference = (candidate - reference).abs() + close = torch.isclose(candidate, reference, rtol=args.rtol, atol=args.atol) + reference_top = torch.topk(reference, k=min(20, reference.numel())).indices.tolist() + candidate_top = torch.topk(candidate, k=min(20, candidate.numel())).indices.tolist() + report = { + 'reference': str(args.reference), + 'reference_mode': reference_mode, + 'candidate': str(args.candidate), + 'candidate_mode': candidate_mode, + 'input_ids': reference_ids, + 'shape': list(reference.shape), + 'rtol': args.rtol, + 'atol': args.atol, + 'allclose': bool(close.all().item()), + 'close_fraction': close.float().mean().item(), + 'max_abs_diff': difference.max().item(), + 'mean_abs_diff': difference.mean().item(), + 'reference_top20': reference_top, + 'candidate_top20': candidate_top, + 'top20_overlap': len(set(reference_top) & set(candidate_top)), + } + + output = args.output or args.candidate.with_name( + f'compare_{reference_mode}_vs_{candidate_mode}.json') + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding='utf-8') + print(json.dumps(report, ensure_ascii=False, indent=2)) + print(f'Report saved to: {output.resolve()}') + if not report['allclose']: + raise SystemExit(1) + + +if __name__ == '__main__': + main() diff --git a/cookbook/client/server/transformer/diagnostics/probe_dsv4_4layer_logits.py b/cookbook/client/server/transformer/diagnostics/probe_dsv4_4layer_logits.py new file mode 100755 index 000000000..dd40d122d --- /dev/null +++ b/cookbook/client/server/transformer/diagnostics/probe_dsv4_4layer_logits.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Save last-token logits from a running four-layer Twinkle server.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path +from typing import Any + +import torch +from peft import LoraConfig + +from twinkle_client import init_twinkle_client +from twinkle_client.model import MultiLoraTransformersModel + + +DEFAULT_INPUT_IDS = [0, 128803, 2788, 6573, 70979, 36005, 320, 128804, 128821] +TARGET_PARAMETERS = ['mlp.experts.gate_up_proj', 'mlp.experts.down_proj'] + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--mode', required=True, choices=('no_ep', 'ep_loop', 'ep_gmm')) + parser.add_argument('--server-url', default='http://127.0.0.1:8000') + parser.add_argument('--server-token', default='EMPTY_TOKEN') + parser.add_argument('--served-model', default='deepseek-v4-0731-local') + parser.add_argument('--output-dir', type=Path, default=Path('output/dsv4_ep_diag')) + parser.add_argument('--input-ids', default=','.join(str(item) for item in DEFAULT_INPUT_IDS)) + return parser.parse_args() + + +def _extract_logits(result: Any) -> torch.Tensor: + if hasattr(result, 'model_dump'): + result = result.model_dump() + if isinstance(result, list) and len(result) == 1 and isinstance(result[0], dict): + result = result[0] + if not isinstance(result, dict) or result.get('logits') is None: + raise RuntimeError(f'forward_only did not return logits; result type={type(result).__name__}') + + logits = torch.as_tensor(result['logits'], dtype=torch.float32) + original_shape = tuple(logits.shape) + while logits.ndim > 3: + logits = logits[0] + if logits.ndim == 3: + logits = logits[0, -1] + elif logits.ndim == 2: + logits = logits[-1] + elif logits.ndim != 1: + raise RuntimeError(f'Unsupported logits shape: {original_shape}') + if logits.numel() < 1000: + raise RuntimeError(f'Last-token logits look too small: shape={tuple(logits.shape)}') + return logits.contiguous() + + +def _tensor_sha256(tensor: torch.Tensor) -> str: + values = tensor.detach().cpu().contiguous().numpy().tobytes() + return hashlib.sha256(values).hexdigest() + + +def main() -> None: + args = parse_args() + input_ids = [int(item.strip()) for item in args.input_ids.split(',') if item.strip()] + if not input_ids: + raise SystemExit('--input-ids must contain at least one token ID') + + client = init_twinkle_client( + base_url=args.server_url, + api_key=args.server_token, + session_heartbeat_interval=10, + ) + try: + capacity = client.get_capacity_info() + if capacity.free_loras < 1: + raise RuntimeError('No free LoRA slot. Restart the diagnostic server before running the probe.') + + model = MultiLoraTransformersModel(model_id=args.served_model) + model.add_adapter_to_model( + f'dsv4_diag_{args.mode}', + LoraConfig( + r=8, + lora_alpha=32, + lora_dropout=0.0, + target_modules=None, + target_parameters=TARGET_PARAMETERS, + bias='none', + ), + gradient_accumulation_steps=1, + ) + model.set_processor('InputProcessor', padding_side='left', padding_free=False) + + raw_input = { + 'input_ids': input_ids, + 'attention_mask': [1] * len(input_ids), + 'position_ids': list(range(len(input_ids))), + } + response = model.forward_only( + inputs=[raw_input], + disable_lora=True, + return_logits=True, + ) + last_logits = _extract_logits(response.result) + finally: + client.close() + + finite = torch.isfinite(last_logits) + top_values, top_indices = torch.topk(last_logits, k=min(20, last_logits.numel())) + report = { + 'mode': args.mode, + 'server_url': args.server_url, + 'served_model': args.served_model, + 'input_ids': input_ids, + 'last_logits_shape': list(last_logits.shape), + 'dtype_saved': str(last_logits.dtype), + 'sha256': _tensor_sha256(last_logits), + 'finite': bool(finite.all().item()), + 'nan_count': int(torch.isnan(last_logits).sum().item()), + 'inf_count': int(torch.isinf(last_logits).sum().item()), + 'sum': last_logits.sum().item(), + 'abs_sum': last_logits.abs().sum().item(), + 'min': last_logits.min().item(), + 'max': last_logits.max().item(), + 'top_token_ids': top_indices.tolist(), + 'top_logits': top_values.tolist(), + } + + args.output_dir.mkdir(parents=True, exist_ok=True) + tensor_path = args.output_dir / f'{args.mode}_last_logits.pt' + json_path = args.output_dir / f'{args.mode}_last_logits.json' + torch.save( + { + 'mode': args.mode, + 'input_ids': input_ids, + 'last_logits': last_logits, + }, + tensor_path, + ) + json_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding='utf-8') + print(json.dumps(report, ensure_ascii=False, indent=2)) + print(f'Logits saved to: {tensor_path.resolve()}') + print(f'Report saved to: {json_path.resolve()}') + + +if __name__ == '__main__': + main() diff --git a/cookbook/client/server/transformer/diagnostics/test_dsv4_npu_gmm_layout.py b/cookbook/client/server/transformer/diagnostics/test_dsv4_npu_gmm_layout.py new file mode 100755 index 000000000..bd65df18d --- /dev/null +++ b/cookbook/client/server/transformer/diagnostics/test_dsv4_npu_gmm_layout.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +"""Compare the DeepSeek-V4 square expert layout on NPU GMM against F.linear.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +import torch +import torch.nn.functional as F +from torch import nn + +from twinkle.kernel.ops.moe.npu import GmmFunction, _normalize_packed_expert_weights + + +class PackedExperts(nn.Module): + + def __init__(self, gate_up_proj: torch.Tensor, down_proj: torch.Tensor): + super().__init__() + self.gate_up_proj = nn.Parameter(gate_up_proj, requires_grad=False) + self.down_proj = nn.Parameter(down_proj, requires_grad=False) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--device', default='npu:0') + parser.add_argument('--dtype', choices=('float16', 'bfloat16'), default='bfloat16') + parser.add_argument('--atol', type=float, default=2e-2) + parser.add_argument('--rtol', type=float, default=1e-2) + parser.add_argument('--output', type=Path, default=Path('output/dsv4_ep_diag/npu_gmm_layout.json')) + return parser.parse_args() + + +def reference_forward( + inputs: torch.Tensor, + counts: list[int], + gate_up_proj: torch.Tensor, + down_proj: torch.Tensor, +) -> torch.Tensor: + outputs = [] + start = 0 + for expert, count in enumerate(counts): + expert_input = inputs[start:start + count] + gate_up = F.linear(expert_input, gate_up_proj[expert]) + gate, up = gate_up.chunk(2, dim=-1) + outputs.append(F.linear(F.silu(gate) * up, down_proj[expert])) + start += count + return torch.cat(outputs, dim=0) + + +def gmm_forward( + inputs: torch.Tensor, + counts: torch.Tensor, + gate_up_weight: torch.Tensor, + down_weight: torch.Tensor, +) -> torch.Tensor: + import torch_npu + + gate_up = GmmFunction.apply(inputs, counts, gate_up_weight) + activated = torch_npu.npu_swiglu(gate_up, dim=-1) + return GmmFunction.apply(activated, counts, down_weight) + + +def main() -> None: + args = parse_args() + try: + import torch_npu # noqa: F401 + except ImportError as exc: + raise SystemExit('torch_npu is required; run this script in the Ascend container.') from exc + + if not torch.npu.is_available(): + raise SystemExit('torch.npu.is_available() is False') + + device = torch.device(args.device) + dtype = getattr(torch, args.dtype) + torch.npu.set_device(device.index or 0) + torch.manual_seed(20260901) + torch.npu.manual_seed_all(20260901) + + # Preserve the DeepSeek-V4 relation hidden == 2 * intermediate. The gate/up + # matrix is deliberately non-symmetric so an omitted transpose is visible. + experts = 2 + hidden = 64 + intermediate = 32 + token_counts = [8, 8] + inputs = torch.randn(sum(token_counts), hidden, device=device, dtype=dtype) * 0.1 + gate_up_proj = torch.randn(experts, 2 * intermediate, hidden, device=device, dtype=dtype) * 0.02 + down_proj = torch.randn(experts, hidden, intermediate, device=device, dtype=dtype) * 0.02 + module = PackedExperts(gate_up_proj, down_proj).to(device) + + normalized_gate_up, normalized_down = _normalize_packed_expert_weights(module, dtype, hidden) + counts = torch.tensor(token_counts, device=device, dtype=torch.int64) + + with torch.no_grad(): + expected = reference_forward(inputs, token_counts, gate_up_proj, down_proj) + actual = gmm_forward(inputs, counts, normalized_gate_up, normalized_down) + # Reproduce the old DeepSeek-V4 bug: the square gate/up tensor was not transposed. + old_bug = gmm_forward(inputs, counts, gate_up_proj, down_proj.transpose(1, 2)) + torch.npu.synchronize() + + difference = (actual.float() - expected.float()).abs() + old_difference = (old_bug.float() - expected.float()).abs() + passed = torch.allclose(actual.float(), expected.float(), rtol=args.rtol, atol=args.atol) + report = { + 'device': str(device), + 'dtype': str(dtype), + 'input_shape': list(inputs.shape), + 'gate_up_shape_transformers': list(gate_up_proj.shape), + 'down_shape_transformers': list(down_proj.shape), + 'gate_up_shape_gmm': list(normalized_gate_up.shape), + 'down_shape_gmm': list(normalized_down.shape), + 'rtol': args.rtol, + 'atol': args.atol, + 'max_abs_diff': difference.max().item(), + 'mean_abs_diff': difference.mean().item(), + 'old_bug_max_abs_diff': old_difference.max().item(), + 'old_bug_mean_abs_diff': old_difference.mean().item(), + 'passed': bool(passed), + } + + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(report, indent=2), encoding='utf-8') + print(json.dumps(report, indent=2)) + print(f'Report saved to: {args.output.resolve()}') + if not passed: + raise SystemExit(1) + + +if __name__ == '__main__': + main() diff --git a/cookbook/client/server/transformer/read_me.md b/cookbook/client/server/transformer/read_me.md new file mode 100644 index 000000000..260279e8b --- /dev/null +++ b/cookbook/client/server/transformer/read_me.md @@ -0,0 +1,546 @@ +# DeepSeek-V4 四层模型:FSDP/EP/NPU GMM 排查手册 + +本文档用于定位 Twinkle 加载 DeepSeek-V4 后基座生成异常的问题。整套检查只需要从正式 BF16 模型截取的前四层,不依赖模型能够正常回答自然语言。 + +排查目标是区分: + +1. checkpoint 或 Transformers 转换错误; +2. 两个节点加载出的源权重不一致; +3. FSDP node-local 权重分发错误; +4. EP 专家切分或 AllToAll 错误; +5. NPU grouped-matmul 专家权重方向错误。 + +四层模型无法形成有意义的自然语言回答是正常现象。所有结论必须依据权重诊断日志和最后一个位置的 logits,不能依据生成文本是否可读。 + +## 本次修改包含的文件 + +核心修复及诊断开关: + +- `src/twinkle/kernel/ops/moe/npu.py` + - 同时根据 `gate_up_proj` 和 `down_proj` 判断 Transformers `[E,out,in]` 与 GMM `[E,in,out]` 布局。 + - 修复 DeepSeek-V4 中 `hidden_size == 2 * moe_intermediate_size` 导致方阵 `gate_up_proj` 被错误识别的问题。 +- `src/twinkle/kernel/ops/ep/__init__.py` + - 新增 `TWINKLE_EP_FORCE_LOOP=1`,可强制使用逐专家 `F.linear` 参考实现。 +- `src/twinkle/model/transformers/moe/expert_parallel.py` + - 新增首轮 EP 路由、split、专家区间及输出诊断日志。 +- `src/twinkle/model/transformers/strategy/native_fsdp.py` + - 新增 node-local 源权重和 EP 本地专家切片诊断日志。 + +四层诊断配置及启动脚本: + +- `cookbook/client/server/transformer/server_config_dsv4_4layer_diag_no_ep.yaml` +- `cookbook/client/server/transformer/server_config_dsv4_4layer_diag_ep.yaml` +- `cookbook/client/server/transformer/run_dsv4_4layer_ep_diagnostic.sh` +- `cookbook/client/server/transformer/run_dsv4_0731_npu_2node_2npu.sh` + - 现在支持通过 `TWINKLE_SERVER_CONFIG_PATH` 选择配置。 + +诊断工具: + +- `cookbook/client/server/transformer/diagnostics/test_dsv4_npu_gmm_layout.py` +- `cookbook/client/server/transformer/diagnostics/probe_dsv4_4layer_logits.py` +- `cookbook/client/server/transformer/diagnostics/compare_dsv4_4layer_logits.py` + +单元测试: + +- `tests/kernel/ops/test_moe.py` + +## 1. 四层模型要求 + +必须使用从正式 DeepSeek-V4-Flash BF16 checkpoint 截取的前四层,不能使用随机初始化模型。配置至少需要保持: + +```text +num_hidden_layers=4 +hidden_size=4096 +moe_intermediate_size=2048 +n_routed_experts=256 +num_experts_per_tok=6 +dtype=bfloat16 +``` + +重点是保留: + +```text +hidden_size == 2 * moe_intermediate_size == 4096 +``` + +这正是原实现中方阵 `gate_up_proj` 布局误判的触发条件。 + +两个节点必须看到相同的绝对模型路径,并使用相同的 Twinkle、Transformers、PyTorch、torch-npu 和 CANN 版本。 + +启动前在两个节点分别检查: + +```bash +PROJECT_DIR=/opt/twinkle +MODEL_DIR=/highcode/shared_data/DeepSeek-V4-Flash-0731-BF16-4layers + +cd "$PROJECT_DIR" +git rev-parse HEAD +test -f "$MODEL_DIR/config.json" +test -f "$MODEL_DIR/model.safetensors.index.json" +python3 - <<'PY' +import torch +import transformers +import torch_npu + +print('torch=', torch.__version__) +print('torch_npu=', torch_npu.__version__) +print('transformers=', transformers.__version__) +PY +``` + +两个节点的输出必须一致。 + +## 2. 避免占用 Pod ephemeral-storage + +Ray 的 Unix socket 路径必须短,但缓存又不应写入只有 10 GiB 的容器临时盘。已知容器的 `/dev/shm` 有 800 GiB 时,建议把 Ray 临时目录放到共享内存;日志和结果仍写入存储卷: + +```bash +mkdir -p /dev/shm/rh +mkdir -p /highcode/shared_data/dsv4_ep_diag/tmp +mkdir -p /highcode/shared_data/dsv4_ep_diag/logs +mkdir -p /highcode/shared_data/dsv4_ep_diag/results +``` + +之后使用: + +```bash +export RAY_TMPDIR=/dev/shm/rh +export TMPDIR=/highcode/shared_data/dsv4_ep_diag/tmp +export RAY_ROTATION_MAX_BYTES=20971520 +export RAY_ROTATION_BACKUP_COUNT=1 +``` + +不要把 Ray 临时目录设成很长的路径,否则可能再次触发 AF_UNIX 107 字节路径上限。 + +## 3. 先做单卡 NPU GMM 数值测试 + +该步骤不启动 Ray,也不加载四层模型。它使用保持 DeepSeek-V4 比例的微型专家,直接比较: + +```text +F.linear 参考结果 +vs +npu_grouped_matmul 结果 +``` + +在一台 NPU 机器执行: + +```bash +cd /opt/twinkle + +ASCEND_RT_VISIBLE_DEVICES=0 \ +PYTHONPATH=/opt/twinkle/src \ +python3 cookbook/client/server/transformer/diagnostics/test_dsv4_npu_gmm_layout.py \ + --device npu:0 \ + --output /highcode/shared_data/dsv4_ep_diag/results/npu_gmm_layout.json +``` + +成功时: + +```text +passed=true +max_abs_diff 和 mean_abs_diff 在 BF16 允许范围内 +old_bug_max_abs_diff 明显大于修复后的 max_abs_diff +``` + +如果这里失败,不要启动分布式服务,先处理 NPU GMM 与 `F.linear` 的数值差异。 + +## 4. 三组服务端对照模式 + +三组模式只改变 EP 和专家计算方式: + +| 模式 | FSDP | EP | 专家计算 | +|---|---:|---:|---| +| `no_ep` | 开启 | 关闭 | Transformers 原始前向 | +| `ep_loop` | 开启 | 开启 | 强制逐专家 `F.linear` | +| `ep_gmm` | 开启 | 开启 | NPU grouped-matmul | + +两份 YAML 除 EP 设置外保持一致: + +```text +两节点 +每节点 2 张 NPU +world_size=4 +fsdp_size=4 +ep_size=4(仅 EP 模式) +memory_efficient_init=true +max_loras=1 +max_length=512 +``` + +EP=4 时,256 个专家的预期区间为: + +```text +rank 0 / ep_rank 0: experts 0..63 +rank 1 / ep_rank 1: experts 64..127 +rank 2 / ep_rank 2: experts 128..191 +rank 3 / ep_rank 3: experts 192..255 +``` + +## 5. 两个节点的公共环境 + +以下示例使用: + +```text +Head: 172.61.10.111 +Worker: 172.61.12.165 +网卡: eth0 +``` + +如果实际环境使用 `bond0` 或其他地址,只修改环境变量,不需要修改脚本或 YAML。 + +Head 节点执行: + +```bash +cd /opt/twinkle + +export DSV4_MODEL_ID=/highcode/shared_data/DeepSeek-V4-Flash-0731-BF16-4layers +export HEAD_IP=172.61.10.111 +export WORKER_IP=172.61.12.165 +export NODE_IP=172.61.10.111 +export NETWORK_IFACE=eth0 +export ASCEND_RT_VISIBLE_DEVICES=0,1 +export RESET_RAY=1 +export RAY_TMPDIR=/dev/shm/rh +export TMPDIR=/highcode/shared_data/dsv4_ep_diag/tmp +export RAY_ROTATION_MAX_BYTES=20971520 +export RAY_ROTATION_BACKUP_COUNT=1 +``` + +Worker 节点执行: + +```bash +cd /opt/twinkle + +export DSV4_MODEL_ID=/highcode/shared_data/DeepSeek-V4-Flash-0731-BF16-4layers +export HEAD_IP=172.61.10.111 +export WORKER_IP=172.61.12.165 +export NODE_IP=172.61.12.165 +export NETWORK_IFACE=eth0 +export ASCEND_RT_VISIBLE_DEVICES=0,1 +export RESET_RAY=1 +export RAY_TMPDIR=/dev/shm/rh +export TMPDIR=/highcode/shared_data/dsv4_ep_diag/tmp +export RAY_ROTATION_MAX_BYTES=20971520 +export RAY_ROTATION_BACKUP_COUNT=1 +``` + +必须确保两个节点选择相同模式。 + +## 6. 模式一:启动 no-EP 基准 + +先在 Head 节点启动;脚本会等待 Worker 加入: + +```bash +nohup bash cookbook/client/server/transformer/run_dsv4_4layer_ep_diagnostic.sh head no_ep \ + >/highcode/shared_data/dsv4_ep_diag/logs/no_ep_head.log 2>&1 /highcode/shared_data/dsv4_ep_diag/logs/no_ep_worker.log 2>&1 /highcode/shared_data/dsv4_ep_diag/logs/ep_loop_head.log 2>&1 /highcode/shared_data/dsv4_ep_diag/logs/ep_loop_worker.log 2>&1 /highcode/shared_data/dsv4_ep_diag/logs/ep_gmm_head.log 2>&1 /highcode/shared_data/dsv4_ep_diag/logs/ep_gmm_worker.log 2>&1 /highcode/shared_data/dsv4_ep_diag/results/ep_diagnostics.txt +``` + +重点检查以下日志。 + +### 11.1 两个节点的源权重 + +每个 node-local source rank 都会输出: + +```text +local_source= +param=<参数名> +full_shape=<完整专家形状> +source_preview=<采样值> +``` + +相同参数在两个 node-local source 上必须满足: + +```text +full_shape 相同 +source_preview 相同 +``` + +如果不同,优先检查两个节点: + +- 是否使用相同模型目录; +- 模型文件是否一致; +- Transformers/torch版本是否一致; +- 是否实际运行同一个 Twinkle commit。 + +### 11.2 EP 专家范围 + +每个rank会输出: + +```text +rank= +ep_rank= +expert_range=[start,end) +local_shape=<本地权重形状> +local_preview=<本地切片采样值> +``` + +EP=4时必须严格对应: + +```text +rank0 -> [0,64) +rank1 -> [64,128) +rank2 -> [128,192) +rank3 -> [192,256) +``` + +本地权重预期形状: + +```text +gate_up_proj: [64,4096,4096] +down_proj: [64,4096,2048] +``` + +### 11.3 Router与AllToAll split + +每层第一次前向会输出: + +```text +selected_experts +routing_weights +input_splits +output_splits +output_finite +``` + +固定输入下,各rank在AllToAll前的 `selected_experts` 和 `routing_weights` 应一致。代码还会验证: + +```text +sum(input_splits) == token_count * num_experts_per_tok +``` + +如果该条件不成立,诊断模式会直接报错退出。 + +## 12. 单元测试和静态检查 + +在安装了项目依赖的环境执行: + +```bash +cd /opt/twinkle + +python3 -m pytest -q tests/kernel/ops/test_moe.py + +python3 -m py_compile \ + cookbook/client/server/transformer/diagnostics/test_dsv4_npu_gmm_layout.py \ + cookbook/client/server/transformer/diagnostics/probe_dsv4_4layer_logits.py \ + cookbook/client/server/transformer/diagnostics/compare_dsv4_4layer_logits.py + +bash -n \ + cookbook/client/server/transformer/run_dsv4_0731_npu_2node_2npu.sh \ + cookbook/client/server/transformer/run_dsv4_4layer_ep_diagnostic.sh +``` + +## 13. 完整模型的最终验证 + +只有满足以下条件后才启动43层完整模型: + +```text +单卡 NPU GMM 与 F.linear 一致 +no_ep logits ≈ ep_loop logits +ep_loop logits ≈ ep_gmm logits +两个 node-local source 的权重采样一致 +EP 专家范围正确 +路由 split 不变量通过 +``` + +完整模型启动时应关闭诊断开关: + +```bash +export TWINKLE_EP_FORCE_LOOP=0 +export TWINKLE_EP_DIAGNOSTICS=0 +``` + +然后按顺序验证: + +1. 不训练、不注册旧LoRA,先检查基座生成; +2. 注册全零LoRA,确认它与 `disable_lora=True` 逐token一致; +3. 修复前在错误EP前向下训练的LoRA不能作为正确性基准; +4. 基座确认正常后重新训练LoRA; +5. 对比重新训练后的 base/logprob/生成结果。 + +## 14. 清理 + +诊断结束后在两个节点执行: + +```bash +ray stop --force +``` + +结果位于共享卷: + +```text +/highcode/shared_data/dsv4_ep_diag/results +/highcode/shared_data/dsv4_ep_diag/logs +``` + +确认不再需要后可手动清理共享卷中的诊断日志和结果。`/dev/shm/rh` 仅用于当前 Pod 的 Ray 临时文件,Pod 删除后不会保留。 diff --git a/cookbook/client/server/transformer/run_dsv4_0731_npu.sh b/cookbook/client/server/transformer/run_dsv4_0731_npu.sh new file mode 100755 index 000000000..dd2eea223 --- /dev/null +++ b/cookbook/client/server/transformer/run_dsv4_0731_npu.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd -- "$SCRIPT_DIR/../../../.." && pwd)" +cd "$PROJECT_DIR" + +if [[ -f /usr/local/Ascend/ascend-toolkit/set_env.sh ]]; then + # shellcheck disable=SC1091 + source /usr/local/Ascend/ascend-toolkit/set_env.sh +fi + +export PYTHONPATH="$PROJECT_DIR/src${PYTHONPATH:+:$PYTHONPATH}" +export ASCEND_RT_VISIBLE_DEVICES="${ASCEND_RT_VISIBLE_DEVICES:-0,1}" +export RAY_EXPERIMENTAL_NOSET_ASCEND_RT_VISIBLE_DEVICES=1 +export TWINKLE_TRUST_REMOTE_CODE=1 +export TWINKLE_FAIL_FAST=1 +export TOKENIZERS_PARALLELISM=true +export HCCL_CONNECT_TIMEOUT="${HCCL_CONNECT_TIMEOUT:-7200}" +export HCCL_EXEC_TIMEOUT="${HCCL_EXEC_TIMEOUT:-0}" + +MODEL_PATH=/nas/disk1/random-deepseek-v4-4b +CONFIG_PATH="$SCRIPT_DIR/server_config_dsv4_0731_npu.yaml" + +test -f "$MODEL_PATH/config.json" +test -f "$MODEL_PATH/tokenizer.json" + +python3 - <<'PY' +import os +import torch +import torch_npu # noqa: F401 + +visible = [item.strip() for item in os.environ['ASCEND_RT_VISIBLE_DEVICES'].split(',') if item.strip()] +if len(visible) != 2: + raise SystemExit(f'需要两张 NPU,当前 ASCEND_RT_VISIBLE_DEVICES={visible}') +if not torch.npu.is_available(): + raise SystemExit('torch.npu.is_available() 为 False') +if torch.npu.device_count() < 2: + raise SystemExit(f'当前进程只能看到 {torch.npu.device_count()} 张 NPU') +print(f'NPU 检查通过:visible={visible}, device_count={torch.npu.device_count()}') +PY + +if ! ray status >/dev/null 2>&1; then + ray start \ + --head \ + --num-cpus="${TWINKLE_RAY_CPUS:-8}" \ + --resources='{"NPU": 2}' \ + --disable-usage-stats \ + --include-dashboard=false +fi + +python3 - <<'PY' +import ray + +ray.init(address='auto', logging_level='ERROR') +npu_count = float(ray.cluster_resources().get('NPU', 0)) +ray.shutdown() +if npu_count < 2: + raise SystemExit( + f'当前 Ray 集群只有 {npu_count:g} 个 NPU 资源。' + '请确认没有其他任务占用后执行 ray stop --force,再重新运行本脚本。' + ) +print(f'Ray NPU 资源检查通过:NPU={npu_count:g}') +PY + +python3 -m twinkle.server check-config --config "$CONFIG_PATH" +exec python3 "$SCRIPT_DIR/server_dsv4_0731_npu.py" diff --git a/cookbook/client/server/transformer/run_dsv4_0731_npu_2node_2npu.sh b/cookbook/client/server/transformer/run_dsv4_0731_npu_2node_2npu.sh new file mode 100755 index 000000000..814a9eb94 --- /dev/null +++ b/cookbook/client/server/transformer/run_dsv4_0731_npu_2node_2npu.sh @@ -0,0 +1,170 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Head node (172.61.10.111): +# bash cookbook/client/server/transformer/run_dsv4_0731_npu_2node_2npu.sh head +# +# Worker node (172.61.12.165): +# bash cookbook/client/server/transformer/run_dsv4_0731_npu_2node_2npu.sh worker +# +# Override NETWORK_IFACE when the Ray/HCCL interface is not eth0. Set +# RESET_RAY=1 when intentionally replacing an existing local Ray runtime. + +ROLE="${1:-}" +if [[ "$ROLE" != "head" && "$ROLE" != "worker" ]]; then + echo "Usage: $0 {head|worker}" >&2 + exit 2 +fi + +DEFAULT_HEAD_IP="172.61.10.111" +DEFAULT_WORKER_IP="172.61.12.165" +HEAD_IP="${HEAD_IP:-$DEFAULT_HEAD_IP}" +WORKER_IP="${WORKER_IP:-$DEFAULT_WORKER_IP}" + +if [[ "$ROLE" == "head" ]]; then + NODE_IP="${NODE_IP:-$HEAD_IP}" + NODE_RANK=0 +else + NODE_IP="${NODE_IP:-$WORKER_IP}" + NODE_RANK=1 +fi + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd -- "$SCRIPT_DIR/../../../.." && pwd)" +CONFIG_PATH="${TWINKLE_SERVER_CONFIG_PATH:-$SCRIPT_DIR/server_config_dsv4_0731_npu_2node_2npu.yaml}" +if [[ "$CONFIG_PATH" != /* ]]; then + CONFIG_PATH="$PROJECT_DIR/$CONFIG_PATH" +fi + +export DSV4_MODEL_ID="${DSV4_MODEL_ID:-hf://deepseek-ai/DeepSeek-V4-Flash-0731}" +DATASET_PATH="${DATASET_ID:-/model/ljl/dataset/self-cognition.jsonl}" + +NPU_PER_NODE=2 +NNODES=2 +TOTAL_NPUS=$((NPU_PER_NODE * NNODES)) +RAY_PORT="${RAY_PORT:-6379}" +RAY_CPUS_PER_NODE="${TWINKLE_RAY_CPUS:-$(nproc)}" +CLUSTER_WAIT_SECONDS="${CLUSTER_WAIT_SECONDS:-1800}" +NETWORK_IFACE="${NETWORK_IFACE:-eth0}" + +if [[ -f /usr/local/Ascend/ascend-toolkit/set_env.sh ]]; then + # shellcheck disable=SC1091 + source /usr/local/Ascend/ascend-toolkit/set_env.sh +fi + +cd "$PROJECT_DIR" +export PYTHONPATH="$PROJECT_DIR/src${PYTHONPATH:+:$PYTHONPATH}" +export ASCEND_RT_VISIBLE_DEVICES="${ASCEND_RT_VISIBLE_DEVICES:-0,1}" +export RAY_EXPERIMENTAL_NOSET_ASCEND_RT_VISIBLE_DEVICES=1 +export TWINKLE_TRUST_REMOTE_CODE=1 +export TWINKLE_FAIL_FAST=1 +export TWINKLE_EP_FORCE_LOOP="${TWINKLE_EP_FORCE_LOOP:-0}" +export TWINKLE_EP_DIAGNOSTICS="${TWINKLE_EP_DIAGNOSTICS:-0}" +export TOKENIZERS_PARALLELISM=true +export GLOO_SOCKET_IFNAME="$NETWORK_IFACE" +export HCCL_SOCKET_IFNAME="$NETWORK_IFACE" +export HCCL_CONNECT_TIMEOUT="${HCCL_CONNECT_TIMEOUT:-7200}" +export HCCL_EXEC_TIMEOUT="${HCCL_EXEC_TIMEOUT:-0}" +export HCCL_IF_BASE_PORT="${HCCL_IF_BASE_PORT:-20000}" +export NODE_RANK + +IFS=',' read -r -a VISIBLE_NPUS <<< "$ASCEND_RT_VISIBLE_DEVICES" +if [[ "${#VISIBLE_NPUS[@]}" -ne "$NPU_PER_NODE" ]]; then + echo "Expected $NPU_PER_NODE visible NPUs, got ASCEND_RT_VISIBLE_DEVICES=$ASCEND_RT_VISIBLE_DEVICES" >&2 + exit 1 +fi + +test -f "$CONFIG_PATH" +if [[ "${SKIP_DATASET_CHECK:-0}" != "1" ]]; then + test -f "$DATASET_PATH" +fi + +if [[ "$DSV4_MODEL_ID" == hf://* || "$DSV4_MODEL_ID" == ms://* ]]; then + echo "Model will be downloaded through the configured Hub backend: $DSV4_MODEL_ID" +elif [[ -d "$DSV4_MODEL_ID" ]]; then + test -f "$DSV4_MODEL_ID/config.json" + test -f "$DSV4_MODEL_ID/tokenizer.json" +else + echo "Invalid DSV4_MODEL_ID: use an existing local directory or an explicit hf:// or ms:// ID." >&2 + exit 1 +fi + +python3 - "$NPU_PER_NODE" <<'PY' +import sys +import torch +import torch_npu # noqa: F401 + +expected = int(sys.argv[1]) +if not torch.npu.is_available(): + raise SystemExit('torch.npu.is_available() is False') +actual = torch.npu.device_count() +if actual < expected: + raise SystemExit(f'Expected at least {expected} visible NPUs, got {actual}') +print(f'Ascend check passed: visible NPU count={actual}') +PY + +if [[ "${RESET_RAY:-0}" == "1" ]]; then + ray stop --force || true +fi + +if ray status >/dev/null 2>&1; then + echo "Ray is already running on this node; set RESET_RAY=1 to replace it." +elif [[ "$ROLE" == "head" ]]; then + ray start \ + --head \ + --node-ip-address="$HEAD_IP" \ + --port="$RAY_PORT" \ + --num-cpus="$RAY_CPUS_PER_NODE" \ + --resources="{\"NPU\": $NPU_PER_NODE}" \ + --disable-usage-stats \ + --include-dashboard=false +else + ray start \ + --address="$HEAD_IP:$RAY_PORT" \ + --node-ip-address="$NODE_IP" \ + --num-cpus="$RAY_CPUS_PER_NODE" \ + --resources="{\"NPU\": $NPU_PER_NODE}" \ + --disable-usage-stats +fi + +if [[ "$ROLE" == "worker" ]]; then + echo "Worker $NODE_IP joined Ray at $HEAD_IP:$RAY_PORT with $NPU_PER_NODE NPU resources." + exit 0 +fi + +python3 - "$TOTAL_NPUS" "$NNODES" "$NPU_PER_NODE" "$CLUSTER_WAIT_SECONDS" <<'PY' +import sys +import time +import ray + +expected_total = int(sys.argv[1]) +expected_nodes = int(sys.argv[2]) +expected_per_node = int(sys.argv[3]) +timeout = int(sys.argv[4]) +deadline = time.monotonic() + timeout + +ray.init(address='auto', logging_level='ERROR') +try: + while True: + alive_nodes = [node for node in ray.nodes() if node.get('Alive', True)] + npu_nodes = [ + node for node in alive_nodes + if float(node.get('Resources', {}).get('NPU', 0)) >= expected_per_node + ] + total = int(ray.cluster_resources().get('NPU', 0)) + print(f'Waiting for cluster: NPU={total}/{expected_total}, NPU nodes={len(npu_nodes)}/{expected_nodes}') + if total >= expected_total and len(npu_nodes) >= expected_nodes: + break + if time.monotonic() >= deadline: + raise SystemExit( + f'Timed out waiting for {expected_nodes} nodes and {expected_total} total NPU resources') + time.sleep(5) +finally: + ray.shutdown() +PY + +python3 -m twinkle.server check-config --config "$CONFIG_PATH" +echo "Twinkle config: $CONFIG_PATH" +echo "EP diagnostics: force_loop=$TWINKLE_EP_FORCE_LOOP diagnostics=$TWINKLE_EP_DIAGNOSTICS" +echo "Launching Twinkle Server at http://$HEAD_IP:8000" +exec python3 -m twinkle.server launch --config "$CONFIG_PATH" diff --git a/cookbook/client/server/transformer/run_dsv4_0731_npu_multinode.sh b/cookbook/client/server/transformer/run_dsv4_0731_npu_multinode.sh new file mode 100755 index 000000000..b2547993c --- /dev/null +++ b/cookbook/client/server/transformer/run_dsv4_0731_npu_multinode.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Start a two-node Ascend A3 Ray cluster and launch the 32-NPU Twinkle server. +# +# Head node (starts Ray, waits for the worker, then launches Twinkle Server): +# HEAD_IP=10.0.0.10 NODE_IP=10.0.0.10 NETWORK_IFACE=eth0 \ +# bash run_dsv4_0731_npu_multinode.sh head +# +# Worker node (joins Ray and exits, leaving the Ray daemon running): +# HEAD_IP=10.0.0.10 NODE_IP=10.0.0.11 NETWORK_IFACE=eth0 \ +# bash run_dsv4_0731_npu_multinode.sh worker +# +# Set RESET_RAY=1 when intentionally replacing an existing local Ray runtime. + +ROLE="${1:-}" +if [[ "$ROLE" != "head" && "$ROLE" != "worker" ]]; then + echo "Usage: HEAD_IP= NODE_IP= NETWORK_IFACE= $0 {head|worker}" >&2 + exit 2 +fi + +: "${HEAD_IP:?Set HEAD_IP to the Ray head node IP}" +: "${NODE_IP:?Set NODE_IP to this node IP}" + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd -- "$SCRIPT_DIR/../../../.." && pwd)" +CONFIG_PATH="$SCRIPT_DIR/server_config_dsv4_0731_npu_multinode.yaml" +export DSV4_MODEL_ID="${DSV4_MODEL_ID:-hf://deepseek-ai/DeepSeek-V4-Flash-0731}" +DATASET_PATH="${DATASET_ID:-/model/ljl/dataset/self-cognition.jsonl}" + +NPU_PER_NODE=16 +NNODES=2 +TOTAL_NPUS=$((NPU_PER_NODE * NNODES)) +RAY_PORT="${RAY_PORT:-6379}" +RAY_CPUS_PER_NODE="${TWINKLE_RAY_CPUS:-$(nproc)}" +CLUSTER_WAIT_SECONDS="${CLUSTER_WAIT_SECONDS:-1800}" +NETWORK_IFACE="${NETWORK_IFACE:-eth0}" + +if [[ -f /usr/local/Ascend/ascend-toolkit/set_env.sh ]]; then + # shellcheck disable=SC1091 + source /usr/local/Ascend/ascend-toolkit/set_env.sh +fi + +cd "$PROJECT_DIR" +export PYTHONPATH="$PROJECT_DIR/src${PYTHONPATH:+:$PYTHONPATH}" +export ASCEND_RT_VISIBLE_DEVICES="${ASCEND_RT_VISIBLE_DEVICES:-0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15}" +export RAY_EXPERIMENTAL_NOSET_ASCEND_RT_VISIBLE_DEVICES=1 +export TWINKLE_TRUST_REMOTE_CODE=1 +export TWINKLE_FAIL_FAST=1 +export TOKENIZERS_PARALLELISM=true +export GLOO_SOCKET_IFNAME="$NETWORK_IFACE" +export HCCL_SOCKET_IFNAME="$NETWORK_IFACE" +export HCCL_CONNECT_TIMEOUT="${HCCL_CONNECT_TIMEOUT:-7200}" +export HCCL_EXEC_TIMEOUT="${HCCL_EXEC_TIMEOUT:-0}" +export HCCL_IF_BASE_PORT="${HCCL_IF_BASE_PORT:-20000}" + +if [[ "$ROLE" == "head" ]]; then + export NODE_RANK=0 +else + export NODE_RANK=1 +fi + +IFS=',' read -r -a VISIBLE_NPUS <<< "$ASCEND_RT_VISIBLE_DEVICES" +if [[ "${#VISIBLE_NPUS[@]}" -ne "$NPU_PER_NODE" ]]; then + echo "Expected $NPU_PER_NODE visible NPUs, got ASCEND_RT_VISIBLE_DEVICES=$ASCEND_RT_VISIBLE_DEVICES" >&2 + exit 1 +fi + +test -f "$CONFIG_PATH" +test -f "$DATASET_PATH" + +if [[ "$DSV4_MODEL_ID" == hf://* || "$DSV4_MODEL_ID" == ms://* ]]; then + echo "Model will be downloaded through the configured Hub backend: $DSV4_MODEL_ID" +elif [[ -d "$DSV4_MODEL_ID" ]]; then + test -f "$DSV4_MODEL_ID/config.json" + test -f "$DSV4_MODEL_ID/tokenizer.json" +else + echo "Invalid DSV4_MODEL_ID: use an existing local directory or an explicit hf:// or ms:// ID." >&2 + exit 1 +fi + +python3 - "$NPU_PER_NODE" <<'PY' +import sys +import torch +import torch_npu # noqa: F401 + +expected = int(sys.argv[1]) +if not torch.npu.is_available(): + raise SystemExit('torch.npu.is_available() is False') +actual = torch.npu.device_count() +if actual < expected: + raise SystemExit(f'Expected at least {expected} visible NPUs, got {actual}') +print(f'Ascend check passed: visible NPU count={actual}') +PY + +if [[ "${RESET_RAY:-0}" == "1" ]]; then + ray stop --force || true +fi + +if ray status >/dev/null 2>&1; then + echo "Ray is already running on this node; set RESET_RAY=1 to replace it." +elif [[ "$ROLE" == "head" ]]; then + ray start \ + --head \ + --node-ip-address="$HEAD_IP" \ + --port="$RAY_PORT" \ + --num-cpus="$RAY_CPUS_PER_NODE" \ + --resources="{\"NPU\": $NPU_PER_NODE}" \ + --disable-usage-stats \ + --include-dashboard=false +else + ray start \ + --address="$HEAD_IP:$RAY_PORT" \ + --node-ip-address="$NODE_IP" \ + --num-cpus="$RAY_CPUS_PER_NODE" \ + --resources="{\"NPU\": $NPU_PER_NODE}" \ + --disable-usage-stats +fi + +if [[ "$ROLE" == "worker" ]]; then + echo "Worker joined Ray at $HEAD_IP:$RAY_PORT with $NPU_PER_NODE NPU resources." + exit 0 +fi + +python3 - "$TOTAL_NPUS" "$NNODES" "$NPU_PER_NODE" "$CLUSTER_WAIT_SECONDS" <<'PY' +import sys +import time +import ray + +expected_total = int(sys.argv[1]) +expected_nodes = int(sys.argv[2]) +expected_per_node = int(sys.argv[3]) +timeout = int(sys.argv[4]) +deadline = time.monotonic() + timeout + +ray.init(address='auto', logging_level='ERROR') +try: + while True: + alive_nodes = [node for node in ray.nodes() if node.get('Alive', True)] + npu_nodes = [node for node in alive_nodes if float(node.get('Resources', {}).get('NPU', 0)) >= expected_per_node] + total = int(ray.cluster_resources().get('NPU', 0)) + print(f'Waiting for cluster: NPU={total}/{expected_total}, NPU nodes={len(npu_nodes)}/{expected_nodes}') + if total >= expected_total and len(npu_nodes) >= expected_nodes: + break + if time.monotonic() >= deadline: + raise SystemExit('Timed out waiting for the two-node 32-NPU Ray cluster') + time.sleep(5) +finally: + ray.shutdown() +PY + +python3 -m twinkle.server check-config --config "$CONFIG_PATH" +echo "Launching Twinkle Server at http://$HEAD_IP:8000" +exec python3 -m twinkle.server launch --config "$CONFIG_PATH" diff --git a/cookbook/client/server/transformer/run_dsv4_4layer_ep_diagnostic.sh b/cookbook/client/server/transformer/run_dsv4_4layer_ep_diagnostic.sh new file mode 100755 index 000000000..e1821f91f --- /dev/null +++ b/cookbook/client/server/transformer/run_dsv4_4layer_ep_diagnostic.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROLE="${1:-}" +MODE="${2:-}" + +if [[ "$ROLE" != "head" && "$ROLE" != "worker" ]]; then + echo "Usage: $0 {head|worker} {no_ep|ep_loop|ep_gmm}" >&2 + exit 2 +fi +if [[ "$MODE" != "no_ep" && "$MODE" != "ep_loop" && "$MODE" != "ep_gmm" ]]; then + echo "Usage: $0 {head|worker} {no_ep|ep_loop|ep_gmm}" >&2 + exit 2 +fi + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + +case "$MODE" in + no_ep) + export TWINKLE_SERVER_CONFIG_PATH="$SCRIPT_DIR/server_config_dsv4_4layer_diag_no_ep.yaml" + export TWINKLE_EP_FORCE_LOOP=0 + ;; + ep_loop) + export TWINKLE_SERVER_CONFIG_PATH="$SCRIPT_DIR/server_config_dsv4_4layer_diag_ep.yaml" + export TWINKLE_EP_FORCE_LOOP=1 + ;; + ep_gmm) + export TWINKLE_SERVER_CONFIG_PATH="$SCRIPT_DIR/server_config_dsv4_4layer_diag_ep.yaml" + export TWINKLE_EP_FORCE_LOOP=0 + ;; +esac + +export TWINKLE_EP_DIAGNOSTICS="${TWINKLE_EP_DIAGNOSTICS:-1}" +export SKIP_DATASET_CHECK=1 + +echo "Starting DeepSeek-V4 four-layer diagnostic: role=$ROLE mode=$MODE" +exec "$SCRIPT_DIR/run_dsv4_0731_npu_2node_2npu.sh" "$ROLE" diff --git a/cookbook/client/server/transformer/server_config_dsv4_0731.yaml b/cookbook/client/server/transformer/server_config_dsv4_0731.yaml new file mode 100644 index 000000000..5a8b49542 --- /dev/null +++ b/cookbook/client/server/transformer/server_config_dsv4_0731.yaml @@ -0,0 +1,120 @@ +# DeepSeek-V4-Flash-0731 client/server Multi-LoRA SFT on one 2-GPU node. +# Both GPUs are assigned to the Transformers training model. A vLLM sampler +# is intentionally omitted because it would compete with training for GPUs. + +proxy_location: EveryNode + +http_options: + host: 0.0.0.0 + port: 8000 + +telemetry: + enabled: false + otlp_endpoint: http://localhost:4317 + +persistence: + mode: file + file_path: /tmp/twinkle_dsv4_0731_state.json + +applications: + - name: server + route_prefix: /api/v1 + import_path: server + args: + server_config: + per_token_model_limit: 2 + # This public name is used by the client and HTTP routes. The launcher + # maps it to the local model_id configured on the model deployment. + supported_models: + - deepseek-v4-0731-local + deployments: + - name: TinkerCompatServer + max_ongoing_requests: 50 + autoscaling_config: + min_replicas: 1 + max_replicas: 1 + target_ongoing_requests: 32 + ray_actor_options: + num_cpus: 0.1 + runtime_env: + env_vars: + TWINKLE_FAIL_FAST: "1" + + - name: models-deepseek-v4-0731-local + route_prefix: /api/v1/model/deepseek-v4-0731-local + import_path: model + args: + backend: transformers + model_id: /nas/disk1/random-deepseek-v4-4b + nproc_per_node: 2 + max_loras: 2 + max_r: 8 + max_length: 8192 + mixed_precision: bf16 + strategy: native_fsdp + memory_efficient_init: true + target_modules: all-linear + fsdp_config: + reshard_after_forward: true + expert_parallel: + enabled: true + ep_size: 2 + router_dtype: fp32 + keep_router_logits: false + device_group: + name: model + ranks: 2 + device_type: cuda + device_mesh: + device_type: cuda + fsdp_size: 2 + dp_size: 1 + ep_size: 2 + queue_config: + rps_limit: 20 + tps_limit: 100000 + max_input_tokens: 8192 + queue_timeout: 3600 + execution_timeout: 3600 + adapter_config: + adapter_timeout: 3600 + adapter_max_lifetime: 86400 + deployments: + - name: ModelManagement + autoscaling_config: + min_replicas: 1 + max_replicas: 1 + target_ongoing_requests: 8 + ray_actor_options: + num_cpus: 0.1 + runtime_env: + env_vars: + TWINKLE_TRUST_REMOTE_CODE: "1" + TWINKLE_FAIL_FAST: "1" + + # Dataset/tokenization stays on CPU and reads the local JSONL path from the + # machine running the server. + - name: processor + route_prefix: /api/v1/processor + import_path: processor + args: + ncpu_proc_per_node: 2 + device_group: + name: processor + ranks: 2 + device_type: CPU + device_mesh: + device_type: CPU + dp_size: 2 + deployments: + - name: ProcessorManagement + autoscaling_config: + min_replicas: 1 + max_replicas: 1 + target_ongoing_requests: 32 + ray_actor_options: + num_cpus: 0.1 + runtime_env: + env_vars: + TWINKLE_TRUST_REMOTE_CODE: "1" + TWINKLE_FAIL_FAST: "1" diff --git a/cookbook/client/server/transformer/server_config_dsv4_0731_npu.yaml b/cookbook/client/server/transformer/server_config_dsv4_0731_npu.yaml new file mode 100644 index 000000000..6d00f66eb --- /dev/null +++ b/cookbook/client/server/transformer/server_config_dsv4_0731_npu.yaml @@ -0,0 +1,116 @@ +# DeepSeek-V4-Flash-0731 client/server Multi-LoRA SFT on one 2-NPU node. +# Both NPUs are assigned to the Transformers training model. A sampler is +# intentionally omitted because it would compete with training for devices. + +proxy_location: EveryNode + +http_options: + host: 0.0.0.0 + port: 8000 + +telemetry: + enabled: false + otlp_endpoint: http://localhost:4317 + +persistence: + mode: file + file_path: /tmp/twinkle_dsv4_0731_npu_state.json + +applications: + - name: server + route_prefix: /api/v1 + import_path: server + args: + server_config: + per_token_model_limit: 2 + supported_models: + - deepseek-v4-0731-local + deployments: + - name: TinkerCompatServer + max_ongoing_requests: 50 + autoscaling_config: + min_replicas: 1 + max_replicas: 1 + target_ongoing_requests: 32 + ray_actor_options: + num_cpus: 0.1 + runtime_env: + env_vars: + TWINKLE_FAIL_FAST: "1" + + - name: models-deepseek-v4-0731-local + route_prefix: /api/v1/model/deepseek-v4-0731-local + import_path: model + args: + backend: transformers + model_id: /nas/disk1/random-deepseek-v4-4b + nproc_per_node: 2 + max_loras: 2 + max_r: 8 + max_length: 2048 + mixed_precision: bf16 + strategy: native_fsdp + memory_efficient_init: true + target_modules: all-linear + fsdp_config: + reshard_after_forward: true + expert_parallel: + enabled: true + ep_size: 2 + router_dtype: fp32 + keep_router_logits: false + device_group: + name: model + ranks: 2 + device_type: npu + device_mesh: + device_type: npu + fsdp_size: 2 + dp_size: 1 + ep_size: 2 + queue_config: + rps_limit: 20 + tps_limit: 100000 + max_input_tokens: 4096 + queue_timeout: 3600 + execution_timeout: 3600 + adapter_config: + adapter_timeout: 3600 + adapter_max_lifetime: 86400 + deployments: + - name: ModelManagement + autoscaling_config: + min_replicas: 1 + max_replicas: 1 + target_ongoing_requests: 8 + ray_actor_options: + num_cpus: 0.1 + runtime_env: + env_vars: + TWINKLE_TRUST_REMOTE_CODE: "1" + TWINKLE_FAIL_FAST: "1" + + - name: processor + route_prefix: /api/v1/processor + import_path: processor + args: + ncpu_proc_per_node: 2 + device_group: + name: processor + ranks: 2 + device_type: CPU + device_mesh: + device_type: CPU + dp_size: 2 + deployments: + - name: ProcessorManagement + autoscaling_config: + min_replicas: 1 + max_replicas: 1 + target_ongoing_requests: 32 + ray_actor_options: + num_cpus: 0.1 + runtime_env: + env_vars: + TWINKLE_TRUST_REMOTE_CODE: "1" + TWINKLE_FAIL_FAST: "1" diff --git a/cookbook/client/server/transformer/server_config_dsv4_0731_npu_2node_2npu.yaml b/cookbook/client/server/transformer/server_config_dsv4_0731_npu_2node_2npu.yaml new file mode 100644 index 000000000..f03121f8e --- /dev/null +++ b/cookbook/client/server/transformer/server_config_dsv4_0731_npu_2node_2npu.yaml @@ -0,0 +1,121 @@ +# DeepSeek-V4-compatible Multi-LoRA SFT on two Ascend A3 nodes. +# Each node contributes 2 NPUs, giving a 4-rank Native FSDP2 + EP mesh. +# +# DSV4_MODEL_ID may be either: +# - hf://deepseek-ai/DeepSeek-V4-Flash-0731 +# - ms://namespace/model-name +# - the same absolute local model directory on both nodes + +proxy_location: EveryNode + +http_options: + host: 0.0.0.0 + port: 8000 + +telemetry: + enabled: false + otlp_endpoint: http://localhost:4317 + +persistence: + mode: memory + key_prefix: dsv4-0731-a3-2node-4npu + +applications: + - name: server + route_prefix: /api/v1 + import_path: server + args: + server_config: + per_token_model_limit: 2 + supported_models: + - deepseek-v4-0731-local + deployments: + - name: TinkerCompatServer + max_ongoing_requests: 50 + autoscaling_config: + min_replicas: 1 + max_replicas: 1 + target_ongoing_requests: 32 + ray_actor_options: + num_cpus: 0.1 + runtime_env: + env_vars: + TWINKLE_FAIL_FAST: "1" + + - name: models-deepseek-v4-0731-local + route_prefix: /api/v1/model/deepseek-v4-0731-local + import_path: model + args: + backend: transformers + # Resolved by OmegaConf when the head node reads this YAML. + model_id: ${oc.env:DSV4_MODEL_ID,hf://deepseek-ai/DeepSeek-V4-Flash-0731} + nproc_per_node: 2 + max_loras: 2 + max_r: 8 + max_length: 2048 + mixed_precision: bf16 + strategy: native_fsdp + memory_efficient_init: true + target_modules: all-linear + fsdp_config: + reshard_after_forward: true + expert_parallel: + enabled: true + ep_size: 4 + router_dtype: fp32 + keep_router_logits: false + device_group: + name: model + ranks: 4 + device_type: npu + device_mesh: + device_type: npu + fsdp_size: 4 + dp_size: 1 + ep_size: 4 + queue_config: + rps_limit: 20 + tps_limit: 100000 + max_input_tokens: 4096 + queue_timeout: 7200 + execution_timeout: 7200 + adapter_config: + adapter_timeout: 60 + adapter_max_lifetime: 86400 + deployments: + - name: ModelManagement + autoscaling_config: + min_replicas: 1 + max_replicas: 1 + target_ongoing_requests: 8 + ray_actor_options: + num_cpus: 0.1 + runtime_env: + env_vars: + TWINKLE_TRUST_REMOTE_CODE: "1" + TWINKLE_FAIL_FAST: "1" + + - name: processor + route_prefix: /api/v1/processor + import_path: processor + args: + ncpu_proc_per_node: 2 + device_group: + name: processor + ranks: 2 + device_type: CPU + device_mesh: + device_type: CPU + dp_size: 2 + deployments: + - name: ProcessorManagement + autoscaling_config: + min_replicas: 1 + max_replicas: 1 + target_ongoing_requests: 32 + ray_actor_options: + num_cpus: 0.1 + runtime_env: + env_vars: + TWINKLE_TRUST_REMOTE_CODE: "1" + TWINKLE_FAIL_FAST: "1" diff --git a/cookbook/client/server/transformer/server_config_dsv4_0731_npu_multinode.yaml b/cookbook/client/server/transformer/server_config_dsv4_0731_npu_multinode.yaml new file mode 100644 index 000000000..c82324fad --- /dev/null +++ b/cookbook/client/server/transformer/server_config_dsv4_0731_npu_multinode.yaml @@ -0,0 +1,127 @@ +# DeepSeek-V4-Flash-0731 Multi-LoRA SFT on two Ascend A3 nodes. +# Each node contributes 16 NPUs, for a 32-rank Native FSDP2 + EP deployment. +# +# Required on both nodes: +# - the same Twinkle commit and Python/CANN/torch_npu environment +# - enough local Hub cache space on both nodes for the downloaded checkpoint +# - OUTPUT_DIR used by the client mounted as a shared filesystem + +proxy_location: EveryNode + +http_options: + host: 0.0.0.0 + port: 8000 + +telemetry: + enabled: false + otlp_endpoint: http://localhost:4317 + +# "memory" is backed by one detached Ray actor and is shared by all Serve +# processes on both nodes. It does not survive a full Ray-cluster shutdown. +persistence: + mode: memory + key_prefix: dsv4-0731-a3-32npu + +applications: + - name: server + route_prefix: /api/v1 + import_path: server + args: + server_config: + per_token_model_limit: 1 + supported_models: + - deepseek-v4-0731-local + deployments: + - name: TinkerCompatServer + max_ongoing_requests: 50 + autoscaling_config: + min_replicas: 1 + max_replicas: 1 + target_ongoing_requests: 32 + ray_actor_options: + num_cpus: 0.1 + runtime_env: + env_vars: + TWINKLE_FAIL_FAST: "1" + + - name: models-deepseek-v4-0731-local + route_prefix: /api/v1/model/deepseek-v4-0731-local + import_path: model + args: + backend: transformers + # Twinkle treats an unprefixed ID as ModelScope. Keep the hf:// prefix + # here to select Hugging Face explicitly. + # Resolved from the launch environment on the head node. + model_id: ${oc.env:DSV4_MODEL_ID,hf://deepseek-ai/DeepSeek-V4-Flash-0731} + nproc_per_node: 16 + max_loras: 1 + max_r: 8 + max_length: 8192 + mixed_precision: bf16 + strategy: native_fsdp + memory_efficient_init: true + target_modules: all-linear + fsdp_config: + reshard_after_forward: true + expert_parallel: + enabled: true + ep_size: 32 + router_dtype: fp32 + keep_router_logits: false + device_group: + name: model + ranks: 32 + device_type: npu + device_mesh: + device_type: npu + fsdp_size: 32 + dp_size: 1 + ep_size: 32 + queue_config: + rps_limit: 20 + tps_limit: 100000 + max_input_tokens: 8192 + queue_timeout: 7200 + execution_timeout: 7200 + adapter_config: + adapter_timeout: 60 + adapter_max_lifetime: 86400 + deployments: + - name: ModelManagement + autoscaling_config: + min_replicas: 1 + max_replicas: 1 + target_ongoing_requests: 8 + ray_actor_options: + num_cpus: 0.1 + runtime_env: + env_vars: + TWINKLE_TRUST_REMOTE_CODE: "1" + TWINKLE_FAIL_FAST: "1" + + # Dataset construction and tokenization run on CPU. The dataset and model + # encoding files are resolved on the node selected for this deployment. + - name: processor + route_prefix: /api/v1/processor + import_path: processor + args: + ncpu_proc_per_node: 2 + device_group: + name: processor + ranks: 2 + device_type: CPU + device_mesh: + device_type: CPU + dp_size: 2 + deployments: + - name: ProcessorManagement + autoscaling_config: + min_replicas: 1 + max_replicas: 1 + target_ongoing_requests: 32 + ray_actor_options: + num_cpus: 0.1 + runtime_env: + env_vars: + TWINKLE_TRUST_REMOTE_CODE: "1" + TWINKLE_FAIL_FAST: "1" diff --git a/cookbook/client/server/transformer/server_config_dsv4_4layer_diag_ep.yaml b/cookbook/client/server/transformer/server_config_dsv4_4layer_diag_ep.yaml new file mode 100644 index 000000000..13d9dabe6 --- /dev/null +++ b/cookbook/client/server/transformer/server_config_dsv4_4layer_diag_ep.yaml @@ -0,0 +1,117 @@ +# Four-layer DeepSeek-V4 diagnostic: FSDP2 + EP on two nodes, two NPUs per node. +# DSV4_MODEL_ID must point to a structurally complete four-layer BF16 checkpoint. + +proxy_location: EveryNode + +http_options: + host: 0.0.0.0 + port: 8000 + +telemetry: + enabled: false + otlp_endpoint: http://localhost:4317 + +persistence: + mode: memory + key_prefix: dsv4-4layer-diag-ep + +applications: + - name: server + route_prefix: /api/v1 + import_path: server + args: + server_config: + per_token_model_limit: 1 + supported_models: + - deepseek-v4-0731-local + deployments: + - name: TinkerCompatServer + max_ongoing_requests: 4 + autoscaling_config: + min_replicas: 1 + max_replicas: 1 + target_ongoing_requests: 1 + ray_actor_options: + num_cpus: 0.1 + runtime_env: + env_vars: + TWINKLE_FAIL_FAST: "1" + + - name: models-deepseek-v4-0731-local + route_prefix: /api/v1/model/deepseek-v4-0731-local + import_path: model + args: + backend: transformers + model_id: ${oc.env:DSV4_MODEL_ID} + nproc_per_node: 2 + max_loras: 1 + max_r: 8 + max_length: 512 + mixed_precision: bf16 + strategy: native_fsdp + memory_efficient_init: true + target_modules: all-linear + fsdp_config: + reshard_after_forward: true + expert_parallel: + enabled: true + ep_size: 4 + router_dtype: fp32 + keep_router_logits: false + device_group: + name: model + ranks: 4 + device_type: npu + device_mesh: + device_type: npu + fsdp_size: 4 + dp_size: 1 + ep_size: 4 + queue_config: + rps_limit: 1 + tps_limit: 10000 + max_input_tokens: 512 + queue_timeout: 7200 + execution_timeout: 7200 + adapter_config: + adapter_timeout: 60 + adapter_max_lifetime: 7200 + deployments: + - name: ModelManagement + autoscaling_config: + min_replicas: 1 + max_replicas: 1 + target_ongoing_requests: 1 + ray_actor_options: + num_cpus: 0.1 + runtime_env: + env_vars: + TWINKLE_TRUST_REMOTE_CODE: "1" + TWINKLE_FAIL_FAST: "1" + TWINKLE_EP_FORCE_LOOP: ${oc.env:TWINKLE_EP_FORCE_LOOP,0} + TWINKLE_EP_DIAGNOSTICS: ${oc.env:TWINKLE_EP_DIAGNOSTICS,1} + + - name: processor + route_prefix: /api/v1/processor + import_path: processor + args: + ncpu_proc_per_node: 1 + device_group: + name: processor + ranks: 2 + device_type: CPU + device_mesh: + device_type: CPU + dp_size: 2 + deployments: + - name: ProcessorManagement + autoscaling_config: + min_replicas: 1 + max_replicas: 1 + target_ongoing_requests: 1 + ray_actor_options: + num_cpus: 0.1 + runtime_env: + env_vars: + TWINKLE_TRUST_REMOTE_CODE: "1" + TWINKLE_FAIL_FAST: "1" diff --git a/cookbook/client/server/transformer/server_config_dsv4_4layer_diag_no_ep.yaml b/cookbook/client/server/transformer/server_config_dsv4_4layer_diag_no_ep.yaml new file mode 100644 index 000000000..3576f7794 --- /dev/null +++ b/cookbook/client/server/transformer/server_config_dsv4_4layer_diag_no_ep.yaml @@ -0,0 +1,117 @@ +# Four-layer DeepSeek-V4 diagnostic reference: FSDP2 without expert parallel. +# Apart from the EP settings, keep this aligned with server_config_dsv4_4layer_diag_ep.yaml. + +proxy_location: EveryNode + +http_options: + host: 0.0.0.0 + port: 8000 + +telemetry: + enabled: false + otlp_endpoint: http://localhost:4317 + +persistence: + mode: memory + key_prefix: dsv4-4layer-diag-no-ep + +applications: + - name: server + route_prefix: /api/v1 + import_path: server + args: + server_config: + per_token_model_limit: 1 + supported_models: + - deepseek-v4-0731-local + deployments: + - name: TinkerCompatServer + max_ongoing_requests: 4 + autoscaling_config: + min_replicas: 1 + max_replicas: 1 + target_ongoing_requests: 1 + ray_actor_options: + num_cpus: 0.1 + runtime_env: + env_vars: + TWINKLE_FAIL_FAST: "1" + + - name: models-deepseek-v4-0731-local + route_prefix: /api/v1/model/deepseek-v4-0731-local + import_path: model + args: + backend: transformers + model_id: ${oc.env:DSV4_MODEL_ID} + nproc_per_node: 2 + max_loras: 1 + max_r: 8 + max_length: 512 + mixed_precision: bf16 + strategy: native_fsdp + memory_efficient_init: true + target_modules: all-linear + fsdp_config: + reshard_after_forward: true + expert_parallel: + enabled: false + ep_size: 1 + router_dtype: fp32 + keep_router_logits: false + device_group: + name: model + ranks: 4 + device_type: npu + device_mesh: + device_type: npu + fsdp_size: 4 + dp_size: 1 + ep_size: 1 + queue_config: + rps_limit: 1 + tps_limit: 10000 + max_input_tokens: 512 + queue_timeout: 7200 + execution_timeout: 7200 + adapter_config: + adapter_timeout: 60 + adapter_max_lifetime: 7200 + deployments: + - name: ModelManagement + autoscaling_config: + min_replicas: 1 + max_replicas: 1 + target_ongoing_requests: 1 + ray_actor_options: + num_cpus: 0.1 + runtime_env: + env_vars: + TWINKLE_TRUST_REMOTE_CODE: "1" + TWINKLE_FAIL_FAST: "1" + TWINKLE_EP_FORCE_LOOP: "0" + TWINKLE_EP_DIAGNOSTICS: ${oc.env:TWINKLE_EP_DIAGNOSTICS,1} + + - name: processor + route_prefix: /api/v1/processor + import_path: processor + args: + ncpu_proc_per_node: 1 + device_group: + name: processor + ranks: 2 + device_type: CPU + device_mesh: + device_type: CPU + dp_size: 2 + deployments: + - name: ProcessorManagement + autoscaling_config: + min_replicas: 1 + max_replicas: 1 + target_ongoing_requests: 1 + ray_actor_options: + num_cpus: 0.1 + runtime_env: + env_vars: + TWINKLE_TRUST_REMOTE_CODE: "1" + TWINKLE_FAIL_FAST: "1" diff --git a/cookbook/client/server/transformer/server_dsv4_0731.py b/cookbook/client/server/transformer/server_dsv4_0731.py new file mode 100644 index 000000000..3e8de2797 --- /dev/null +++ b/cookbook/client/server/transformer/server_dsv4_0731.py @@ -0,0 +1,12 @@ +# Copyright (c) ModelScope Contributors. All rights reserved. +"""Launch the two-GPU DeepSeek-V4-0731 Multi-LoRA server.""" +import os + +os.environ.setdefault('TWINKLE_TRUST_REMOTE_CODE', '1') + +from twinkle.server import launch_server # noqa: E402 + +file_dir = os.path.abspath(os.path.dirname(__file__)) +config_path = os.path.join(file_dir, 'server_config_dsv4_0731.yaml') + +launch_server(config_path=config_path) diff --git a/cookbook/client/server/transformer/server_dsv4_0731_npu.py b/cookbook/client/server/transformer/server_dsv4_0731_npu.py new file mode 100644 index 000000000..7b19f24c3 --- /dev/null +++ b/cookbook/client/server/transformer/server_dsv4_0731_npu.py @@ -0,0 +1,12 @@ +# Copyright (c) ModelScope Contributors. All rights reserved. +"""Launch the two-NPU DeepSeek-V4-0731 Multi-LoRA server.""" +import os + +os.environ.setdefault('TWINKLE_TRUST_REMOTE_CODE', '1') + +from twinkle.server import launch_server # noqa: E402 + +file_dir = os.path.abspath(os.path.dirname(__file__)) +config_path = os.path.join(file_dir, 'server_config_dsv4_0731_npu.yaml') + +launch_server(config_path=config_path) diff --git a/cookbook/client/twinkle/dsv4_multi_lora_self_cognition_sft.py b/cookbook/client/twinkle/dsv4_multi_lora_self_cognition_sft.py new file mode 100644 index 000000000..e52b58aa4 --- /dev/null +++ b/cookbook/client/twinkle/dsv4_multi_lora_self_cognition_sft.py @@ -0,0 +1,198 @@ +# Copyright (c) ModelScope Contributors. All rights reserved. +"""Client-side self-cognition SFT for a DeepSeek-V4-Flash-0731 server. + +Start the DeepSeek-V4 multi-node server first. This client trains one LoRA +tenant and updates only the routed-expert gate/up/down parameters. +""" +import math +import os + +from peft import LoraConfig + +from twinkle import get_logger, init_twinkle_client +from twinkle.dataset import DatasetMeta +from twinkle_client.dataloader import DataLoader +from twinkle_client.dataset import Dataset +from twinkle_client.model import MultiLoraTransformersModel + +logger = get_logger() + +SERVER_URL = os.environ.get('TWINKLE_SERVER_URL', 'http://localhost:8000') +SERVER_TOKEN = os.environ.get('TWINKLE_SERVER_TOKEN', 'EMPTY_TOKEN') +SERVED_MODEL_NAME = os.environ.get('TWINKLE_MODEL_ID', 'deepseek-v4-0731-local') +MODEL_PATH = os.environ.get('DSV4_MODEL_ID', '/nas/disk1/random-deepseek-v4-4b') +DATASET_ID = os.environ.get('DATASET_ID') +OUTPUT_DIR = os.environ.get('OUTPUT_DIR', '/tmp/twinkle_dsv4_0731_self_cognition') +MODEL_NAME = os.environ.get('SELF_COGNITION_MODEL_NAME', 'twinkle模型') +MODEL_AUTHOR = os.environ.get('SELF_COGNITION_MODEL_AUTHOR', 'ModelScope社区') + +ADAPTER_NAME = os.environ.get('ADAPTER_NAME', 'tenant_a').strip() +BATCH_SIZE = int(os.environ.get('BATCH_SIZE', '32')) +GRAD_ACCUM_STEPS = int(os.environ.get('GRAD_ACCUM_STEPS', '1')) +NUM_EPOCHS = int(os.environ.get('NUM_EPOCHS', '3')) +MAX_STEPS = int(os.environ.get('MAX_STEPS', '0')) +MAX_LENGTH = int(os.environ.get('MAX_LENGTH', '8192')) +TRUNCATION_STRATEGY = os.environ.get('TRUNCATION_STRATEGY', 'delete') +LR = float(os.environ.get('LR', '1e-4')) +LORA_R = int(os.environ.get('LORA_R', '8')) +LORA_ALPHA = int(os.environ.get('LORA_ALPHA', '32')) +ROUTED_EXPERT_TARGET_PARAMETERS = [ + 'mlp.experts.gate_up_proj', + 'mlp.experts.down_proj', +] + + +def _assert_finite_output(value, path='result') -> None: + if isinstance(value, dict): + for key, item in value.items(): + _assert_finite_output(item, f'{path}.{key}') + elif isinstance(value, (list, tuple)): + for index, item in enumerate(value): + _assert_finite_output(item, f'{path}[{index}]') + elif isinstance(value, float) and not math.isfinite(value): + raise RuntimeError(f'Inference smoke test produced a non-finite value at {path}: {value}') + + +def _build_dataset() -> Dataset: + if not DATASET_ID: + raise ValueError('Set DATASET_ID to a local self-cognition JSON or JSONL file.') + if DATASET_ID.startswith(('hf://', 'ms://')): + raise ValueError(f'DATASET_ID must be local for this recipe, got: {DATASET_ID}') + if not os.path.exists(DATASET_ID): + raise FileNotFoundError(f'Local self-cognition dataset not found: {DATASET_ID}') + + dataset = Dataset(dataset_meta=DatasetMeta(DATASET_ID)) + dataset.set_template( + 'DeepseekV4Template', + model_id=MODEL_PATH, + max_length=MAX_LENGTH, + truncation_strategy=TRUNCATION_STRATEGY, + ) + dataset.map( + 'SelfCognitionProcessor', + init_args={ + 'model_name': MODEL_NAME, + 'model_author': MODEL_AUTHOR, + }, + ) + dataset.encode() + return dataset + + +def _build_lora_config() -> LoraConfig: + return LoraConfig( + r=LORA_R, + lora_alpha=LORA_ALPHA, + lora_dropout=0.0, + target_modules=None, + target_parameters=ROUTED_EXPERT_TARGET_PARAMETERS, + bias='none', + ) + + +def train() -> None: + client = init_twinkle_client(base_url=SERVER_URL, api_key=SERVER_TOKEN) + supported_models = [item.model_name for item in client.get_server_capabilities().supported_models] + if SERVED_MODEL_NAME not in supported_models: + raise RuntimeError(f'{SERVED_MODEL_NAME!r} is not served; available models: {supported_models}') + if not ADAPTER_NAME: + raise ValueError('ADAPTER_NAME must not be empty.') + if BATCH_SIZE < 32 or BATCH_SIZE % 32 != 0: + raise ValueError('BATCH_SIZE must be at least 32 and divisible by 32 for the 32-rank FSDP model.') + if NUM_EPOCHS <= 0: + raise ValueError('NUM_EPOCHS must be greater than zero.') + if MAX_STEPS < 0: + raise ValueError('MAX_STEPS must be zero (unlimited) or a positive integer.') + + dataset = _build_dataset() + dataloader = DataLoader( + dataset=dataset, + batch_size=BATCH_SIZE, + drop_last=True, + shuffle=True, + ) + model = MultiLoraTransformersModel(model_id=SERVED_MODEL_NAME) + save_dir = os.path.join(OUTPUT_DIR, ADAPTER_NAME) + os.makedirs(save_dir, exist_ok=True) + model.add_adapter_to_model( + ADAPTER_NAME, + _build_lora_config(), + gradient_accumulation_steps=GRAD_ACCUM_STEPS, + save_dir=save_dir, + ) + model.set_template( + 'DeepseekV4Template', + max_length=MAX_LENGTH, + truncation_strategy=TRUNCATION_STRATEGY, + ) + model.set_processor('InputProcessor', padding_side='right') + model.set_loss('CrossEntropyLoss') + model.set_optimizer('AdamW', lr=LR, foreach=False) + + completed_steps = 0 + stop_training = False + for epoch in range(NUM_EPOCHS): + logger.info('Starting epoch %s/%s', epoch + 1, NUM_EPOCHS) + for batch in dataloader: + if MAX_STEPS > 0 and completed_steps >= MAX_STEPS: + stop_training = True + break + model.forward_backward(inputs=batch, gradient_accumulation_steps=GRAD_ACCUM_STEPS) + model.clip_grad_and_step(max_grad_norm=1.0, gradient_accumulation_steps=GRAD_ACCUM_STEPS) + completed_steps += 1 + + if completed_steps % GRAD_ACCUM_STEPS == 0: + metric = model.calculate_metric(is_training=True) + logger.info( + 'adapter=%s epoch=%s/%s micro_step=%s metric=%s', + ADAPTER_NAME, + epoch + 1, + NUM_EPOCHS, + completed_steps, + metric.result, + ) + if stop_training: + break + + if completed_steps == 0: + raise RuntimeError( + f'No full batch was produced: dataset is smaller than BATCH_SIZE={BATCH_SIZE}. ' + 'Use a smaller valid global batch or provide more data.' + ) + + checkpoint = model.save( + name=f'dsv4-0731-{ADAPTER_NAME}-self-cognition-final', + save_optimizer=True, + consumed_train_samples=dataloader.get_state()['consumed_train_samples'], + ) + logger.info('Saved adapter %s: %s', ADAPTER_NAME, checkpoint.twinkle_path) + + eval_loader = DataLoader( + dataset=dataset, + batch_size=BATCH_SIZE, + drop_last=True, + shuffle=False, + ) + eval_batch = next(iter(eval_loader)) + lora_result = model.forward_only(inputs=eval_batch, disable_lora=False).result + lora_loss = model.calculate_loss().result + base_result = model.forward_only(inputs=eval_batch, disable_lora=True).result + base_loss = model.calculate_loss().result + _assert_finite_output(lora_result, f'{ADAPTER_NAME}.lora') + _assert_finite_output(base_result, f'{ADAPTER_NAME}.base') + _assert_finite_output(lora_loss, f'{ADAPTER_NAME}.lora_loss') + _assert_finite_output(base_loss, f'{ADAPTER_NAME}.base_loss') + logger.warning( + 'Inference result: adapter=%s lora_loss=%.6f base_loss=%.6f loss_delta=%.6f ' + 'lora_keys=%s base_keys=%s', + ADAPTER_NAME, + lora_loss, + base_loss, + lora_loss - base_loss, + list(lora_result) if isinstance(lora_result, dict) else type(lora_result).__name__, + list(base_result) if isinstance(base_result, dict) else type(base_result).__name__, + ) + + +if __name__ == '__main__': + train() diff --git a/cookbook/client/twinkle/dsv4_multi_lora_sft.py b/cookbook/client/twinkle/dsv4_multi_lora_sft.py new file mode 100644 index 000000000..24438a526 --- /dev/null +++ b/cookbook/client/twinkle/dsv4_multi_lora_sft.py @@ -0,0 +1,203 @@ +# Copyright (c) ModelScope Contributors. All rights reserved. +"""Client-side GSM8K LoRA SFT for a DeepSeek-V4-Flash-0731 server. + +Start the DeepSeek-V4 multi-node server first. This client trains one LoRA +tenant on the EP/FSDP model. +""" +import math +import os + +from peft import LoraConfig + +from twinkle import get_logger, init_twinkle_client +from twinkle.dataset import DatasetMeta +from twinkle_client.dataloader import DataLoader +from twinkle_client.dataset import Dataset +from twinkle_client.model import MultiLoraTransformersModel + +logger = get_logger() + +SERVER_URL = os.environ.get('TWINKLE_SERVER_URL', 'http://localhost:8000') +SERVER_TOKEN = os.environ.get('TWINKLE_SERVER_TOKEN', 'EMPTY_TOKEN') +SERVED_MODEL_NAME = os.environ.get('TWINKLE_MODEL_ID', 'deepseek-v4-0731-local') +MODEL_PATH = os.environ.get('DSV4_MODEL_ID', '/nas/disk1/random-deepseek-v4-4b') +DATASET_ID = os.environ.get('DATASET_ID') +DATASET_SUBSET = os.environ.get('DATASET_SUBSET', 'default') +DATASET_SPLIT = os.environ.get('DATASET_SPLIT', 'train') +OUTPUT_DIR = os.environ.get('OUTPUT_DIR', '/tmp/twinkle_dsv4_0731_multi_lora') + +ADAPTER_NAMES = tuple( + name.strip() for name in os.environ.get('ADAPTER_NAMES', 'tenant_a').split(',') if name.strip()) +BATCH_SIZE = int(os.environ.get('BATCH_SIZE', '32')) +GRAD_ACCUM_STEPS = int(os.environ.get('GRAD_ACCUM_STEPS', '4')) +MAX_STEPS = int(os.environ.get('MAX_STEPS', '10')) +MAX_LENGTH = int(os.environ.get('MAX_LENGTH', '8192')) +TRUNCATION_STRATEGY = os.environ.get('TRUNCATION_STRATEGY', 'delete') +LR = float(os.environ.get('LR', '1e-4')) +LORA_R = int(os.environ.get('LORA_R', '8')) +LORA_ALPHA = int(os.environ.get('LORA_ALPHA', '32')) +ROUTED_EXPERT_TARGET_PARAMETERS = [ + 'mlp.experts.gate_up_proj', + 'mlp.experts.down_proj', +] + + +def _assert_finite_output(value, path='result') -> None: + """Fail the inference smoke test when a returned numeric value is NaN/Inf.""" + if isinstance(value, dict): + for key, item in value.items(): + _assert_finite_output(item, f'{path}.{key}') + elif isinstance(value, (list, tuple)): + for index, item in enumerate(value): + _assert_finite_output(item, f'{path}[{index}]') + elif isinstance(value, float) and not math.isfinite(value): + raise RuntimeError(f'Inference smoke test produced a non-finite value at {path}: {value}') + + +def _build_dataset() -> Dataset: + if not DATASET_ID: + raise ValueError('Set DATASET_ID to a local GSM8K JSON/JSONL file or directory.') + if DATASET_ID.startswith(('hf://', 'ms://')): + raise ValueError(f'DATASET_ID must be local for this recipe, got: {DATASET_ID}') + if not os.path.exists(DATASET_ID): + raise FileNotFoundError(f'Local GSM8K dataset not found: {DATASET_ID}') + dataset = Dataset(dataset_meta=DatasetMeta( + DATASET_ID, + subset_name=DATASET_SUBSET, + split=DATASET_SPLIT, + )) + dataset.set_template( + 'DeepseekV4Template', + model_id=MODEL_PATH, + max_length=MAX_LENGTH, + truncation_strategy=TRUNCATION_STRATEGY, + ) + dataset.map( + 'GSM8KProcessor', + init_args={ + 'system': 'Solve the math problem step by step and put the final answer in \\boxed{}.', + # SFT needs the reference solution as the assistant target. + 'add_assistant': True, + }, + ) + dataset.encode() + return dataset + + +def _build_lora_config() -> LoraConfig: + return LoraConfig( + r=LORA_R, + lora_alpha=LORA_ALPHA, + lora_dropout=0.0, + # Train only the fused 3D routed-expert parameters. Do not install + # module LoRA on attention, router, shared experts, or the LM head. + target_modules=None, + target_parameters=ROUTED_EXPERT_TARGET_PARAMETERS, + bias='none', + ) + + +def train() -> None: + client = init_twinkle_client(base_url=SERVER_URL, api_key=SERVER_TOKEN) + supported_models = [item.model_name for item in client.get_server_capabilities().supported_models] + if SERVED_MODEL_NAME not in supported_models: + raise RuntimeError(f'{SERVED_MODEL_NAME!r} is not served; available models: {supported_models}') + if len(ADAPTER_NAMES) != 1: + raise ValueError('This recipe requires exactly one ADAPTER_NAMES entry.') + if BATCH_SIZE < 32 or BATCH_SIZE % 32 != 0: + raise ValueError('BATCH_SIZE must be at least 32 and divisible by 32 for the 32-rank FSDP model.') + + dataset = _build_dataset() + # Drop the final undersized batch because 32 FSDP ranks require a full + # global batch here. GSM8K is large enough for the configured run, so each + # adapter traverses at most one shuffled epoch. + dataloaders = { + name: DataLoader(dataset=dataset, batch_size=BATCH_SIZE, drop_last=True, shuffle=True) + for name in ADAPTER_NAMES + } + models = {name: MultiLoraTransformersModel(model_id=SERVED_MODEL_NAME) for name in ADAPTER_NAMES} + lora_config = _build_lora_config() + + # Register every tenant before set_optimizer(). The first optimizer call + # materializes and EP/FSDP-shards all preallocated Multi-LoRA slots. + for name, model in models.items(): + save_dir = os.path.join(OUTPUT_DIR, name) + os.makedirs(save_dir, exist_ok=True) + model.add_adapter_to_model( + name, + lora_config, + gradient_accumulation_steps=GRAD_ACCUM_STEPS, + save_dir=save_dir, + ) + model.set_template( + 'DeepseekV4Template', + max_length=MAX_LENGTH, + truncation_strategy=TRUNCATION_STRATEGY, + ) + model.set_processor('InputProcessor', padding_side='right') + model.set_loss('CrossEntropyLoss') + + for model in models.values(): + model.set_optimizer('AdamW', lr=LR, foreach=False) + + iterators = {name: iter(loader) for name, loader in dataloaders.items()} + completed_steps = {name: 0 for name in ADAPTER_NAMES} + active_names = set(ADAPTER_NAMES) + + while active_names and any(step < MAX_STEPS for step in completed_steps.values()): + for name in ADAPTER_NAMES: + if name not in active_names or completed_steps[name] >= MAX_STEPS: + continue + try: + batch = next(iterators[name]) + except StopIteration: + active_names.remove(name) + continue + + model = models[name] + model.forward_backward(inputs=batch, gradient_accumulation_steps=GRAD_ACCUM_STEPS) + model.clip_grad_and_step(max_grad_norm=1.0, gradient_accumulation_steps=GRAD_ACCUM_STEPS) + completed_steps[name] += 1 + + if completed_steps[name] % GRAD_ACCUM_STEPS == 0: + metric = model.calculate_metric(is_training=True) + logger.info('adapter=%s micro_step=%s metric=%s', name, completed_steps[name], metric.result) + + for name, model in models.items(): + checkpoint = model.save( + name=f'dsv4-0731-{name}-final', + save_optimizer=True, + consumed_train_samples=dataloaders[name].get_state()['consumed_train_samples'], + ) + logger.info('Saved adapter %s: %s', name, checkpoint.twinkle_path) + + # Run a real no-grad forward pass after saving. Compare the trained LoRA + # path with the base-model path so both can be verified on the same input. + # Saving first preserves the expensive training result if this check fails. + eval_loader = DataLoader(dataset=dataset, batch_size=BATCH_SIZE, drop_last=True, shuffle=False) + eval_batch = next(iter(eval_loader)) + for name, model in models.items(): + lora_result = model.forward_only(inputs=eval_batch, disable_lora=False).result + lora_loss = model.calculate_loss().result + base_result = model.forward_only(inputs=eval_batch, disable_lora=True).result + base_loss = model.calculate_loss().result + _assert_finite_output(lora_result, f'{name}.lora') + _assert_finite_output(base_result, f'{name}.base') + _assert_finite_output(lora_loss, f'{name}.lora_loss') + _assert_finite_output(base_loss, f'{name}.base_loss') + # Use WARNING so the inference result remains visible even when the + # deployment intentionally suppresses INFO logs to save local storage. + logger.warning( + 'Inference result: adapter=%s lora_loss=%.6f base_loss=%.6f loss_delta=%.6f ' + 'lora_keys=%s base_keys=%s', + name, + lora_loss, + base_loss, + lora_loss - base_loss, + list(lora_result) if isinstance(lora_result, dict) else type(lora_result).__name__, + list(base_result) if isinstance(base_result, dict) else type(base_result).__name__, + ) + + +if __name__ == '__main__': + train() diff --git a/cookbook/client/twinkle/run_dsv4_0731_npu_2node_2npu_client.sh b/cookbook/client/twinkle/run_dsv4_0731_npu_2node_2npu_client.sh new file mode 100755 index 000000000..5cce5dfce --- /dev/null +++ b/cookbook/client/twinkle/run_dsv4_0731_npu_2node_2npu_client.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Run exactly one client process. The default server address points at the +# two-node cluster head configured in run_dsv4_0731_npu_2node_2npu.sh. + +HEAD_IP="${HEAD_IP:-172.61.10.111}" +OUTPUT_DIR="${OUTPUT_DIR:-/shared/twinkle_output/dsv4-0731-a3-2node-4npu}" + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd -- "$SCRIPT_DIR/../../.." && pwd)" +cd "$PROJECT_DIR" + +export PYTHONPATH="$PROJECT_DIR/src${PYTHONPATH:+:$PYTHONPATH}" +export TWINKLE_SERVER_URL="${TWINKLE_SERVER_URL:-http://$HEAD_IP:8000}" +export TWINKLE_SERVER_TOKEN="${TWINKLE_SERVER_TOKEN:-EMPTY_TOKEN}" +export TWINKLE_MODEL_ID="${TWINKLE_MODEL_ID:-deepseek-v4-0731-local}" +export DSV4_MODEL_ID="${DSV4_MODEL_ID:-hf://deepseek-ai/DeepSeek-V4-Flash-0731}" +export DATASET_ID="${DATASET_ID:-/model/ljl/dataset/self-cognition.jsonl}" +export ADAPTER_NAMES="${ADAPTER_NAMES:-tenant_a,tenant_b}" +export OUTPUT_DIR + +# Four global FSDP ranks: one sample per rank by default. +export BATCH_SIZE="${BATCH_SIZE:-4}" +export GRAD_ACCUM_STEPS="${GRAD_ACCUM_STEPS:-4}" +export MAX_STEPS="${MAX_STEPS:-10}" +export MAX_LENGTH="${MAX_LENGTH:-2048}" +export LORA_R="${LORA_R:-8}" +export LORA_ALPHA="${LORA_ALPHA:-32}" +export LR="${LR:-1e-4}" + +mkdir -p "$OUTPUT_DIR" +exec python3 "$SCRIPT_DIR/dsv4_multi_lora_sft.py" diff --git a/cookbook/client/twinkle/run_dsv4_0731_npu_client.sh b/cookbook/client/twinkle/run_dsv4_0731_npu_client.sh new file mode 100755 index 000000000..60068b705 --- /dev/null +++ b/cookbook/client/twinkle/run_dsv4_0731_npu_client.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd -- "$SCRIPT_DIR/../../.." && pwd)" +cd "$PROJECT_DIR" + +export PYTHONPATH="$PROJECT_DIR/src${PYTHONPATH:+:$PYTHONPATH}" +export TWINKLE_SERVER_URL="${TWINKLE_SERVER_URL:-http://127.0.0.1:8000}" +export TWINKLE_SERVER_TOKEN="${TWINKLE_SERVER_TOKEN:-EMPTY_TOKEN}" +export TWINKLE_MODEL_ID="${TWINKLE_MODEL_ID:-deepseek-v4-0731-local}" +export DSV4_MODEL_ID="${DSV4_MODEL_ID:-/nas/disk1/random-deepseek-v4-4b}" +export DATASET_ID="${DATASET_ID:-/model/ljl/dataset/self-cognition.jsonl}" +export ADAPTER_NAMES="${ADAPTER_NAMES:-tenant_a,tenant_b}" +export BATCH_SIZE="${BATCH_SIZE:-2}" +export GRAD_ACCUM_STEPS="${GRAD_ACCUM_STEPS:-4}" +export MAX_STEPS="${MAX_STEPS:-10}" +export MAX_LENGTH="${MAX_LENGTH:-2048}" +export LORA_R="${LORA_R:-8}" +export LORA_ALPHA="${LORA_ALPHA:-32}" +export LR="${LR:-1e-4}" +export OUTPUT_DIR="${OUTPUT_DIR:-/tmp/twinkle_dsv4_0731_npu_multi_lora}" + +test -f "$DATASET_ID" + +exec python3 "$SCRIPT_DIR/dsv4_multi_lora_sft.py" diff --git a/cookbook/client/twinkle/run_dsv4_0731_npu_multinode_client.sh b/cookbook/client/twinkle/run_dsv4_0731_npu_multinode_client.sh new file mode 100755 index 000000000..e56542d48 --- /dev/null +++ b/cookbook/client/twinkle/run_dsv4_0731_npu_multinode_client.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail + +# The client is an HTTP orchestrator. Run exactly one client process; it does +# not join Ray and does not need NPU devices. +# +# Example: +# HEAD_IP=10.0.0.10 \ +# OUTPUT_DIR=/shared/twinkle_output/dsv4-0731-a3-32npu \ +# bash run_dsv4_0731_npu_multinode_client.sh + +: "${HEAD_IP:?Set HEAD_IP to the Twinkle/Ray head node IP}" +: "${OUTPUT_DIR:?Set OUTPUT_DIR to a shared absolute path mounted on both server nodes}" + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd -- "$SCRIPT_DIR/../../.." && pwd)" +cd "$PROJECT_DIR" + +export PYTHONPATH="$PROJECT_DIR/src${PYTHONPATH:+:$PYTHONPATH}" +export TWINKLE_SERVER_URL="${TWINKLE_SERVER_URL:-http://$HEAD_IP:8000}" +export TWINKLE_SERVER_TOKEN="${TWINKLE_SERVER_TOKEN:-EMPTY_TOKEN}" +export TWINKLE_MODEL_ID="${TWINKLE_MODEL_ID:-deepseek-v4-0731-local}" +export ADAPTER_NAMES="${ADAPTER_NAMES:-tenant_a}" +# The template/encoding side must resolve the same checkpoint as the server. +export DSV4_MODEL_ID="${DSV4_MODEL_ID:-hf://deepseek-ai/DeepSeek-V4-Flash-0731}" +: "${DATASET_ID:?Set DATASET_ID to a local GSM8K JSON/JSONL file or directory}" +if [[ "$DATASET_ID" == hf://* || "$DATASET_ID" == ms://* ]]; then + echo "DATASET_ID must be local for this recipe, got: $DATASET_ID" >&2 + exit 1 +fi +test -e "$DATASET_ID" +export DATASET_ID +export DATASET_SUBSET="${DATASET_SUBSET:-default}" +export DATASET_SPLIT="${DATASET_SPLIT:-train}" +export OUTPUT_DIR + +# forward_backward dispatches one slice to each of the 32 FSDP ranks. +export BATCH_SIZE="${BATCH_SIZE:-32}" +export GRAD_ACCUM_STEPS="${GRAD_ACCUM_STEPS:-4}" +export MAX_STEPS="${MAX_STEPS:-10}" +export MAX_LENGTH="${MAX_LENGTH:-8192}" +export TRUNCATION_STRATEGY="${TRUNCATION_STRATEGY:-delete}" +export LORA_R="${LORA_R:-8}" +export LORA_ALPHA="${LORA_ALPHA:-32}" + +mkdir -p "$OUTPUT_DIR" +exec python3 cookbook/client/twinkle/dsv4_multi_lora_sft.py diff --git a/cookbook/transformers/ep_fsdp2_multi_lora_deepseek_v4.py b/cookbook/transformers/ep_fsdp2_multi_lora_deepseek_v4.py index bc06ef5f2..799cd1111 100644 --- a/cookbook/transformers/ep_fsdp2_multi_lora_deepseek_v4.py +++ b/cookbook/transformers/ep_fsdp2_multi_lora_deepseek_v4.py @@ -1,8 +1,8 @@ # Copyright (c) ModelScope Contributors. All rights reserved. """EP + FSDP2 + Multi-LoRA SFT cookbook for DeepSeek-V4. -Run on 8 GPUs: - torchrun --nproc-per-node=8 cookbook/transformers/ep_fsdp2_multi_lora_deepseek_v4.py +Run on 2 GPUs: + torchrun --nproc-per-node=2 cookbook/transformers/ep_fsdp2_multi_lora_deepseek_v4.py """ import os from pathlib import Path @@ -19,10 +19,10 @@ logger = get_logger() -MODEL_ID = os.environ.get('DSV4_MODEL_ID', 'ms://deepseek-ai/DeepSeek-V4-Flash') -DATASET_ID = os.environ.get('DATASET_ID', 'ms://swift/self-cognition') +MODEL_ID = os.environ.get('DSV4_MODEL_ID', '/nas/disk1/random-deepseek-v4-4b') +DATASET_ID = os.environ.get('DATASET_ID', '/model/ljl/dataset/self-cognition.jsonl') TEMPLATE_ID = os.environ.get('TEMPLATE_ID', 'DeepseekV4Template') -BATCH_SIZE = int(os.environ.get('BATCH_SIZE', '4')) +BATCH_SIZE = int(os.environ.get('BATCH_SIZE', '1')) GRAD_ACCUM_STEPS = int(os.environ.get('GRAD_ACCUM_STEPS', '4')) LOG_INTERVAL = GRAD_ACCUM_STEPS LR = float(os.environ.get('LR', '1e-4')) @@ -37,11 +37,12 @@ RESUME_ONLY_MODEL = os.environ.get('RESUME_ONLY_MODEL', '0') == '1' IGNORE_DATA_SKIP = os.environ.get('IGNORE_DATA_SKIP', '0') == '1' ADAPTER_NAMES = [name.strip() for name in os.environ.get('ADAPTER_NAMES', 'tenant_a,tenant_b').split(',') if name] +WORLD_SIZE = int(os.environ.get('WORLD_SIZE', '2')) device_mesh = DeviceMesh.from_sizes( - fsdp_size=8, + fsdp_size=WORLD_SIZE, dp_size=1, - ep_size=8, + ep_size=WORLD_SIZE, device_type=Platform.get_platform().device_prefix(), ) twinkle.initialize(mode='local', global_device_mesh=device_mesh) @@ -86,14 +87,14 @@ def train(): dataset.encode(batched=True) dataloader = DataLoader(dataset=dataset, batch_size=BATCH_SIZE, device_mesh=device_mesh) - ep_lora_cfg = _build_lora_config(enable_ep=ENABLE_EP) # LoraConfig for target params + ep_lora_cfg = _build_lora_config(enable_ep=ENABLE_EP) # LoraConfig for target params lora_cfg = _build_lora_config(enable_ep=False) # LoraConfig for PEFT adapter model = MultiLoraTransformersModel( model_id=MODEL_ID, config=config, device_mesh=device_mesh, strategy='native_fsdp', - memory_efficient_init=False, + memory_efficient_init=True, max_loras=MAX_LORAS, max_r=MAX_R, fsdp_config={ @@ -109,6 +110,10 @@ def train(): for adapter_name in ADAPTER_NAMES: model.add_adapter_to_model(adapter_name, ep_lora_cfg, gradient_accumulation_steps=GRAD_ACCUM_STEPS) + # Materialize rank-local EP/FSDP shards before loading adapter checkpoints + # or creating optimizers. + model._lazy_wrap_model() + if RESUME_FROM_CHECKPOINT: checkpoint_path = Path(RESUME_FROM_CHECKPOINT).expanduser().resolve() progress = None @@ -128,9 +133,6 @@ def train(): f'Total steps: {len(dataloader)}, batch_size={BATCH_SIZE}, grad_accum={GRAD_ACCUM_STEPS}, ' f'enable_ep={ENABLE_EP}, adapters={ADAPTER_NAMES}, output_dir={OUTPUT_DIR}') - # After LoRA init, before forward (LoRA active): perform EP + FSDP broadcast & sharding. - model._lazy_wrap_model() - # Must call set_optimizer() after EP + FSDP sharding, otherwise optimizer may # capture stale parameter references and fail to update the actual LoRA weights. for adapter_name in ADAPTER_NAMES: diff --git a/read_me.md b/read_me.md new file mode 100644 index 000000000..efe55d02e --- /dev/null +++ b/read_me.md @@ -0,0 +1,56 @@ +已写好。既然模型目录存在 `tokenizer.json`,会直接从 `/nas/disk1/random-deepseek-v4-4b` 加载,不需要单独配置 `tokenizer_id`。 + +生成的文件: + +- [server_config_dsv4_0731.yaml](/Users/linjiajia/project/twinkle/cookbook/client/server/transformer/server_config_dsv4_0731.yaml) +- [server_dsv4_0731.py](/Users/linjiajia/project/twinkle/cookbook/client/server/transformer/server_dsv4_0731.py) +- [dsv4_multi_lora_sft.py](/Users/linjiajia/project/twinkle/cookbook/client/twinkle/dsv4_multi_lora_sft.py) + +配置为: + +- 本地模型:`/nas/disk1/random-deepseek-v4-4b` +- 本地数据集:`/model/ljl/dataset/self-cognition.jsonl` +- 2 张 GPU +- Native FSDP2 + EP=2 +- `memory_efficient_init: true` +- 两个 LoRA:`tenant_a`、`tenant_b` +- `DeepseekV4Template` +- 两张 GPU 全部用于训练,因此没有启动 vLLM sampler + +服务端启动: + +```bash +cd /model/ljl/project/remote-git/dsv4_0731/twinkle +export PYTHONPATH="$PWD/src:$PYTHONPATH" + +CUDA_VISIBLE_DEVICES=0,1 ray start \ + --head \ + --num-gpus=2 \ + --disable-usage-stats \ + --include-dashboard=false + +CUDA_VISIBLE_DEVICES=0,1 python3 \ + cookbook/client/server/transformer/server_dsv4_0731.py +``` + +如果 Ray 集群已经启动,跳过 `ray start`。 + +另一个终端启动客户端: + +```bash +cd /model/ljl/project/remote-git/dsv4_0731/twinkle +export PYTHONPATH="$PWD/src:$PYTHONPATH" + +TWINKLE_SERVER_URL=http://127.0.0.1:8000 \ +TWINKLE_SERVER_TOKEN=EMPTY_TOKEN \ +python3 cookbook/client/twinkle/dsv4_multi_lora_sft.py +``` + +`DeepseekV4Template` 会优先加载模型目录里的: + +```text +tokenizer.json +encoding/encoding_dsv4.py +``` + +若不存在 `encoding/encoding_dsv4.py`,才回退到 Twinkle 内置 encoding。相关检查共 `31 passed, 1 skipped`;当前改动尚未 commit/push。 diff --git a/src/twinkle/infra/_ray/ray_helper.py b/src/twinkle/infra/_ray/ray_helper.py index 6eb991383..3593c1929 100644 --- a/src/twinkle/infra/_ray/ray_helper.py +++ b/src/twinkle/infra/_ray/ray_helper.py @@ -7,6 +7,36 @@ T = TypeVar('T') +# Ray injects these process-local values when it launches each worker. They +# must never be copied from a parent actor into a child runtime_env: a child +# scheduled on another node would otherwise monitor the parent's raylet PID +# and immediately exit with "the local raylet failed". +_RAY_INTERNAL_ENV_VARS = frozenset({ + 'RAY_JOB_ID', + 'RAY_RAYLET_PID', + 'RAY_OVERRIDE_NODE_ID_FOR_TESTING', +}) + + +def _copy_worker_env() -> Dict[str, str]: + """Copy inherited environment without Ray's per-process internal flags.""" + return {key: value for key, value in os.environ.items() if key not in _RAY_INTERNAL_ENV_VARS} + + +def _get_node_local_topology(placements: List[Dict[str, Any]]) -> List[Tuple[int, List[int]]]: + """Return each worker's node-local index and distributed ranks on that node.""" + node_to_ranks: Dict[int, List[int]] = {} + for rank, placement in enumerate(placements): + node_rank = int(placement.get('node_rank', 0)) + node_to_ranks.setdefault(node_rank, []).append(rank) + + topology = [] + for rank, placement in enumerate(placements): + node_rank = int(placement.get('node_rank', 0)) + node_ranks = node_to_ranks[node_rank] + topology.append((node_ranks.index(rank), node_ranks)) + return topology + class RayHelper: @@ -304,6 +334,7 @@ def create_workers(worker_cls: Type[T], ip, port = RayHelper.get_master_id_port(placement_groups[0]['placement_group']) device_type_upper = (device_config.device_type or '').upper() + node_local_topology = _get_node_local_topology(placement_groups) if device_type_upper != 'CPU': world_size = len(ranks) device_type = Platform.get_platform(device_type_upper).__name__ @@ -311,7 +342,7 @@ def create_workers(worker_cls: Type[T], deploy_pg: Dict cluster_name = group worker_name = key + '-' + str(pg_idx) - env_vars = os.environ.copy() + env_vars = _copy_worker_env() env_vars.update({ 'WORLD_SIZE': str(world_size), @@ -319,6 +350,17 @@ def create_workers(worker_cls: Type[T], str(pg_idx), 'LOCAL_RANK': str(0), + # Each Ray actor sees only its own accelerator, so LOCAL_RANK + # must remain 0 as the device index. Keep the node-local + # distributed topology in separate Twinkle variables. + 'TWINKLE_NODE_LOCAL_RANK': + str(node_local_topology[pg_idx][0]), + 'TWINKLE_NODE_LOCAL_WORLD_SIZE': + str(len(node_local_topology[pg_idx][1])), + 'TWINKLE_NODE_RANKS': + ','.join(str(rank) for rank in node_local_topology[pg_idx][1]), + 'NODE_RANK': + str(deploy_pg.get('node_rank', 0)), 'CLUSTER_NAME': cluster_name, 'WORKER_NAME': @@ -368,11 +410,15 @@ def create_workers(worker_cls: Type[T], deploy_pg: Dict cluster_name = group worker_name = key + '-' + str(rank) - env_vars = os.environ.copy() + env_vars = _copy_worker_env() env_vars.update({ 'WORLD_SIZE': str(world_size), 'RANK': str(rank), 'LOCAL_RANK': str(0), + 'TWINKLE_NODE_LOCAL_RANK': str(node_local_topology[rank][0]), + 'TWINKLE_NODE_LOCAL_WORLD_SIZE': str(len(node_local_topology[rank][1])), + 'TWINKLE_NODE_RANKS': ','.join(str(item) for item in node_local_topology[rank][1]), + 'NODE_RANK': str(deploy_pg.get('node_rank', 0)), 'CLUSTER_NAME': cluster_name, 'WORKER_NAME': worker_name, 'TWINKLE_MODE': 'ray', diff --git a/src/twinkle/infra/_ray/resource_manager.py b/src/twinkle/infra/_ray/resource_manager.py index e9cba0f4d..c51e0a512 100644 --- a/src/twinkle/infra/_ray/resource_manager.py +++ b/src/twinkle/infra/_ray/resource_manager.py @@ -217,6 +217,7 @@ def get_visible_devices(): local_device_groups.append( dict( gpu_rank=gpu_ranks_local, + node_rank=node_rank, placement_group=self.node2pg[node_rank], ray_address=ray_address)) else: @@ -224,7 +225,11 @@ def get_visible_devices(): node_rank = alloc_rank // nproc_per_node gpu_rank = self.visible_devices[node_rank - self.min_node_idx][alloc_rank % nproc_per_node] local_device_groups.append( - dict(gpu_rank=[gpu_rank], placement_group=self.node2pg[node_rank], ray_address=ray_address)) + dict( + gpu_rank=[gpu_rank], + node_rank=node_rank, + placement_group=self.node2pg[node_rank], + ray_address=ray_address)) self.device_groups[group.name] = local_device_groups @@ -237,9 +242,11 @@ def get_visible_devices(): ranks = group.ranks local_device_groups = [] for _ in range(ranks): + node_rank = self.cpu_node_map[global_cpu_proc_idx][0] local_device_groups.append( dict( - placement_group=self.cpu_placement_groups[self.cpu_node_map[global_cpu_proc_idx][0]], + node_rank=node_rank, + placement_group=self.cpu_placement_groups[node_rank], ray_address=ray_address)) global_cpu_proc_idx += 1 self.device_groups[group.name] = local_device_groups diff --git a/src/twinkle/kernel/ops/ep/__init__.py b/src/twinkle/kernel/ops/ep/__init__.py index c011d3ced..556d1cff7 100644 --- a/src/twinkle/kernel/ops/ep/__init__.py +++ b/src/twinkle/kernel/ops/ep/__init__.py @@ -11,6 +11,7 @@ """ from __future__ import annotations +import os import torch from abc import ABC, abstractmethod from torch import nn @@ -51,6 +52,11 @@ def forward( _IMPLS: list[EpExpertsGmm] | None = None _PATH_LOGGED = False _WARN_LOGGED = False +_FORCE_LOOP_LOGGED = False + + +def _env_flag(name: str) -> bool: + return os.environ.get(name, '').strip().lower() in {'1', 'true', 'yes', 'on'} def _get_impls() -> list[EpExpertsGmm]: @@ -59,6 +65,14 @@ def _get_impls() -> list[EpExpertsGmm]: Each backend module is imported defensively: platforms lacking its dependencies (e.g. no torch_npu) simply skip that backend. """ + global _FORCE_LOOP_LOGGED + if _env_flag('TWINKLE_EP_FORCE_LOOP'): + from .loop import LoopEpExpertsGmm + if not _FORCE_LOOP_LOGGED: + logger.warning('EP experts compute: TWINKLE_EP_FORCE_LOOP=1; forcing the per-expert F.linear loop.') + _FORCE_LOOP_LOGGED = True + return [LoopEpExpertsGmm()] + global _IMPLS if _IMPLS is None: _IMPLS = [] diff --git a/src/twinkle/kernel/ops/moe/npu.py b/src/twinkle/kernel/ops/moe/npu.py index 1f847669b..8fcbfa694 100644 --- a/src/twinkle/kernel/ops/moe/npu.py +++ b/src/twinkle/kernel/ops/moe/npu.py @@ -62,18 +62,32 @@ def npu_grouped_mm(input: torch.Tensor, weight_ekn: torch.Tensor, offs: torch.Te def _normalize_packed_expert_weights(module, input_dtype, hidden_dim): gate_up_proj = module.gate_up_proj.to(input_dtype) down_proj = module.down_proj.to(input_dtype) - if gate_up_proj.shape[1] == hidden_dim: - gate_up_weight = gate_up_proj - elif gate_up_proj.shape[2] == hidden_dim: + if gate_up_proj.ndim != 3 or down_proj.ndim != 3: + raise RuntimeError( + 'Packed expert weights must be 3D: ' + f'gate_up_proj={tuple(gate_up_proj.shape)}, down_proj={tuple(down_proj.shape)}.') + + # torch.nn.functional.linear stores weights as [out_features, in_features], + # while torch_npu.npu_grouped_matmul consumes [in_features, out_features]. + # Infer one layout from *both* tensors instead of inspecting gate_up_proj in + # isolation. DeepSeek-V4 has hidden_size == 2 * intermediate_size, so its + # gate_up_proj is square and either of its last two dimensions can look like + # the input dimension. down_proj remains non-square and disambiguates it. + linear_layout = gate_up_proj.shape[2] == hidden_dim and down_proj.shape[1] == hidden_dim + grouped_mm_layout = gate_up_proj.shape[1] == hidden_dim and down_proj.shape[2] == hidden_dim + if linear_layout == grouped_mm_layout: + raise RuntimeError( + 'Unable to determine packed expert weight layout: ' + f'gate_up_proj={tuple(gate_up_proj.shape)}, down_proj={tuple(down_proj.shape)}, ' + f'hidden_dim={hidden_dim}. Expected either Transformers/F.linear ' + '[E, out, in] tensors or grouped-matmul [E, in, out] tensors.') + + if linear_layout: gate_up_weight = gate_up_proj.transpose(1, 2) - else: - raise RuntimeError(f'Unsupported gate_up_proj shape: {tuple(gate_up_proj.shape)}.') - if down_proj.shape[2] == hidden_dim: - down_weight = down_proj - elif down_proj.shape[1] == hidden_dim: down_weight = down_proj.transpose(1, 2) else: - raise RuntimeError(f'Unsupported down_proj shape: {tuple(down_proj.shape)}.') + gate_up_weight = gate_up_proj + down_weight = down_proj return gate_up_weight, down_weight diff --git a/src/twinkle/model/base.py b/src/twinkle/model/base.py index 8ea00d696..4aaf3fc8b 100644 --- a/src/twinkle/model/base.py +++ b/src/twinkle/model/base.py @@ -30,6 +30,10 @@ def forward(self, *, inputs: Dict[str, Any], **kwargs) -> ModelOutput: def forward_only(self, *, inputs: Dict[str, Any], **kwargs) -> ModelOutput: ... + def generate(self, *, inputs: Dict[str, Any], **kwargs) -> Any: + """Generate completions with the training model when the backend supports it.""" + raise NotImplementedError(f'{type(self).__name__} does not implement generate()') + @abstractmethod def calculate_loss(self, **kwargs) -> float: ... diff --git a/src/twinkle/model/megatron/multi_lora_megatron.py b/src/twinkle/model/megatron/multi_lora_megatron.py index 3bd197158..3b3224727 100644 --- a/src/twinkle/model/megatron/multi_lora_megatron.py +++ b/src/twinkle/model/megatron/multi_lora_megatron.py @@ -445,6 +445,5 @@ def add_metric(self, metric_cls: Union[Metric, str], is_training: Optional[bool] @remote_function() def remove_adapter(self, adapter_name: str): - if adapter_name in self.optimizer_group: - self.optimizer_group.pop(adapter_name) self.multi_adapter.release_lora(adapter_name) + self.optimizer_group.pop(adapter_name, None) diff --git a/src/twinkle/model/multi_lora.py b/src/twinkle/model/multi_lora.py index 43cd6108d..e58c8c7ce 100644 --- a/src/twinkle/model/multi_lora.py +++ b/src/twinkle/model/multi_lora.py @@ -30,14 +30,18 @@ class LoraTenant: class MultiLora: - def __init__(self, max_loras=5, max_r=32, max_length: int = 8192): + def __init__(self, max_loras=5, max_r=32, max_length: int = 8192, defer_initial_weights: bool = False): self.max_loras = max_loras self.max_r = max_r self.loras: List[LoraTenant] = [] self.module: PeftModel self._active_adapters = [] self.max_length = max_length - self.target_parameter_manager = TargetParameterLoraManager(max_loras=max_loras, max_r=max_r) + self.target_parameter_manager = TargetParameterLoraManager( + max_loras=max_loras, + max_r=max_r, + defer_initial_weights=defer_initial_weights, + ) def _get_available_lora(self) -> Optional[LoraTenant]: for _lora in self.loras: @@ -48,6 +52,12 @@ def _get_available_lora(self) -> Optional[LoraTenant]: def _read_param_tensor(self, parameter): return torch_util.to_local_tensor(parameter) + @staticmethod + def _read_local_param_tensor(parameter): + if hasattr(parameter, 'to_local'): + return parameter.to_local() + return parameter + @staticmethod def _is_distributed_param(parameter): return hasattr(parameter, 'device_mesh') and hasattr(parameter, 'placements') @@ -56,7 +66,14 @@ def _is_distributed_param(parameter): def _is_target_parameter_lora_name(name: str) -> bool: return '._twinkle_lora_' in name + @torch.no_grad() def _write_param_tensor(self, parameter, value): + """Copy a value into a regular parameter or its local DTensor shard. + + ``DTensor.to_local()`` can return a view produced by a custom autograd + Function. In-place writes to that view are forbidden while grad mode is + enabled, even for lifecycle operations such as adapter reset/load. + """ if value is None: return value = value.detach().to(dtype=parameter.dtype) @@ -115,6 +132,10 @@ def _copy_rank_tensor(name: str, target, value): def _count_available_loras(self): return len([_lora for _lora in self.loras if _lora.tenant_adapter_name is None]) + def _lora_slot_assignments(self): + """Return a compact snapshot used to diagnose per-rank slot state.""" + return [(lora.adapter_name, lora.tenant_adapter_name) for lora in self.loras] + def reset_adapter_status(self): """Force lora_0 require_grad, disable others""" if isinstance(self.module, list): @@ -152,9 +173,15 @@ def patch_target_parameters(self, module, target_parameters): self.target_parameter_manager.patch(module, target_parameters) @contextmanager - def adapter(self, tenant_adapter_name: str, disable_lora: bool = False): + def adapter(self, + tenant_adapter_name: str, + disable_lora: bool = False, + cache_target_parameters: bool = True): self.activate_adapter(tenant_adapter_name) - with self.target_parameter_manager.adapter(tenant_adapter_name, disable_lora=disable_lora): + with self.target_parameter_manager.adapter( + tenant_adapter_name, + disable_lora=disable_lora, + cache=cache_target_parameters): if disable_lora: # Temporarily disable all adapters while keeping optimizer_group active with self._disable_lora_context(tenant_adapter_name): @@ -222,19 +249,39 @@ def acquire_lora(self, tenant_adapter_name: str, config: LoraConfig) -> str: slot_name=_available_lora.adapter_name, config=config, ) - logger.info(f'Lora count: {len(self.loras)}, available lora: {self._count_available_loras()}') + logger.info( + 'LoRA acquired: tenant=%s, slot=%s, available_lora=%s', + tenant_adapter_name, + _available_lora.adapter_name, + self._count_available_loras(), + ) return _available_lora.adapter_name def release_lora(self, tenant_adapter_name: str) -> Optional[str]: try: _lora = self.find_lora_by_tenant(tenant_adapter_name) - _lora.tenant_config = None - _lora.tenant_adapter_name = None - self._load_initial_weights(_lora.adapter_name) - self.target_parameter_manager.release(tenant_adapter_name) - logger.info(f'Lora count: {len(self.loras)}, available lora: {self._count_available_loras()}') except ValueError: + logger.warning( + 'LoRA release skipped: tenant=%s was not found, assignments=%s', + tenant_adapter_name, + self._lora_slot_assignments(), + ranks='all', + ) return + # Restore every backing slot before publishing it as available. If a + # DTensor reset fails, retain the tenant mapping so cleanup can retry + # safely instead of exposing a partially reset LoRA slot. + self._load_initial_weights(_lora.adapter_name) + self.target_parameter_manager.release(tenant_adapter_name) + _lora.tenant_config = None + _lora.tenant_adapter_name = None + logger.info( + 'LoRA released: tenant=%s, slot=%s, available_lora=%s', + tenant_adapter_name, + _lora.adapter_name, + self._count_available_loras(), + ) + return _lora.adapter_name def has_lora(self, adapter_name: str) -> bool: return len([_lora for _lora in self.loras if _lora.tenant_adapter_name == adapter_name]) > 0 @@ -565,13 +612,19 @@ def _store_weights(_module): if self._is_target_parameter_lora_name(name): continue if pattern.search(name): - lora_tenant.lora_A_weights[name] = self._read_param_tensor(parameter).clone().to('cpu') + local_parameter = self._read_local_param_tensor(parameter) + if local_parameter.is_meta: + raise RuntimeError( + f'LoRA parameter {name} is still on meta; materialize the model before saving ' + 'its initial weights.') + lora_tenant.lora_A_weights[name] = local_parameter.detach().cpu().clone() if isinstance(self.module, list): for _module in self.module: _store_weights(_module) else: _store_weights(self.module) + self.target_parameter_manager.save_initial_weights() def load_lora_converter(self, name, parameter, **kwargs): @@ -738,12 +791,12 @@ def _load_initial_weights(_module): if self._is_target_parameter_lora_name(name): continue if pattern_A.search(name): - local_param = self._read_param_tensor(parameter) + local_param = self._read_local_param_tensor(parameter) if local_param is not None: value = _lora.lora_A_weights[name].to(dtype=parameter.dtype, device=local_param.device) self._write_param_tensor(parameter, value) if pattern_B.search(name): - local_param = self._read_param_tensor(parameter) + local_param = self._read_local_param_tensor(parameter) if local_param is not None: self._write_param_tensor(parameter, torch.zeros_like(local_param)) diff --git a/src/twinkle/model/multi_lora_target_parameters.py b/src/twinkle/model/multi_lora_target_parameters.py index 0eb33eea7..451a0e399 100644 --- a/src/twinkle/model/multi_lora_target_parameters.py +++ b/src/twinkle/model/multi_lora_target_parameters.py @@ -37,7 +37,13 @@ def key(self) -> str: class TargetParameterLoraWrapper(nn.Module): - def __init__(self, record: TargetParameterRecord, max_loras: int, max_r: int): + def __init__( + self, + record: TargetParameterRecord, + max_loras: int, + max_r: int, + defer_initial_weights: bool = False, + ): super().__init__() self.record = record # Unsharded original target parameter (pre-sharding snapshot) @@ -53,6 +59,7 @@ def __init__(self, record: TargetParameterRecord, max_loras: int, max_r: int): self.max_loras = max_loras self.max_r = max_r + self.defer_initial_weights = defer_initial_weights self.active_adapter: str | None = None self.disable_adapters = False self.lora_A = nn.ParameterDict() @@ -90,10 +97,7 @@ def _init_slots(self) -> None: raise ValueError( f'target parameter {self.record.key} has {parameter.ndim} dimensions; only 2D and 3D are supported') - # Note: reset_slot requires the tensor to be created on a physical device, not on a meta device. device = parameter.device - if device.type == 'meta': - device = 'cpu' for index in range(self.max_loras): slot_name = f'lora_{index}' self.lora_A[slot_name] = nn.Parameter( @@ -114,24 +118,68 @@ def _init_slots(self) -> None: )) self.r[slot_name] = self.max_r self.scaling[slot_name] = 1.0 - self.reset_slot(slot_name) + if device.type != 'meta': + nn.init.kaiming_uniform_(self.lora_A[slot_name], a=math.sqrt(5)) + nn.init.zeros_(self.lora_B[slot_name]) + if not self.defer_initial_weights: + self._initial_lora_A[slot_name] = self.lora_A[slot_name].detach().cpu().clone() + + @staticmethod + def _read_parameter(parameter: nn.Parameter) -> torch.Tensor: + if hasattr(parameter, 'to_local'): + return parameter.to_local() + if hasattr(parameter, 'full_tensor'): + return parameter.full_tensor() + return parameter + + @staticmethod + @torch.no_grad() + def _write_parameter(parameter: nn.Parameter, value: torch.Tensor) -> None: + if hasattr(parameter, 'to_local') and hasattr(parameter, 'device_mesh'): + local_parameter = parameter.to_local() + if tuple(value.shape) == tuple(local_parameter.shape): + local_parameter.copy_(value.to(device=local_parameter.device, dtype=local_parameter.dtype)) + return + if tuple(value.shape) != tuple(parameter.shape): + raise ValueError(f'Cannot restore target-parameter LoRA with shape {tuple(value.shape)} to distributed ' + f'parameter with global shape {tuple(parameter.shape)} and local shape ' + f'{tuple(local_parameter.shape)}') + from torch.distributed.tensor import distribute_tensor + distributed = distribute_tensor( + value.to(device=parameter.device, dtype=parameter.dtype), + parameter.device_mesh, + parameter.placements, + ) + local_parameter.copy_(distributed.to_local()) + return + parameter.copy_(value.to(device=parameter.device, dtype=parameter.dtype)) + + def save_initial_weights(self) -> None: + for slot_name, parameter in self.lora_A.items(): + tensor = self._read_parameter(parameter) + if tensor.is_meta: + raise RuntimeError(f'Target-parameter LoRA slot {self.record.key}.{slot_name} is still on meta; ' + 'materialize the model before saving its initial weights.') + self._initial_lora_A[slot_name] = tensor.detach().cpu().clone() def reset_slot(self, slot_name: str) -> None: - if slot_name not in self._initial_lora_A: - nn.init.kaiming_uniform_(self.lora_A[slot_name], a=math.sqrt(5)) - self._initial_lora_A[slot_name] = self.lora_A[slot_name].detach().clone().cpu() - else: - initial = self._initial_lora_A[slot_name] - if hasattr(self.record.module, '_ep_local_start') and hasattr(self.record.module, '_ep_local_end'): - start = self.record.module._ep_local_start - end = self.record.module._ep_local_end - initial = initial[start:end] - initial = initial.to( - device=self.lora_A[slot_name].device, - dtype=self.lora_A[slot_name].dtype, - ) - self.lora_A[slot_name].data.copy_(initial) - nn.init.zeros_(self.lora_B[slot_name]) + if self.lora_A[slot_name].is_meta or self.lora_B[slot_name].is_meta: + raise RuntimeError(f'Target-parameter LoRA slot {self.record.key}.{slot_name} is still on meta; ' + 'materialize the model before resetting it.') + with torch.no_grad(): + if slot_name not in self._initial_lora_A: + nn.init.kaiming_uniform_(self.lora_A[slot_name], a=math.sqrt(5)) + self._initial_lora_A[slot_name] = self.lora_A[slot_name].detach().clone().cpu() + else: + initial = self._initial_lora_A[slot_name] + if (initial.shape[0] != self.lora_A[slot_name].shape[0] + and hasattr(self.record.module, '_ep_local_start') + and hasattr(self.record.module, '_ep_local_end')): + start = self.record.module._ep_local_start + end = self.record.module._ep_local_end + initial = initial[start:end] + self._write_parameter(self.lora_A[slot_name], initial) + self._read_parameter(self.lora_B[slot_name]).zero_() def configure_slot(self, slot_name: str, config: LoraConfig) -> None: if slot_name not in self.lora_A: @@ -179,7 +227,7 @@ def get_delta_weight(self, slot_name: str) -> torch.Tensor: return torch.einsum('e o r, e r i -> e i o', weight_B, weight_A) * self.scaling[slot_name] @contextmanager - def activate(self, slot_name: str | None, disable_lora: bool = False): + def activate(self, slot_name: str | None, disable_lora: bool = False, cache: bool = True): if disable_lora or slot_name is None or slot_name not in self.lora_A: yield return @@ -198,7 +246,10 @@ def activate(self, slot_name: str | None, disable_lora: bool = False): ) module.parametrizations[param_name].original.requires_grad_(requires_grad_before) try: - with nn.utils.parametrize.cached(): + if cache: + with nn.utils.parametrize.cached(): + yield + else: yield finally: if not already_parametrized: @@ -280,14 +331,19 @@ def set_state_dict(self, slot_name: str, state_dict: dict[str, torch.Tensor]) -> class TargetParameterLoraManager: - def __init__(self, max_loras: int, max_r: int): + def __init__(self, max_loras: int, max_r: int, defer_initial_weights: bool = False): self.max_loras = max_loras self.max_r = max_r + self.defer_initial_weights = defer_initial_weights self.wrappers: list[TargetParameterLoraWrapper] = [] self.tenant_to_slot: dict[str, str] = {} self.tenant_configs: dict[str, LoraConfig] = {} self._target_parameters: tuple[str, ...] | None = None + @property + def patched_target_parameters(self) -> tuple[str, ...] | None: + return self._target_parameters + def patch(self, model: nn.Module, target_parameters: Iterable[str]) -> None: target_parameters = tuple(target_parameters) if not target_parameters: @@ -312,7 +368,12 @@ def patch(self, model: nn.Module, target_parameters: Iterable[str]) -> None: raise ValueError(f'target_parameters={target_parameters} were set but no parameter was matched') for record in records: - wrapper = TargetParameterLoraWrapper(record, max_loras=self.max_loras, max_r=self.max_r) + wrapper = TargetParameterLoraWrapper( + record, + max_loras=self.max_loras, + max_r=self.max_r, + defer_initial_weights=self.defer_initial_weights, + ) record.module.add_module(f'_twinkle_lora_{record.parameter_name}', wrapper) self.wrappers.append(wrapper) self._assign_peft_key_prefixes() @@ -341,19 +402,24 @@ def acquire(self, tenant_adapter_name: str, slot_name: str, config: LoraConfig) wrapper.configure_slot(slot_name, config) def release(self, tenant_adapter_name: str) -> None: - slot_name = self.tenant_to_slot.pop(tenant_adapter_name, None) - self.tenant_configs.pop(tenant_adapter_name, None) + slot_name = self.tenant_to_slot.get(tenant_adapter_name) if slot_name is None: return for wrapper in self.wrappers: wrapper.reset_slot(slot_name) + self.tenant_to_slot.pop(tenant_adapter_name, None) + self.tenant_configs.pop(tenant_adapter_name, None) + + def save_initial_weights(self) -> None: + for wrapper in self.wrappers: + wrapper.save_initial_weights() @contextmanager - def adapter(self, tenant_adapter_name: str, disable_lora: bool = False): + def adapter(self, tenant_adapter_name: str, disable_lora: bool = False, cache: bool = True): slot_name = self.tenant_to_slot.get(tenant_adapter_name) with ExitStack() as stack: for wrapper in self.wrappers: - stack.enter_context(wrapper.activate(slot_name, disable_lora=disable_lora)) + stack.enter_context(wrapper.activate(slot_name, disable_lora=disable_lora, cache=cache)) yield def parameters_for_tenant(self, tenant_adapter_name: str) -> list[nn.Parameter]: diff --git a/src/twinkle/model/transformers/moe/expert_parallel.py b/src/twinkle/model/transformers/moe/expert_parallel.py index 218e7b337..0bc3a391d 100644 --- a/src/twinkle/model/transformers/moe/expert_parallel.py +++ b/src/twinkle/model/transformers/moe/expert_parallel.py @@ -2,16 +2,24 @@ from __future__ import annotations import inspect +import os import torch import torch.distributed as dist from dataclasses import dataclass from torch import nn from typing import Any, Dict, Iterable, List, Optional, Tuple +from twinkle import get_logger from twinkle.kernel.ops import ep_forward from twinkle.model.transformers.moe.ep_utils import preprocess, token_pre_all2all, tokens_post_all2all from twinkle.utils import DeviceMesh +logger = get_logger() + + +def _ep_diagnostics_enabled() -> bool: + return os.environ.get('TWINKLE_EP_DIAGNOSTICS', '').strip().lower() in {'1', 'true', 'yes', 'on'} + @dataclass class ExpertParallelConfig: @@ -262,6 +270,32 @@ def forward(hidden_states: torch.Tensor, *args, **kwargs): num_global_sum_tokens_per_local_expert, ) = preprocess(expert_mask, num_experts, ep_group) + log_diagnostics = _ep_diagnostics_enabled() and not getattr(block, '_ep_diagnostics_logged', False) + if log_diagnostics: + expected_assignments = hidden_states_2d.shape[0] * top_k + actual_assignments = int(input_splits.sum().item()) + if actual_assignments != expected_assignments: + raise RuntimeError( + f'EP routing assignment mismatch for {block_name}: ' + f'input_splits={actual_assignments}, expected={expected_assignments}.') + selected_preview = selected_experts[:min(8, selected_experts.shape[0])].detach().cpu().tolist() + weights_preview = routing_weights[:min(8, routing_weights.shape[0])].float().detach().cpu().tolist() + logger.warning( + '[EP_DIAG] rank=%s ep_rank=%s block=%s expert_range=[%s,%s) tokens=%s top_k=%s ' + 'input_splits=%s output_splits=%s selected_experts=%s routing_weights=%s', + dist.get_rank(), + block._ep_rank, + block_name, + block._ep_local_start, + block._ep_local_end, + hidden_states_2d.shape[0], + top_k, + input_splits.tolist(), + output_splits.tolist(), + selected_preview, + weights_preview, + ) + # 2. token_pre_all2all: permute → all_to_all → sort_chunks ( global_permuted_hidden_states, @@ -317,6 +351,21 @@ def forward(hidden_states: torch.Tensor, *args, **kwargs): if len(orig_shape) == 3: final_hidden = final_hidden.view(batch_size, seq_len, hidden_dim) + if log_diagnostics: + flat_output = final_hidden.detach().reshape(-1) + preview = flat_output[:min(8, flat_output.numel())].float().cpu().tolist() + finite = bool(torch.isfinite(final_hidden).all().item()) + logger.warning( + '[EP_DIAG] rank=%s ep_rank=%s block=%s output_shape=%s output_finite=%s output_preview=%s', + dist.get_rank(), + block._ep_rank, + block_name, + tuple(final_hidden.shape), + finite, + preview, + ) + block._ep_diagnostics_logged = True + if cfg.keep_router_logits and returns_router_logits: return final_hidden, router_logits return final_hidden @@ -431,8 +480,10 @@ def _is_moe_experts(experts: Any) -> bool: def _shard_tensor_experts(experts: nn.Module, start: int, end: int) -> None: - experts.gate_up_proj = nn.Parameter(experts.gate_up_proj.data[start:end].clone()) - experts.down_proj = nn.Parameter(experts.down_proj.data[start:end].clone()) + experts.gate_up_proj = nn.Parameter( + experts.gate_up_proj.data[start:end].clone(), requires_grad=experts.gate_up_proj.requires_grad) + experts.down_proj = nn.Parameter( + experts.down_proj.data[start:end].clone(), requires_grad=experts.down_proj.requires_grad) if hasattr(experts, 'num_experts'): experts.num_experts = end - start @@ -441,9 +492,11 @@ def _shard_tensor_experts(experts: nn.Module, start: int, end: int) -> None: if not isinstance(target_param_wrapper, TargetParameterLoraWrapper): continue for tenant_name, tenant_tensor in target_param_wrapper.lora_A.items(): - target_param_wrapper.lora_A[tenant_name] = nn.Parameter(tenant_tensor.data[start:end].clone()) + target_param_wrapper.lora_A[tenant_name] = nn.Parameter( + tenant_tensor.data[start:end].clone(), requires_grad=tenant_tensor.requires_grad) for tenant_name, tenant_tensor in target_param_wrapper.lora_B.items(): - target_param_wrapper.lora_B[tenant_name] = nn.Parameter(tenant_tensor.data[start:end].clone()) + target_param_wrapper.lora_B[tenant_name] = nn.Parameter( + tenant_tensor.data[start:end].clone(), requires_grad=tenant_tensor.requires_grad) def _run_local_experts( diff --git a/src/twinkle/model/transformers/multi_lora_transformers.py b/src/twinkle/model/transformers/multi_lora_transformers.py index ea53930de..54b828ef9 100644 --- a/src/twinkle/model/transformers/multi_lora_transformers.py +++ b/src/twinkle/model/transformers/multi_lora_transformers.py @@ -81,10 +81,24 @@ def __init__( self.sp_strategy = None # Initialize expert parallel attributes (required by set_optimizer in TransformersModel) self.optimizer_group: Dict[str, OptimizerGroup] = {} - self.multi_adapter = MultiLora(max_loras=max_loras, max_r=max_r, max_length=max_length) + self.multi_adapter = MultiLora( + max_loras=max_loras, + max_r=max_r, + max_length=max_length, + defer_initial_weights=self._memory_efficient_init, + ) self.model.gradient_checkpointing_enable() self.model = self.multi_adapter.patch(self.model, target_modules=target_modules, lora_config=self.lora_config) - self.multi_adapter.save_initial_weights() + # PEFT initializes LoRA parameters in FP32 even when the base model is + # BF16. Native FSDP2 records the pre-wrap parameter dtype as the + # DTensor grad_dtype, so materializing those slots from the BF16 rank-0 + # state later would make FP32 reduced gradients incompatible with the + # BF16 sharded parameters. Keep all preallocated slots aligned before + # EP state capture and FSDP wrapping on every rank, including meta ranks. + self._ensure_lora_dtype(self.model) + self._initial_lora_weights_saved = False + if not self._memory_efficient_init: + self._save_initial_lora_weights() # Active group for compatibility with single adapter self.active_group = None self.handler = self.register_global_mm_forward_hook() @@ -114,22 +128,33 @@ def unregister_mm_forward_hook(self, optimizer_group: OptimizerGroup): pass def _lazy_wrap_model(self): - return super()._lazy_wrap_model() + super()._lazy_wrap_model() + # Non-source ranks keep both PEFT and target-parameter LoRA slots on + # meta until NativeFSDPStrategy broadcasts the rank-local shards. + self._save_initial_lora_weights() + + def _save_initial_lora_weights(self): + if self._initial_lora_weights_saved: + return + self.multi_adapter.save_initial_weights() + self._initial_lora_weights_saved = True def _maybe_apply_expert_parallel(self): - if self._memory_efficient_init: - raise NotImplementedError('Expert parallel is not supported with memory_efficient_init') return super()._maybe_apply_expert_parallel() def _ensure_target_parameter_lora_installed(self, config: LoraConfig) -> None: target_parameters = getattr(config, 'target_parameters', None) if not target_parameters: return + target_parameter_manager = self.multi_adapter.target_parameter_manager + if target_parameter_manager.patched_target_parameters is not None: + # The first tenant preallocates target-parameter slots for every + # LoRA. Later tenants can reuse the same slots after EP/FSDP wrap; + # patch() still rejects a different target set. + self.multi_adapter.patch_target_parameters(self.model, target_parameters) + return if self._model_wrapped: raise RuntimeError('target_parameters LoRA must be installed before FSDP/DDP wrapping') - if getattr(self, '_enable_expert_parallel', False): - self.strategy.capture_pre_ep_state_if_needed(self.model, enable_ep=True) - # self._maybe_apply_expert_parallel() # 各rank广播之前不能对moe层进行分片, 没有实际权重时不能分片 self.multi_adapter.patch_target_parameters(self.model, target_parameters) @remote_function(dispatch='slice_dp', collect=collect_tensor_dict) @@ -166,6 +191,33 @@ def forward_only(self, *, inputs: Union[InputFeature, List[InputFeature], List[T with self.multi_adapter.adapter(adapter_name, disable_lora=disable_lora): return super().forward_only(inputs=inputs, **kwargs) + @remote_function(dispatch='all', collect='first', sync=True, lazy_collect=False) + def generate(self, + *, + inputs: Union[InputFeature, List[InputFeature], Trajectory, List[Trajectory]], + generation_config: Optional[Dict[str, Any]] = None, + **kwargs): + adapter_name = kwargs.pop('adapter_name', None) + disable_lora = kwargs.pop('disable_lora', False) + self._check_adapter_valid(adapter_name) + # Target-parameter LoRA uses a temporary parametrization while active. + # FSDP must shard the unparametrized model, so finish lazy wrapping + # before entering the adapter context when generate() is the first call. + self._lazy_wrap_model() + # Generation invokes many forwards inside one context. Do not retain a + # parametrized expert weight across FSDP reshard boundaries; recompute + # the routed-expert LoRA delta when each decoder forward accesses it. + with self.multi_adapter.adapter( + adapter_name, + disable_lora=disable_lora, + cache_target_parameters=False): + return super().generate( + inputs=inputs, + adapter_name=adapter_name, + generation_config=generation_config, + **kwargs, + ) + @remote_function(collect='mean') def calculate_loss(self, **kwargs): self._check_adapter_valid(kwargs.get('adapter_name')) @@ -213,6 +265,10 @@ def set_loss(self, loss_cls: Union[Type[Loss], str], **kwargs): @remote_function() def set_optimizer(self, optimizer_cls: Union[Type[Optimizer], str], **kwargs): self._check_adapter_valid(kwargs.get('adapter_name')) + # Materialize/shard the preallocated LoRA slots before an optimizer + # captures parameter references. Otherwise the first optimizer can + # retain pre-FSDP/meta parameters while later optimizers see DTensors. + self._lazy_wrap_model() with self.multi_adapter.adapter(kwargs.get('adapter_name')): super().set_optimizer(optimizer_cls, **kwargs) @@ -264,8 +320,9 @@ def get_state_dict(self, **kwargs): return self.multi_adapter.get_state_dict(kwargs.get('adapter_name')) def _get_adapter_state_dict_for_save(self, adapter_name: str) -> dict: + slot_name = self.multi_adapter.find_lora_by_tenant(adapter_name).adapter_name adapter_state = self.multi_adapter.get_state_dict(adapter_name) - return {key: torch_util.to_local_tensor(value).cpu() for key, value in adapter_state.items()} + return self.strategy.gather_adapter_state_dict(self.model, adapter_state, slot_name) @remote_function(collect='first') def save(self, name, output_dir: Optional[str] = None, interval=1, **kwargs): @@ -280,6 +337,7 @@ def save(self, name, output_dir: Optional[str] = None, interval=1, **kwargs): def load(self, name: str, output_dir: Optional[str] = None, **kwargs): adapter_name = kwargs.get('adapter_name') self._check_adapter_valid(adapter_name) + self._lazy_wrap_model() with self.multi_adapter.save_context(kwargs.get('adapter_name')): load_optimizer = kwargs.get('load_optimizer', False) if output_dir is None: @@ -318,9 +376,9 @@ def calculate_metric(self, is_training, **kwargs): @remote_function() def remove_adapter(self, adapter_name: str): - if adapter_name in self.optimizer_group: - self.optimizer_group.pop(adapter_name) + self._lazy_wrap_model() self.multi_adapter.release_lora(adapter_name) + self.optimizer_group.pop(adapter_name, None) def _get_nb_trainable_parameters(self, adapter_name, model): with self.multi_adapter.adapter(adapter_name): diff --git a/src/twinkle/model/transformers/strategy/accelerate.py b/src/twinkle/model/transformers/strategy/accelerate.py index 3bf627e9d..96f952c0b 100644 --- a/src/twinkle/model/transformers/strategy/accelerate.py +++ b/src/twinkle/model/transformers/strategy/accelerate.py @@ -217,6 +217,11 @@ def get_adapter_state_dict(self, model, adapter_name: str) -> dict: del local return state_dict + def gather_adapter_state_dict(self, model, adapter_state: dict, adapter_name: str) -> dict: + """Move an already filtered Multi-LoRA state dict to CPU.""" + from twinkle.utils import torch_util + return {name: torch_util.to_local_tensor(param).cpu() for name, param in adapter_state.items()} + def _is_lora_state_key(name: str) -> bool: return 'lora_A' in name or 'lora_B' in name or 'lora_embedding' in name diff --git a/src/twinkle/model/transformers/strategy/native_fsdp.py b/src/twinkle/model/transformers/strategy/native_fsdp.py index f57877bb0..95e64d81d 100644 --- a/src/twinkle/model/transformers/strategy/native_fsdp.py +++ b/src/twinkle/model/transformers/strategy/native_fsdp.py @@ -2,6 +2,7 @@ import os import torch import torch.distributed as dist +from contextlib import contextmanager from torch import nn from torch.distributed.device_mesh import DeviceMesh as TorchDeviceMesh from torch.distributed.fsdp import fully_shard @@ -19,6 +20,18 @@ LORA_STATE_KEY_MARKERS = ('lora_A', 'lora_B', 'lora_embedding') PEFT_BASE_PREFIX = 'base_model.model.' PEFT_BASE_LAYER_SEGMENT = 'base_layer' +TWINKLE_NODE_LOCAL_RANK = 'TWINKLE_NODE_LOCAL_RANK' +TWINKLE_NODE_LOCAL_WORLD_SIZE = 'TWINKLE_NODE_LOCAL_WORLD_SIZE' +TWINKLE_NODE_RANKS = 'TWINKLE_NODE_RANKS' + + +def _get_node_local_rank() -> int: + """Return process topology rank without changing the actor-local device index.""" + return int(os.environ.get(TWINKLE_NODE_LOCAL_RANK, Platform.get_local_rank())) + + +def _get_node_local_world_size() -> int: + return int(os.environ.get(TWINKLE_NODE_LOCAL_WORLD_SIZE, Platform.get_local_world_size())) class NativeFSDPStrategy: @@ -49,14 +62,20 @@ def pretrained_load_context(self): def use_rank0_pretrained_broadcast(self) -> bool: return self._memory_efficient_init and self.device_mesh is not None + def is_node_local_source_rank(self) -> bool: + local_rank = _get_node_local_rank() + if local_rank < 0: + raise RuntimeError('Native FSDP memory_efficient_init requires node-local rank topology.') + return local_rank == 0 + def capture_pre_ep_state_if_needed(self, model, *, enable_ep: bool) -> None: if self._pre_ep_state_captured: return if not (enable_ep and self.use_rank0_pretrained_broadcast()): return - local_rank = Platform.get_local_rank() + local_rank = _get_node_local_rank() if local_rank < 0: - raise RuntimeError('Native FSDP node-local pre-EP state capture requires LOCAL_RANK.') + raise RuntimeError('Native FSDP node-local pre-EP state capture requires node-local rank topology.') is_source_rank = dist.is_available() and dist.is_initialized() and local_rank == 0 self.set_rank0_pre_ep_full_state_dict(clone_state_dict_to_cpu(model.state_dict()) if is_source_rank else {}) self._pre_ep_state_captured = True @@ -113,9 +132,15 @@ def _build_ep_fsdp_device_mesh(self, ep_size: Optional[int] = None) -> Optional[ return ep_mesh.to_torch_device_mesh() def wrap_model(self, model, optimizer=None): - if self.device_mesh is None: + fsdp_mesh = _build_fsdp_mesh(self.device_mesh) if self.device_mesh is not None else None + if fsdp_mesh is None: + # FSDP normally materializes/moves parameters onto the mesh device + # while wrapping. A singleton (or absent) mesh skips FSDP, so do + # the equivalent device placement explicitly. Without this, a + # model loaded by ``from_pretrained`` remains on CPU while the + # input processor creates tensors on the actor's CUDA/NPU device. + model = model.to(torch.device(Platform.get_local_device())) return model, optimizer - fsdp_mesh = _build_fsdp_mesh(self.device_mesh) if fsdp_mesh is not None: ep_enabled = (self.enable_ep and self.ep_fsdp_device_mesh is not None) @@ -130,17 +155,23 @@ def wrap_model(self, model, optimizer=None): adapter_source_sd = {} adapter_full_sd = {} if use_meta: - local_rank = Platform.get_local_rank() + local_rank = _get_node_local_rank() if local_rank < 0: - raise RuntimeError('Native FSDP node-local state loading requires LOCAL_RANK.') + raise RuntimeError('Native FSDP node-local state loading requires node-local rank topology.') is_source_rank = local_rank == 0 if ep_enabled and self._rank0_pre_ep_full_state_dict is not None: original_sd = self._rank0_pre_ep_full_state_dict if is_source_rank else {} else: original_sd = model.state_dict() if is_source_rank else {} adapter_source_sd = _collect_adapter_source_state(model.state_dict()) - adapter_full_sd = ( - self._adapter_full_state_dict if is_source_rank and self._adapter_full_state_dict else {}) + if is_source_rank: + # Multi-LoRA target-parameter slots are installed before EP + # and then sharded with their experts. Preserve their full + # pre-EP tensors so every EP rank receives its own expert + # range instead of a copy of the source rank's local range. + adapter_full_sd = _collect_adapter_source_state(original_sd or {}, clone=False) + if self._adapter_full_state_dict: + adapter_full_sd.update(self._adapter_full_state_dict) saved_buffers = _get_non_persistent_buffers(model) if is_source_rank else {} if is_source_rank: model = model.to(torch.device('meta')) @@ -234,6 +265,28 @@ def wrap_model(self, model, optimizer=None): return model, optimizer + @contextmanager + def generation_context(self, model): + """Materialize root FSDP parameters while PEFT delegates generation. + + ``PeftModel.generate()`` calls ``get_base_model().generate()`` directly, + bypassing the outer PEFT module's forward hooks. When that outer module + is an FSDP2 root, its directly managed parameters (for example token + embeddings and ``lm_head``) would otherwise remain DTensors while the + generation inputs are regular tensors. + """ + unshard = getattr(model, 'unshard', None) + reshard = getattr(model, 'reshard', None) + if not callable(unshard) or not callable(reshard): + yield + return + + unshard() + try: + yield + finally: + reshard() + def _prepare_optimizer_state_dict_options(self, *, for_load: bool): from torch.distributed.checkpoint.state_dict import StateDictOptions @@ -327,16 +380,16 @@ def get_full_state_dict(self, model) -> dict: ep_group = ep_fsdp_mesh['ep'].get_group() ep_world_size = ep_fsdp_mesh['ep'].size() - ep_expert_names = _detect_ep_expert_names(unwrapped) if ep_world_size > 1 else set() + expert_specs = _collect_ep_expert_shard_specs(unwrapped) if ep_world_size > 1 else {} for name, param in unwrapped.named_parameters(): local_full = torch_util.to_local_tensor(param) - if name in ep_expert_names and ep_world_size > 1 and ep_group is not None: + if name in expert_specs and ep_world_size > 1 and ep_group is not None: local_full = local_full.contiguous().to(Platform.get_local_device()) gathered = [torch.empty_like(local_full) for _ in range(ep_world_size)] dist.all_gather(gathered, local_full, group=ep_group) - local_full = torch.cat(gathered, dim=_ep_expert_state_dict_gather_dim(name)) + local_full = _concat_ep_expert_shards(name, gathered, expert_specs[name]) state_dict[name] = local_full.cpu() del gathered, local_full else: @@ -357,7 +410,7 @@ def get_adapter_state_dict(self, model, adapter_name: str) -> dict: ep_group = ep_fsdp_mesh['ep'].get_group() ep_world_size = ep_fsdp_mesh['ep'].size() - ep_expert_names = _detect_ep_expert_names(unwrapped) if ep_world_size > 1 else set() + expert_specs = _collect_ep_expert_shard_specs(unwrapped) if ep_world_size > 1 else {} adapter_suffix = f'.{adapter_name}.' for name, param in unwrapped.named_parameters(): @@ -365,11 +418,11 @@ def get_adapter_state_dict(self, model, adapter_name: str) -> dict: continue local_full = torch_util.to_local_tensor(param) - if name in ep_expert_names and ep_world_size > 1 and ep_group is not None: + if name in expert_specs and ep_world_size > 1 and ep_group is not None: local_full = local_full.contiguous().to(Platform.get_local_device()) gathered = [torch.empty_like(local_full) for _ in range(ep_world_size)] dist.all_gather(gathered, local_full, group=ep_group) - local_full = torch.cat(gathered, dim=_ep_expert_state_dict_gather_dim(name)) + local_full = _concat_ep_expert_shards(name, gathered, expert_specs[name]) state_dict[name] = local_full.cpu() del gathered, local_full else: @@ -378,6 +431,41 @@ def get_adapter_state_dict(self, model, adapter_name: str) -> dict: return state_dict + def gather_adapter_state_dict(self, model, adapter_state: dict, adapter_name: str) -> dict: + """Gather a tenant-filtered Multi-LoRA state dict across the EP group.""" + unwrapped = self.unwrap_model(model) + ep_mesh = self.ep_fsdp_device_mesh + if ep_mesh is None or ep_mesh['ep'].size() <= 1: + return {name: torch_util.to_local_tensor(param).cpu() for name, param in adapter_state.items()} + + ep_group = ep_mesh['ep'].get_group() + slot_suffix = f'.{adapter_name}.' + normalized_specs = { + _strip_peft_base_prefix(name.replace(slot_suffix, '.')): spec + for name, spec in _collect_ep_expert_shard_specs(unwrapped).items() + } + expert_owner_specs = { + name.split('.experts.', 1)[0] + '.experts.': spec + for name, spec in normalized_specs.items() if '.experts.' in name + } + state_dict = {} + for name, param in adapter_state.items(): + local = torch_util.to_local_tensor(param) + canonical_name = _strip_peft_base_prefix(name) + spec = normalized_specs.get(canonical_name) + if spec is None and '.experts.' in canonical_name: + owner = canonical_name.split('.experts.', 1)[0] + '.experts.' + spec = expert_owner_specs.get(owner) + if spec is not None: + local = local.contiguous().to(Platform.get_local_device()) + gathered = [torch.empty_like(local) for _ in range(ep_mesh['ep'].size())] + dist.all_gather(gathered, local, group=ep_group) + local = _concat_ep_expert_shards(name, gathered, spec) + del gathered + state_dict[name] = local.cpu() + del local + return state_dict + def _detect_ep_expert_names(model: nn.Module) -> Set[str]: candidate_names = set() @@ -394,12 +482,21 @@ def _detect_ep_expert_names(model: nn.Module) -> Set[str]: return candidate_names & actual_param_names -def _ep_expert_state_dict_gather_dim(name: str) -> int: +def _ep_expert_state_dict_gather_dim( + name: str, + shape: Optional[tuple] = None, + experts_per_rank: Optional[int] = None, +) -> int: # PEFT ParamWrapper keeps expert LoRA tensors flattened instead of storing # them as [num_experts, ...]: lora_A is [r * num_experts, in] and lora_B is # [out, r * num_experts]. EP therefore owns a contiguous expert block on # dim 0 for A and dim 1 for B. This is still expert sharding, not LoRA rank # parallelism, so the forward pass does not need an EP all-reduce. + # Current target-parameter/3D PEFT tensors keep experts explicitly on dim 0: + # A=[local_experts, r, in], B=[local_experts, out, r]. Detect this before + # applying the legacy flattened-PEFT convention below. + if shape and len(shape) == 3: + return 0 if '_twinkle_lora_' in name: return 0 if 'lora_B' in name: @@ -407,6 +504,18 @@ def _ep_expert_state_dict_gather_dim(name: str) -> int: return 0 +def _concat_ep_expert_shards(name: str, shards: List[torch.Tensor], spec: Dict[str, int]) -> torch.Tensor: + if not shards: + raise ValueError(f'No EP shards collected for {name}.') + local_shape = tuple(shards[0].shape) + gather_dim = _ep_expert_state_dict_gather_dim(name, local_shape, spec['experts_per_rank']) + result = torch.cat(shards, dim=gather_dim) + if local_shape[0] == spec['experts_per_rank'] and result.shape[0] != spec['num_experts']: + raise RuntimeError(f"EP adapter parameter '{name}' reconstructed {result.shape[0]} experts; " + f"expected {spec['num_experts']}.") + return result + + def _build_mp_policy(mixed_precision: str) -> 'MixedPrecisionPolicy': from torch.distributed.fsdp import MixedPrecisionPolicy if mixed_precision == 'bf16': @@ -576,19 +685,28 @@ def _get_local_rank_info() -> tuple[int, int, int, List[int]]: """Return local-rank topology for node-local state-dict fanout.""" rank = dist.get_rank() world_size = dist.get_world_size() - local_rank = Platform.get_local_rank() - if 'LOCAL_WORLD_SIZE' not in os.environ and 'LOCAL_SIZE' not in os.environ: - raise RuntimeError('Native FSDP node-local state loading requires LOCAL_WORLD_SIZE or LOCAL_SIZE.') - local_world_size = Platform.get_local_world_size() - if local_rank < 0 or local_world_size <= 0 or world_size % local_world_size != 0: + local_rank = _get_node_local_rank() + has_twinkle_topology = TWINKLE_NODE_LOCAL_WORLD_SIZE in os.environ + if not has_twinkle_topology and 'LOCAL_WORLD_SIZE' not in os.environ and 'LOCAL_SIZE' not in os.environ: + raise RuntimeError('Native FSDP node-local state loading requires Twinkle Ray worker topology, ' + 'LOCAL_WORLD_SIZE, or LOCAL_SIZE.') + local_world_size = _get_node_local_world_size() + raw_node_ranks = os.environ.get(TWINKLE_NODE_RANKS) + if (local_rank < 0 or local_world_size <= 0 or (not raw_node_ranks and world_size % local_world_size != 0)): raise RuntimeError(f'Invalid local rank topology: rank={rank}, world_size={world_size}, ' f'local_rank={local_rank}, local_world_size={local_world_size}.') - node_start = rank - local_rank - node_ranks = list(range(node_start, min(node_start + local_world_size, world_size))) + if raw_node_ranks: + node_ranks = [int(item) for item in raw_node_ranks.split(',') if item] + else: + node_start = rank - local_rank + node_ranks = list(range(node_start, min(node_start + local_world_size, world_size))) if rank not in node_ranks or len(node_ranks) != local_world_size: raise RuntimeError(f'Invalid local rank group: rank={rank}, local_rank={local_rank}, ' f'local_world_size={local_world_size}, node_ranks={node_ranks}.') - return rank, world_size, node_start, node_ranks + if node_ranks[local_rank] != rank: + raise RuntimeError(f'Invalid node-local rank ordering: rank={rank}, local_rank={local_rank}, ' + f'node_ranks={node_ranks}.') + return rank, world_size, node_ranks[0], node_ranks def _find_experts_in_layer(layer_mod: nn.Module, experts_map: Dict[str, nn.Module]) -> Optional[nn.Module]: @@ -737,14 +855,15 @@ def _resolve_full_state_source_key(param_name: str, source_state: Mapping[str, A f'Tried source keys: {", ".join(candidates)}.') -def _collect_adapter_source_state(state_dict: Mapping[str, Any]) -> Dict[str, Any]: +def _collect_adapter_source_state(state_dict: Mapping[str, Any], *, clone: bool = True) -> Dict[str, Any]: adapter_state = {} for name, tensor in state_dict.items(): if not _is_lora_state_key(name) or not hasattr(tensor, 'detach'): continue if getattr(tensor, 'is_meta', False): continue - adapter_state[name] = tensor.detach().cpu().clone() + tensor = tensor.detach().cpu() + adapter_state[name] = tensor.clone() if clone else tensor return adapter_state @@ -785,7 +904,7 @@ def _split_for_ep_pre_distribute(model, model_key: str, value: torch.Tensor, ep_ if not matched: return value - shard_dim = _ep_expert_state_dict_gather_dim(model_key) + shard_dim = _ep_expert_state_dict_gather_dim(model_key, tuple(value.shape)) chunk = value.size(shard_dim) // ep_world_size return value.narrow(shard_dim, ep_rank * chunk, chunk).contiguous() @@ -863,6 +982,9 @@ def _broadcast_sharded_state_dict( rank_to_ep_rank = rank_to_ep_rank or {} adapter_source_sd = adapter_source_sd or {} adapter_full_sd = adapter_full_sd or {} + diagnostics_enabled = os.environ.get('TWINKLE_EP_DIAGNOSTICS', '').strip().lower() in { + '1', 'true', 'yes', 'on' + } source_metadata = None source_keys = None adapter_metadata = None @@ -937,7 +1059,9 @@ def _scatter_ep_adapter_tensor(param_name, full_tensor, sharded_param): local_shape = tuple(sharded_param.size()) _, source_dtype = adapter_metadata[param_name] local_tensor = torch.empty(local_shape, device=device_type, dtype=source_dtype) - shard_dim = _ep_expert_state_dict_gather_dim(param_name) + spec = expert_shard_specs.get(param_name) + experts_per_rank = spec['experts_per_rank'] if spec is not None else None + shard_dim = _ep_expert_state_dict_gather_dim(param_name, local_shape, experts_per_rank) local_dim = local_shape[shard_dim] local_tensor = _scatter_ep_tensor_from_source( full_tensor, @@ -974,14 +1098,43 @@ def _scatter_ep_expert_tensor(param_name, full_tensor, sharded_param): raise RuntimeError(f"EP expert parameter '{param_name}' expects {num_experts} experts, " f'but source state has shape {tuple(full_tensor.shape)}. ' 'Rank0 must capture the full pre-EP state_dict before apply_expert_parallel().') + if diagnostics_enabled: + logger.warning( + '[EP_DIAG] rank=%s local_source=%s param=%s full_shape=%s source_preview=%s', + rank, + local_source_rank, + param_name, + tuple(full_tensor.shape), + _diagnostic_tensor_preview(full_tensor), + ) local_tensor = _scatter_ep_tensor_from_source( full_tensor, local_tensor, shard_dim=0, shard_size=experts_per_rank, ) + if diagnostics_enabled: + ep_rank = rank_to_ep_rank[rank] + start = ep_rank * experts_per_rank + logger.warning( + '[EP_DIAG] rank=%s ep_rank=%s param=%s expert_range=[%s,%s) local_shape=%s local_preview=%s', + rank, + ep_rank, + param_name, + start, + start + experts_per_rank, + tuple(local_tensor.shape), + _diagnostic_tensor_preview(local_tensor), + ) return local_tensor + def _diagnostic_tensor_preview(tensor: torch.Tensor) -> List[float]: + flat = tensor.detach().reshape(-1) + if flat.numel() == 0: + return [] + indices = sorted({0, flat.numel() // 3, (2 * flat.numel()) // 3, flat.numel() - 1}) + return flat[indices].float().cpu().tolist() + def _scatter_ep_tensor_from_source(full_tensor, local_tensor, *, shard_dim: int, shard_size: int): if is_source_rank: if full_tensor is None: diff --git a/src/twinkle/model/transformers/transformers.py b/src/twinkle/model/transformers/transformers.py index 57fc0ee62..97afafa97 100644 --- a/src/twinkle/model/transformers/transformers.py +++ b/src/twinkle/model/transformers/transformers.py @@ -236,6 +236,9 @@ def _should_init_empty_pretrained_model_on_this_rank(self) -> bool: use_rank0_broadcast = getattr(self.strategy, 'use_rank0_pretrained_broadcast', lambda: False) if not (use_rank0_broadcast() and dist.is_available() and dist.is_initialized()): return False + is_node_local_source_rank = getattr(self.strategy, 'is_node_local_source_rank', None) + if is_node_local_source_rank is not None: + return not is_node_local_source_rank() local_rank = Platform.get_local_rank() if local_rank < 0: raise RuntimeError('Native FSDP memory_efficient_init requires LOCAL_RANK.') @@ -622,6 +625,174 @@ def forward_only(self, *, inputs: Union[InputFeature, List[InputFeature], List[T return_outputs['routed_experts'] = recorded_routing return return_outputs + @staticmethod + def _generated_token_ids(sequence: torch.Tensor, prompt_width: int, eos_token_ids, pad_token_id): + """Return completion IDs without batch padding and whether EOS was reached.""" + if eos_token_ids is None: + eos_ids = set() + elif isinstance(eos_token_ids, int): + eos_ids = {eos_token_ids} + else: + eos_ids = {int(token_id) for token_id in eos_token_ids} + + token_ids = [] + stopped = False + for token_id in sequence[prompt_width:].tolist(): + token_id = int(token_id) + if pad_token_id is not None and token_id == pad_token_id and token_id not in eos_ids: + break + token_ids.append(token_id) + if token_id in eos_ids: + stopped = True + break + return token_ids, stopped + + def _prepare_generate_inputs(self, inputs, optimizer_config): + """Encode trajectories and create a left-padded HF generation batch.""" + if isinstance(inputs, dict): + inputs = [inputs] + else: + inputs = list(inputs) + if not inputs: + raise ValueError('inputs empty, check your generate() inputs') + + template = optimizer_config.template + if self._not_encoded(inputs[0]): + assert template is not None, \ + 'Use set_template before passing Trajectory inputs to generate()' + inputs = template.batch_encode(inputs, add_generation_prompt=True) + + if hasattr(self, 'multi_adapter'): + self.multi_adapter.check_length(inputs) + + prompt_token_ids = [] + for item in inputs: + ids = item.get('input_ids') + if ids is None: + raise ValueError("Every generate() input must contain 'input_ids'") + if torch.is_tensor(ids): + ids = ids.detach().cpu().reshape(-1).tolist() + elif isinstance(ids, np.ndarray): + ids = ids.reshape(-1).tolist() + else: + ids = list(ids) + prompt_token_ids.append([int(token_id) for token_id in ids]) + + # The training processor defaults to right padding and may be configured + # for padding-free batches. Decoder-only generation needs a conventional + # left-padded batch, so use a shallow per-call copy without mutating the + # tenant's training processor. + processor = copy(optimizer_config.processor) + assert isinstance(processor, InputProcessor), 'Set InputProcessor correctly before generate()' + processor.padding_side = 'left' + processor.padding_free = False + model_inputs: Dict[str, Any] = processor( + inputs, + sp_strategy=None, + model=self.model, + hf_config=self.hf_config, + enable_sp=False, + ) + for key in ('labels', 'completion_mask', 'length', 'routed_experts'): + model_inputs.pop(key, None) + + # Rebuild position IDs after left padding. Template position IDs describe + # each unpadded sample and therefore cannot be padded with -1 and passed + # unchanged to GenerationMixin. + attention_mask = model_inputs.get('attention_mask') + if torch.is_tensor(attention_mask) and attention_mask.dim() == 2: + position_ids = attention_mask.long().cumsum(-1) - 1 + position_ids.masked_fill_(attention_mask == 0, 0) + model_inputs['position_ids'] = position_ids + + return model_inputs, prompt_token_ids, template + + @remote_function(dispatch='all', collect='first', sync=True, lazy_collect=False) + def generate(self, + *, + inputs: Union[InputFeature, List[InputFeature], Trajectory, List[Trajectory]], + generation_config: Optional[Dict[str, Any]] = None, + **kwargs): + """Generate completions directly with the resident Transformers model. + + Unlike ``forward_only()``, this keeps and reuses ``past_key_values`` via + Hugging Face ``GenerationMixin``. All distributed ranks receive the same + request so FSDP/EP collectives remain aligned; only rank 0's JSON-safe + result is returned by the remote-function collector. + """ + adapter_name = kwargs.pop('adapter_name', self._get_default_group()) + optimizer_config = self.optimizer_group[adapter_name] + self._lazy_wrap_model() + if getattr(self, '_enable_sp', False): + raise NotImplementedError('TransformersModel.generate() does not support sequence parallelism; ' + 'start this model with ulysses/sp size 1.') + + self.model.eval() + model_inputs, prompt_token_ids, template = self._prepare_generate_inputs(inputs, optimizer_config) + generate_model = self.strategy.unwrap_model(self.model) + if not hasattr(generate_model, 'generate'): + raise TypeError(f'{type(generate_model).__name__} does not expose Hugging Face generate()') + + gen_kwargs = dict(generation_config or {}) + gen_kwargs.setdefault('max_new_tokens', 128) + gen_kwargs.setdefault('do_sample', False) + gen_kwargs.setdefault('use_cache', True) + gen_kwargs.setdefault('return_dict_in_generate', False) + if gen_kwargs['return_dict_in_generate']: + raise ValueError('return_dict_in_generate=True is not supported by the Twinkle generate API') + + tokenizer = template.tokenizer if template is not None else None + if tokenizer is not None: + if tokenizer.pad_token_id is None and tokenizer.eos_token_id is not None: + gen_kwargs.setdefault('pad_token_id', tokenizer.eos_token_id) + else: + gen_kwargs.setdefault('pad_token_id', tokenizer.pad_token_id) + gen_kwargs.setdefault('eos_token_id', tokenizer.eos_token_id) + + seed = int(gen_kwargs.pop('seed', 0)) + if dist.is_available() and dist.is_initialized() and dist.get_world_size() > 1: + # Ranks may finish at different moments for different prompts. Keep + # every rank participating until the complete distributed group is done. + gen_kwargs.setdefault('synced_gpus', True) + + num_return_sequences = int(gen_kwargs.get('num_return_sequences', 1)) + rng_state = self._get_training_rng_state() + try: + # Identical seeds keep sampled tokens aligned on all FSDP/EP ranks. + # Restore the training RNG afterwards so an evaluation request does + # not perturb dropout or any later stochastic training operation. + Torch.seed_everything(seed) + generation_context = getattr(self.strategy, 'generation_context', None) + fsdp_root_context = ( + generation_context(generate_model) if generation_context is not None else contextlib.nullcontext()) + with torch.no_grad(), fsdp_root_context: + sequences = generate_model.generate(**model_inputs, **gen_kwargs) + finally: + self._set_training_rng_state(rng_state) + + if not torch.is_tensor(sequences): + raise TypeError(f'generate() returned unsupported output type: {type(sequences).__name__}') + expected = len(prompt_token_ids) * num_return_sequences + if sequences.shape[0] != expected: + raise RuntimeError(f'generate() returned {sequences.shape[0]} sequences, expected {expected}') + + prompt_width = int(model_inputs['input_ids'].shape[-1]) + eos_token_ids = gen_kwargs.get('eos_token_id') + pad_token_id = gen_kwargs.get('pad_token_id') + max_new_tokens = int(gen_kwargs['max_new_tokens']) + results = [] + for sequence_index, sequence in enumerate(sequences): + prompt_index = sequence_index // num_return_sequences + token_ids, stopped = self._generated_token_ids(sequence, prompt_width, eos_token_ids, pad_token_id) + text = template.decode(token_ids, skip_special_tokens=True) if template is not None else '' + results.append({ + 'prompt_token_ids': prompt_token_ids[prompt_index], + 'tokens': token_ids, + 'text': text, + 'stop_reason': 'stop' if stopped or len(token_ids) < max_new_tokens else 'length', + }) + return results + @remote_function(collect='mean') def calculate_loss(self, **kwargs): """Calculate loss @@ -1223,8 +1394,8 @@ def _get_training_rng_state(self): state['device_rng_state'] = None return state - def _load_rng_state(self, rng_path): - rng_state = torch.load(rng_path, map_location='cpu', weights_only=False) + @staticmethod + def _set_training_rng_state(rng_state): random.setstate(rng_state['python_rng_state']) np.random.set_state(rng_state['numpy_rng_state']) torch.set_rng_state(rng_state['torch_rng_state']) @@ -1236,6 +1407,10 @@ def _load_rng_state(self, rng_path): if device_module and hasattr(device_module, 'is_available') and device_module.is_available(): device_module.set_rng_state(device_rng_state) + def _load_rng_state(self, rng_path): + rng_state = torch.load(rng_path, map_location='cpu', weights_only=False) + self._set_training_rng_state(rng_state) + def _restore_training_state(self, checkpoint_dir, *, adapter_name=''): trainer_state_path = os.path.join(checkpoint_dir, 'trainer_state.json') with open(trainer_state_path) as f: diff --git a/src/twinkle/server/config/application_spec.py b/src/twinkle/server/config/application_spec.py index 51015245c..63fe09f1b 100644 --- a/src/twinkle/server/config/application_spec.py +++ b/src/twinkle/server/config/application_spec.py @@ -57,7 +57,15 @@ class ModelArgs(_ArgsBase): adapter_config: dict[str, Any] | None = None queue_config: TaskQueueConfig = Field(default_factory=TaskQueueConfig) max_loras: int = 5 + max_r: int | None = None max_length: int | None = None + mixed_precision: Literal['no', 'fp8', 'fp16', 'bf16'] | None = None + strategy: Literal['accelerate', 'native_fsdp'] | None = None + ddp_config: dict[str, Any] | None = None + fsdp_config: dict[str, Any] | None = None + grad_scaler_config: dict[str, Any] | None = None + memory_efficient_init: bool | None = None + target_modules: str | list[str] | None = None class SamplerArgs(_ArgsBase): diff --git a/src/twinkle/server/model/backends/mock_model.py b/src/twinkle/server/model/backends/mock_model.py index bc0fc06f2..93a44db95 100644 --- a/src/twinkle/server/model/backends/mock_model.py +++ b/src/twinkle/server/model/backends/mock_model.py @@ -90,6 +90,16 @@ def forward(self, *, inputs: Any, **kwargs: Any) -> list[dict[str, Any]]: def forward_only(self, *, inputs: Any, **kwargs: Any) -> list[dict[str, Any]]: return self._build_forward_result(inputs, kwargs.get('adapter_name')) + @remote_function(dispatch='all', collect='first', lazy_collect=False) + def generate(self, *, inputs: Any, **kwargs: Any) -> list[dict[str, Any]]: + generated = [1, 2, 3] + return [{ + 'prompt_token_ids': list(item.get('input_ids', [])), + 'tokens': generated, + 'text': 'mock completion', + 'stop_reason': 'length', + } for item in (inputs if isinstance(inputs, list) else [inputs])] + @remote_function() def forward_backward(self, *, inputs: Any, **kwargs: Any) -> list[Any]: loss = float(np.random.default_rng(self._rng_seed).uniform(0.0, 1.0)) diff --git a/src/twinkle/server/model/backends/transformers_model.py b/src/twinkle/server/model/backends/transformers_model.py index e7677b619..909903756 100644 --- a/src/twinkle/server/model/backends/transformers_model.py +++ b/src/twinkle/server/model/backends/transformers_model.py @@ -99,6 +99,11 @@ def forward_only(self, *, inputs: InputFeature | list[InputFeature] | Trajectory output = super().forward_only(inputs=inputs, **kwargs) return to_cpu_safe_output(output) + @remote_function(dispatch='all', collect='first', sync=True, lazy_collect=False) + def generate(self, *, inputs: InputFeature | list[InputFeature] | Trajectory | list[Trajectory], **kwargs): + """Generate with the resident Transformers model rather than a sampler deployment.""" + return super().generate(inputs=inputs, **kwargs) + @remote_function(dispatch='slice_dp', collect=collect_tensor_dict) @nccl_safe def forward_backward(self, *, inputs: InputFeature | list[InputFeature] | Trajectory | list[Trajectory], **kwargs): diff --git a/src/twinkle/server/model/twinkle_handlers.py b/src/twinkle/server/model/twinkle_handlers.py index 2074f5e40..7cabed651 100644 --- a/src/twinkle/server/model/twinkle_handlers.py +++ b/src/twinkle/server/model/twinkle_handlers.py @@ -149,6 +149,39 @@ async def _task(): task_type='forward_only', )) + @app.post('/twinkle/generate', response_model=types.GenerateResponse) + async def generate( + request: Request, + body: types.GenerateRequest, + self: ModelManagement = Depends(self_fn), + ) -> types.GenerateResponse: + """Generate directly with the Transformers model deployment.""" + token = await self._on_request_start(request) + adapter_name = _get_twinkle_adapter_name(request, body.adapter_name) + + async def _task(): + self.assert_resource_exists(adapter_name) + extra_kwargs = body.model_extra or {} + inputs = _parse_inputs(body.inputs) + ret = self.model.generate( + inputs=inputs, + adapter_name=adapter_name, + generation_config=body.generation_config, + **extra_kwargs, + ) + return {'result': ret} + + inputs_list = body.inputs if isinstance(body.inputs, list) else [body.inputs] + input_tokens = sum(len(inp.get('input_ids', [])) if isinstance(inp, dict) else 0 for inp in inputs_list) + return await run_task( + self.schedule_task_and_wait( + _task, + model_id=adapter_name, + token=token, + input_tokens=input_tokens, + task_type='generate', + )) + @app.post('/twinkle/calculate_loss', response_model=types.CalculateLossResponse) async def calculate_loss( request: Request, diff --git a/src/twinkle_client/model/multi_lora_transformers.py b/src/twinkle_client/model/multi_lora_transformers.py index c628c353b..281ecc84c 100644 --- a/src/twinkle_client/model/multi_lora_transformers.py +++ b/src/twinkle_client/model/multi_lora_transformers.py @@ -8,6 +8,7 @@ ClipGradNormResponse, ForwardBackwardResponse, ForwardResponse, + GenerateResponse, GetStateDictResponse, GetTrainConfigsResponse, SaveResponse, @@ -67,6 +68,32 @@ def forward_only(self, inputs: Any, **kwargs) -> ForwardResponse: response.raise_for_status() return ForwardResponse(**response.json()) + def generate( + self, + inputs: Any, + generation_config: Optional[Dict[str, Any]] = None, + *, + timeout: Optional[int] = None, + **kwargs, + ) -> GenerateResponse: + """Generate text directly with the Transformers training model. + + This does not use the sampler deployment. ``generation_config`` is + forwarded to Hugging Face ``generate()``. + """ + response = http_post( + url=f'{self.server_url}/generate', + json_data={ + 'inputs': inputs, + 'adapter_name': self.adapter_name, + 'generation_config': generation_config or {}, + **kwargs, + }, + timeout=timeout, + ) + response.raise_for_status() + return GenerateResponse(**response.json()) + def calculate_loss(self, **kwargs) -> CalculateLossResponse: """Calculate loss from model outputs.""" response = http_post( diff --git a/src/twinkle_client/types/__init__.py b/src/twinkle_client/types/__init__.py index 49673b0e9..20298bffe 100644 --- a/src/twinkle_client/types/__init__.py +++ b/src/twinkle_client/types/__init__.py @@ -20,6 +20,9 @@ ForwardOnlyRequest, ForwardRequest, ForwardResponse, + GenerateRequest, + GenerateResponse, + GeneratedSequence, GetStateDictRequest, GetStateDictResponse, GetTrainConfigsResponse, diff --git a/src/twinkle_client/types/model.py b/src/twinkle_client/types/model.py index 10a60b947..337650c5d 100644 --- a/src/twinkle_client/types/model.py +++ b/src/twinkle_client/types/model.py @@ -4,7 +4,7 @@ These models are used by both the server-side handler and the twinkle client. """ -from pydantic import BaseModel, field_validator +from pydantic import BaseModel, Field, field_validator from typing import Any, Dict, List, Optional, Union @@ -30,6 +30,15 @@ class Config: extra = 'allow' +class GenerateRequest(BaseModel): + inputs: Any + adapter_name: Optional[str] = None + generation_config: Dict[str, Any] = Field(default_factory=dict) + + class Config: + extra = 'allow' + + class AdapterRequest(BaseModel): adapter_name: str @@ -200,6 +209,18 @@ class ForwardBackwardResponse(BaseModel): result: Any +class GeneratedSequence(BaseModel): + """One completion returned by the Transformers training model.""" + prompt_token_ids: List[int] + tokens: List[int] + text: str + stop_reason: str + + +class GenerateResponse(BaseModel): + result: List[GeneratedSequence] + + class CalculateLossResponse(BaseModel): """Response for /calculate_loss endpoint (returns float).""" result: float diff --git a/tests/infra/test_ray_worker_topology.py b/tests/infra/test_ray_worker_topology.py new file mode 100644 index 000000000..4582a7fb1 --- /dev/null +++ b/tests/infra/test_ray_worker_topology.py @@ -0,0 +1,43 @@ +from twinkle.infra._ray.ray_helper import _copy_worker_env, _get_node_local_topology + + +def test_copy_worker_env_drops_ray_process_local_flags(monkeypatch): + monkeypatch.setenv('RAY_JOB_ID', 'parent-job') + monkeypatch.setenv('RAY_RAYLET_PID', '12345') + monkeypatch.setenv('RAY_OVERRIDE_NODE_ID_FOR_TESTING', 'parent-node') + monkeypatch.setenv('RAY_EXPERIMENTAL_NOSET_ASCEND_RT_VISIBLE_DEVICES', '1') + monkeypatch.setenv('TWINKLE_TEST_ENV', 'preserved') + + env = _copy_worker_env() + + assert 'RAY_JOB_ID' not in env + assert 'RAY_RAYLET_PID' not in env + assert 'RAY_OVERRIDE_NODE_ID_FOR_TESTING' not in env + assert env['RAY_EXPERIMENTAL_NOSET_ASCEND_RT_VISIBLE_DEVICES'] == '1' + assert env['TWINKLE_TEST_ENV'] == 'preserved' + + +def test_get_node_local_topology_for_single_node_workers(): + placements = [ + {'node_rank': 0}, + {'node_rank': 0}, + ] + + assert _get_node_local_topology(placements) == [ + (0, [0, 1]), + (1, [0, 1]), + ] + + +def test_get_node_local_topology_does_not_assume_contiguous_global_ranks(): + placements = [ + {'node_rank': 0}, + {'node_rank': 1}, + {'node_rank': 0}, + ] + + assert _get_node_local_topology(placements) == [ + (0, [0, 2]), + (0, [1]), + (1, [0, 2]), + ] diff --git a/tests/kernel/ops/test_moe.py b/tests/kernel/ops/test_moe.py index 4b210029c..e77bc9b6a 100644 --- a/tests/kernel/ops/test_moe.py +++ b/tests/kernel/ops/test_moe.py @@ -1,3 +1,10 @@ +import importlib + +import pytest +import torch +from torch import nn + + def test_moe_imports(): from twinkle.kernel.ops.moe.npu import ( GmmFunction, @@ -5,8 +12,64 @@ def test_moe_imports(): npu_packed_moe_experts_forward, npu_qwen3_5_moe_sparse_block_forward, ) - import torch assert issubclass(GmmFunction, torch.autograd.Function) assert callable(npu_grouped_mm) assert callable(npu_packed_moe_experts_forward) - assert callable(npu_qwen3_5_moe_sparse_block_forward) \ No newline at end of file + assert callable(npu_qwen3_5_moe_sparse_block_forward) + + +class _PackedExperts(nn.Module): + + def __init__(self, gate_up_proj: torch.Tensor, down_proj: torch.Tensor): + super().__init__() + self.gate_up_proj = nn.Parameter(gate_up_proj) + self.down_proj = nn.Parameter(down_proj) + + +def test_normalize_packed_expert_weights_resolves_square_dsv4_gate_from_down_proj(): + from twinkle.kernel.ops.moe.npu import _normalize_packed_expert_weights + + # DeepSeek-V4 relation: hidden == 2 * intermediate. gate_up_proj is + # therefore square even though it still uses F.linear [out, in] layout. + experts, hidden, intermediate = 2, 8, 4 + gate_up = torch.arange(experts * hidden * hidden, dtype=torch.float32).reshape(experts, hidden, hidden) + down = torch.arange(experts * hidden * intermediate, + dtype=torch.float32).reshape(experts, hidden, intermediate) + module = _PackedExperts(gate_up, down) + + normalized_gate_up, normalized_down = _normalize_packed_expert_weights(module, torch.float32, hidden) + + assert torch.equal(normalized_gate_up, gate_up.transpose(1, 2)) + assert torch.equal(normalized_down, down.transpose(1, 2)) + + +def test_normalize_packed_expert_weights_keeps_grouped_mm_layout(): + from twinkle.kernel.ops.moe.npu import _normalize_packed_expert_weights + + experts, hidden, intermediate = 2, 8, 4 + gate_up = torch.randn(experts, hidden, intermediate * 2) + down = torch.randn(experts, intermediate, hidden) + module = _PackedExperts(gate_up, down) + + normalized_gate_up, normalized_down = _normalize_packed_expert_weights(module, torch.float32, hidden) + + assert torch.equal(normalized_gate_up, gate_up) + assert torch.equal(normalized_down, down) + + +def test_normalize_packed_expert_weights_rejects_inconsistent_layout(): + from twinkle.kernel.ops.moe.npu import _normalize_packed_expert_weights + + module = _PackedExperts(torch.randn(2, 7, 9), torch.randn(2, 5, 6)) + with pytest.raises(RuntimeError, match='Unable to determine packed expert weight layout'): + _normalize_packed_expert_weights(module, torch.float32, hidden_dim=8) + + +def test_ep_force_loop_environment_switch(monkeypatch): + ep_ops = importlib.import_module('twinkle.kernel.ops.ep') + + monkeypatch.setenv('TWINKLE_EP_FORCE_LOOP', '1') + implementations = ep_ops._get_impls() + + assert len(implementations) == 1 + assert implementations[0].name == 'per-expert loop' diff --git a/tests/model/test_multi_lora_dtensor_release.py b/tests/model/test_multi_lora_dtensor_release.py new file mode 100644 index 000000000..9794726fa --- /dev/null +++ b/tests/model/test_multi_lora_dtensor_release.py @@ -0,0 +1,106 @@ +import pytest +import torch +from peft import LoraConfig +from torch import nn + +from twinkle.model.multi_lora import LoraTenant, MultiLora +from twinkle.model.multi_lora_target_parameters import TargetParameterLoraWrapper + + +class _AutogradView(torch.autograd.Function): + + @staticmethod + def forward(ctx, tensor): + return tensor.view_as(tensor) + + @staticmethod + def backward(ctx, grad): + return grad + + +class _FakeDistributedParameter: + + def __init__(self, local_parameter, global_shape): + self._local_parameter = local_parameter + self.shape = global_shape + self.dtype = local_parameter.dtype + self.device = local_parameter.device + self.device_mesh = object() + self.placements = () + + def to_local(self): + return _AutogradView.apply(self._local_parameter) + + +def _make_lora_config(rank): + return LoraConfig(r=rank, lora_alpha=rank * 2, target_modules=['linear']) + + +def test_multilora_writes_dtensor_local_autograd_view_without_tracking_gradients(): + local_parameter = nn.Parameter(torch.ones(2)) + distributed_parameter = _FakeDistributedParameter(local_parameter, global_shape=(4,)) + + MultiLora()._write_param_tensor(distributed_parameter, torch.zeros(2)) + + assert torch.count_nonzero(local_parameter) == 0 + + +def test_target_parameter_lora_writes_dtensor_local_autograd_view_without_tracking_gradients(): + local_parameter = nn.Parameter(torch.ones(2)) + distributed_parameter = _FakeDistributedParameter(local_parameter, global_shape=(4,)) + + TargetParameterLoraWrapper._write_parameter(distributed_parameter, torch.zeros(2)) + + assert torch.count_nonzero(local_parameter) == 0 + + +def test_multilora_release_keeps_tenant_when_slot_reset_fails(monkeypatch): + multi_lora = MultiLora(max_loras=1, max_r=4) + tenant = LoraTenant( + index=0, + adapter_name='lora_0', + config=_make_lora_config(4), + tenant_adapter_name='adapter_a', + tenant_config=_make_lora_config(2), + ) + multi_lora.loras = [tenant] + + def fail_reset(_adapter_name): + raise RuntimeError('reset failed') + + monkeypatch.setattr(multi_lora, '_load_initial_weights', fail_reset) + + with pytest.raises(RuntimeError, match='reset failed'): + multi_lora.release_lora('adapter_a') + + assert tenant.tenant_adapter_name == 'adapter_a' + assert tenant.tenant_config is not None + + +def test_multilora_release_reports_each_released_slot(monkeypatch): + multi_lora = MultiLora(max_loras=2, max_r=4) + slot_config = _make_lora_config(4) + tenant_config = _make_lora_config(2) + multi_lora.loras = [ + LoraTenant( + index=0, + adapter_name='lora_0', + config=slot_config, + tenant_adapter_name='adapter_a', + tenant_config=tenant_config, + ), + LoraTenant( + index=1, + adapter_name='lora_1', + config=slot_config, + tenant_adapter_name='adapter_b', + tenant_config=tenant_config, + ), + ] + monkeypatch.setattr(multi_lora, '_load_initial_weights', lambda _adapter_name: None) + monkeypatch.setattr(multi_lora.target_parameter_manager, 'release', lambda _tenant_name: None) + + assert multi_lora.release_lora('adapter_a') == 'lora_0' + assert multi_lora._count_available_loras() == 1 + assert multi_lora.release_lora('adapter_b') == 'lora_1' + assert multi_lora._count_available_loras() == 2 diff --git a/tests/model/test_multi_lora_dtype.py b/tests/model/test_multi_lora_dtype.py new file mode 100644 index 000000000..1a2a78102 --- /dev/null +++ b/tests/model/test_multi_lora_dtype.py @@ -0,0 +1,55 @@ +import sys +import torch +import types +from peft import LoraConfig +from torch import nn + + +def _ensure_dummy_zmq(): + if 'zmq' in sys.modules: + return + sys.modules['zmq'] = types.SimpleNamespace( + Context=object, + Socket=object, + RCVTIMEO=1, + SNDTIMEO=2, + LINGER=3, + ) + + +def _make_multi_lora_model(device: str): + _ensure_dummy_zmq() + from twinkle.model.multi_lora import MultiLora + + model = nn.Sequential(nn.Linear(4, 4, device=device, dtype=torch.bfloat16)) + multi_lora = MultiLora(max_loras=2, max_r=4, defer_initial_weights=True) + model = multi_lora.patch( + model, + target_modules=['0'], + lora_config=LoraConfig(r=4, lora_alpha=8, target_modules=['0']), + ) + return model + + +def _align_lora_dtype(model): + _ensure_dummy_zmq() + from twinkle.model.transformers.transformers import TransformersModel + + TransformersModel._ensure_lora_dtype(None, model) + + +def test_multi_lora_dtype_matches_bf16_base_before_fsdp_wrap(): + model = _make_multi_lora_model('cpu') + + assert {param.dtype for name, param in model.named_parameters() if 'lora_' in name} == {torch.float32} + _align_lora_dtype(model) + assert {param.dtype for name, param in model.named_parameters() if 'lora_' in name} == {torch.bfloat16} + + +def test_meta_multi_lora_dtype_matches_bf16_base_before_fsdp_wrap(): + model = _make_multi_lora_model('meta') + + assert all(param.is_meta for param in model.parameters()) + assert {param.dtype for name, param in model.named_parameters() if 'lora_' in name} == {torch.float32} + _align_lora_dtype(model) + assert {param.dtype for name, param in model.named_parameters() if 'lora_' in name} == {torch.bfloat16} diff --git a/tests/model/test_multi_lora_target_parameters.py b/tests/model/test_multi_lora_target_parameters.py index b28ef6b36..fb47e39bd 100644 --- a/tests/model/test_multi_lora_target_parameters.py +++ b/tests/model/test_multi_lora_target_parameters.py @@ -266,4 +266,4 @@ def test_multilora_transformers_installs_target_parameters_once(): assert test_target_parameter_multi_lora_updates_only_active_adapter() == True assert test_multilora_releases_target_parameter_slot_to_initial_weights() == True assert test_multilora_state_dict_round_trips_target_parameters() == True - assert test_multilora_transformers_installs_target_parameters_once() == True \ No newline at end of file + assert test_multilora_transformers_installs_target_parameters_once() == True diff --git a/tests/model/test_transformers_generate.py b/tests/model/test_transformers_generate.py new file mode 100644 index 000000000..a4ada6687 --- /dev/null +++ b/tests/model/test_transformers_generate.py @@ -0,0 +1,111 @@ +from contextlib import contextmanager +from types import SimpleNamespace +from unittest.mock import patch + +import numpy as np +import torch + +from twinkle.model.transformers.transformers import TransformersModel +from twinkle.processor import InputProcessor + + +class _Tokenizer: + pad_token_id = 0 + eos_token_id = 2 + + +class _Template: + tokenizer = _Tokenizer() + + def batch_encode(self, rows, add_generation_prompt=False): + assert add_generation_prompt + return [ + { + 'input_ids': np.array([10, 11]), + 'attention_mask': np.ones(2), + 'position_ids': np.arange(2), + 'labels': np.array([-100, -100]), + } for _ in rows + ] + + def decode(self, token_ids, **kwargs): + return ' '.join(map(str, token_ids)) + + +class _GenerateModel: + + def eval(self): + return self + + def generate(self, input_ids, **kwargs): + completion = torch.tensor([[20, 2]], device=input_ids.device).expand(input_ids.shape[0], -1) + return torch.cat([input_ids, completion], dim=-1) + + +class _Strategy: + + def __init__(self): + self.generation_events = [] + + @staticmethod + def unwrap_model(model): + return model + + @contextmanager + def generation_context(self, model): + self.generation_events.append(('enter', model)) + try: + yield + finally: + self.generation_events.append(('exit', model)) + + +def _model_wrapper(): + wrapper = object.__new__(TransformersModel) + wrapper.optimizer_group = { + 'tenant': SimpleNamespace(template=_Template(), processor=InputProcessor()), + } + wrapper.model = _GenerateModel() + wrapper.strategy = _Strategy() + wrapper.hf_config = SimpleNamespace() + wrapper._model_wrapped = True + wrapper._enable_sp = False + wrapper._lazy_wrap_model = lambda: None + wrapper._get_default_group = lambda: 'tenant' + return wrapper + + +def test_generate_encodes_trajectory_and_returns_completion_only(): + wrapper = _model_wrapper() + torch.manual_seed(1234) + rng_state = torch.get_rng_state().clone() + with patch('twinkle.processor.base.Platform.get_local_device', return_value=torch.device('cpu')): + result = TransformersModel.generate.__wrapped__( + wrapper, + inputs=[{'messages': [{'role': 'user', 'content': 'hello'}]}], + adapter_name='tenant', + generation_config={'max_new_tokens': 2}, + ) + + assert result == [{ + 'prompt_token_ids': [10, 11], + 'tokens': [20, 2], + 'text': '20 2', + 'stop_reason': 'stop', + }] + assert wrapper.strategy.generation_events == [ + ('enter', wrapper.model), + ('exit', wrapper.model), + ] + assert torch.equal(torch.get_rng_state(), rng_state) + + +def test_generated_token_ids_trims_padding_after_eos(): + tokens, stopped = TransformersModel._generated_token_ids( + torch.tensor([10, 11, 20, 2, 0, 0]), + prompt_width=2, + eos_token_ids=[2], + pad_token_id=0, + ) + assert tokens == [20, 2] + assert stopped is True diff --git a/tests/moe/test_ep_multi_lora_target_parameters.py b/tests/moe/test_ep_multi_lora_target_parameters.py index b92509d5f..8012cc87c 100644 --- a/tests/moe/test_ep_multi_lora_target_parameters.py +++ b/tests/moe/test_ep_multi_lora_target_parameters.py @@ -2,6 +2,7 @@ import sys import torch import types +from torch import nn def _ensure_dummy_zmq(): @@ -24,7 +25,165 @@ def test_ep_target_parameter_lora_gather_dim_matches_peft_flattening(): assert _ep_expert_state_dict_gather_dim("model.layers.0.mlp.experts.base_layer.lora_A.weight") == 0 assert _ep_expert_state_dict_gather_dim("model.layers.0.mlp.experts.lora_B.weight") == 1 assert _ep_expert_state_dict_gather_dim("model.layers.0.mlp.experts.base_layer.lora_B.weight") == 1 - assert _ep_expert_state_dict_gather_dim("model.layers.0.mlp.experts._twinkle_lora_gate_up_proj.lora_B.lora_0.weight") == 0 + assert _ep_expert_state_dict_gather_dim( + "model.layers.0.mlp.experts._twinkle_lora_gate_up_proj.lora_B.lora_0.weight") == 0 + + +def test_ep_3d_expert_lora_gathers_both_factors_on_expert_dim(): + _ensure_dummy_zmq() + from twinkle.model.transformers.strategy.native_fsdp import ( + _concat_ep_expert_shards, + _ep_expert_state_dict_gather_dim, + ) + + name = "model.layers.0.mlp.experts.base_layer.lora_B.lora_0.weight" + assert _ep_expert_state_dict_gather_dim(name, (8, 4096, 8), 8) == 0 + + shards = [torch.full((8, 2, 1), rank) for rank in range(4)] + full = _concat_ep_expert_shards(name, shards, {"experts_per_rank": 8, "num_experts": 32}) + assert full.shape == (32, 2, 1) + assert torch.equal(full[:, 0, 0], torch.arange(4).repeat_interleave(8)) + + +def test_ep_3d_expert_lora_load_splits_lora_b_on_expert_dim(): + _ensure_dummy_zmq() + from twinkle.model.transformers.strategy.native_fsdp import _split_for_ep_pre_distribute + + class _Box(nn.Module): + pass + + model = _Box() + model.base_model = _Box() + model.base_model.model = _Box() + model.base_model.model.model = _Box() + model.base_model.model.model.layers = nn.ModuleList([_Box()]) + mlp = _Box() + model.base_model.model.model.layers[0].mlp = mlp + mlp._ep_patched = True + mlp.experts = _Box() + mlp.experts.base_layer = _Box() + mlp.experts.base_layer.lora_B = _Box() + mlp.experts.base_layer.lora_B.lora_0 = _Box() + + key = 'base_model.model.model.layers.0.mlp.experts.base_layer.lora_B.lora_0.weight' + full = torch.arange(32).reshape(32, 1, 1).expand(32, 2, 1) + local = _split_for_ep_pre_distribute(model, key, full, ep_world_size=4, ep_rank=2) + + assert local.shape == (8, 2, 1) + assert torch.equal(local[:, 0, 0], torch.arange(16, 24)) + + +class _FakeTensorExperts(nn.Module): + + def __init__(self, *, device="cpu", dtype=torch.float32): + super().__init__() + self.gate_up_proj = nn.Parameter(torch.empty(4, 3, 8, device=device, dtype=dtype)) + self.down_proj = nn.Parameter(torch.empty(4, 4, 3, device=device, dtype=dtype)) + self.num_experts = 4 + + +def test_target_parameter_lora_slots_stay_meta_until_fsdp_materialization(): + from twinkle.model.multi_lora_target_parameters import TargetParameterLoraManager + + model = nn.Module() + model.experts = _FakeTensorExperts(device="meta") + manager = TargetParameterLoraManager(max_loras=2, max_r=4) + manager.patch(model, ["experts.gate_up_proj", "experts.down_proj"]) + + for wrapper in manager.wrappers: + assert all(param.is_meta for param in wrapper.lora_A.values()) + assert all(param.is_meta for param in wrapper.lora_B.values()) + + +def test_target_parameter_lora_defers_initial_snapshot_on_source_rank(): + from twinkle.model.multi_lora_target_parameters import TargetParameterLoraManager + + model = nn.Module() + model.experts = _FakeTensorExperts() + manager = TargetParameterLoraManager(max_loras=2, max_r=4, defer_initial_weights=True) + manager.patch(model, ["experts.gate_up_proj", "experts.down_proj"]) + + for wrapper in manager.wrappers: + assert all(not param.is_meta for param in wrapper.lora_A.values()) + assert all(torch.count_nonzero(param) == 0 for param in wrapper.lora_B.values()) + assert wrapper._initial_lora_A == {} + + manager.save_initial_weights() + + for wrapper in manager.wrappers: + assert set(wrapper._initial_lora_A) == {"lora_0", "lora_1"} + + +def test_target_parameter_lora_reuses_matching_preallocated_slots(): + from twinkle.model.multi_lora_target_parameters import TargetParameterLoraManager + + model = nn.Module() + model.experts = _FakeTensorExperts() + manager = TargetParameterLoraManager(max_loras=2, max_r=4) + targets = ["experts.gate_up_proj", "experts.down_proj"] + + manager.patch(model, targets) + wrappers = list(manager.wrappers) + manager.patch(model, targets) + + assert manager.patched_target_parameters == tuple(targets) + assert manager.wrappers == wrappers + + with pytest.raises(ValueError, match="target_parameters already patched"): + manager.patch(model, ["experts.gate_up_proj"]) + + +def test_ep_shards_target_parameter_lora_slots_on_meta(): + _ensure_dummy_zmq() + from twinkle.model.multi_lora_target_parameters import TargetParameterLoraManager + from twinkle.model.transformers.moe.expert_parallel import _shard_tensor_experts + + model = nn.Module() + model.experts = _FakeTensorExperts(device="meta") + manager = TargetParameterLoraManager(max_loras=2, max_r=4) + manager.patch(model, ["experts.gate_up_proj", "experts.down_proj"]) + + _shard_tensor_experts(model.experts, 2, 4) + + assert model.experts.gate_up_proj.shape[0] == 2 + assert model.experts.down_proj.shape[0] == 2 + for wrapper in manager.wrappers: + assert wrapper.num_experts == 2 + assert all(param.shape[0] == 2 and param.is_meta for param in wrapper.lora_A.values()) + assert all(param.shape[0] == 2 and param.is_meta for param in wrapper.lora_B.values()) + + +def test_target_parameter_slot_reset_uses_materialized_ep_local_snapshot(): + _ensure_dummy_zmq() + from peft import LoraConfig + from twinkle.model.multi_lora_target_parameters import TargetParameterLoraManager + from twinkle.model.transformers.moe.expert_parallel import _shard_tensor_experts + + torch.manual_seed(0) + model = nn.Module() + model.experts = _FakeTensorExperts() + manager = TargetParameterLoraManager(max_loras=1, max_r=4) + targets = ["experts.gate_up_proj", "experts.down_proj"] + manager.patch(model, targets) + manager.acquire( + "tenant_a", + "lora_0", + LoraConfig(r=2, lora_alpha=4, target_modules=[], target_parameters=targets), + ) + + _shard_tensor_experts(model.experts, 2, 4) + manager.save_initial_weights() + initial_a = [wrapper.lora_A["lora_0"].detach().clone() for wrapper in manager.wrappers] + + with torch.no_grad(): + for wrapper in manager.wrappers: + wrapper.lora_A["lora_0"].add_(1) + wrapper.lora_B["lora_0"].add_(1) + manager.release("tenant_a") + + for wrapper, expected_a in zip(manager.wrappers, initial_a): + assert torch.equal(wrapper.lora_A["lora_0"], expected_a) + assert torch.count_nonzero(wrapper.lora_B["lora_0"]) == 0 @pytest.mark.skipif(not torch.cuda.is_available() or torch.cuda.device_count() < 4, reason="Need 4 GPUs") diff --git a/tests/server/config/test_server_config.py b/tests/server/config/test_server_config.py index 93644cbef..ca62716d0 100644 --- a/tests/server/config/test_server_config.py +++ b/tests/server/config/test_server_config.py @@ -240,6 +240,7 @@ def test_cookbook_examples_load() -> None: here = Path(__file__).resolve().parents[3] examples = [ here / 'cookbook' / 'client' / 'server' / 'transformer' / 'server_config.yaml', + here / 'cookbook' / 'client' / 'server' / 'transformer' / 'server_config_dsv4_0731.yaml', here / 'cookbook' / 'client' / 'server' / 'megatron' / 'server_config.yaml', here / 'cookbook' / 'client' / 'server' / 'megatron' / 'server_config_4b.yaml', ] diff --git a/tests/server/integration/test_mock_mode_startup.py b/tests/server/integration/test_mock_mode_startup.py index be8610ced..f26a2e89b 100644 --- a/tests/server/integration/test_mock_mode_startup.py +++ b/tests/server/integration/test_mock_mode_startup.py @@ -240,6 +240,8 @@ def _exercise_twinkle_clients(base: str) -> None: assert fwd.result is not None fwd_only = model.forward_only(inputs) assert fwd_only.result is not None + generated = model.generate(inputs, generation_config={'max_new_tokens': 3}) + assert generated.result[0].text == 'mock completion' fwd_bwd = model.forward_backward(inputs) assert fwd_bwd.result is not None diff --git a/tests/server/model/test_mock_model.py b/tests/server/model/test_mock_model.py index 5c24450f4..2d26419a2 100644 --- a/tests/server/model/test_mock_model.py +++ b/tests/server/model/test_mock_model.py @@ -29,6 +29,7 @@ 'tinker_calculate_metric', 'tinker_load', 'forward_only', + 'generate', 'forward_backward', 'forward', 'calculate_loss', @@ -88,6 +89,16 @@ def test_forward_only_deterministic_and_shaped(seq_lens: list, seed: int) -> Non assert len(record['elementwise_loss']) == n +def test_generate_returns_json_safe_completion() -> None: + model = TwinkleCompatMockModel('mid') + assert model.generate(inputs=[{'input_ids': [10, 11]}]) == [{ + 'prompt_token_ids': [10, 11], + 'tokens': [1, 2, 3], + 'text': 'mock completion', + 'stop_reason': 'length', + }] + + @settings(max_examples=100) @given(seq_lens=st.lists(st.integers(min_value=1, max_value=8), min_size=1, max_size=4)) def test_tinker_forward_backward_loss_is_finite(seq_lens: list) -> None: diff --git a/tests/transformers/test_native_fsdp_node_topology.py b/tests/transformers/test_native_fsdp_node_topology.py new file mode 100644 index 000000000..b22f9654b --- /dev/null +++ b/tests/transformers/test_native_fsdp_node_topology.py @@ -0,0 +1,58 @@ +import torch.distributed as dist + +from twinkle.model.transformers.strategy.native_fsdp import ( + NativeFSDPStrategy, + _get_local_rank_info, +) + + +_TOPOLOGY_ENV_NAMES = ( + 'TWINKLE_NODE_LOCAL_RANK', + 'TWINKLE_NODE_LOCAL_WORLD_SIZE', + 'TWINKLE_NODE_RANKS', + 'LOCAL_RANK', + 'LOCAL_WORLD_SIZE', + 'LOCAL_SIZE', +) + + +def _clear_topology_env(monkeypatch): + for name in _TOPOLOGY_ENV_NAMES: + monkeypatch.delenv(name, raising=False) + + +def test_native_fsdp_uses_ray_node_rank_instead_of_actor_device_index(monkeypatch): + _clear_topology_env(monkeypatch) + monkeypatch.setenv('LOCAL_RANK', '0') + monkeypatch.setenv('TWINKLE_NODE_LOCAL_RANK', '1') + monkeypatch.setenv('TWINKLE_NODE_LOCAL_WORLD_SIZE', '2') + monkeypatch.setenv('TWINKLE_NODE_RANKS', '0,1') + monkeypatch.setattr(dist, 'get_rank', lambda: 1) + monkeypatch.setattr(dist, 'get_world_size', lambda: 2) + + strategy = NativeFSDPStrategy(device_mesh=None, memory_efficient_init=True) + + assert strategy.is_node_local_source_rank() is False + assert _get_local_rank_info() == (1, 2, 0, [0, 1]) + + +def test_native_fsdp_accepts_explicit_noncontiguous_node_ranks(monkeypatch): + _clear_topology_env(monkeypatch) + monkeypatch.setenv('LOCAL_RANK', '0') + monkeypatch.setenv('TWINKLE_NODE_LOCAL_RANK', '1') + monkeypatch.setenv('TWINKLE_NODE_LOCAL_WORLD_SIZE', '2') + monkeypatch.setenv('TWINKLE_NODE_RANKS', '0,2') + monkeypatch.setattr(dist, 'get_rank', lambda: 2) + monkeypatch.setattr(dist, 'get_world_size', lambda: 3) + + assert _get_local_rank_info() == (2, 3, 0, [0, 2]) + + +def test_native_fsdp_keeps_torchrun_local_rank_fallback(monkeypatch): + _clear_topology_env(monkeypatch) + monkeypatch.setenv('LOCAL_RANK', '1') + monkeypatch.setenv('LOCAL_WORLD_SIZE', '2') + monkeypatch.setattr(dist, 'get_rank', lambda: 1) + monkeypatch.setattr(dist, 'get_world_size', lambda: 2) + + assert _get_local_rank_info() == (1, 2, 0, [0, 1]) diff --git a/tests/transformers/test_native_fsdp_single_device.py b/tests/transformers/test_native_fsdp_single_device.py new file mode 100644 index 000000000..67ea1c07b --- /dev/null +++ b/tests/transformers/test_native_fsdp_single_device.py @@ -0,0 +1,79 @@ +import numpy as np +import pytest +import torch +from torch import nn + +from twinkle import DeviceMesh +from twinkle.model.transformers.strategy.native_fsdp import NativeFSDPStrategy +from twinkle.utils import Platform + + +class _DeviceTrackingModel(nn.Module): + + def __init__(self): + super().__init__() + self.linear = nn.Linear(2, 2) + self.to_devices = [] + + def to(self, *args, **kwargs): + device = args[0] if args else kwargs.get('device') + self.to_devices.append(torch.device(device)) + return super().to(*args, **kwargs) + + +class _FSDPRoot: + + def __init__(self): + self.events = [] + + def unshard(self): + self.events.append('unshard') + + def reshard(self): + self.events.append('reshard') + + +def test_native_fsdp_singleton_mesh_places_model_on_local_device(monkeypatch): + monkeypatch.setattr(Platform, 'get_local_device', lambda: 'cpu') + device_mesh = DeviceMesh( + device_type='cuda', + mesh=np.array([0]), + mesh_dim_names=('fsdp', ), + ) + strategy = NativeFSDPStrategy( + device_mesh=device_mesh, + memory_efficient_init=True, + enable_ep=False, + ) + model = _DeviceTrackingModel() + optimizer = torch.optim.SGD(model.parameters(), lr=0.1) + original_optimizer_params = list(optimizer.param_groups[0]['params']) + + wrapped_model, wrapped_optimizer = strategy.wrap_model(model, optimizer) + + assert wrapped_model is model + assert wrapped_optimizer is optimizer + assert model.to_devices == [torch.device('cpu')] + assert all(param.device.type == 'cpu' for param in model.parameters()) + assert list(optimizer.param_groups[0]['params']) == original_optimizer_params + + +def test_native_fsdp_generation_context_unshards_and_restores_root(): + strategy = NativeFSDPStrategy(device_mesh=None, enable_ep=False) + model = _FSDPRoot() + + with strategy.generation_context(model): + model.events.append('generate') + + assert model.events == ['unshard', 'generate', 'reshard'] + + +def test_native_fsdp_generation_context_reshards_after_failure(): + strategy = NativeFSDPStrategy(device_mesh=None, enable_ep=False) + model = _FSDPRoot() + + with pytest.raises(RuntimeError, match='generation failed'): + with strategy.generation_context(model): + raise RuntimeError('generation failed') + + assert model.events == ['unshard', 'reshard']