Repository navigation
feat: add Qwen Flash Next training and compact 256K recipes #1731
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 23 commits
f9cd2e8
eb9804b
d75a763
cf474a7
b590a82
587d7c5
84a116e
4f74684
940d6a7
81c3d78
7d98a51
3426e93
64093ad
c85d029
fc9b258
7f1d254
6052ad3
4cfff49
5abc9cc
b0c3ff4
a926944
ab5422b
097e146
8ac2497
b777e82
249b5b1
7f30178
f9e0639
eec0ef0
d691d0d
f5861c2
ed0daca
a848768
abe292c
5713996
3ef85e5
105a6d6
2a639f4
d0a505f
85b55bb
6ea0b8f
e11bf3d
562c01c
8567037
3a46db4
d081449
dddaa87
8887ee0
09e431e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,15 +1,32 @@ | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| import os | ||
| from pathlib import Path | ||
|
|
||
| from datasets import load_dataset | ||
|
|
||
|
|
||
| def _rank_local_cache_dir(cache_dir: str | None) -> str | None: | ||
| if cache_dir is None: | ||
| return None | ||
| return str(Path(cache_dir) / f"rank-{os.getenv('RANK', '0')}") | ||
|
|
||
|
|
||
| def get_gsm8k_sft_dataset( | ||
| path: str, | ||
| split: str, | ||
| tokenizer, | ||
| max_length: int | None = None, | ||
| cache_dir: str | None = None, | ||
| **_: object, | ||
| ): | ||
| dataset = load_dataset(path=path, name="main", split=split) | ||
| dataset = load_dataset( | ||
| path=path, | ||
| name="main", | ||
| split=split, | ||
| cache_dir=_rank_local_cache_dir(cache_dir), | ||
| keep_in_memory=True, | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This changes the default for every GSM8K caller by forcing the dataset, map, and filter results into memory, even when cache_dir is not configured. The new Qwen RL recipe does not set cache_dir, and the added test only exercises the SFT loader with an explicit cache path, so the global default behavior and RL path are untested. Why is this dataset-wide change part of the model integration? |
||
| ) | ||
|
|
||
| def process(sample): | ||
| seq_token = tokenizer.encode( | ||
|
|
@@ -19,11 +36,15 @@ def process(sample): | |
| loss_mask = [0] * len(prompt_token) + [1] * (len(seq_token) - len(prompt_token)) | ||
| return {"input_ids": seq_token, "loss_mask": loss_mask} | ||
|
|
||
| dataset = dataset.map(process).remove_columns(["question", "answer"]) | ||
| dataset = dataset.map(process, keep_in_memory=True).remove_columns( | ||
| ["question", "answer"] | ||
| ) | ||
|
|
||
| if max_length is not None: | ||
| # Filter out sequences longer than max_length | ||
| dataset = dataset.filter(lambda x: len(x["input_ids"]) <= max_length) | ||
| dataset = dataset.filter( | ||
| lambda x: len(x["input_ids"]) <= max_length, keep_in_memory=True | ||
| ) | ||
|
|
||
| return dataset | ||
|
|
||
|
|
@@ -33,8 +54,16 @@ def get_gsm8k_rl_dataset( | |
| split: str, | ||
| tokenizer, | ||
| max_length: int | None = None, | ||
| cache_dir: str | None = None, | ||
| **_: object, | ||
| ): | ||
| dataset = load_dataset(path=path, name="main", split=split) | ||
| dataset = load_dataset( | ||
| path=path, | ||
| name="main", | ||
| split=split, | ||
| cache_dir=_rank_local_cache_dir(cache_dir), | ||
| keep_in_memory=True, | ||
| ) | ||
|
|
||
| def process(sample): | ||
| messages = [ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -28,11 +28,13 @@ | |
|
|
||
| from __future__ import annotations | ||
|
|
||
| import os | ||
| from typing import Any | ||
|
|
||
| import torch | ||
|
|
||
| from areal.engine.awex.memory_saver import patch_tms_hook_mode | ||
| from areal.engine.awex.metadata import serialize_metadata_gc | ||
|
|
||
| # Must run before any awex import: awex.models.registry auto-imports model | ||
| # modules at module load, and the BailingMoe module's transitive megatron import | ||
|
|
@@ -60,13 +62,25 @@ def __init__(self, *args, model: torch.nn.Module, **kwargs): | |
| self._model_device = device | ||
| super().__init__(*args, model=model, **kwargs) | ||
|
|
||
| def _init_reader_in_colocate_mode(self): | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This overrides the AWEX colocate transport for every existing model, not only Qwen4Exp, and defaults expert packing on. The pinned AWEX 0.8.1 already has an AWEX_CHUNK_MB bounded-memory path, while the added repository tests mock P2P and cannot regression-test real NCCL for existing models. Why does a Qwen recipe PR need to replace the generic transport instead of scoping or reusing the upstream path? |
||
| from areal.engine.awex.colocate_transport import ( | ||
| _BoundedMemoryNcclColocateStreamBatchTransport, | ||
| ) | ||
|
|
||
| super()._init_reader_in_colocate_mode() | ||
| self.colocate_transport = _BoundedMemoryNcclColocateStreamBatchTransport( | ||
| self.transfer_rank, self.infer_world_size | ||
| ) | ||
|
Comment on lines
+65
to
+73
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could we move the shared transport changes into a separate PR? This replaces the transport for existing AWEX colocated models too, and changes buffer lifetimes and CUDA stream synchronization beyond the new model integration. Could we also add a regression test covering multiple peers and consecutive weight updates, checking received weight values rather than only successful completion? |
||
|
|
||
| def _set_device(self) -> None: | ||
| # TODO(agent): This adapter is CUDA-only; physical ids remain metadata | ||
| # identities and must not be used as CUDA_VISIBLE_DEVICES indices. | ||
| torch.cuda.set_device(self._model_device) | ||
| self.barrier_device = self._model_device.index | ||
| self.backend = "nccl" | ||
| self.ready_tensor = torch.tensor(1, device=self._model_device) | ||
| self.ready_tensor = torch.tensor( | ||
| 1, dtype=torch.int64, device=self._model_device | ||
| ) | ||
| logger.info( | ||
| "Bound AWEX reader rank %s to model device %s", | ||
| self.transfer_rank, | ||
|
|
@@ -228,6 +242,7 @@ def _select_rank0(raw_meta_list): | |
| def get_model_arch_name(self) -> str: | ||
| return self._model_arch_name | ||
|
|
||
| @serialize_metadata_gc | ||
| def get_parameters_meta(self): | ||
| return self._build_params_meta() | ||
|
|
||
|
|
@@ -267,6 +282,9 @@ def __init__(self, scheduler: Any): | |
| self._infer_params_meta = None | ||
| self._infer_conf: dict | None = None | ||
| self._initialized = False | ||
| self._qwen4_frozen_contract = None | ||
| self._qwen4_frozen_binder = None | ||
| self._qwen4_parameter_identity = None | ||
|
|
||
| # ── model / context helpers ─────────────────────────────────────── | ||
|
|
||
|
|
@@ -289,16 +307,10 @@ def _build_model_context(self) -> dict[str, Any]: | |
| pp_size = int(getattr(server_args, "pp_size", 1)) | ||
| dp_size = int(getattr(server_args, "dp_size", 1)) | ||
|
|
||
| from areal.engine.sglang_fork_contract import resolve_scheduler_parallel_attr | ||
|
|
||
| def rank_attr(name: str) -> int | None: | ||
| for obj in ( | ||
| scheduler, | ||
| getattr(scheduler, "ps", None), | ||
| getattr(scheduler, "tp_worker", None), | ||
| ): | ||
| value = getattr(obj, name, None) if obj is not None else None | ||
| if value is not None: | ||
| return int(value) | ||
| return None | ||
| return resolve_scheduler_parallel_attr(scheduler, name) | ||
|
|
||
| tp_rank = rank_attr("tp_rank") | ||
| if tp_rank is None and self._instance_local_rank is not None: | ||
|
|
@@ -418,12 +430,45 @@ def _build_instance_params_meta(self): | |
| ) | ||
| return resolver.get_parameters_meta() | ||
|
|
||
| def _bind_qwen4_frozen_contract(self, timeout_s: float) -> None: | ||
| from pathlib import Path | ||
|
|
||
| from sglang.srt.managers.scheduler_components import weight_updater | ||
|
|
||
| from areal.models.mcore.qwen4_exp_awex import register_qwen4_exp_awex | ||
| from areal.models.mcore.qwen4_exp_awex_binding import SglangFrozenBinder | ||
| from areal.models.mcore.qwen4_exp_awex_contract import load_frozen_contract | ||
|
|
||
| manifest = os.environ.get("QWEN_AWEX_FROZEN_CONTRACT") | ||
| if not manifest: | ||
| raise ValueError("Qwen4Exp AWEX requires QWEN_AWEX_FROZEN_CONTRACT") | ||
| contract = load_frozen_contract( | ||
| Path(manifest), Path(self._scheduler.server_args.model_path) | ||
| ) | ||
| train_info = self._meta_server_client.get_object( | ||
| "awex_train_info", timeout=timeout_s | ||
| ) | ||
| if ( | ||
| train_info.get("train_world_size") != self._train_world_size | ||
| or train_info.get("qwen4_exp_frozen_contract") != contract.to_dict() | ||
| ): | ||
| raise ValueError("Training and inference frozen contracts differ") | ||
| binder = SglangFrozenBinder(self._get_model, contract, weight_updater) | ||
| register_qwen4_exp_awex(sglang_binder=binder) | ||
| self._qwen4_frozen_contract = contract | ||
| self._qwen4_frozen_binder = binder | ||
|
|
||
| def get_weight_metadata(self): | ||
| """Inference-side parameters_meta for ONE engine instance.""" | ||
| if self._engine_rank is None: | ||
| raise RuntimeError( | ||
| "AwexColocateReader must be initialized before getting weight metadata" | ||
| ) | ||
| if ( | ||
| type(self._get_model()).__name__ == "Qwen4ExpForConditionalGeneration" | ||
| and self._qwen4_frozen_binder is None | ||
| ): | ||
| raise RuntimeError("Qwen4Exp metadata requires matching frozen contracts") | ||
| if self._infer_params_meta is None: | ||
| self._infer_params_meta = self._build_instance_params_meta() | ||
| return self._infer_params_meta | ||
|
|
@@ -487,6 +532,8 @@ def initialize( | |
|
|
||
| host, port = meta_server_addr.rsplit(":", 1) | ||
| self._meta_server_client = MetaServerClient(host, int(port)) | ||
| if type(self._get_model()).__name__ == "Qwen4ExpForConditionalGeneration": | ||
| self._bind_qwen4_frozen_contract(timeout_s) | ||
|
|
||
| # Compute single-instance parameters_meta (also reused as the native | ||
| # reader's constructor arg later). | ||
|
|
@@ -511,6 +558,10 @@ def initialize( | |
| # awex. | ||
| "router_dtype": _get_router_dtype(awex_hf_config), | ||
| } | ||
| if self._qwen4_frozen_contract is not None: | ||
| infer_conf["qwen4_exp_frozen_contract"] = ( | ||
| self._qwen4_frozen_contract.to_dict() | ||
| ) | ||
| self._infer_conf = infer_conf | ||
|
|
||
| # Only one rank publishes the engine-instance-wide info the writer waits | ||
|
|
@@ -569,6 +620,11 @@ def _ensure_reader(self) -> NCCLWorkerWeightsReader: | |
| reader.meta_server_client, self._local_gpu_id | ||
| ) | ||
| reader.initialize() | ||
| if self._qwen4_frozen_contract is not None: | ||
| self._qwen4_parameter_identity = tuple( | ||
| (name, id(parameter)) | ||
| for name, parameter in self._get_model().named_parameters() | ||
| ) | ||
| self._reader = reader | ||
| logger.info( | ||
| "Constructed native NCCLWorkerWeightsReader (transfer_rank=%d, " | ||
|
|
@@ -591,6 +647,16 @@ def update_weights(self, version: int) -> None: | |
| if not self._initialized: | ||
| raise RuntimeError("AwexColocateReader not initialized") | ||
| reader = self._ensure_reader() | ||
| if self._qwen4_frozen_contract is not None: | ||
| identity = tuple( | ||
| (name, id(parameter)) | ||
| for name, parameter in self._get_model().named_parameters() | ||
| ) | ||
| if identity != self._qwen4_parameter_identity: | ||
| raise RuntimeError( | ||
| "Qwen4Exp Parameters changed; reconstruct the AWEX reader after recovery" | ||
| ) | ||
| reader.weight_converter.refresh_frozen_contract() | ||
| reader.update_weights(step_id=version) | ||
| self._rebuild_derived_weights() | ||
| logger.info("Colocate weight update completed: version=%d", version) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
reward_normalization_use_stdchanges the relative scale of training signals across prompt groups and is not specific to Qwen Flash Next. Could we move it to a separate PR with the intended use case and tests, keeping this PR focused on model integration?