Text Generation
Transformers
Safetensors
English
qwen3
long-context
sparse-attention
aha
l2a-style
reproducibility
conversational
text-generation-inference
Instructions to use keepsloading/icml_repro_scratch with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use keepsloading/icml_repro_scratch with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("text-generation", model="keepsloading/icml_repro_scratch") messages = [ {"role": "user", "content": "Who are you?"}, ] pipe(messages)# pip install -U transformers accelerate # Load model directly from transformers import AutoTokenizer, AutoModelForCausalLM tokenizer = AutoTokenizer.from_pretrained("keepsloading/icml_repro_scratch") model = AutoModelForCausalLM.from_pretrained("keepsloading/icml_repro_scratch", device_map="auto") messages = [ {"role": "user", "content": "Who are you?"}, ] inputs = tokenizer.apply_chat_template( messages, add_generation_prompt=True, tokenize=True, return_dict=True, return_tensors="pt", ).to(model.device) outputs = model.generate(**inputs, max_new_tokens=256) print(tokenizer.decode(outputs[0][inputs["input_ids"].shape[-1]:])) - Notebooks
- Google Colab
- Kaggle
- Local Apps Settings
- vLLM
How to use keepsloading/icml_repro_scratch with vLLM:
Install from pip and serve model
# Install vLLM from pip: pip install vllm # Start the vLLM server: vllm serve "keepsloading/icml_repro_scratch" # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:8000/v1/chat/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "keepsloading/icml_repro_scratch", "messages": [ { "role": "user", "content": "What is the capital of France?" } ] }'Use Docker
docker model run hf.co/keepsloading/icml_repro_scratch
- SGLang
How to use keepsloading/icml_repro_scratch with SGLang:
Install from pip and serve model
# Install SGLang from pip: pip install sglang # Start the SGLang server: python3 -m sglang.launch_server \ --model-path "keepsloading/icml_repro_scratch" \ --host 0.0.0.0 \ --port 30000 # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:30000/v1/chat/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "keepsloading/icml_repro_scratch", "messages": [ { "role": "user", "content": "What is the capital of France?" } ] }'Use Docker images
docker run --gpus all \ --shm-size 32g \ -p 30000:30000 \ -v ~/.cache/huggingface:/root/.cache/huggingface \ --env "HF_TOKEN=<secret>" \ --ipc=host \ lmsysorg/sglang:latest \ python3 -m sglang.launch_server \ --model-path "keepsloading/icml_repro_scratch" \ --host 0.0.0.0 \ --port 30000 # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:30000/v1/chat/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "keepsloading/icml_repro_scratch", "messages": [ { "role": "user", "content": "What is the capital of France?" } ] }' - Docker Model Runner
How to use keepsloading/icml_repro_scratch with Docker Model Runner:
docker model run hf.co/keepsloading/icml_repro_scratch
Upload folder using huggingface_hub
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +3 -34
- .gitignore +4 -0
- README.md +123 -0
- added_tokens.json +28 -0
- chat_template.jinja +89 -0
- config.json +60 -0
- generation_config.json +13 -0
- merges.txt +0 -0
- model.safetensors +3 -0
- recipe/.pytest_cache/.gitignore +2 -0
- recipe/.pytest_cache/CACHEDIR.TAG +4 -0
- recipe/.pytest_cache/README.md +8 -0
- recipe/.pytest_cache/v/cache/nodeids +12 -0
- recipe/Dockerfile +16 -0
- recipe/data/am_distilled_long_mix/build_summary.json +18 -0
- recipe/data/am_distilled_long_mix/dataset_dict.json +1 -0
- recipe/data/am_distilled_long_mix/train/cache-e6708cbf54082730.arrow +3 -0
- recipe/data/am_distilled_long_mix/train/data-00000-of-00001.arrow +3 -0
- recipe/data/am_distilled_long_mix/train/dataset_info.json +152 -0
- recipe/data/am_distilled_long_mix/train/state.json +18 -0
- recipe/data/eval_inputs/helmet_icl_8k_n50_per_config.jsonl +0 -0
- recipe/data/eval_inputs/input_manifest.json +34 -0
- recipe/data/eval_inputs/mrcr_8k_2_4_8needle_n10_per_config.jsonl +0 -0
- recipe/duo_train.py +818 -0
- recipe/dynamic_duo_train.py +494 -0
- recipe/manifest.json +13 -0
- recipe/modeling_aha_qwen3.py +1226 -0
- recipe/protocol.json +83 -0
- recipe/requirements.txt +12 -0
- recipe/router_training_utils.py +86 -0
- recipe/scripts/eval_cell.sh +82 -0
- recipe/scripts/eval_qwen_external_longctx_pilot.py +275 -0
- recipe/scripts/eval_sweep.sh +64 -0
- recipe/scripts/evaluate.sh +20 -0
- recipe/scripts/export_tuned_vanilla_aha_hotstart.py +140 -0
- recipe/scripts/reproduce.sh +45 -0
- recipe/scripts/summarize_qwen1p7b_router_granularity_20260714.py +403 -0
- recipe/scripts/train_aha.sh +4 -0
- recipe/scripts/train_both.sh +30 -0
- recipe/scripts/train_l2a_style.sh +4 -0
- recipe/scripts/train_one.sh +127 -0
- recipe/scripts/validate_release.py +72 -0
- recipe/sft.py +642 -0
- recipe/smoke_test.py +122 -0
- recipe/source_training/all_results.json +9 -0
- recipe/source_training/train_results.json +9 -0
- recipe/source_training/trainer_state.json +0 -0
- recipe/tests/__init__.py +1 -0
- recipe/tests/test_router_granularity.py +278 -0
- special_tokens_map.json +31 -0
.gitattributes
CHANGED
|
@@ -1,35 +1,4 @@
|
|
| 1 |
-
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
-
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
-
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
-
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
-
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
-
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
-
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
-
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
-
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
-
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
-
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
-
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
-
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
-
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
-
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
-
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
-
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
-
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
-
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
-
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
-
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
-
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
-
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
-
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
-
|
| 27 |
-
*.
|
| 28 |
-
|
| 29 |
-
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
-
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
-
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
-
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
-
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
-
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
-
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.jsonl filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
tokenizer.json filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.gitignore
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
outputs/
|
| 2 |
+
.cache/
|
| 3 |
+
__pycache__/
|
| 4 |
+
*.pyc
|
README.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
language:
|
| 3 |
+
- en
|
| 4 |
+
license: apache-2.0
|
| 5 |
+
library_name: transformers
|
| 6 |
+
pipeline_tag: text-generation
|
| 7 |
+
base_model: Qwen/Qwen3-1.7B
|
| 8 |
+
tags:
|
| 9 |
+
- qwen3
|
| 10 |
+
- long-context
|
| 11 |
+
- sparse-attention
|
| 12 |
+
- aha
|
| 13 |
+
- l2a-style
|
| 14 |
+
- reproducibility
|
| 15 |
+
---
|
| 16 |
+
|
| 17 |
+
# AHA vs L2A-style shared-gate: Qwen3-1.7B reproducibility package
|
| 18 |
+
|
| 19 |
+
This repository is a self-contained package for training and evaluating two matched sparse-attention variants from the same tuned Qwen3-1.7B checkpoint. No separate source repository, starting checkpoint, or training dataset is required.
|
| 20 |
+
|
| 21 |
+
## Start here
|
| 22 |
+
|
| 23 |
+
An **arm** means one independent experimental variant. It does not refer to an ARM processor or a group of GPUs.
|
| 24 |
+
|
| 25 |
+
| Variant | Router granularity | Selected output checkpoint |
|
| 26 |
+
|---|---|---|
|
| 27 |
+
| AHA | one gate per token, KV head, and layer (`token_kv_head`) | `outputs/aha/stage2/checkpoint-25` |
|
| 28 |
+
| L2A-style shared-gate | one gate per token and layer, shared by every head (`token`) | `outputs/l2a_style/stage2/checkpoint-25` |
|
| 29 |
+
|
| 30 |
+
The L2A-style variant is not an official L2A implementation or a claim of exact L2A reproduction. Both variants use the same AHA code, local attention, losses, data, optimizer settings, and two-stage protocol. Router granularity is the only experimental variable.
|
| 31 |
+
|
| 32 |
+
The downloaded repository contains the exact tuned-vanilla starting checkpoint at its root. It does **not** contain pre-trained AHA or L2A-style output checkpoints; the training command below creates them.
|
| 33 |
+
|
| 34 |
+
## Hardware and GPU behavior
|
| 35 |
+
|
| 36 |
+
- Linux with Docker and NVIDIA Container Toolkit.
|
| 37 |
+
- At least one NVIDIA GPU with 80 GB of memory. The protocol has been run on an A100 80 GB. H100 is expected to be compatible, but this release has not completed a separate end-to-end H100 validation.
|
| 38 |
+
- At least 120 GB of free disk space is recommended for the Docker image, optimizer states, and intermediate checkpoints.
|
| 39 |
+
- Internet access is needed for the initial Docker build and for public evaluation task dependencies.
|
| 40 |
+
|
| 41 |
+
Each variant is a **single-GPU, world-size-1** run with global batch size 1:
|
| 42 |
+
|
| 43 |
+
- With one GPU, AHA and L2A-style run sequentially on GPU 0.
|
| 44 |
+
- With two or more GPUs, AHA runs on GPU 0 and L2A-style runs on GPU 1 concurrently.
|
| 45 |
+
- On an eight-GPU machine, the default launcher still uses only GPUs 0 and 1; the other six GPUs remain unused.
|
| 46 |
+
- The launcher does not use `torchrun`, DDP, FSDP, or DeepSpeed.
|
| 47 |
+
|
| 48 |
+
Using eight-way DDP for one variant would change the declared global batch size and optimization protocol, so it would no longer reproduce this experiment.
|
| 49 |
+
|
| 50 |
+
## Train both variants
|
| 51 |
+
|
| 52 |
+
```bash
|
| 53 |
+
python -m pip install 'huggingface-hub==0.36.2'
|
| 54 |
+
|
| 55 |
+
hf download jiamingshan/AHA-L2A-Qwen3-1.7B-repro \
|
| 56 |
+
--local-dir AHA-L2A-Qwen3-1.7B-repro
|
| 57 |
+
|
| 58 |
+
cd AHA-L2A-Qwen3-1.7B-repro
|
| 59 |
+
bash recipe/scripts/reproduce.sh
|
| 60 |
+
```
|
| 61 |
+
|
| 62 |
+
The host-side entry point builds the pinned Docker environment, verifies file hashes and dataset sizes, runs ten implementation tests, and then executes hot start, Stage 1, and Stage 2 for both variants. Do not run the Python training programs directly in the host environment.
|
| 63 |
+
|
| 64 |
+
The selected checkpoints are:
|
| 65 |
+
|
| 66 |
+
```text
|
| 67 |
+
outputs/aha/stage2/checkpoint-25
|
| 68 |
+
outputs/l2a_style/stage2/checkpoint-25
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
Logs are written to `outputs/aha/logs/` and `outputs/l2a_style/logs/`. The recovery stage continues through step 75 for diagnostics, but step 25 is the predeclared selected checkpoint. The launcher is restart-safe at completed checkpoint boundaries.
|
| 72 |
+
|
| 73 |
+
## Exact training protocol
|
| 74 |
+
|
| 75 |
+
Both arms start from the tuned-vanilla checkpoint at repository root. Dynamic gate weights are zero and gate bias is initialized to `logit(0.9)`, so the initial hard route is full attention.
|
| 76 |
+
|
| 77 |
+
| Stage | Data | Length / batch | Steps | Trainable | LR | Objective | Seed |
|
| 78 |
+
|---|---|---|---:|---|---|---|---:|
|
| 79 |
+
| Hot start | tuned vanilla | — | 0 | none | — | 90% full gate initialization | — |
|
| 80 |
+
| 1. Gate distillation | bundled 1,024-row AM-distilled long mix | 8,192 / 1 | 300 (0.293 epoch) | native gate rows only | `3e-5` | hidden-state distillation + `0.1 × mean(gate_soft)`, CE=0 | 42 |
|
| 81 |
+
| 2. Grouped-LR recovery | same rows | 8,192 / 1 | 75; select step 25 | gate + backbone, embedding/LM head frozen | gate `3e-6`, backbone `3e-7` | CE `1.0` + attention distill `0.5` + regularizer `0.01` | 47 |
|
| 82 |
+
|
| 83 |
+
Both arms use `64 sink + 256 recent` local attention. The Stage-2 train threshold is `0.58`. The effective sparsity denominator is always `token × KV-head × layer`, including after broadcasting the shared token gate.
|
| 84 |
+
|
| 85 |
+
Machine-readable details are in [`recipe/protocol.json`](recipe/protocol.json). Every checkpoint receives a training manifest containing the resolved granularity, native gate parameter count, data path, learning rates, threshold, seed, and source checkpoint.
|
| 86 |
+
|
| 87 |
+
## Strict evaluation
|
| 88 |
+
|
| 89 |
+
After training:
|
| 90 |
+
|
| 91 |
+
```bash
|
| 92 |
+
bash recipe/scripts/evaluate.sh
|
| 93 |
+
```
|
| 94 |
+
|
| 95 |
+
This evaluates aligned tuned vanilla, AHA, and L2A-style checkpoints on:
|
| 96 |
+
|
| 97 |
+
- local RULER 13-config set × 20 samples;
|
| 98 |
+
- BabiLong qa1–qa5 × 50;
|
| 99 |
+
- HELMET-ICL 5 configs × 50;
|
| 100 |
+
- MRCR 2/4/8-needle × 10.
|
| 101 |
+
|
| 102 |
+
It scans inference thresholds `0.45, 0.50, 0.525, 0.55, 0.575, 0.60, 0.625, 0.65`. Prefill and decode both use sparse routing. Full-decode fallback, force-full heads, head protection, and prefill-tail fallback are disabled.
|
| 103 |
+
|
| 104 |
+
The summarizer emits per-sample logs, per-config CSV, total JSON/CSV, prompt/target hash audits, and quality–sparsity plots under `outputs/summary/`. The headline point is the highest measured effective sparsity whose macro score is at least 95% of tuned vanilla on **every** suite. If no threshold passes, the output explicitly reports that no passing point was found.
|
| 105 |
+
|
| 106 |
+
The local RULER 13-config set is not claimed to be the complete upstream RULER suite. Quality–sparsity measurements are not latency measurements; this package makes no speedup claim.
|
| 107 |
+
|
| 108 |
+
## Integrity and environment
|
| 109 |
+
|
| 110 |
+
The Docker image starts from `pytorch/pytorch:2.9.1-cuda12.8-cudnn9-runtime`. Exact Python dependencies are pinned in [`recipe/requirements.txt`](recipe/requirements.txt).
|
| 111 |
+
|
| 112 |
+
Before training, the launcher automatically:
|
| 113 |
+
|
| 114 |
+
1. verifies SHA-256 for the starting weights, training Arrow file, frozen evaluation inputs, and core training sources;
|
| 115 |
+
2. checks that the bundled dataset has exactly 1,024 rows;
|
| 116 |
+
3. checks the 250 HELMET and 30 MRCR frozen rows;
|
| 117 |
+
4. runs ten tiny-config tests covering router shapes, broadcast, full/local parity, regularizer equality, gate-only updates, true 10× grouped LR, save/load, embedded modeling code, one-step training, and native/effective sparsity accounting.
|
| 118 |
+
|
| 119 |
+
The source implementation corresponds to Git commit [`b47a549`](https://github.com/shanjiaming/AHA/commit/b47a549) on `codex/l2a-router-granularity`. File hashes are recorded in [`recipe/manifest.json`](recipe/manifest.json).
|
| 120 |
+
|
| 121 |
+
## Data and model note
|
| 122 |
+
|
| 123 |
+
The starting weights are derived from `Qwen/Qwen3-1.7B`. The bundled training and evaluation snapshots are provided to freeze the exact experimental rows. Users remain responsible for complying with the terms of the respective upstream model and datasets.
|
added_tokens.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"</think>": 151668,
|
| 3 |
+
"</tool_call>": 151658,
|
| 4 |
+
"</tool_response>": 151666,
|
| 5 |
+
"<think>": 151667,
|
| 6 |
+
"<tool_call>": 151657,
|
| 7 |
+
"<tool_response>": 151665,
|
| 8 |
+
"<|box_end|>": 151649,
|
| 9 |
+
"<|box_start|>": 151648,
|
| 10 |
+
"<|endoftext|>": 151643,
|
| 11 |
+
"<|file_sep|>": 151664,
|
| 12 |
+
"<|fim_middle|>": 151660,
|
| 13 |
+
"<|fim_pad|>": 151662,
|
| 14 |
+
"<|fim_prefix|>": 151659,
|
| 15 |
+
"<|fim_suffix|>": 151661,
|
| 16 |
+
"<|im_end|>": 151645,
|
| 17 |
+
"<|im_start|>": 151644,
|
| 18 |
+
"<|image_pad|>": 151655,
|
| 19 |
+
"<|object_ref_end|>": 151647,
|
| 20 |
+
"<|object_ref_start|>": 151646,
|
| 21 |
+
"<|quad_end|>": 151651,
|
| 22 |
+
"<|quad_start|>": 151650,
|
| 23 |
+
"<|repo_name|>": 151663,
|
| 24 |
+
"<|video_pad|>": 151656,
|
| 25 |
+
"<|vision_end|>": 151653,
|
| 26 |
+
"<|vision_pad|>": 151654,
|
| 27 |
+
"<|vision_start|>": 151652
|
| 28 |
+
}
|
chat_template.jinja
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{%- if tools %}
|
| 2 |
+
{{- '<|im_start|>system\n' }}
|
| 3 |
+
{%- if messages[0].role == 'system' %}
|
| 4 |
+
{{- messages[0].content + '\n\n' }}
|
| 5 |
+
{%- endif %}
|
| 6 |
+
{{- "# Tools\n\nYou may call one or more functions to assist with the user query.\n\nYou are provided with function signatures within <tools></tools> XML tags:\n<tools>" }}
|
| 7 |
+
{%- for tool in tools %}
|
| 8 |
+
{{- "\n" }}
|
| 9 |
+
{{- tool | tojson }}
|
| 10 |
+
{%- endfor %}
|
| 11 |
+
{{- "\n</tools>\n\nFor each function call, return a json object with function name and arguments within <tool_call></tool_call> XML tags:\n<tool_call>\n{\"name\": <function-name>, \"arguments\": <args-json-object>}\n</tool_call><|im_end|>\n" }}
|
| 12 |
+
{%- else %}
|
| 13 |
+
{%- if messages[0].role == 'system' %}
|
| 14 |
+
{{- '<|im_start|>system\n' + messages[0].content + '<|im_end|>\n' }}
|
| 15 |
+
{%- endif %}
|
| 16 |
+
{%- endif %}
|
| 17 |
+
{%- set ns = namespace(multi_step_tool=true, last_query_index=messages|length - 1) %}
|
| 18 |
+
{%- for message in messages[::-1] %}
|
| 19 |
+
{%- set index = (messages|length - 1) - loop.index0 %}
|
| 20 |
+
{%- if ns.multi_step_tool and message.role == "user" and message.content is string and not(message.content.startswith('<tool_response>') and message.content.endswith('</tool_response>')) %}
|
| 21 |
+
{%- set ns.multi_step_tool = false %}
|
| 22 |
+
{%- set ns.last_query_index = index %}
|
| 23 |
+
{%- endif %}
|
| 24 |
+
{%- endfor %}
|
| 25 |
+
{%- for message in messages %}
|
| 26 |
+
{%- if message.content is string %}
|
| 27 |
+
{%- set content = message.content %}
|
| 28 |
+
{%- else %}
|
| 29 |
+
{%- set content = '' %}
|
| 30 |
+
{%- endif %}
|
| 31 |
+
{%- if (message.role == "user") or (message.role == "system" and not loop.first) %}
|
| 32 |
+
{{- '<|im_start|>' + message.role + '\n' + content + '<|im_end|>' + '\n' }}
|
| 33 |
+
{%- elif message.role == "assistant" %}
|
| 34 |
+
{%- set reasoning_content = '' %}
|
| 35 |
+
{%- if message.reasoning_content is string %}
|
| 36 |
+
{%- set reasoning_content = message.reasoning_content %}
|
| 37 |
+
{%- else %}
|
| 38 |
+
{%- if '</think>' in content %}
|
| 39 |
+
{%- set reasoning_content = content.split('</think>')[0].rstrip('\n').split('<think>')[-1].lstrip('\n') %}
|
| 40 |
+
{%- set content = content.split('</think>')[-1].lstrip('\n') %}
|
| 41 |
+
{%- endif %}
|
| 42 |
+
{%- endif %}
|
| 43 |
+
{%- if loop.index0 > ns.last_query_index %}
|
| 44 |
+
{%- if loop.last or (not loop.last and reasoning_content) %}
|
| 45 |
+
{{- '<|im_start|>' + message.role + '\n<think>\n' + reasoning_content.strip('\n') + '\n</think>\n\n' + content.lstrip('\n') }}
|
| 46 |
+
{%- else %}
|
| 47 |
+
{{- '<|im_start|>' + message.role + '\n' + content }}
|
| 48 |
+
{%- endif %}
|
| 49 |
+
{%- else %}
|
| 50 |
+
{{- '<|im_start|>' + message.role + '\n' + content }}
|
| 51 |
+
{%- endif %}
|
| 52 |
+
{%- if message.tool_calls %}
|
| 53 |
+
{%- for tool_call in message.tool_calls %}
|
| 54 |
+
{%- if (loop.first and content) or (not loop.first) %}
|
| 55 |
+
{{- '\n' }}
|
| 56 |
+
{%- endif %}
|
| 57 |
+
{%- if tool_call.function %}
|
| 58 |
+
{%- set tool_call = tool_call.function %}
|
| 59 |
+
{%- endif %}
|
| 60 |
+
{{- '<tool_call>\n{"name": "' }}
|
| 61 |
+
{{- tool_call.name }}
|
| 62 |
+
{{- '", "arguments": ' }}
|
| 63 |
+
{%- if tool_call.arguments is string %}
|
| 64 |
+
{{- tool_call.arguments }}
|
| 65 |
+
{%- else %}
|
| 66 |
+
{{- tool_call.arguments | tojson }}
|
| 67 |
+
{%- endif %}
|
| 68 |
+
{{- '}\n</tool_call>' }}
|
| 69 |
+
{%- endfor %}
|
| 70 |
+
{%- endif %}
|
| 71 |
+
{{- '<|im_end|>\n' }}
|
| 72 |
+
{%- elif message.role == "tool" %}
|
| 73 |
+
{%- if loop.first or (messages[loop.index0 - 1].role != "tool") %}
|
| 74 |
+
{{- '<|im_start|>user' }}
|
| 75 |
+
{%- endif %}
|
| 76 |
+
{{- '\n<tool_response>\n' }}
|
| 77 |
+
{{- content }}
|
| 78 |
+
{{- '\n</tool_response>' }}
|
| 79 |
+
{%- if loop.last or (messages[loop.index0 + 1].role != "tool") %}
|
| 80 |
+
{{- '<|im_end|>\n' }}
|
| 81 |
+
{%- endif %}
|
| 82 |
+
{%- endif %}
|
| 83 |
+
{%- endfor %}
|
| 84 |
+
{%- if add_generation_prompt %}
|
| 85 |
+
{{- '<|im_start|>assistant\n' }}
|
| 86 |
+
{%- if enable_thinking is defined and enable_thinking is false %}
|
| 87 |
+
{{- '<think>\n\n</think>\n\n' }}
|
| 88 |
+
{%- endif %}
|
| 89 |
+
{%- endif %}
|
config.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"architectures": [
|
| 3 |
+
"Qwen3ForCausalLM"
|
| 4 |
+
],
|
| 5 |
+
"attention_bias": false,
|
| 6 |
+
"attention_dropout": 0.0,
|
| 7 |
+
"bos_token_id": 151643,
|
| 8 |
+
"eos_token_id": 151645,
|
| 9 |
+
"head_dim": 128,
|
| 10 |
+
"hidden_act": "silu",
|
| 11 |
+
"hidden_size": 2048,
|
| 12 |
+
"initializer_range": 0.02,
|
| 13 |
+
"intermediate_size": 6144,
|
| 14 |
+
"layer_types": [
|
| 15 |
+
"full_attention",
|
| 16 |
+
"full_attention",
|
| 17 |
+
"full_attention",
|
| 18 |
+
"full_attention",
|
| 19 |
+
"full_attention",
|
| 20 |
+
"full_attention",
|
| 21 |
+
"full_attention",
|
| 22 |
+
"full_attention",
|
| 23 |
+
"full_attention",
|
| 24 |
+
"full_attention",
|
| 25 |
+
"full_attention",
|
| 26 |
+
"full_attention",
|
| 27 |
+
"full_attention",
|
| 28 |
+
"full_attention",
|
| 29 |
+
"full_attention",
|
| 30 |
+
"full_attention",
|
| 31 |
+
"full_attention",
|
| 32 |
+
"full_attention",
|
| 33 |
+
"full_attention",
|
| 34 |
+
"full_attention",
|
| 35 |
+
"full_attention",
|
| 36 |
+
"full_attention",
|
| 37 |
+
"full_attention",
|
| 38 |
+
"full_attention",
|
| 39 |
+
"full_attention",
|
| 40 |
+
"full_attention",
|
| 41 |
+
"full_attention",
|
| 42 |
+
"full_attention"
|
| 43 |
+
],
|
| 44 |
+
"max_position_embeddings": 40960,
|
| 45 |
+
"max_window_layers": 28,
|
| 46 |
+
"model_type": "qwen3",
|
| 47 |
+
"num_attention_heads": 16,
|
| 48 |
+
"num_hidden_layers": 28,
|
| 49 |
+
"num_key_value_heads": 8,
|
| 50 |
+
"rms_norm_eps": 1e-06,
|
| 51 |
+
"rope_scaling": null,
|
| 52 |
+
"rope_theta": 1000000,
|
| 53 |
+
"sliding_window": null,
|
| 54 |
+
"tie_word_embeddings": true,
|
| 55 |
+
"torch_dtype": "bfloat16",
|
| 56 |
+
"transformers_version": "4.54.0",
|
| 57 |
+
"use_cache": false,
|
| 58 |
+
"use_sliding_window": false,
|
| 59 |
+
"vocab_size": 151936
|
| 60 |
+
}
|
generation_config.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"bos_token_id": 151643,
|
| 3 |
+
"do_sample": true,
|
| 4 |
+
"eos_token_id": [
|
| 5 |
+
151645,
|
| 6 |
+
151643
|
| 7 |
+
],
|
| 8 |
+
"pad_token_id": 151643,
|
| 9 |
+
"temperature": 0.6,
|
| 10 |
+
"top_k": 20,
|
| 11 |
+
"top_p": 0.95,
|
| 12 |
+
"transformers_version": "4.54.0"
|
| 13 |
+
}
|
merges.txt
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
model.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:22c971a65f7f1835b0e2a38b45f2f92191f5d8b63b30969f2f61a69d30afbc05
|
| 3 |
+
size 3441185608
|
recipe/.pytest_cache/.gitignore
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Created by pytest automatically.
|
| 2 |
+
*
|
recipe/.pytest_cache/CACHEDIR.TAG
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Signature: 8a477f597d28d172789f06886806bc55
|
| 2 |
+
# This file is a cache directory tag created by pytest.
|
| 3 |
+
# For information about cache directory tags, see:
|
| 4 |
+
# https://bford.info/cachedir/spec.html
|
recipe/.pytest_cache/README.md
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# pytest cache directory #
|
| 2 |
+
|
| 3 |
+
This directory contains data from the pytest's cache plugin,
|
| 4 |
+
which provides the `--lf` and `--ff` options, as well as the `cache` fixture.
|
| 5 |
+
|
| 6 |
+
**Do not** commit this to version control.
|
| 7 |
+
|
| 8 |
+
See [the docs](https://docs.pytest.org/en/stable/how-to/cache.html) for more information.
|
recipe/.pytest_cache/v/cache/nodeids
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
"tests/test_router_granularity.py::RouterGranularityTest::test_auto_class_checkpoint_embeds_modeling_source",
|
| 3 |
+
"tests/test_router_granularity.py::RouterGranularityTest::test_force_closed_uses_identical_local_branch",
|
| 4 |
+
"tests/test_router_granularity.py::RouterGranularityTest::test_force_open_matches_full_attention_logits",
|
| 5 |
+
"tests/test_router_granularity.py::RouterGranularityTest::test_gate_only_updates_only_appended_rows",
|
| 6 |
+
"tests/test_router_granularity.py::RouterGranularityTest::test_legacy_default_and_save_load_roundtrip",
|
| 7 |
+
"tests/test_router_granularity.py::RouterGranularityTest::test_one_step_smoke_is_finite_and_reloadable",
|
| 8 |
+
"tests/test_router_granularity.py::RouterGranularityTest::test_projection_and_effective_gate_shapes",
|
| 9 |
+
"tests/test_router_granularity.py::RouterGranularityTest::test_regularizer_uses_effective_kv_head_denominator",
|
| 10 |
+
"tests/test_router_granularity.py::RouterGranularityTest::test_rowwise_adamw_applies_real_ten_x_lr_ratio",
|
| 11 |
+
"tests/test_router_granularity.py::RouterGranularityTest::test_sparsity_tracker_records_native_and_effective_counts"
|
| 12 |
+
]
|
recipe/Dockerfile
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM pytorch/pytorch:2.9.1-cuda12.8-cudnn9-runtime
|
| 2 |
+
|
| 3 |
+
ENV DEBIAN_FRONTEND=noninteractive \
|
| 4 |
+
PYTHONUNBUFFERED=1 \
|
| 5 |
+
TOKENIZERS_PARALLELISM=false \
|
| 6 |
+
HF_HOME=/workspace/.cache/huggingface \
|
| 7 |
+
PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True
|
| 8 |
+
|
| 9 |
+
RUN apt-get update \
|
| 10 |
+
&& apt-get install -y --no-install-recommends git ca-certificates \
|
| 11 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 12 |
+
|
| 13 |
+
COPY requirements.txt /tmp/requirements.txt
|
| 14 |
+
RUN python -m pip install --no-cache-dir -r /tmp/requirements.txt
|
| 15 |
+
|
| 16 |
+
WORKDIR /workspace/recipe
|
recipe/data/am_distilled_long_mix/build_summary.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"source_dataset": "/workspace/AHA/AHA-OLMO2/experiments/datasets/am-distilled-32768",
|
| 3 |
+
"output_dataset": "/workspace/AHA/AHA-Qwen3/experiments/datasets/am-distilled-32768-mix-long12k512-rand512",
|
| 4 |
+
"seed": 124,
|
| 5 |
+
"long_n": 512,
|
| 6 |
+
"rand_n": 512,
|
| 7 |
+
"min_long": 12288,
|
| 8 |
+
"rows": 1024,
|
| 9 |
+
"min_tokens": 369,
|
| 10 |
+
"p50": 12580,
|
| 11 |
+
"p90": 21189,
|
| 12 |
+
"p95": 24547,
|
| 13 |
+
"p99": 30902,
|
| 14 |
+
"max_tokens": 32618,
|
| 15 |
+
"mean_tokens": 10600.341796875,
|
| 16 |
+
"frac_ge_8192": 0.5517578125,
|
| 17 |
+
"frac_ge_12288": 0.5302734375
|
| 18 |
+
}
|
recipe/data/am_distilled_long_mix/dataset_dict.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
{"splits": ["train"]}
|
recipe/data/am_distilled_long_mix/train/cache-e6708cbf54082730.arrow
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:0135bf6e96f7a4f452894727b9be1ce92ff27cc65330b94faeab11e993f5aa3c
|
| 3 |
+
size 61801216
|
recipe/data/am_distilled_long_mix/train/data-00000-of-00001.arrow
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:27f158ba22512269d33e8fb14db97a84e541ca9b701fba73a98e70c73687f45c
|
| 3 |
+
size 87171000
|
recipe/data/am_distilled_long_mix/train/dataset_info.json
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"builder_name": "generator",
|
| 3 |
+
"citation": "",
|
| 4 |
+
"config_name": "default",
|
| 5 |
+
"dataset_name": "generator",
|
| 6 |
+
"dataset_size": 45895360821,
|
| 7 |
+
"description": "",
|
| 8 |
+
"download_checksums": {},
|
| 9 |
+
"download_size": 0,
|
| 10 |
+
"features": {
|
| 11 |
+
"messages": {
|
| 12 |
+
"feature": {
|
| 13 |
+
"content": {
|
| 14 |
+
"dtype": "string",
|
| 15 |
+
"_type": "Value"
|
| 16 |
+
},
|
| 17 |
+
"role": {
|
| 18 |
+
"dtype": "string",
|
| 19 |
+
"_type": "Value"
|
| 20 |
+
}
|
| 21 |
+
},
|
| 22 |
+
"_type": "List"
|
| 23 |
+
},
|
| 24 |
+
"input_ids": {
|
| 25 |
+
"feature": {
|
| 26 |
+
"dtype": "int32",
|
| 27 |
+
"_type": "Value"
|
| 28 |
+
},
|
| 29 |
+
"_type": "List"
|
| 30 |
+
},
|
| 31 |
+
"attention_mask": {
|
| 32 |
+
"feature": {
|
| 33 |
+
"dtype": "int8",
|
| 34 |
+
"_type": "Value"
|
| 35 |
+
},
|
| 36 |
+
"_type": "List"
|
| 37 |
+
},
|
| 38 |
+
"num_tokens": {
|
| 39 |
+
"dtype": "int64",
|
| 40 |
+
"_type": "Value"
|
| 41 |
+
}
|
| 42 |
+
},
|
| 43 |
+
"homepage": "",
|
| 44 |
+
"license": "",
|
| 45 |
+
"size_in_bytes": 45895360821,
|
| 46 |
+
"splits": {
|
| 47 |
+
"train": {
|
| 48 |
+
"name": "train",
|
| 49 |
+
"num_bytes": 45895360821,
|
| 50 |
+
"num_examples": 1890613,
|
| 51 |
+
"shard_lengths": [
|
| 52 |
+
15000,
|
| 53 |
+
14000,
|
| 54 |
+
14000,
|
| 55 |
+
14000,
|
| 56 |
+
14000,
|
| 57 |
+
14000,
|
| 58 |
+
14000,
|
| 59 |
+
15000,
|
| 60 |
+
14000,
|
| 61 |
+
14000,
|
| 62 |
+
14000,
|
| 63 |
+
14000,
|
| 64 |
+
14000,
|
| 65 |
+
14000,
|
| 66 |
+
14000,
|
| 67 |
+
14000,
|
| 68 |
+
14000,
|
| 69 |
+
14000,
|
| 70 |
+
14000,
|
| 71 |
+
14000,
|
| 72 |
+
14000,
|
| 73 |
+
14000,
|
| 74 |
+
14000,
|
| 75 |
+
25000,
|
| 76 |
+
26000,
|
| 77 |
+
27000,
|
| 78 |
+
26000,
|
| 79 |
+
26000,
|
| 80 |
+
25000,
|
| 81 |
+
26000,
|
| 82 |
+
25000,
|
| 83 |
+
26000,
|
| 84 |
+
25000,
|
| 85 |
+
26000,
|
| 86 |
+
25000,
|
| 87 |
+
26000,
|
| 88 |
+
26000,
|
| 89 |
+
25000,
|
| 90 |
+
25000,
|
| 91 |
+
25000,
|
| 92 |
+
24000,
|
| 93 |
+
25000,
|
| 94 |
+
24000,
|
| 95 |
+
24000,
|
| 96 |
+
24000,
|
| 97 |
+
24000,
|
| 98 |
+
23000,
|
| 99 |
+
19000,
|
| 100 |
+
18000,
|
| 101 |
+
18000,
|
| 102 |
+
18000,
|
| 103 |
+
18000,
|
| 104 |
+
18000,
|
| 105 |
+
18000,
|
| 106 |
+
18000,
|
| 107 |
+
19000,
|
| 108 |
+
22000,
|
| 109 |
+
23000,
|
| 110 |
+
23000,
|
| 111 |
+
23000,
|
| 112 |
+
23000,
|
| 113 |
+
23000,
|
| 114 |
+
23000,
|
| 115 |
+
23000,
|
| 116 |
+
23000,
|
| 117 |
+
23000,
|
| 118 |
+
23000,
|
| 119 |
+
22000,
|
| 120 |
+
22000,
|
| 121 |
+
23000,
|
| 122 |
+
22000,
|
| 123 |
+
22000,
|
| 124 |
+
22000,
|
| 125 |
+
23000,
|
| 126 |
+
22000,
|
| 127 |
+
22000,
|
| 128 |
+
22000,
|
| 129 |
+
22000,
|
| 130 |
+
23000,
|
| 131 |
+
23000,
|
| 132 |
+
23000,
|
| 133 |
+
23000,
|
| 134 |
+
22000,
|
| 135 |
+
22000,
|
| 136 |
+
28000,
|
| 137 |
+
30000,
|
| 138 |
+
30000,
|
| 139 |
+
29000,
|
| 140 |
+
30000,
|
| 141 |
+
20613
|
| 142 |
+
],
|
| 143 |
+
"dataset_name": "generator"
|
| 144 |
+
}
|
| 145 |
+
},
|
| 146 |
+
"version": {
|
| 147 |
+
"version_str": "0.0.0",
|
| 148 |
+
"major": 0,
|
| 149 |
+
"minor": 0,
|
| 150 |
+
"patch": 0
|
| 151 |
+
}
|
| 152 |
+
}
|
recipe/data/am_distilled_long_mix/train/state.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"_data_files": [
|
| 3 |
+
{
|
| 4 |
+
"filename": "data-00000-of-00001.arrow"
|
| 5 |
+
}
|
| 6 |
+
],
|
| 7 |
+
"_fingerprint": "8470c8174c1f6ae9",
|
| 8 |
+
"_format_columns": [
|
| 9 |
+
"attention_mask",
|
| 10 |
+
"input_ids",
|
| 11 |
+
"messages",
|
| 12 |
+
"num_tokens"
|
| 13 |
+
],
|
| 14 |
+
"_format_kwargs": {},
|
| 15 |
+
"_format_type": null,
|
| 16 |
+
"_output_all_columns": false,
|
| 17 |
+
"_split": null
|
| 18 |
+
}
|
recipe/data/eval_inputs/helmet_icl_8k_n50_per_config.jsonl
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
recipe/data/eval_inputs/input_manifest.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"tokenizer_path": "/data/sjm/AHA/models/Qwen3-1.7B",
|
| 3 |
+
"thinking": false,
|
| 4 |
+
"truncation": false,
|
| 5 |
+
"target_context_k": 8,
|
| 6 |
+
"helmet": {
|
| 7 |
+
"path": "/data/sjm/AHA/AHA-Qwen3/experiments/qwen3_1p7b_fourbench_8k_broad_20260713/inputs/helmet_icl_8k_n50_per_config.jsonl",
|
| 8 |
+
"source": "/data/sjm/AHA/AHA-Qwen3/experiments/qwen3_1p7b_fivebench_n100_20260712/helmet8k_rows_n100.jsonl",
|
| 9 |
+
"configs": [
|
| 10 |
+
"trec_coarse",
|
| 11 |
+
"trec_fine",
|
| 12 |
+
"banking77",
|
| 13 |
+
"clinic150",
|
| 14 |
+
"nlu"
|
| 15 |
+
],
|
| 16 |
+
"samples_per_config": 50,
|
| 17 |
+
"total_samples": 250,
|
| 18 |
+
"min_prompt_tokens": 6344,
|
| 19 |
+
"max_prompt_tokens": 7413
|
| 20 |
+
},
|
| 21 |
+
"mrcr": {
|
| 22 |
+
"path": "/data/sjm/AHA/AHA-Qwen3/experiments/qwen3_1p7b_fourbench_8k_broad_20260713/inputs/mrcr_8k_2_4_8needle_n10_per_config.jsonl",
|
| 23 |
+
"source": "/data/sjm/AHA/AHA-Qwen3/experiments/qwen3_1p7b_fourbench_broad_20260713/inputs/mrcr_8k_16k_2_4_8needle_n10_per_config.jsonl",
|
| 24 |
+
"configs": [
|
| 25 |
+
"8k_2needle",
|
| 26 |
+
"8k_4needle",
|
| 27 |
+
"8k_8needle"
|
| 28 |
+
],
|
| 29 |
+
"samples_per_config": 10,
|
| 30 |
+
"total_samples": 30,
|
| 31 |
+
"min_prompt_tokens": 4312,
|
| 32 |
+
"max_prompt_tokens": 8028
|
| 33 |
+
}
|
| 34 |
+
}
|
recipe/data/eval_inputs/mrcr_8k_2_4_8needle_n10_per_config.jsonl
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
recipe/duo_train.py
ADDED
|
@@ -0,0 +1,818 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Faithful DuoAttention-style training for Qwen3 in the AHA framework.
|
| 2 |
+
|
| 3 |
+
Reproduces the paper's training objective closely:
|
| 4 |
+
L = distill + reg_weight * L1(alpha) + ce_weight * CE(student_logits, labels)
|
| 5 |
+
distill = MSE(h_full, h_mix) on label positions
|
| 6 |
+
h_full = forward with alpha = 1 everywhere (pure global attention)
|
| 7 |
+
h_mix = forward with the currently learned alpha (blend global + streaming)
|
| 8 |
+
|
| 9 |
+
The CE anchor term defaults to 0 (paper-grade DuoAttention). It is required
|
| 10 |
+
when --unfreeze_attn_proj is set, because once the backbone is trainable the
|
| 11 |
+
distill objective degenerates: the teacher (alpha=1 forward) is rebuilt from
|
| 12 |
+
the same drifting backbone, so distill becomes self-distillation against a
|
| 13 |
+
moving target and admits collapse solutions where h_full ≡ h_mix but both
|
| 14 |
+
generate garbled tokens. CE pins the backbone to "predicting labels under
|
| 15 |
+
mix attention" and prevents that drift; see docs §9.4.4.
|
| 16 |
+
|
| 17 |
+
Data: synthetic multi-passkey retrieval on PaulGrahamEssays haystack,
|
| 18 |
+
ported from duo-attention/duo_attn/data.py::MultiplePasskeyRetrievalDataset.
|
| 19 |
+
|
| 20 |
+
Trainable params:
|
| 21 |
+
- default : 224 alpha scalars (28 layers * 8 kv_heads on Qwen3-0.6B).
|
| 22 |
+
- --unfreeze_attn_proj : alphas + q/k/v/o_proj weights of every layer
|
| 23 |
+
(Setting B; sink-ablation experiment).
|
| 24 |
+
|
| 25 |
+
Use `attn_implementation="eager"` or "sdpa" — no flash-attn dependency.
|
| 26 |
+
|
| 27 |
+
Usage (paper-grade, frozen backbone):
|
| 28 |
+
python duo_train.py \\
|
| 29 |
+
--model_path /workspace/AHA/models/Qwen3-0.6B \\
|
| 30 |
+
--haystack_dir /workspace/AHA/third_party/duo-attention/eval/needle/PaulGrahamEssays \\
|
| 31 |
+
--output_dir ckpts/duo_paper_s64_r256 \\
|
| 32 |
+
--max_length 8192 --context_length_min 2000 --context_length_max 8000 \\
|
| 33 |
+
--num_steps 800 --lr 0.02 --reg_weight 0.05 \\
|
| 34 |
+
--sink_size 64 --recent_size 256
|
| 35 |
+
|
| 36 |
+
Usage (Setting B sink ablation, unfrozen backbone + CE anchor):
|
| 37 |
+
python duo_train.py \\
|
| 38 |
+
--model_path /workspace/AHA/models/Qwen3-0.6B \\
|
| 39 |
+
--haystack_dir /workspace/AHA/third_party/duo-attention/eval/needle/PaulGrahamEssays \\
|
| 40 |
+
--output_dir ckpts/duo_sinkabl_Bv3_ce \\
|
| 41 |
+
--max_length 8192 --context_length_min 2000 --context_length_max 8000 \\
|
| 42 |
+
--num_steps 400 --lr 0.02 --reg_weight 0.1 \\
|
| 43 |
+
--sink_size 0 --recent_size 256 \\
|
| 44 |
+
--unfreeze_attn_proj --backbone_lr 1e-5 --ce_weight 1.0
|
| 45 |
+
"""
|
| 46 |
+
|
| 47 |
+
import argparse
|
| 48 |
+
import json
|
| 49 |
+
import math
|
| 50 |
+
import os
|
| 51 |
+
import random
|
| 52 |
+
import sys
|
| 53 |
+
from typing import List
|
| 54 |
+
|
| 55 |
+
import numpy as np
|
| 56 |
+
import torch
|
| 57 |
+
import torch.distributed as dist
|
| 58 |
+
from torch.nn.parallel import DistributedDataParallel as DDP
|
| 59 |
+
from torch.utils.data import Dataset, DataLoader
|
| 60 |
+
from torch.utils.data.distributed import DistributedSampler
|
| 61 |
+
from transformers import AutoTokenizer
|
| 62 |
+
|
| 63 |
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
| 64 |
+
if HERE not in sys.path:
|
| 65 |
+
sys.path.insert(0, HERE)
|
| 66 |
+
|
| 67 |
+
from modeling_aha_qwen3 import AHAQwen3ForCausalLM # noqa: E402
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
LONG_BENCH_PROMPT_TEMPLATES = {
|
| 71 |
+
"qasper": (
|
| 72 |
+
"You are given a scientific article and a question. "
|
| 73 |
+
"Answer the question as concisely as you can, using a single phrase or sentence if possible. "
|
| 74 |
+
"If the question cannot be answered based on the information in the article, write \"unanswerable\". "
|
| 75 |
+
"If the question is a yes/no question, answer \"yes\", \"no\", or \"unanswerable\". "
|
| 76 |
+
"Do not provide any explanation.\n\n"
|
| 77 |
+
"Article: {context}\n\n"
|
| 78 |
+
"Answer the question based on the above article as concisely as you can, using a single phrase or sentence if possible. "
|
| 79 |
+
"If the question cannot be answered based on the information in the article, write \"unanswerable\". "
|
| 80 |
+
"If the question is a yes/no question, answer \"yes\", \"no\", or \"unanswerable\". "
|
| 81 |
+
"Do not provide any explanation.\n\nQuestion: {question}\nAnswer:"
|
| 82 |
+
),
|
| 83 |
+
"multifieldqa_en": (
|
| 84 |
+
"Read the following text and answer briefly.\n\n"
|
| 85 |
+
"{context}\n\n"
|
| 86 |
+
"Now, answer the following question based on the above text, only give me the answer and do not output any other words.\n\n"
|
| 87 |
+
"Question: {question}\nAnswer:"
|
| 88 |
+
),
|
| 89 |
+
"2wikimqa": (
|
| 90 |
+
"Answer the question based on the given passages. Only give me the answer and do not output any other words.\n\n"
|
| 91 |
+
"The following are given passages.\n{context}\n\n"
|
| 92 |
+
"Answer the question based on the given passages. Only give me the answer and do not output any other words.\n\n"
|
| 93 |
+
"Question: {question}\nAnswer:"
|
| 94 |
+
),
|
| 95 |
+
"passage_retrieval_en": (
|
| 96 |
+
"Here are 30 paragraphs from Wikipedia, along with an abstract. "
|
| 97 |
+
"Please determine which paragraph the abstract is from.\n\n"
|
| 98 |
+
"{context}\n\n"
|
| 99 |
+
"The following is an abstract.\n\n"
|
| 100 |
+
"{question}\n\n"
|
| 101 |
+
"Please enter the number of the paragraph that the abstract is from. "
|
| 102 |
+
"The answer format must be like \"Paragraph 1\", \"Paragraph 2\", etc.\n\n"
|
| 103 |
+
"The answer is: "
|
| 104 |
+
),
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
# -----------------------------------------------------------------------------
|
| 109 |
+
# Dataset (direct port of duo_attn/data.py::MultiplePasskeyRetrievalDataset)
|
| 110 |
+
# -----------------------------------------------------------------------------
|
| 111 |
+
PASSKEY_ALPHABET = [
|
| 112 |
+
"alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel",
|
| 113 |
+
"india", "juliett", "kilo", "lima", "mike", "november", "oscar", "papa",
|
| 114 |
+
"quebec", "romeo", "sierra", "tango", "uniform", "victor", "whiskey",
|
| 115 |
+
"xray", "yankee", "zulu",
|
| 116 |
+
]
|
| 117 |
+
ORDINAL_NUMBERS = [
|
| 118 |
+
"first", "second", "third", "fourth", "fifth", "sixth", "seventh",
|
| 119 |
+
"eighth", "ninth", "tenth", "eleventh", "twelfth", "thirteenth",
|
| 120 |
+
"fourteenth", "fifteenth", "sixteenth", "seventeenth", "eighteenth",
|
| 121 |
+
"nineteenth", "twentieth",
|
| 122 |
+
]
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def _load_haystack_text(haystack_dir: str) -> str:
|
| 126 |
+
parts = []
|
| 127 |
+
for fname in sorted(os.listdir(haystack_dir)):
|
| 128 |
+
if not fname.endswith(".txt"):
|
| 129 |
+
continue
|
| 130 |
+
with open(os.path.join(haystack_dir, fname), "r", encoding="utf-8", errors="ignore") as f:
|
| 131 |
+
parts.append(f.read())
|
| 132 |
+
return "\n\n".join(parts)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
class MultiPasskeyDataset(Dataset):
|
| 136 |
+
def __init__(
|
| 137 |
+
self,
|
| 138 |
+
tokenizer,
|
| 139 |
+
haystack_text: str,
|
| 140 |
+
context_length_min: int,
|
| 141 |
+
context_length_max: int,
|
| 142 |
+
context_lengths_num_intervals: int,
|
| 143 |
+
depth_ratio_num_intervals: int,
|
| 144 |
+
min_depth_ratio: float,
|
| 145 |
+
max_depth_ratio: float,
|
| 146 |
+
num_passkeys: int,
|
| 147 |
+
passkey_length: int,
|
| 148 |
+
pad_multiple: int = 16,
|
| 149 |
+
buffer_size: int = 300,
|
| 150 |
+
needle: str = "Remember this sequence of words, it's the {ordinal_number} passkey to the vault: ",
|
| 151 |
+
retrieval_question: str = "Based on the content of the book, what is the {ordinal_number} passkey to the vault?\nPasskey: ",
|
| 152 |
+
prompt1: str = "<|im_start|> This is a very long story book: <book> ",
|
| 153 |
+
prompt2: str = " </book>.\n\n",
|
| 154 |
+
seperator: str = "\n\n",
|
| 155 |
+
):
|
| 156 |
+
self.tokenizer = tokenizer
|
| 157 |
+
self.num_passkeys = num_passkeys
|
| 158 |
+
self.passkey_length = passkey_length
|
| 159 |
+
self.pad_multiple = pad_multiple
|
| 160 |
+
|
| 161 |
+
self.context_length_intervals = torch.linspace(
|
| 162 |
+
context_length_min, context_length_max,
|
| 163 |
+
context_lengths_num_intervals, dtype=torch.int,
|
| 164 |
+
).tolist()
|
| 165 |
+
self.depth_ratio_intervals = torch.linspace(
|
| 166 |
+
min_depth_ratio, max_depth_ratio, depth_ratio_num_intervals,
|
| 167 |
+
).tolist()
|
| 168 |
+
|
| 169 |
+
self.needle_tokens_list = [
|
| 170 |
+
tokenizer.encode(
|
| 171 |
+
needle.format(ordinal_number=ord_), add_special_tokens=False
|
| 172 |
+
) for ord_ in ORDINAL_NUMBERS[:num_passkeys]
|
| 173 |
+
]
|
| 174 |
+
self.retrieval_question_tokens_list = [
|
| 175 |
+
tokenizer.encode(
|
| 176 |
+
retrieval_question.format(ordinal_number=ord_), add_special_tokens=False
|
| 177 |
+
) for ord_ in ORDINAL_NUMBERS[:num_passkeys]
|
| 178 |
+
]
|
| 179 |
+
|
| 180 |
+
self.haystack_tokens = tokenizer.encode(haystack_text, add_special_tokens=False)
|
| 181 |
+
if len(self.haystack_tokens) < context_length_max:
|
| 182 |
+
# tile the corpus until long enough
|
| 183 |
+
repeats = context_length_max // max(1, len(self.haystack_tokens)) + 2
|
| 184 |
+
self.haystack_tokens = self.haystack_tokens * repeats
|
| 185 |
+
self.haystack_tokens = self.haystack_tokens[: context_length_max + 200]
|
| 186 |
+
|
| 187 |
+
self.seperator_tokens = tokenizer.encode(seperator, add_special_tokens=False)
|
| 188 |
+
self.prompt1_tokens = tokenizer.encode(prompt1, add_special_tokens=True)
|
| 189 |
+
self.prompt2_tokens = tokenizer.encode(prompt2, add_special_tokens=False)
|
| 190 |
+
self.buffer_size = buffer_size
|
| 191 |
+
|
| 192 |
+
def __len__(self):
|
| 193 |
+
return 10 ** 9 # effectively infinite; trainer slices by num_steps
|
| 194 |
+
|
| 195 |
+
def _gen_passkey(self):
|
| 196 |
+
seq = torch.randint(0, len(PASSKEY_ALPHABET), (self.passkey_length,))
|
| 197 |
+
return " ".join(PASSKEY_ALPHABET[i] for i in seq)
|
| 198 |
+
|
| 199 |
+
def __getitem__(self, idx):
|
| 200 |
+
rng = random.Random(idx)
|
| 201 |
+
context_length = int(rng.choice(self.context_length_intervals))
|
| 202 |
+
depths = sorted(rng.sample(self.depth_ratio_intervals, self.num_passkeys))
|
| 203 |
+
passkey_tokens_list = [
|
| 204 |
+
self.tokenizer.encode(self._gen_passkey(), add_special_tokens=False)
|
| 205 |
+
for _ in range(self.num_passkeys)
|
| 206 |
+
]
|
| 207 |
+
|
| 208 |
+
haystack = self.haystack_tokens[:context_length]
|
| 209 |
+
context = []
|
| 210 |
+
last = 0
|
| 211 |
+
for i, (d, pk) in enumerate(zip(depths, passkey_tokens_list)):
|
| 212 |
+
ip = int(len(haystack) * d)
|
| 213 |
+
needle = self.needle_tokens_list[i] + pk
|
| 214 |
+
context += haystack[last:ip] + self.seperator_tokens + needle + self.seperator_tokens
|
| 215 |
+
last = ip
|
| 216 |
+
context += haystack[last:]
|
| 217 |
+
|
| 218 |
+
qa = []
|
| 219 |
+
for i, pk in enumerate(passkey_tokens_list):
|
| 220 |
+
qa += self.retrieval_question_tokens_list[i] + pk + self.seperator_tokens
|
| 221 |
+
|
| 222 |
+
ctx = self.prompt1_tokens + context + self.prompt2_tokens
|
| 223 |
+
ids = ctx + qa
|
| 224 |
+
# pad to multiple of 16
|
| 225 |
+
pad = (-len(ids)) % self.pad_multiple
|
| 226 |
+
if pad:
|
| 227 |
+
ids = ids + self.haystack_tokens[-pad:]
|
| 228 |
+
labels = [-100] * (len(ids) - len(qa)) + qa
|
| 229 |
+
# clip pad-extension off labels
|
| 230 |
+
labels = labels[: len(ids)]
|
| 231 |
+
assert len(ids) == len(labels)
|
| 232 |
+
return {"input_ids": torch.tensor(ids), "labels": torch.tensor(labels)}
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def collate(batch):
|
| 236 |
+
return {
|
| 237 |
+
"input_ids": torch.stack([b["input_ids"] for b in batch]),
|
| 238 |
+
"labels": torch.stack([b["labels"] for b in batch]),
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def _find_subsequence(haystack: List[int], needle: List[int]) -> int:
|
| 243 |
+
if not needle or len(needle) > len(haystack):
|
| 244 |
+
return -1
|
| 245 |
+
last = len(haystack) - len(needle)
|
| 246 |
+
for i in range(last + 1):
|
| 247 |
+
if haystack[i:i + len(needle)] == needle:
|
| 248 |
+
return i
|
| 249 |
+
return -1
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
class AmDistilledDataset(Dataset):
|
| 253 |
+
"""Wraps a pre-tokenized HF dataset (e.g. /workspace/...am-distilled-8192).
|
| 254 |
+
|
| 255 |
+
Expects each sample to have an `input_ids` field already produced by
|
| 256 |
+
`tokenize-am_distill.py`. By default labels = input_ids (full-token
|
| 257 |
+
distill). With label_mode="answer_only", labels before the final answer
|
| 258 |
+
span are masked to -100, matching DuoAttention's answer-only distill
|
| 259 |
+
pressure more closely.
|
| 260 |
+
|
| 261 |
+
Used when `--data_source am_distilled` is set, as a drop-in replacement
|
| 262 |
+
for the passkey synthetic dataset. Distill loss is then computed over
|
| 263 |
+
real reasoning data instead of haystack passkey retrieval, which avoids
|
| 264 |
+
the in-distribution overfitting documented in docs §9.4.7-8.
|
| 265 |
+
"""
|
| 266 |
+
|
| 267 |
+
def __init__(
|
| 268 |
+
self,
|
| 269 |
+
ds_path: str,
|
| 270 |
+
split: str,
|
| 271 |
+
max_length: int,
|
| 272 |
+
seed: int = 42,
|
| 273 |
+
tokenizer=None,
|
| 274 |
+
label_mode: str = "full",
|
| 275 |
+
):
|
| 276 |
+
import datasets as hf_datasets
|
| 277 |
+
loaded = hf_datasets.load_from_disk(ds_path)
|
| 278 |
+
if hasattr(loaded, "keys"):
|
| 279 |
+
self.ds = loaded[split]
|
| 280 |
+
else:
|
| 281 |
+
self.ds = loaded
|
| 282 |
+
self.max_length = max_length
|
| 283 |
+
self.label_mode = label_mode
|
| 284 |
+
self.answer_marker_ids = []
|
| 285 |
+
self.think_end_ids = []
|
| 286 |
+
self.assistant_marker_ids = []
|
| 287 |
+
if tokenizer is not None:
|
| 288 |
+
self.answer_marker_ids = tokenizer.encode("<answer>", add_special_tokens=False)
|
| 289 |
+
self.think_end_ids = tokenizer.encode("</think>", add_special_tokens=False)
|
| 290 |
+
self.assistant_marker_ids = tokenizer.encode("<|im_start|>assistant", add_special_tokens=False)
|
| 291 |
+
self._order = list(range(len(self.ds)))
|
| 292 |
+
random.Random(seed).shuffle(self._order)
|
| 293 |
+
|
| 294 |
+
def __len__(self):
|
| 295 |
+
return len(self._order)
|
| 296 |
+
|
| 297 |
+
def __getitem__(self, idx):
|
| 298 |
+
real_idx = self._order[idx % len(self._order)]
|
| 299 |
+
sample = self.ds[real_idx]
|
| 300 |
+
ids = list(sample["input_ids"])[: self.max_length]
|
| 301 |
+
labels = list(ids)
|
| 302 |
+
if self.label_mode == "answer_only":
|
| 303 |
+
labels = [-100] * len(ids)
|
| 304 |
+
start = _find_subsequence(ids, self.answer_marker_ids)
|
| 305 |
+
if start >= 0:
|
| 306 |
+
start = start + len(self.answer_marker_ids)
|
| 307 |
+
else:
|
| 308 |
+
start = _find_subsequence(ids, self.think_end_ids)
|
| 309 |
+
if start >= 0:
|
| 310 |
+
start = start + len(self.think_end_ids)
|
| 311 |
+
if start < 0:
|
| 312 |
+
# Fallback for traces without explicit <answer> inside the
|
| 313 |
+
# truncation window: supervise only assistant-side tokens.
|
| 314 |
+
start = _find_subsequence(ids, self.assistant_marker_ids)
|
| 315 |
+
if start >= 0:
|
| 316 |
+
start = start + len(self.assistant_marker_ids)
|
| 317 |
+
if 0 <= start < len(ids):
|
| 318 |
+
labels[start:] = ids[start:]
|
| 319 |
+
elif self.label_mode != "full":
|
| 320 |
+
raise ValueError(f"unknown AM label_mode: {self.label_mode}")
|
| 321 |
+
return {
|
| 322 |
+
"input_ids": torch.tensor(ids, dtype=torch.long),
|
| 323 |
+
"labels": torch.tensor(labels, dtype=torch.long),
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
class LongBenchLiteAnswerDataset(Dataset):
|
| 328 |
+
"""Small target-distribution calibration set for alpha-only diagnostics.
|
| 329 |
+
|
| 330 |
+
Builds LongBench-lite prompts with the same templates as eval, appends one
|
| 331 |
+
gold answer, and masks labels to answer tokens only. This is intentionally
|
| 332 |
+
a diagnostic data source: it answers whether a high-sparsity static Duo mask
|
| 333 |
+
exists on the target distribution.
|
| 334 |
+
"""
|
| 335 |
+
|
| 336 |
+
def __init__(
|
| 337 |
+
self,
|
| 338 |
+
tokenizer,
|
| 339 |
+
tasks: List[str],
|
| 340 |
+
samples_per_task: int,
|
| 341 |
+
max_length: int,
|
| 342 |
+
seed: int = 42,
|
| 343 |
+
cache_dir: str = "/workspace/AHA/AHA-Qwen3/data/longbench_cache",
|
| 344 |
+
pad_multiple: int = 16,
|
| 345 |
+
):
|
| 346 |
+
from datasets import load_dataset
|
| 347 |
+
|
| 348 |
+
self.tokenizer = tokenizer
|
| 349 |
+
self.max_length = max_length
|
| 350 |
+
self.pad_multiple = pad_multiple
|
| 351 |
+
self.rows = []
|
| 352 |
+
for task in tasks:
|
| 353 |
+
template = LONG_BENCH_PROMPT_TEMPLATES[task]
|
| 354 |
+
ds = load_dataset("Xnhyacinth/LongBench", task, split="test", cache_dir=cache_dir)
|
| 355 |
+
n = min(samples_per_task, len(ds))
|
| 356 |
+
for idx in range(n):
|
| 357 |
+
sample = ds[idx]
|
| 358 |
+
user_content = template.format(context=sample["context"], question=sample["question"])
|
| 359 |
+
prompt = tokenizer.apply_chat_template(
|
| 360 |
+
[{"role": "user", "content": user_content}],
|
| 361 |
+
tokenize=False,
|
| 362 |
+
add_generation_prompt=True,
|
| 363 |
+
enable_thinking=False,
|
| 364 |
+
)
|
| 365 |
+
answers = sample["answers"] if isinstance(sample["answers"], list) else [sample["answers"]]
|
| 366 |
+
answer = str(answers[0])
|
| 367 |
+
prompt_ids = tokenizer(prompt, truncation=False, add_special_tokens=False)["input_ids"]
|
| 368 |
+
answer_ids = tokenizer(answer, truncation=False, add_special_tokens=False)["input_ids"]
|
| 369 |
+
budget = max_length - len(answer_ids) - 1
|
| 370 |
+
if len(prompt_ids) > budget:
|
| 371 |
+
half = max(1, budget // 2)
|
| 372 |
+
prompt_ids = prompt_ids[:half] + prompt_ids[-(budget - half):]
|
| 373 |
+
ids = prompt_ids + answer_ids
|
| 374 |
+
pad = (-len(ids)) % pad_multiple
|
| 375 |
+
if pad:
|
| 376 |
+
ids = ids + [tokenizer.pad_token_id] * pad
|
| 377 |
+
labels = [-100] * len(prompt_ids) + answer_ids + [-100] * pad
|
| 378 |
+
self.rows.append({"input_ids": ids, "labels": labels, "task": task, "idx": idx})
|
| 379 |
+
|
| 380 |
+
random.Random(seed).shuffle(self.rows)
|
| 381 |
+
|
| 382 |
+
def __len__(self):
|
| 383 |
+
return 10 ** 9
|
| 384 |
+
|
| 385 |
+
def __getitem__(self, idx):
|
| 386 |
+
row = self.rows[idx % len(self.rows)]
|
| 387 |
+
return {
|
| 388 |
+
"input_ids": torch.tensor(row["input_ids"], dtype=torch.long),
|
| 389 |
+
"labels": torch.tensor(row["labels"], dtype=torch.long),
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
# -----------------------------------------------------------------------------
|
| 394 |
+
# Training
|
| 395 |
+
# -----------------------------------------------------------------------------
|
| 396 |
+
@torch.no_grad()
|
| 397 |
+
def _set_alpha_full(model, value: float = 1.0):
|
| 398 |
+
"""Temporarily overwrite `full_attention_heads` to a constant.
|
| 399 |
+
|
| 400 |
+
Used to compute the 'teacher' forward pass (full attention everywhere).
|
| 401 |
+
Call `_restore_alpha` with the saved tensors afterwards.
|
| 402 |
+
"""
|
| 403 |
+
saved = []
|
| 404 |
+
for layer in model.model.layers:
|
| 405 |
+
p = layer.self_attn.full_attention_heads
|
| 406 |
+
saved.append(p.data.clone())
|
| 407 |
+
p.data.fill_(value)
|
| 408 |
+
return saved
|
| 409 |
+
|
| 410 |
+
|
| 411 |
+
@torch.no_grad()
|
| 412 |
+
def _restore_alpha(model, saved):
|
| 413 |
+
for layer, s in zip(model.model.layers, saved):
|
| 414 |
+
layer.self_attn.full_attention_heads.data.copy_(s)
|
| 415 |
+
|
| 416 |
+
|
| 417 |
+
def log_alpha_stats(model) -> dict:
|
| 418 |
+
with torch.no_grad():
|
| 419 |
+
alphas = torch.stack([
|
| 420 |
+
layer.self_attn.full_attention_heads.detach().float().clamp(0, 1)
|
| 421 |
+
for layer in model.model.layers
|
| 422 |
+
], dim=0)
|
| 423 |
+
m = alphas.mean().item()
|
| 424 |
+
return {
|
| 425 |
+
"alpha_mean": m,
|
| 426 |
+
"alpha_std": alphas.std().item(),
|
| 427 |
+
"alpha_gt05": (alphas > 0.5).float().mean().item(),
|
| 428 |
+
"alpha_min": alphas.min().item(),
|
| 429 |
+
"alpha_max": alphas.max().item(),
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
|
| 433 |
+
def save_alpha_matrix(model, path: str):
|
| 434 |
+
with torch.no_grad():
|
| 435 |
+
alphas = torch.stack([
|
| 436 |
+
layer.self_attn.full_attention_heads.detach().float().clamp(0, 1).cpu()
|
| 437 |
+
for layer in model.model.layers
|
| 438 |
+
], dim=0).numpy()
|
| 439 |
+
np.savetxt(path, alphas, delimiter="\t")
|
| 440 |
+
|
| 441 |
+
|
| 442 |
+
def _init_distributed():
|
| 443 |
+
world_size = int(os.environ.get("WORLD_SIZE", "1"))
|
| 444 |
+
if world_size <= 1:
|
| 445 |
+
return False, 0, 0, 1, torch.device("cuda")
|
| 446 |
+
local_rank = int(os.environ.get("LOCAL_RANK", "0"))
|
| 447 |
+
rank = int(os.environ.get("RANK", "0"))
|
| 448 |
+
torch.cuda.set_device(local_rank)
|
| 449 |
+
dist.init_process_group(backend="nccl")
|
| 450 |
+
return True, local_rank, rank, world_size, torch.device("cuda", local_rank)
|
| 451 |
+
|
| 452 |
+
|
| 453 |
+
def main():
|
| 454 |
+
p = argparse.ArgumentParser()
|
| 455 |
+
p.add_argument("--model_path", required=True)
|
| 456 |
+
p.add_argument("--aha_checkpoint_path", default="",
|
| 457 |
+
help="Optional AHA/Duo checkpoint to continue training from. "
|
| 458 |
+
"When set, model weights and alpha scalars are loaded from "
|
| 459 |
+
"this checkpoint instead of converting --model_path from base Qwen3.")
|
| 460 |
+
p.add_argument("--haystack_dir", default="",
|
| 461 |
+
help="Path to PaulGraham essays for the synthetic passkey dataset. "
|
| 462 |
+
"Required when --data_source=passkey, ignored otherwise.")
|
| 463 |
+
p.add_argument("--output_dir", required=True)
|
| 464 |
+
p.add_argument("--max_length", type=int, default=8192)
|
| 465 |
+
p.add_argument("--context_length_min", type=int, default=2000)
|
| 466 |
+
p.add_argument("--context_length_max", type=int, default=8000)
|
| 467 |
+
p.add_argument("--context_lengths_num_intervals", type=int, default=20)
|
| 468 |
+
p.add_argument("--depth_ratio_num_intervals", type=int, default=1000)
|
| 469 |
+
p.add_argument("--min_depth_ratio", type=float, default=0.05)
|
| 470 |
+
p.add_argument("--max_depth_ratio", type=float, default=0.95)
|
| 471 |
+
p.add_argument("--num_passkeys", type=int, default=10)
|
| 472 |
+
p.add_argument("--passkey_length", type=int, default=32)
|
| 473 |
+
p.add_argument("--num_steps", type=int, default=800)
|
| 474 |
+
p.add_argument("--warmup_ratio", type=float, default=0.2)
|
| 475 |
+
p.add_argument("--lr", type=float, default=0.02)
|
| 476 |
+
p.add_argument("--reg_weight", type=float, default=0.05)
|
| 477 |
+
p.add_argument("--sink_size", type=int, default=64)
|
| 478 |
+
p.add_argument("--recent_size", type=int, default=256)
|
| 479 |
+
p.add_argument("--batch_size", type=int, default=1)
|
| 480 |
+
p.add_argument("--grad_accum", type=int, default=1)
|
| 481 |
+
p.add_argument("--save_steps", type=int, default=200)
|
| 482 |
+
p.add_argument("--log_steps", type=int, default=10)
|
| 483 |
+
p.add_argument("--seed", type=int, default=42)
|
| 484 |
+
p.add_argument("--dtype", default="bfloat16")
|
| 485 |
+
p.add_argument("--attn_impl", default="sdpa", choices=["sdpa", "eager"])
|
| 486 |
+
# Optional: unfreeze attention projection weights (q/k/v/o_proj) together
|
| 487 |
+
# with the alpha scalars — this reproduces the senior-student experiment
|
| 488 |
+
# where "retraining" the model removes the need for the attention sink.
|
| 489 |
+
p.add_argument("--unfreeze_attn_proj", action="store_true",
|
| 490 |
+
help="Also train q/k/v/o_proj weights alongside alpha scalars.")
|
| 491 |
+
p.add_argument("--backbone_lr", type=float, default=1e-5,
|
| 492 |
+
help="Learning rate for unfrozen backbone params (alpha keeps --lr).")
|
| 493 |
+
# CE anchor: required to prevent self-distill collapse when backbone is unfrozen.
|
| 494 |
+
# When backbone is frozen (paper-grade DuoAttention), distill alone is well-defined
|
| 495 |
+
# because the teacher (alpha=1 forward) is a fixed pretrained reference; CE is
|
| 496 |
+
# redundant. When backbone is trainable, the teacher itself drifts together with
|
| 497 |
+
# the student, so distill becomes self-distillation against a moving target and
|
| 498 |
+
# admits degenerate solutions (h_full ≡ h_mix but both wrong → garbled output).
|
| 499 |
+
# CE on the student forward pins backbone to the "predicting labels correctly"
|
| 500 |
+
# manifold, blocking that failure mode.
|
| 501 |
+
p.add_argument("--ce_weight", type=float, default=0.0,
|
| 502 |
+
help="Weight for cross-entropy anchor loss on labels (student/mix forward). "
|
| 503 |
+
"0 disables (paper-grade DuoAttention). Recommended >0 when "
|
| 504 |
+
"--unfreeze_attn_proj is set, to prevent backbone drift.")
|
| 505 |
+
# Data source: passkey (DuoAttention legacy synthetic) or am_distilled
|
| 506 |
+
# (real reasoning SFT data, see docs §9.4.7-8 for why we may want this).
|
| 507 |
+
p.add_argument("--data_source", default="passkey",
|
| 508 |
+
choices=["passkey", "am_distilled", "longbench_lite"],
|
| 509 |
+
help="Training data: 'passkey' replicates DuoAttention's "
|
| 510 |
+
"synthetic haystack retrieval; 'am_distilled' uses a "
|
| 511 |
+
"pre-tokenized multi-task SFT dataset (e.g. AM-Thinking "
|
| 512 |
+
"or AM-Qwen3-Distilled). 'longbench_lite' is a diagnostic "
|
| 513 |
+
"target-distribution calibration source.")
|
| 514 |
+
p.add_argument("--am_dataset_path", default="/workspace/Direct-Multitoken-Decoding/am-distilled-8192",
|
| 515 |
+
help="Path to a `datasets.load_from_disk`-compatible dataset.")
|
| 516 |
+
p.add_argument("--am_dataset_split", default="train")
|
| 517 |
+
p.add_argument("--am_label_mode", default="full", choices=["full", "answer_only"],
|
| 518 |
+
help="Label mask for --data_source=am_distilled. 'full' keeps the legacy "
|
| 519 |
+
"all-token hidden-state distill; 'answer_only' masks tokens before "
|
| 520 |
+
"the final <answer> span, closer to DuoAttention's QA-only objective.")
|
| 521 |
+
p.add_argument("--longbench_tasks", nargs="+",
|
| 522 |
+
default=["passage_retrieval_en", "multifieldqa_en", "qasper", "2wikimqa"])
|
| 523 |
+
p.add_argument("--longbench_samples_per_task", type=int, default=30)
|
| 524 |
+
p.add_argument("--longbench_cache_dir", default="/workspace/AHA/AHA-Qwen3/data/longbench_cache")
|
| 525 |
+
args = p.parse_args()
|
| 526 |
+
|
| 527 |
+
distributed, local_rank, rank, world_size, device = _init_distributed()
|
| 528 |
+
is_main = rank == 0
|
| 529 |
+
|
| 530 |
+
def log(*log_args, **log_kwargs):
|
| 531 |
+
if is_main:
|
| 532 |
+
print(*log_args, **log_kwargs)
|
| 533 |
+
|
| 534 |
+
torch.manual_seed(args.seed + rank)
|
| 535 |
+
random.seed(args.seed + rank)
|
| 536 |
+
np.random.seed(args.seed + rank)
|
| 537 |
+
os.makedirs(args.output_dir, exist_ok=True)
|
| 538 |
+
if is_main:
|
| 539 |
+
with open(os.path.join(args.output_dir, "duo_train_args.json"), "w") as f:
|
| 540 |
+
saved_args = vars(args).copy()
|
| 541 |
+
saved_args.update({"distributed": distributed, "world_size": world_size})
|
| 542 |
+
json.dump(saved_args, f, indent=2)
|
| 543 |
+
|
| 544 |
+
log(f"[duo-train] model={args.model_path} ctx=[{args.context_length_min},{args.context_length_max}]")
|
| 545 |
+
log(f"[duo-train] sink={args.sink_size} recent={args.recent_size} passkeys={args.num_passkeys}")
|
| 546 |
+
log(f"[duo-train] lr={args.lr} reg_weight={args.reg_weight} num_steps={args.num_steps}")
|
| 547 |
+
if distributed:
|
| 548 |
+
log(f"[duo-train] distributed=torchrun world_size={world_size}")
|
| 549 |
+
|
| 550 |
+
tokenizer = AutoTokenizer.from_pretrained(args.model_path, trust_remote_code=True)
|
| 551 |
+
if tokenizer.pad_token_id is None:
|
| 552 |
+
tokenizer.pad_token = tokenizer.eos_token
|
| 553 |
+
|
| 554 |
+
if args.data_source == "passkey":
|
| 555 |
+
if not args.haystack_dir:
|
| 556 |
+
raise ValueError("--haystack_dir is required when --data_source=passkey")
|
| 557 |
+
haystack_text = _load_haystack_text(args.haystack_dir)
|
| 558 |
+
log(f"[duo-train] haystack char length: {len(haystack_text):,}")
|
| 559 |
+
else:
|
| 560 |
+
haystack_text = ""
|
| 561 |
+
|
| 562 |
+
# Build model in DUO mode. Default is initialising from base Qwen3 weights;
|
| 563 |
+
# --aha_checkpoint_path is used for static-alpha continuation controls.
|
| 564 |
+
dtype = {"bfloat16": torch.bfloat16, "float16": torch.float16, "float32": torch.float32}[args.dtype]
|
| 565 |
+
if args.aha_checkpoint_path:
|
| 566 |
+
log(f"[duo-train] continuing from aha_checkpoint_path={args.aha_checkpoint_path}")
|
| 567 |
+
model = AHAQwen3ForCausalLM.from_pretrained_aha(
|
| 568 |
+
args.aha_checkpoint_path,
|
| 569 |
+
torch_dtype=dtype,
|
| 570 |
+
attn_implementation=args.attn_impl,
|
| 571 |
+
).to(device)
|
| 572 |
+
if getattr(model.config, "aha_mode", "") != "duo":
|
| 573 |
+
raise ValueError("--aha_checkpoint_path for duo_train.py must have aha_mode='duo'")
|
| 574 |
+
model.config.duo_sink_size = args.sink_size
|
| 575 |
+
model.config.duo_recent_size = args.recent_size
|
| 576 |
+
model.config.aha_distill_weight = 0.0
|
| 577 |
+
model.config.aha_ce_weight = 0.0
|
| 578 |
+
model.config.aha_lambda = 0.0
|
| 579 |
+
model.config.aha_gate_target = 0.0
|
| 580 |
+
model.config.aha_reg_weight = -1.0
|
| 581 |
+
else:
|
| 582 |
+
model = AHAQwen3ForCausalLM.from_pretrained_qwen3(
|
| 583 |
+
args.model_path,
|
| 584 |
+
aha_mode="duo",
|
| 585 |
+
duo_sink_size=args.sink_size,
|
| 586 |
+
duo_recent_size=args.recent_size,
|
| 587 |
+
duo_alpha_init=1.0,
|
| 588 |
+
aha_distill_weight=0.0, # we compute distill externally
|
| 589 |
+
aha_ce_weight=0.0, # no CE in DuoAttention objective
|
| 590 |
+
aha_lambda=0.0, # we compute L1 externally
|
| 591 |
+
aha_gate_target=0.0,
|
| 592 |
+
torch_dtype=dtype,
|
| 593 |
+
attn_implementation=args.attn_impl,
|
| 594 |
+
).to(device)
|
| 595 |
+
|
| 596 |
+
# Freeze everything except full_attention_heads
|
| 597 |
+
for param in model.parameters():
|
| 598 |
+
param.requires_grad = False
|
| 599 |
+
alpha_params, backbone_params = [], []
|
| 600 |
+
for layer in model.model.layers:
|
| 601 |
+
layer.self_attn.full_attention_heads.requires_grad = True
|
| 602 |
+
alpha_params.append(layer.self_attn.full_attention_heads)
|
| 603 |
+
if args.unfreeze_attn_proj:
|
| 604 |
+
# Sink-ablation setting B: also retrain attention projections so the
|
| 605 |
+
# model can learn attention patterns that do not rely on sink tokens.
|
| 606 |
+
for proj in ("q_proj", "k_proj", "v_proj", "o_proj"):
|
| 607 |
+
mod = getattr(layer.self_attn, proj, None)
|
| 608 |
+
if mod is None:
|
| 609 |
+
continue
|
| 610 |
+
for pname, param in mod.named_parameters():
|
| 611 |
+
param.requires_grad = True
|
| 612 |
+
backbone_params.append(param)
|
| 613 |
+
|
| 614 |
+
# Gradient checkpointing requires *some* input to require grad. In the
|
| 615 |
+
# pure-alpha setting all backbone weights are frozen so we must manually
|
| 616 |
+
# enable input grads; when `--unfreeze_attn_proj` is on the projections
|
| 617 |
+
# themselves already require grad so this is still harmless but optional.
|
| 618 |
+
model.enable_input_require_grads()
|
| 619 |
+
model.gradient_checkpointing_enable(gradient_checkpointing_kwargs={"use_reentrant": False})
|
| 620 |
+
core_model = model.model
|
| 621 |
+
if distributed:
|
| 622 |
+
core_model = DDP(core_model, device_ids=[local_rank], output_device=local_rank)
|
| 623 |
+
n_alpha = sum(p.numel() for p in alpha_params)
|
| 624 |
+
n_backbone = sum(p.numel() for p in backbone_params)
|
| 625 |
+
log(f"[duo-train] trainable alpha scalars: {n_alpha}")
|
| 626 |
+
if args.unfreeze_attn_proj:
|
| 627 |
+
log(f"[duo-train] trainable backbone params (q/k/v/o_proj): {n_backbone:,}")
|
| 628 |
+
else:
|
| 629 |
+
log(f"[duo-train] backbone: frozen (paper-grade DuoAttention protocol)")
|
| 630 |
+
|
| 631 |
+
if args.data_source == "passkey":
|
| 632 |
+
log(f"[duo-train] data_source=passkey, haystack_dir={args.haystack_dir}")
|
| 633 |
+
dataset = MultiPasskeyDataset(
|
| 634 |
+
tokenizer=tokenizer,
|
| 635 |
+
haystack_text=haystack_text,
|
| 636 |
+
context_length_min=args.context_length_min,
|
| 637 |
+
context_length_max=args.context_length_max,
|
| 638 |
+
context_lengths_num_intervals=args.context_lengths_num_intervals,
|
| 639 |
+
depth_ratio_num_intervals=args.depth_ratio_num_intervals,
|
| 640 |
+
min_depth_ratio=args.min_depth_ratio,
|
| 641 |
+
max_depth_ratio=args.max_depth_ratio,
|
| 642 |
+
num_passkeys=args.num_passkeys,
|
| 643 |
+
passkey_length=args.passkey_length,
|
| 644 |
+
)
|
| 645 |
+
elif args.data_source == "am_distilled":
|
| 646 |
+
log(f"[duo-train] data_source=am_distilled, path={args.am_dataset_path} split={args.am_dataset_split}")
|
| 647 |
+
dataset = AmDistilledDataset(
|
| 648 |
+
ds_path=args.am_dataset_path,
|
| 649 |
+
split=args.am_dataset_split,
|
| 650 |
+
max_length=args.max_length,
|
| 651 |
+
seed=args.seed,
|
| 652 |
+
tokenizer=tokenizer,
|
| 653 |
+
label_mode=args.am_label_mode,
|
| 654 |
+
)
|
| 655 |
+
log(f"[duo-train] am_distilled dataset n={len(dataset):,}, max_length={args.max_length}, "
|
| 656 |
+
f"label_mode={args.am_label_mode}")
|
| 657 |
+
elif args.data_source == "longbench_lite":
|
| 658 |
+
log(f"[duo-train] data_source=longbench_lite tasks={args.longbench_tasks} "
|
| 659 |
+
f"samples_per_task={args.longbench_samples_per_task}")
|
| 660 |
+
dataset = LongBenchLiteAnswerDataset(
|
| 661 |
+
tokenizer=tokenizer,
|
| 662 |
+
tasks=args.longbench_tasks,
|
| 663 |
+
samples_per_task=args.longbench_samples_per_task,
|
| 664 |
+
max_length=args.max_length,
|
| 665 |
+
seed=args.seed,
|
| 666 |
+
cache_dir=args.longbench_cache_dir,
|
| 667 |
+
)
|
| 668 |
+
else:
|
| 669 |
+
raise ValueError(f"unknown data_source: {args.data_source}")
|
| 670 |
+
sampler = DistributedSampler(dataset, num_replicas=world_size, rank=rank, shuffle=False) if distributed else None
|
| 671 |
+
loader = DataLoader(
|
| 672 |
+
dataset,
|
| 673 |
+
batch_size=args.batch_size,
|
| 674 |
+
shuffle=False,
|
| 675 |
+
sampler=sampler,
|
| 676 |
+
collate_fn=collate,
|
| 677 |
+
num_workers=0,
|
| 678 |
+
)
|
| 679 |
+
data_iter = iter(loader)
|
| 680 |
+
|
| 681 |
+
# Two parameter groups with independent LR multipliers. Group 0 = alpha
|
| 682 |
+
# scalars (base lr = args.lr, e.g. 0.02); group 1 = backbone (base lr =
|
| 683 |
+
# args.backbone_lr, e.g. 1e-5). The trapezoidal schedule multiplies both.
|
| 684 |
+
param_groups = [{"params": alpha_params, "base_lr": args.lr, "lr": args.lr}]
|
| 685 |
+
if backbone_params:
|
| 686 |
+
param_groups.append({"params": backbone_params, "base_lr": args.backbone_lr, "lr": args.backbone_lr})
|
| 687 |
+
optim = torch.optim.AdamW(param_groups, weight_decay=0.0)
|
| 688 |
+
warm = max(1, int(args.num_steps * args.warmup_ratio))
|
| 689 |
+
|
| 690 |
+
def lr_at(step):
|
| 691 |
+
# trapezoidal schedule, same as DuoAttention's: ramp up over warm, hold, ramp down over warm
|
| 692 |
+
if step < warm:
|
| 693 |
+
return max(0.1, (step + 1) / warm)
|
| 694 |
+
if step > args.num_steps - warm:
|
| 695 |
+
return max(0.1, (args.num_steps - step) / warm)
|
| 696 |
+
return 1.0
|
| 697 |
+
|
| 698 |
+
model.train()
|
| 699 |
+
running_distill = running_reg = running_ce = 0.0
|
| 700 |
+
steps_in_window = 0
|
| 701 |
+
data_epoch = 0
|
| 702 |
+
for step in range(args.num_steps):
|
| 703 |
+
try:
|
| 704 |
+
batch = next(data_iter)
|
| 705 |
+
except StopIteration:
|
| 706 |
+
data_epoch += 1
|
| 707 |
+
if sampler is not None:
|
| 708 |
+
sampler.set_epoch(data_epoch)
|
| 709 |
+
data_iter = iter(loader)
|
| 710 |
+
batch = next(data_iter)
|
| 711 |
+
input_ids = batch["input_ids"].to(device)
|
| 712 |
+
labels = batch["labels"].to(device)
|
| 713 |
+
label_mask = labels != -100
|
| 714 |
+
|
| 715 |
+
# --- Teacher forward: force alpha = 1 everywhere (hidden_states) ----
|
| 716 |
+
saved = _set_alpha_full(model, 1.0)
|
| 717 |
+
old_teacher_fastpath = getattr(model.config, "_aha_teacher_full_fastpath", False)
|
| 718 |
+
model.config._aha_teacher_full_fastpath = True
|
| 719 |
+
try:
|
| 720 |
+
with torch.no_grad():
|
| 721 |
+
out_full = core_model(input_ids=input_ids, use_cache=False)
|
| 722 |
+
h_full = out_full.last_hidden_state
|
| 723 |
+
finally:
|
| 724 |
+
model.config._aha_teacher_full_fastpath = old_teacher_fastpath
|
| 725 |
+
_restore_alpha(model, saved)
|
| 726 |
+
|
| 727 |
+
# --- Student forward: current alpha --------------------------------
|
| 728 |
+
out_mix = core_model(input_ids=input_ids, use_cache=False)
|
| 729 |
+
h_mix = out_mix.last_hidden_state
|
| 730 |
+
|
| 731 |
+
# DuoAttention's exact distill: mean over hidden_dim, then mean over labelled tokens
|
| 732 |
+
if label_mask.any():
|
| 733 |
+
diff = (h_full.float() - h_mix.float())[label_mask] # [N_tok, d_model]
|
| 734 |
+
distill = diff.pow(2).mean(dim=-1).mean()
|
| 735 |
+
else:
|
| 736 |
+
distill = (h_full.float() - h_mix.float()).pow(2).mean(dim=-1).mean()
|
| 737 |
+
|
| 738 |
+
# L1 on alpha (clamped)
|
| 739 |
+
alpha_all = torch.cat([
|
| 740 |
+
layer.self_attn.full_attention_heads.clamp(0.0, 1.0)
|
| 741 |
+
for layer in model.model.layers
|
| 742 |
+
])
|
| 743 |
+
# DuoAttention uses sum/numel == mean; kept explicit for clarity.
|
| 744 |
+
reg = alpha_all.abs().sum() / alpha_all.numel()
|
| 745 |
+
|
| 746 |
+
# CE anchor on the student (mix) forward. Only computed when ce_weight > 0
|
| 747 |
+
# to keep paper-grade DuoAttention runs bit-identical to before.
|
| 748 |
+
if args.ce_weight > 0.0:
|
| 749 |
+
logits = model.lm_head(h_mix).float()
|
| 750 |
+
shift_logits = logits[:, :-1, :].contiguous()
|
| 751 |
+
shift_labels = labels[:, 1:].contiguous()
|
| 752 |
+
ce = torch.nn.functional.cross_entropy(
|
| 753 |
+
shift_logits.view(-1, shift_logits.size(-1)),
|
| 754 |
+
shift_labels.view(-1),
|
| 755 |
+
ignore_index=-100,
|
| 756 |
+
)
|
| 757 |
+
else:
|
| 758 |
+
ce = h_mix.new_zeros((), dtype=torch.float32)
|
| 759 |
+
|
| 760 |
+
loss = distill + args.reg_weight * reg + args.ce_weight * ce
|
| 761 |
+
(loss / args.grad_accum).backward()
|
| 762 |
+
|
| 763 |
+
if (step + 1) % args.grad_accum == 0:
|
| 764 |
+
for g in optim.param_groups:
|
| 765 |
+
g["lr"] = g["base_lr"] * lr_at(step)
|
| 766 |
+
optim.step()
|
| 767 |
+
optim.zero_grad()
|
| 768 |
+
# hard clamp alpha into [0, 1]
|
| 769 |
+
with torch.no_grad():
|
| 770 |
+
for layer in model.model.layers:
|
| 771 |
+
layer.self_attn.full_attention_heads.data.clamp_(0.0, 1.0)
|
| 772 |
+
|
| 773 |
+
running_distill += float(distill.detach())
|
| 774 |
+
running_reg += float(reg.detach())
|
| 775 |
+
running_ce += float(ce.detach())
|
| 776 |
+
steps_in_window += 1
|
| 777 |
+
if (step + 1) % args.log_steps == 0:
|
| 778 |
+
stats = log_alpha_stats(model)
|
| 779 |
+
lr_str = f"lr_alpha={optim.param_groups[0]['lr']:.4e}"
|
| 780 |
+
if len(optim.param_groups) > 1:
|
| 781 |
+
lr_str += f" lr_bb={optim.param_groups[1]['lr']:.2e}"
|
| 782 |
+
ce_str = f"ce={running_ce/steps_in_window:.4f} " if args.ce_weight > 0.0 else ""
|
| 783 |
+
log(
|
| 784 |
+
f"[step {step+1:4d}/{args.num_steps}] "
|
| 785 |
+
f"distill={running_distill/steps_in_window:.4f} "
|
| 786 |
+
f"reg={running_reg/steps_in_window:.4f} "
|
| 787 |
+
f"{ce_str}"
|
| 788 |
+
f"alpha_mean={stats['alpha_mean']:.3f} "
|
| 789 |
+
f"alpha_std={stats['alpha_std']:.3f} "
|
| 790 |
+
f"alpha>0.5_frac={stats['alpha_gt05']:.3f} "
|
| 791 |
+
f"{lr_str} "
|
| 792 |
+
f"seq_len={input_ids.shape[1]}",
|
| 793 |
+
flush=True,
|
| 794 |
+
)
|
| 795 |
+
running_distill = running_reg = running_ce = 0.0
|
| 796 |
+
steps_in_window = 0
|
| 797 |
+
|
| 798 |
+
if (step + 1) % args.save_steps == 0 or (step + 1) == args.num_steps:
|
| 799 |
+
sub = os.path.join(args.output_dir, f"checkpoint-{step+1}")
|
| 800 |
+
if is_main:
|
| 801 |
+
os.makedirs(sub, exist_ok=True)
|
| 802 |
+
model.save_pretrained(sub, safe_serialization=True)
|
| 803 |
+
tokenizer.save_pretrained(sub)
|
| 804 |
+
save_alpha_matrix(model, os.path.join(sub, "full_attention_heads.tsv"))
|
| 805 |
+
stats = log_alpha_stats(model)
|
| 806 |
+
with open(os.path.join(sub, "duo_state.json"), "w") as f:
|
| 807 |
+
json.dump({"step": step + 1, **stats}, f, indent=2)
|
| 808 |
+
log(f"[duo-train] saved {sub}")
|
| 809 |
+
if distributed:
|
| 810 |
+
dist.barrier()
|
| 811 |
+
|
| 812 |
+
log(f"[duo-train] done. Final alpha stats: {log_alpha_stats(model)}")
|
| 813 |
+
if distributed:
|
| 814 |
+
dist.destroy_process_group()
|
| 815 |
+
|
| 816 |
+
|
| 817 |
+
if __name__ == "__main__":
|
| 818 |
+
main()
|
recipe/dynamic_duo_train.py
ADDED
|
@@ -0,0 +1,494 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Duo-style hidden-state distillation for AHA dynamic gates.
|
| 2 |
+
|
| 3 |
+
This is the clean static-to-dynamic continuation experiment:
|
| 4 |
+
|
| 5 |
+
loss = MSE(h_full, h_dynamic) on labelled tokens + reg_weight * mean(gate_soft)
|
| 6 |
+
|
| 7 |
+
where h_full is produced by the same frozen checkpoint with every dynamic gate
|
| 8 |
+
forced to 1.0, and h_dynamic uses the learned per-token gate. For
|
| 9 |
+
``aha_mode="duo_dynamic"``, "forced to 1.0" means "reproduce the locked static
|
| 10 |
+
Duo full-head mask"; Duo streaming heads remain streaming. Backbone weights
|
| 11 |
+
stay frozen; only q_proj gate rows are trainable.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import argparse
|
| 15 |
+
import json
|
| 16 |
+
import os
|
| 17 |
+
import random
|
| 18 |
+
import shutil
|
| 19 |
+
import sys
|
| 20 |
+
|
| 21 |
+
import numpy as np
|
| 22 |
+
import torch
|
| 23 |
+
from torch.utils.data import DataLoader
|
| 24 |
+
from transformers import AutoTokenizer
|
| 25 |
+
|
| 26 |
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
| 27 |
+
if HERE not in sys.path:
|
| 28 |
+
sys.path.insert(0, HERE)
|
| 29 |
+
|
| 30 |
+
from duo_train import AmDistilledDataset, LongBenchLiteAnswerDataset, collate # noqa: E402
|
| 31 |
+
from modeling_aha_qwen3 import ( # noqa: E402
|
| 32 |
+
AHA_ROUTER_GRANULARITY,
|
| 33 |
+
AHAQwen3Config,
|
| 34 |
+
AHAQwen3ForCausalLM,
|
| 35 |
+
aha_router_output_size,
|
| 36 |
+
)
|
| 37 |
+
from router_training_utils import configure_gate_only # noqa: E402
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _set_force_gate(model, value):
|
| 41 |
+
prev = getattr(model.config, "aha_force_gate_value", None)
|
| 42 |
+
model.config.aha_force_gate_value = value
|
| 43 |
+
return prev
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _restore_force_gate(model, value):
|
| 47 |
+
model.config.aha_force_gate_value = value
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _gate_tensors(out):
|
| 51 |
+
return [g.float() for g in out.all_gate_soft]
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def gate_stats_from_output(out) -> dict:
|
| 55 |
+
with torch.no_grad():
|
| 56 |
+
soft = torch.cat([g.reshape(-1) for g in _gate_tensors(out)])
|
| 57 |
+
hard = torch.cat([g.float().reshape(-1) for g in out.all_gate_hard])
|
| 58 |
+
return {
|
| 59 |
+
"gate_soft_mean": soft.mean().item(),
|
| 60 |
+
"gate_soft_std": soft.std().item(),
|
| 61 |
+
"gate_hard_mean": hard.mean().item(),
|
| 62 |
+
"gate_min": soft.min().item(),
|
| 63 |
+
"gate_max": soft.max().item(),
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def gate_param_stats(model) -> dict:
|
| 68 |
+
weights = []
|
| 69 |
+
biases = []
|
| 70 |
+
num_heads = model.config.num_attention_heads
|
| 71 |
+
head_dim = getattr(model.config, "head_dim", model.config.hidden_size // num_heads)
|
| 72 |
+
q_rows = num_heads * head_dim
|
| 73 |
+
with torch.no_grad():
|
| 74 |
+
for layer in model.model.layers:
|
| 75 |
+
q_proj = layer.self_attn.q_proj
|
| 76 |
+
weights.append(q_proj.weight[q_rows:].detach().float().reshape(-1).cpu())
|
| 77 |
+
if q_proj.bias is not None:
|
| 78 |
+
biases.append(q_proj.bias[q_rows:].detach().float().cpu())
|
| 79 |
+
w = torch.cat(weights)
|
| 80 |
+
stats = {
|
| 81 |
+
"gate_weight_l2": w.norm().item(),
|
| 82 |
+
"gate_weight_abs_mean": w.abs().mean().item(),
|
| 83 |
+
}
|
| 84 |
+
if biases:
|
| 85 |
+
b = torch.cat(biases)
|
| 86 |
+
alpha = torch.sigmoid(b)
|
| 87 |
+
stats.update({
|
| 88 |
+
"gate_bias_mean": b.mean().item(),
|
| 89 |
+
"gate_bias_std": b.std().item(),
|
| 90 |
+
"gate_bias_alpha_mean": alpha.mean().item(),
|
| 91 |
+
"gate_bias_alpha_gt05": (alpha > 0.5).float().mean().item(),
|
| 92 |
+
})
|
| 93 |
+
if getattr(model.config, "aha_mode", "dynamic") == "duo_dynamic":
|
| 94 |
+
masks = []
|
| 95 |
+
with torch.no_grad():
|
| 96 |
+
for layer in model.model.layers:
|
| 97 |
+
alpha_static = layer.self_attn.full_attention_heads.detach().float()
|
| 98 |
+
masks.append((alpha_static > 0.5).reshape(-1).cpu())
|
| 99 |
+
m = torch.cat(masks).float()
|
| 100 |
+
stats.update({
|
| 101 |
+
"duo_static_full_frac": m.mean().item(),
|
| 102 |
+
"duo_static_streaming_frac": (1.0 - m).mean().item(),
|
| 103 |
+
})
|
| 104 |
+
return stats
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def build_reg_head_weights(model, mode: str, power: float):
|
| 108 |
+
"""Optional per-head weights for the sparsity regularizer.
|
| 109 |
+
|
| 110 |
+
``duo_alpha_margin`` uses Duo's own static alpha as confidence: full heads
|
| 111 |
+
barely above 0.5 receive little sparsity pressure, while high-alpha full
|
| 112 |
+
heads receive normal pressure. This tests whether the alpha-low8 inference
|
| 113 |
+
guard can be moved into training as a smooth objective.
|
| 114 |
+
"""
|
| 115 |
+
if mode == "uniform":
|
| 116 |
+
return None
|
| 117 |
+
if mode != "duo_alpha_margin":
|
| 118 |
+
raise ValueError(f"unknown reg head weight mode: {mode}")
|
| 119 |
+
if getattr(model.config, "aha_mode", "dynamic") != "duo_dynamic":
|
| 120 |
+
raise ValueError(
|
| 121 |
+
"--reg_head_weight_mode=duo_alpha_margin requires aha_mode=duo_dynamic"
|
| 122 |
+
)
|
| 123 |
+
if power <= 0.0:
|
| 124 |
+
raise ValueError("--reg_head_weight_power must be positive")
|
| 125 |
+
|
| 126 |
+
weights = []
|
| 127 |
+
with torch.no_grad():
|
| 128 |
+
for layer in model.model.layers:
|
| 129 |
+
alpha = layer.self_attn.full_attention_heads.detach().float().clamp(0.0, 1.0)
|
| 130 |
+
duo_full = (alpha > 0.5).float()
|
| 131 |
+
margin = ((alpha - 0.5) / 0.5).clamp(0.0, 1.0).pow(power)
|
| 132 |
+
weights.append((margin * duo_full).view(1, 1, -1))
|
| 133 |
+
flat = torch.cat([w.reshape(-1).cpu() for w in weights])
|
| 134 |
+
print(
|
| 135 |
+
"[dynamic-duo] reg_head_weight_mode=duo_alpha_margin "
|
| 136 |
+
f"power={power:g} nonzero={int((flat > 0).sum().item())}/{flat.numel()} "
|
| 137 |
+
f"mean={flat.mean().item():.4f}",
|
| 138 |
+
flush=True,
|
| 139 |
+
)
|
| 140 |
+
return weights
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def sparsity_reg_from_gates(out, reg_head_weights):
|
| 144 |
+
gate_layers = _gate_tensors(out)
|
| 145 |
+
if reg_head_weights is None:
|
| 146 |
+
gate_soft = torch.cat([g.reshape(-1) for g in gate_layers])
|
| 147 |
+
return gate_soft.mean()
|
| 148 |
+
|
| 149 |
+
weighted = []
|
| 150 |
+
for gate, weight in zip(gate_layers, reg_head_weights):
|
| 151 |
+
w = weight.to(device=gate.device, dtype=gate.dtype)
|
| 152 |
+
weighted.append((gate * w).reshape(-1))
|
| 153 |
+
if not weighted:
|
| 154 |
+
raise ValueError("empty weighted sparsity regularizer")
|
| 155 |
+
return torch.cat(weighted).mean()
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def build_gate_optimizer(model, lr: float):
|
| 159 |
+
setup = configure_gate_only(model)
|
| 160 |
+
print(
|
| 161 |
+
f"[dynamic-duo] router_granularity={model.config.aha_router_granularity} "
|
| 162 |
+
f"gate_rows/layer={setup.gate_rows} effective trainable gate params: "
|
| 163 |
+
f"{setup.effective_parameter_count:,}",
|
| 164 |
+
flush=True,
|
| 165 |
+
)
|
| 166 |
+
return torch.optim.AdamW([{"params": setup.parameters, "lr": lr}], weight_decay=0.0)
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def save_checkpoint(model, tokenizer, output_dir: str, step: int, stats: dict):
|
| 170 |
+
sub = os.path.join(output_dir, f"checkpoint-{step}")
|
| 171 |
+
os.makedirs(sub, exist_ok=True)
|
| 172 |
+
prev_force = getattr(model.config, "aha_force_gate_value", None)
|
| 173 |
+
model.config.aha_force_gate_value = None
|
| 174 |
+
model.save_pretrained(sub, safe_serialization=True)
|
| 175 |
+
tokenizer.save_pretrained(sub)
|
| 176 |
+
model.config.aha_force_gate_value = prev_force
|
| 177 |
+
with open(os.path.join(sub, "dynamic_duo_state.json"), "w") as f:
|
| 178 |
+
json.dump(
|
| 179 |
+
{
|
| 180 |
+
"step": step,
|
| 181 |
+
"router_granularity": getattr(
|
| 182 |
+
model.config,
|
| 183 |
+
"aha_router_granularity",
|
| 184 |
+
AHA_ROUTER_GRANULARITY,
|
| 185 |
+
),
|
| 186 |
+
"native_gate_rows_per_layer": aha_router_output_size(model.config),
|
| 187 |
+
"effective_sparsity_denominator": "token x KV-head x layer",
|
| 188 |
+
**stats,
|
| 189 |
+
**gate_param_stats(model),
|
| 190 |
+
},
|
| 191 |
+
f,
|
| 192 |
+
indent=2,
|
| 193 |
+
)
|
| 194 |
+
args_manifest = os.path.join(output_dir, "dynamic_duo_train_args.json")
|
| 195 |
+
if os.path.exists(args_manifest):
|
| 196 |
+
shutil.copyfile(
|
| 197 |
+
args_manifest,
|
| 198 |
+
os.path.join(sub, "dynamic_duo_train_args.json"),
|
| 199 |
+
)
|
| 200 |
+
print(f"[dynamic-duo] saved {sub}", flush=True)
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def main():
|
| 204 |
+
AHAQwen3Config.register_for_auto_class()
|
| 205 |
+
AHAQwen3ForCausalLM.register_for_auto_class("AutoModelForCausalLM")
|
| 206 |
+
p = argparse.ArgumentParser()
|
| 207 |
+
p.add_argument("--aha_checkpoint", required=True)
|
| 208 |
+
p.add_argument("--model_path", default="/workspace/AHA/models/Qwen3-0.6B")
|
| 209 |
+
p.add_argument("--output_dir", required=True)
|
| 210 |
+
p.add_argument("--am_dataset_path", default="/workspace/Direct-Multitoken-Decoding/am-distilled-8192")
|
| 211 |
+
p.add_argument("--am_dataset_split", default="train")
|
| 212 |
+
p.add_argument(
|
| 213 |
+
"--data_source",
|
| 214 |
+
default="am_distilled",
|
| 215 |
+
choices=["am_distilled", "longbench_lite"],
|
| 216 |
+
help=(
|
| 217 |
+
"Training data for dynamic gate continuation. The default keeps the "
|
| 218 |
+
"legacy AM-distilled behavior. longbench_lite is a diagnostic "
|
| 219 |
+
"target-distribution calibration source, not a paper main protocol."
|
| 220 |
+
),
|
| 221 |
+
)
|
| 222 |
+
p.add_argument(
|
| 223 |
+
"--am_label_mode",
|
| 224 |
+
default="full",
|
| 225 |
+
choices=["full", "answer_only"],
|
| 226 |
+
help=(
|
| 227 |
+
"Label mask for --data_source=am_distilled. full keeps all-token "
|
| 228 |
+
"hidden-state distill; answer_only distills only the final answer span."
|
| 229 |
+
),
|
| 230 |
+
)
|
| 231 |
+
p.add_argument(
|
| 232 |
+
"--longbench_tasks",
|
| 233 |
+
nargs="+",
|
| 234 |
+
default=["passage_retrieval_en", "multifieldqa_en", "qasper", "2wikimqa"],
|
| 235 |
+
help="Tasks used when --data_source=longbench_lite.",
|
| 236 |
+
)
|
| 237 |
+
p.add_argument("--longbench_samples_per_task", type=int, default=30)
|
| 238 |
+
p.add_argument("--longbench_cache_dir", default="/workspace/AHA/AHA-Qwen3/data/longbench_cache")
|
| 239 |
+
p.add_argument("--max_length", type=int, default=8192)
|
| 240 |
+
p.add_argument("--num_steps", type=int, default=400)
|
| 241 |
+
p.add_argument("--warmup_ratio", type=float, default=0.2)
|
| 242 |
+
p.add_argument("--lr", type=float, default=3e-5)
|
| 243 |
+
p.add_argument("--reg_weight", type=float, default=0.05)
|
| 244 |
+
p.add_argument(
|
| 245 |
+
"--reg_head_weight_mode",
|
| 246 |
+
default="uniform",
|
| 247 |
+
choices=["uniform", "duo_alpha_margin"],
|
| 248 |
+
help=(
|
| 249 |
+
"Per-head weighting for the sparsity regularizer. uniform keeps "
|
| 250 |
+
"the original mean(gate_soft). duo_alpha_margin downweights Duo "
|
| 251 |
+
"static-full heads close to alpha=0.5 so fragile boundary full "
|
| 252 |
+
"heads are not pushed local as strongly."
|
| 253 |
+
),
|
| 254 |
+
)
|
| 255 |
+
p.add_argument(
|
| 256 |
+
"--reg_head_weight_power",
|
| 257 |
+
type=float,
|
| 258 |
+
default=1.0,
|
| 259 |
+
help=(
|
| 260 |
+
"Power applied to the Duo alpha margin when "
|
| 261 |
+
"--reg_head_weight_mode=duo_alpha_margin."
|
| 262 |
+
),
|
| 263 |
+
)
|
| 264 |
+
p.add_argument("--ce_weight", type=float, default=0.0)
|
| 265 |
+
p.add_argument(
|
| 266 |
+
"--distill_tail_frac",
|
| 267 |
+
type=float,
|
| 268 |
+
default=0.0,
|
| 269 |
+
help=(
|
| 270 |
+
"If >0, add a tail-aware hidden-state distill term over the top "
|
| 271 |
+
"fraction of labelled-token MSE values. This keeps the standard "
|
| 272 |
+
"mean distill objective but prevents rare long-retrieval errors "
|
| 273 |
+
"from being averaged away."
|
| 274 |
+
),
|
| 275 |
+
)
|
| 276 |
+
p.add_argument(
|
| 277 |
+
"--distill_tail_weight",
|
| 278 |
+
type=float,
|
| 279 |
+
default=0.0,
|
| 280 |
+
help="Weight for the top-token MSE term enabled by --distill_tail_frac.",
|
| 281 |
+
)
|
| 282 |
+
p.add_argument("--batch_size", type=int, default=1)
|
| 283 |
+
p.add_argument("--grad_accum", type=int, default=1)
|
| 284 |
+
p.add_argument("--save_steps", type=int, default=100)
|
| 285 |
+
p.add_argument("--log_steps", type=int, default=10)
|
| 286 |
+
p.add_argument("--seed", type=int, default=42)
|
| 287 |
+
p.add_argument("--dtype", default="bfloat16", choices=["bfloat16", "float16", "float32"])
|
| 288 |
+
p.add_argument("--attn_impl", default="sdpa", choices=["sdpa", "eager"])
|
| 289 |
+
p.add_argument("--aha_local_kind", default="sink_recent", choices=["sink_recent", "sliding_window"])
|
| 290 |
+
p.add_argument(
|
| 291 |
+
"--router_granularity",
|
| 292 |
+
default=None,
|
| 293 |
+
choices=["token", "token_kv_head"],
|
| 294 |
+
help="Assert that the input checkpoint has this persisted router granularity.",
|
| 295 |
+
)
|
| 296 |
+
args = p.parse_args()
|
| 297 |
+
|
| 298 |
+
torch.manual_seed(args.seed)
|
| 299 |
+
random.seed(args.seed)
|
| 300 |
+
np.random.seed(args.seed)
|
| 301 |
+
os.makedirs(args.output_dir, exist_ok=True)
|
| 302 |
+
with open(os.path.join(args.output_dir, "dynamic_duo_train_args.json"), "w") as f:
|
| 303 |
+
json.dump(vars(args), f, indent=2)
|
| 304 |
+
|
| 305 |
+
dtype = {"bfloat16": torch.bfloat16, "float16": torch.float16, "float32": torch.float32}[args.dtype]
|
| 306 |
+
print(f"[dynamic-duo] checkpoint={args.aha_checkpoint}", flush=True)
|
| 307 |
+
print(
|
| 308 |
+
f"[dynamic-duo] loss=hidden_state_distill + {args.reg_weight} * mean(gate_soft), "
|
| 309 |
+
f"ce_weight={args.ce_weight} tail_frac={args.distill_tail_frac} "
|
| 310 |
+
f"tail_weight={args.distill_tail_weight}",
|
| 311 |
+
flush=True,
|
| 312 |
+
)
|
| 313 |
+
if not (0.0 <= args.distill_tail_frac <= 1.0):
|
| 314 |
+
raise ValueError("--distill_tail_frac must be in [0, 1]")
|
| 315 |
+
if args.distill_tail_weight < 0.0:
|
| 316 |
+
raise ValueError("--distill_tail_weight must be non-negative")
|
| 317 |
+
|
| 318 |
+
model = AHAQwen3ForCausalLM.from_pretrained_aha(
|
| 319 |
+
args.aha_checkpoint,
|
| 320 |
+
torch_dtype=dtype,
|
| 321 |
+
attn_implementation=args.attn_impl,
|
| 322 |
+
).cuda()
|
| 323 |
+
if getattr(model.config, "aha_mode", "dynamic") not in ("dynamic", "duo_dynamic"):
|
| 324 |
+
raise ValueError("dynamic_duo_train.py requires a dynamic or duo_dynamic AHA checkpoint")
|
| 325 |
+
loaded_granularity = getattr(
|
| 326 |
+
model.config, "aha_router_granularity", AHA_ROUTER_GRANULARITY
|
| 327 |
+
)
|
| 328 |
+
if args.router_granularity and args.router_granularity != loaded_granularity:
|
| 329 |
+
raise ValueError(
|
| 330 |
+
"--router_granularity does not match checkpoint architecture: "
|
| 331 |
+
f"requested={args.router_granularity!r}, checkpoint={loaded_granularity!r}"
|
| 332 |
+
)
|
| 333 |
+
model.config.aha_local_kind = args.aha_local_kind
|
| 334 |
+
model.config.aha_distill_weight = 0.0
|
| 335 |
+
model.config.aha_ce_weight = 0.0
|
| 336 |
+
model.config.aha_reg_weight = -1.0
|
| 337 |
+
model.config.aha_force_gate_value = None
|
| 338 |
+
print(
|
| 339 |
+
f"[dynamic-duo] router_granularity={loaded_granularity} "
|
| 340 |
+
f"native_gate_rows={aha_router_output_size(model.config)} "
|
| 341 |
+
f"local_kind={model.config.aha_local_kind} "
|
| 342 |
+
f"sink={getattr(model.config, 'duo_sink_size', None)} "
|
| 343 |
+
f"recent={getattr(model.config, 'duo_recent_size', None)}",
|
| 344 |
+
flush=True,
|
| 345 |
+
)
|
| 346 |
+
|
| 347 |
+
tokenizer = AutoTokenizer.from_pretrained(args.model_path, trust_remote_code=True)
|
| 348 |
+
if tokenizer.pad_token_id is None:
|
| 349 |
+
tokenizer.pad_token = tokenizer.eos_token
|
| 350 |
+
|
| 351 |
+
optim = build_gate_optimizer(model, args.lr)
|
| 352 |
+
reg_head_weights = build_reg_head_weights(
|
| 353 |
+
model, args.reg_head_weight_mode, args.reg_head_weight_power
|
| 354 |
+
)
|
| 355 |
+
model.enable_input_require_grads()
|
| 356 |
+
model.gradient_checkpointing_enable(gradient_checkpointing_kwargs={"use_reentrant": False})
|
| 357 |
+
|
| 358 |
+
if args.data_source == "am_distilled":
|
| 359 |
+
dataset = AmDistilledDataset(
|
| 360 |
+
ds_path=args.am_dataset_path,
|
| 361 |
+
split=args.am_dataset_split,
|
| 362 |
+
max_length=args.max_length,
|
| 363 |
+
seed=args.seed,
|
| 364 |
+
tokenizer=tokenizer,
|
| 365 |
+
label_mode=args.am_label_mode,
|
| 366 |
+
)
|
| 367 |
+
print(
|
| 368 |
+
f"[dynamic-duo] data_source=am_distilled path={args.am_dataset_path} "
|
| 369 |
+
f"split={args.am_dataset_split} n={len(dataset):,} "
|
| 370 |
+
f"label_mode={args.am_label_mode} max_length={args.max_length}",
|
| 371 |
+
flush=True,
|
| 372 |
+
)
|
| 373 |
+
elif args.data_source == "longbench_lite":
|
| 374 |
+
dataset = LongBenchLiteAnswerDataset(
|
| 375 |
+
tokenizer=tokenizer,
|
| 376 |
+
tasks=args.longbench_tasks,
|
| 377 |
+
samples_per_task=args.longbench_samples_per_task,
|
| 378 |
+
max_length=args.max_length,
|
| 379 |
+
seed=args.seed,
|
| 380 |
+
cache_dir=args.longbench_cache_dir,
|
| 381 |
+
)
|
| 382 |
+
print(
|
| 383 |
+
f"[dynamic-duo] data_source=longbench_lite tasks={args.longbench_tasks} "
|
| 384 |
+
f"samples_per_task={args.longbench_samples_per_task} "
|
| 385 |
+
f"rows={len(dataset.rows):,} max_length={args.max_length}",
|
| 386 |
+
flush=True,
|
| 387 |
+
)
|
| 388 |
+
else:
|
| 389 |
+
raise ValueError(f"unknown data_source: {args.data_source}")
|
| 390 |
+
loader = DataLoader(dataset, batch_size=args.batch_size, shuffle=False, collate_fn=collate, num_workers=0)
|
| 391 |
+
data_iter = iter(loader)
|
| 392 |
+
warm = max(1, int(args.num_steps * args.warmup_ratio))
|
| 393 |
+
|
| 394 |
+
def lr_at(step):
|
| 395 |
+
if step < warm:
|
| 396 |
+
return max(0.1, (step + 1) / warm)
|
| 397 |
+
if step > args.num_steps - warm:
|
| 398 |
+
return max(0.1, (args.num_steps - step) / warm)
|
| 399 |
+
return 1.0
|
| 400 |
+
|
| 401 |
+
model.train()
|
| 402 |
+
running = {"distill": 0.0, "reg": 0.0, "ce": 0.0, "loss": 0.0, "gate_soft": 0.0, "gate_hard": 0.0}
|
| 403 |
+
steps_in_window = 0
|
| 404 |
+
|
| 405 |
+
for step in range(args.num_steps):
|
| 406 |
+
try:
|
| 407 |
+
batch = next(data_iter)
|
| 408 |
+
except StopIteration:
|
| 409 |
+
data_iter = iter(loader)
|
| 410 |
+
batch = next(data_iter)
|
| 411 |
+
|
| 412 |
+
input_ids = batch["input_ids"].cuda()
|
| 413 |
+
labels = batch["labels"].cuda()
|
| 414 |
+
label_mask = labels != -100
|
| 415 |
+
|
| 416 |
+
prev_force = _set_force_gate(model, 1.0)
|
| 417 |
+
with torch.no_grad():
|
| 418 |
+
out_full = model.model(input_ids=input_ids, use_cache=False)
|
| 419 |
+
h_full = out_full.last_hidden_state
|
| 420 |
+
_restore_force_gate(model, prev_force)
|
| 421 |
+
|
| 422 |
+
out_mix = model.model(input_ids=input_ids, use_cache=False)
|
| 423 |
+
h_mix = out_mix.last_hidden_state
|
| 424 |
+
|
| 425 |
+
if label_mask.any():
|
| 426 |
+
diff = (h_full.float() - h_mix.float())[label_mask]
|
| 427 |
+
else:
|
| 428 |
+
diff = (h_full.float() - h_mix.float()).reshape(-1, h_mix.shape[-1])
|
| 429 |
+
token_mse = diff.pow(2).mean(dim=-1)
|
| 430 |
+
distill_mean = token_mse.mean()
|
| 431 |
+
if args.distill_tail_frac > 0.0 and args.distill_tail_weight > 0.0:
|
| 432 |
+
k = max(1, int(np.ceil(token_mse.numel() * args.distill_tail_frac)))
|
| 433 |
+
distill_tail = torch.topk(token_mse, k=k, largest=True).values.mean()
|
| 434 |
+
distill = distill_mean + args.distill_tail_weight * distill_tail
|
| 435 |
+
else:
|
| 436 |
+
distill_tail = h_mix.new_zeros((), dtype=torch.float32)
|
| 437 |
+
distill = distill_mean
|
| 438 |
+
|
| 439 |
+
reg = sparsity_reg_from_gates(out_mix, reg_head_weights)
|
| 440 |
+
|
| 441 |
+
if args.ce_weight > 0.0:
|
| 442 |
+
logits = model.lm_head(h_mix).float()
|
| 443 |
+
shift_logits = logits[:, :-1, :].contiguous()
|
| 444 |
+
shift_labels = labels[:, 1:].contiguous()
|
| 445 |
+
ce = torch.nn.functional.cross_entropy(
|
| 446 |
+
shift_logits.view(-1, shift_logits.size(-1)),
|
| 447 |
+
shift_labels.view(-1),
|
| 448 |
+
ignore_index=-100,
|
| 449 |
+
)
|
| 450 |
+
else:
|
| 451 |
+
ce = h_mix.new_zeros((), dtype=torch.float32)
|
| 452 |
+
|
| 453 |
+
loss = distill + args.reg_weight * reg + args.ce_weight * ce
|
| 454 |
+
(loss / args.grad_accum).backward()
|
| 455 |
+
|
| 456 |
+
if (step + 1) % args.grad_accum == 0:
|
| 457 |
+
for group in optim.param_groups:
|
| 458 |
+
group["lr"] = args.lr * lr_at(step)
|
| 459 |
+
optim.step()
|
| 460 |
+
optim.zero_grad()
|
| 461 |
+
|
| 462 |
+
stats = gate_stats_from_output(out_mix)
|
| 463 |
+
running["distill"] += float(distill.detach())
|
| 464 |
+
running["reg"] += float(reg.detach())
|
| 465 |
+
running["ce"] += float(ce.detach())
|
| 466 |
+
running["loss"] += float(loss.detach())
|
| 467 |
+
running["gate_soft"] += stats["gate_soft_mean"]
|
| 468 |
+
running["gate_hard"] += stats["gate_hard_mean"]
|
| 469 |
+
steps_in_window += 1
|
| 470 |
+
|
| 471 |
+
if (step + 1) % args.log_steps == 0:
|
| 472 |
+
denom = float(steps_in_window)
|
| 473 |
+
print(
|
| 474 |
+
f"[step {step + 1:4d}/{args.num_steps}] "
|
| 475 |
+
f"distill={running['distill']/denom:.6f} "
|
| 476 |
+
f"reg={running['reg']/denom:.6f} "
|
| 477 |
+
f"loss={running['loss']/denom:.6f} "
|
| 478 |
+
f"gate_soft={running['gate_soft']/denom:.4f} "
|
| 479 |
+
f"gate_hard={running['gate_hard']/denom:.4f} "
|
| 480 |
+
f"lr={optim.param_groups[0]['lr']:.4e} "
|
| 481 |
+
f"seq_len={input_ids.shape[1]}",
|
| 482 |
+
flush=True,
|
| 483 |
+
)
|
| 484 |
+
running = {k: 0.0 for k in running}
|
| 485 |
+
steps_in_window = 0
|
| 486 |
+
|
| 487 |
+
if (step + 1) % args.save_steps == 0 or (step + 1) == args.num_steps:
|
| 488 |
+
save_checkpoint(model, tokenizer, args.output_dir, step + 1, stats)
|
| 489 |
+
|
| 490 |
+
print(f"[dynamic-duo] done. Final gate param stats: {gate_param_stats(model)}", flush=True)
|
| 491 |
+
|
| 492 |
+
|
| 493 |
+
if __name__ == "__main__":
|
| 494 |
+
main()
|
recipe/manifest.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"schema": "aha-l2a-qwen3-repro-v1",
|
| 3 |
+
"source_commit": "b47a549",
|
| 4 |
+
"files": {
|
| 5 |
+
"../model.safetensors": "22c971a65f7f1835b0e2a38b45f2f92191f5d8b63b30969f2f61a69d30afbc05",
|
| 6 |
+
"data/am_distilled_long_mix/train/data-00000-of-00001.arrow": "27f158ba22512269d33e8fb14db97a84e541ca9b701fba73a98e70c73687f45c",
|
| 7 |
+
"data/eval_inputs/helmet_icl_8k_n50_per_config.jsonl": "76e4c8115014d11e2dbf47ca8a2e300c490fd8e454326d5e589b12682b7f140e",
|
| 8 |
+
"data/eval_inputs/mrcr_8k_2_4_8needle_n10_per_config.jsonl": "7074281289fa051278e213ecea523cedf3f609e89b6ae13ab575c075f386ddc4",
|
| 9 |
+
"modeling_aha_qwen3.py": "4b10c633ab62dbbacbd2a9ac0c69f8225876e97f70bd6ddc4b37367053e2fc1b",
|
| 10 |
+
"dynamic_duo_train.py": "14188f72ae418831ccda997d0e4cae71c808a59b96aabd3be1e289536a1e073e",
|
| 11 |
+
"sft.py": "d1f7ae5818639b25ed130433073f4717dcad310dc941bf4a13a9063730b4e555"
|
| 12 |
+
}
|
| 13 |
+
}
|
recipe/modeling_aha_qwen3.py
ADDED
|
@@ -0,0 +1,1226 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AHA-Qwen3: All-or-Here Attention applied to Qwen3.
|
| 3 |
+
|
| 4 |
+
Minimal modification to standard Qwen3 attention: adds a binary gate that
|
| 5 |
+
dynamically toggles between full attention and local attention. The persisted
|
| 6 |
+
``aha_router_granularity`` config selects either one gate per (token, KV-head)
|
| 7 |
+
or one gate per token shared by every head in the layer.
|
| 8 |
+
|
| 9 |
+
Based on: transformers.models.qwen3.modeling_qwen3 (Qwen3Attention)
|
| 10 |
+
Changes:
|
| 11 |
+
1. q_proj outputs either one extra gate value (``token``) or
|
| 12 |
+
``num_key_value_heads`` values (``token_kv_head``). The shared-token
|
| 13 |
+
value is broadcast to all KV/query heads before attention mixing.
|
| 14 |
+
2. Gate: sigmoid → hard threshold → STE for training
|
| 15 |
+
3. Double-mix: global attention + local window attention, blended by
|
| 16 |
+
gate (broadcast across the GQA group)
|
| 17 |
+
4. Auxiliary loss: λ * mean(gate_soft) to encourage sparsity
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
import atexit
|
| 21 |
+
import json
|
| 22 |
+
import os
|
| 23 |
+
from dataclasses import dataclass
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
from typing import Callable, Optional, Union
|
| 26 |
+
|
| 27 |
+
import torch
|
| 28 |
+
import torch.nn as nn
|
| 29 |
+
from safetensors.torch import load_file
|
| 30 |
+
|
| 31 |
+
from transformers import Qwen3Config, Qwen3ForCausalLM
|
| 32 |
+
from transformers.modeling_layers import GradientCheckpointingLayer
|
| 33 |
+
from transformers.models.qwen3.modeling_qwen3 import (
|
| 34 |
+
Qwen3Attention,
|
| 35 |
+
Qwen3DecoderLayer,
|
| 36 |
+
Qwen3Model,
|
| 37 |
+
Qwen3RMSNorm,
|
| 38 |
+
apply_rotary_pos_emb,
|
| 39 |
+
)
|
| 40 |
+
from transformers.cache_utils import Cache, DynamicCache
|
| 41 |
+
from transformers.masking_utils import create_causal_mask, create_sliding_window_causal_mask
|
| 42 |
+
from transformers.modeling_utils import ALL_ATTENTION_FUNCTIONS
|
| 43 |
+
from transformers.modeling_outputs import BaseModelOutputWithPast, CausalLMOutputWithPast
|
| 44 |
+
from transformers.processing_utils import Unpack
|
| 45 |
+
from transformers.utils.generic import TransformersKwargs
|
| 46 |
+
|
| 47 |
+
# AHA constants
|
| 48 |
+
AHA_WINDOW_SIZE = 1024
|
| 49 |
+
AHA_LAMBDA = 3e-4
|
| 50 |
+
AHA_DISTILL_WEIGHT = 0.0
|
| 51 |
+
AHA_CE_WEIGHT = 1.0
|
| 52 |
+
# Gate-density ceiling (hinge target). The aux loss only fires when
|
| 53 |
+
# ``mean(gate_soft) > AHA_GATE_TARGET``, so sparsity cannot drop
|
| 54 |
+
# below ``1 - AHA_GATE_TARGET`` in steady state regardless of how
|
| 55 |
+
# large ``aha_lambda`` is. Setting this to 1.0 recovers the legacy
|
| 56 |
+
# behaviour (unconditional downward pressure).
|
| 57 |
+
AHA_GATE_TARGET = 1.0
|
| 58 |
+
|
| 59 |
+
# DuoAttention baseline knobs. When ``aha_mode == "duo"`` the per-(token,
|
| 60 |
+
# kv_head) gate is replaced by a per-(layer, kv_head) learnable scalar
|
| 61 |
+
# (``duo_alpha``, 224 scalars total for Qwen3-0.6B), and the "local"
|
| 62 |
+
# branch becomes a streaming mask with ``duo_sink_size`` attention sinks
|
| 63 |
+
# plus a recent window of ``duo_recent_size``. This is a strict reduction
|
| 64 |
+
# of the dynamic AHA gate and reproduces the DuoAttention paper's head
|
| 65 |
+
# classifier (Xiao et al. 2024) on the Qwen3 backbone.
|
| 66 |
+
AHA_MODE = "dynamic"
|
| 67 |
+
DUO_SINK_SIZE = 64
|
| 68 |
+
DUO_RECENT_SIZE = 256
|
| 69 |
+
DUO_ALPHA_INIT = 1.0
|
| 70 |
+
# Local-branch mask kind for the dual-branch attention (global vs local).
|
| 71 |
+
# - "sliding_window": last ``aha_window_size`` tokens, no attention sinks.
|
| 72 |
+
# Historical default for ``aha_mode="dynamic"`` (Bv3 was trained with this).
|
| 73 |
+
# - "sink_recent": first ``duo_sink_size`` tokens + last ``duo_recent_size``
|
| 74 |
+
# tokens, matching the DuoAttention paper. Historical hardcoded default for
|
| 75 |
+
# ``aha_mode="duo"`` (still forced for duo regardless of this field).
|
| 76 |
+
# § 9.4.13.3 found that hot-starting a dynamic-mode ckpt from a duo ckpt with
|
| 77 |
+
# sliding_window local loses ~20pp LongBench retention vs the duo source,
|
| 78 |
+
# almost entirely from passage_retrieval_en (sinks are critical for long
|
| 79 |
+
# passage retrieval). Setting this to "sink_recent" for hot-started dynamic
|
| 80 |
+
# ckpts closes that architectural gap.
|
| 81 |
+
AHA_LOCAL_KIND = "sliding_window"
|
| 82 |
+
AHA_ROUTER_GRANULARITY = "token_kv_head"
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def aha_router_output_size(config: Qwen3Config) -> int:
|
| 86 |
+
"""Return the number of native dynamic-router logits per token."""
|
| 87 |
+
|
| 88 |
+
granularity = getattr(config, "aha_router_granularity", AHA_ROUTER_GRANULARITY)
|
| 89 |
+
if granularity == "token":
|
| 90 |
+
return 1
|
| 91 |
+
if granularity == "token_kv_head":
|
| 92 |
+
return int(config.num_key_value_heads)
|
| 93 |
+
raise ValueError(f"Unsupported aha_router_granularity={granularity!r}")
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
class _AHAInferenceSparsityTracker:
|
| 97 |
+
"""Accumulate exact inference hard-route counts without changing outputs."""
|
| 98 |
+
|
| 99 |
+
def __init__(self) -> None:
|
| 100 |
+
# Keep counters as device scalars until process exit. Calling .item()
|
| 101 |
+
# once per layer and decode token serialized CUDA and made the
|
| 102 |
+
# diagnostic tracker materially slow generation.
|
| 103 |
+
self.sparse_decisions: dict[int, torch.Tensor] = {}
|
| 104 |
+
self.total_decisions: dict[int, int] = {}
|
| 105 |
+
self.phase_sparse_decisions: dict[str, torch.Tensor] = {}
|
| 106 |
+
self.phase_total_decisions: dict[str, int] = {}
|
| 107 |
+
raw_thresholds = os.environ.get("AHA_COUNTERFACTUAL_THRESHOLDS", "")
|
| 108 |
+
self.counterfactual_thresholds = tuple(
|
| 109 |
+
sorted({float(value) for value in raw_thresholds.split(",") if value.strip()})
|
| 110 |
+
)
|
| 111 |
+
if any(value < 0.0 or value > 1.0 for value in self.counterfactual_thresholds):
|
| 112 |
+
raise ValueError("AHA_COUNTERFACTUAL_THRESHOLDS values must be in [0, 1]")
|
| 113 |
+
self.counterfactual_sparse: dict[float, torch.Tensor] = {}
|
| 114 |
+
self.router_granularity: Optional[str] = None
|
| 115 |
+
self.native_router_decisions = 0
|
| 116 |
+
atexit.register(self.write_stats)
|
| 117 |
+
|
| 118 |
+
def update(
|
| 119 |
+
self,
|
| 120 |
+
gate_hard: torch.Tensor,
|
| 121 |
+
gate_soft: torch.Tensor,
|
| 122 |
+
layer_idx: int,
|
| 123 |
+
phase: str,
|
| 124 |
+
router_granularity: str,
|
| 125 |
+
native_router_width: int,
|
| 126 |
+
) -> None:
|
| 127 |
+
if self.router_granularity is None:
|
| 128 |
+
self.router_granularity = router_granularity
|
| 129 |
+
elif self.router_granularity != router_granularity:
|
| 130 |
+
raise RuntimeError(
|
| 131 |
+
"AHA sparsity tracker received mixed router granularities: "
|
| 132 |
+
f"{self.router_granularity!r} and {router_granularity!r}"
|
| 133 |
+
)
|
| 134 |
+
sparse = torch.count_nonzero(gate_hard == 0).to(torch.int64)
|
| 135 |
+
total = int(gate_hard.numel())
|
| 136 |
+
effective_width = int(gate_hard.shape[-1])
|
| 137 |
+
self.native_router_decisions += total // effective_width * native_router_width
|
| 138 |
+
if layer_idx not in self.sparse_decisions:
|
| 139 |
+
self.sparse_decisions[layer_idx] = sparse
|
| 140 |
+
else:
|
| 141 |
+
self.sparse_decisions[layer_idx].add_(sparse)
|
| 142 |
+
self.total_decisions[layer_idx] = self.total_decisions.get(layer_idx, 0) + total
|
| 143 |
+
if phase not in self.phase_sparse_decisions:
|
| 144 |
+
self.phase_sparse_decisions[phase] = sparse.clone()
|
| 145 |
+
else:
|
| 146 |
+
self.phase_sparse_decisions[phase].add_(sparse)
|
| 147 |
+
self.phase_total_decisions[phase] = self.phase_total_decisions.get(phase, 0) + total
|
| 148 |
+
if self.counterfactual_thresholds:
|
| 149 |
+
flat = gate_soft.detach().reshape(-1, 1)
|
| 150 |
+
thresholds = flat.new_tensor(self.counterfactual_thresholds).reshape(1, -1)
|
| 151 |
+
counts = (flat <= thresholds).sum(dim=0, dtype=torch.int64)
|
| 152 |
+
for threshold, count in zip(self.counterfactual_thresholds, counts.unbind()):
|
| 153 |
+
if threshold not in self.counterfactual_sparse:
|
| 154 |
+
self.counterfactual_sparse[threshold] = count
|
| 155 |
+
else:
|
| 156 |
+
self.counterfactual_sparse[threshold].add_(count)
|
| 157 |
+
|
| 158 |
+
def write_stats(self) -> None:
|
| 159 |
+
output = os.environ.get("AHA_SPARSITY_STATS_PATH")
|
| 160 |
+
if not output:
|
| 161 |
+
return
|
| 162 |
+
sparse_by_layer = {
|
| 163 |
+
layer_idx: int(value.item())
|
| 164 |
+
for layer_idx, value in self.sparse_decisions.items()
|
| 165 |
+
}
|
| 166 |
+
sparse = sum(sparse_by_layer.values())
|
| 167 |
+
total = sum(self.total_decisions.values())
|
| 168 |
+
per_layer = {}
|
| 169 |
+
for layer_idx in sorted(self.total_decisions):
|
| 170 |
+
layer_sparse = sparse_by_layer.get(layer_idx, 0)
|
| 171 |
+
layer_total = self.total_decisions[layer_idx]
|
| 172 |
+
per_layer[str(layer_idx)] = {
|
| 173 |
+
"sparse_decisions": layer_sparse,
|
| 174 |
+
"total_decisions": layer_total,
|
| 175 |
+
"sparsity": layer_sparse / layer_total if layer_total else None,
|
| 176 |
+
}
|
| 177 |
+
payload = {
|
| 178 |
+
"definition": (
|
| 179 |
+
"hard AHA gate zeros / all token x KV-head x layer decisions, "
|
| 180 |
+
"token-weighted over prefill and decode"
|
| 181 |
+
),
|
| 182 |
+
"router_granularity": self.router_granularity,
|
| 183 |
+
"native_router_decisions": self.native_router_decisions,
|
| 184 |
+
"effective_router_decisions": total,
|
| 185 |
+
"native_router_note": (
|
| 186 |
+
"Native decisions count learned gate logits before broadcast; effective "
|
| 187 |
+
"decisions always use token x KV-head x layer for matched sparsity."
|
| 188 |
+
),
|
| 189 |
+
"sparse_decisions": sparse,
|
| 190 |
+
"total_decisions": total,
|
| 191 |
+
"sparsity": sparse / total if total else None,
|
| 192 |
+
"full_attention_usage": 1.0 - sparse / total if total else None,
|
| 193 |
+
"per_layer": per_layer,
|
| 194 |
+
"by_phase": {
|
| 195 |
+
phase: {
|
| 196 |
+
"sparse_decisions": int(self.phase_sparse_decisions[phase].item()),
|
| 197 |
+
"total_decisions": phase_total,
|
| 198 |
+
"sparsity": int(self.phase_sparse_decisions[phase].item()) / phase_total
|
| 199 |
+
if phase_total else None,
|
| 200 |
+
"full_attention_usage": 1.0
|
| 201 |
+
- int(self.phase_sparse_decisions[phase].item()) / phase_total
|
| 202 |
+
if phase_total else None,
|
| 203 |
+
}
|
| 204 |
+
for phase, phase_total in sorted(self.phase_total_decisions.items())
|
| 205 |
+
},
|
| 206 |
+
}
|
| 207 |
+
if self.counterfactual_thresholds:
|
| 208 |
+
payload["counterfactual_sparsity_by_threshold"] = {
|
| 209 |
+
f"{threshold:.6g}": {
|
| 210 |
+
"sparse_decisions": int(self.counterfactual_sparse[threshold].item()),
|
| 211 |
+
"total_decisions": total,
|
| 212 |
+
"sparsity": int(self.counterfactual_sparse[threshold].item()) / total
|
| 213 |
+
if total else None,
|
| 214 |
+
}
|
| 215 |
+
for threshold in self.counterfactual_thresholds
|
| 216 |
+
}
|
| 217 |
+
payload["counterfactual_note"] = (
|
| 218 |
+
"Routing counts only; benchmark quality must be measured in a separate "
|
| 219 |
+
"run with AHA_GATE_HARD_THRESHOLD set to the selected threshold."
|
| 220 |
+
)
|
| 221 |
+
path = Path(output)
|
| 222 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 223 |
+
path.write_text(json.dumps(payload, indent=2) + "\n")
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
_AHA_INFERENCE_SPARSITY_TRACKER: Optional[_AHAInferenceSparsityTracker] = None
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def _track_aha_inference_sparsity(
|
| 230 |
+
gate_hard: torch.Tensor,
|
| 231 |
+
gate_soft: torch.Tensor,
|
| 232 |
+
layer_idx: int,
|
| 233 |
+
phase: str,
|
| 234 |
+
router_granularity: str,
|
| 235 |
+
native_router_width: int,
|
| 236 |
+
) -> None:
|
| 237 |
+
global _AHA_INFERENCE_SPARSITY_TRACKER
|
| 238 |
+
if not os.environ.get("AHA_SPARSITY_STATS_PATH"):
|
| 239 |
+
return
|
| 240 |
+
if _AHA_INFERENCE_SPARSITY_TRACKER is None:
|
| 241 |
+
_AHA_INFERENCE_SPARSITY_TRACKER = _AHAInferenceSparsityTracker()
|
| 242 |
+
_AHA_INFERENCE_SPARSITY_TRACKER.update(
|
| 243 |
+
gate_hard.detach(), gate_soft.detach(), layer_idx, phase,
|
| 244 |
+
router_granularity, native_router_width,
|
| 245 |
+
)
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
def _get_optional_nonnegative_int_env(name: str) -> Optional[int]:
|
| 249 |
+
raw = os.environ.get(name, "").strip()
|
| 250 |
+
if not raw:
|
| 251 |
+
return None
|
| 252 |
+
value = int(raw)
|
| 253 |
+
if value < 0:
|
| 254 |
+
raise ValueError(f"{name} must be non-negative, got {value}")
|
| 255 |
+
return value
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def _apply_low_alpha_force_full_heads_from_env(model: nn.Module) -> None:
|
| 259 |
+
"""Auto-protect the weakest static-full heads in duo_dynamic mode.
|
| 260 |
+
|
| 261 |
+
``AHA_FORCE_LOW_ALPHA_FULL_HEADS=K`` selects the K smallest
|
| 262 |
+
``full_attention_heads`` values among heads that static Duo still kept
|
| 263 |
+
full (alpha > 0.5). These are the fragile boundary full heads: raising the
|
| 264 |
+
dynamic threshold can close them, but static Duo's alpha says they should
|
| 265 |
+
not be fully discarded. The selected heads are OR-ed with any manually
|
| 266 |
+
supplied ``AHA_FORCE_FULL_HEADS`` mask.
|
| 267 |
+
"""
|
| 268 |
+
k = _get_optional_nonnegative_int_env("AHA_FORCE_LOW_ALPHA_FULL_HEADS")
|
| 269 |
+
if not k:
|
| 270 |
+
return
|
| 271 |
+
if getattr(model.config, "aha_mode", "dynamic") != "duo_dynamic":
|
| 272 |
+
return
|
| 273 |
+
|
| 274 |
+
candidates: list[tuple[float, int, int]] = []
|
| 275 |
+
with torch.no_grad():
|
| 276 |
+
for layer_idx, layer in enumerate(model.model.layers):
|
| 277 |
+
attn = layer.self_attn
|
| 278 |
+
alpha = getattr(attn, "full_attention_heads", None)
|
| 279 |
+
if alpha is None:
|
| 280 |
+
continue
|
| 281 |
+
for head_idx, value in enumerate(alpha.detach().float().cpu().tolist()):
|
| 282 |
+
if value > 0.5:
|
| 283 |
+
candidates.append((float(value), layer_idx, head_idx))
|
| 284 |
+
selected = sorted(candidates)[: min(k, len(candidates))]
|
| 285 |
+
if not selected:
|
| 286 |
+
return
|
| 287 |
+
|
| 288 |
+
selected_by_layer: dict[int, list[int]] = {}
|
| 289 |
+
for _, layer_idx, head_idx in selected:
|
| 290 |
+
selected_by_layer.setdefault(layer_idx, []).append(head_idx)
|
| 291 |
+
with torch.no_grad():
|
| 292 |
+
for layer_idx, heads in selected_by_layer.items():
|
| 293 |
+
mask = model.model.layers[layer_idx].self_attn._aha_force_full_heads_mask
|
| 294 |
+
for head_idx in heads:
|
| 295 |
+
mask[head_idx] = True
|
| 296 |
+
model.config.aha_force_low_alpha_full_heads = int(k)
|
| 297 |
+
formatted = ",".join(f"{layer}:{head}" for _, layer, head in selected)
|
| 298 |
+
print(
|
| 299 |
+
f"[AHA] AHA_FORCE_LOW_ALPHA_FULL_HEADS={k} selected {len(selected)} "
|
| 300 |
+
f"duo_dynamic heads: {formatted}",
|
| 301 |
+
flush=True,
|
| 302 |
+
)
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
class AHAQwen3Config(Qwen3Config):
|
| 306 |
+
"""Qwen3Config with AHA-specific parameters."""
|
| 307 |
+
model_type = "aha_qwen3"
|
| 308 |
+
|
| 309 |
+
def __init__(
|
| 310 |
+
self,
|
| 311 |
+
aha_window_size=AHA_WINDOW_SIZE,
|
| 312 |
+
aha_lambda=AHA_LAMBDA,
|
| 313 |
+
aha_distill_weight=AHA_DISTILL_WEIGHT,
|
| 314 |
+
aha_ce_weight=AHA_CE_WEIGHT,
|
| 315 |
+
aha_gate_target=AHA_GATE_TARGET,
|
| 316 |
+
aha_reg_weight=-1.0,
|
| 317 |
+
aha_force_gate_value=None,
|
| 318 |
+
aha_mode=AHA_MODE,
|
| 319 |
+
duo_sink_size=DUO_SINK_SIZE,
|
| 320 |
+
duo_recent_size=DUO_RECENT_SIZE,
|
| 321 |
+
duo_alpha_init=DUO_ALPHA_INIT,
|
| 322 |
+
aha_local_kind=AHA_LOCAL_KIND,
|
| 323 |
+
aha_router_granularity=AHA_ROUTER_GRANULARITY,
|
| 324 |
+
**kwargs,
|
| 325 |
+
):
|
| 326 |
+
super().__init__(**kwargs)
|
| 327 |
+
self.aha_window_size = aha_window_size
|
| 328 |
+
self.aha_lambda = aha_lambda
|
| 329 |
+
# Per-layer attention distillation weight. When > 0 and model is
|
| 330 |
+
# training, each layer contributes ``mean(((1-g)·(global-local))**2)``
|
| 331 |
+
# to the total loss. This is a dense per-(token, head) signal that
|
| 332 |
+
# pushes the gate toward 1 where SWA is insufficient to approximate
|
| 333 |
+
# full attention. Cost: ~0 extra forward compute (global/local are
|
| 334 |
+
# already computed for the mix).
|
| 335 |
+
self.aha_distill_weight = aha_distill_weight
|
| 336 |
+
# Language-modeling CE weight for the training objective. Default 1.0
|
| 337 |
+
# keeps the standard SFT behaviour. Set to 0.0 to drop CE entirely
|
| 338 |
+
# and train the gate purely from ``aux + distill`` (useful when the
|
| 339 |
+
# backbone is frozen and we only want to shape the gate). Evaluation
|
| 340 |
+
# always reports unweighted ``ce_loss`` regardless of this setting.
|
| 341 |
+
self.aha_ce_weight = aha_ce_weight
|
| 342 |
+
# Direct Duo-style sparsity regularizer. When >= 0, this replaces the
|
| 343 |
+
# legacy hinge term and uses ``aha_reg_weight * mean(gate_soft)``.
|
| 344 |
+
# Default -1 keeps older checkpoints on their original hinge objective.
|
| 345 |
+
self.aha_reg_weight = float(aha_reg_weight)
|
| 346 |
+
# Optional override used by Duo-style dynamic distillation. When set
|
| 347 |
+
# to 1.0, dynamic mode behaves as a full-attention teacher for one
|
| 348 |
+
# forward; None keeps the learned gate.
|
| 349 |
+
self.aha_force_gate_value = aha_force_gate_value
|
| 350 |
+
# Target ceiling on mean(gate_soft). Aux loss is hinge-shaped:
|
| 351 |
+
# ``λ · max(0, mean(gate_soft) - τ)``. Setting τ < 1.0 guarantees
|
| 352 |
+
# the gate cannot drop below ``1 - τ`` local-attention fraction
|
| 353 |
+
# in steady state -- the failure mode we observed with the legacy
|
| 354 |
+
# unconditional aux, where ``λ = 1.0`` drove mean(gate_hard) to
|
| 355 |
+
# 0.005 (99.5% local) and killed long-context accuracy
|
| 356 |
+
# (gsm8k 74% -> 19%). Default 1.0 is a no-op for backward
|
| 357 |
+
# compatibility with checkpoints trained on the old loss.
|
| 358 |
+
self.aha_gate_target = aha_gate_target
|
| 359 |
+
# DuoAttention-style baseline. ``dynamic`` keeps the original per-
|
| 360 |
+
# (token, kv_head) MLP gate. ``duo`` replaces it with a per-(layer,
|
| 361 |
+
# kv_head) learnable scalar and swaps the local branch's mask for a
|
| 362 |
+
# streaming (sink + recent) pattern. ``duo_dynamic`` keeps the Duo
|
| 363 |
+
# static mask for KV-capacity planning, but applies a dynamic AHA gate
|
| 364 |
+
# only on the static-full heads: effective_full = duo_full AND dyn_gate.
|
| 365 |
+
# Sink / recent sizes are fixed at train time; set ``duo_sink_size=0``
|
| 366 |
+
# for the sink-ablation run.
|
| 367 |
+
if aha_mode not in ("dynamic", "duo", "duo_dynamic"):
|
| 368 |
+
raise ValueError(
|
| 369 |
+
f"aha_mode must be 'dynamic', 'duo', or 'duo_dynamic', got {aha_mode!r}"
|
| 370 |
+
)
|
| 371 |
+
self.aha_mode = aha_mode
|
| 372 |
+
if aha_router_granularity not in ("token", "token_kv_head"):
|
| 373 |
+
raise ValueError(
|
| 374 |
+
"aha_router_granularity must be 'token' or 'token_kv_head', "
|
| 375 |
+
f"got {aha_router_granularity!r}"
|
| 376 |
+
)
|
| 377 |
+
# Persist this even for static Duo checkpoints. Dynamic AHA uses it to
|
| 378 |
+
# choose the native q_proj gate-row count; old configs omit the field
|
| 379 |
+
# and therefore retain the historical token_kv_head behavior.
|
| 380 |
+
self.aha_router_granularity = aha_router_granularity
|
| 381 |
+
self.duo_sink_size = int(duo_sink_size)
|
| 382 |
+
self.duo_recent_size = int(duo_recent_size)
|
| 383 |
+
self.duo_alpha_init = float(duo_alpha_init)
|
| 384 |
+
if aha_local_kind not in ("sliding_window", "sink_recent"):
|
| 385 |
+
raise ValueError(
|
| 386 |
+
f"aha_local_kind must be 'sliding_window' or 'sink_recent', "
|
| 387 |
+
f"got {aha_local_kind!r}"
|
| 388 |
+
)
|
| 389 |
+
# When aha_mode="duo" the local branch is always sink+recent regardless
|
| 390 |
+
# of this field (forced to the historical DuoAttention behaviour). The
|
| 391 |
+
# field only controls the dynamic mode's local mask.
|
| 392 |
+
self.aha_local_kind = aha_local_kind
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
def _build_streaming_causal_mask(
|
| 396 |
+
seq_len_q: int,
|
| 397 |
+
seq_len_kv: int,
|
| 398 |
+
sink_size: int,
|
| 399 |
+
recent_size: int,
|
| 400 |
+
device: torch.device,
|
| 401 |
+
dtype: torch.dtype,
|
| 402 |
+
kv_offset: int = 0,
|
| 403 |
+
) -> torch.Tensor:
|
| 404 |
+
"""Construct a streaming attention mask (sink + recent, causal).
|
| 405 |
+
|
| 406 |
+
Returns an additive mask of shape ``[1, 1, Q, K]`` where
|
| 407 |
+
disallowed positions are ``finfo(dtype).min`` and allowed positions
|
| 408 |
+
are ``0``. A query at position ``q`` (absolute in the sequence,
|
| 409 |
+
with ``kv_offset`` tokens already in the KV cache) attends to a key
|
| 410 |
+
at position ``k`` iff ``k <= q_abs`` AND (``k < sink_size`` OR
|
| 411 |
+
``q_abs - k < recent_size``).
|
| 412 |
+
"""
|
| 413 |
+
q_abs = torch.arange(seq_len_q, device=device).unsqueeze(-1) + kv_offset # [Q, 1]
|
| 414 |
+
k_abs = torch.arange(seq_len_kv, device=device).unsqueeze(0) # [1, K]
|
| 415 |
+
causal = k_abs <= q_abs
|
| 416 |
+
sink = k_abs < sink_size if sink_size > 0 else torch.zeros_like(causal)
|
| 417 |
+
recent = (q_abs - k_abs) < recent_size
|
| 418 |
+
allowed = causal & (sink | recent)
|
| 419 |
+
mask = torch.zeros(seq_len_q, seq_len_kv, dtype=dtype, device=device)
|
| 420 |
+
mask.masked_fill_(~allowed, torch.finfo(dtype).min)
|
| 421 |
+
return mask.unsqueeze(0).unsqueeze(0)
|
| 422 |
+
|
| 423 |
+
|
| 424 |
+
class AHAQwen3Attention(nn.Module):
|
| 425 |
+
"""Qwen3 attention with AHA gate for per-head local/global routing."""
|
| 426 |
+
|
| 427 |
+
def __init__(self, config: AHAQwen3Config, layer_idx: int):
|
| 428 |
+
super().__init__()
|
| 429 |
+
self.config = config
|
| 430 |
+
self.layer_idx = layer_idx
|
| 431 |
+
self.head_dim = getattr(config, "head_dim", config.hidden_size // config.num_attention_heads)
|
| 432 |
+
self.num_key_value_groups = config.num_attention_heads // config.num_key_value_heads
|
| 433 |
+
self.scaling = self.head_dim ** -0.5
|
| 434 |
+
self.attention_dropout = config.attention_dropout
|
| 435 |
+
self.is_causal = True
|
| 436 |
+
self.aha_window_size = config.aha_window_size
|
| 437 |
+
|
| 438 |
+
# q_proj: extra router outputs for either one shared per-token gate or
|
| 439 |
+
# one per-(token, KV-head) gate.
|
| 440 |
+
# DUO mode uses a per-(layer, kv_head) static scalar instead, so
|
| 441 |
+
# q_proj keeps its vanilla Qwen3 shape and is binary-compatible
|
| 442 |
+
# with the base model checkpoint.
|
| 443 |
+
self._has_dyn_gate_logits = getattr(config, "aha_mode", "dynamic") in (
|
| 444 |
+
"dynamic",
|
| 445 |
+
"duo_dynamic",
|
| 446 |
+
)
|
| 447 |
+
self.aha_router_outputs = aha_router_output_size(config)
|
| 448 |
+
q_out_dim = config.num_attention_heads * self.head_dim
|
| 449 |
+
if self._has_dyn_gate_logits:
|
| 450 |
+
q_out_dim += self.aha_router_outputs
|
| 451 |
+
self.q_proj = nn.Linear(
|
| 452 |
+
config.hidden_size,
|
| 453 |
+
q_out_dim,
|
| 454 |
+
bias=config.attention_bias,
|
| 455 |
+
)
|
| 456 |
+
self.k_proj = nn.Linear(
|
| 457 |
+
config.hidden_size, config.num_key_value_heads * self.head_dim, bias=config.attention_bias
|
| 458 |
+
)
|
| 459 |
+
self.v_proj = nn.Linear(
|
| 460 |
+
config.hidden_size, config.num_key_value_heads * self.head_dim, bias=config.attention_bias
|
| 461 |
+
)
|
| 462 |
+
self.o_proj = nn.Linear(
|
| 463 |
+
config.num_attention_heads * self.head_dim, config.hidden_size, bias=config.attention_bias
|
| 464 |
+
)
|
| 465 |
+
# Qwen3 applies q_norm/k_norm per head_dim (not full projection)
|
| 466 |
+
self.q_norm = Qwen3RMSNorm(self.head_dim, eps=config.rms_norm_eps)
|
| 467 |
+
self.k_norm = Qwen3RMSNorm(self.head_dim, eps=config.rms_norm_eps)
|
| 468 |
+
|
| 469 |
+
# DuoAttention baseline: per-(layer, kv_head) learnable scalar.
|
| 470 |
+
# Populated in ``duo`` and ``duo_dynamic``. In ``duo_dynamic`` this
|
| 471 |
+
# scalar is the frozen Duo capacity mask; the dynamic gate is only
|
| 472 |
+
# allowed to close heads where this scalar says "full". Registered
|
| 473 |
+
# as ``full_attention_heads`` to match the name used in the
|
| 474 |
+
# official DuoAttention repo (``duo_attn.patch.llama``), so
|
| 475 |
+
# per-head statistics (e.g. retrieval heads vs streaming heads)
|
| 476 |
+
# can be read back with the same tooling.
|
| 477 |
+
if getattr(config, "aha_mode", "dynamic") in ("duo", "duo_dynamic"):
|
| 478 |
+
init = float(getattr(config, "duo_alpha_init", 1.0))
|
| 479 |
+
self.full_attention_heads = nn.Parameter(
|
| 480 |
+
torch.full((config.num_key_value_heads,), init, dtype=torch.float32)
|
| 481 |
+
)
|
| 482 |
+
else:
|
| 483 |
+
self.full_attention_heads = None
|
| 484 |
+
force_full_mask = torch.zeros(config.num_key_value_heads, dtype=torch.bool)
|
| 485 |
+
for raw in os.environ.get("AHA_FORCE_FULL_HEADS", "").split(","):
|
| 486 |
+
raw = raw.strip()
|
| 487 |
+
if not raw:
|
| 488 |
+
continue
|
| 489 |
+
layer_s, head_s = raw.split(":", 1)
|
| 490 |
+
if int(layer_s) == int(layer_idx):
|
| 491 |
+
force_full_mask[int(head_s)] = True
|
| 492 |
+
self.register_buffer(
|
| 493 |
+
"_aha_force_full_heads_mask",
|
| 494 |
+
force_full_mask,
|
| 495 |
+
persistent=False,
|
| 496 |
+
)
|
| 497 |
+
|
| 498 |
+
def forward(
|
| 499 |
+
self,
|
| 500 |
+
hidden_states: torch.Tensor,
|
| 501 |
+
position_embeddings: tuple[torch.Tensor, torch.Tensor],
|
| 502 |
+
attention_mask: Optional[dict], # dict with "global" and "local" keys
|
| 503 |
+
past_key_values: Optional[Cache] = None,
|
| 504 |
+
cache_position: Optional[torch.LongTensor] = None,
|
| 505 |
+
**kwargs,
|
| 506 |
+
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, Optional[torch.Tensor]]:
|
| 507 |
+
input_shape = hidden_states.shape[:-1]
|
| 508 |
+
hidden_shape = (*input_shape, -1, self.head_dim)
|
| 509 |
+
|
| 510 |
+
# === Q projection + (optional) gate extraction ===
|
| 511 |
+
q_proj_out = self.q_proj(hidden_states)
|
| 512 |
+
if self._has_dyn_gate_logits:
|
| 513 |
+
query_states, gate = torch.split(
|
| 514 |
+
q_proj_out,
|
| 515 |
+
[
|
| 516 |
+
self.config.num_attention_heads * self.head_dim,
|
| 517 |
+
self.aha_router_outputs,
|
| 518 |
+
],
|
| 519 |
+
dim=-1,
|
| 520 |
+
)
|
| 521 |
+
else:
|
| 522 |
+
query_states = q_proj_out
|
| 523 |
+
# DUO mode: use a zero placeholder so downstream dtype/device
|
| 524 |
+
# checks stay unchanged. The actual gate comes from the
|
| 525 |
+
# per-kv-head scalar ``full_attention_heads`` below.
|
| 526 |
+
gate = torch.zeros(
|
| 527 |
+
*input_shape, self.config.num_key_value_heads,
|
| 528 |
+
dtype=query_states.dtype, device=query_states.device,
|
| 529 |
+
)
|
| 530 |
+
|
| 531 |
+
# Qwen3-style: norm then reshape then transpose
|
| 532 |
+
query_states = self.q_norm(query_states.view(hidden_shape)).transpose(1, 2)
|
| 533 |
+
key_states = self.k_norm(self.k_proj(hidden_states).view(hidden_shape)).transpose(1, 2)
|
| 534 |
+
value_states = self.v_proj(hidden_states).view(hidden_shape).transpose(1, 2)
|
| 535 |
+
|
| 536 |
+
cos, sin = position_embeddings
|
| 537 |
+
query_states, key_states = apply_rotary_pos_emb(query_states, key_states, cos, sin)
|
| 538 |
+
|
| 539 |
+
if past_key_values is not None:
|
| 540 |
+
cache_kwargs = {"sin": sin, "cos": cos, "cache_position": cache_position}
|
| 541 |
+
key_states, value_states = past_key_values.update(
|
| 542 |
+
key_states, value_states, self.layer_idx, cache_kwargs
|
| 543 |
+
)
|
| 544 |
+
|
| 545 |
+
# === Gate computation ===
|
| 546 |
+
# gate shape: (B, Q, H_kv) — per-(token, kv_head) in dynamic mode,
|
| 547 |
+
# or per-kv_head broadcast to (B, Q, H_kv) in DUO mode.
|
| 548 |
+
# At inference, threshold is read from env var AHA_GATE_HARD_THRESHOLD
|
| 549 |
+
# so users can trade sparsity for quality without re-training.
|
| 550 |
+
# Default 0.5 keeps training-time behaviour. Lowering to e.g. 0.35
|
| 551 |
+
# lets more (token, kv_head) pairs route through global attention,
|
| 552 |
+
# which recovers long-CoT quality at the cost of sparsity.
|
| 553 |
+
aha_mode = getattr(self.config, "aha_mode", "dynamic")
|
| 554 |
+
if aha_mode == "duo":
|
| 555 |
+
# DuoAttention: alpha is per-(layer, kv_head) learnable scalar.
|
| 556 |
+
# gate_soft = clamp(alpha, 0, 1); gate_hard = (alpha > 0.5).
|
| 557 |
+
# Both broadcast to (B, Q, H_kv) for a uniform downstream API
|
| 558 |
+
# (so the distill / aux / blend paths do not need to branch).
|
| 559 |
+
qhead_alpha = getattr(self, "_aha_qhead_full_attention_heads", None)
|
| 560 |
+
if qhead_alpha is not None:
|
| 561 |
+
# Eval-only oracle diagnostic: route at Q-head granularity
|
| 562 |
+
# instead of KV-head/GQA-group granularity. This does not
|
| 563 |
+
# represent a KV-capacity-saving deployment point; it isolates
|
| 564 |
+
# whether GQA grouping itself is the quality bottleneck.
|
| 565 |
+
alpha = qhead_alpha.to(gate.dtype).clamp(0.0, 1.0)
|
| 566 |
+
gate_soft = alpha.view(1, 1, -1).expand(*input_shape, -1)
|
| 567 |
+
import os as _os
|
| 568 |
+
_thr = float(_os.environ.get("AHA_GATE_HARD_THRESHOLD", "0.5"))
|
| 569 |
+
gate_hard = (alpha > _thr).to(gate.dtype).view(1, 1, -1).expand(*input_shape, -1)
|
| 570 |
+
gate_ste = gate_hard
|
| 571 |
+
|
| 572 |
+
else:
|
| 573 |
+
alpha = self.full_attention_heads.to(gate.dtype).clamp(0.0, 1.0)
|
| 574 |
+
gate_soft = alpha.view(1, 1, -1).expand(*input_shape, -1)
|
| 575 |
+
if self.training:
|
| 576 |
+
gate_hard = (alpha > 0.5).to(gate.dtype).view(1, 1, -1).expand(*input_shape, -1)
|
| 577 |
+
# Straight-through estimator through the clamped alpha so
|
| 578 |
+
# L1 / distill gradients flow into the scalar.
|
| 579 |
+
gate_ste = gate_soft
|
| 580 |
+
else:
|
| 581 |
+
import os as _os
|
| 582 |
+
_thr = float(_os.environ.get("AHA_GATE_HARD_THRESHOLD", "0.5"))
|
| 583 |
+
gate_hard = (alpha > _thr).to(gate.dtype).view(1, 1, -1).expand(*input_shape, -1)
|
| 584 |
+
gate_ste = gate_hard
|
| 585 |
+
elif aha_mode == "duo_dynamic":
|
| 586 |
+
# Duo + AHA-on-full-heads:
|
| 587 |
+
# duo_full_mask = 1 for static Duo full/retrieval heads
|
| 588 |
+
# dyn_gate = per-token AHA decision inside those heads
|
| 589 |
+
# effective_full = duo_full_mask AND dyn_gate
|
| 590 |
+
#
|
| 591 |
+
# Duo streaming heads are locked to local/streaming forever. This
|
| 592 |
+
# preserves Duo's KV-capacity saving: only static-full heads need a
|
| 593 |
+
# full KV cache. ``aha_force_gate_value=1`` therefore reproduces
|
| 594 |
+
# the static Duo deployment point exactly, not all-full attention.
|
| 595 |
+
alpha = self.full_attention_heads.to(gate.dtype).clamp(0.0, 1.0)
|
| 596 |
+
duo_full = (alpha > 0.5).to(gate.dtype).view(1, 1, -1)
|
| 597 |
+
dyn_soft = torch.sigmoid(gate)
|
| 598 |
+
force_gate_value = getattr(self.config, "aha_force_gate_value", None)
|
| 599 |
+
if force_gate_value is not None:
|
| 600 |
+
dyn_soft = torch.full_like(dyn_soft, float(force_gate_value))
|
| 601 |
+
gate_soft = dyn_soft * duo_full
|
| 602 |
+
gate_hard = gate_soft
|
| 603 |
+
gate_ste = gate_soft
|
| 604 |
+
elif self.training:
|
| 605 |
+
train_thr = float(os.environ.get("AHA_TRAIN_GATE_HARD_THRESHOLD", "0.5"))
|
| 606 |
+
gate_soft = dyn_soft * duo_full
|
| 607 |
+
dyn_hard = (dyn_soft > train_thr).to(gate.dtype)
|
| 608 |
+
gate_hard = dyn_hard * duo_full
|
| 609 |
+
gate_ste = gate_hard + (gate_soft - gate_soft.detach())
|
| 610 |
+
else:
|
| 611 |
+
import os as _os
|
| 612 |
+
_thr = float(_os.environ.get("AHA_GATE_HARD_THRESHOLD", "0.5"))
|
| 613 |
+
gate_soft = dyn_soft * duo_full
|
| 614 |
+
dyn_hard = (dyn_soft > _thr).to(gate.dtype)
|
| 615 |
+
gate_hard = dyn_hard * duo_full
|
| 616 |
+
gate_ste = gate_hard
|
| 617 |
+
force_full = self._aha_force_full_heads_mask.to(gate.dtype).view(1, 1, -1)
|
| 618 |
+
if bool(force_full.any().item()):
|
| 619 |
+
force_full = force_full * duo_full
|
| 620 |
+
gate_hard = torch.maximum(gate_hard, force_full)
|
| 621 |
+
gate_ste = torch.maximum(gate_ste, force_full)
|
| 622 |
+
else:
|
| 623 |
+
gate_soft = torch.sigmoid(gate)
|
| 624 |
+
force_gate_value = getattr(self.config, "aha_force_gate_value", None)
|
| 625 |
+
if force_gate_value is not None:
|
| 626 |
+
force = float(force_gate_value)
|
| 627 |
+
gate_soft = torch.full_like(gate_soft, force)
|
| 628 |
+
gate_hard = torch.full_like(gate_soft, force)
|
| 629 |
+
gate_ste = gate_soft
|
| 630 |
+
elif self.training:
|
| 631 |
+
train_thr = float(os.environ.get("AHA_TRAIN_GATE_HARD_THRESHOLD", "0.5"))
|
| 632 |
+
gate_hard = (gate_soft > train_thr).to(gate.dtype)
|
| 633 |
+
gate_ste = gate_hard + (gate_soft - gate_soft.detach())
|
| 634 |
+
else:
|
| 635 |
+
import os as _os
|
| 636 |
+
_thr = float(_os.environ.get("AHA_GATE_HARD_THRESHOLD", "0.5"))
|
| 637 |
+
gate_hard = (gate_soft > _thr).to(gate.dtype)
|
| 638 |
+
gate_ste = gate_hard
|
| 639 |
+
|
| 640 |
+
# Normalize the downstream contract to effective
|
| 641 |
+
# token x KV-head decisions. In shared-token mode there is only one
|
| 642 |
+
# learned logit per token, but regularization, sparsity accounting, and
|
| 643 |
+
# attention mixing all see the same KV-head denominator as the
|
| 644 |
+
# head-granular arm.
|
| 645 |
+
if gate_soft.shape[-1] == 1 and self.config.num_key_value_heads != 1:
|
| 646 |
+
effective_shape = (*gate_soft.shape[:-1], self.config.num_key_value_heads)
|
| 647 |
+
gate_soft = gate_soft.expand(effective_shape)
|
| 648 |
+
gate_hard = gate_hard.expand(effective_shape)
|
| 649 |
+
gate_ste = gate_ste.expand(effective_shape)
|
| 650 |
+
|
| 651 |
+
if (
|
| 652 |
+
aha_mode in ("duo", "duo_dynamic")
|
| 653 |
+
and not self.training
|
| 654 |
+
and os.environ.get("AHA_DUO_PREFILL_FULL", "0") == "1"
|
| 655 |
+
and query_states.shape[-2] == key_states.shape[-2]
|
| 656 |
+
):
|
| 657 |
+
# Diagnostic parity with the official DuoAttention HF eval path:
|
| 658 |
+
# initial prefill is full attention; streaming heads are only
|
| 659 |
+
# localized during subsequent decode steps.
|
| 660 |
+
gate_hard = torch.ones_like(gate_hard)
|
| 661 |
+
gate_ste = torch.ones_like(gate_ste)
|
| 662 |
+
|
| 663 |
+
if not self.training:
|
| 664 |
+
phase = (
|
| 665 |
+
"decode"
|
| 666 |
+
if hidden_states.shape[1] == 1 and key_states.shape[-2] > 1
|
| 667 |
+
else "prefill"
|
| 668 |
+
)
|
| 669 |
+
_track_aha_inference_sparsity(
|
| 670 |
+
gate_hard, gate_soft, self.layer_idx, phase,
|
| 671 |
+
getattr(
|
| 672 |
+
self.config,
|
| 673 |
+
"aha_router_granularity",
|
| 674 |
+
AHA_ROUTER_GRANULARITY,
|
| 675 |
+
),
|
| 676 |
+
self.aha_router_outputs if self._has_dyn_gate_logits
|
| 677 |
+
else self.config.num_key_value_heads,
|
| 678 |
+
)
|
| 679 |
+
|
| 680 |
+
# === Attention: double_mix (global + local, blended by gate) ===
|
| 681 |
+
from transformers.models.qwen3.modeling_qwen3 import eager_attention_forward
|
| 682 |
+
|
| 683 |
+
attention_interface: Callable = eager_attention_forward
|
| 684 |
+
if self.config._attn_implementation != "eager":
|
| 685 |
+
attention_interface = ALL_ATTENTION_FUNCTIONS[self.config._attn_implementation]
|
| 686 |
+
|
| 687 |
+
# Global attention (full context)
|
| 688 |
+
global_attn_output, _ = attention_interface(
|
| 689 |
+
self, query_states, key_states, value_states,
|
| 690 |
+
attention_mask["global"],
|
| 691 |
+
dropout=0.0 if not self.training else self.attention_dropout,
|
| 692 |
+
scaling=self.scaling,
|
| 693 |
+
**kwargs,
|
| 694 |
+
)
|
| 695 |
+
|
| 696 |
+
if getattr(self.config, "_aha_teacher_full_fastpath", False):
|
| 697 |
+
attn_output = global_attn_output.reshape(*input_shape, -1).contiguous()
|
| 698 |
+
attn_output = self.o_proj(attn_output)
|
| 699 |
+
return attn_output, gate_soft, gate_hard, None
|
| 700 |
+
|
| 701 |
+
# Local / streaming branch. The local mask was pre-built by the
|
| 702 |
+
# model's ``forward`` either as sliding-window (legacy dynamic mode
|
| 703 |
+
# default) or sink+recent (duo mode, and dynamic mode when
|
| 704 |
+
# ``aha_local_kind="sink_recent"``). Only pass ``sliding_window`` to
|
| 705 |
+
# the SDPA backend when the mask is the sliding-window kind, otherwise
|
| 706 |
+
# the backend will double-mask and wipe out the sink positions.
|
| 707 |
+
local_attn_kwargs = dict(
|
| 708 |
+
dropout=0.0 if not self.training else self.attention_dropout,
|
| 709 |
+
scaling=self.scaling,
|
| 710 |
+
)
|
| 711 |
+
local_kind = "sink_recent" if aha_mode in ("duo", "duo_dynamic") else getattr(
|
| 712 |
+
self.config, "aha_local_kind", "sliding_window"
|
| 713 |
+
)
|
| 714 |
+
if local_kind == "sliding_window":
|
| 715 |
+
local_attn_kwargs["sliding_window"] = self.aha_window_size
|
| 716 |
+
local_attn_output, _ = attention_interface(
|
| 717 |
+
self, query_states, key_states, value_states,
|
| 718 |
+
attention_mask["local"],
|
| 719 |
+
**local_attn_kwargs,
|
| 720 |
+
**kwargs,
|
| 721 |
+
)
|
| 722 |
+
|
| 723 |
+
# Blend: normal gate_ste is (B, Q, H_kv); broadcast to (B, Q, H, 1)
|
| 724 |
+
# by repeating each kv_head decision. The optional qhead oracle above
|
| 725 |
+
# already emits (B, Q, H), so it bypasses this broadcast.
|
| 726 |
+
if gate_ste.shape[-1] == self.config.num_attention_heads:
|
| 727 |
+
g_bqh = gate_ste
|
| 728 |
+
else:
|
| 729 |
+
g_bqh = gate_ste.repeat_interleave(self.num_key_value_groups, dim=-1)
|
| 730 |
+
attn_output = (
|
| 731 |
+
global_attn_output * g_bqh.unsqueeze(-1)
|
| 732 |
+
+ local_attn_output * (1 - g_bqh.unsqueeze(-1))
|
| 733 |
+
)
|
| 734 |
+
|
| 735 |
+
# Per-layer attention distillation signal. Returns a scalar so the
|
| 736 |
+
# outer model can aggregate across layers. When gate → 1 the term
|
| 737 |
+
# vanishes; when gate → 0 on positions where global/local disagree
|
| 738 |
+
# strongly, the gradient pushes gate back toward 1.
|
| 739 |
+
#
|
| 740 |
+
# ``diff`` is ``detach``-ed so distill only trains the gate; it does
|
| 741 |
+
# not reshape attention outputs through the backbone. This also
|
| 742 |
+
# prevents the squared-difference graph from retaining activations
|
| 743 |
+
# for all 28 layers' global/local branches, which was the source of
|
| 744 |
+
# OOM when gradient checkpointing is disabled on the custom forward.
|
| 745 |
+
distill_per_layer: Optional[torch.Tensor] = None
|
| 746 |
+
if self.training and getattr(self.config, "aha_distill_weight", 0.0) > 0.0:
|
| 747 |
+
with torch.no_grad():
|
| 748 |
+
diff_sq = (global_attn_output - local_attn_output).float() ** 2
|
| 749 |
+
one_minus_g_sq = ((1.0 - g_bqh.float()) ** 2).unsqueeze(-1)
|
| 750 |
+
distill_per_layer = (one_minus_g_sq * diff_sq).mean()
|
| 751 |
+
|
| 752 |
+
attn_output = attn_output.reshape(*input_shape, -1).contiguous()
|
| 753 |
+
attn_output = self.o_proj(attn_output)
|
| 754 |
+
return attn_output, gate_soft, gate_hard, distill_per_layer
|
| 755 |
+
|
| 756 |
+
|
| 757 |
+
class AHAQwen3DecoderLayer(GradientCheckpointingLayer):
|
| 758 |
+
"""Decoder layer with gradient-checkpointing support.
|
| 759 |
+
|
| 760 |
+
Inheriting from ``GradientCheckpointingLayer`` lets HuggingFace Trainer's
|
| 761 |
+
``gradient_checkpointing_enable`` take effect on this layer without any
|
| 762 |
+
explicit ``_gradient_checkpointing_func`` call in the outer forward.
|
| 763 |
+
"""
|
| 764 |
+
|
| 765 |
+
def __init__(self, config: AHAQwen3Config, layer_idx: int):
|
| 766 |
+
super().__init__()
|
| 767 |
+
self.hidden_size = config.hidden_size
|
| 768 |
+
self.self_attn = AHAQwen3Attention(config=config, layer_idx=layer_idx)
|
| 769 |
+
from transformers.models.qwen3.modeling_qwen3 import Qwen3MLP
|
| 770 |
+
self.mlp = Qwen3MLP(config)
|
| 771 |
+
self.input_layernorm = Qwen3RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
|
| 772 |
+
self.post_attention_layernorm = Qwen3RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
|
| 773 |
+
|
| 774 |
+
def forward(
|
| 775 |
+
self,
|
| 776 |
+
hidden_states: torch.Tensor,
|
| 777 |
+
attention_mask: Optional[dict] = None,
|
| 778 |
+
position_ids: Optional[torch.LongTensor] = None,
|
| 779 |
+
past_key_values: Optional[Cache] = None,
|
| 780 |
+
use_cache: Optional[bool] = False,
|
| 781 |
+
cache_position: Optional[torch.LongTensor] = None,
|
| 782 |
+
position_embeddings: Optional[tuple[torch.Tensor, torch.Tensor]] = None,
|
| 783 |
+
**kwargs,
|
| 784 |
+
):
|
| 785 |
+
residual = hidden_states
|
| 786 |
+
hidden_states = self.input_layernorm(hidden_states)
|
| 787 |
+
hidden_states, gate_soft, gate_hard, distill_per_layer = self.self_attn(
|
| 788 |
+
hidden_states=hidden_states,
|
| 789 |
+
attention_mask=attention_mask,
|
| 790 |
+
position_ids=position_ids,
|
| 791 |
+
past_key_values=past_key_values,
|
| 792 |
+
use_cache=use_cache,
|
| 793 |
+
cache_position=cache_position,
|
| 794 |
+
position_embeddings=position_embeddings,
|
| 795 |
+
**kwargs,
|
| 796 |
+
)
|
| 797 |
+
hidden_states = residual + hidden_states
|
| 798 |
+
|
| 799 |
+
residual = hidden_states
|
| 800 |
+
hidden_states = self.post_attention_layernorm(hidden_states)
|
| 801 |
+
hidden_states = self.mlp(hidden_states)
|
| 802 |
+
hidden_states = residual + hidden_states
|
| 803 |
+
return hidden_states, gate_soft, gate_hard, distill_per_layer
|
| 804 |
+
|
| 805 |
+
|
| 806 |
+
@dataclass
|
| 807 |
+
class AHACausalLMOutputWithPast(CausalLMOutputWithPast):
|
| 808 |
+
"""Causal LM output with AHA loss breakdown and gate density stats (training / eval with labels)."""
|
| 809 |
+
|
| 810 |
+
ce_loss: Optional[torch.FloatTensor] = None
|
| 811 |
+
gate_aux_loss: Optional[torch.FloatTensor] = None
|
| 812 |
+
distill_loss: Optional[torch.FloatTensor] = None
|
| 813 |
+
gate_soft_mean: Optional[torch.FloatTensor] = None
|
| 814 |
+
gate_hard_mean: Optional[torch.FloatTensor] = None
|
| 815 |
+
|
| 816 |
+
|
| 817 |
+
class AHAModelOutputWithPast(BaseModelOutputWithPast):
|
| 818 |
+
def __init__(self, last_hidden_state, past_key_values=None, hidden_states=None, attentions=None,
|
| 819 |
+
all_gate_soft=None, all_gate_hard=None, all_distill_per_layer=None):
|
| 820 |
+
super().__init__(last_hidden_state=last_hidden_state, past_key_values=past_key_values,
|
| 821 |
+
hidden_states=hidden_states, attentions=attentions)
|
| 822 |
+
self.all_gate_soft = all_gate_soft
|
| 823 |
+
self.all_gate_hard = all_gate_hard
|
| 824 |
+
# Tuple of per-layer distill scalars, or empty tuple when distillation is off.
|
| 825 |
+
self.all_distill_per_layer = all_distill_per_layer if all_distill_per_layer is not None else ()
|
| 826 |
+
|
| 827 |
+
|
| 828 |
+
class AHAQwen3Model(Qwen3Model):
|
| 829 |
+
config_class = AHAQwen3Config
|
| 830 |
+
|
| 831 |
+
def __init__(self, config: AHAQwen3Config):
|
| 832 |
+
from transformers import PreTrainedModel
|
| 833 |
+
PreTrainedModel.__init__(self, config)
|
| 834 |
+
self.padding_idx = config.pad_token_id
|
| 835 |
+
self.vocab_size = config.vocab_size
|
| 836 |
+
self.embed_tokens = nn.Embedding(config.vocab_size, config.hidden_size, self.padding_idx)
|
| 837 |
+
self.layers = nn.ModuleList(
|
| 838 |
+
[AHAQwen3DecoderLayer(config, layer_idx) for layer_idx in range(config.num_hidden_layers)]
|
| 839 |
+
)
|
| 840 |
+
self.norm = Qwen3RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
|
| 841 |
+
from transformers.models.qwen3.modeling_qwen3 import Qwen3RotaryEmbedding
|
| 842 |
+
self.rotary_emb = Qwen3RotaryEmbedding(config=config)
|
| 843 |
+
self.gradient_checkpointing = False
|
| 844 |
+
self.post_init()
|
| 845 |
+
|
| 846 |
+
def forward(
|
| 847 |
+
self,
|
| 848 |
+
input_ids: Optional[torch.LongTensor] = None,
|
| 849 |
+
attention_mask: Optional[torch.Tensor] = None,
|
| 850 |
+
position_ids: Optional[torch.LongTensor] = None,
|
| 851 |
+
past_key_values: Optional[Cache] = None,
|
| 852 |
+
inputs_embeds: Optional[torch.FloatTensor] = None,
|
| 853 |
+
use_cache: Optional[bool] = None,
|
| 854 |
+
cache_position: Optional[torch.LongTensor] = None,
|
| 855 |
+
**kwargs,
|
| 856 |
+
) -> AHAModelOutputWithPast:
|
| 857 |
+
if (input_ids is None) ^ (inputs_embeds is not None):
|
| 858 |
+
raise ValueError("You must specify exactly one of input_ids or inputs_embeds")
|
| 859 |
+
if inputs_embeds is None:
|
| 860 |
+
inputs_embeds = self.embed_tokens(input_ids)
|
| 861 |
+
if use_cache and past_key_values is None:
|
| 862 |
+
past_key_values = DynamicCache(config=self.config)
|
| 863 |
+
if cache_position is None:
|
| 864 |
+
past_seen_tokens = past_key_values.get_seq_length() if past_key_values is not None else 0
|
| 865 |
+
cache_position = torch.arange(
|
| 866 |
+
past_seen_tokens, past_seen_tokens + inputs_embeds.shape[1], device=inputs_embeds.device
|
| 867 |
+
)
|
| 868 |
+
if position_ids is None:
|
| 869 |
+
position_ids = cache_position.unsqueeze(0)
|
| 870 |
+
|
| 871 |
+
# Build two masks: global (full causal) and local (sliding window
|
| 872 |
+
# for dynamic mode, streaming sink+recent for DUO mode).
|
| 873 |
+
orig_sliding_window = getattr(self.config, "sliding_window", None)
|
| 874 |
+
self.config.sliding_window = self.config.aha_window_size
|
| 875 |
+
mask_kwargs = {
|
| 876 |
+
"config": self.config,
|
| 877 |
+
"input_embeds": inputs_embeds,
|
| 878 |
+
"attention_mask": attention_mask,
|
| 879 |
+
"cache_position": cache_position,
|
| 880 |
+
"past_key_values": past_key_values,
|
| 881 |
+
"position_ids": position_ids,
|
| 882 |
+
}
|
| 883 |
+
global_mask = create_causal_mask(**mask_kwargs)
|
| 884 |
+
aha_mode = getattr(self.config, "aha_mode", "dynamic")
|
| 885 |
+
# duo / duo_dynamic force sink_recent for backward compat. dynamic mode
|
| 886 |
+
# reads the local kind from config (default sliding_window for legacy
|
| 887 |
+
# Bv3 ckpts; hot-started ckpts from duo set sink_recent — see
|
| 888 |
+
# §9.4.13.3).
|
| 889 |
+
local_kind = "sink_recent" if aha_mode in ("duo", "duo_dynamic") else getattr(
|
| 890 |
+
self.config, "aha_local_kind", "sliding_window"
|
| 891 |
+
)
|
| 892 |
+
if local_kind == "sink_recent":
|
| 893 |
+
seq_len_q = inputs_embeds.shape[1]
|
| 894 |
+
past_seen = past_key_values.get_seq_length() if past_key_values is not None else 0
|
| 895 |
+
seq_len_kv = seq_len_q + past_seen
|
| 896 |
+
local_mask = _build_streaming_causal_mask(
|
| 897 |
+
seq_len_q=seq_len_q,
|
| 898 |
+
seq_len_kv=seq_len_kv,
|
| 899 |
+
sink_size=int(getattr(self.config, "duo_sink_size", 64)),
|
| 900 |
+
recent_size=int(getattr(self.config, "duo_recent_size", 256)),
|
| 901 |
+
device=inputs_embeds.device,
|
| 902 |
+
dtype=inputs_embeds.dtype,
|
| 903 |
+
kv_offset=past_seen,
|
| 904 |
+
)
|
| 905 |
+
else:
|
| 906 |
+
local_mask = create_sliding_window_causal_mask(**mask_kwargs)
|
| 907 |
+
causal_mask_dict = {"global": global_mask, "local": local_mask}
|
| 908 |
+
self.config.sliding_window = orig_sliding_window
|
| 909 |
+
|
| 910 |
+
hidden_states = inputs_embeds
|
| 911 |
+
position_embeddings = self.rotary_emb(hidden_states, position_ids)
|
| 912 |
+
|
| 913 |
+
all_gate_soft = ()
|
| 914 |
+
all_gate_hard = ()
|
| 915 |
+
all_distill_per_layer: tuple[torch.Tensor, ...] = ()
|
| 916 |
+
for decoder_layer in self.layers:
|
| 917 |
+
hidden_states, gate_soft, gate_hard, distill_per_layer = decoder_layer(
|
| 918 |
+
hidden_states,
|
| 919 |
+
attention_mask=causal_mask_dict,
|
| 920 |
+
position_ids=position_ids,
|
| 921 |
+
past_key_values=past_key_values,
|
| 922 |
+
use_cache=use_cache,
|
| 923 |
+
cache_position=cache_position,
|
| 924 |
+
position_embeddings=position_embeddings,
|
| 925 |
+
**kwargs,
|
| 926 |
+
)
|
| 927 |
+
all_gate_soft += (gate_soft,)
|
| 928 |
+
all_gate_hard += (gate_hard,)
|
| 929 |
+
if distill_per_layer is not None:
|
| 930 |
+
all_distill_per_layer += (distill_per_layer,)
|
| 931 |
+
|
| 932 |
+
hidden_states = self.norm(hidden_states)
|
| 933 |
+
return AHAModelOutputWithPast(
|
| 934 |
+
last_hidden_state=hidden_states,
|
| 935 |
+
past_key_values=past_key_values if use_cache else None,
|
| 936 |
+
all_gate_soft=all_gate_soft,
|
| 937 |
+
all_gate_hard=all_gate_hard,
|
| 938 |
+
all_distill_per_layer=all_distill_per_layer,
|
| 939 |
+
)
|
| 940 |
+
|
| 941 |
+
|
| 942 |
+
class AHAQwen3ForCausalLM(Qwen3ForCausalLM):
|
| 943 |
+
config_class = AHAQwen3Config
|
| 944 |
+
_tied_weights_keys = ["lm_head.weight"]
|
| 945 |
+
|
| 946 |
+
def __init__(self, config: AHAQwen3Config):
|
| 947 |
+
# Skip Qwen3ForCausalLM.__init__ which creates Qwen3Model
|
| 948 |
+
# Go to PreTrainedModel.__init__ directly
|
| 949 |
+
from transformers import PreTrainedModel
|
| 950 |
+
PreTrainedModel.__init__(self, config)
|
| 951 |
+
self.model = AHAQwen3Model(config)
|
| 952 |
+
self.vocab_size = config.vocab_size
|
| 953 |
+
self.lm_head = nn.Linear(config.hidden_size, config.vocab_size, bias=False)
|
| 954 |
+
self.post_init()
|
| 955 |
+
|
| 956 |
+
def forward(
|
| 957 |
+
self,
|
| 958 |
+
input_ids: Optional[torch.LongTensor] = None,
|
| 959 |
+
attention_mask: Optional[torch.Tensor] = None,
|
| 960 |
+
position_ids: Optional[torch.LongTensor] = None,
|
| 961 |
+
past_key_values: Optional[Cache] = None,
|
| 962 |
+
inputs_embeds: Optional[torch.FloatTensor] = None,
|
| 963 |
+
labels: Optional[torch.LongTensor] = None,
|
| 964 |
+
use_cache: Optional[bool] = None,
|
| 965 |
+
cache_position: Optional[torch.LongTensor] = None,
|
| 966 |
+
logits_to_keep: Union[int, torch.Tensor] = 0,
|
| 967 |
+
**kwargs,
|
| 968 |
+
) -> AHACausalLMOutputWithPast:
|
| 969 |
+
outputs = self.model(
|
| 970 |
+
input_ids=input_ids,
|
| 971 |
+
attention_mask=attention_mask,
|
| 972 |
+
position_ids=position_ids,
|
| 973 |
+
past_key_values=past_key_values,
|
| 974 |
+
inputs_embeds=inputs_embeds,
|
| 975 |
+
use_cache=use_cache,
|
| 976 |
+
cache_position=cache_position,
|
| 977 |
+
**kwargs,
|
| 978 |
+
)
|
| 979 |
+
hidden_states = outputs.last_hidden_state
|
| 980 |
+
slice_indices = slice(-logits_to_keep, None) if isinstance(logits_to_keep, int) else logits_to_keep
|
| 981 |
+
logits = self.lm_head(hidden_states[:, slice_indices, :])
|
| 982 |
+
|
| 983 |
+
loss = None
|
| 984 |
+
ce_loss = None
|
| 985 |
+
gate_aux_loss = None
|
| 986 |
+
distill_loss = None
|
| 987 |
+
gate_soft_mean = None
|
| 988 |
+
gate_hard_mean = None
|
| 989 |
+
|
| 990 |
+
if labels is not None:
|
| 991 |
+
from torch.nn import CrossEntropyLoss
|
| 992 |
+
shift_logits = logits[..., :-1, :].contiguous()
|
| 993 |
+
shift_labels = labels[..., 1:].contiguous()
|
| 994 |
+
loss_fct = CrossEntropyLoss()
|
| 995 |
+
ce_loss = loss_fct(shift_logits.view(-1, self.vocab_size), shift_labels.view(-1))
|
| 996 |
+
|
| 997 |
+
all_gate_soft = outputs.all_gate_soft
|
| 998 |
+
all_gate_hard = outputs.all_gate_hard
|
| 999 |
+
if labels is not None and len(all_gate_soft) > 0 and len(all_gate_hard) > 0:
|
| 1000 |
+
gate_soft_stack = torch.stack(
|
| 1001 |
+
[g.to(hidden_states.device) for g in all_gate_soft], dim=-1
|
| 1002 |
+
).float()
|
| 1003 |
+
gate_hard_stack = torch.stack(
|
| 1004 |
+
[g.to(hidden_states.device).float() for g in all_gate_hard], dim=-1
|
| 1005 |
+
)
|
| 1006 |
+
shift_gate_soft = gate_soft_stack[:, :-1, :].contiguous()
|
| 1007 |
+
shift_gate_hard = gate_hard_stack[:, :-1, :].contiguous()
|
| 1008 |
+
# Match the CE/PPL population exactly. The old unmasked mean
|
| 1009 |
+
# counted padded sequence positions as real gate decisions, which
|
| 1010 |
+
# made the training sparsity curve disagree with inference-time
|
| 1011 |
+
# sparsity on variable-length batches.
|
| 1012 |
+
valid_gate = shift_labels.ne(-100)
|
| 1013 |
+
if attention_mask is not None:
|
| 1014 |
+
valid_gate = valid_gate & attention_mask[:, 1:].to(torch.bool)
|
| 1015 |
+
if bool(valid_gate.any()):
|
| 1016 |
+
valid_gate = valid_gate[..., None, None].to(shift_gate_soft.dtype)
|
| 1017 |
+
normalizer = (
|
| 1018 |
+
valid_gate.sum()
|
| 1019 |
+
* shift_gate_soft.shape[-2]
|
| 1020 |
+
* shift_gate_soft.shape[-1]
|
| 1021 |
+
)
|
| 1022 |
+
gate_soft_mean = (shift_gate_soft * valid_gate).sum() / normalizer
|
| 1023 |
+
gate_hard_mean = (shift_gate_hard * valid_gate).sum() / normalizer
|
| 1024 |
+
else:
|
| 1025 |
+
gate_soft_mean = shift_gate_soft.mean()
|
| 1026 |
+
gate_hard_mean = shift_gate_hard.mean()
|
| 1027 |
+
|
| 1028 |
+
# Per-layer distillation loss: mean over layers of
|
| 1029 |
+
# ``mean(((1-g)·(global-local))**2)``. Populated only when training
|
| 1030 |
+
# with ``config.aha_distill_weight > 0`` (see AHAQwen3Attention).
|
| 1031 |
+
all_distill = outputs.all_distill_per_layer
|
| 1032 |
+
if self.training and len(all_distill) > 0:
|
| 1033 |
+
distill_loss = torch.stack(list(all_distill)).mean()
|
| 1034 |
+
|
| 1035 |
+
# === Loss assembly ===
|
| 1036 |
+
# Eval path: standard LM loss for compatibility with HF metrics.
|
| 1037 |
+
# Train path: weighted sum of (ce, gate_aux, distill) so callers can
|
| 1038 |
+
# zero any term by setting its weight to 0 (e.g. ``aha_ce_weight=0``
|
| 1039 |
+
# to train the gate purely from ``aux + distill``).
|
| 1040 |
+
if not self.training:
|
| 1041 |
+
loss = ce_loss
|
| 1042 |
+
else:
|
| 1043 |
+
ce_weight = float(getattr(self.config, "aha_ce_weight", 1.0))
|
| 1044 |
+
distill_weight = float(getattr(self.config, "aha_distill_weight", 0.0))
|
| 1045 |
+
terms: list[torch.Tensor] = []
|
| 1046 |
+
if ce_loss is not None and ce_weight > 0.0:
|
| 1047 |
+
terms.append(ce_weight * ce_loss)
|
| 1048 |
+
if gate_soft_mean is not None:
|
| 1049 |
+
reg_weight = float(getattr(self.config, "aha_reg_weight", -1.0))
|
| 1050 |
+
if reg_weight >= 0.0:
|
| 1051 |
+
# Duo-style direct sparsity term:
|
| 1052 |
+
# alpha/gate near 1 means full/global attention, so penalize
|
| 1053 |
+
# mean gate usage directly.
|
| 1054 |
+
gate_aux_loss = reg_weight * gate_soft_mean
|
| 1055 |
+
else:
|
| 1056 |
+
# Legacy hinge aux: penalise only the excess above the target
|
| 1057 |
+
# ceiling. Below ``τ`` the gradient is zero, so CE and
|
| 1058 |
+
# distill freely shape where the gate opens without an
|
| 1059 |
+
# unbounded downward push from aux. See AHAQwen3Config
|
| 1060 |
+
# for the rationale and the collapse that motivated it.
|
| 1061 |
+
gate_target = float(getattr(self.config, "aha_gate_target", 1.0))
|
| 1062 |
+
excess = torch.clamp(gate_soft_mean - gate_target, min=0.0)
|
| 1063 |
+
gate_aux_loss = self.config.aha_lambda * excess
|
| 1064 |
+
terms.append(gate_aux_loss)
|
| 1065 |
+
if distill_loss is not None and distill_weight > 0.0:
|
| 1066 |
+
terms.append(distill_weight * distill_loss)
|
| 1067 |
+
if terms:
|
| 1068 |
+
loss = terms[0]
|
| 1069 |
+
for t in terms[1:]:
|
| 1070 |
+
loss = loss + t
|
| 1071 |
+
|
| 1072 |
+
return AHACausalLMOutputWithPast(
|
| 1073 |
+
loss=loss,
|
| 1074 |
+
logits=logits,
|
| 1075 |
+
past_key_values=outputs.past_key_values,
|
| 1076 |
+
ce_loss=ce_loss,
|
| 1077 |
+
gate_aux_loss=gate_aux_loss,
|
| 1078 |
+
distill_loss=distill_loss,
|
| 1079 |
+
gate_soft_mean=gate_soft_mean,
|
| 1080 |
+
gate_hard_mean=gate_hard_mean,
|
| 1081 |
+
)
|
| 1082 |
+
|
| 1083 |
+
@classmethod
|
| 1084 |
+
def from_pretrained_qwen3(cls, qwen3_path: str, aha_window_size: int = AHA_WINDOW_SIZE,
|
| 1085 |
+
aha_lambda: float = AHA_LAMBDA,
|
| 1086 |
+
aha_distill_weight: float = AHA_DISTILL_WEIGHT,
|
| 1087 |
+
aha_ce_weight: float = AHA_CE_WEIGHT,
|
| 1088 |
+
aha_gate_target: float = AHA_GATE_TARGET,
|
| 1089 |
+
aha_reg_weight: float = -1.0,
|
| 1090 |
+
aha_force_gate_value=None,
|
| 1091 |
+
aha_mode: str = AHA_MODE,
|
| 1092 |
+
duo_sink_size: int = DUO_SINK_SIZE,
|
| 1093 |
+
duo_recent_size: int = DUO_RECENT_SIZE,
|
| 1094 |
+
duo_alpha_init: float = DUO_ALPHA_INIT,
|
| 1095 |
+
aha_local_kind: str = AHA_LOCAL_KIND,
|
| 1096 |
+
aha_router_granularity: str = AHA_ROUTER_GRANULARITY,
|
| 1097 |
+
**kwargs):
|
| 1098 |
+
"""Load a pretrained Qwen3 model and convert to AHA-Qwen3.
|
| 1099 |
+
|
| 1100 |
+
Initializes the gate weights in q_proj randomly (the extra num_heads outputs).
|
| 1101 |
+
All other weights are copied from the pretrained model.
|
| 1102 |
+
"""
|
| 1103 |
+
# Load original Qwen3
|
| 1104 |
+
original = Qwen3ForCausalLM.from_pretrained(qwen3_path, **kwargs)
|
| 1105 |
+
original_config = original.config
|
| 1106 |
+
|
| 1107 |
+
# Create AHA config
|
| 1108 |
+
config_dict = original_config.to_dict()
|
| 1109 |
+
config_dict["aha_window_size"] = aha_window_size
|
| 1110 |
+
config_dict["aha_lambda"] = aha_lambda
|
| 1111 |
+
config_dict["aha_distill_weight"] = aha_distill_weight
|
| 1112 |
+
config_dict["aha_ce_weight"] = aha_ce_weight
|
| 1113 |
+
config_dict["aha_gate_target"] = aha_gate_target
|
| 1114 |
+
config_dict["aha_reg_weight"] = aha_reg_weight
|
| 1115 |
+
config_dict["aha_force_gate_value"] = aha_force_gate_value
|
| 1116 |
+
config_dict["aha_mode"] = aha_mode
|
| 1117 |
+
config_dict["duo_sink_size"] = duo_sink_size
|
| 1118 |
+
config_dict["duo_recent_size"] = duo_recent_size
|
| 1119 |
+
config_dict["duo_alpha_init"] = duo_alpha_init
|
| 1120 |
+
config_dict["aha_local_kind"] = aha_local_kind
|
| 1121 |
+
config_dict["aha_router_granularity"] = aha_router_granularity
|
| 1122 |
+
config_dict["model_type"] = "aha_qwen3"
|
| 1123 |
+
aha_config = AHAQwen3Config(**config_dict)
|
| 1124 |
+
aha_config._attn_implementation = kwargs.get("attn_implementation", "sdpa")
|
| 1125 |
+
|
| 1126 |
+
# Create AHA model
|
| 1127 |
+
aha_model = cls(aha_config)
|
| 1128 |
+
|
| 1129 |
+
# Copy weights
|
| 1130 |
+
# embed_tokens, lm_head, norm
|
| 1131 |
+
aha_model.model.embed_tokens.load_state_dict(original.model.embed_tokens.state_dict())
|
| 1132 |
+
aha_model.lm_head.load_state_dict(original.lm_head.state_dict())
|
| 1133 |
+
aha_model.model.norm.load_state_dict(original.model.norm.state_dict())
|
| 1134 |
+
aha_model.model.rotary_emb.load_state_dict(original.model.rotary_emb.state_dict())
|
| 1135 |
+
|
| 1136 |
+
# Copy per-layer weights
|
| 1137 |
+
num_heads = original_config.num_attention_heads
|
| 1138 |
+
head_dim = aha_config.head_dim
|
| 1139 |
+
|
| 1140 |
+
for i in range(original_config.num_hidden_layers):
|
| 1141 |
+
orig_layer = original.model.layers[i]
|
| 1142 |
+
aha_layer = aha_model.model.layers[i]
|
| 1143 |
+
|
| 1144 |
+
# MLP + norms: direct copy
|
| 1145 |
+
aha_layer.mlp.load_state_dict(orig_layer.mlp.state_dict())
|
| 1146 |
+
aha_layer.input_layernorm.load_state_dict(orig_layer.input_layernorm.state_dict())
|
| 1147 |
+
aha_layer.post_attention_layernorm.load_state_dict(orig_layer.post_attention_layernorm.state_dict())
|
| 1148 |
+
|
| 1149 |
+
# Attention: k_proj, v_proj, o_proj, q_norm, k_norm — direct copy
|
| 1150 |
+
aha_attn = aha_layer.self_attn
|
| 1151 |
+
orig_attn = orig_layer.self_attn
|
| 1152 |
+
aha_attn.k_proj.load_state_dict(orig_attn.k_proj.state_dict())
|
| 1153 |
+
aha_attn.v_proj.load_state_dict(orig_attn.v_proj.state_dict())
|
| 1154 |
+
aha_attn.o_proj.load_state_dict(orig_attn.o_proj.state_dict())
|
| 1155 |
+
aha_attn.q_norm.load_state_dict(orig_attn.q_norm.state_dict())
|
| 1156 |
+
aha_attn.k_norm.load_state_dict(orig_attn.k_norm.state_dict())
|
| 1157 |
+
|
| 1158 |
+
# q_proj:
|
| 1159 |
+
# - dynamic / duo_dynamic mode: copy original Q rows, init extra
|
| 1160 |
+
# gate rows random (~0.5).
|
| 1161 |
+
# - duo mode: q_proj is identical shape to base Qwen3 (no gate
|
| 1162 |
+
# rows), so direct load_state_dict works.
|
| 1163 |
+
orig_q_weight = orig_attn.q_proj.weight.data # (num_heads*head_dim, hidden_size)
|
| 1164 |
+
aha_q_weight = aha_attn.q_proj.weight.data
|
| 1165 |
+
if aha_attn._has_dyn_gate_logits:
|
| 1166 |
+
aha_q_weight[:num_heads * head_dim, :] = orig_q_weight
|
| 1167 |
+
nn.init.normal_(aha_q_weight[num_heads * head_dim:, :], mean=0.0, std=0.01)
|
| 1168 |
+
if orig_attn.q_proj.bias is not None and aha_attn.q_proj.bias is not None:
|
| 1169 |
+
aha_attn.q_proj.bias.data[:num_heads * head_dim] = orig_attn.q_proj.bias.data
|
| 1170 |
+
nn.init.zeros_(aha_attn.q_proj.bias.data[num_heads * head_dim:])
|
| 1171 |
+
else:
|
| 1172 |
+
aha_attn.q_proj.load_state_dict(orig_attn.q_proj.state_dict())
|
| 1173 |
+
|
| 1174 |
+
del original
|
| 1175 |
+
return aha_model
|
| 1176 |
+
|
| 1177 |
+
@classmethod
|
| 1178 |
+
def from_pretrained_aha(cls, checkpoint_path: str, **kwargs):
|
| 1179 |
+
"""Load an AHA-Qwen3 checkpoint saved by `trainer.save_model`.
|
| 1180 |
+
|
| 1181 |
+
Supports both single-file (``model.safetensors``) and multi-shard
|
| 1182 |
+
(``model.safetensors.index.json`` + ``model-*-of-*.safetensors``)
|
| 1183 |
+
layouts. The latter is automatically used by HF when a checkpoint
|
| 1184 |
+
exceeds the single-file size threshold (e.g. Qwen3-1.7B+).
|
| 1185 |
+
"""
|
| 1186 |
+
config = AHAQwen3Config.from_pretrained(checkpoint_path)
|
| 1187 |
+
config._attn_implementation = kwargs.get(
|
| 1188 |
+
"attn_implementation",
|
| 1189 |
+
getattr(config, "_attn_implementation", "sdpa"),
|
| 1190 |
+
)
|
| 1191 |
+
|
| 1192 |
+
model = cls(config)
|
| 1193 |
+
|
| 1194 |
+
single_path = os.path.join(checkpoint_path, "model.safetensors")
|
| 1195 |
+
index_path = os.path.join(checkpoint_path, "model.safetensors.index.json")
|
| 1196 |
+
if os.path.exists(single_path):
|
| 1197 |
+
state_dict = load_file(single_path)
|
| 1198 |
+
elif os.path.exists(index_path):
|
| 1199 |
+
with open(index_path) as f:
|
| 1200 |
+
shard_index = json.load(f)
|
| 1201 |
+
shard_files = sorted(set(shard_index["weight_map"].values()))
|
| 1202 |
+
state_dict = {}
|
| 1203 |
+
for shard in shard_files:
|
| 1204 |
+
state_dict.update(load_file(os.path.join(checkpoint_path, shard)))
|
| 1205 |
+
else:
|
| 1206 |
+
raise FileNotFoundError(
|
| 1207 |
+
f"No safetensors file or index found in {checkpoint_path}. "
|
| 1208 |
+
f"Expected either model.safetensors or model.safetensors.index.json."
|
| 1209 |
+
)
|
| 1210 |
+
missing_keys, unexpected_keys = model.load_state_dict(state_dict, strict=False)
|
| 1211 |
+
allowed_missing = {"lm_head.weight"} if getattr(config, "tie_word_embeddings", False) else set()
|
| 1212 |
+
extra_missing = set(missing_keys) - allowed_missing
|
| 1213 |
+
if extra_missing or unexpected_keys:
|
| 1214 |
+
raise RuntimeError(
|
| 1215 |
+
f"Unexpected checkpoint mismatch. missing={sorted(extra_missing)}, "
|
| 1216 |
+
f"unexpected={sorted(unexpected_keys)}"
|
| 1217 |
+
)
|
| 1218 |
+
if "lm_head.weight" in missing_keys:
|
| 1219 |
+
model.tie_weights()
|
| 1220 |
+
|
| 1221 |
+
_apply_low_alpha_force_full_heads_from_env(model)
|
| 1222 |
+
|
| 1223 |
+
torch_dtype = kwargs.get("torch_dtype")
|
| 1224 |
+
if torch_dtype is not None:
|
| 1225 |
+
model = model.to(dtype=torch_dtype)
|
| 1226 |
+
return model
|
recipe/protocol.json
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"release": "jiamingshan/AHA-L2A-Qwen3-1.7B-repro",
|
| 3 |
+
"source_git": {
|
| 4 |
+
"repository": "https://github.com/shanjiaming/AHA",
|
| 5 |
+
"branch": "codex/l2a-router-granularity",
|
| 6 |
+
"commit": "b47a549"
|
| 7 |
+
},
|
| 8 |
+
"base_checkpoint": {
|
| 9 |
+
"location": "repository root",
|
| 10 |
+
"description": "Qwen3-1.7B tuned vanilla, global step 6911",
|
| 11 |
+
"sha256_model_safetensors": "22c971a65f7f1835b0e2a38b45f2f92191f5d8b63b30969f2f61a69d30afbc05"
|
| 12 |
+
},
|
| 13 |
+
"execution": {
|
| 14 |
+
"distributed_training": false,
|
| 15 |
+
"world_size_per_arm": 1,
|
| 16 |
+
"gpu_count_per_arm": 1,
|
| 17 |
+
"one_gpu_machine": "run AHA then L2A-style sequentially on GPU 0",
|
| 18 |
+
"two_or_more_gpu_machine": "run AHA on GPU 0 and L2A-style on GPU 1 concurrently; remaining GPUs are unused",
|
| 19 |
+
"arm_definition": "one independent experimental variant, not a processor architecture or GPU group"
|
| 20 |
+
},
|
| 21 |
+
"arms": {
|
| 22 |
+
"aha": {
|
| 23 |
+
"router_granularity": "token_kv_head",
|
| 24 |
+
"description": "one native gate per token, KV head, and layer"
|
| 25 |
+
},
|
| 26 |
+
"l2a_style": {
|
| 27 |
+
"router_granularity": "token",
|
| 28 |
+
"description": "one native gate per token and layer, shared across all heads",
|
| 29 |
+
"claim_boundary": "L2A-style shared-gate; not an official L2A reproduction"
|
| 30 |
+
}
|
| 31 |
+
},
|
| 32 |
+
"common": {
|
| 33 |
+
"local_attention": {
|
| 34 |
+
"kind": "sink_recent",
|
| 35 |
+
"sink_tokens": 64,
|
| 36 |
+
"recent_tokens": 256
|
| 37 |
+
},
|
| 38 |
+
"gate_initialization": {
|
| 39 |
+
"weight": "zeros",
|
| 40 |
+
"full_probability": 0.9
|
| 41 |
+
},
|
| 42 |
+
"effective_sparsity_denominator": "token x KV-head x layer"
|
| 43 |
+
},
|
| 44 |
+
"stage_1": {
|
| 45 |
+
"dataset": "recipe/data/am_distilled_long_mix",
|
| 46 |
+
"rows": 1024,
|
| 47 |
+
"max_length": 8192,
|
| 48 |
+
"batch_size": 1,
|
| 49 |
+
"optimizer_steps": 300,
|
| 50 |
+
"epochs": 0.29296875,
|
| 51 |
+
"trainable": "native gate rows only",
|
| 52 |
+
"learning_rate": 0.00003,
|
| 53 |
+
"warmup_ratio": 0.1,
|
| 54 |
+
"hidden_state_distillation": 1.0,
|
| 55 |
+
"regularizer": 0.1,
|
| 56 |
+
"ce": 0.0,
|
| 57 |
+
"train_threshold": 0.5,
|
| 58 |
+
"seed": 42
|
| 59 |
+
},
|
| 60 |
+
"stage_2": {
|
| 61 |
+
"dataset": "same 1024 rows",
|
| 62 |
+
"max_length": 8192,
|
| 63 |
+
"batch_size": 1,
|
| 64 |
+
"optimizer_steps_run": 75,
|
| 65 |
+
"selected_checkpoint_step": 25,
|
| 66 |
+
"selected_epochs": 0.0244140625,
|
| 67 |
+
"gate_learning_rate": 0.000003,
|
| 68 |
+
"backbone_learning_rate": 0.0000003,
|
| 69 |
+
"ce": 1.0,
|
| 70 |
+
"attention_distillation": 0.5,
|
| 71 |
+
"regularizer": 0.01,
|
| 72 |
+
"train_threshold": 0.58,
|
| 73 |
+
"freeze_embeddings_and_lm_head": true,
|
| 74 |
+
"seed": 47
|
| 75 |
+
},
|
| 76 |
+
"evaluation": {
|
| 77 |
+
"thresholds": [0.45, 0.5, 0.525, 0.55, 0.575, 0.6, 0.625, 0.65],
|
| 78 |
+
"strict_prefill_and_decode_sparse_routing": true,
|
| 79 |
+
"force_full_heads": false,
|
| 80 |
+
"full_decode": false,
|
| 81 |
+
"headline": "highest measured effective sparsity whose score is at least 95% of tuned vanilla on every suite"
|
| 82 |
+
}
|
| 83 |
+
}
|
recipe/requirements.txt
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
transformers==4.54.0
|
| 2 |
+
trl==0.19.1
|
| 3 |
+
datasets==4.8.4
|
| 4 |
+
accelerate==1.13.0
|
| 5 |
+
safetensors==0.7.0
|
| 6 |
+
huggingface-hub==0.36.2
|
| 7 |
+
tokenizers==0.21.4
|
| 8 |
+
pyarrow==24.0.0
|
| 9 |
+
pandas==3.0.3
|
| 10 |
+
numpy==2.3.4
|
| 11 |
+
matplotlib==3.10.9
|
| 12 |
+
lm_eval==0.4.11
|
recipe/router_training_utils.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared training helpers for AHA q_proj router rows."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
|
| 7 |
+
import torch
|
| 8 |
+
|
| 9 |
+
from modeling_aha_qwen3 import aha_router_output_size
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@dataclass
|
| 13 |
+
class GateOnlySetup:
|
| 14 |
+
parameters: list[torch.nn.Parameter]
|
| 15 |
+
effective_parameter_count: int
|
| 16 |
+
q_rows: int
|
| 17 |
+
gate_rows: int
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class RowWiseAdamW(torch.optim.AdamW):
|
| 21 |
+
"""AdamW with an exact lower LR on prefixes of selected tensors."""
|
| 22 |
+
|
| 23 |
+
def __init__(self, params, *, row_scales, **kwargs):
|
| 24 |
+
super().__init__(params, **kwargs)
|
| 25 |
+
self._row_scales = row_scales
|
| 26 |
+
|
| 27 |
+
@torch.no_grad()
|
| 28 |
+
def step(self, closure=None):
|
| 29 |
+
before = [p[:n_rows].detach().clone() for p, n_rows, _ in self._row_scales]
|
| 30 |
+
loss = super().step(closure=closure)
|
| 31 |
+
for (parameter, n_rows, scale), old in zip(self._row_scales, before):
|
| 32 |
+
if scale != 1.0:
|
| 33 |
+
new = parameter[:n_rows]
|
| 34 |
+
new.copy_(old + scale * (new - old))
|
| 35 |
+
return loss
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def q_projection_rows(config) -> int:
|
| 39 |
+
head_dim = getattr(
|
| 40 |
+
config,
|
| 41 |
+
"head_dim",
|
| 42 |
+
config.hidden_size // config.num_attention_heads,
|
| 43 |
+
)
|
| 44 |
+
return int(config.num_attention_heads * head_dim)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def configure_gate_only(model: torch.nn.Module) -> GateOnlySetup:
|
| 48 |
+
"""Freeze a model and expose only the appended q_proj gate rows.
|
| 49 |
+
|
| 50 |
+
PyTorch cannot mark only a slice of a Parameter trainable, so each q_proj
|
| 51 |
+
tensor remains trainable while a hook zeros the ordinary Q-row gradient.
|
| 52 |
+
The returned parameter count is the effective native gate parameter count,
|
| 53 |
+
not the full q_proj tensor size seen by the optimizer.
|
| 54 |
+
"""
|
| 55 |
+
|
| 56 |
+
for parameter in model.parameters():
|
| 57 |
+
parameter.requires_grad = False
|
| 58 |
+
|
| 59 |
+
q_rows = q_projection_rows(model.config)
|
| 60 |
+
gate_rows = aha_router_output_size(model.config)
|
| 61 |
+
|
| 62 |
+
def mask_q_rows(gradient: torch.Tensor) -> torch.Tensor:
|
| 63 |
+
masked = gradient.clone()
|
| 64 |
+
masked[:q_rows] = 0.0
|
| 65 |
+
return masked
|
| 66 |
+
|
| 67 |
+
parameters: list[torch.nn.Parameter] = []
|
| 68 |
+
effective = 0
|
| 69 |
+
for layer in model.model.layers:
|
| 70 |
+
q_proj = layer.self_attn.q_proj
|
| 71 |
+
q_proj.weight.requires_grad = True
|
| 72 |
+
q_proj.weight.register_hook(mask_q_rows)
|
| 73 |
+
parameters.append(q_proj.weight)
|
| 74 |
+
effective += gate_rows * q_proj.in_features
|
| 75 |
+
if q_proj.bias is not None:
|
| 76 |
+
q_proj.bias.requires_grad = True
|
| 77 |
+
q_proj.bias.register_hook(mask_q_rows)
|
| 78 |
+
parameters.append(q_proj.bias)
|
| 79 |
+
effective += gate_rows
|
| 80 |
+
|
| 81 |
+
return GateOnlySetup(
|
| 82 |
+
parameters=parameters,
|
| 83 |
+
effective_parameter_count=effective,
|
| 84 |
+
q_rows=q_rows,
|
| 85 |
+
gate_rows=gate_rows,
|
| 86 |
+
)
|
recipe/scripts/eval_cell.sh
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
if [[ $# -ne 4 ]]; then
|
| 5 |
+
echo "Usage: $0 METHOD MODEL_PATH GPU BENCHMARK" >&2
|
| 6 |
+
exit 2
|
| 7 |
+
fi
|
| 8 |
+
|
| 9 |
+
METHOD="$1"
|
| 10 |
+
MODEL_PATH="$2"
|
| 11 |
+
GPU="$3"
|
| 12 |
+
BENCHMARK="$4"
|
| 13 |
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
| 14 |
+
RECIPE="$(cd "$SCRIPT_DIR/.." && pwd)"
|
| 15 |
+
REPO="$(cd "$RECIPE/.." && pwd)"
|
| 16 |
+
EVAL_ROOT="${EVAL_ROOT:-$REPO/outputs/eval}"
|
| 17 |
+
OUT="$EVAL_ROOT/$METHOD"
|
| 18 |
+
LOG="$EVAL_ROOT/logs/$METHOD"
|
| 19 |
+
TOKENIZER="${VANILLA_DIR:-$REPO}"
|
| 20 |
+
HELMET_ROWS="$RECIPE/data/eval_inputs/helmet_icl_8k_n50_per_config.jsonl"
|
| 21 |
+
MRCR_ROWS="$RECIPE/data/eval_inputs/mrcr_8k_2_4_8needle_n10_per_config.jsonl"
|
| 22 |
+
RULER_LIMIT="${RULER_LIMIT:-20}"
|
| 23 |
+
BABILONG_LIMIT="${BABILONG_LIMIT:-50}"
|
| 24 |
+
AHA_GATE_HARD_THRESHOLD="${AHA_GATE_HARD_THRESHOLD:-0.5}"
|
| 25 |
+
mkdir -p "$OUT" "$LOG"
|
| 26 |
+
|
| 27 |
+
if [[ -f "$OUT/${BENCHMARK}.DONE" ]]; then
|
| 28 |
+
exit 0
|
| 29 |
+
fi
|
| 30 |
+
|
| 31 |
+
export CUDA_VISIBLE_DEVICES="$GPU"
|
| 32 |
+
export TOKENIZERS_PARALLELISM=false
|
| 33 |
+
export PYTHONUNBUFFERED=1
|
| 34 |
+
export AHA_SPARSITY_STATS_PATH="$OUT/sparsity_${BENCHMARK}.json"
|
| 35 |
+
export AHA_GATE_HARD_THRESHOLD
|
| 36 |
+
export AHA_FORCE_FULL_DECODE=0
|
| 37 |
+
export AHA_FORCE_FULL_PREFILL_TAIL=0
|
| 38 |
+
export AHA_FORCE_FULL_HEADS=
|
| 39 |
+
export AHA_FORCE_LOW_ALPHA_FULL_HEADS=0
|
| 40 |
+
export AHA_DUO_PREFILL_FULL=0
|
| 41 |
+
|
| 42 |
+
model_args="pretrained=${MODEL_PATH},trust_remote_code=True,dtype=bfloat16,max_length=16384,attn_implementation=sdpa"
|
| 43 |
+
split_a="niah_single_1,niah_single_3,niah_multikey_2,niah_multiquery,ruler_vt,ruler_fwe,ruler_qa_hotpot"
|
| 44 |
+
split_b="niah_single_2,niah_multikey_1,niah_multikey_3,niah_multivalue,ruler_cwe,ruler_qa_squad"
|
| 45 |
+
|
| 46 |
+
case "$BENCHMARK" in
|
| 47 |
+
ruler_a|ruler_b)
|
| 48 |
+
tasks="$split_a"
|
| 49 |
+
[[ "$BENCHMARK" == ruler_b ]] && tasks="$split_b"
|
| 50 |
+
python -m lm_eval \
|
| 51 |
+
--model hf --model_args "$model_args" \
|
| 52 |
+
--tasks "$tasks" --metadata '{"max_seq_lengths":[8192]}' \
|
| 53 |
+
--batch_size 1 --limit "$RULER_LIMIT" --log_samples \
|
| 54 |
+
--output_path "$OUT/ruler8k_splits/${BENCHMARK#ruler_}/lm_eval" \
|
| 55 |
+
>"$LOG/${BENCHMARK}.log" 2>&1
|
| 56 |
+
;;
|
| 57 |
+
babilong)
|
| 58 |
+
python -m lm_eval \
|
| 59 |
+
--model hf --model_args "$model_args" \
|
| 60 |
+
--tasks babilong_longctx --metadata '{"max_seq_lengths":"8k"}' \
|
| 61 |
+
--num_fewshot 2 --batch_size 1 --limit "$BABILONG_LIMIT" --log_samples \
|
| 62 |
+
--output_path "$OUT/babilong8k_qa1_qa5_n50" \
|
| 63 |
+
>"$LOG/babilong.log" 2>&1
|
| 64 |
+
;;
|
| 65 |
+
helmet)
|
| 66 |
+
python "$SCRIPT_DIR/eval_qwen_external_longctx_pilot.py" \
|
| 67 |
+
--benchmark helmet_icl --model-path "$MODEL_PATH" --tokenizer-path "$TOKENIZER" \
|
| 68 |
+
--attn-implementation sdpa --rows "$HELMET_ROWS" \
|
| 69 |
+
--method "$METHOD" --output "$OUT/helmet_icl8k_n50.jsonl" \
|
| 70 |
+
>"$LOG/helmet.log" 2>&1
|
| 71 |
+
;;
|
| 72 |
+
mrcr)
|
| 73 |
+
python "$SCRIPT_DIR/eval_qwen_external_longctx_pilot.py" \
|
| 74 |
+
--benchmark mrcr --model-path "$MODEL_PATH" --tokenizer-path "$TOKENIZER" \
|
| 75 |
+
--attn-implementation sdpa --rows "$MRCR_ROWS" \
|
| 76 |
+
--method "$METHOD" --output "$OUT/mrcr_8k_2_4_8needle_n10.jsonl" \
|
| 77 |
+
>"$LOG/mrcr.log" 2>&1
|
| 78 |
+
;;
|
| 79 |
+
*) echo "Unknown benchmark: $BENCHMARK" >&2; exit 2 ;;
|
| 80 |
+
esac
|
| 81 |
+
|
| 82 |
+
touch "$OUT/${BENCHMARK}.DONE"
|
recipe/scripts/eval_qwen_external_longctx_pilot.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Aligned 8K HELMET-ICL and OpenAI-MRCR pilot for Qwen checkpoints.
|
| 3 |
+
|
| 4 |
+
This is deliberately a small diagnostic runner. HELMET prompt construction
|
| 5 |
+
matches the ICL schedule used by the local OmniServe reproduction; MRCR uses
|
| 6 |
+
the official OpenAI rows selected by that reproduction, but renders the
|
| 7 |
+
messages with the Qwen chat template.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import argparse
|
| 13 |
+
import csv
|
| 14 |
+
import hashlib
|
| 15 |
+
import json
|
| 16 |
+
import math
|
| 17 |
+
import random
|
| 18 |
+
import re
|
| 19 |
+
import string
|
| 20 |
+
from collections import defaultdict
|
| 21 |
+
from difflib import SequenceMatcher
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
|
| 24 |
+
import pandas as pd
|
| 25 |
+
import torch
|
| 26 |
+
from datasets import load_dataset
|
| 27 |
+
from huggingface_hub import hf_hub_download
|
| 28 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
HELMET_SPECS = {
|
| 32 |
+
"trec_coarse": ("icl_trec_coarse_400shot_balance", 6),
|
| 33 |
+
"trec_fine": ("icl_trec_fine_400shot_balance", 50),
|
| 34 |
+
"banking77": ("icl_banking77_360shot_balance", 77),
|
| 35 |
+
"clinic150": ("icl_clinic150_440shot_balance", 151),
|
| 36 |
+
"nlu": ("icl_nlu_510shot_balance", 68),
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def balanced(data, shots: int, label_field: str, seed: int):
|
| 41 |
+
rng = random.Random(seed)
|
| 42 |
+
by_label = defaultdict(list)
|
| 43 |
+
for item in data:
|
| 44 |
+
by_label[item[label_field]].append(item)
|
| 45 |
+
rounds = math.ceil(shots / len(by_label))
|
| 46 |
+
selected_rounds = [[] for _ in range(rounds)]
|
| 47 |
+
for examples in by_label.values():
|
| 48 |
+
indices = rng.sample(range(len(examples)), rounds % len(examples))
|
| 49 |
+
while len(indices) < rounds:
|
| 50 |
+
indices += rng.sample(range(len(examples)), min(rounds - len(indices), len(examples)))
|
| 51 |
+
for index, example_index in enumerate(indices):
|
| 52 |
+
selected_rounds[index].append(examples[example_index])
|
| 53 |
+
for examples in selected_rounds:
|
| 54 |
+
rng.shuffle(examples)
|
| 55 |
+
return [item for group in selected_rounds for item in group][:shots]
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def helmet_source(family: str, seed: int):
|
| 59 |
+
if family == "trec_coarse":
|
| 60 |
+
source = load_dataset("CogComp/trec", trust_remote_code=True)
|
| 61 |
+
return source["train"], source["test"], "text", "coarse_label"
|
| 62 |
+
if family == "trec_fine":
|
| 63 |
+
source = load_dataset("CogComp/trec", trust_remote_code=True)
|
| 64 |
+
return source["train"], source["test"], "text", "fine_label"
|
| 65 |
+
if family == "banking77":
|
| 66 |
+
source = load_dataset("PolyAI/banking77", trust_remote_code=True)
|
| 67 |
+
return source["train"], source["test"], "text", "label"
|
| 68 |
+
if family == "clinic150":
|
| 69 |
+
source = load_dataset("clinc/clinc_oos", "plus")
|
| 70 |
+
return source["train"], source["validation"], "text", "intent"
|
| 71 |
+
if family == "nlu":
|
| 72 |
+
source = load_dataset("xingkunliuxtracta/nlu_evaluation_data", trust_remote_code=True)["train"]
|
| 73 |
+
split = source.train_test_split(test_size=0.1, seed=seed)
|
| 74 |
+
return split["train"], split["test"], "text", "label"
|
| 75 |
+
raise ValueError(family)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def build_helmet(seed: int, limit: int):
|
| 79 |
+
rows = []
|
| 80 |
+
user_template = (
|
| 81 |
+
'Use the provided mapping from the text to label to assign a label to the text. '
|
| 82 |
+
'Only output "label: {{label}}" and nothing else. \n\n{context}\n\n{question}'
|
| 83 |
+
)
|
| 84 |
+
for family, (dataset_name, num_labels) in HELMET_SPECS.items():
|
| 85 |
+
shots = int(dataset_name.split("shot")[0].split("_")[-1])
|
| 86 |
+
train, test, text_field, label_field = helmet_source(family, seed)
|
| 87 |
+
samples = balanced(test, limit, label_field, seed)
|
| 88 |
+
for sample_index, sample in enumerate(samples):
|
| 89 |
+
local_seed = (int(hashlib.sha256(sample[text_field].encode()).hexdigest(), 16) + seed) % 2**31
|
| 90 |
+
demos = balanced(train, shots, label_field, local_seed)
|
| 91 |
+
mapping = list(range(num_labels))
|
| 92 |
+
random.Random(local_seed).shuffle(mapping)
|
| 93 |
+
context = "\n\n".join(
|
| 94 |
+
f"{demo[text_field]}\nlabel: {mapping[int(demo[label_field])]}" for demo in demos
|
| 95 |
+
)
|
| 96 |
+
rows.append({
|
| 97 |
+
"row_id": f"helmet_icl:8k:{family}:{sample_index}",
|
| 98 |
+
"benchmark": "helmet_icl",
|
| 99 |
+
"config": family,
|
| 100 |
+
"prompt": user_template.format(context=context, question=sample[text_field]) + "\nlabel:",
|
| 101 |
+
"answer": str(mapping[int(sample[label_field])]),
|
| 102 |
+
"max_new_tokens": 20,
|
| 103 |
+
"metadata": {"dataset": dataset_name, "shots": shots},
|
| 104 |
+
})
|
| 105 |
+
return rows
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def build_mrcr(tokenizer, selection_path: Path, limit: int):
|
| 109 |
+
selection = [json.loads(line) for line in selection_path.read_text(encoding="utf-8").splitlines() if line.strip()]
|
| 110 |
+
chosen = sorted(
|
| 111 |
+
(row for row in selection if int(row["context_k"]) == 8),
|
| 112 |
+
key=lambda row: int(row["sample_index"]),
|
| 113 |
+
)[:limit]
|
| 114 |
+
if len(chosen) < limit:
|
| 115 |
+
raise ValueError(f"MRCR selection contains {len(chosen)} 8K rows, requested {limit}")
|
| 116 |
+
files = [
|
| 117 |
+
hf_hub_download("openai/mrcr", filename=f"2needle/2needle_{shard}.parquet", repo_type="dataset")
|
| 118 |
+
for shard in (0, 1)
|
| 119 |
+
]
|
| 120 |
+
frame = pd.concat([pd.read_parquet(path) for path in files], ignore_index=True)
|
| 121 |
+
rows = []
|
| 122 |
+
for selected in chosen:
|
| 123 |
+
source = frame.iloc[int(selected["source_index"])]
|
| 124 |
+
messages = json.loads(source["prompt"])
|
| 125 |
+
prompt = tokenizer.apply_chat_template(
|
| 126 |
+
messages,
|
| 127 |
+
tokenize=False,
|
| 128 |
+
add_generation_prompt=True,
|
| 129 |
+
enable_thinking=False,
|
| 130 |
+
)
|
| 131 |
+
answer = str(source["answer"])
|
| 132 |
+
sample_index = int(selected["sample_index"])
|
| 133 |
+
rows.append({
|
| 134 |
+
"row_id": f"mrcr:8k:2needle:{sample_index}",
|
| 135 |
+
"benchmark": "mrcr",
|
| 136 |
+
"config": "2needle",
|
| 137 |
+
"prompt": prompt,
|
| 138 |
+
"answer": answer,
|
| 139 |
+
"prefix": str(source["random_string_to_prepend"]),
|
| 140 |
+
"max_new_tokens": min(768, len(tokenizer(answer, add_special_tokens=False).input_ids) + 64),
|
| 141 |
+
"metadata": {"source_index": int(selected["source_index"]), "official_tokens": int(selected["official_tokens"])},
|
| 142 |
+
})
|
| 143 |
+
return rows
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def normalize(text: str) -> str:
|
| 147 |
+
text = text.lower()
|
| 148 |
+
text = re.sub(r"\b(a|an|the)\b", " ", text)
|
| 149 |
+
text = "".join(ch for ch in text if ch not in string.punctuation)
|
| 150 |
+
return " ".join(text.split())
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def score(row, prediction: str) -> float:
|
| 154 |
+
if row["benchmark"] == "helmet_icl":
|
| 155 |
+
first = prediction.strip().splitlines()[0] if prediction.strip() else ""
|
| 156 |
+
parsed = re.sub(r"^label:", "", first, flags=re.I).strip()
|
| 157 |
+
return float(normalize(parsed) == normalize(row["answer"]))
|
| 158 |
+
prefix = row["prefix"]
|
| 159 |
+
if not prediction.startswith(prefix):
|
| 160 |
+
return 0.0
|
| 161 |
+
response = prediction.removeprefix(prefix).strip()
|
| 162 |
+
reference = row["answer"].removeprefix(prefix).strip()
|
| 163 |
+
return float(SequenceMatcher(None, response, reference).ratio())
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def main():
|
| 167 |
+
parser = argparse.ArgumentParser()
|
| 168 |
+
parser.add_argument("--benchmark", choices=("helmet_icl", "mrcr"), required=True)
|
| 169 |
+
parser.add_argument("--model-path", required=True)
|
| 170 |
+
parser.add_argument("--tokenizer-path", required=True)
|
| 171 |
+
parser.add_argument(
|
| 172 |
+
"--attn-implementation",
|
| 173 |
+
default="flash_attention_2",
|
| 174 |
+
choices=("eager", "sdpa", "flash_attention_2"),
|
| 175 |
+
help="Transformers attention backend; AHA 4-D local masks require SDPA or eager.",
|
| 176 |
+
)
|
| 177 |
+
parser.add_argument("--selection", type=Path)
|
| 178 |
+
parser.add_argument("--rows", type=Path, help="Frozen aligned prompt rows (JSONL)")
|
| 179 |
+
parser.add_argument("--method", required=True)
|
| 180 |
+
parser.add_argument("--output", type=Path, required=True)
|
| 181 |
+
parser.add_argument("--seed", type=int, default=20260710)
|
| 182 |
+
parser.add_argument("--limit", type=int, default=1, help="Samples per benchmark config")
|
| 183 |
+
parser.add_argument(
|
| 184 |
+
"--stop-new-line",
|
| 185 |
+
action="store_true",
|
| 186 |
+
help="Use HELMET's newline stop-token policy during generation.",
|
| 187 |
+
)
|
| 188 |
+
parser.add_argument(
|
| 189 |
+
"--duo-sink-size",
|
| 190 |
+
type=int,
|
| 191 |
+
help="Optional inference-only override for the AHA sink token count.",
|
| 192 |
+
)
|
| 193 |
+
parser.add_argument(
|
| 194 |
+
"--duo-recent-size",
|
| 195 |
+
type=int,
|
| 196 |
+
help="Optional inference-only override for the AHA recent-window token count.",
|
| 197 |
+
)
|
| 198 |
+
args = parser.parse_args()
|
| 199 |
+
|
| 200 |
+
tokenizer = AutoTokenizer.from_pretrained(args.tokenizer_path, trust_remote_code=True, use_fast=False)
|
| 201 |
+
if args.rows:
|
| 202 |
+
rows = [json.loads(line) for line in args.rows.read_text(encoding="utf-8").splitlines() if line.strip()]
|
| 203 |
+
else:
|
| 204 |
+
rows = (
|
| 205 |
+
build_helmet(args.seed, args.limit)
|
| 206 |
+
if args.benchmark == "helmet_icl"
|
| 207 |
+
else build_mrcr(tokenizer, args.selection, args.limit)
|
| 208 |
+
)
|
| 209 |
+
model = AutoModelForCausalLM.from_pretrained(
|
| 210 |
+
args.model_path,
|
| 211 |
+
trust_remote_code=True,
|
| 212 |
+
torch_dtype=torch.bfloat16,
|
| 213 |
+
attn_implementation=args.attn_implementation,
|
| 214 |
+
)
|
| 215 |
+
for field, value in (
|
| 216 |
+
("duo_sink_size", args.duo_sink_size),
|
| 217 |
+
("duo_recent_size", args.duo_recent_size),
|
| 218 |
+
):
|
| 219 |
+
if value is not None:
|
| 220 |
+
if value < 0:
|
| 221 |
+
parser.error(f"--{field.replace('_', '-')} must be non-negative")
|
| 222 |
+
previous = getattr(model.config, field, None)
|
| 223 |
+
setattr(model.config, field, value)
|
| 224 |
+
print(f"[eval] {field} override: {previous!r} -> {value!r}", flush=True)
|
| 225 |
+
model = model.to("cuda").eval()
|
| 226 |
+
stop_token_ids = model.generation_config.eos_token_id
|
| 227 |
+
stop_token_ids = list(stop_token_ids) if isinstance(stop_token_ids, list) else [stop_token_ids]
|
| 228 |
+
if args.stop_new_line:
|
| 229 |
+
newline_tokens = ["\n", "Ċ", "ĊĊ", "<0x0A>"]
|
| 230 |
+
stop_token_ids += [tokenizer.convert_tokens_to_ids(token) for token in newline_tokens]
|
| 231 |
+
stop_token_ids = sorted(
|
| 232 |
+
{
|
| 233 |
+
token_id
|
| 234 |
+
for token_id in stop_token_ids
|
| 235 |
+
if token_id is not None and token_id != tokenizer.unk_token_id
|
| 236 |
+
}
|
| 237 |
+
)
|
| 238 |
+
print(f"[eval] stop_token_ids={stop_token_ids}", flush=True)
|
| 239 |
+
args.output.parent.mkdir(parents=True, exist_ok=True)
|
| 240 |
+
outputs = []
|
| 241 |
+
for ordinal, row in enumerate(rows, 1):
|
| 242 |
+
encoded = tokenizer(row["prompt"], return_tensors="pt", add_special_tokens=False)
|
| 243 |
+
prompt_tokens = int(encoded.input_ids.shape[1])
|
| 244 |
+
print(f"[{args.benchmark}] {ordinal}/{len(rows)} {row['config']} tokens={prompt_tokens}", flush=True)
|
| 245 |
+
encoded = {key: value.to("cuda") for key, value in encoded.items()}
|
| 246 |
+
with torch.inference_mode():
|
| 247 |
+
generated = model.generate(
|
| 248 |
+
**encoded,
|
| 249 |
+
do_sample=False,
|
| 250 |
+
max_new_tokens=row["max_new_tokens"],
|
| 251 |
+
use_cache=True,
|
| 252 |
+
eos_token_id=stop_token_ids,
|
| 253 |
+
pad_token_id=tokenizer.pad_token_id,
|
| 254 |
+
)
|
| 255 |
+
prediction = tokenizer.decode(generated[0, prompt_tokens:], skip_special_tokens=True).strip()
|
| 256 |
+
outputs.append({
|
| 257 |
+
**row,
|
| 258 |
+
"method": args.method,
|
| 259 |
+
"prompt_tokens": prompt_tokens,
|
| 260 |
+
"prompt_sha256": hashlib.sha256(row["prompt"].encode()).hexdigest(),
|
| 261 |
+
"prediction": prediction,
|
| 262 |
+
"score": score(row, prediction),
|
| 263 |
+
})
|
| 264 |
+
args.output.write_text("".join(json.dumps(row, ensure_ascii=False) + "\n" for row in outputs))
|
| 265 |
+
summary = args.output.with_suffix(".summary.csv")
|
| 266 |
+
with summary.open("w", newline="", encoding="utf-8") as handle:
|
| 267 |
+
writer = csv.DictWriter(handle, fieldnames=["method", "benchmark", "config", "n", "score", "prompt_tokens"])
|
| 268 |
+
writer.writeheader()
|
| 269 |
+
for row in outputs:
|
| 270 |
+
writer.writerow({"method": args.method, "benchmark": row["benchmark"], "config": row["config"], "n": 1, "score": row["score"], "prompt_tokens": row["prompt_tokens"]})
|
| 271 |
+
print(summary)
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
if __name__ == "__main__":
|
| 275 |
+
main()
|
recipe/scripts/eval_sweep.sh
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
| 5 |
+
RECIPE="$(cd "$SCRIPT_DIR/.." && pwd)"
|
| 6 |
+
REPO="$(cd "$RECIPE/.." && pwd)"
|
| 7 |
+
OUTPUT_ROOT="${OUTPUT_ROOT:-$REPO/outputs}"
|
| 8 |
+
EVAL_ROOT="${EVAL_ROOT:-$OUTPUT_ROOT/eval}"
|
| 9 |
+
VANILLA_MODEL="${VANILLA_DIR:-$REPO}"
|
| 10 |
+
AHA_MODEL="${AHA_MODEL:-$OUTPUT_ROOT/aha/stage2/checkpoint-25}"
|
| 11 |
+
L2A_MODEL="${L2A_MODEL:-$OUTPUT_ROOT/l2a_style/stage2/checkpoint-25}"
|
| 12 |
+
GPU_LIST="${GPU_LIST:-0}"
|
| 13 |
+
THRESHOLDS=(0.45 0.50 0.525 0.55 0.575 0.60 0.625 0.65)
|
| 14 |
+
BENCHMARKS=(ruler_a ruler_b babilong helmet mrcr)
|
| 15 |
+
IFS=',' read -r -a GPUS <<< "$GPU_LIST"
|
| 16 |
+
declare -A SLOT_PIDS=()
|
| 17 |
+
mkdir -p "$EVAL_ROOT"
|
| 18 |
+
|
| 19 |
+
for model in "$VANILLA_MODEL" "$AHA_MODEL" "$L2A_MODEL"; do
|
| 20 |
+
[[ -f "$model/config.json" ]] || { echo "Missing model: $model" >&2; exit 2; }
|
| 21 |
+
done
|
| 22 |
+
|
| 23 |
+
jobs=()
|
| 24 |
+
for benchmark in "${BENCHMARKS[@]}"; do
|
| 25 |
+
jobs+=("vanilla|$VANILLA_MODEL|0.5|$benchmark")
|
| 26 |
+
done
|
| 27 |
+
for threshold in "${THRESHOLDS[@]}"; do
|
| 28 |
+
slug="${threshold/./}"
|
| 29 |
+
for benchmark in "${BENCHMARKS[@]}"; do
|
| 30 |
+
jobs+=("token_kv_head_t${slug}|$AHA_MODEL|$threshold|$benchmark")
|
| 31 |
+
jobs+=("token_t${slug}|$L2A_MODEL|$threshold|$benchmark")
|
| 32 |
+
done
|
| 33 |
+
done
|
| 34 |
+
|
| 35 |
+
launch() {
|
| 36 |
+
local gpu="$1" method="$2" model="$3" threshold="$4" benchmark="$5"
|
| 37 |
+
EVAL_ROOT="$EVAL_ROOT" RULER_LIMIT=20 BABILONG_LIMIT=50 \
|
| 38 |
+
AHA_GATE_HARD_THRESHOLD="$threshold" \
|
| 39 |
+
"$SCRIPT_DIR/eval_cell.sh" "$method" "$model" "$gpu" "$benchmark" &
|
| 40 |
+
LAST_PID="$!"
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
for i in "${!jobs[@]}"; do
|
| 44 |
+
slot=$((i % ${#GPUS[@]}))
|
| 45 |
+
if [[ -n "${SLOT_PIDS[$slot]:-}" ]]; then
|
| 46 |
+
wait "${SLOT_PIDS[$slot]}"
|
| 47 |
+
fi
|
| 48 |
+
IFS='|' read -r method model threshold benchmark <<< "${jobs[$i]}"
|
| 49 |
+
launch "${GPUS[$slot]}" "$method" "$model" "$threshold" "$benchmark"
|
| 50 |
+
SLOT_PIDS[$slot]="$LAST_PID"
|
| 51 |
+
done
|
| 52 |
+
|
| 53 |
+
status=0
|
| 54 |
+
for pid in "${SLOT_PIDS[@]}"; do
|
| 55 |
+
wait "$pid" || status=1
|
| 56 |
+
done
|
| 57 |
+
[[ "$status" == 0 ]] || exit "$status"
|
| 58 |
+
|
| 59 |
+
python "$SCRIPT_DIR/summarize_qwen1p7b_router_granularity_20260714.py" \
|
| 60 |
+
--repo "$REPO" \
|
| 61 |
+
--eval-root "$EVAL_ROOT" \
|
| 62 |
+
--baseline-root "$EVAL_ROOT/vanilla" \
|
| 63 |
+
--input-dir "$RECIPE/data/eval_inputs" \
|
| 64 |
+
--output-dir "$OUTPUT_ROOT/summary"
|
recipe/scripts/evaluate.sh
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
| 5 |
+
REPO="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
| 6 |
+
IMAGE="${IMAGE:-aha-l2a-qwen3-repro:torch2.9.1-cu128}"
|
| 7 |
+
GPU_COUNT="$(nvidia-smi -L | wc -l)"
|
| 8 |
+
if [[ "$GPU_COUNT" -lt 1 ]]; then
|
| 9 |
+
echo "At least one NVIDIA GPU is required." >&2
|
| 10 |
+
exit 2
|
| 11 |
+
fi
|
| 12 |
+
GPU_LIST="$(seq -s, 0 $((GPU_COUNT - 1)))"
|
| 13 |
+
|
| 14 |
+
docker build -t "$IMAGE" -f "$REPO/recipe/Dockerfile" "$REPO/recipe"
|
| 15 |
+
docker run --rm --gpus all --ipc=host \
|
| 16 |
+
--ulimit memlock=-1 --ulimit stack=67108864 \
|
| 17 |
+
-e GPU_LIST="$GPU_LIST" \
|
| 18 |
+
-v "$REPO:/workspace" \
|
| 19 |
+
-w /workspace/recipe \
|
| 20 |
+
"$IMAGE" bash scripts/eval_sweep.sh
|
recipe/scripts/export_tuned_vanilla_aha_hotstart.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Convert a tuned vanilla Qwen3 checkpoint into a quality-safe AHA hot start.
|
| 3 |
+
|
| 4 |
+
All vanilla weights are preserved. Dynamic gate weights start at zero and a
|
| 5 |
+
trainable bias initializes every gate to the requested full-attention
|
| 6 |
+
probability, so hard routing is initially exactly vanilla full attention.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import argparse
|
| 12 |
+
import json
|
| 13 |
+
import math
|
| 14 |
+
import sys
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
import torch
|
| 18 |
+
from torch import nn
|
| 19 |
+
from transformers import AutoTokenizer
|
| 20 |
+
|
| 21 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 22 |
+
sys.path.insert(0, str(ROOT))
|
| 23 |
+
|
| 24 |
+
from modeling_aha_qwen3 import AHAQwen3Config, AHAQwen3ForCausalLM
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def add_zero_bias(linear: nn.Linear) -> nn.Linear:
|
| 28 |
+
new = nn.Linear(
|
| 29 |
+
linear.in_features,
|
| 30 |
+
linear.out_features,
|
| 31 |
+
bias=True,
|
| 32 |
+
device=linear.weight.device,
|
| 33 |
+
dtype=linear.weight.dtype,
|
| 34 |
+
)
|
| 35 |
+
with torch.no_grad():
|
| 36 |
+
new.weight.copy_(linear.weight)
|
| 37 |
+
new.bias.zero_()
|
| 38 |
+
return new
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def main() -> None:
|
| 42 |
+
parser = argparse.ArgumentParser()
|
| 43 |
+
parser.add_argument("--vanilla-path", required=True)
|
| 44 |
+
parser.add_argument("--output-path", required=True)
|
| 45 |
+
parser.add_argument("--window-size", type=int, default=128)
|
| 46 |
+
parser.add_argument("--gate-init-full-prob", type=float, default=0.90)
|
| 47 |
+
parser.add_argument(
|
| 48 |
+
"--local-kind", choices=("sliding_window", "sink_recent"), default="sliding_window"
|
| 49 |
+
)
|
| 50 |
+
parser.add_argument(
|
| 51 |
+
"--router-granularity",
|
| 52 |
+
choices=("token", "token_kv_head"),
|
| 53 |
+
default="token_kv_head",
|
| 54 |
+
help="Native dynamic gate shape. token is one shared gate per layer/token.",
|
| 55 |
+
)
|
| 56 |
+
args = parser.parse_args()
|
| 57 |
+
|
| 58 |
+
if not 0.5 < args.gate_init_full_prob < 1.0:
|
| 59 |
+
raise ValueError("--gate-init-full-prob must be strictly between 0.5 and 1")
|
| 60 |
+
output = Path(args.output_path)
|
| 61 |
+
output.mkdir(parents=True, exist_ok=True)
|
| 62 |
+
|
| 63 |
+
AHAQwen3Config.register_for_auto_class()
|
| 64 |
+
AHAQwen3ForCausalLM.register_for_auto_class("AutoModelForCausalLM")
|
| 65 |
+
model = AHAQwen3ForCausalLM.from_pretrained_qwen3(
|
| 66 |
+
args.vanilla_path,
|
| 67 |
+
aha_window_size=args.window_size,
|
| 68 |
+
aha_local_kind=args.local_kind,
|
| 69 |
+
aha_router_granularity=args.router_granularity,
|
| 70 |
+
aha_mode="dynamic",
|
| 71 |
+
aha_gate_target=0.70,
|
| 72 |
+
aha_reg_weight=-1.0,
|
| 73 |
+
torch_dtype=torch.bfloat16,
|
| 74 |
+
attn_implementation="sdpa",
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
# Qwen3 normally has bias-free projections. A gate bias gives a stable,
|
| 78 |
+
# token-independent full-attention hot start while zero biases on q/k/v/o
|
| 79 |
+
# keep the original attention computation bit-for-bit unchanged.
|
| 80 |
+
if not model.config.attention_bias:
|
| 81 |
+
for layer in model.model.layers:
|
| 82 |
+
attn = layer.self_attn
|
| 83 |
+
attn.q_proj = add_zero_bias(attn.q_proj)
|
| 84 |
+
attn.k_proj = add_zero_bias(attn.k_proj)
|
| 85 |
+
attn.v_proj = add_zero_bias(attn.v_proj)
|
| 86 |
+
attn.o_proj = add_zero_bias(attn.o_proj)
|
| 87 |
+
model.config.attention_bias = True
|
| 88 |
+
|
| 89 |
+
q_rows = model.config.num_attention_heads * model.config.head_dim
|
| 90 |
+
gate_logit = math.log(args.gate_init_full_prob / (1.0 - args.gate_init_full_prob))
|
| 91 |
+
with torch.no_grad():
|
| 92 |
+
for layer in model.model.layers:
|
| 93 |
+
q_proj = layer.self_attn.q_proj
|
| 94 |
+
q_proj.weight[q_rows:].zero_()
|
| 95 |
+
q_proj.bias[q_rows:].fill_(gate_logit)
|
| 96 |
+
|
| 97 |
+
# Keep a distinct LM head so the custom checkpoint has an explicit,
|
| 98 |
+
# self-contained state dict under safetensors.
|
| 99 |
+
model.config.tie_word_embeddings = False
|
| 100 |
+
if model.lm_head.weight.data_ptr() == model.model.embed_tokens.weight.data_ptr():
|
| 101 |
+
model.lm_head.weight = nn.Parameter(model.lm_head.weight.detach().clone())
|
| 102 |
+
model.config.aha_hotstart_source = str(Path(args.vanilla_path).resolve())
|
| 103 |
+
model.config.aha_gate_init_full_prob = args.gate_init_full_prob
|
| 104 |
+
|
| 105 |
+
tokenizer = AutoTokenizer.from_pretrained(args.vanilla_path, trust_remote_code=True)
|
| 106 |
+
model.save_pretrained(output, safe_serialization=True)
|
| 107 |
+
tokenizer.save_pretrained(output)
|
| 108 |
+
gate_rows = model.model.layers[0].self_attn.aha_router_outputs
|
| 109 |
+
gate_parameters = len(model.model.layers) * gate_rows * (
|
| 110 |
+
model.config.hidden_size + 1
|
| 111 |
+
)
|
| 112 |
+
(output / "aha_hotstart_manifest.json").write_text(
|
| 113 |
+
json.dumps(
|
| 114 |
+
{
|
| 115 |
+
"source_checkpoint": str(Path(args.vanilla_path).resolve()),
|
| 116 |
+
"router_granularity": args.router_granularity,
|
| 117 |
+
"native_gate_rows_per_layer": gate_rows,
|
| 118 |
+
"effective_gate_parameters": gate_parameters,
|
| 119 |
+
"gate_init_full_probability": args.gate_init_full_prob,
|
| 120 |
+
"gate_weight_init": "zeros",
|
| 121 |
+
"gate_bias_logit": gate_logit,
|
| 122 |
+
"local_attention": {
|
| 123 |
+
"kind": args.local_kind,
|
| 124 |
+
"sink_size": model.config.duo_sink_size,
|
| 125 |
+
"recent_size": model.config.duo_recent_size,
|
| 126 |
+
},
|
| 127 |
+
},
|
| 128 |
+
indent=2,
|
| 129 |
+
)
|
| 130 |
+
+ "\n"
|
| 131 |
+
)
|
| 132 |
+
print(
|
| 133 |
+
f"saved={output} init_full_prob={args.gate_init_full_prob:.4f} "
|
| 134 |
+
f"gate_logit={gate_logit:.6f} hard_sparsity=0.0 "
|
| 135 |
+
f"router_granularity={args.router_granularity} gate_params={gate_parameters:,}"
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
if __name__ == "__main__":
|
| 140 |
+
main()
|
recipe/scripts/reproduce.sh
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
| 5 |
+
REPO="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
| 6 |
+
IMAGE="${IMAGE:-aha-l2a-qwen3-repro:torch2.9.1-cu128}"
|
| 7 |
+
GPU_COUNT="$(nvidia-smi -L | wc -l)"
|
| 8 |
+
if [[ "$GPU_COUNT" -lt 1 ]]; then
|
| 9 |
+
echo "At least one NVIDIA GPU is required." >&2
|
| 10 |
+
exit 2
|
| 11 |
+
fi
|
| 12 |
+
GPU_AHA="${GPU_AHA:-0}"
|
| 13 |
+
if [[ -z "${GPU_L2A+x}" ]]; then
|
| 14 |
+
GPU_L2A=0
|
| 15 |
+
if [[ "$GPU_COUNT" -ge 2 ]]; then
|
| 16 |
+
GPU_L2A=1
|
| 17 |
+
fi
|
| 18 |
+
fi
|
| 19 |
+
|
| 20 |
+
for gpu in "$GPU_AHA" "$GPU_L2A"; do
|
| 21 |
+
if [[ ! "$gpu" =~ ^[0-9]+$ ]] || (( gpu >= GPU_COUNT )); then
|
| 22 |
+
echo "Invalid GPU index $gpu; nvidia-smi reports $GPU_COUNT visible GPU(s)." >&2
|
| 23 |
+
exit 2
|
| 24 |
+
fi
|
| 25 |
+
done
|
| 26 |
+
|
| 27 |
+
echo "Training mode: one GPU per experimental variant (no DDP/FSDP/DeepSpeed)."
|
| 28 |
+
echo "AHA variant GPU: $GPU_AHA"
|
| 29 |
+
echo "L2A-style variant GPU: $GPU_L2A"
|
| 30 |
+
if [[ "$GPU_AHA" == "$GPU_L2A" ]]; then
|
| 31 |
+
echo "The two variants will run sequentially on the same GPU."
|
| 32 |
+
else
|
| 33 |
+
echo "The two variants will run concurrently on two independent GPUs."
|
| 34 |
+
fi
|
| 35 |
+
if [[ "$GPU_COUNT" -gt 2 ]]; then
|
| 36 |
+
echo "Visible GPUs: $GPU_COUNT; this recipe intentionally does not use all GPUs."
|
| 37 |
+
fi
|
| 38 |
+
|
| 39 |
+
docker build -t "$IMAGE" -f "$REPO/recipe/Dockerfile" "$REPO/recipe"
|
| 40 |
+
docker run --rm --gpus all --ipc=host \
|
| 41 |
+
--ulimit memlock=-1 --ulimit stack=67108864 \
|
| 42 |
+
-e GPU_AHA="$GPU_AHA" -e GPU_L2A="$GPU_L2A" \
|
| 43 |
+
-v "$REPO:/workspace" \
|
| 44 |
+
-w /workspace/recipe \
|
| 45 |
+
"$IMAGE" bash scripts/train_both.sh
|
recipe/scripts/summarize_qwen1p7b_router_granularity_20260714.py
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Audit and summarize the matched shared-gate vs KV-head-gate sweep."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import argparse
|
| 7 |
+
import csv
|
| 8 |
+
import glob
|
| 9 |
+
import hashlib
|
| 10 |
+
import json
|
| 11 |
+
import subprocess
|
| 12 |
+
from collections import defaultdict
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
import matplotlib.pyplot as plt
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
SUITES = ("RULER-local-full13", "BabiLong", "HELMET-ICL", "MRCR")
|
| 19 |
+
THRESHOLDS = (0.45, 0.50, 0.525, 0.55, 0.575, 0.60, 0.625, 0.65)
|
| 20 |
+
ARMS = ("token", "token_kv_head")
|
| 21 |
+
SLUGS = {
|
| 22 |
+
"RULER-local-full13": "ruler",
|
| 23 |
+
"BabiLong": "babilong",
|
| 24 |
+
"HELMET-ICL": "helmet",
|
| 25 |
+
"MRCR": "mrcr",
|
| 26 |
+
}
|
| 27 |
+
SCORE_DEFINITIONS = {
|
| 28 |
+
"RULER-local-full13": "unweighted macro of lm-eval 8192 string-match scores over the local 13-config set",
|
| 29 |
+
"BabiLong": "unweighted macro exact-match accuracy over qa1-qa5",
|
| 30 |
+
"HELMET-ICL": "unweighted macro label exact-match over five ICL configurations",
|
| 31 |
+
"MRCR": "unweighted macro of prefix-check plus SequenceMatcher scores over 2/4/8-needle configurations",
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def threshold_slug(value: float) -> str:
|
| 36 |
+
return {
|
| 37 |
+
0.45: "045",
|
| 38 |
+
0.50: "050",
|
| 39 |
+
0.525: "0525",
|
| 40 |
+
0.55: "055",
|
| 41 |
+
0.575: "0575",
|
| 42 |
+
0.60: "060",
|
| 43 |
+
0.625: "0625",
|
| 44 |
+
0.65: "065",
|
| 45 |
+
}[value]
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def one(pattern: str) -> Path:
|
| 49 |
+
paths = [Path(path) for path in glob.glob(pattern, recursive=True)]
|
| 50 |
+
if len(paths) != 1:
|
| 51 |
+
raise RuntimeError(f"expected one path for {pattern}, found {paths}")
|
| 52 |
+
return paths[0]
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def read_jsonl(path: Path) -> list[dict]:
|
| 56 |
+
return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def collect_scores(root: Path) -> dict[tuple[str, str], dict]:
|
| 60 |
+
rows: dict[tuple[str, str], dict] = {}
|
| 61 |
+
for split in ("a", "b"):
|
| 62 |
+
result = one(str(root / f"ruler8k_splits/{split}/lm_eval/**/results_*.json"))
|
| 63 |
+
payload = json.loads(result.read_text(encoding="utf-8"))
|
| 64 |
+
for config, metrics in payload["results"].items():
|
| 65 |
+
if config in payload["n-samples"]:
|
| 66 |
+
rows[("RULER-local-full13", config)] = {
|
| 67 |
+
"score": float(metrics["8192,none"]),
|
| 68 |
+
"n": int(payload["n-samples"][config]["effective"]),
|
| 69 |
+
}
|
| 70 |
+
result = one(str(root / "babilong8k_qa1_qa5_n50/**/results_*.json"))
|
| 71 |
+
payload = json.loads(result.read_text(encoding="utf-8"))
|
| 72 |
+
for config, metrics in payload["results"].items():
|
| 73 |
+
if config in payload["n-samples"]:
|
| 74 |
+
rows[("BabiLong", config)] = {
|
| 75 |
+
"score": float(metrics["acc,none"]),
|
| 76 |
+
"n": int(payload["n-samples"][config]["effective"]),
|
| 77 |
+
}
|
| 78 |
+
for suite, filename in (
|
| 79 |
+
("HELMET-ICL", "helmet_icl8k_n50.jsonl"),
|
| 80 |
+
("MRCR", "mrcr_8k_2_4_8needle_n10.jsonl"),
|
| 81 |
+
):
|
| 82 |
+
grouped: dict[str, list[dict]] = defaultdict(list)
|
| 83 |
+
for row in read_jsonl(root / filename):
|
| 84 |
+
grouped[row["config"]].append(row)
|
| 85 |
+
for config, values in grouped.items():
|
| 86 |
+
rows[(suite, config)] = {
|
| 87 |
+
"score": sum(float(row["score"]) for row in values) / len(values),
|
| 88 |
+
"n": len(values),
|
| 89 |
+
}
|
| 90 |
+
return rows
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def sample_signatures(root: Path, score_rows: dict[tuple[str, str], dict]) -> dict[str, str]:
|
| 94 |
+
signatures: dict[str, str] = {}
|
| 95 |
+
for suite, rel_dirs in (
|
| 96 |
+
("RULER-local-full13", ("ruler8k_splits/a/lm_eval", "ruler8k_splits/b/lm_eval")),
|
| 97 |
+
("BabiLong", ("babilong8k_qa1_qa5_n50",)),
|
| 98 |
+
):
|
| 99 |
+
configs = [config for row_suite, config in score_rows if row_suite == suite]
|
| 100 |
+
for config in configs:
|
| 101 |
+
matches = []
|
| 102 |
+
for rel_dir in rel_dirs:
|
| 103 |
+
matches.extend(root.glob(f"{rel_dir}/**/samples_{config}_*.jsonl"))
|
| 104 |
+
if len(matches) != 1:
|
| 105 |
+
raise RuntimeError(f"expected one sample log for {suite}/{config}: {matches}")
|
| 106 |
+
values = [
|
| 107 |
+
(
|
| 108 |
+
row.get("doc_id"),
|
| 109 |
+
row.get("doc_hash"),
|
| 110 |
+
row.get("prompt_hash"),
|
| 111 |
+
row.get("target_hash"),
|
| 112 |
+
)
|
| 113 |
+
for row in read_jsonl(matches[0])
|
| 114 |
+
]
|
| 115 |
+
signatures[f"{suite}/{config}"] = hashlib.sha256(
|
| 116 |
+
json.dumps(values, sort_keys=True).encode()
|
| 117 |
+
).hexdigest()
|
| 118 |
+
for suite, filename in (
|
| 119 |
+
("HELMET-ICL", "helmet_icl8k_n50.jsonl"),
|
| 120 |
+
("MRCR", "mrcr_8k_2_4_8needle_n10.jsonl"),
|
| 121 |
+
):
|
| 122 |
+
grouped: dict[str, list[tuple]] = defaultdict(list)
|
| 123 |
+
for row in read_jsonl(root / filename):
|
| 124 |
+
grouped[row["config"]].append(
|
| 125 |
+
(row["row_id"], row.get("prompt_sha256"), row.get("target"))
|
| 126 |
+
)
|
| 127 |
+
for config, values in grouped.items():
|
| 128 |
+
signatures[f"{suite}/{config}"] = hashlib.sha256(
|
| 129 |
+
json.dumps(values, sort_keys=True).encode()
|
| 130 |
+
).hexdigest()
|
| 131 |
+
return signatures
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def read_sparsity(root: Path, suite: str) -> dict:
|
| 135 |
+
if suite == "RULER-local-full13":
|
| 136 |
+
parts = [
|
| 137 |
+
json.loads((root / f"sparsity_ruler_{split}.json").read_text(encoding="utf-8"))
|
| 138 |
+
for split in ("a", "b")
|
| 139 |
+
]
|
| 140 |
+
sparse = sum(int(part["sparse_decisions"]) for part in parts)
|
| 141 |
+
total = sum(int(part["total_decisions"]) for part in parts)
|
| 142 |
+
granularities = {part.get("router_granularity") for part in parts}
|
| 143 |
+
native = sum(int(part.get("native_router_decisions", 0)) for part in parts)
|
| 144 |
+
phases: dict[str, dict[str, int]] = defaultdict(lambda: {"sparse": 0, "total": 0})
|
| 145 |
+
for part in parts:
|
| 146 |
+
for phase, values in part.get("by_phase", {}).items():
|
| 147 |
+
phases[phase]["sparse"] += int(values["sparse_decisions"])
|
| 148 |
+
phases[phase]["total"] += int(values["total_decisions"])
|
| 149 |
+
else:
|
| 150 |
+
part = json.loads((root / f"sparsity_{SLUGS[suite]}.json").read_text(encoding="utf-8"))
|
| 151 |
+
sparse = int(part["sparse_decisions"])
|
| 152 |
+
total = int(part["total_decisions"])
|
| 153 |
+
granularities = {part.get("router_granularity")}
|
| 154 |
+
native = int(part.get("native_router_decisions", 0))
|
| 155 |
+
phases = {
|
| 156 |
+
phase: {
|
| 157 |
+
"sparse": int(values["sparse_decisions"]),
|
| 158 |
+
"total": int(values["total_decisions"]),
|
| 159 |
+
}
|
| 160 |
+
for phase, values in part.get("by_phase", {}).items()
|
| 161 |
+
}
|
| 162 |
+
return {
|
| 163 |
+
"sparse_decisions": sparse,
|
| 164 |
+
"total_decisions": total,
|
| 165 |
+
"sparsity": sparse / total,
|
| 166 |
+
"full_attention_usage": 1.0 - sparse / total,
|
| 167 |
+
"router_granularities": sorted(value for value in granularities if value),
|
| 168 |
+
"native_router_decisions": native or None,
|
| 169 |
+
"by_phase": phases,
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def sha256(path: Path) -> str:
|
| 174 |
+
digest = hashlib.sha256()
|
| 175 |
+
with path.open("rb") as handle:
|
| 176 |
+
for block in iter(lambda: handle.read(1024 * 1024), b""):
|
| 177 |
+
digest.update(block)
|
| 178 |
+
return digest.hexdigest()
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def command_output(command: list[str], cwd: Path) -> str:
|
| 182 |
+
try:
|
| 183 |
+
return subprocess.run(
|
| 184 |
+
command, cwd=cwd, check=True, text=True, capture_output=True
|
| 185 |
+
).stdout.strip()
|
| 186 |
+
except (OSError, subprocess.CalledProcessError):
|
| 187 |
+
return "unavailable in downloaded HF snapshot"
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def main() -> None:
|
| 191 |
+
parser = argparse.ArgumentParser()
|
| 192 |
+
parser.add_argument(
|
| 193 |
+
"--repo", type=Path, default=Path("/data/sjm/AHA/AHA-Qwen3")
|
| 194 |
+
)
|
| 195 |
+
parser.add_argument(
|
| 196 |
+
"--eval-root",
|
| 197 |
+
type=Path,
|
| 198 |
+
default=Path("/data/sjm/AHA/AHA-Qwen3/experiments/qwen3_1p7b_router_granularity_20260714/eval"),
|
| 199 |
+
)
|
| 200 |
+
parser.add_argument(
|
| 201 |
+
"--baseline-root",
|
| 202 |
+
type=Path,
|
| 203 |
+
default=Path("/data/sjm/AHA/AHA-Qwen3/experiments/qwen3_1p7b_fourbench_8k_broad_20260713/vanilla"),
|
| 204 |
+
)
|
| 205 |
+
parser.add_argument("--output-dir", type=Path)
|
| 206 |
+
parser.add_argument("--input-dir", type=Path)
|
| 207 |
+
args = parser.parse_args()
|
| 208 |
+
output = args.output_dir or args.eval_root.parent / "summary"
|
| 209 |
+
output.mkdir(parents=True, exist_ok=True)
|
| 210 |
+
|
| 211 |
+
baseline = collect_scores(args.baseline_root)
|
| 212 |
+
baseline_signatures = sample_signatures(args.baseline_root, baseline)
|
| 213 |
+
curve_rows = []
|
| 214 |
+
per_config = []
|
| 215 |
+
points = []
|
| 216 |
+
alignment = {}
|
| 217 |
+
done_files = ("ruler_a.DONE", "ruler_b.DONE", "babilong.DONE", "helmet.DONE", "mrcr.DONE")
|
| 218 |
+
|
| 219 |
+
for arm in ARMS:
|
| 220 |
+
for threshold in THRESHOLDS:
|
| 221 |
+
method = f"{arm}_t{threshold_slug(threshold)}"
|
| 222 |
+
root = args.eval_root / method
|
| 223 |
+
missing = [name for name in done_files if not (root / name).exists()]
|
| 224 |
+
if missing:
|
| 225 |
+
raise RuntimeError(f"incomplete {method}: missing {missing}")
|
| 226 |
+
scores = collect_scores(root)
|
| 227 |
+
if set(scores) != set(baseline):
|
| 228 |
+
raise RuntimeError(f"configuration mismatch for {method}")
|
| 229 |
+
if any(scores[key]["n"] != baseline[key]["n"] for key in baseline):
|
| 230 |
+
raise RuntimeError(f"sample-count mismatch for {method}")
|
| 231 |
+
signatures = sample_signatures(root, scores)
|
| 232 |
+
mismatches = sorted(
|
| 233 |
+
key for key in baseline_signatures if signatures.get(key) != baseline_signatures[key]
|
| 234 |
+
)
|
| 235 |
+
alignment[method] = {"aligned": not mismatches, "mismatches": mismatches}
|
| 236 |
+
if mismatches:
|
| 237 |
+
raise RuntimeError(f"prompt/target hash mismatch for {method}: {mismatches}")
|
| 238 |
+
|
| 239 |
+
suite_rows = []
|
| 240 |
+
sparse_sum = total_sum = 0
|
| 241 |
+
for suite in SUITES:
|
| 242 |
+
keys = sorted(key for key in baseline if key[0] == suite)
|
| 243 |
+
baseline_score = sum(baseline[key]["score"] for key in keys) / len(keys)
|
| 244 |
+
score = sum(scores[key]["score"] for key in keys) / len(keys)
|
| 245 |
+
sparsity = read_sparsity(root, suite)
|
| 246 |
+
expected_granularity = arm
|
| 247 |
+
if sparsity["router_granularities"] != [expected_granularity]:
|
| 248 |
+
raise RuntimeError(
|
| 249 |
+
f"{method}/{suite} recorded {sparsity['router_granularities']}, "
|
| 250 |
+
f"expected {[expected_granularity]}"
|
| 251 |
+
)
|
| 252 |
+
sparse_sum += sparsity["sparse_decisions"]
|
| 253 |
+
total_sum += sparsity["total_decisions"]
|
| 254 |
+
row = {
|
| 255 |
+
"arm": arm,
|
| 256 |
+
"router_granularity": arm,
|
| 257 |
+
"threshold": threshold,
|
| 258 |
+
"benchmark": suite,
|
| 259 |
+
"config_count": len(keys),
|
| 260 |
+
"samples_per_config": ",".join(
|
| 261 |
+
str(value) for value in sorted({baseline[key]["n"] for key in keys})
|
| 262 |
+
),
|
| 263 |
+
"total_samples": sum(baseline[key]["n"] for key in keys),
|
| 264 |
+
"tuned_vanilla_score": baseline_score,
|
| 265 |
+
"quality_floor_95pct": 0.95 * baseline_score,
|
| 266 |
+
"score": score,
|
| 267 |
+
"retention": score / baseline_score if baseline_score else None,
|
| 268 |
+
"quality_pass": score >= 0.95 * baseline_score,
|
| 269 |
+
**{key: sparsity[key] for key in (
|
| 270 |
+
"sparse_decisions", "total_decisions", "sparsity",
|
| 271 |
+
"full_attention_usage", "native_router_decisions", "by_phase"
|
| 272 |
+
)},
|
| 273 |
+
}
|
| 274 |
+
curve_rows.append(row)
|
| 275 |
+
suite_rows.append(row)
|
| 276 |
+
for key in keys:
|
| 277 |
+
per_config.append({
|
| 278 |
+
"arm": arm,
|
| 279 |
+
"threshold": threshold,
|
| 280 |
+
"benchmark": suite,
|
| 281 |
+
"config": key[1],
|
| 282 |
+
"n": baseline[key]["n"],
|
| 283 |
+
"tuned_vanilla_score": baseline[key]["score"],
|
| 284 |
+
"score": scores[key]["score"],
|
| 285 |
+
})
|
| 286 |
+
points.append({
|
| 287 |
+
"arm": arm,
|
| 288 |
+
"threshold": threshold,
|
| 289 |
+
"all_suite_quality_pass": all(row["quality_pass"] for row in suite_rows),
|
| 290 |
+
"decision_weighted_sparsity": sparse_sum / total_sum,
|
| 291 |
+
"full_attention_usage": 1.0 - sparse_sum / total_sum,
|
| 292 |
+
"sparse_decisions": sparse_sum,
|
| 293 |
+
"total_decisions": total_sum,
|
| 294 |
+
"suites": suite_rows,
|
| 295 |
+
})
|
| 296 |
+
|
| 297 |
+
headline = {}
|
| 298 |
+
for arm in ARMS:
|
| 299 |
+
valid = [
|
| 300 |
+
point for point in points
|
| 301 |
+
if point["arm"] == arm and point["all_suite_quality_pass"]
|
| 302 |
+
]
|
| 303 |
+
if valid:
|
| 304 |
+
best = max(valid, key=lambda point: point["decision_weighted_sparsity"])
|
| 305 |
+
headline[arm] = {"found": True, **best}
|
| 306 |
+
else:
|
| 307 |
+
headline[arm] = {
|
| 308 |
+
"found": False,
|
| 309 |
+
"statement": "No measured threshold preserved at least 95% of tuned vanilla on every suite.",
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
input_dir = args.input_dir or args.baseline_root.parent / "inputs"
|
| 313 |
+
frozen_inputs = [
|
| 314 |
+
input_dir / "helmet_icl_8k_n50_per_config.jsonl",
|
| 315 |
+
input_dir / "mrcr_8k_2_4_8needle_n10_per_config.jsonl",
|
| 316 |
+
]
|
| 317 |
+
payload = {
|
| 318 |
+
"protocol": {
|
| 319 |
+
"model": "Qwen3-1.7B tuned vanilla",
|
| 320 |
+
"context_length": 8192,
|
| 321 |
+
"arms": {
|
| 322 |
+
"token": "L2A-style shared-gate: one native gate per token/layer",
|
| 323 |
+
"token_kv_head": "AHA: one native gate per token/KV-head/layer",
|
| 324 |
+
},
|
| 325 |
+
"local_attention": {"sink_tokens": 64, "recent_tokens": 256},
|
| 326 |
+
"inference": "strict AHA routing in both prefill and decode; no force-full heads or full-decode fallback",
|
| 327 |
+
"thresholds": list(THRESHOLDS),
|
| 328 |
+
"quality_rule": "every suite macro score >= 95% of aligned tuned-vanilla macro score",
|
| 329 |
+
"headline_rule": "highest decision-weighted measured sparsity among thresholds passing every suite",
|
| 330 |
+
"sparsity_definition": "hard local routes / token x KV-head x layer effective decisions",
|
| 331 |
+
"suite_scope": "RULER local full13 means this repository's fixed 13-config set, not complete upstream RULER",
|
| 332 |
+
},
|
| 333 |
+
"score_definitions": SCORE_DEFINITIONS,
|
| 334 |
+
"headline": headline,
|
| 335 |
+
"points": points,
|
| 336 |
+
"curves": curve_rows,
|
| 337 |
+
"per_config": per_config,
|
| 338 |
+
"alignment": {"all_aligned": all(value["aligned"] for value in alignment.values()), "methods": alignment},
|
| 339 |
+
"appendix_note": (
|
| 340 |
+
"Upstream L2A results are not merged into this table because architecture, "
|
| 341 |
+
"training data, objective, and evaluation protocols differ."
|
| 342 |
+
),
|
| 343 |
+
"reproducibility": {
|
| 344 |
+
"git_commit": command_output(["git", "rev-parse", "HEAD"], args.repo),
|
| 345 |
+
"git_status_short": command_output(["git", "status", "--short"], args.repo),
|
| 346 |
+
"container": "dmtd-repro",
|
| 347 |
+
"container_image": command_output(
|
| 348 |
+
["docker", "inspect", "-f", "{{.Config.Image}}", "dmtd-repro"], args.repo
|
| 349 |
+
),
|
| 350 |
+
"baseline_root": str(args.baseline_root),
|
| 351 |
+
"eval_root": str(args.eval_root),
|
| 352 |
+
"frozen_input_sha256": {
|
| 353 |
+
str(path): sha256(path) for path in frozen_inputs if path.exists()
|
| 354 |
+
},
|
| 355 |
+
},
|
| 356 |
+
}
|
| 357 |
+
(output / "router_granularity_results.json").write_text(
|
| 358 |
+
json.dumps(payload, indent=2) + "\n", encoding="utf-8"
|
| 359 |
+
)
|
| 360 |
+
|
| 361 |
+
flat_curve_rows = [
|
| 362 |
+
{key: value for key, value in row.items() if key != "by_phase"}
|
| 363 |
+
for row in curve_rows
|
| 364 |
+
]
|
| 365 |
+
with (output / "quality_sparsity_curves.csv").open("w", newline="", encoding="utf-8") as handle:
|
| 366 |
+
writer = csv.DictWriter(handle, fieldnames=list(flat_curve_rows[0]))
|
| 367 |
+
writer.writeheader()
|
| 368 |
+
writer.writerows(flat_curve_rows)
|
| 369 |
+
with (output / "per_config_scores.csv").open("w", newline="", encoding="utf-8") as handle:
|
| 370 |
+
writer = csv.DictWriter(handle, fieldnames=list(per_config[0]))
|
| 371 |
+
writer.writeheader()
|
| 372 |
+
writer.writerows(per_config)
|
| 373 |
+
|
| 374 |
+
figure, axes = plt.subplots(2, 2, figsize=(12, 9), constrained_layout=True)
|
| 375 |
+
colors = {"token": "#d62728", "token_kv_head": "#1f77b4"}
|
| 376 |
+
labels = {"token": "L2A-style shared-gate", "token_kv_head": "AHA KV-head gate"}
|
| 377 |
+
for axis, suite in zip(axes.flat, SUITES):
|
| 378 |
+
for arm in ARMS:
|
| 379 |
+
values = sorted(
|
| 380 |
+
(row for row in curve_rows if row["arm"] == arm and row["benchmark"] == suite),
|
| 381 |
+
key=lambda row: row["sparsity"],
|
| 382 |
+
)
|
| 383 |
+
axis.plot(
|
| 384 |
+
[100 * row["sparsity"] for row in values],
|
| 385 |
+
[100 * row["retention"] for row in values],
|
| 386 |
+
marker="o", color=colors[arm], label=labels[arm],
|
| 387 |
+
)
|
| 388 |
+
for row in values:
|
| 389 |
+
axis.annotate(f"{row['threshold']:.3g}", (100 * row["sparsity"], 100 * row["retention"]), fontsize=7)
|
| 390 |
+
axis.axhline(95, color="black", linestyle="--", linewidth=1)
|
| 391 |
+
axis.set_title(suite)
|
| 392 |
+
axis.set_xlabel("Measured effective sparsity (%)")
|
| 393 |
+
axis.set_ylabel("Retention vs tuned vanilla (%)")
|
| 394 |
+
axis.grid(alpha=0.25)
|
| 395 |
+
axes.flat[0].legend()
|
| 396 |
+
figure.suptitle("Qwen3-1.7B matched router-granularity quality–sparsity curves")
|
| 397 |
+
figure.savefig(output / "quality_sparsity_curves.png", dpi=180)
|
| 398 |
+
plt.close(figure)
|
| 399 |
+
print(json.dumps(headline, indent=2))
|
| 400 |
+
|
| 401 |
+
|
| 402 |
+
if __name__ == "__main__":
|
| 403 |
+
main()
|
recipe/scripts/train_aha.sh
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
| 4 |
+
exec "$SCRIPT_DIR/train_one.sh" aha "${1:-0}"
|
recipe/scripts/train_both.sh
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
| 5 |
+
RECIPE="$(cd "$SCRIPT_DIR/.." && pwd)"
|
| 6 |
+
GPU_AHA="${GPU_AHA:-0}"
|
| 7 |
+
GPU_L2A="${GPU_L2A:-1}"
|
| 8 |
+
cd "$RECIPE"
|
| 9 |
+
|
| 10 |
+
echo "AHA = per-token, per-KV-head gate; assigned visible GPU $GPU_AHA."
|
| 11 |
+
echo "L2A-style = per-token, head-shared gate; assigned visible GPU $GPU_L2A."
|
| 12 |
+
echo "Each variant is a separate world-size-1 run with global batch size 1."
|
| 13 |
+
|
| 14 |
+
python "$SCRIPT_DIR/validate_release.py"
|
| 15 |
+
python -m unittest -v tests.test_router_granularity
|
| 16 |
+
|
| 17 |
+
if [[ "$GPU_AHA" == "$GPU_L2A" ]]; then
|
| 18 |
+
"$SCRIPT_DIR/train_one.sh" aha "$GPU_AHA"
|
| 19 |
+
"$SCRIPT_DIR/train_one.sh" l2a_style "$GPU_L2A"
|
| 20 |
+
else
|
| 21 |
+
"$SCRIPT_DIR/train_one.sh" aha "$GPU_AHA" & aha_pid=$!
|
| 22 |
+
"$SCRIPT_DIR/train_one.sh" l2a_style "$GPU_L2A" & l2a_pid=$!
|
| 23 |
+
status=0
|
| 24 |
+
wait "$aha_pid" || status=1
|
| 25 |
+
wait "$l2a_pid" || status=1
|
| 26 |
+
[[ "$status" == 0 ]] || exit "$status"
|
| 27 |
+
fi
|
| 28 |
+
|
| 29 |
+
echo "AHA selected checkpoint: ${OUTPUT_ROOT:-$RECIPE/../outputs}/aha/stage2/checkpoint-25"
|
| 30 |
+
echo "L2A-style selected checkpoint: ${OUTPUT_ROOT:-$RECIPE/../outputs}/l2a_style/stage2/checkpoint-25"
|
recipe/scripts/train_l2a_style.sh
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
| 4 |
+
exec "$SCRIPT_DIR/train_one.sh" l2a_style "${1:-0}"
|
recipe/scripts/train_one.sh
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
if [[ $# -lt 1 || $# -gt 2 ]]; then
|
| 5 |
+
echo "Usage: $0 aha|l2a_style [GPU_ID]" >&2
|
| 6 |
+
exit 2
|
| 7 |
+
fi
|
| 8 |
+
|
| 9 |
+
ARM="$1"
|
| 10 |
+
GPU="${2:-0}"
|
| 11 |
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
| 12 |
+
RECIPE="$(cd "$SCRIPT_DIR/.." && pwd)"
|
| 13 |
+
REPO="$(cd "$RECIPE/.." && pwd)"
|
| 14 |
+
VANILLA_DIR="${VANILLA_DIR:-$REPO}"
|
| 15 |
+
DATA_DIR="${DATA_DIR:-$RECIPE/data/am_distilled_long_mix}"
|
| 16 |
+
OUTPUT_ROOT="${OUTPUT_ROOT:-$REPO/outputs}"
|
| 17 |
+
|
| 18 |
+
case "$ARM" in
|
| 19 |
+
aha) GRANULARITY=token_kv_head ;;
|
| 20 |
+
l2a_style) GRANULARITY=token ;;
|
| 21 |
+
*) echo "Unknown arm: $ARM (expected aha or l2a_style)" >&2; exit 2 ;;
|
| 22 |
+
esac
|
| 23 |
+
|
| 24 |
+
ARM_OUT="$OUTPUT_ROOT/$ARM"
|
| 25 |
+
HOTSTART="$ARM_OUT/hotstart"
|
| 26 |
+
STAGE1="$ARM_OUT/stage1"
|
| 27 |
+
STAGE2="$ARM_OUT/stage2"
|
| 28 |
+
LOG_DIR="$ARM_OUT/logs"
|
| 29 |
+
mkdir -p "$LOG_DIR"
|
| 30 |
+
|
| 31 |
+
export CUDA_VISIBLE_DEVICES="$GPU"
|
| 32 |
+
export PYTHONUNBUFFERED=1
|
| 33 |
+
export TOKENIZERS_PARALLELISM=false
|
| 34 |
+
export PYTORCH_CUDA_ALLOC_CONF="${PYTORCH_CUDA_ALLOC_CONF:-expandable_segments:True}"
|
| 35 |
+
|
| 36 |
+
if [[ ! -f "$VANILLA_DIR/model.safetensors" ]]; then
|
| 37 |
+
echo "Missing tuned-vanilla checkpoint: $VANILLA_DIR/model.safetensors" >&2
|
| 38 |
+
exit 2
|
| 39 |
+
fi
|
| 40 |
+
|
| 41 |
+
if [[ ! -f "$HOTSTART/config.json" ]]; then
|
| 42 |
+
python "$SCRIPT_DIR/export_tuned_vanilla_aha_hotstart.py" \
|
| 43 |
+
--vanilla-path "$VANILLA_DIR" \
|
| 44 |
+
--output-path "$HOTSTART" \
|
| 45 |
+
--window-size 128 \
|
| 46 |
+
--local-kind sink_recent \
|
| 47 |
+
--gate-init-full-prob 0.90 \
|
| 48 |
+
--router-granularity "$GRANULARITY" \
|
| 49 |
+
2>&1 | tee "$LOG_DIR/hotstart.log"
|
| 50 |
+
fi
|
| 51 |
+
|
| 52 |
+
if [[ ! -f "$STAGE1/checkpoint-300/config.json" ]]; then
|
| 53 |
+
AHA_TRAIN_GATE_HARD_THRESHOLD=0.50 \
|
| 54 |
+
python "$RECIPE/dynamic_duo_train.py" \
|
| 55 |
+
--aha_checkpoint "$HOTSTART" \
|
| 56 |
+
--model_path "$VANILLA_DIR" \
|
| 57 |
+
--output_dir "$STAGE1" \
|
| 58 |
+
--data_source am_distilled \
|
| 59 |
+
--am_dataset_path "$DATA_DIR" \
|
| 60 |
+
--am_dataset_split train \
|
| 61 |
+
--am_label_mode full \
|
| 62 |
+
--max_length 8192 \
|
| 63 |
+
--num_steps 300 \
|
| 64 |
+
--warmup_ratio 0.10 \
|
| 65 |
+
--lr 3e-5 \
|
| 66 |
+
--reg_weight 0.1 \
|
| 67 |
+
--ce_weight 0.0 \
|
| 68 |
+
--batch_size 1 \
|
| 69 |
+
--grad_accum 1 \
|
| 70 |
+
--save_steps 100 \
|
| 71 |
+
--log_steps 10 \
|
| 72 |
+
--seed 42 \
|
| 73 |
+
--dtype bfloat16 \
|
| 74 |
+
--attn_impl sdpa \
|
| 75 |
+
--aha_local_kind sink_recent \
|
| 76 |
+
--router_granularity "$GRANULARITY" \
|
| 77 |
+
2>&1 | tee "$LOG_DIR/stage1.log"
|
| 78 |
+
fi
|
| 79 |
+
|
| 80 |
+
if [[ ! -f "$STAGE2/checkpoint-75/config.json" ]]; then
|
| 81 |
+
MODEL_PATH="$VANILLA_DIR" \
|
| 82 |
+
AHA_CHECKPOINT_PATH="$STAGE1/checkpoint-300" \
|
| 83 |
+
DATASET_PATH="$DATA_DIR" \
|
| 84 |
+
DATASET_SPLIT=train \
|
| 85 |
+
OUTPUT_DIR="$STAGE2" \
|
| 86 |
+
AHA_MODE=dynamic \
|
| 87 |
+
AHA_ROUTER_GRANULARITY="$GRANULARITY" \
|
| 88 |
+
AHA_LOCAL_KIND=sink_recent \
|
| 89 |
+
AHA_CE_WEIGHT=1.0 \
|
| 90 |
+
AHA_DISTILL_WEIGHT=0.5 \
|
| 91 |
+
AHA_REG_WEIGHT=0.01 \
|
| 92 |
+
AHA_TRAIN_GATE_HARD_THRESHOLD=0.58 \
|
| 93 |
+
GROUPED_LR=1 \
|
| 94 |
+
GATE_ONLY=0 \
|
| 95 |
+
LEARNING_RATE=3e-6 \
|
| 96 |
+
GATE_LEARNING_RATE=3e-6 \
|
| 97 |
+
BACKBONE_LEARNING_RATE=3e-7 \
|
| 98 |
+
LR_SCHEDULER_TYPE=constant_with_warmup \
|
| 99 |
+
WARMUP_RATIO=0.10 \
|
| 100 |
+
WEIGHT_DECAY=0.0 \
|
| 101 |
+
MAX_SEQ_LENGTH=8192 \
|
| 102 |
+
PER_DEVICE_TRAIN_BATCH_SIZE=1 \
|
| 103 |
+
GRADIENT_ACCUMULATION_STEPS=1 \
|
| 104 |
+
MAX_STEPS=75 \
|
| 105 |
+
LOGGING_STEPS=5 \
|
| 106 |
+
SAVE_STEPS=25 \
|
| 107 |
+
SAVE_TOTAL_LIMIT=3 \
|
| 108 |
+
FREEZE_EMBEDDINGS_LM_HEAD=1 \
|
| 109 |
+
REPORT_TO=none \
|
| 110 |
+
SEED=47 \
|
| 111 |
+
python "$RECIPE/sft.py" \
|
| 112 |
+
2>&1 | tee "$LOG_DIR/stage2.log"
|
| 113 |
+
fi
|
| 114 |
+
|
| 115 |
+
python - "$STAGE2/checkpoint-25" "$GRANULARITY" <<'PY'
|
| 116 |
+
import json
|
| 117 |
+
import sys
|
| 118 |
+
from pathlib import Path
|
| 119 |
+
|
| 120 |
+
checkpoint = Path(sys.argv[1])
|
| 121 |
+
expected = sys.argv[2]
|
| 122 |
+
config = json.loads((checkpoint / "config.json").read_text())
|
| 123 |
+
actual = config.get("aha_router_granularity", "token_kv_head")
|
| 124 |
+
if actual != expected:
|
| 125 |
+
raise RuntimeError(f"checkpoint granularity {actual!r} != {expected!r}")
|
| 126 |
+
print(f"selected_checkpoint={checkpoint} router_granularity={actual}")
|
| 127 |
+
PY
|
recipe/scripts/validate_release.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Fail fast if a downloaded release is incomplete or has drifted."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import hashlib
|
| 7 |
+
import json
|
| 8 |
+
import os
|
| 9 |
+
from collections import Counter
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
from datasets import load_from_disk
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
RECIPE = Path(__file__).resolve().parents[1]
|
| 16 |
+
REPO = RECIPE.parent
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def sha256(path: Path) -> str:
|
| 20 |
+
digest = hashlib.sha256()
|
| 21 |
+
with path.open("rb") as handle:
|
| 22 |
+
for block in iter(lambda: handle.read(8 * 1024 * 1024), b""):
|
| 23 |
+
digest.update(block)
|
| 24 |
+
return digest.hexdigest()
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def read_jsonl(path: Path) -> list[dict]:
|
| 28 |
+
return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def main() -> None:
|
| 32 |
+
manifest = json.loads((RECIPE / "manifest.json").read_text(encoding="utf-8"))
|
| 33 |
+
checked = []
|
| 34 |
+
for relative, expected in manifest["files"].items():
|
| 35 |
+
path = (RECIPE / relative).resolve()
|
| 36 |
+
if not path.exists():
|
| 37 |
+
raise FileNotFoundError(path)
|
| 38 |
+
if path.name == "model.safetensors" and os.environ.get("SKIP_LARGE_HASH") == "1":
|
| 39 |
+
continue
|
| 40 |
+
actual = sha256(path)
|
| 41 |
+
if actual != expected:
|
| 42 |
+
raise RuntimeError(f"SHA256 mismatch for {path}: {actual} != {expected}")
|
| 43 |
+
checked.append(str(path.relative_to(REPO)))
|
| 44 |
+
|
| 45 |
+
config = json.loads((REPO / "config.json").read_text(encoding="utf-8"))
|
| 46 |
+
if config.get("model_type") != "qwen3":
|
| 47 |
+
raise RuntimeError(f"unexpected tuned-vanilla model_type: {config.get('model_type')}")
|
| 48 |
+
dataset = load_from_disk(str(RECIPE / "data/am_distilled_long_mix"))
|
| 49 |
+
if len(dataset["train"]) != 1024:
|
| 50 |
+
raise RuntimeError(f"expected 1024 training rows, found {len(dataset['train'])}")
|
| 51 |
+
|
| 52 |
+
helmet = read_jsonl(RECIPE / "data/eval_inputs/helmet_icl_8k_n50_per_config.jsonl")
|
| 53 |
+
mrcr = read_jsonl(RECIPE / "data/eval_inputs/mrcr_8k_2_4_8needle_n10_per_config.jsonl")
|
| 54 |
+
helmet_counts = Counter(row["config"] for row in helmet)
|
| 55 |
+
mrcr_counts = Counter(row["config"] for row in mrcr)
|
| 56 |
+
if sorted(helmet_counts.values()) != [50] * 5:
|
| 57 |
+
raise RuntimeError(f"unexpected HELMET counts: {helmet_counts}")
|
| 58 |
+
if sorted(mrcr_counts.values()) != [10] * 3:
|
| 59 |
+
raise RuntimeError(f"unexpected MRCR counts: {mrcr_counts}")
|
| 60 |
+
|
| 61 |
+
print(json.dumps({
|
| 62 |
+
"status": "ok",
|
| 63 |
+
"checked_sha256": checked,
|
| 64 |
+
"model_type": config["model_type"],
|
| 65 |
+
"training_rows": len(dataset["train"]),
|
| 66 |
+
"helmet_rows": len(helmet),
|
| 67 |
+
"mrcr_rows": len(mrcr),
|
| 68 |
+
}, indent=2))
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
if __name__ == "__main__":
|
| 72 |
+
main()
|
recipe/sft.py
ADDED
|
@@ -0,0 +1,642 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AHA-Qwen3 SFT training script.
|
| 3 |
+
|
| 4 |
+
Loads pretrained Qwen3-0.6B, converts to AHA-Qwen3 (adds gate to q_proj),
|
| 5 |
+
and trains on am-distilled data. Only gate weights are randomly initialized;
|
| 6 |
+
all other weights come from the pretrained model.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
import math
|
| 11 |
+
import os
|
| 12 |
+
import shutil
|
| 13 |
+
import socket
|
| 14 |
+
import subprocess
|
| 15 |
+
from typing import Optional
|
| 16 |
+
|
| 17 |
+
import datasets
|
| 18 |
+
import torch
|
| 19 |
+
import torch.distributed as dist
|
| 20 |
+
from trl import SFTConfig, SFTTrainer
|
| 21 |
+
from transformers import AutoTokenizer, TrainerCallback
|
| 22 |
+
from transformers.trainer_utils import get_last_checkpoint
|
| 23 |
+
|
| 24 |
+
from modeling_aha_qwen3 import (
|
| 25 |
+
AHA_ROUTER_GRANULARITY,
|
| 26 |
+
AHAQwen3Config,
|
| 27 |
+
AHAQwen3ForCausalLM,
|
| 28 |
+
aha_router_output_size,
|
| 29 |
+
)
|
| 30 |
+
from router_training_utils import RowWiseAdamW, configure_gate_only
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class AHASFTTrainer(SFTTrainer):
|
| 34 |
+
"""Accumulate CE vs gate-aux vs distill breakdown and gate density; log with HF `loss` (total)."""
|
| 35 |
+
|
| 36 |
+
def __init__(self, *args, **kwargs):
|
| 37 |
+
super().__init__(*args, **kwargs)
|
| 38 |
+
# ``AHAQwen3ForCausalLM.forward`` accepts ``**kwargs`` for attention
|
| 39 |
+
# backends, but its CE loss is an ordinary local token mean and does
|
| 40 |
+
# not consume Trainer's ``num_items_in_batch``. Transformers 4.57
|
| 41 |
+
# otherwise mistakes the variadic signature for a globally normalized
|
| 42 |
+
# loss, multiplies it by world size, and skips the normal gradient-
|
| 43 |
+
# accumulation division. Mark the actual loss contract explicitly so
|
| 44 |
+
# lr=3e-5 has the same meaning at every world size.
|
| 45 |
+
self.model_accepts_loss_kwargs = False
|
| 46 |
+
self._aha_ce_sum = 0.0
|
| 47 |
+
self._aha_gate_aux_sum = 0.0
|
| 48 |
+
self._aha_distill_sum = 0.0
|
| 49 |
+
self._aha_gate_soft_sum = 0.0
|
| 50 |
+
self._aha_gate_hard_sum = 0.0
|
| 51 |
+
self._aha_metric_count = 0
|
| 52 |
+
# Cached for loss_total aggregation in log().
|
| 53 |
+
self._aha_distill_weight = float(
|
| 54 |
+
getattr(self.model.config, "aha_distill_weight", 0.0)
|
| 55 |
+
)
|
| 56 |
+
self._aha_ce_weight = float(
|
| 57 |
+
getattr(self.model.config, "aha_ce_weight", 1.0)
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
def compute_loss(self, model, inputs, return_outputs=False, num_items_in_batch=None):
|
| 61 |
+
loss, outputs = super().compute_loss(
|
| 62 |
+
model, inputs, return_outputs=True, num_items_in_batch=num_items_in_batch
|
| 63 |
+
)
|
| 64 |
+
if getattr(outputs, "ce_loss", None) is not None:
|
| 65 |
+
self._aha_metric_count += 1
|
| 66 |
+
self._aha_ce_sum += float(outputs.ce_loss.detach().float().mean().cpu())
|
| 67 |
+
if getattr(outputs, "gate_aux_loss", None) is not None:
|
| 68 |
+
self._aha_gate_aux_sum += float(outputs.gate_aux_loss.detach().float().mean().cpu())
|
| 69 |
+
if getattr(outputs, "distill_loss", None) is not None:
|
| 70 |
+
self._aha_distill_sum += float(outputs.distill_loss.detach().float().mean().cpu())
|
| 71 |
+
if getattr(outputs, "gate_soft_mean", None) is not None:
|
| 72 |
+
self._aha_gate_soft_sum += float(outputs.gate_soft_mean.detach().float().mean().cpu())
|
| 73 |
+
if getattr(outputs, "gate_hard_mean", None) is not None:
|
| 74 |
+
self._aha_gate_hard_sum += float(outputs.gate_hard_mean.detach().float().mean().cpu())
|
| 75 |
+
if return_outputs:
|
| 76 |
+
return (loss, outputs)
|
| 77 |
+
return loss
|
| 78 |
+
|
| 79 |
+
def _distributed_mean_of_sums(self, sum_val: float, count: int) -> float:
|
| 80 |
+
if count <= 0:
|
| 81 |
+
return float("nan")
|
| 82 |
+
device = self.accelerator.device
|
| 83 |
+
if dist.is_available() and dist.is_initialized() and dist.get_world_size() > 1:
|
| 84 |
+
t = torch.tensor([sum_val, float(count)], device=device, dtype=torch.float64)
|
| 85 |
+
dist.all_reduce(t, op=dist.ReduceOp.SUM)
|
| 86 |
+
return (t[0] / t[1]).item()
|
| 87 |
+
return sum_val / float(count)
|
| 88 |
+
|
| 89 |
+
def log(self, logs: dict[str, float], start_time: Optional[float] = None) -> None:
|
| 90 |
+
if self._aha_metric_count > 0:
|
| 91 |
+
n = self._aha_metric_count
|
| 92 |
+
ce_m = self._distributed_mean_of_sums(self._aha_ce_sum, n)
|
| 93 |
+
aux_m = self._distributed_mean_of_sums(self._aha_gate_aux_sum, n)
|
| 94 |
+
distill_m = self._distributed_mean_of_sums(self._aha_distill_sum, n)
|
| 95 |
+
gs_m = self._distributed_mean_of_sums(self._aha_gate_soft_sum, n)
|
| 96 |
+
gh_m = self._distributed_mean_of_sums(self._aha_gate_hard_sum, n)
|
| 97 |
+
if not math.isnan(ce_m):
|
| 98 |
+
logs["ce_loss"] = round(ce_m, 4)
|
| 99 |
+
if not math.isnan(aux_m):
|
| 100 |
+
logs["gate_aux_loss"] = round(aux_m, 6)
|
| 101 |
+
if not math.isnan(distill_m) and distill_m != 0.0:
|
| 102 |
+
logs["distill_loss"] = round(distill_m, 6)
|
| 103 |
+
# loss_total = aha_ce_weight * ce + gate_aux
|
| 104 |
+
# + aha_distill_weight * distill_loss
|
| 105 |
+
# (distill_m / ce_m are unweighted; apply weights here so the
|
| 106 |
+
# sum matches the scalar actually added to total loss.)
|
| 107 |
+
if not math.isnan(ce_m) and not math.isnan(aux_m):
|
| 108 |
+
lt = self._aha_ce_weight * ce_m + aux_m
|
| 109 |
+
if not math.isnan(distill_m):
|
| 110 |
+
lt = lt + self._aha_distill_weight * distill_m
|
| 111 |
+
logs["loss_total"] = round(lt, 4)
|
| 112 |
+
if "loss" in logs and lt > 1e-8:
|
| 113 |
+
# With ``model_accepts_loss_kwargs=False``, HF's logging
|
| 114 |
+
# window and the raw model loss should agree up to normal
|
| 115 |
+
# batch-to-batch weighting differences. A large integer
|
| 116 |
+
# ratio is a regression in Trainer loss normalization.
|
| 117 |
+
logs["hf_loss_ratio"] = round(logs["loss"] / lt, 2)
|
| 118 |
+
if not math.isnan(gs_m):
|
| 119 |
+
logs["gate_soft_mean"] = round(gs_m, 4)
|
| 120 |
+
if not math.isnan(gh_m):
|
| 121 |
+
logs["gate_hard_mean"] = round(gh_m, 4)
|
| 122 |
+
self._aha_ce_sum = 0.0
|
| 123 |
+
self._aha_gate_aux_sum = 0.0
|
| 124 |
+
self._aha_distill_sum = 0.0
|
| 125 |
+
self._aha_gate_soft_sum = 0.0
|
| 126 |
+
self._aha_gate_hard_sum = 0.0
|
| 127 |
+
self._aha_metric_count = 0
|
| 128 |
+
return super().log(logs, start_time)
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
class AHAManifestCallback(TrainerCallback):
|
| 132 |
+
"""Copy the immutable run manifest into each Trainer checkpoint."""
|
| 133 |
+
|
| 134 |
+
def __init__(self, manifest_path: str):
|
| 135 |
+
self.manifest_path = manifest_path
|
| 136 |
+
|
| 137 |
+
def on_save(self, args, state, control, **kwargs):
|
| 138 |
+
if state.is_world_process_zero and os.path.exists(self.manifest_path):
|
| 139 |
+
checkpoint_dir = os.path.join(
|
| 140 |
+
args.output_dir, f"checkpoint-{state.global_step}"
|
| 141 |
+
)
|
| 142 |
+
if os.path.isdir(checkpoint_dir):
|
| 143 |
+
shutil.copyfile(
|
| 144 |
+
self.manifest_path,
|
| 145 |
+
os.path.join(checkpoint_dir, "aha_training_manifest.json"),
|
| 146 |
+
)
|
| 147 |
+
return control
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def get_optional_int(name: str) -> Optional[int]:
|
| 151 |
+
value = os.environ.get(name)
|
| 152 |
+
return None if value in {None, ""} else int(value)
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def resolve_resume_checkpoint(output_dir: str) -> Optional[str]:
|
| 156 |
+
resume_from_checkpoint = os.environ.get("RESUME_FROM_CHECKPOINT")
|
| 157 |
+
if resume_from_checkpoint in {None, ""}:
|
| 158 |
+
return None
|
| 159 |
+
if resume_from_checkpoint == "latest":
|
| 160 |
+
if not os.path.isdir(output_dir):
|
| 161 |
+
return None
|
| 162 |
+
return get_last_checkpoint(output_dir)
|
| 163 |
+
return resume_from_checkpoint
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def parse_fsdp_options(name: str) -> list[str]:
|
| 167 |
+
value = os.environ.get(name, "").replace(",", " ").strip()
|
| 168 |
+
return [item for item in value.split() if item]
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def parse_bool(name: str, default: bool = False) -> bool:
|
| 172 |
+
value = os.environ.get(name)
|
| 173 |
+
if value is None:
|
| 174 |
+
return default
|
| 175 |
+
return value.lower() in {"1", "true", "yes", "on"}
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def parse_report_to() -> list[str]:
|
| 179 |
+
"""Comma-separated integrations, e.g. REPORT_TO=wandb or wandb,tensorboard. Empty / none / off -> []."""
|
| 180 |
+
raw = os.environ.get("REPORT_TO", "").strip()
|
| 181 |
+
if not raw or raw.lower() in ("none", "off"):
|
| 182 |
+
return []
|
| 183 |
+
return [x.strip() for x in raw.split(",") if x.strip()]
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def build_grouped_lr_optimizer(model: torch.nn.Module) -> torch.optim.Optimizer:
|
| 187 |
+
"""Build an AdamW optimizer with separate *effective* LR for gate rows.
|
| 188 |
+
|
| 189 |
+
Dynamic-mode gate logits live in the final router rows of
|
| 190 |
+
each attention ``q_proj`` instead of in a separate Parameter. PyTorch
|
| 191 |
+
optimizer groups cannot split one Parameter by row, so q_proj tensors are
|
| 192 |
+
placed in the gate-lr group and :class:`RowWiseAdamW` scales their realized
|
| 193 |
+
Q-row updates by ``backbone_lr / gate_lr``. Other trainable parameters use
|
| 194 |
+
the backbone-lr group directly.
|
| 195 |
+
"""
|
| 196 |
+
if getattr(model.config, "aha_mode", "dynamic") != "dynamic":
|
| 197 |
+
raise ValueError("GROUPED_LR currently only supports AHA_MODE=dynamic")
|
| 198 |
+
if gate_learning_rate <= 0.0 or backbone_learning_rate < 0.0:
|
| 199 |
+
raise ValueError(
|
| 200 |
+
"GATE_LEARNING_RATE must be positive and BACKBONE_LEARNING_RATE must be non-negative"
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
num_heads = model.config.num_attention_heads
|
| 204 |
+
head_dim = getattr(model.config, "head_dim", model.config.hidden_size // num_heads)
|
| 205 |
+
q_rows = num_heads * head_dim
|
| 206 |
+
q_row_scale = backbone_learning_rate / gate_learning_rate
|
| 207 |
+
|
| 208 |
+
gate_params = []
|
| 209 |
+
gate_param_ids = set()
|
| 210 |
+
|
| 211 |
+
row_scales = []
|
| 212 |
+
for layer in model.model.layers:
|
| 213 |
+
q_proj = layer.self_attn.q_proj
|
| 214 |
+
for p in (q_proj.weight, q_proj.bias):
|
| 215 |
+
if p is None or not p.requires_grad:
|
| 216 |
+
continue
|
| 217 |
+
gate_params.append(p)
|
| 218 |
+
gate_param_ids.add(id(p))
|
| 219 |
+
row_scales.append((p, q_rows, q_row_scale))
|
| 220 |
+
|
| 221 |
+
backbone_params = [
|
| 222 |
+
p for p in model.parameters()
|
| 223 |
+
if p.requires_grad and id(p) not in gate_param_ids
|
| 224 |
+
]
|
| 225 |
+
if not gate_params:
|
| 226 |
+
raise ValueError("GROUPED_LR found no trainable q_proj gate parameters")
|
| 227 |
+
|
| 228 |
+
gate_n = sum(p.numel() for p in gate_params)
|
| 229 |
+
effective_gate_n = sum(
|
| 230 |
+
layer.self_attn.aha_router_outputs
|
| 231 |
+
* (layer.self_attn.q_proj.in_features + (layer.self_attn.q_proj.bias is not None))
|
| 232 |
+
for layer in model.model.layers
|
| 233 |
+
)
|
| 234 |
+
backbone_n = sum(p.numel() for p in backbone_params)
|
| 235 |
+
print(
|
| 236 |
+
" GROUPED_LR optimizer: "
|
| 237 |
+
f"gate_lr={gate_learning_rate:.3e}, backbone_lr={backbone_learning_rate:.3e}, "
|
| 238 |
+
f"q_row_update_scale={q_row_scale:.3g}, "
|
| 239 |
+
f"q_proj_params={gate_n:,}, effective_gate_params={effective_gate_n:,}, "
|
| 240 |
+
f"other_trainable={backbone_n:,}"
|
| 241 |
+
)
|
| 242 |
+
|
| 243 |
+
param_groups = [{"params": gate_params, "lr": gate_learning_rate}]
|
| 244 |
+
if backbone_params:
|
| 245 |
+
param_groups.append({"params": backbone_params, "lr": backbone_learning_rate})
|
| 246 |
+
return RowWiseAdamW(
|
| 247 |
+
param_groups,
|
| 248 |
+
row_scales=row_scales,
|
| 249 |
+
weight_decay=weight_decay,
|
| 250 |
+
betas=(adam_beta1, adam_beta2),
|
| 251 |
+
)
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
# === Configuration (override via environment variables) ===
|
| 255 |
+
model_path = os.environ.get("MODEL_PATH", "/workspace/AHA/models/Qwen3-0.6B")
|
| 256 |
+
aha_checkpoint_path = os.environ.get("AHA_CHECKPOINT_PATH", "")
|
| 257 |
+
dataset_path = os.environ.get("DATASET_PATH", "/workspace/Direct-Multitoken-Decoding/am-distilled-8192")
|
| 258 |
+
dataset_split = os.environ.get("DATASET_SPLIT", "train")
|
| 259 |
+
output_dir = os.environ.get("OUTPUT_DIR", "/workspace/AHA/AHA-Qwen3/ckpts/aha_qwen3_w1024")
|
| 260 |
+
aha_window_size = int(os.environ.get("AHA_WINDOW_SIZE", "1024"))
|
| 261 |
+
aha_lambda = float(os.environ.get("AHA_LAMBDA", "3e-4"))
|
| 262 |
+
aha_distill_weight = float(os.environ.get("AHA_DISTILL_WEIGHT", "0.0"))
|
| 263 |
+
# Language-modeling CE weight. Default 1.0 = standard SFT. Set to 0.0 to
|
| 264 |
+
# drop CE from the training objective and shape the gate purely via
|
| 265 |
+
# ``aux + distill`` (useful with GATE_ONLY + frozen backbone).
|
| 266 |
+
aha_ce_weight = float(os.environ.get("AHA_CE_WEIGHT", "1.0"))
|
| 267 |
+
# Hinge target on mean(gate_soft). Aux loss is ``λ · max(0, ḡ - τ)``.
|
| 268 |
+
# Default 1.0 keeps the legacy unconditional aux (τ=1 makes the clamp
|
| 269 |
+
# vacuous). Set e.g. 0.15 to cap global-attention density at 15% of
|
| 270 |
+
# tokens in steady state -- this is the safety floor that prevents
|
| 271 |
+
# gate collapse when training without CE. See AHAQwen3Config.
|
| 272 |
+
aha_gate_target = float(os.environ.get("AHA_GATE_TARGET", "1.0"))
|
| 273 |
+
# Direct Duo-style sparsity regularizer. When set to a non-negative value,
|
| 274 |
+
# the AHA loss uses ``AHA_REG_WEIGHT * mean(gate_soft)`` instead of the legacy
|
| 275 |
+
# hinge controlled by AHA_LAMBDA/AHA_GATE_TARGET.
|
| 276 |
+
aha_reg_weight = float(os.environ.get("AHA_REG_WEIGHT", "-1.0"))
|
| 277 |
+
# AHA mode: "dynamic" (per-(token, kv_head) MLP gate) or "duo" (per-(layer,
|
| 278 |
+
# kv_head) static scalar, DuoAttention-style). DUO mode ignores GATE_ONLY
|
| 279 |
+
# and instead uses its own ``DUO_ALPHA_ONLY`` freeze path.
|
| 280 |
+
aha_mode = os.environ.get("AHA_MODE", "dynamic")
|
| 281 |
+
aha_router_granularity_request = os.environ.get(
|
| 282 |
+
"AHA_ROUTER_GRANULARITY", ""
|
| 283 |
+
).strip()
|
| 284 |
+
if aha_router_granularity_request and aha_router_granularity_request not in {
|
| 285 |
+
"token",
|
| 286 |
+
"token_kv_head",
|
| 287 |
+
}:
|
| 288 |
+
raise ValueError(
|
| 289 |
+
"AHA_ROUTER_GRANULARITY must be 'token' or 'token_kv_head', got "
|
| 290 |
+
f"{aha_router_granularity_request!r}"
|
| 291 |
+
)
|
| 292 |
+
# Override aha_local_kind on a loaded ckpt: "sink_recent" or
|
| 293 |
+
# "sliding_window". Useful for ablating sink dependence in dynamic mode
|
| 294 |
+
# from the same hot-start ckpt without retraining the source. Empty
|
| 295 |
+
# string keeps the ckpt's existing config value.
|
| 296 |
+
aha_local_kind_override = os.environ.get("AHA_LOCAL_KIND", "").strip()
|
| 297 |
+
duo_sink_size = int(os.environ.get("DUO_SINK_SIZE", "64"))
|
| 298 |
+
duo_recent_size = int(os.environ.get("DUO_RECENT_SIZE", "256"))
|
| 299 |
+
duo_alpha_init = float(os.environ.get("DUO_ALPHA_INIT", "1.0"))
|
| 300 |
+
duo_alpha_only = parse_bool("DUO_ALPHA_ONLY", default=(aha_mode == "duo"))
|
| 301 |
+
gate_only = parse_bool("GATE_ONLY", default=False)
|
| 302 |
+
grouped_lr = parse_bool("GROUPED_LR", default=False)
|
| 303 |
+
max_seq_length = int(os.environ.get("MAX_SEQ_LENGTH", "8192"))
|
| 304 |
+
min_train_tokens = get_optional_int("MIN_TRAIN_TOKENS")
|
| 305 |
+
max_train_tokens = get_optional_int("MAX_TRAIN_TOKENS")
|
| 306 |
+
max_train_samples = get_optional_int("MAX_TRAIN_SAMPLES")
|
| 307 |
+
learning_rate = float(os.environ.get("LEARNING_RATE", "3e-5"))
|
| 308 |
+
gate_learning_rate = float(os.environ.get("GATE_LEARNING_RATE", str(learning_rate)))
|
| 309 |
+
backbone_learning_rate = float(os.environ.get("BACKBONE_LEARNING_RATE", str(learning_rate)))
|
| 310 |
+
lr_scheduler_type = os.environ.get("LR_SCHEDULER_TYPE", "cosine")
|
| 311 |
+
warmup_ratio = float(os.environ.get("WARMUP_RATIO", "0.03"))
|
| 312 |
+
adam_beta1 = float(os.environ.get("ADAM_BETA1", "0.9"))
|
| 313 |
+
adam_beta2 = float(os.environ.get("ADAM_BETA2", "0.999"))
|
| 314 |
+
weight_decay = float(os.environ.get("WEIGHT_DECAY", "0.0"))
|
| 315 |
+
max_grad_norm = float(os.environ.get("MAX_GRAD_NORM", "1.0"))
|
| 316 |
+
per_device_batch_size = int(os.environ.get("PER_DEVICE_TRAIN_BATCH_SIZE", "2"))
|
| 317 |
+
gradient_accumulation_steps = int(os.environ.get("GRADIENT_ACCUMULATION_STEPS", "16"))
|
| 318 |
+
num_train_epochs = float(os.environ.get("NUM_TRAIN_EPOCHS", "1"))
|
| 319 |
+
max_steps = int(os.environ.get("MAX_STEPS", "-1")) # -1 disables the cap; positive overrides num_train_epochs
|
| 320 |
+
logging_steps = int(os.environ.get("LOGGING_STEPS", "10"))
|
| 321 |
+
save_steps = int(os.environ.get("SAVE_STEPS", "500"))
|
| 322 |
+
save_total_limit = int(os.environ.get("SAVE_TOTAL_LIMIT", "2"))
|
| 323 |
+
save_strategy = os.environ.get("SAVE_STRATEGY", "steps")
|
| 324 |
+
seed = int(os.environ.get("SEED", "42"))
|
| 325 |
+
resume_from_checkpoint = resolve_resume_checkpoint(output_dir)
|
| 326 |
+
report_to = parse_report_to()
|
| 327 |
+
wandb_run_name = os.environ.get("WANDB_RUN_NAME") or os.environ.get("RUN_NAME") or os.path.basename(output_dir.rstrip("/"))
|
| 328 |
+
fsdp_options = parse_fsdp_options("FSDP_OPTIONS")
|
| 329 |
+
fsdp_transformer_layer_cls = os.environ.get("FSDP_TRANSFORMER_LAYER_CLS_TO_WRAP", "")
|
| 330 |
+
fsdp_activation_checkpointing = parse_bool("FSDP_ACTIVATION_CHECKPOINTING", default=True)
|
| 331 |
+
freeze_embeddings_lm_head = parse_bool("FREEZE_EMBEDDINGS_LM_HEAD", default=True)
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
def main():
|
| 335 |
+
AHAQwen3Config.register_for_auto_class()
|
| 336 |
+
AHAQwen3ForCausalLM.register_for_auto_class("AutoModelForCausalLM")
|
| 337 |
+
os.makedirs(output_dir, exist_ok=True)
|
| 338 |
+
|
| 339 |
+
print(f"Loading Qwen3 from {model_path} and converting to AHA-Qwen3...")
|
| 340 |
+
if aha_checkpoint_path:
|
| 341 |
+
print(f" aha_checkpoint_path={aha_checkpoint_path}")
|
| 342 |
+
print(
|
| 343 |
+
f" aha_mode={aha_mode}, "
|
| 344 |
+
f"aha_router_granularity={aha_router_granularity_request or 'checkpoint/default'}, "
|
| 345 |
+
f"aha_window_size={aha_window_size}, "
|
| 346 |
+
f"aha_lambda={aha_lambda}, aha_distill_weight={aha_distill_weight}, "
|
| 347 |
+
f"aha_ce_weight={aha_ce_weight}, aha_gate_target={aha_gate_target}, "
|
| 348 |
+
f"aha_reg_weight={aha_reg_weight}, "
|
| 349 |
+
f"gate_only={gate_only}, duo_alpha_only={duo_alpha_only}, grouped_lr={grouped_lr}"
|
| 350 |
+
)
|
| 351 |
+
if aha_mode == "duo":
|
| 352 |
+
print(
|
| 353 |
+
f" duo_sink_size={duo_sink_size}, duo_recent_size={duo_recent_size}, "
|
| 354 |
+
f"duo_alpha_init={duo_alpha_init}"
|
| 355 |
+
)
|
| 356 |
+
print(f" dataset={dataset_path}[{dataset_split}]")
|
| 357 |
+
print(f" output_dir={output_dir}")
|
| 358 |
+
print(f" max_seq_length={max_seq_length}")
|
| 359 |
+
print(
|
| 360 |
+
" optimizer/schedule: "
|
| 361 |
+
f"lr={learning_rate:.3e}, scheduler={lr_scheduler_type}, warmup_ratio={warmup_ratio}, "
|
| 362 |
+
f"betas=({adam_beta1}, {adam_beta2}), weight_decay={weight_decay}, "
|
| 363 |
+
f"max_grad_norm={max_grad_norm}"
|
| 364 |
+
)
|
| 365 |
+
world_size = int(os.environ.get("WORLD_SIZE", "1"))
|
| 366 |
+
effective_batch_size = per_device_batch_size * gradient_accumulation_steps * world_size
|
| 367 |
+
print(
|
| 368 |
+
" batch: "
|
| 369 |
+
f"per_device={per_device_batch_size}, grad_accum={gradient_accumulation_steps}, "
|
| 370 |
+
f"world_size={world_size}, effective_global={effective_batch_size}"
|
| 371 |
+
)
|
| 372 |
+
if min_train_tokens is not None or max_train_tokens is not None:
|
| 373 |
+
print(f" token_filter=[{min_train_tokens}, {max_train_tokens}]")
|
| 374 |
+
if max_train_samples is not None:
|
| 375 |
+
print(f" max_train_samples={max_train_samples}")
|
| 376 |
+
if resume_from_checkpoint is not None:
|
| 377 |
+
print(f" resume_from_checkpoint={resume_from_checkpoint}")
|
| 378 |
+
if fsdp_options:
|
| 379 |
+
print(f" fsdp_options={fsdp_options}")
|
| 380 |
+
if fsdp_transformer_layer_cls:
|
| 381 |
+
print(f" fsdp_transformer_layer_cls_to_wrap={fsdp_transformer_layer_cls}")
|
| 382 |
+
print(f" report_to={report_to if report_to else 'none'}")
|
| 383 |
+
if report_to and "wandb" in report_to:
|
| 384 |
+
print(
|
| 385 |
+
" wandb: set WANDB_PROJECT (required for cloud UI); optional WANDB_ENTITY, WANDB_RUN_GROUP, WANDB_TAGS. "
|
| 386 |
+
"Run `wandb login` once. Metrics include train/ce_loss, train/gate_aux_loss, train/loss_total, train/loss (HF)."
|
| 387 |
+
)
|
| 388 |
+
|
| 389 |
+
if aha_checkpoint_path:
|
| 390 |
+
model = AHAQwen3ForCausalLM.from_pretrained_aha(
|
| 391 |
+
aha_checkpoint_path,
|
| 392 |
+
torch_dtype=torch.bfloat16,
|
| 393 |
+
attn_implementation="sdpa",
|
| 394 |
+
)
|
| 395 |
+
# Config carried inside the checkpoint may predate aha_distill_weight /
|
| 396 |
+
# aha_ce_weight / aha_gate_target; honor the env override either way.
|
| 397 |
+
model.config.aha_distill_weight = aha_distill_weight
|
| 398 |
+
model.config.aha_ce_weight = aha_ce_weight
|
| 399 |
+
model.config.aha_lambda = aha_lambda
|
| 400 |
+
model.config.aha_gate_target = aha_gate_target
|
| 401 |
+
model.config.aha_reg_weight = aha_reg_weight
|
| 402 |
+
loaded_granularity = getattr(
|
| 403 |
+
model.config, "aha_router_granularity", AHA_ROUTER_GRANULARITY
|
| 404 |
+
)
|
| 405 |
+
if (
|
| 406 |
+
aha_router_granularity_request
|
| 407 |
+
and aha_router_granularity_request != loaded_granularity
|
| 408 |
+
):
|
| 409 |
+
raise ValueError(
|
| 410 |
+
"AHA_ROUTER_GRANULARITY does not match checkpoint architecture: "
|
| 411 |
+
f"requested={aha_router_granularity_request!r}, "
|
| 412 |
+
f"checkpoint={loaded_granularity!r}"
|
| 413 |
+
)
|
| 414 |
+
if aha_local_kind_override:
|
| 415 |
+
if aha_local_kind_override not in {"sink_recent", "sliding_window"}:
|
| 416 |
+
raise ValueError(
|
| 417 |
+
f"AHA_LOCAL_KIND must be 'sink_recent' or 'sliding_window', got {aha_local_kind_override!r}"
|
| 418 |
+
)
|
| 419 |
+
prev = getattr(model.config, "aha_local_kind", None)
|
| 420 |
+
model.config.aha_local_kind = aha_local_kind_override
|
| 421 |
+
print(f" AHA_LOCAL_KIND override: {prev!r} -> {aha_local_kind_override!r}")
|
| 422 |
+
else:
|
| 423 |
+
model = AHAQwen3ForCausalLM.from_pretrained_qwen3(
|
| 424 |
+
model_path,
|
| 425 |
+
aha_window_size=aha_window_size,
|
| 426 |
+
aha_lambda=aha_lambda,
|
| 427 |
+
aha_distill_weight=aha_distill_weight,
|
| 428 |
+
aha_ce_weight=aha_ce_weight,
|
| 429 |
+
aha_gate_target=aha_gate_target,
|
| 430 |
+
aha_reg_weight=aha_reg_weight,
|
| 431 |
+
aha_mode=aha_mode,
|
| 432 |
+
aha_router_granularity=(
|
| 433 |
+
aha_router_granularity_request or AHA_ROUTER_GRANULARITY
|
| 434 |
+
),
|
| 435 |
+
duo_sink_size=duo_sink_size,
|
| 436 |
+
duo_recent_size=duo_recent_size,
|
| 437 |
+
duo_alpha_init=duo_alpha_init,
|
| 438 |
+
torch_dtype=torch.bfloat16,
|
| 439 |
+
attn_implementation="sdpa",
|
| 440 |
+
)
|
| 441 |
+
|
| 442 |
+
# Historical Qwen experiments froze embeddings and lm_head. The rebuttal
|
| 443 |
+
# scaling protocol disables this to match the OLMo-2 end-to-end recipe,
|
| 444 |
+
# where the complete pretrained model and routers are fine-tuned jointly.
|
| 445 |
+
if freeze_embeddings_lm_head:
|
| 446 |
+
for param in model.model.embed_tokens.parameters():
|
| 447 |
+
param.requires_grad = False
|
| 448 |
+
for param in model.lm_head.parameters():
|
| 449 |
+
param.requires_grad = False
|
| 450 |
+
print(f" freeze_embeddings_lm_head={freeze_embeddings_lm_head}")
|
| 451 |
+
|
| 452 |
+
if duo_alpha_only:
|
| 453 |
+
# DuoAttention-style: only the per-(layer, kv_head) scalar
|
| 454 |
+
# ``full_attention_heads`` is trainable; everything else is
|
| 455 |
+
# frozen. For Qwen3-0.6B this yields 224 trainable scalars.
|
| 456 |
+
if getattr(model.config, "aha_mode", "dynamic") != "duo":
|
| 457 |
+
raise ValueError(
|
| 458 |
+
"DUO_ALPHA_ONLY=1 requires AHA_MODE=duo; otherwise there are no alpha params."
|
| 459 |
+
)
|
| 460 |
+
for param in model.parameters():
|
| 461 |
+
param.requires_grad = False
|
| 462 |
+
unfrozen = 0
|
| 463 |
+
for layer in model.model.layers:
|
| 464 |
+
p = layer.self_attn.full_attention_heads
|
| 465 |
+
p.requires_grad = True
|
| 466 |
+
unfrozen += p.numel()
|
| 467 |
+
print(f" DUO_ALPHA_ONLY: trainable scalars {unfrozen:,}")
|
| 468 |
+
elif gate_only:
|
| 469 |
+
# Gate-only training: freeze everything, then unfreeze just the
|
| 470 |
+
# gate rows of q_proj via gradient masks. q_proj.weight / bias
|
| 471 |
+
# layout is [num_heads*head_dim Q rows, native router rows].
|
| 472 |
+
setup = configure_gate_only(model)
|
| 473 |
+
print(
|
| 474 |
+
f" GATE_ONLY: granularity={model.config.aha_router_granularity}, "
|
| 475 |
+
f"gate_rows/layer={setup.gate_rows}, "
|
| 476 |
+
f"effective gate parameters {setup.effective_parameter_count:,} "
|
| 477 |
+
"(Q rows grad-masked to 0)"
|
| 478 |
+
)
|
| 479 |
+
|
| 480 |
+
n_trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
|
| 481 |
+
n_total = sum(p.numel() for p in model.parameters())
|
| 482 |
+
print(f" Trainable: {n_trainable:,} / {n_total:,} ({n_trainable/n_total:.1%})")
|
| 483 |
+
|
| 484 |
+
tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True)
|
| 485 |
+
|
| 486 |
+
print(f"Loading dataset from {dataset_path}...")
|
| 487 |
+
dataset_dict = datasets.load_from_disk(dataset_path)
|
| 488 |
+
train_dataset = dataset_dict[dataset_split]
|
| 489 |
+
print(f" loaded_rows={len(train_dataset):,}")
|
| 490 |
+
|
| 491 |
+
if min_train_tokens is not None:
|
| 492 |
+
train_dataset = train_dataset.filter(lambda example: example["num_tokens"] >= min_train_tokens)
|
| 493 |
+
print(f" rows_after_min_train_tokens={len(train_dataset):,}")
|
| 494 |
+
if max_train_tokens is not None:
|
| 495 |
+
train_dataset = train_dataset.filter(lambda example: example["num_tokens"] <= max_train_tokens)
|
| 496 |
+
print(f" rows_after_max_train_tokens={len(train_dataset):,}")
|
| 497 |
+
if max_train_samples is not None:
|
| 498 |
+
keep = min(max_train_samples, len(train_dataset))
|
| 499 |
+
train_dataset = train_dataset.shuffle(seed=seed).select(range(keep))
|
| 500 |
+
print(f" rows_after_max_train_samples={len(train_dataset):,}")
|
| 501 |
+
if len(train_dataset) == 0:
|
| 502 |
+
raise ValueError("Training dataset is empty after filtering.")
|
| 503 |
+
|
| 504 |
+
if int(os.environ.get("RANK", "0")) == 0:
|
| 505 |
+
try:
|
| 506 |
+
git_commit = subprocess.check_output(
|
| 507 |
+
["git", "rev-parse", "HEAD"], text=True
|
| 508 |
+
).strip()
|
| 509 |
+
git_status = subprocess.check_output(
|
| 510 |
+
["git", "status", "--short"], text=True
|
| 511 |
+
).splitlines()
|
| 512 |
+
except (OSError, subprocess.CalledProcessError):
|
| 513 |
+
git_commit, git_status = None, []
|
| 514 |
+
manifest = {
|
| 515 |
+
"schema": "aha-qwen3-training-manifest-v1",
|
| 516 |
+
"git_commit": git_commit,
|
| 517 |
+
"git_status": git_status,
|
| 518 |
+
"host": socket.gethostname(),
|
| 519 |
+
"model_path": str(model_path),
|
| 520 |
+
"source_aha_checkpoint": str(aha_checkpoint_path) or None,
|
| 521 |
+
"router_granularity": getattr(
|
| 522 |
+
model.config, "aha_router_granularity", AHA_ROUTER_GRANULARITY
|
| 523 |
+
),
|
| 524 |
+
"native_gate_rows_per_layer": aha_router_output_size(model.config),
|
| 525 |
+
"effective_gate_parameters": sum(
|
| 526 |
+
layer.self_attn.aha_router_outputs
|
| 527 |
+
* (
|
| 528 |
+
layer.self_attn.q_proj.in_features
|
| 529 |
+
+ (layer.self_attn.q_proj.bias is not None)
|
| 530 |
+
)
|
| 531 |
+
for layer in model.model.layers
|
| 532 |
+
),
|
| 533 |
+
"effective_sparsity_denominator": "token x KV-head x layer",
|
| 534 |
+
"dataset": {
|
| 535 |
+
"path": str(dataset_path),
|
| 536 |
+
"split": dataset_split,
|
| 537 |
+
"rows_after_filtering": len(train_dataset),
|
| 538 |
+
"max_sequence_length": max_seq_length,
|
| 539 |
+
},
|
| 540 |
+
"training": {
|
| 541 |
+
"gate_only": gate_only,
|
| 542 |
+
"grouped_lr": grouped_lr,
|
| 543 |
+
"learning_rate": learning_rate,
|
| 544 |
+
"gate_learning_rate": gate_learning_rate,
|
| 545 |
+
"backbone_learning_rate": backbone_learning_rate,
|
| 546 |
+
"regularizer_weight": aha_reg_weight,
|
| 547 |
+
"ce_weight": aha_ce_weight,
|
| 548 |
+
"attention_distill_weight": aha_distill_weight,
|
| 549 |
+
"train_gate_threshold": float(
|
| 550 |
+
os.environ.get("AHA_TRAIN_GATE_HARD_THRESHOLD", "0.5")
|
| 551 |
+
),
|
| 552 |
+
"max_steps": max_steps,
|
| 553 |
+
"seed": seed,
|
| 554 |
+
"per_device_batch_size": per_device_batch_size,
|
| 555 |
+
"gradient_accumulation_steps": gradient_accumulation_steps,
|
| 556 |
+
"world_size": world_size,
|
| 557 |
+
},
|
| 558 |
+
}
|
| 559 |
+
manifest_path = os.path.join(output_dir, "aha_training_manifest.json")
|
| 560 |
+
with open(manifest_path, "w") as f:
|
| 561 |
+
json.dump(manifest, f, indent=2)
|
| 562 |
+
else:
|
| 563 |
+
manifest_path = os.path.join(output_dir, "aha_training_manifest.json")
|
| 564 |
+
|
| 565 |
+
fsdp_config = None
|
| 566 |
+
gradient_checkpointing = True
|
| 567 |
+
gradient_checkpointing_kwargs = {"use_reentrant": False}
|
| 568 |
+
if fsdp_options:
|
| 569 |
+
fsdp_config = {}
|
| 570 |
+
if fsdp_transformer_layer_cls:
|
| 571 |
+
fsdp_config["transformer_layer_cls_to_wrap"] = [fsdp_transformer_layer_cls]
|
| 572 |
+
if fsdp_activation_checkpointing:
|
| 573 |
+
# Transformers warns that FSDP should use fsdp_config.activation_checkpointing
|
| 574 |
+
# instead of Trainer-level gradient_checkpointing to avoid redundant all-gathers.
|
| 575 |
+
fsdp_config["activation_checkpointing"] = True
|
| 576 |
+
gradient_checkpointing = False
|
| 577 |
+
gradient_checkpointing_kwargs = None
|
| 578 |
+
|
| 579 |
+
sft_config = SFTConfig(
|
| 580 |
+
output_dir=output_dir,
|
| 581 |
+
per_device_train_batch_size=per_device_batch_size,
|
| 582 |
+
gradient_accumulation_steps=gradient_accumulation_steps,
|
| 583 |
+
learning_rate=learning_rate,
|
| 584 |
+
num_train_epochs=num_train_epochs,
|
| 585 |
+
max_steps=max_steps,
|
| 586 |
+
max_length=max_seq_length,
|
| 587 |
+
lr_scheduler_type=lr_scheduler_type,
|
| 588 |
+
warmup_ratio=warmup_ratio,
|
| 589 |
+
adam_beta1=adam_beta1,
|
| 590 |
+
adam_beta2=adam_beta2,
|
| 591 |
+
weight_decay=weight_decay,
|
| 592 |
+
max_grad_norm=max_grad_norm,
|
| 593 |
+
logging_steps=logging_steps,
|
| 594 |
+
save_steps=save_steps,
|
| 595 |
+
save_total_limit=save_total_limit,
|
| 596 |
+
save_strategy=save_strategy,
|
| 597 |
+
bf16=True,
|
| 598 |
+
tf32=True,
|
| 599 |
+
gradient_checkpointing=gradient_checkpointing,
|
| 600 |
+
gradient_checkpointing_kwargs=gradient_checkpointing_kwargs,
|
| 601 |
+
dataloader_drop_last=True,
|
| 602 |
+
remove_unused_columns=True,
|
| 603 |
+
report_to=report_to if report_to else "none",
|
| 604 |
+
run_name=wandb_run_name,
|
| 605 |
+
seed=seed,
|
| 606 |
+
# The custom model returns a standard local mean CE and does not use
|
| 607 |
+
# ``num_items_in_batch``. Keep legacy OLMo-style per-microbatch means
|
| 608 |
+
# instead of Transformers' global token-count scaling path.
|
| 609 |
+
average_tokens_across_devices=False,
|
| 610 |
+
fsdp=fsdp_options,
|
| 611 |
+
fsdp_config=fsdp_config,
|
| 612 |
+
)
|
| 613 |
+
|
| 614 |
+
trainer = AHASFTTrainer(
|
| 615 |
+
model=model,
|
| 616 |
+
args=sft_config,
|
| 617 |
+
train_dataset=train_dataset,
|
| 618 |
+
processing_class=tokenizer,
|
| 619 |
+
optimizers=(build_grouped_lr_optimizer(model), None) if grouped_lr else (None, None),
|
| 620 |
+
callbacks=[AHAManifestCallback(manifest_path)],
|
| 621 |
+
)
|
| 622 |
+
|
| 623 |
+
print(
|
| 624 |
+
"Starting training… Extra log fields: ce_loss, gate_aux_loss, loss_total (=ce+gate_aux), "
|
| 625 |
+
"gate_soft_mean, gate_hard_mean, hf_loss_ratio (= key `loss` / loss_total). "
|
| 626 |
+
"ce_loss / loss_total are raw model means (language quality + AHA aux). "
|
| 627 |
+
"Key `loss` is HuggingFace Trainer’s gradient-accumulation-normalized aggregate; "
|
| 628 |
+
"hf_loss_ratio should stay near 1.0."
|
| 629 |
+
)
|
| 630 |
+
print("Starting training...")
|
| 631 |
+
train_result = trainer.train(resume_from_checkpoint=resume_from_checkpoint)
|
| 632 |
+
trainer.save_model(output_dir)
|
| 633 |
+
trainer.save_state()
|
| 634 |
+
|
| 635 |
+
metrics = train_result.metrics
|
| 636 |
+
trainer.log_metrics("train", metrics)
|
| 637 |
+
trainer.save_metrics("train", metrics)
|
| 638 |
+
print(f"Training completed. Metrics: {metrics}")
|
| 639 |
+
|
| 640 |
+
|
| 641 |
+
if __name__ == "__main__":
|
| 642 |
+
main()
|
recipe/smoke_test.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
import time
|
| 4 |
+
import torch
|
| 5 |
+
|
| 6 |
+
# Add recipe path to sys.path
|
| 7 |
+
_RECIPE_ROOT = os.path.dirname(os.path.abspath(__file__))
|
| 8 |
+
sys.path.insert(0, _RECIPE_ROOT)
|
| 9 |
+
|
| 10 |
+
from modeling_aha_qwen3 import AHAQwen3ForCausalLM, AHAQwen3Config
|
| 11 |
+
from router_training_utils import RowWiseAdamW
|
| 12 |
+
|
| 13 |
+
def main():
|
| 14 |
+
AHAQwen3Config.register_for_auto_class()
|
| 15 |
+
AHAQwen3ForCausalLM.register_for_auto_class("AutoModelForCausalLM")
|
| 16 |
+
|
| 17 |
+
# The repo root is the parent directory of recipe
|
| 18 |
+
model_path = os.path.dirname(_RECIPE_ROOT)
|
| 19 |
+
|
| 20 |
+
print("Loading model in BF16...", flush=True)
|
| 21 |
+
model = AHAQwen3ForCausalLM.from_pretrained_qwen3(
|
| 22 |
+
model_path,
|
| 23 |
+
aha_window_size=128,
|
| 24 |
+
aha_lambda=3e-4,
|
| 25 |
+
aha_distill_weight=0.0,
|
| 26 |
+
aha_ce_weight=1.0,
|
| 27 |
+
aha_gate_target=1.0,
|
| 28 |
+
aha_reg_weight=0.01,
|
| 29 |
+
aha_mode="dynamic",
|
| 30 |
+
aha_router_granularity="token",
|
| 31 |
+
duo_sink_size=64,
|
| 32 |
+
duo_recent_size=256,
|
| 33 |
+
duo_alpha_init=1.0,
|
| 34 |
+
torch_dtype=torch.bfloat16,
|
| 35 |
+
attn_implementation="sdpa",
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
# Freeze embeddings and LM head as in stage 2 SFT training
|
| 39 |
+
for param in model.model.embed_tokens.parameters():
|
| 40 |
+
param.requires_grad = False
|
| 41 |
+
for param in model.lm_head.parameters():
|
| 42 |
+
param.requires_grad = False
|
| 43 |
+
|
| 44 |
+
print("Moving model to CUDA...", flush=True)
|
| 45 |
+
model = model.to("cuda")
|
| 46 |
+
|
| 47 |
+
# Configure RowWiseAdamW optimizer
|
| 48 |
+
num_heads = model.config.num_attention_heads
|
| 49 |
+
head_dim = getattr(model.config, "head_dim", model.config.hidden_size // num_heads)
|
| 50 |
+
q_rows = num_heads * head_dim
|
| 51 |
+
q_row_scale = 3e-7 / 3e-6 # backbone_lr / gate_lr
|
| 52 |
+
|
| 53 |
+
gate_params = []
|
| 54 |
+
gate_param_ids = set()
|
| 55 |
+
row_scales = []
|
| 56 |
+
for layer in model.model.layers:
|
| 57 |
+
q_proj = layer.self_attn.q_proj
|
| 58 |
+
for p in (q_proj.weight, q_proj.bias):
|
| 59 |
+
if p is None or not p.requires_grad:
|
| 60 |
+
continue
|
| 61 |
+
gate_params.append(p)
|
| 62 |
+
gate_param_ids.add(id(p))
|
| 63 |
+
row_scales.append((p, q_rows, q_row_scale))
|
| 64 |
+
|
| 65 |
+
backbone_params = [
|
| 66 |
+
p for p in model.parameters()
|
| 67 |
+
if p.requires_grad and id(p) not in gate_param_ids
|
| 68 |
+
]
|
| 69 |
+
|
| 70 |
+
param_groups = [{"params": gate_params, "lr": 3e-6}]
|
| 71 |
+
if backbone_params:
|
| 72 |
+
param_groups.append({"params": backbone_params, "lr": 3e-7})
|
| 73 |
+
|
| 74 |
+
optimizer = RowWiseAdamW(
|
| 75 |
+
param_groups,
|
| 76 |
+
row_scales=row_scales,
|
| 77 |
+
weight_decay=0.0,
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
# Allocate a batch of seq_len=8192
|
| 81 |
+
seq_len = 8192
|
| 82 |
+
print(f"Allocating dummy batch: batch_size=1, seq_len={seq_len}", flush=True)
|
| 83 |
+
input_ids = torch.randint(0, model.config.vocab_size, (1, seq_len), device="cuda")
|
| 84 |
+
labels = input_ids.clone()
|
| 85 |
+
|
| 86 |
+
# Enable gradient checkpointing
|
| 87 |
+
model.gradient_checkpointing_enable(gradient_checkpointing_kwargs={"use_reentrant": False})
|
| 88 |
+
|
| 89 |
+
# Warmup step (GPU caching, model trace creation, etc.)
|
| 90 |
+
print("Warmup step...", flush=True)
|
| 91 |
+
outputs = model(input_ids=input_ids, labels=labels)
|
| 92 |
+
loss = outputs.loss
|
| 93 |
+
loss.backward()
|
| 94 |
+
optimizer.step()
|
| 95 |
+
optimizer.zero_grad()
|
| 96 |
+
|
| 97 |
+
# Reset peak memory stats and time the next step
|
| 98 |
+
print("Starting measured smoke test step...", flush=True)
|
| 99 |
+
torch.cuda.reset_peak_memory_stats()
|
| 100 |
+
torch.cuda.synchronize()
|
| 101 |
+
start_time = time.time()
|
| 102 |
+
|
| 103 |
+
outputs = model(input_ids=input_ids, labels=labels)
|
| 104 |
+
loss = outputs.loss
|
| 105 |
+
loss.backward()
|
| 106 |
+
optimizer.step()
|
| 107 |
+
optimizer.zero_grad()
|
| 108 |
+
|
| 109 |
+
torch.cuda.synchronize()
|
| 110 |
+
step_time = time.time() - start_time
|
| 111 |
+
|
| 112 |
+
peak_mem = torch.cuda.max_memory_allocated() / (1024 ** 3)
|
| 113 |
+
reserved_mem = torch.cuda.memory_reserved() / (1024 ** 3)
|
| 114 |
+
|
| 115 |
+
print("=== SMOKE TEST RESULTS ===", flush=True)
|
| 116 |
+
print(f"Peak VRAM: {peak_mem:.4f} GB", flush=True)
|
| 117 |
+
print(f"Reserved VRAM: {reserved_mem:.4f} GB", flush=True)
|
| 118 |
+
print(f"Step time: {step_time:.4f} seconds", flush=True)
|
| 119 |
+
print("==========================", flush=True)
|
| 120 |
+
|
| 121 |
+
if __name__ == '__main__':
|
| 122 |
+
main()
|
recipe/source_training/all_results.json
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"ce_loss": 0.8887,
|
| 3 |
+
"total_flos": 6.189458802853544e+18,
|
| 4 |
+
"train_loss": 0.9857371548739723,
|
| 5 |
+
"train_ppl": 2.431889,
|
| 6 |
+
"train_runtime": 23521.9665,
|
| 7 |
+
"train_samples_per_second": 37.607,
|
| 8 |
+
"train_steps_per_second": 0.294
|
| 9 |
+
}
|
recipe/source_training/train_results.json
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"ce_loss": 0.8887,
|
| 3 |
+
"total_flos": 6.189458802853544e+18,
|
| 4 |
+
"train_loss": 0.9857371548739723,
|
| 5 |
+
"train_ppl": 2.431889,
|
| 6 |
+
"train_runtime": 23521.9665,
|
| 7 |
+
"train_samples_per_second": 37.607,
|
| 8 |
+
"train_steps_per_second": 0.294
|
| 9 |
+
}
|
recipe/source_training/trainer_state.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
recipe/tests/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Release-package tests."""
|
recipe/tests/test_router_granularity.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import math
|
| 5 |
+
import os
|
| 6 |
+
import tempfile
|
| 7 |
+
import unittest
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from unittest import mock
|
| 10 |
+
|
| 11 |
+
import torch
|
| 12 |
+
from transformers import Qwen3Config, Qwen3ForCausalLM
|
| 13 |
+
|
| 14 |
+
import modeling_aha_qwen3 as aha_module
|
| 15 |
+
from modeling_aha_qwen3 import (
|
| 16 |
+
AHAQwen3Config,
|
| 17 |
+
AHAQwen3ForCausalLM,
|
| 18 |
+
aha_router_output_size,
|
| 19 |
+
)
|
| 20 |
+
from router_training_utils import RowWiseAdamW, configure_gate_only
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def base_config() -> Qwen3Config:
|
| 24 |
+
config = Qwen3Config(
|
| 25 |
+
vocab_size=97,
|
| 26 |
+
hidden_size=32,
|
| 27 |
+
intermediate_size=64,
|
| 28 |
+
num_hidden_layers=2,
|
| 29 |
+
num_attention_heads=4,
|
| 30 |
+
num_key_value_heads=2,
|
| 31 |
+
head_dim=8,
|
| 32 |
+
max_position_embeddings=64,
|
| 33 |
+
attention_dropout=0.0,
|
| 34 |
+
attention_bias=True,
|
| 35 |
+
tie_word_embeddings=False,
|
| 36 |
+
)
|
| 37 |
+
config._attn_implementation = "eager"
|
| 38 |
+
return config
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def aha_config(granularity: str, *, force_gate_value=None) -> AHAQwen3Config:
|
| 42 |
+
payload = base_config().to_dict()
|
| 43 |
+
payload.update(
|
| 44 |
+
aha_window_size=2,
|
| 45 |
+
aha_local_kind="sliding_window",
|
| 46 |
+
aha_mode="dynamic",
|
| 47 |
+
aha_router_granularity=granularity,
|
| 48 |
+
aha_force_gate_value=force_gate_value,
|
| 49 |
+
aha_reg_weight=1.0,
|
| 50 |
+
aha_ce_weight=0.0,
|
| 51 |
+
model_type="aha_qwen3",
|
| 52 |
+
)
|
| 53 |
+
config = AHAQwen3Config(**payload)
|
| 54 |
+
config._attn_implementation = "eager"
|
| 55 |
+
return config
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def copy_base_weights(base: Qwen3ForCausalLM, target: AHAQwen3ForCausalLM) -> None:
|
| 59 |
+
source = base.state_dict()
|
| 60 |
+
destination = target.state_dict()
|
| 61 |
+
q_rows = target.config.num_attention_heads * target.config.head_dim
|
| 62 |
+
with torch.no_grad():
|
| 63 |
+
for name, value in source.items():
|
| 64 |
+
if name not in destination:
|
| 65 |
+
continue
|
| 66 |
+
if destination[name].shape == value.shape:
|
| 67 |
+
destination[name].copy_(value)
|
| 68 |
+
elif name.endswith("self_attn.q_proj.weight"):
|
| 69 |
+
destination[name][:q_rows].copy_(value)
|
| 70 |
+
destination[name][q_rows:].zero_()
|
| 71 |
+
elif name.endswith("self_attn.q_proj.bias"):
|
| 72 |
+
destination[name][:q_rows].copy_(value)
|
| 73 |
+
destination[name][q_rows:].zero_()
|
| 74 |
+
else:
|
| 75 |
+
raise AssertionError(f"unexpected shape mismatch for {name}")
|
| 76 |
+
target.load_state_dict(destination)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def aligned_models():
|
| 80 |
+
torch.manual_seed(7)
|
| 81 |
+
base = Qwen3ForCausalLM(base_config()).eval()
|
| 82 |
+
models = {}
|
| 83 |
+
for granularity in ("token", "token_kv_head"):
|
| 84 |
+
model = AHAQwen3ForCausalLM(
|
| 85 |
+
aha_config(granularity, force_gate_value=1.0)
|
| 86 |
+
).eval()
|
| 87 |
+
copy_base_weights(base, model)
|
| 88 |
+
models[granularity] = model
|
| 89 |
+
return base, models
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
class RouterGranularityTest(unittest.TestCase):
|
| 93 |
+
def test_auto_class_checkpoint_embeds_modeling_source(self):
|
| 94 |
+
AHAQwen3Config.register_for_auto_class()
|
| 95 |
+
AHAQwen3ForCausalLM.register_for_auto_class("AutoModelForCausalLM")
|
| 96 |
+
with tempfile.TemporaryDirectory() as tmp:
|
| 97 |
+
AHAQwen3ForCausalLM(aha_config("token")).save_pretrained(
|
| 98 |
+
tmp, safe_serialization=True
|
| 99 |
+
)
|
| 100 |
+
self.assertTrue((Path(tmp) / "modeling_aha_qwen3.py").exists())
|
| 101 |
+
|
| 102 |
+
def test_projection_and_effective_gate_shapes(self):
|
| 103 |
+
_, models = aligned_models()
|
| 104 |
+
input_ids = torch.tensor([[1, 2, 3, 4, 5]])
|
| 105 |
+
expected_q_rows = 4 * 8
|
| 106 |
+
for granularity, native_rows in (("token", 1), ("token_kv_head", 2)):
|
| 107 |
+
with self.subTest(granularity=granularity):
|
| 108 |
+
model = models[granularity]
|
| 109 |
+
attention = model.model.layers[0].self_attn
|
| 110 |
+
self.assertEqual(attention.q_proj.out_features, expected_q_rows + native_rows)
|
| 111 |
+
self.assertEqual(attention.aha_router_outputs, native_rows)
|
| 112 |
+
output = model.model(input_ids=input_ids, use_cache=False)
|
| 113 |
+
self.assertEqual(len(output.all_gate_soft), 2)
|
| 114 |
+
self.assertEqual(output.all_gate_soft[0].shape, (1, 5, 2))
|
| 115 |
+
self.assertEqual(output.all_gate_hard[0].shape, (1, 5, 2))
|
| 116 |
+
|
| 117 |
+
def test_force_open_matches_full_attention_logits(self):
|
| 118 |
+
base, models = aligned_models()
|
| 119 |
+
input_ids = torch.tensor([[1, 4, 2, 8, 3, 9]])
|
| 120 |
+
with torch.no_grad():
|
| 121 |
+
expected = base(input_ids=input_ids, use_cache=False).logits
|
| 122 |
+
for granularity, model in models.items():
|
| 123 |
+
with self.subTest(granularity=granularity):
|
| 124 |
+
actual = model(input_ids=input_ids, use_cache=False).logits
|
| 125 |
+
torch.testing.assert_close(actual, expected, atol=1e-6, rtol=1e-5)
|
| 126 |
+
|
| 127 |
+
def test_force_closed_uses_identical_local_branch(self):
|
| 128 |
+
_, models = aligned_models()
|
| 129 |
+
input_ids = torch.tensor([[1, 4, 2, 8, 3, 9]])
|
| 130 |
+
for model in models.values():
|
| 131 |
+
model.config.aha_force_gate_value = 0.0
|
| 132 |
+
with torch.no_grad():
|
| 133 |
+
token_logits = models["token"](input_ids=input_ids, use_cache=False).logits
|
| 134 |
+
head_logits = models["token_kv_head"](
|
| 135 |
+
input_ids=input_ids, use_cache=False
|
| 136 |
+
).logits
|
| 137 |
+
torch.testing.assert_close(token_logits, head_logits, atol=1e-6, rtol=1e-5)
|
| 138 |
+
|
| 139 |
+
def test_regularizer_uses_effective_kv_head_denominator(self):
|
| 140 |
+
_, models = aligned_models()
|
| 141 |
+
probability = 0.73
|
| 142 |
+
bias = math.log(probability / (1.0 - probability))
|
| 143 |
+
input_ids = torch.tensor([[1, 4, 2, 8, 3, 9]])
|
| 144 |
+
outputs = {}
|
| 145 |
+
for granularity, model in models.items():
|
| 146 |
+
model.train()
|
| 147 |
+
model.config.aha_force_gate_value = None
|
| 148 |
+
q_rows = model.config.num_attention_heads * model.config.head_dim
|
| 149 |
+
with torch.no_grad():
|
| 150 |
+
for layer in model.model.layers:
|
| 151 |
+
layer.self_attn.q_proj.weight[q_rows:].zero_()
|
| 152 |
+
layer.self_attn.q_proj.bias[q_rows:].fill_(bias)
|
| 153 |
+
outputs[granularity] = model(
|
| 154 |
+
input_ids=input_ids, labels=input_ids.clone(), use_cache=False
|
| 155 |
+
)
|
| 156 |
+
torch.testing.assert_close(
|
| 157 |
+
outputs["token"].gate_soft_mean,
|
| 158 |
+
outputs["token_kv_head"].gate_soft_mean,
|
| 159 |
+
)
|
| 160 |
+
torch.testing.assert_close(
|
| 161 |
+
outputs["token"].gate_aux_loss,
|
| 162 |
+
outputs["token_kv_head"].gate_aux_loss,
|
| 163 |
+
)
|
| 164 |
+
self.assertAlmostEqual(outputs["token"].gate_soft_mean.item(), probability, places=6)
|
| 165 |
+
|
| 166 |
+
def test_gate_only_updates_only_appended_rows(self):
|
| 167 |
+
for granularity, expected_rows in (("token", 1), ("token_kv_head", 2)):
|
| 168 |
+
with self.subTest(granularity=granularity):
|
| 169 |
+
model = AHAQwen3ForCausalLM(aha_config(granularity))
|
| 170 |
+
setup = configure_gate_only(model)
|
| 171 |
+
self.assertEqual(setup.gate_rows, expected_rows)
|
| 172 |
+
q_proj = model.model.layers[0].self_attn.q_proj
|
| 173 |
+
before = q_proj.weight.detach().clone()
|
| 174 |
+
q_proj.weight.sum().backward()
|
| 175 |
+
self.assertEqual(
|
| 176 |
+
torch.count_nonzero(q_proj.weight.grad[: setup.q_rows]).item(), 0
|
| 177 |
+
)
|
| 178 |
+
self.assertGreater(
|
| 179 |
+
torch.count_nonzero(q_proj.weight.grad[setup.q_rows :]).item(), 0
|
| 180 |
+
)
|
| 181 |
+
torch.optim.SGD(setup.parameters, lr=0.1).step()
|
| 182 |
+
torch.testing.assert_close(
|
| 183 |
+
q_proj.weight[: setup.q_rows], before[: setup.q_rows]
|
| 184 |
+
)
|
| 185 |
+
self.assertFalse(
|
| 186 |
+
torch.equal(q_proj.weight[setup.q_rows :], before[setup.q_rows :])
|
| 187 |
+
)
|
| 188 |
+
|
| 189 |
+
def test_rowwise_adamw_applies_real_ten_x_lr_ratio(self):
|
| 190 |
+
parameter = torch.nn.Parameter(torch.zeros(3, 1))
|
| 191 |
+
optimizer = RowWiseAdamW(
|
| 192 |
+
[{"params": [parameter], "lr": 0.1}],
|
| 193 |
+
row_scales=[(parameter, 2, 0.1)],
|
| 194 |
+
weight_decay=0.0,
|
| 195 |
+
betas=(0.9, 0.999),
|
| 196 |
+
)
|
| 197 |
+
parameter.grad = torch.ones_like(parameter)
|
| 198 |
+
optimizer.step()
|
| 199 |
+
backbone_update = parameter[:2].abs().mean().item()
|
| 200 |
+
gate_update = parameter[2:].abs().mean().item()
|
| 201 |
+
self.assertAlmostEqual(gate_update / backbone_update, 10.0, places=5)
|
| 202 |
+
|
| 203 |
+
def test_one_step_smoke_is_finite_and_reloadable(self):
|
| 204 |
+
input_ids = torch.tensor([[1, 4, 2, 8, 3, 9]])
|
| 205 |
+
for granularity in ("token", "token_kv_head"):
|
| 206 |
+
with self.subTest(granularity=granularity), tempfile.TemporaryDirectory() as tmp:
|
| 207 |
+
model = AHAQwen3ForCausalLM(aha_config(granularity)).train()
|
| 208 |
+
setup = configure_gate_only(model)
|
| 209 |
+
optimizer = torch.optim.AdamW(setup.parameters, lr=3e-5)
|
| 210 |
+
output = model(
|
| 211 |
+
input_ids=input_ids, labels=input_ids.clone(), use_cache=False
|
| 212 |
+
)
|
| 213 |
+
self.assertTrue(torch.isfinite(output.loss).item())
|
| 214 |
+
output.loss.backward()
|
| 215 |
+
self.assertTrue(
|
| 216 |
+
all(
|
| 217 |
+
parameter.grad is None
|
| 218 |
+
or torch.isfinite(parameter.grad).all().item()
|
| 219 |
+
for parameter in setup.parameters
|
| 220 |
+
)
|
| 221 |
+
)
|
| 222 |
+
optimizer.step()
|
| 223 |
+
model.save_pretrained(tmp, safe_serialization=True)
|
| 224 |
+
reloaded = AHAQwen3ForCausalLM.from_pretrained_aha(
|
| 225 |
+
tmp, torch_dtype=torch.float32, attn_implementation="eager"
|
| 226 |
+
)
|
| 227 |
+
self.assertEqual(
|
| 228 |
+
reloaded.config.aha_router_granularity, granularity
|
| 229 |
+
)
|
| 230 |
+
|
| 231 |
+
def test_legacy_default_and_save_load_roundtrip(self):
|
| 232 |
+
legacy = AHAQwen3Config(**base_config().to_dict())
|
| 233 |
+
self.assertEqual(legacy.aha_router_granularity, "token_kv_head")
|
| 234 |
+
self.assertEqual(aha_router_output_size(legacy), legacy.num_key_value_heads)
|
| 235 |
+
|
| 236 |
+
with tempfile.TemporaryDirectory() as tmp:
|
| 237 |
+
model = AHAQwen3ForCausalLM(aha_config("token"))
|
| 238 |
+
model.save_pretrained(tmp, safe_serialization=True)
|
| 239 |
+
loaded = AHAQwen3ForCausalLM.from_pretrained_aha(
|
| 240 |
+
tmp, torch_dtype=torch.float32, attn_implementation="eager"
|
| 241 |
+
)
|
| 242 |
+
self.assertEqual(loaded.config.aha_router_granularity, "token")
|
| 243 |
+
self.assertEqual(loaded.model.layers[0].self_attn.aha_router_outputs, 1)
|
| 244 |
+
|
| 245 |
+
def test_sparsity_tracker_records_native_and_effective_counts(self):
|
| 246 |
+
with tempfile.TemporaryDirectory() as tmp:
|
| 247 |
+
output = os.path.join(tmp, "sparsity.json")
|
| 248 |
+
with mock.patch.dict(os.environ, {"AHA_SPARSITY_STATS_PATH": output}):
|
| 249 |
+
tracker = aha_module._AHAInferenceSparsityTracker()
|
| 250 |
+
gate_hard = torch.tensor([[[1.0, 1.0], [0.0, 0.0]]])
|
| 251 |
+
gate_soft = torch.tensor([[[0.8, 0.8], [0.2, 0.2]]])
|
| 252 |
+
tracker.update(
|
| 253 |
+
gate_hard,
|
| 254 |
+
gate_soft,
|
| 255 |
+
layer_idx=0,
|
| 256 |
+
phase="prefill",
|
| 257 |
+
router_granularity="token",
|
| 258 |
+
native_router_width=1,
|
| 259 |
+
)
|
| 260 |
+
tracker.update(
|
| 261 |
+
gate_hard[:, :1],
|
| 262 |
+
gate_soft[:, :1],
|
| 263 |
+
layer_idx=0,
|
| 264 |
+
phase="decode",
|
| 265 |
+
router_granularity="token",
|
| 266 |
+
native_router_width=1,
|
| 267 |
+
)
|
| 268 |
+
tracker.write_stats()
|
| 269 |
+
payload = json.loads(Path(output).read_text(encoding="utf-8"))
|
| 270 |
+
self.assertEqual(payload["router_granularity"], "token")
|
| 271 |
+
self.assertEqual(payload["native_router_decisions"], 3)
|
| 272 |
+
self.assertEqual(payload["effective_router_decisions"], 6)
|
| 273 |
+
self.assertAlmostEqual(payload["sparsity"], 1 / 3)
|
| 274 |
+
self.assertEqual(payload["by_phase"]["decode"]["total_decisions"], 2)
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
if __name__ == "__main__":
|
| 278 |
+
unittest.main()
|
special_tokens_map.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"additional_special_tokens": [
|
| 3 |
+
"<|im_start|>",
|
| 4 |
+
"<|im_end|>",
|
| 5 |
+
"<|object_ref_start|>",
|
| 6 |
+
"<|object_ref_end|>",
|
| 7 |
+
"<|box_start|>",
|
| 8 |
+
"<|box_end|>",
|
| 9 |
+
"<|quad_start|>",
|
| 10 |
+
"<|quad_end|>",
|
| 11 |
+
"<|vision_start|>",
|
| 12 |
+
"<|vision_end|>",
|
| 13 |
+
"<|vision_pad|>",
|
| 14 |
+
"<|image_pad|>",
|
| 15 |
+
"<|video_pad|>"
|
| 16 |
+
],
|
| 17 |
+
"eos_token": {
|
| 18 |
+
"content": "<|im_end|>",
|
| 19 |
+
"lstrip": false,
|
| 20 |
+
"normalized": false,
|
| 21 |
+
"rstrip": false,
|
| 22 |
+
"single_word": false
|
| 23 |
+
},
|
| 24 |
+
"pad_token": {
|
| 25 |
+
"content": "<|endoftext|>",
|
| 26 |
+
"lstrip": false,
|
| 27 |
+
"normalized": false,
|
| 28 |
+
"rstrip": false,
|
| 29 |
+
"single_word": false
|
| 30 |
+
}
|
| 31 |
+
}
|