keepsloading commited on
Commit
46b9eea
·
verified ·
1 Parent(s): fba4278

Upload folder using huggingface_hub

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +3 -34
  2. .gitignore +4 -0
  3. README.md +123 -0
  4. added_tokens.json +28 -0
  5. chat_template.jinja +89 -0
  6. config.json +60 -0
  7. generation_config.json +13 -0
  8. merges.txt +0 -0
  9. model.safetensors +3 -0
  10. recipe/.pytest_cache/.gitignore +2 -0
  11. recipe/.pytest_cache/CACHEDIR.TAG +4 -0
  12. recipe/.pytest_cache/README.md +8 -0
  13. recipe/.pytest_cache/v/cache/nodeids +12 -0
  14. recipe/Dockerfile +16 -0
  15. recipe/data/am_distilled_long_mix/build_summary.json +18 -0
  16. recipe/data/am_distilled_long_mix/dataset_dict.json +1 -0
  17. recipe/data/am_distilled_long_mix/train/cache-e6708cbf54082730.arrow +3 -0
  18. recipe/data/am_distilled_long_mix/train/data-00000-of-00001.arrow +3 -0
  19. recipe/data/am_distilled_long_mix/train/dataset_info.json +152 -0
  20. recipe/data/am_distilled_long_mix/train/state.json +18 -0
  21. recipe/data/eval_inputs/helmet_icl_8k_n50_per_config.jsonl +0 -0
  22. recipe/data/eval_inputs/input_manifest.json +34 -0
  23. recipe/data/eval_inputs/mrcr_8k_2_4_8needle_n10_per_config.jsonl +0 -0
  24. recipe/duo_train.py +818 -0
  25. recipe/dynamic_duo_train.py +494 -0
  26. recipe/manifest.json +13 -0
  27. recipe/modeling_aha_qwen3.py +1226 -0
  28. recipe/protocol.json +83 -0
  29. recipe/requirements.txt +12 -0
  30. recipe/router_training_utils.py +86 -0
  31. recipe/scripts/eval_cell.sh +82 -0
  32. recipe/scripts/eval_qwen_external_longctx_pilot.py +275 -0
  33. recipe/scripts/eval_sweep.sh +64 -0
  34. recipe/scripts/evaluate.sh +20 -0
  35. recipe/scripts/export_tuned_vanilla_aha_hotstart.py +140 -0
  36. recipe/scripts/reproduce.sh +45 -0
  37. recipe/scripts/summarize_qwen1p7b_router_granularity_20260714.py +403 -0
  38. recipe/scripts/train_aha.sh +4 -0
  39. recipe/scripts/train_both.sh +30 -0
  40. recipe/scripts/train_l2a_style.sh +4 -0
  41. recipe/scripts/train_one.sh +127 -0
  42. recipe/scripts/validate_release.py +72 -0
  43. recipe/sft.py +642 -0
  44. recipe/smoke_test.py +122 -0
  45. recipe/source_training/all_results.json +9 -0
  46. recipe/source_training/train_results.json +9 -0
  47. recipe/source_training/trainer_state.json +0 -0
  48. recipe/tests/__init__.py +1 -0
  49. recipe/tests/test_router_granularity.py +278 -0
  50. 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
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
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
+ }