Files
2026-07-13 13:18:33 +08:00

284 lines
12 KiB
Python

# Copyright (c) Microsoft Corporation.
# SPDX-License-Identifier: Apache-2.0
# DeepSpeed Team
"""Qwen3 + DeepSpeed ZeRO-3 benchmark for the SDMA allgather feature.
Loads a Qwen3 model with random initialisation under `deepspeed.zero.Init`
so each rank only allocates its 1/world_size shard, then runs a small number
of training steps on either real wikitext or synthetic random tokens. Step
time is measured rank-0 side and reported with peak memory and the average
loss. The same trainer is used for the SDMA-on and SDMA-off comparison runs
in run_qwen3_sdma_{on,off}.sh.
The SDMA fast-path is opt-in via a single env var: ``deepspeed.comm``
brings up the mori SDMA backend at init time when ``DS_SDMA_ALLGATHER=1``
and routes WORLD-group ``all_gather_into_tensor`` through
``mori_cpp.AllGatherIntoTensor`` on AMD MI300. No ``ds_config`` flag is
required. Leaving ``DS_SDMA_ALLGATHER`` unset (the default) reproduces
the RCCL/NCCL baseline for A/B comparisons even with mori installed.
Real-data path uses HuggingFace `datasets` to stream wikitext-103 and the
model's own tokenizer to pad/truncate to seq_length. No external benchmark
repo is required.
"""
import argparse
import os
import time
import deepspeed
import torch
from deepspeed import comm as dist
from deepspeed.accelerator import get_accelerator
from torch.utils.data import DataLoader, Dataset
from torch.utils.data.distributed import DistributedSampler
from transformers import AutoConfig, AutoModelForCausalLM, AutoTokenizer
def parse_args():
p = argparse.ArgumentParser()
p.add_argument("--model_name", default="Qwen/Qwen3-32B")
p.add_argument("--num_layers",
type=int,
default=0,
help="0 = use model default; smaller values for quick smoke runs")
p.add_argument("--seq_length", type=int, default=1024)
p.add_argument("--batch_size", type=int, default=1)
p.add_argument("--num_steps", type=int, default=50)
p.add_argument("--warmup_steps", type=int, default=10)
p.add_argument("--log_interval", type=int, default=10)
p.add_argument("--ds_config", required=True)
p.add_argument("--dataset",
default="wikitext",
choices=["wikitext", "synthetic"],
help="Real text (wikitext-103) or pre-generated random ids")
p.add_argument("--dataset_percentage",
type=float,
default=10.0,
help="Percentage of wikitext train split to load (1.0-100.0)")
p.add_argument("--local_rank", type=int, default=-1)
return p.parse_args()
# ---------------------------------------------------------------------------
# Self-contained data pipeline (no external benchmark repo dependency).
# ---------------------------------------------------------------------------
class _SyntheticDataset(Dataset):
"""Pre-generated random token ids for deterministic timing runs."""
def __init__(self, vocab_size, seq_length, num_samples=10000, seed=42):
gen = torch.Generator().manual_seed(seed)
self.input_ids = torch.randint(0, vocab_size, (num_samples, seq_length), generator=gen, dtype=torch.long)
self.seq_length = seq_length
def __len__(self):
return self.input_ids.shape[0]
def __getitem__(self, idx):
ids = self.input_ids[idx]
return {
"input_ids": ids,
"labels": ids.clone(),
"attention_mask": torch.ones(self.seq_length, dtype=torch.long),
}
def _build_wikitext_loader(model_name, seq_length, batch_size, dataset_percentage, rank, world_size, is_main):
"""Stream wikitext-103-raw-v1 as a concatenated token stream sliced into
fixed `seq_length` chunks.
This is the standard "group_texts" / GPT-style chunking pattern: every
sample is exactly seq_length REAL tokens with no padding and no per-row
boundaries. Result is uniform-difficulty samples, so the per-step loss
has no variance from "this row was 90 % padding" effects — which is what
made the per-row+padding variant of this loader jittery on bs=1 demos.
"""
from datasets import DownloadConfig, load_dataset
from datasets.utils.logging import disable_progress_bar
if not is_main:
disable_progress_bar()
fraction = max(1, int(dataset_percentage))
split = "train" if dataset_percentage >= 100 else f"train[:{fraction}%]"
if is_main:
print(f"[trainer] loading wikitext-103-raw-v1 split={split}")
raw = load_dataset("wikitext",
"wikitext-103-raw-v1",
split=split,
download_config=DownloadConfig(disable_tqdm=True))
tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True)
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token or tokenizer.convert_ids_to_tokens(2)
if is_main:
print(f"[trainer] encoding {len(raw)} rows as a single stream ...")
text = "\n\n".join(t for t in raw["text"] if t.strip())
all_ids = tokenizer.encode(text, add_special_tokens=False)
# Optional cap on number of chunks (env var) so the per-epoch length can
# be tuned for short demos. 0 = use all available chunks.
max_chunks = int(os.environ.get("QWEN3_MAX_CHUNKS", "0"))
n_full = len(all_ids) // seq_length
if max_chunks > 0:
n_full = min(n_full, max_chunks)
chunks = torch.tensor(all_ids[:n_full * seq_length], dtype=torch.long).view(n_full, seq_length)
if is_main:
print(f"[trainer] chunked: {len(all_ids)} tokens -> {n_full} "
f"sequences of {seq_length} (no padding)",
flush=True)
class _ChunkDataset(Dataset):
def __init__(self, t):
self.t = t
def __len__(self):
return self.t.shape[0]
def __getitem__(self, idx):
ids = self.t[idx]
return {
"input_ids": ids,
"labels": ids.clone(),
"attention_mask": torch.ones(ids.shape[0], dtype=torch.long),
}
ds = _ChunkDataset(chunks)
sampler = DistributedSampler(ds, num_replicas=world_size, rank=rank)
return DataLoader(ds, batch_size=batch_size, sampler=sampler, num_workers=0, drop_last=True, pin_memory=True)
def _build_loader(args, vocab_size, rank, world_size, is_main):
if args.dataset == "wikitext":
return _build_wikitext_loader(args.model_name, args.seq_length, args.batch_size, args.dataset_percentage, rank,
world_size, is_main)
ds = _SyntheticDataset(vocab_size, args.seq_length)
return DataLoader(ds, batch_size=args.batch_size, shuffle=False, drop_last=True, num_workers=0, pin_memory=True)
# ---------------------------------------------------------------------------
# Model construction under deepspeed.zero.Init so each rank only materialises
# its shard.
# ---------------------------------------------------------------------------
def build_model(model_name, num_layers, ds_config_path):
cfg = AutoConfig.from_pretrained(model_name, trust_remote_code=True)
if num_layers > 0:
cfg.num_hidden_layers = num_layers
cfg.torch_dtype = torch.bfloat16
cfg.use_cache = False
cfg.attn_implementation = "eager" # FA2 not always available on AMD; eager is safe.
if dist.is_initialized() and dist.get_rank() == 0:
print(f"[trainer] {model_name}: layers={cfg.num_hidden_layers} "
f"hidden={cfg.hidden_size} heads={cfg.num_attention_heads} "
f"kv_heads={cfg.num_key_value_heads} vocab={cfg.vocab_size}")
with deepspeed.zero.Init(config_dict_or_path=ds_config_path):
model = AutoModelForCausalLM.from_config(cfg, trust_remote_code=True)
return model, cfg
def main():
args = parse_args()
deepspeed.init_distributed()
rank = dist.get_rank()
world = dist.get_world_size()
accel = get_accelerator()
device_idx = args.local_rank if args.local_rank >= 0 else rank % accel.device_count()
device = torch.device(accel.device_name(device_idx))
accel.set_device(device_idx)
if rank == 0:
print(f"[trainer] world={world} device={device} ds_config={args.ds_config}")
model, cfg = build_model(args.model_name, args.num_layers, args.ds_config)
engine, _, _, _ = deepspeed.initialize(
args=args,
model=model,
model_parameters=[p for p in model.parameters() if p.requires_grad],
config=args.ds_config,
)
if rank == 0:
from deepspeed.comm import mori as _mori
print(f"[trainer] SDMA handle is_enabled={_mori.is_enabled()}", flush=True)
loader = _build_loader(args, cfg.vocab_size, rank, world, rank == 0)
if rank == 0:
print(f"[trainer] dataloader: {len(loader)} batches/epoch, "
f"running {args.num_steps} steps", flush=True)
step_times, losses = [], []
get_accelerator().reset_peak_memory_stats()
t_train_start = time.perf_counter()
step, epoch = 0, 0
data_iter = iter(loader)
skipped_empty = 0
while step < args.num_steps:
try:
batch = next(data_iter)
except StopIteration:
epoch += 1
if hasattr(loader.sampler, "set_epoch"):
loader.sampler.set_epoch(epoch)
data_iter = iter(loader)
batch = next(data_iter)
ids = batch["input_ids"].to(device, non_blocking=True)
labels = batch["labels"].to(device, non_blocking=True)
attn = batch["attention_mask"].to(device, non_blocking=True)
# Defensive: on the chunked wikitext loader every chunk is full of
# real tokens so these guards are no-ops, but they keep the trainer
# safe for the synthetic mode and any future padded variants.
if int(attn.sum().item()) == 0:
skipped_empty += 1
continue
labels = labels.masked_fill(attn == 0, -100)
get_accelerator().synchronize()
t0 = time.perf_counter()
out = engine(input_ids=ids, labels=labels, attention_mask=attn)
engine.backward(out.loss)
engine.step()
get_accelerator().synchronize()
dt = time.perf_counter() - t0
if step >= args.warmup_steps:
step_times.append(dt)
losses.append(out.loss.detach().item())
if rank == 0 and step % args.log_interval == 0:
tag = "warmup" if step < args.warmup_steps else "measured"
tps = args.batch_size * args.seq_length * world / dt
print(
f"[trainer] step {step:4d} ({tag:7s}) | loss {out.loss.item():8.4f} | "
f"step {dt*1000:7.1f} ms | {tps:8.0f} tok/s",
flush=True)
step += 1
t_train_end = time.perf_counter()
if rank == 0:
n = len(step_times)
avg_dt = sum(step_times) / n
tokens_per_step = args.batch_size * args.seq_length * world
tps = tokens_per_step / avg_dt
peak_gb = get_accelerator().max_memory_allocated() / 1e9
avg_loss = sum(losses) / n
print()
print("=" * 70)
print("Qwen3 ZeRO-3 benchmark complete")
print(f" measured steps : {n} (warmup={args.warmup_steps} skipped)")
print(f" total wall (s) : {t_train_end - t_train_start:.1f}")
print(f" avg step (ms) : {avg_dt * 1000:.1f}")
print(f" tokens/sec (ws) : {tps:.1f}")
print(f" peak mem (GB,r0) : {peak_gb:.2f}")
print(f" avg loss : {avg_loss:.4f}")
print(f" final loss : {losses[-1]:.4f}")
print(f" empty batches : {skipped_empty}")
print("=" * 70)
if __name__ == "__main__":
main()