Add files using upload-large-folder tool
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- code/align/assets/tokenizer/goedel-prover-v2-8b/PROVENANCE.md +7 -0
- code/align/assets/tokenizer/goedel-prover-v2-8b/special_tokens_map.json +31 -0
- code/align/assets/tokenizer/goedel-prover-v2-8b/tokenizer_config.json +241 -0
- code/align/model/artifacts/logit_noise_floor.json +20 -0
- code/align/model/artifacts/sdpa_baseline.json +17 -0
- code/align/model/artifacts/triton_fa2_equivalence.json +25 -0
- code/align/rl/envs/__init__.py +35 -0
- code/align/rl/envs/lean_multiturn.py +538 -0
- code/align/rl/envs/subgoal_repair.py +694 -0
- code/align/rl/lean_rl.py +659 -0
- code/align/rl/lean_rl_run.py +716 -0
- code/align/rl/lean_rl_testing.py +174 -0
- code/align/rl/multiturn_source.py +454 -0
- code/align/rl/ops.py +99 -0
- code/align/rl/rewards.py +98 -0
- code/align/rl/stage_config.py +253 -0
- code/align/rl/tripwire.py +202 -0
- code/align/rl/types.py +84 -0
- code/align/rollout/__init__.py +74 -0
- code/align/rollout/client.py +234 -0
- code/align/rollout/engine.py +674 -0
- code/align/rollout/native.py +372 -0
- code/align/rollout/paged.py +494 -0
- code/align/rollout/protocol.py +227 -0
- code/align/rollout/tp_engine.py +169 -0
- code/align/tokenizer/__init__.py +85 -0
- code/align/tokenizer/_qwen2_unicode.py +875 -0
- code/align/tokenizer/bytelevel.py +693 -0
- code/align/tokenizer/core.py +615 -0
- code/align/train/__init__.py +16 -0
- code/align/train/adamw8bit.py +630 -0
- code/align/train/ckpt_format.py +124 -0
- code/align/train/dpo_loss.py +237 -0
- code/align/train/dpo_stage.py +1362 -0
- code/align/train/fsdp.py +124 -0
- code/align/train/lora_scale.py +110 -0
- code/align/train/moe_module.py +691 -0
- code/align/train/offload_adamw.py +680 -0
- code/align/train/probes.py +627 -0
- code/align/train/quant8.py +193 -0
- code/align/train/qwen3_stage.py +466 -0
- code/align/train/ref_cache.py +834 -0
- code/align/train/sft_loss.py +150 -0
- code/align/train/sharded_ckpt.py +1285 -0
- code/align/train/stage_gates.py +1002 -0
- code/align/train/stage_smoke.py +530 -0
- code/align/train/train_module.py +283 -0
- code/align/train/trainer.py +0 -0
- code/align/utils/__init__.py +0 -0
- code/align/utils/dist.py +391 -0
code/align/assets/tokenizer/goedel-prover-v2-8b/PROVENANCE.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Goedel-LM/Goedel-Prover-V2-8B tokenizer files (Qwen3 byte-level BPE; Apache-2.0), vendored so
|
| 2 |
+
P2 tokenisation does not need the vLLM venv or the 16 GB model snapshot. Copied byte-for-byte
|
| 3 |
+
from the HF snapshot dfd02e6271a58375dfbf3ece0175277cf6b6a89a. sha256:
|
| 4 |
+
tokenizer.json aeb13307a71acd8fe81861d94ad54ab689df773318809eed3cbe794b4492dae4
|
| 5 |
+
tokenizer_config.json 5d27a191a7ba6f93bcc2a8e78bc633764bbaaee263e9f3d9225e2c8621a5dd72
|
| 6 |
+
special_tokens_map.json 76862e765266b85aa9459767e33cbaf13970f327a0e88d1c65846c2ddd3a1ecd
|
| 7 |
+
Tokenizer only: no config.json, no weights, no vocab.json/merges.txt (redundant with tokenizer.json).
|
code/align/assets/tokenizer/goedel-prover-v2-8b/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 |
+
}
|
code/align/assets/tokenizer/goedel-prover-v2-8b/tokenizer_config.json
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"add_bos_token": false,
|
| 3 |
+
"add_prefix_space": false,
|
| 4 |
+
"added_tokens_decoder": {
|
| 5 |
+
"151643": {
|
| 6 |
+
"content": "<|endoftext|>",
|
| 7 |
+
"lstrip": false,
|
| 8 |
+
"normalized": false,
|
| 9 |
+
"rstrip": false,
|
| 10 |
+
"single_word": false,
|
| 11 |
+
"special": true
|
| 12 |
+
},
|
| 13 |
+
"151644": {
|
| 14 |
+
"content": "<|im_start|>",
|
| 15 |
+
"lstrip": false,
|
| 16 |
+
"normalized": false,
|
| 17 |
+
"rstrip": false,
|
| 18 |
+
"single_word": false,
|
| 19 |
+
"special": true
|
| 20 |
+
},
|
| 21 |
+
"151645": {
|
| 22 |
+
"content": "<|im_end|>",
|
| 23 |
+
"lstrip": false,
|
| 24 |
+
"normalized": false,
|
| 25 |
+
"rstrip": false,
|
| 26 |
+
"single_word": false,
|
| 27 |
+
"special": true
|
| 28 |
+
},
|
| 29 |
+
"151646": {
|
| 30 |
+
"content": "<|object_ref_start|>",
|
| 31 |
+
"lstrip": false,
|
| 32 |
+
"normalized": false,
|
| 33 |
+
"rstrip": false,
|
| 34 |
+
"single_word": false,
|
| 35 |
+
"special": true
|
| 36 |
+
},
|
| 37 |
+
"151647": {
|
| 38 |
+
"content": "<|object_ref_end|>",
|
| 39 |
+
"lstrip": false,
|
| 40 |
+
"normalized": false,
|
| 41 |
+
"rstrip": false,
|
| 42 |
+
"single_word": false,
|
| 43 |
+
"special": true
|
| 44 |
+
},
|
| 45 |
+
"151648": {
|
| 46 |
+
"content": "<|box_start|>",
|
| 47 |
+
"lstrip": false,
|
| 48 |
+
"normalized": false,
|
| 49 |
+
"rstrip": false,
|
| 50 |
+
"single_word": false,
|
| 51 |
+
"special": true
|
| 52 |
+
},
|
| 53 |
+
"151649": {
|
| 54 |
+
"content": "<|box_end|>",
|
| 55 |
+
"lstrip": false,
|
| 56 |
+
"normalized": false,
|
| 57 |
+
"rstrip": false,
|
| 58 |
+
"single_word": false,
|
| 59 |
+
"special": true
|
| 60 |
+
},
|
| 61 |
+
"151650": {
|
| 62 |
+
"content": "<|quad_start|>",
|
| 63 |
+
"lstrip": false,
|
| 64 |
+
"normalized": false,
|
| 65 |
+
"rstrip": false,
|
| 66 |
+
"single_word": false,
|
| 67 |
+
"special": true
|
| 68 |
+
},
|
| 69 |
+
"151651": {
|
| 70 |
+
"content": "<|quad_end|>",
|
| 71 |
+
"lstrip": false,
|
| 72 |
+
"normalized": false,
|
| 73 |
+
"rstrip": false,
|
| 74 |
+
"single_word": false,
|
| 75 |
+
"special": true
|
| 76 |
+
},
|
| 77 |
+
"151652": {
|
| 78 |
+
"content": "<|vision_start|>",
|
| 79 |
+
"lstrip": false,
|
| 80 |
+
"normalized": false,
|
| 81 |
+
"rstrip": false,
|
| 82 |
+
"single_word": false,
|
| 83 |
+
"special": true
|
| 84 |
+
},
|
| 85 |
+
"151653": {
|
| 86 |
+
"content": "<|vision_end|>",
|
| 87 |
+
"lstrip": false,
|
| 88 |
+
"normalized": false,
|
| 89 |
+
"rstrip": false,
|
| 90 |
+
"single_word": false,
|
| 91 |
+
"special": true
|
| 92 |
+
},
|
| 93 |
+
"151654": {
|
| 94 |
+
"content": "<|vision_pad|>",
|
| 95 |
+
"lstrip": false,
|
| 96 |
+
"normalized": false,
|
| 97 |
+
"rstrip": false,
|
| 98 |
+
"single_word": false,
|
| 99 |
+
"special": true
|
| 100 |
+
},
|
| 101 |
+
"151655": {
|
| 102 |
+
"content": "<|image_pad|>",
|
| 103 |
+
"lstrip": false,
|
| 104 |
+
"normalized": false,
|
| 105 |
+
"rstrip": false,
|
| 106 |
+
"single_word": false,
|
| 107 |
+
"special": true
|
| 108 |
+
},
|
| 109 |
+
"151656": {
|
| 110 |
+
"content": "<|video_pad|>",
|
| 111 |
+
"lstrip": false,
|
| 112 |
+
"normalized": false,
|
| 113 |
+
"rstrip": false,
|
| 114 |
+
"single_word": false,
|
| 115 |
+
"special": true
|
| 116 |
+
},
|
| 117 |
+
"151657": {
|
| 118 |
+
"content": "<tool_call>",
|
| 119 |
+
"lstrip": false,
|
| 120 |
+
"normalized": false,
|
| 121 |
+
"rstrip": false,
|
| 122 |
+
"single_word": false,
|
| 123 |
+
"special": false
|
| 124 |
+
},
|
| 125 |
+
"151658": {
|
| 126 |
+
"content": "</tool_call>",
|
| 127 |
+
"lstrip": false,
|
| 128 |
+
"normalized": false,
|
| 129 |
+
"rstrip": false,
|
| 130 |
+
"single_word": false,
|
| 131 |
+
"special": false
|
| 132 |
+
},
|
| 133 |
+
"151659": {
|
| 134 |
+
"content": "<|fim_prefix|>",
|
| 135 |
+
"lstrip": false,
|
| 136 |
+
"normalized": false,
|
| 137 |
+
"rstrip": false,
|
| 138 |
+
"single_word": false,
|
| 139 |
+
"special": false
|
| 140 |
+
},
|
| 141 |
+
"151660": {
|
| 142 |
+
"content": "<|fim_middle|>",
|
| 143 |
+
"lstrip": false,
|
| 144 |
+
"normalized": false,
|
| 145 |
+
"rstrip": false,
|
| 146 |
+
"single_word": false,
|
| 147 |
+
"special": false
|
| 148 |
+
},
|
| 149 |
+
"151661": {
|
| 150 |
+
"content": "<|fim_suffix|>",
|
| 151 |
+
"lstrip": false,
|
| 152 |
+
"normalized": false,
|
| 153 |
+
"rstrip": false,
|
| 154 |
+
"single_word": false,
|
| 155 |
+
"special": false
|
| 156 |
+
},
|
| 157 |
+
"151662": {
|
| 158 |
+
"content": "<|fim_pad|>",
|
| 159 |
+
"lstrip": false,
|
| 160 |
+
"normalized": false,
|
| 161 |
+
"rstrip": false,
|
| 162 |
+
"single_word": false,
|
| 163 |
+
"special": false
|
| 164 |
+
},
|
| 165 |
+
"151663": {
|
| 166 |
+
"content": "<|repo_name|>",
|
| 167 |
+
"lstrip": false,
|
| 168 |
+
"normalized": false,
|
| 169 |
+
"rstrip": false,
|
| 170 |
+
"single_word": false,
|
| 171 |
+
"special": false
|
| 172 |
+
},
|
| 173 |
+
"151664": {
|
| 174 |
+
"content": "<|file_sep|>",
|
| 175 |
+
"lstrip": false,
|
| 176 |
+
"normalized": false,
|
| 177 |
+
"rstrip": false,
|
| 178 |
+
"single_word": false,
|
| 179 |
+
"special": false
|
| 180 |
+
},
|
| 181 |
+
"151665": {
|
| 182 |
+
"content": "<tool_response>",
|
| 183 |
+
"lstrip": false,
|
| 184 |
+
"normalized": false,
|
| 185 |
+
"rstrip": false,
|
| 186 |
+
"single_word": false,
|
| 187 |
+
"special": false
|
| 188 |
+
},
|
| 189 |
+
"151666": {
|
| 190 |
+
"content": "</tool_response>",
|
| 191 |
+
"lstrip": false,
|
| 192 |
+
"normalized": false,
|
| 193 |
+
"rstrip": false,
|
| 194 |
+
"single_word": false,
|
| 195 |
+
"special": false
|
| 196 |
+
},
|
| 197 |
+
"151667": {
|
| 198 |
+
"content": "<think>",
|
| 199 |
+
"lstrip": false,
|
| 200 |
+
"normalized": false,
|
| 201 |
+
"rstrip": false,
|
| 202 |
+
"single_word": false,
|
| 203 |
+
"special": false
|
| 204 |
+
},
|
| 205 |
+
"151668": {
|
| 206 |
+
"content": "</think>",
|
| 207 |
+
"lstrip": false,
|
| 208 |
+
"normalized": false,
|
| 209 |
+
"rstrip": false,
|
| 210 |
+
"single_word": false,
|
| 211 |
+
"special": false
|
| 212 |
+
}
|
| 213 |
+
},
|
| 214 |
+
"additional_special_tokens": [
|
| 215 |
+
"<|im_start|>",
|
| 216 |
+
"<|im_end|>",
|
| 217 |
+
"<|object_ref_start|>",
|
| 218 |
+
"<|object_ref_end|>",
|
| 219 |
+
"<|box_start|>",
|
| 220 |
+
"<|box_end|>",
|
| 221 |
+
"<|quad_start|>",
|
| 222 |
+
"<|quad_end|>",
|
| 223 |
+
"<|vision_start|>",
|
| 224 |
+
"<|vision_end|>",
|
| 225 |
+
"<|vision_pad|>",
|
| 226 |
+
"<|image_pad|>",
|
| 227 |
+
"<|video_pad|>"
|
| 228 |
+
],
|
| 229 |
+
"bos_token": null,
|
| 230 |
+
"chat_template": "{%- if tools %}\n {{- '<|im_start|>system\\n' }}\n {%- if messages[0].role == 'system' %}\n {{- messages[0].content + '\\n\\n' }}\n {%- endif %}\n {{- \"# 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>\" }}\n {%- for tool in tools %}\n {{- \"\\n\" }}\n {{- tool | tojson }}\n {%- endfor %}\n {{- \"\\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\" }}\n{%- else %}\n {%- if messages[0].role == 'system' %}\n {{- '<|im_start|>system\\n' + messages[0].content + '<|im_end|>\\n' }}\n {%- endif %}\n{%- endif %}\n{%- set ns = namespace(multi_step_tool=true, last_query_index=messages|length - 1) %}\n{%- for message in messages[::-1] %}\n {%- set index = (messages|length - 1) - loop.index0 %}\n {%- 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>')) %}\n {%- set ns.multi_step_tool = false %}\n {%- set ns.last_query_index = index %}\n {%- endif %}\n{%- endfor %}\n{%- for message in messages %}\n {%- if message.content is string %}\n {%- set content = message.content %}\n {%- else %}\n {%- set content = '' %}\n {%- endif %}\n {%- if (message.role == \"user\") or (message.role == \"system\" and not loop.first) %}\n {{- '<|im_start|>' + message.role + '\\n' + content + '<|im_end|>' + '\\n' }}\n {%- elif message.role == \"assistant\" %}\n {%- set reasoning_content = '' %}\n {%- if message.reasoning_content is string %}\n {%- set reasoning_content = message.reasoning_content %}\n {%- else %}\n {%- if '</think>' in content %}\n {%- set reasoning_content = content.split('</think>')[0].rstrip('\\n').split('<think>')[-1].lstrip('\\n') %}\n {%- set content = content.split('</think>')[-1].lstrip('\\n') %}\n {%- endif %}\n {%- endif %}\n {%- if loop.index0 > ns.last_query_index %}\n {%- if loop.last or (not loop.last and reasoning_content) %}\n {{- '<|im_start|>' + message.role + '\\n<think>\\n' + reasoning_content.strip('\\n') + '\\n</think>\\n\\n' + content.lstrip('\\n') }}\n {%- else %}\n {{- '<|im_start|>' + message.role + '\\n' + content }}\n {%- endif %}\n {%- else %}\n {{- '<|im_start|>' + message.role + '\\n' + content }}\n {%- endif %}\n {%- if message.tool_calls %}\n {%- for tool_call in message.tool_calls %}\n {%- if (loop.first and content) or (not loop.first) %}\n {{- '\\n' }}\n {%- endif %}\n {%- if tool_call.function %}\n {%- set tool_call = tool_call.function %}\n {%- endif %}\n {{- '<tool_call>\\n{\"name\": \"' }}\n {{- tool_call.name }}\n {{- '\", \"arguments\": ' }}\n {%- if tool_call.arguments is string %}\n {{- tool_call.arguments }}\n {%- else %}\n {{- tool_call.arguments | tojson }}\n {%- endif %}\n {{- '}\\n</tool_call>' }}\n {%- endfor %}\n {%- endif %}\n {{- '<|im_end|>\\n' }}\n {%- elif message.role == \"tool\" %}\n {%- if loop.first or (messages[loop.index0 - 1].role != \"tool\") %}\n {{- '<|im_start|>user' }}\n {%- endif %}\n {{- '\\n<tool_response>\\n' }}\n {{- content }}\n {{- '\\n</tool_response>' }}\n {%- if loop.last or (messages[loop.index0 + 1].role != \"tool\") %}\n {{- '<|im_end|>\\n' }}\n {%- endif %}\n {%- endif %}\n{%- endfor %}\n{%- if add_generation_prompt %}\n {{- '<|im_start|>assistant\\n' }}\n {%- if enable_thinking is defined and enable_thinking is false %}\n {{- '<think>\\n\\n</think>\\n\\n' }}\n {%- endif %}\n{%- endif %}",
|
| 231 |
+
"clean_up_tokenization_spaces": false,
|
| 232 |
+
"eos_token": "<|im_end|>",
|
| 233 |
+
"errors": "replace",
|
| 234 |
+
"extra_special_tokens": {},
|
| 235 |
+
"model_max_length": 131072,
|
| 236 |
+
"pad_token": "<|endoftext|>",
|
| 237 |
+
"padding_side": "right",
|
| 238 |
+
"split_special_tokens": false,
|
| 239 |
+
"tokenizer_class": "Qwen2Tokenizer",
|
| 240 |
+
"unk_token": null
|
| 241 |
+
}
|
code/align/model/artifacts/logit_noise_floor.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"2": {
|
| 3 |
+
"max_abs_dlogit": 0.75586,
|
| 4 |
+
"mean_abs_dlogit": 0.015027,
|
| 5 |
+
"argmax_flips": 1,
|
| 6 |
+
"positions": 794
|
| 7 |
+
},
|
| 8 |
+
"4": {
|
| 9 |
+
"max_abs_dlogit": 0.78516,
|
| 10 |
+
"mean_abs_dlogit": 0.01436,
|
| 11 |
+
"argmax_flips": 1,
|
| 12 |
+
"positions": 794
|
| 13 |
+
},
|
| 14 |
+
"8": {
|
| 15 |
+
"max_abs_dlogit": 0.78516,
|
| 16 |
+
"mean_abs_dlogit": 0.014431,
|
| 17 |
+
"argmax_flips": 1,
|
| 18 |
+
"positions": 794
|
| 19 |
+
}
|
| 20 |
+
}
|
code/align/model/artifacts/sdpa_baseline.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"512": {
|
| 3 |
+
"FLASH_ATTENTION": 0.18939885194413364,
|
| 4 |
+
"EFFICIENT_ATTENTION": NaN,
|
| 5 |
+
"MATH": 4.349638050189242
|
| 6 |
+
},
|
| 7 |
+
"1024": {
|
| 8 |
+
"FLASH_ATTENTION": 0.6236543995328248,
|
| 9 |
+
"EFFICIENT_ATTENTION": NaN,
|
| 10 |
+
"MATH": 16.252079099649563
|
| 11 |
+
},
|
| 12 |
+
"2048": {
|
| 13 |
+
"FLASH_ATTENTION": 2.281787298852578,
|
| 14 |
+
"EFFICIENT_ATTENTION": NaN,
|
| 15 |
+
"MATH": 61.56193719943985
|
| 16 |
+
}
|
| 17 |
+
}
|
code/align/model/artifacts/triton_fa2_equivalence.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"environment": {
|
| 3 |
+
"compute_capability": "8.6",
|
| 4 |
+
"device_name": "NVIDIA A10G",
|
| 5 |
+
"torch": "2.11.0+cu128",
|
| 6 |
+
"triton": "3.6.0"
|
| 7 |
+
},
|
| 8 |
+
"kernel_sha256": "5f2cbe2088a7e583895268c59895366e39a4eb79d4dc8b719cbca2e57c2eed4b",
|
| 9 |
+
"schema_version": 1,
|
| 10 |
+
"shapes_covered": [
|
| 11 |
+
"gqa (n_q_heads, n_kv_heads) in [(8,2), (32,8)] plus MHA-degenerate (4,4)",
|
| 12 |
+
"head_dim in [64, 128]",
|
| 13 |
+
"seq in [192, 256, 512, 1000] (block-divisible AND non-divisible)",
|
| 14 |
+
"causal and non-causal",
|
| 15 |
+
"key_padding_mask, right-padded, non-block-aligned",
|
| 16 |
+
"every autotune config individually pinned (fwd + both bwd kernels)",
|
| 17 |
+
"dtype bfloat16 only (fp16/fp32 rejected)"
|
| 18 |
+
],
|
| 19 |
+
"tolerances": {
|
| 20 |
+
"absolute_floor": 0.02,
|
| 21 |
+
"policy": "max |err| vs fp32 reference <= max(3.0 x torch-SDPA-bf16 error, 2e-2)",
|
| 22 |
+
"slack_over_sdpa": 3.0
|
| 23 |
+
},
|
| 24 |
+
"verified_at": "2026-08-18T13:18:04+00:00"
|
| 25 |
+
}
|
code/align/rl/envs/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""RL environments (opt-in; nothing in `align.rl` imports this package).
|
| 2 |
+
|
| 3 |
+
lean_multiturn.py multi-turn Lean prover episodes with Goedel correction feedback,
|
| 4 |
+
multi-turn loss masks and per-turn advantage assignment.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from .lean_multiturn import (
|
| 8 |
+
EpisodeRecord,
|
| 9 |
+
EpisodeSpec,
|
| 10 |
+
GenRequest,
|
| 11 |
+
Generation,
|
| 12 |
+
LeanMultiTurnConfig,
|
| 13 |
+
LeanMultiTurnEnv,
|
| 14 |
+
PrefixBreak,
|
| 15 |
+
Problem,
|
| 16 |
+
SandboxNotWired,
|
| 17 |
+
TrainingRow,
|
| 18 |
+
TurnRecord,
|
| 19 |
+
assign_advantages,
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
__all__ = [
|
| 23 |
+
"EpisodeRecord",
|
| 24 |
+
"EpisodeSpec",
|
| 25 |
+
"GenRequest",
|
| 26 |
+
"Generation",
|
| 27 |
+
"LeanMultiTurnConfig",
|
| 28 |
+
"LeanMultiTurnEnv",
|
| 29 |
+
"PrefixBreak",
|
| 30 |
+
"Problem",
|
| 31 |
+
"SandboxNotWired",
|
| 32 |
+
"TrainingRow",
|
| 33 |
+
"TurnRecord",
|
| 34 |
+
"assign_advantages",
|
| 35 |
+
]
|
code/align/rl/envs/lean_multiturn.py
ADDED
|
@@ -0,0 +1,538 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Multi-turn Lean prover environment (P4-B): proof attempt -> compiler feedback -> retry.
|
| 2 |
+
|
| 3 |
+
An EPISODE is up to K turns on one formal statement. Turn 0 is the Goedel round-0 prompt.
|
| 4 |
+
If the verifier rejects the proof with compile errors, turn k+1's prompt is the Goedel
|
| 5 |
+
CORRECTION prompt: the history, the failed attempt as an assistant message, and a user
|
| 6 |
+
message carrying the `<error></error>`-annotated compiler output. The episode ends at the
|
| 7 |
+
first verified turn, at K turns, or at the first rejection that feedback cannot repair.
|
| 8 |
+
|
| 9 |
+
Everything below exists to prevent one named failure; each is pinned by a test.
|
| 10 |
+
|
| 11 |
+
* PROMPT DRIFT. The prompts are NOT re-expressed here. `extras/vllm_rollout/lean_prompts.py`
|
| 12 |
+
(byte-faithful to Goedel-Prover-V2) is loaded BY FILE PATH, read-only, the way
|
| 13 |
+
tests/test_lean_gen_contract.py and scripts/p2/p2_common.py do. A second copy of the
|
| 14 |
+
correction template would drift by one newline and silently change the policy sampled.
|
| 15 |
+
|
| 16 |
+
* LOSS ON FEEDBACK TOKENS. The compiler's error text is environment output, not policy
|
| 17 |
+
output. If it carried loss the model would be trained to *write* error messages, and the
|
| 18 |
+
gradient would reward predicting them. Rows carry `response_mask` that is 1 ONLY on tokens
|
| 19 |
+
the generator returned; prompt, chat-template glue and feedback are 0. `turn_ids` labels
|
| 20 |
+
each token with its turn (-1 unlabeled) so tests and the loss can audit it.
|
| 21 |
+
|
| 22 |
+
* RE-TOKENIZATION DRIFT. Token ids are never re-derived from text for generated tokens.
|
| 23 |
+
layout="concatenated" builds ONE row per episode: context_k = context_{k-1} + generated_{k-1}
|
| 24 |
+
+ encode(template delta). That is only valid when the chat template renders turn k's
|
| 25 |
+
prompt as a pure extension of (turn k-1's prompt + completion text); templates that rewrite
|
| 26 |
+
earlier assistant content (Qwen3 strips old <think> blocks) break it. We CHECK the prefix
|
| 27 |
+
property and raise `PrefixBreak` rather than train on tokens the policy never saw.
|
| 28 |
+
layout="per_turn" (the default) emits one row per turn from that turn's exact prompt ids;
|
| 29 |
+
it is always exact, at the price of re-running the shared prefix.
|
| 30 |
+
|
| 31 |
+
* STATEMENT REWRITING IS NOT A SOLVE. `problem_check` re-inserts the ORIGINAL statement over
|
| 32 |
+
whatever theorem the model displayed, so a model that states an easier theorem and proves
|
| 33 |
+
that is *accepted* if its tactics happen to close the original. Goedel's evaluation credits
|
| 34 |
+
it; a TRAINING reward must not (it teaches the model to misstate the problem). Before the
|
| 35 |
+
verifier is consulted, `scripts/p2/p2_common.displayed_statement_matches` (loaded by path)
|
| 36 |
+
must hold, else the turn is terminal with reason `statement_rewrite` and reward 0.
|
| 37 |
+
|
| 38 |
+
* TRUNCATION IS NOT A SUBMISSION. A generation that hit the length limit is rejected
|
| 39 |
+
(`truncated`) even if its plan contains an earlier closed code fence; the final answer was
|
| 40 |
+
cut off, and verifying a stale fence rewards an accident.
|
| 41 |
+
|
| 42 |
+
* SPARSE REWARD, NO LENGTH IN THE LOSS. Episode reward = turn_discount ** solved_turn
|
| 43 |
+
(0 if never solved) plus an optional DAPO soft-overlong term on TOTAL generated tokens,
|
| 44 |
+
weighted by `overlong_weight`. The penalty lives in reward composition (reusing
|
| 45 |
+
`align.rl.rewards.soft_overlong_punishment`), never in the loss.
|
| 46 |
+
|
| 47 |
+
* CREDIT ASSIGNMENT. advantage_mode="episode": every generated token gets the Dr.GRPO
|
| 48 |
+
group-centered episode reward (the repo's `compute_group_advantages`, reused).
|
| 49 |
+
advantage_mode="per_turn": turn t gets G_t - mean_group(G_t) with
|
| 50 |
+
G_t = sum_{s>=t} credit_gamma^(s-t) * u_s, u_s the turn's own outcome reward. With
|
| 51 |
+
credit_gamma=0 a failed first attempt gets no credit for the repair that followed (the
|
| 52 |
+
failure mode of episode-level sharing: rewarding the bad first draft); with gamma>0 it
|
| 53 |
+
shares in it, because its error context is what made the repair possible. The baseline for
|
| 54 |
+
turn t averages only episodes that HAVE a turn t.
|
| 55 |
+
|
| 56 |
+
* DEAD VERIFIER != ZERO REWARD. `LeanInfraError` from the verifier propagates unchanged. A
|
| 57 |
+
verifier that is dying SOFTLY (every proof comes back `crash`/`protocol`/`memory`/
|
| 58 |
+
`output_too_large` instead of raising) would otherwise be turned by this env into
|
| 59 |
+
`rejected:crash`, reward 0, a batch of zeros that reads as "the model got worse".
|
| 60 |
+
`LeanReward.accuracy_batch` refuses that; the env bypasses `LeanReward`, so it carries the
|
| 61 |
+
same guard: once at least `min_infra_batch` verifications have run in a `run_group`, a
|
| 62 |
+
cumulative infra-failure share above `max_infra_fraction` raises `LeanInfraError`.
|
| 63 |
+
|
| 64 |
+
* ENGINE FINISH REASONS. The P3 engine reports `eos|stop_token|stop_string|max_new_tokens|
|
| 65 |
+
max_seq_len`, the lean_gen contract reports `stop|length`. Only `length` used to count as
|
| 66 |
+
truncated, so an engine-capped generation (`max_new_tokens`/`max_seq_len`) was VERIFIED as
|
| 67 |
+
if it were a finished answer. Both vocabularies are accepted; an unknown reason raises
|
| 68 |
+
(a new engine reason must be classified by a human, not guessed).
|
| 69 |
+
|
| 70 |
+
* CONTEXT BUDGET. Compiler feedback is unbounded and each repair turn re-sends the whole
|
| 71 |
+
history. With `max_prompt_tokens` set, a repair prompt over the budget ends the episode
|
| 72 |
+
(`context_overflow`, reward 0 for the unsolved episode) instead of being handed to an
|
| 73 |
+
engine that would refuse it (or silently truncate the prompt from the left).
|
| 74 |
+
|
| 75 |
+
* EPISODE KEY COLLISIONS. `generate` adapters key seeds / KV state by `episode_key`; two
|
| 76 |
+
specs with the same (problem_id, sample_idx, statement) would share one stream. Refused.
|
| 77 |
+
|
| 78 |
+
Sandbox: `LeanMultiTurnConfig.sandbox=True` is a pass-through request for the
|
| 79 |
+
`align/verify/lean/sandbox.py` REPL. The pool is not wired to it yet, so the env raises
|
| 80 |
+
`SandboxNotWired` unless the pool itself advertises a truthy `sandbox` attribute.
|
| 81 |
+
|
| 82 |
+
Error positions: the Goedel format needs REPL message dicts (`pos`, `endPos`, `data`)
|
| 83 |
+
indexing `compile_view(code)`. `StrictResult.diagnostics["repl_errors"]` is the contract for
|
| 84 |
+
those; `StrictVerifier` does not populate it yet (it keeps only 3 truncated message strings).
|
| 85 |
+
Absent positions the env degrades per `unpositioned_feedback` ("message_only" renders the
|
| 86 |
+
messages with no code excerpt; "raise" refuses) instead of inventing an `<error>` location.
|
| 87 |
+
|
| 88 |
+
stdlib-only (+ align.rl.rewards / align.rl.advantages); no torch tensors are built here.
|
| 89 |
+
"""
|
| 90 |
+
|
| 91 |
+
from __future__ import annotations
|
| 92 |
+
|
| 93 |
+
import hashlib
|
| 94 |
+
import importlib.util
|
| 95 |
+
import json
|
| 96 |
+
from dataclasses import asdict, dataclass, field
|
| 97 |
+
from pathlib import Path
|
| 98 |
+
from typing import Any, Callable, Optional, Sequence
|
| 99 |
+
|
| 100 |
+
from align.rl.advantages import compute_group_advantages
|
| 101 |
+
from align.rl.rewards import soft_overlong_punishment
|
| 102 |
+
|
| 103 |
+
_REPO = Path(__file__).resolve().parents[3]
|
| 104 |
+
|
| 105 |
+
_MODS: dict[str, Any] = {}
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def _load_by_path(key: str, rel: str):
|
| 109 |
+
if key not in _MODS:
|
| 110 |
+
spec = importlib.util.spec_from_file_location(key, _REPO / rel)
|
| 111 |
+
mod = importlib.util.module_from_spec(spec)
|
| 112 |
+
spec.loader.exec_module(mod)
|
| 113 |
+
_MODS[key] = mod
|
| 114 |
+
return _MODS[key]
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def _lean_prompts():
|
| 118 |
+
return _load_by_path("align_envs_lean_prompts", "extras/vllm_rollout/lean_prompts.py")
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def _p2_common():
|
| 122 |
+
return _load_by_path("align_envs_p2_common", "scripts/p2/p2_common.py")
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
#: finish reasons that mean "the generator ended on its own" / "the generator was cut off"
|
| 126 |
+
STOP_REASONS = frozenset({"stop", "eos", "stop_token", "stop_string"})
|
| 127 |
+
TRUNCATED_REASONS = frozenset({"length", "max_new_tokens", "max_seq_len"})
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
class SandboxNotWired(RuntimeError):
|
| 131 |
+
"""sandbox=True was requested but the verifier pool has no sandboxed REPL."""
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
class PrefixBreak(RuntimeError):
|
| 135 |
+
"""The chat template did not render turn k's prompt as an extension of turn k-1's
|
| 136 |
+
prompt + completion, so one concatenated token row would not be what the policy saw."""
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
@dataclass(frozen=True)
|
| 140 |
+
class Problem:
|
| 141 |
+
problem_id: str
|
| 142 |
+
formal_statement: str
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
@dataclass(frozen=True)
|
| 146 |
+
class EpisodeSpec:
|
| 147 |
+
problem: Problem
|
| 148 |
+
sample_idx: int = 0
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
@dataclass(frozen=True)
|
| 152 |
+
class GenRequest:
|
| 153 |
+
episode_key: str
|
| 154 |
+
turn: int
|
| 155 |
+
prompt_ids: tuple
|
| 156 |
+
prompt_text: str
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
@dataclass(frozen=True)
|
| 160 |
+
class Generation:
|
| 161 |
+
"""`token_ids`: exactly what the engine sampled (incl. a trailing EOS if sampled).
|
| 162 |
+
`text`: the decoded completion WITHOUT the EOS string. `logprobs`: per generated token."""
|
| 163 |
+
|
| 164 |
+
token_ids: tuple
|
| 165 |
+
text: str
|
| 166 |
+
logprobs: Optional[tuple] = None
|
| 167 |
+
finish_reason: str = "stop"
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
@dataclass(frozen=True)
|
| 171 |
+
class LeanMultiTurnConfig:
|
| 172 |
+
max_turns: int = 2
|
| 173 |
+
layout: str = "per_turn" # "per_turn" | "concatenated"
|
| 174 |
+
turn_discount: float = 1.0
|
| 175 |
+
overlong_max_len: Optional[int] = None
|
| 176 |
+
overlong_cache_len: Optional[int] = None
|
| 177 |
+
overlong_weight: float = 1.0
|
| 178 |
+
advantage_mode: str = "episode" # "episode" | "per_turn"
|
| 179 |
+
credit_gamma: float = 0.0
|
| 180 |
+
retry_reasons: tuple = ("compile_error",)
|
| 181 |
+
reject_truncated: bool = True
|
| 182 |
+
#: ABLATION ONLY: accept a proof whose displayed theorem differs from the statement.
|
| 183 |
+
allow_statement_rewrite: bool = False
|
| 184 |
+
error_thres: bool = True
|
| 185 |
+
unpositioned_feedback: str = "message_only" # "message_only" | "raise"
|
| 186 |
+
sandbox: bool = False
|
| 187 |
+
#: ceiling on the repair prompt length in tokens (None = unbounded, the old behaviour)
|
| 188 |
+
max_prompt_tokens: Optional[int] = None
|
| 189 |
+
#: soft-infra-failure guard (see module docstring); same defaults as LeanReward
|
| 190 |
+
max_infra_fraction: float = 0.5
|
| 191 |
+
min_infra_batch: int = 8
|
| 192 |
+
eos_token_id: Optional[int] = None
|
| 193 |
+
eos_text: str = "<|im_end|>"
|
| 194 |
+
|
| 195 |
+
def __post_init__(self) -> None:
|
| 196 |
+
if self.max_turns < 1:
|
| 197 |
+
raise ValueError(f"max_turns must be >= 1, got {self.max_turns}")
|
| 198 |
+
if self.layout not in ("per_turn", "concatenated"):
|
| 199 |
+
raise ValueError(f"layout must be 'per_turn' or 'concatenated', got {self.layout!r}")
|
| 200 |
+
if not 0.0 < self.turn_discount <= 1.0:
|
| 201 |
+
raise ValueError(f"turn_discount must be in (0, 1], got {self.turn_discount}")
|
| 202 |
+
if self.advantage_mode not in ("episode", "per_turn"):
|
| 203 |
+
raise ValueError(f"advantage_mode must be 'episode' or 'per_turn', got {self.advantage_mode!r}")
|
| 204 |
+
if not 0.0 <= self.credit_gamma <= 1.0:
|
| 205 |
+
raise ValueError(f"credit_gamma must be in [0, 1], got {self.credit_gamma}")
|
| 206 |
+
if self.max_prompt_tokens is not None and self.max_prompt_tokens < 1:
|
| 207 |
+
raise ValueError(f"max_prompt_tokens must be None or >= 1, got {self.max_prompt_tokens}")
|
| 208 |
+
if not 0.0 <= self.max_infra_fraction <= 1.0:
|
| 209 |
+
raise ValueError(f"max_infra_fraction must be in [0, 1], got {self.max_infra_fraction}")
|
| 210 |
+
if self.min_infra_batch < 1:
|
| 211 |
+
raise ValueError(f"min_infra_batch must be >= 1, got {self.min_infra_batch}")
|
| 212 |
+
if self.unpositioned_feedback not in ("message_only", "raise"):
|
| 213 |
+
raise ValueError(f"unpositioned_feedback must be 'message_only' or 'raise'")
|
| 214 |
+
# same construction-time refusals as CompositeReward: a half-armed penalty is silently off
|
| 215 |
+
if self.overlong_max_len is None and self.overlong_cache_len is not None:
|
| 216 |
+
raise ValueError("overlong_cache_len given without overlong_max_len: penalty would be silently OFF")
|
| 217 |
+
if self.overlong_max_len is not None:
|
| 218 |
+
if self.overlong_cache_len is None or not 0 < self.overlong_cache_len < self.overlong_max_len:
|
| 219 |
+
raise ValueError("need 0 < overlong_cache_len < overlong_max_len")
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
@dataclass
|
| 223 |
+
class TurnRecord:
|
| 224 |
+
turn: int
|
| 225 |
+
prompt_text: str
|
| 226 |
+
prompt_ids: list
|
| 227 |
+
completion_ids: list
|
| 228 |
+
completion_text: str
|
| 229 |
+
finish_reason: str
|
| 230 |
+
lean_code: Optional[str] = None
|
| 231 |
+
code_error: Optional[str] = None
|
| 232 |
+
displayed_statement_ok: Optional[bool] = None
|
| 233 |
+
reasons: tuple = ()
|
| 234 |
+
verified: bool = False
|
| 235 |
+
credited: bool = False
|
| 236 |
+
feedback_text: Optional[str] = None
|
| 237 |
+
feedback_positioned: Optional[bool] = None
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
@dataclass
|
| 241 |
+
class TrainingRow:
|
| 242 |
+
input_ids: list
|
| 243 |
+
#: 1 ONLY on tokens the generator returned (input space, like RolloutBatch.response_mask)
|
| 244 |
+
response_mask: list
|
| 245 |
+
#: turn index of each generated token, -1 elsewhere
|
| 246 |
+
turn_ids: list
|
| 247 |
+
#: per-token engine logprobs in input space (0.0 where unlabeled); None if not supplied
|
| 248 |
+
logprobs: Optional[list] = None
|
| 249 |
+
#: per-token advantage (0.0 where unlabeled); filled by `assign_advantages`
|
| 250 |
+
advantages: Optional[list] = None
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
@dataclass
|
| 254 |
+
class EpisodeRecord:
|
| 255 |
+
episode_key: str
|
| 256 |
+
problem_id: str
|
| 257 |
+
sample_idx: int
|
| 258 |
+
turns: list
|
| 259 |
+
rows: list
|
| 260 |
+
terminal: str = ""
|
| 261 |
+
solved_turn: Optional[int] = None
|
| 262 |
+
reward: dict = field(default_factory=dict)
|
| 263 |
+
turn_rewards: list = field(default_factory=list) # u_t, the per-turn outcome rewards
|
| 264 |
+
n_generated: int = 0
|
| 265 |
+
|
| 266 |
+
def to_json(self) -> str:
|
| 267 |
+
return json.dumps(asdict(self), sort_keys=True, ensure_ascii=False)
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
# ------------------------------------------------------------------------- reward composition
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
def compose_episode_reward(cfg: LeanMultiTurnConfig, solved_turn: Optional[int], n_turns: int, n_generated: int):
|
| 274 |
+
"""(terms, u): itemised episode reward terms and the per-turn outcome rewards u_t.
|
| 275 |
+
The overlong term is attached to the LAST turn's u (the turn that ended the episode)."""
|
| 276 |
+
u = [0.0] * n_turns
|
| 277 |
+
acc = 0.0
|
| 278 |
+
if solved_turn is not None:
|
| 279 |
+
acc = cfg.turn_discount ** solved_turn
|
| 280 |
+
u[solved_turn] = acc
|
| 281 |
+
ov = 0.0
|
| 282 |
+
if cfg.overlong_max_len is not None:
|
| 283 |
+
ov = cfg.overlong_weight * soft_overlong_punishment(n_generated, cfg.overlong_max_len, cfg.overlong_cache_len)
|
| 284 |
+
u[-1] += ov
|
| 285 |
+
terms = {
|
| 286 |
+
"reward/accuracy": 1.0 if solved_turn is not None else 0.0,
|
| 287 |
+
"reward/turn_discount": acc,
|
| 288 |
+
"reward/overlong": ov,
|
| 289 |
+
"reward/total": acc + ov,
|
| 290 |
+
}
|
| 291 |
+
return terms, u
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def assign_advantages(episodes: Sequence[EpisodeRecord], cfg: LeanMultiTurnConfig) -> None:
|
| 295 |
+
"""Fill `row.advantages` in place. Episodes of one problem_id form a group (input order
|
| 296 |
+
preserved). Groups must be equal-sized in "episode" mode (the shared helper's layout)."""
|
| 297 |
+
groups: dict[str, list] = {}
|
| 298 |
+
for ep in episodes:
|
| 299 |
+
groups.setdefault(ep.problem_id, []).append(ep)
|
| 300 |
+
for eps in groups.values():
|
| 301 |
+
n_turn_max = max(len(e.turns) for e in eps)
|
| 302 |
+
if cfg.advantage_mode == "episode":
|
| 303 |
+
import numpy as np
|
| 304 |
+
|
| 305 |
+
adv = compute_group_advantages(np.array([e.reward["reward/total"] for e in eps]), len(eps), "centered")
|
| 306 |
+
per_turn = [[float(a)] * n_turn_max for a in adv]
|
| 307 |
+
else:
|
| 308 |
+
G = []
|
| 309 |
+
for e in eps:
|
| 310 |
+
g, acc = [0.0] * len(e.turns), 0.0
|
| 311 |
+
for t in range(len(e.turns) - 1, -1, -1):
|
| 312 |
+
acc = e.turn_rewards[t] + cfg.credit_gamma * acc
|
| 313 |
+
g[t] = acc
|
| 314 |
+
G.append(g)
|
| 315 |
+
per_turn = [[0.0] * n_turn_max for _ in eps]
|
| 316 |
+
for t in range(n_turn_max):
|
| 317 |
+
have = [i for i, e in enumerate(eps) if t < len(e.turns)]
|
| 318 |
+
base = sum(G[i][t] for i in have) / len(have)
|
| 319 |
+
for i in have:
|
| 320 |
+
per_turn[i][t] = G[i][t] - base
|
| 321 |
+
for e, adv_t in zip(eps, per_turn):
|
| 322 |
+
for row in e.rows:
|
| 323 |
+
row.advantages = [adv_t[tid] if tid >= 0 else 0.0 for tid in row.turn_ids]
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
# ------------------------------------------------------------------------- the environment
|
| 327 |
+
|
| 328 |
+
|
| 329 |
+
class _Ep:
|
| 330 |
+
def __init__(self, spec: EpisodeSpec, key: str):
|
| 331 |
+
self.spec, self.key = spec, key
|
| 332 |
+
self.turns: list[TurnRecord] = []
|
| 333 |
+
self.messages: list[dict] = []
|
| 334 |
+
self.prompt_text = ""
|
| 335 |
+
self.prompt_ids: list = []
|
| 336 |
+
self.ctx_ids: list = [] # concatenated layout: running context
|
| 337 |
+
self.gens: list[Generation] = []
|
| 338 |
+
self.done = False
|
| 339 |
+
self.terminal = ""
|
| 340 |
+
self.solved_turn: Optional[int] = None
|
| 341 |
+
self.pending: Optional[tuple] = None
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
class LeanMultiTurnEnv:
|
| 345 |
+
"""`verifier`: anything with `verify(statement, code) -> StrictResult` (and optionally
|
| 346 |
+
`verify_batch(items)`), e.g. `StrictVerifier`. `tokenizer`: `apply_chat_template(messages,
|
| 347 |
+
tokenize=False, add_generation_prompt=True)`. `encode`: text -> ids."""
|
| 348 |
+
|
| 349 |
+
def __init__(self, verifier, tokenizer, encode: Callable[[str], list], cfg: LeanMultiTurnConfig = LeanMultiTurnConfig()):
|
| 350 |
+
if cfg.sandbox and not getattr(getattr(verifier, "pool", verifier), "sandbox", None):
|
| 351 |
+
raise SandboxNotWired(
|
| 352 |
+
"sandbox=True requested but the verifier pool exposes no sandboxed REPL "
|
| 353 |
+
"(align/verify/lean/sandbox.py is not wired into repl_pool yet)"
|
| 354 |
+
)
|
| 355 |
+
self.verifier, self.tok, self.encode, self.cfg = verifier, tokenizer, encode, cfg
|
| 356 |
+
|
| 357 |
+
# ---- public
|
| 358 |
+
def run_episode(self, spec: EpisodeSpec, generate: Callable[[list], list]) -> EpisodeRecord:
|
| 359 |
+
return self.run_group([spec], generate)[0]
|
| 360 |
+
|
| 361 |
+
def run_group(self, specs: Sequence[EpisodeSpec], generate: Callable[[list], list]) -> list:
|
| 362 |
+
"""Lockstep over episodes: each round, ONE `generate(list[GenRequest]) -> list[Generation]`
|
| 363 |
+
call for all active episodes, then one batched verification. Output order = input order."""
|
| 364 |
+
eps = [_Ep(s, self._key(s)) for s in specs]
|
| 365 |
+
if len({e.key for e in eps}) != len(eps):
|
| 366 |
+
dup = sorted({e.key for e in eps if sum(x.key == e.key for x in eps) > 1})
|
| 367 |
+
raise ValueError(f"duplicate episode keys {dup}: specs must differ in problem_id/sample_idx/statement")
|
| 368 |
+
n_ver = n_infra = 0
|
| 369 |
+
for ep in eps:
|
| 370 |
+
self._start(ep)
|
| 371 |
+
active = list(eps)
|
| 372 |
+
while active:
|
| 373 |
+
reqs = [GenRequest(e.key, len(e.turns), tuple(e.prompt_ids), e.prompt_text) for e in active]
|
| 374 |
+
gens = generate(reqs)
|
| 375 |
+
if len(gens) != len(active):
|
| 376 |
+
raise ValueError(f"generate returned {len(gens)} generations for {len(active)} requests")
|
| 377 |
+
for e, g in zip(active, gens):
|
| 378 |
+
self._absorb(e, g)
|
| 379 |
+
todo = [e for e in active if not e.done]
|
| 380 |
+
if todo:
|
| 381 |
+
items = [e.pending for e in todo]
|
| 382 |
+
vb = getattr(self.verifier, "verify_batch", None)
|
| 383 |
+
results = vb(items) if vb is not None else [self.verifier.verify(s, c) for s, c in items]
|
| 384 |
+
if len(results) != len(todo):
|
| 385 |
+
raise ValueError(f"verifier returned {len(results)} results for {len(todo)} items")
|
| 386 |
+
n_ver += len(results)
|
| 387 |
+
n_infra += sum(1 for r in results if getattr(r, "infra_failure", False))
|
| 388 |
+
self._check_infra(n_infra, n_ver)
|
| 389 |
+
for e, r in zip(todo, results):
|
| 390 |
+
self._judge(e, r)
|
| 391 |
+
active = [e for e in active if not e.done]
|
| 392 |
+
return [self._finish(e) for e in eps]
|
| 393 |
+
|
| 394 |
+
# ---- internals
|
| 395 |
+
def _check_infra(self, n_infra: int, n_ver: int) -> None:
|
| 396 |
+
cfg = self.cfg
|
| 397 |
+
if n_ver >= cfg.min_infra_batch and n_infra / n_ver > cfg.max_infra_fraction:
|
| 398 |
+
from align.verify.lean.strict import LeanInfraError
|
| 399 |
+
|
| 400 |
+
raise LeanInfraError(
|
| 401 |
+
f"{n_infra}/{n_ver} verifications died for infrastructure reasons: either the pool is "
|
| 402 |
+
f"dead or the policy is emitting REPL-killing proofs; refusing to emit episodes of "
|
| 403 |
+
f"reward 0 that read as 'model got worse'"
|
| 404 |
+
)
|
| 405 |
+
|
| 406 |
+
def _key(self, s: EpisodeSpec) -> str:
|
| 407 |
+
h = hashlib.sha256(f"{s.problem.problem_id}\0{s.sample_idx}\0{s.problem.formal_statement}".encode()).hexdigest()
|
| 408 |
+
return h[:16]
|
| 409 |
+
|
| 410 |
+
def _render(self, messages: list) -> str:
|
| 411 |
+
return self.tok.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
|
| 412 |
+
|
| 413 |
+
def _start(self, ep: _Ep) -> None:
|
| 414 |
+
text, msgs = _lean_prompts().initial_prompt(ep.spec.problem.formal_statement, self.tok)
|
| 415 |
+
ep.messages, ep.prompt_text = list(msgs), text
|
| 416 |
+
ep.prompt_ids = list(self.encode(text))
|
| 417 |
+
ep.ctx_ids = list(ep.prompt_ids)
|
| 418 |
+
|
| 419 |
+
def _absorb(self, ep: _Ep, g: Generation) -> None:
|
| 420 |
+
cfg, lp = self.cfg, _lean_prompts()
|
| 421 |
+
if g.logprobs is not None and len(g.logprobs) != len(g.token_ids):
|
| 422 |
+
raise ValueError(f"{len(g.logprobs)} logprobs for {len(g.token_ids)} generated tokens")
|
| 423 |
+
t = TurnRecord(len(ep.turns), ep.prompt_text, list(ep.prompt_ids), list(g.token_ids), g.text, g.finish_reason)
|
| 424 |
+
ep.turns.append(t)
|
| 425 |
+
ep.gens.append(g)
|
| 426 |
+
stmt = ep.spec.problem.formal_statement
|
| 427 |
+
if g.finish_reason not in STOP_REASONS and g.finish_reason not in TRUNCATED_REASONS:
|
| 428 |
+
raise ValueError(
|
| 429 |
+
f"unknown finish_reason {g.finish_reason!r}; classify it in STOP_REASONS or TRUNCATED_REASONS"
|
| 430 |
+
)
|
| 431 |
+
if cfg.reject_truncated and g.finish_reason in TRUNCATED_REASONS:
|
| 432 |
+
return self._end(ep, "truncated")
|
| 433 |
+
code, err = lp.lean_code_for_record(stmt, g.text)
|
| 434 |
+
t.lean_code, t.code_error = code, err
|
| 435 |
+
if code is None:
|
| 436 |
+
return self._end(ep, f"no_code:{err}")
|
| 437 |
+
if not cfg.allow_statement_rewrite:
|
| 438 |
+
t.displayed_statement_ok = bool(_p2_common().displayed_statement_matches(stmt, g.text))
|
| 439 |
+
if not t.displayed_statement_ok:
|
| 440 |
+
return self._end(ep, "statement_rewrite")
|
| 441 |
+
ep.pending = (stmt, code)
|
| 442 |
+
|
| 443 |
+
def _judge(self, ep: _Ep, r) -> None:
|
| 444 |
+
cfg, t = self.cfg, ep.turns[-1]
|
| 445 |
+
t.reasons, t.verified = tuple(r.reasons), bool(r.accepted)
|
| 446 |
+
if r.accepted:
|
| 447 |
+
t.credited, ep.solved_turn = True, t.turn
|
| 448 |
+
return self._end(ep, "verified")
|
| 449 |
+
if t.turn + 1 >= cfg.max_turns:
|
| 450 |
+
return self._end(ep, "max_turns")
|
| 451 |
+
if not all(x in cfg.retry_reasons for x in t.reasons):
|
| 452 |
+
return self._end(ep, f"rejected:{t.reasons[0] if t.reasons else 'unknown'}")
|
| 453 |
+
fb = self._feedback(ep, r)
|
| 454 |
+
if fb is None:
|
| 455 |
+
return self._end(ep, "no_feedback")
|
| 456 |
+
err_str, positioned = fb
|
| 457 |
+
t.feedback_text, t.feedback_positioned = err_str, positioned
|
| 458 |
+
lp = _lean_prompts()
|
| 459 |
+
text, msgs = lp.DeepSeekCoTHandler().generate_correction_prompt(
|
| 460 |
+
lp.goedel_statement(ep.spec.problem.formal_statement), ep.messages, t.completion_text,
|
| 461 |
+
err_str, self.tok, t.turn + 1,
|
| 462 |
+
)
|
| 463 |
+
if cfg.max_prompt_tokens is not None and len(self.encode(text)) > cfg.max_prompt_tokens:
|
| 464 |
+
return self._end(ep, "context_overflow")
|
| 465 |
+
ep.messages = msgs
|
| 466 |
+
prev_end = ep.prompt_text + t.completion_text
|
| 467 |
+
if cfg.layout == "concatenated":
|
| 468 |
+
if not text.startswith(prev_end):
|
| 469 |
+
raise PrefixBreak(
|
| 470 |
+
f"turn {t.turn + 1} prompt does not extend turn {t.turn} prompt + completion "
|
| 471 |
+
f"(template rewrites earlier assistant content, e.g. Qwen3 <think> stripping); "
|
| 472 |
+
f"use layout='per_turn'"
|
| 473 |
+
)
|
| 474 |
+
delta = text[len(prev_end):]
|
| 475 |
+
gen = ep.gens[-1]
|
| 476 |
+
if (cfg.eos_token_id is not None and gen.token_ids and gen.token_ids[-1] == cfg.eos_token_id
|
| 477 |
+
and delta.startswith(cfg.eos_text)):
|
| 478 |
+
delta = delta[len(cfg.eos_text):] # the sampled EOS is already in the context
|
| 479 |
+
ep.ctx_ids = ep.ctx_ids + list(gen.token_ids) + list(self.encode(delta))
|
| 480 |
+
ep.prompt_ids = list(ep.ctx_ids)
|
| 481 |
+
else:
|
| 482 |
+
ep.prompt_ids = list(self.encode(text))
|
| 483 |
+
ep.prompt_text = text
|
| 484 |
+
|
| 485 |
+
def _feedback(self, ep: _Ep, r) -> Optional[tuple]:
|
| 486 |
+
lp = _lean_prompts()
|
| 487 |
+
diag = r.diagnostics or {}
|
| 488 |
+
t = ep.turns[-1]
|
| 489 |
+
errs = diag.get("repl_errors")
|
| 490 |
+
if errs and all(isinstance(e, dict) and e.get("pos") and "data" in e for e in errs):
|
| 491 |
+
view = diag.get("error_code_view") or lp.compile_view(t.lean_code)
|
| 492 |
+
return lp.get_error_str(view, errs, self.cfg.error_thres), True
|
| 493 |
+
msgs = diag.get("errors")
|
| 494 |
+
if not msgs and not errs:
|
| 495 |
+
return None
|
| 496 |
+
if self.cfg.unpositioned_feedback == "raise":
|
| 497 |
+
raise ValueError("verifier result has no positioned REPL errors (diagnostics['repl_errors'])")
|
| 498 |
+
raw = [e["data"] if isinstance(e, dict) else str(e) for e in (errs or msgs)]
|
| 499 |
+
return "".join(f"\nError {i + 1}:\n\nError Message: {m}\n" for i, m in enumerate(raw)), False
|
| 500 |
+
|
| 501 |
+
def _end(self, ep: _Ep, why: str) -> None:
|
| 502 |
+
ep.done, ep.terminal = True, why
|
| 503 |
+
|
| 504 |
+
def _finish(self, ep: _Ep) -> EpisodeRecord:
|
| 505 |
+
n_gen = sum(len(g.token_ids) for g in ep.gens)
|
| 506 |
+
terms, u = compose_episode_reward(self.cfg, ep.solved_turn, len(ep.turns), n_gen)
|
| 507 |
+
rows = self._rows(ep)
|
| 508 |
+
s = ep.spec
|
| 509 |
+
return EpisodeRecord(ep.key, s.problem.problem_id, s.sample_idx, ep.turns, rows, ep.terminal,
|
| 510 |
+
ep.solved_turn, terms, u, n_gen)
|
| 511 |
+
|
| 512 |
+
def _rows(self, ep: _Ep) -> list:
|
| 513 |
+
if self.cfg.layout == "per_turn":
|
| 514 |
+
out = []
|
| 515 |
+
for t, g in zip(ep.turns, ep.gens):
|
| 516 |
+
n = len(g.token_ids)
|
| 517 |
+
out.append(TrainingRow(
|
| 518 |
+
list(t.prompt_ids) + list(g.token_ids),
|
| 519 |
+
[0] * len(t.prompt_ids) + [1] * n,
|
| 520 |
+
[-1] * len(t.prompt_ids) + [t.turn] * n,
|
| 521 |
+
([0.0] * len(t.prompt_ids) + list(g.logprobs)) if g.logprobs is not None else None,
|
| 522 |
+
))
|
| 523 |
+
return out
|
| 524 |
+
ids, mask, tids, lp, have_lp = [], [], [], [], all(g.logprobs is not None for g in ep.gens)
|
| 525 |
+
for t, g in zip(ep.turns, ep.gens):
|
| 526 |
+
n_ctx = len(t.prompt_ids) - len(ids) # new context tokens (prompt / feedback glue)
|
| 527 |
+
ids += list(t.prompt_ids[len(ids):])
|
| 528 |
+
mask += [0] * n_ctx
|
| 529 |
+
tids += [-1] * n_ctx
|
| 530 |
+
lp += [0.0] * n_ctx
|
| 531 |
+
ids += list(g.token_ids)
|
| 532 |
+
mask += [1] * len(g.token_ids)
|
| 533 |
+
tids += [t.turn] * len(g.token_ids)
|
| 534 |
+
if have_lp:
|
| 535 |
+
lp += list(g.logprobs)
|
| 536 |
+
else:
|
| 537 |
+
lp += [0.0] * len(g.token_ids)
|
| 538 |
+
return [TrainingRow(ids, mask, tids, lp if have_lp else None)]
|
code/align/rl/envs/subgoal_repair.py
ADDED
|
@@ -0,0 +1,694 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""APOLLO-style subgoal extraction and reassembly for failed Lean 4 proofs (K5 agentic arm, docs/K5_LEVERS.md).
|
| 2 |
+
|
| 3 |
+
Pure Python (stdlib only) plus read-only Lean calls through the existing `align.verify.lean` API. Nothing here
|
| 4 |
+
changes the verifier: the reassembled proof is only a CANDIDATE; the strict verifier (`StrictVerifier`) is the sole
|
| 5 |
+
judge of acceptance, against the ORIGINAL statement. So a lemma that drifted from the goal it was cut from (the
|
| 6 |
+
pretty-printer is not a round trip) can cost a wasted attempt, never a false accept.
|
| 7 |
+
|
| 8 |
+
Pipeline (APOLLO, arXiv 2505.05758, section 3; DeepSeek-Prover-V2, arXiv 2504.21801, section 2.1: "decompose, prove the
|
| 9 |
+
subgoals separately, recombine"):
|
| 10 |
+
|
| 11 |
+
1. `locate_targets` map every Lean error of a failed proof to the smallest repairable unit that contains it:
|
| 12 |
+
a failing `have name : T := <rhs>` (kind "have", innermost wins), a failing main-line tactic
|
| 13 |
+
(kind "tactic"), or "the proof ended with goals left" (kind "tail"). Errors on a `have`
|
| 14 |
+
HEADER (the statement itself does not elaborate) are not repairable and are reported.
|
| 15 |
+
2. `build_skeleton` replace each target's text by `sorry` ("sorrifier"). Targets that contain another target are
|
| 16 |
+
dropped (the inner failure is the finer cut; the outer one is picked up by the next round).
|
| 17 |
+
3. `plan_subgoals` run the skeleton in Lean once: its `sorries` carry the exact goal state (hypotheses and
|
| 18 |
+
goal) at each gap. Each state becomes a standalone `theorem k5_sg_<hash> (hyps) : goal`,
|
| 19 |
+
which is checked to elaborate. These are ordinary problems for the prover.
|
| 20 |
+
4. `reassemble` splice a prover-supplied tactic body back into the skeleton in place of the `sorry`
|
| 21 |
+
(inline: the lemma's hypotheses carry the context's names; inaccessible ones are
|
| 22 |
+
re-introduced with `rename_i`). Unproven gaps get their original text back.
|
| 23 |
+
|
| 24 |
+
Coordinates. Lean errors index `compile_view(code)` (header lines removed, `p2_common.compile_view`), not the full
|
| 25 |
+
code. `view_line_map` translates; all text surgery happens on the FULL code so the result can be verified as is.
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
from __future__ import annotations
|
| 29 |
+
|
| 30 |
+
import hashlib
|
| 31 |
+
import re
|
| 32 |
+
from dataclasses import dataclass, field
|
| 33 |
+
from typing import Any, Callable, Optional
|
| 34 |
+
|
| 35 |
+
# --------------------------------------------------------------------------- lexical masking
|
| 36 |
+
|
| 37 |
+
_OPEN = "([{⟨"
|
| 38 |
+
_CLOSE = ")]}⟩"
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def mask(text: str) -> str:
|
| 42 |
+
"""`text` with comments and string literals blanked (newlines kept), so indentation and bracket scans cannot be
|
| 43 |
+
fooled by prose. Same length as the input: offsets and (line, column) positions are preserved."""
|
| 44 |
+
out = list(text)
|
| 45 |
+
i, n = 0, len(text)
|
| 46 |
+
while i < n:
|
| 47 |
+
c = text[i]
|
| 48 |
+
if c == "-" and text.startswith("--", i):
|
| 49 |
+
j = text.find("\n", i)
|
| 50 |
+
j = n if j < 0 else j
|
| 51 |
+
for k in range(i, j):
|
| 52 |
+
out[k] = " "
|
| 53 |
+
i = j
|
| 54 |
+
elif c == "/" and text.startswith("/-", i):
|
| 55 |
+
depth, j = 1, i + 2
|
| 56 |
+
while j < n and depth:
|
| 57 |
+
if text.startswith("/-", j):
|
| 58 |
+
depth += 1
|
| 59 |
+
j += 2
|
| 60 |
+
elif text.startswith("-/", j):
|
| 61 |
+
depth -= 1
|
| 62 |
+
j += 2
|
| 63 |
+
else:
|
| 64 |
+
j += 1
|
| 65 |
+
for k in range(i, min(j, n)):
|
| 66 |
+
if out[k] != "\n":
|
| 67 |
+
out[k] = " "
|
| 68 |
+
i = j
|
| 69 |
+
elif c == '"':
|
| 70 |
+
j = i + 1
|
| 71 |
+
while j < n and text[j] != '"':
|
| 72 |
+
j += 2 if text[j] == "\\" else 1
|
| 73 |
+
for k in range(i + 1, min(j, n)):
|
| 74 |
+
if out[k] != "\n":
|
| 75 |
+
out[k] = " "
|
| 76 |
+
i = j + 1
|
| 77 |
+
else:
|
| 78 |
+
i += 1
|
| 79 |
+
return "".join(out)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def strip_comments(text: str) -> str:
|
| 83 |
+
"""`text` with block and line comments deleted (used on headers only, which hold no string literals)."""
|
| 84 |
+
text = re.sub(r"/-.*?-/", "", text, flags=re.S)
|
| 85 |
+
return re.sub(r"--[^\n]*", "", text)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def view_line_map(code: str) -> list[int]:
|
| 89 |
+
"""`map[i]` = index in `code.split("\\n")` of line `i` of `compile_view(code)` (the REPL's coordinate system).
|
| 90 |
+
`compile_view` (the reference's `handle`) drops every line whose stripped text starts with import / set_option /
|
| 91 |
+
open, nothing else."""
|
| 92 |
+
out = []
|
| 93 |
+
for i, line in enumerate(code.split("\n")):
|
| 94 |
+
s = line.strip()
|
| 95 |
+
if s.startswith("import") or s.startswith("set_option") or s.startswith("open"):
|
| 96 |
+
continue
|
| 97 |
+
out.append(i)
|
| 98 |
+
return out
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
# --------------------------------------------------------------------------- proof structure
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
@dataclass
|
| 105 |
+
class _Lines:
|
| 106 |
+
"""Masked, bullet-normalised working copy of the code."""
|
| 107 |
+
|
| 108 |
+
raw: list[str]
|
| 109 |
+
m: list[str] # masked lines (comments blanked); bullets `· ` replaced by two spaces
|
| 110 |
+
indent: list[int]
|
| 111 |
+
blank: list[bool]
|
| 112 |
+
depth_before: list[int] # open-bracket depth at the start of each line
|
| 113 |
+
cont: list[bool] # the line continues the previous statement at the same indent
|
| 114 |
+
ends: list[int] = field(default_factory=list) # extent end line of a statement starting here (-1: not a start)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
_BULLET = re.compile(r"^(\s*)[·.](?=\s)")
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def _analyse(code: str) -> _Lines:
|
| 121 |
+
raw = code.split("\n")
|
| 122 |
+
mk = mask(code).split("\n")
|
| 123 |
+
m = []
|
| 124 |
+
for ln in mk:
|
| 125 |
+
mm = _BULLET.match(ln)
|
| 126 |
+
if mm:
|
| 127 |
+
ln = mm.group(1) + " " + ln[len(mm.group(1)) + 1:]
|
| 128 |
+
m.append(ln)
|
| 129 |
+
indent = [len(x) - len(x.lstrip(" ")) for x in m]
|
| 130 |
+
blank = [not x.strip() for x in m]
|
| 131 |
+
depth, db = 0, []
|
| 132 |
+
for x in m:
|
| 133 |
+
db.append(depth)
|
| 134 |
+
for ch in x:
|
| 135 |
+
if ch in _OPEN:
|
| 136 |
+
depth += 1
|
| 137 |
+
elif ch in _CLOSE:
|
| 138 |
+
depth = max(0, depth - 1)
|
| 139 |
+
cont = [False] * len(m)
|
| 140 |
+
prev = None
|
| 141 |
+
for i, x in enumerate(m):
|
| 142 |
+
if blank[i]:
|
| 143 |
+
continue
|
| 144 |
+
s = x.strip()
|
| 145 |
+
if prev is not None and (db[i] > 0 or s.startswith("<;>") or m[prev].rstrip().endswith("<;>")):
|
| 146 |
+
cont[i] = True
|
| 147 |
+
prev = i
|
| 148 |
+
L = _Lines(raw, m, indent, blank, db, cont)
|
| 149 |
+
L.ends = [-1] * len(m)
|
| 150 |
+
for i in range(len(m)):
|
| 151 |
+
if blank[i] or cont[i]:
|
| 152 |
+
continue
|
| 153 |
+
e = i
|
| 154 |
+
for j in range(i + 1, len(m)):
|
| 155 |
+
if blank[j]:
|
| 156 |
+
continue
|
| 157 |
+
if indent[j] > indent[i] or (indent[j] == indent[i] and cont[j]):
|
| 158 |
+
e = j
|
| 159 |
+
else:
|
| 160 |
+
break
|
| 161 |
+
L.ends[i] = e
|
| 162 |
+
return L
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
_THEOREM = re.compile(r"^\s*(?:theorem|lemma)\b")
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
@dataclass(frozen=True)
|
| 169 |
+
class Have:
|
| 170 |
+
start: int # first line
|
| 171 |
+
end: int # last line of the unit (inclusive)
|
| 172 |
+
name: str
|
| 173 |
+
assign_line: int # line holding the depth-0 `:=`
|
| 174 |
+
assign_col: int # column of that `:=`
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def _first_assign(L: _Lines, s: int, e: int) -> Optional[tuple[int, int]]:
|
| 178 |
+
depth = 0
|
| 179 |
+
for i in range(s, e + 1):
|
| 180 |
+
x = L.m[i]
|
| 181 |
+
for c, ch in enumerate(x):
|
| 182 |
+
if ch in _OPEN:
|
| 183 |
+
depth += 1
|
| 184 |
+
elif ch in _CLOSE:
|
| 185 |
+
depth = max(0, depth - 1)
|
| 186 |
+
elif ch == ":" and depth == 0 and x.startswith(":=", c):
|
| 187 |
+
return i, c
|
| 188 |
+
return None
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def find_haves(code: str, L: Optional[_Lines] = None) -> list[Have]:
|
| 192 |
+
"""Every `have <name> : T := <rhs>` unit (any nesting depth) that has a `:=`. Side-goal haves (no `:=`, closed by
|
| 193 |
+
following bullets) and `have ... from` are not units this module can cut."""
|
| 194 |
+
L = L or _analyse(code)
|
| 195 |
+
out = []
|
| 196 |
+
for i, x in enumerate(L.m):
|
| 197 |
+
if L.ends[i] < 0:
|
| 198 |
+
continue
|
| 199 |
+
s = x.strip()
|
| 200 |
+
if not (s.startswith("have ") or s.startswith("have:") or s.startswith("haveI ")):
|
| 201 |
+
continue
|
| 202 |
+
a = _first_assign(L, i, L.ends[i])
|
| 203 |
+
if a is None:
|
| 204 |
+
continue
|
| 205 |
+
toks = s.split()
|
| 206 |
+
name = toks[1] if len(toks) > 1 and toks[1] not in (":", ":=") and not toks[1].startswith(("⟨", "(", ":")) else "_"
|
| 207 |
+
out.append(Have(i, L.ends[i], name, a[0], a[1]))
|
| 208 |
+
return out
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
def theorem_by_line(L: _Lines) -> Optional[tuple[int, int]]:
|
| 212 |
+
"""(line, column just after `by`) of the main theorem's `:= by`; None when absent."""
|
| 213 |
+
for i, x in enumerate(L.m):
|
| 214 |
+
if _THEOREM.match(x):
|
| 215 |
+
a = _first_assign(L, i, min(len(L.m) - 1, i + 12))
|
| 216 |
+
if a is None:
|
| 217 |
+
return None
|
| 218 |
+
j = L.m[a[0]].find("by", a[1])
|
| 219 |
+
return (a[0], j + 2) if j >= 0 else None
|
| 220 |
+
return None
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
# --------------------------------------------------------------------------- targets
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
@dataclass
|
| 227 |
+
class Target:
|
| 228 |
+
kind: str # have | tactic | tail
|
| 229 |
+
start: tuple[int, int] # (line, col) of the text to replace, 0-based, full-code coordinates
|
| 230 |
+
end: tuple[int, int] # exclusive end (line, col); for "tail" start == end (insertion point)
|
| 231 |
+
original: str # the replaced text, restorable verbatim
|
| 232 |
+
indent: int # indentation of the statement the gap lives in
|
| 233 |
+
name: str = ""
|
| 234 |
+
errors: list = field(default_factory=list)
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
def _cut(raw: list[str], a: tuple[int, int], b: tuple[int, int]) -> str:
|
| 238 |
+
if a[0] == b[0]:
|
| 239 |
+
return raw[a[0]][a[1]:b[1]]
|
| 240 |
+
parts = [raw[a[0]][a[1]:]] + raw[a[0] + 1:b[0]] + [raw[b[0]][:b[1]]]
|
| 241 |
+
return "\n".join(parts)
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def tail_target(L: _Lines, tb: tuple[int, int]) -> Target:
|
| 245 |
+
"""Insertion point for a `sorry` after the proof's last line: the proof ended with goals left."""
|
| 246 |
+
body_lines = [i for i in range(tb[0] + 1, len(L.m)) if not L.blank[i]]
|
| 247 |
+
ind = min((L.indent[i] for i in body_lines), default=2)
|
| 248 |
+
last = max(body_lines, default=tb[0])
|
| 249 |
+
pos = (last, len(L.raw[last]))
|
| 250 |
+
return Target("tail", pos, pos, "", ind)
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
def locate_targets(code: str, errors: list[dict], repl_code: Optional[str] = None) -> tuple[list[Target], list[dict]]:
|
| 254 |
+
"""Map Lean errors (positions in `repl_code`, default `compile_view(code)`) to repairable targets.
|
| 255 |
+
|
| 256 |
+
Returns (targets sorted by position, unrepairable errors with a `why`). Only severity-error messages are used.
|
| 257 |
+
Rules, in order, for an error at line l, column c:
|
| 258 |
+
* inside the rhs of a `have` -> kind "have" (innermost such have); target = text after its `:=`
|
| 259 |
+
* on the theorem's `:= by` line, or the proof ends with goals left -> kind "tail"
|
| 260 |
+
* on a `have` header (before its `:=`) -> unrepairable "have_header" (the statement itself is wrong)
|
| 261 |
+
* otherwise -> kind "tactic": the statement containing the error, as one unit
|
| 262 |
+
Targets that contain another target are dropped; the finer cut wins."""
|
| 263 |
+
L = _analyse(code)
|
| 264 |
+
vmap = view_line_map(code)
|
| 265 |
+
if repl_code is not None and len(repl_code.split("\n")) != len(vmap):
|
| 266 |
+
raise ValueError("repl_code does not match compile_view(code): the error positions would index other lines")
|
| 267 |
+
haves = find_haves(code, L)
|
| 268 |
+
tb = theorem_by_line(L)
|
| 269 |
+
found: dict[tuple, Target] = {}
|
| 270 |
+
bad: list[dict] = []
|
| 271 |
+
for e in errors:
|
| 272 |
+
if e.get("severity", "error") != "error" or not e.get("pos"):
|
| 273 |
+
continue
|
| 274 |
+
vl, c = int(e["pos"]["line"]) - 1, int(e["pos"]["column"])
|
| 275 |
+
if not 0 <= vl < len(vmap):
|
| 276 |
+
bad.append({**e, "why": "position_outside_code"})
|
| 277 |
+
continue
|
| 278 |
+
l = vmap[vl]
|
| 279 |
+
inside = [h for h in haves if h.start <= l <= h.end and (l, c) > (h.assign_line, h.assign_col + 1)]
|
| 280 |
+
if inside:
|
| 281 |
+
h = max(inside, key=lambda x: x.start)
|
| 282 |
+
rhs_start = (h.assign_line, h.assign_col + 2)
|
| 283 |
+
end = (h.end, len(L.raw[h.end]))
|
| 284 |
+
key = ("have", rhs_start)
|
| 285 |
+
t = found.setdefault(key, Target("have", rhs_start, end, _cut(L.raw, rhs_start, end), L.indent[h.start], h.name))
|
| 286 |
+
t.errors.append(e)
|
| 287 |
+
continue
|
| 288 |
+
if tb is not None and l == tb[0]:
|
| 289 |
+
t = found.setdefault(("tail",), tail_target(L, tb))
|
| 290 |
+
t.errors.append(e)
|
| 291 |
+
continue
|
| 292 |
+
if any(h.start <= l <= h.assign_line and (l, c) <= (h.assign_line, h.assign_col + 1) for h in haves):
|
| 293 |
+
bad.append({**e, "why": "have_header"})
|
| 294 |
+
continue
|
| 295 |
+
starts = [i for i in range(l + 1) if L.ends[i] >= l and not L.blank[i]]
|
| 296 |
+
if not starts or (tb is not None and l < tb[0]):
|
| 297 |
+
bad.append({**e, "why": "no_statement"})
|
| 298 |
+
continue
|
| 299 |
+
s = max(starts)
|
| 300 |
+
end = (L.ends[s], len(L.raw[L.ends[s]]))
|
| 301 |
+
st = (s, L.indent[s])
|
| 302 |
+
t = found.setdefault(("tactic", st), Target("tactic", st, end, _cut(L.raw, st, end), L.indent[s]))
|
| 303 |
+
t.errors.append(e)
|
| 304 |
+
ts = sorted(found.values(), key=lambda t: (t.start, t.end))
|
| 305 |
+
keep = []
|
| 306 |
+
for t in ts:
|
| 307 |
+
if t.kind != "tail" and any(o is not t and o.kind != "tail" and t.start <= o.start and o.end <= t.end and (t.start, t.end) != (o.start, o.end)
|
| 308 |
+
for o in ts):
|
| 309 |
+
continue # contains a finer target
|
| 310 |
+
keep.append(t)
|
| 311 |
+
return keep, bad
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
# --------------------------------------------------------------------------- skeleton
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
GAP = "⟪K5GAP{}⟫"
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
def build_skeleton(code: str, targets: list[Target]) -> tuple[str, list[tuple[int, int]]]:
|
| 321 |
+
"""(skeleton, [(line, col) of the i-th target's `sorry` in the skeleton]). Order of positions == order of
|
| 322 |
+
`targets` as given (they are applied bottom-up on a copy)."""
|
| 323 |
+
raw = code.split("\n")
|
| 324 |
+
order = sorted(range(len(targets)), key=lambda i: (targets[i].start, targets[i].end), reverse=True)
|
| 325 |
+
for i in order:
|
| 326 |
+
t = targets[i]
|
| 327 |
+
if t.kind == "have":
|
| 328 |
+
ins = " by " + GAP.format(i)
|
| 329 |
+
elif t.kind == "tactic":
|
| 330 |
+
ins = GAP.format(i)
|
| 331 |
+
else: # tail: a new line after the last proof line
|
| 332 |
+
raw.insert(t.start[0] + 1, " " * t.indent + GAP.format(i))
|
| 333 |
+
continue
|
| 334 |
+
a, b = t.start, t.end
|
| 335 |
+
raw[a[0]:b[0] + 1] = [raw[a[0]][:a[1]] + ins + raw[b[0]][b[1]:]]
|
| 336 |
+
text = "\n".join(raw)
|
| 337 |
+
pos: dict[int, tuple[int, int]] = {}
|
| 338 |
+
out_lines = []
|
| 339 |
+
for ln_no, ln in enumerate(text.split("\n")):
|
| 340 |
+
while True:
|
| 341 |
+
m = re.search(r"⟪K5GAP(\d+)⟫", ln)
|
| 342 |
+
if not m:
|
| 343 |
+
break
|
| 344 |
+
pos[int(m.group(1))] = (ln_no, m.start())
|
| 345 |
+
ln = ln[:m.start()] + "sorry" + ln[m.end():]
|
| 346 |
+
out_lines.append(ln)
|
| 347 |
+
return "\n".join(out_lines), [pos[i] for i in range(len(targets))]
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
# --------------------------------------------------------------------------- goal state -> lemma
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
_INACC = re.compile(r"✝[⁰¹²³⁴⁵⁶⁷⁸⁹]*$")
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
@dataclass
|
| 357 |
+
class GoalState:
|
| 358 |
+
case: Optional[str]
|
| 359 |
+
hyps: list[tuple[list[str], str]] # (names, type)
|
| 360 |
+
goal: str
|
| 361 |
+
has_let: bool = False
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
def parse_goal_state(text: str) -> GoalState:
|
| 365 |
+
"""Parse a Lean goal display (`case h` / `x y : T` / ... / `⊢ goal`). Wrapped lines (indented) are joined."""
|
| 366 |
+
lines = text.split("\n")
|
| 367 |
+
case = None
|
| 368 |
+
blocks: list[str] = []
|
| 369 |
+
goal_lines: list[str] = []
|
| 370 |
+
in_goal = False
|
| 371 |
+
for ln in lines:
|
| 372 |
+
if in_goal:
|
| 373 |
+
goal_lines.append(ln.strip())
|
| 374 |
+
elif ln.startswith("⊢"):
|
| 375 |
+
in_goal = True
|
| 376 |
+
goal_lines.append(ln[1:].strip())
|
| 377 |
+
elif ln.startswith("case ") and not blocks:
|
| 378 |
+
case = ln[5:].strip()
|
| 379 |
+
elif ln.startswith((" ", "\t")) and blocks:
|
| 380 |
+
blocks[-1] += " " + ln.strip()
|
| 381 |
+
elif ln.strip():
|
| 382 |
+
blocks.append(ln.strip())
|
| 383 |
+
hyps, has_let = [], False
|
| 384 |
+
for b in blocks:
|
| 385 |
+
names, sep, typ = b.partition(" : ")
|
| 386 |
+
if not sep:
|
| 387 |
+
continue
|
| 388 |
+
if re.search(r"\s:=\s", typ):
|
| 389 |
+
has_let = True
|
| 390 |
+
hyps.append((names.split(), typ.strip()))
|
| 391 |
+
return GoalState(case, hyps, " ".join(goal_lines), has_let)
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
@dataclass
|
| 395 |
+
class Lemma:
|
| 396 |
+
name: str
|
| 397 |
+
statement: str # `theorem name (binders) :\n goal := by sorry` (no header)
|
| 398 |
+
rename: list[str] # `rename_i` argument list ("_" for instances); [] when no hypothesis is inaccessible
|
| 399 |
+
|
| 400 |
+
|
| 401 |
+
def lemma_from_goal(gs: GoalState, name: str) -> Optional[Lemma]:
|
| 402 |
+
"""Standalone lemma for a goal state, or None when unsupported (let-bound hypotheses).
|
| 403 |
+
|
| 404 |
+
Inaccessible hypotheses (`n✝`, shadowed or auto-named) get fresh names `k5h<i>` and every later mention of the old name
|
| 405 |
+
in a type or the goal is rewritten, so the lemma is closed; `rename` lists them (and `_` for instances) in context order
|
| 406 |
+
for the `rename_i` that gives the inlined proof the same names."""
|
| 407 |
+
if gs.has_let or not gs.goal:
|
| 408 |
+
return None
|
| 409 |
+
mp: dict[str, str] = {}
|
| 410 |
+
k = 0
|
| 411 |
+
for names, _ in gs.hyps:
|
| 412 |
+
inst = all(_INACC.search(n) and n.startswith("inst") for n in names)
|
| 413 |
+
if inst:
|
| 414 |
+
continue
|
| 415 |
+
for n in names:
|
| 416 |
+
if _INACC.search(n):
|
| 417 |
+
k += 1
|
| 418 |
+
mp[n] = f"k5h{k}"
|
| 419 |
+
pats = sorted(mp, key=len, reverse=True)
|
| 420 |
+
|
| 421 |
+
def fix(text: str) -> str:
|
| 422 |
+
for old in pats:
|
| 423 |
+
text = re.sub(r"(?<![\w.])" + re.escape(old) + r"(?![\w⁰¹²³⁴⁵⁶⁷⁸⁹])", mp[old], text)
|
| 424 |
+
return text
|
| 425 |
+
|
| 426 |
+
binders, rename = [], []
|
| 427 |
+
for names, typ in gs.hyps:
|
| 428 |
+
inst = all(_INACC.search(n) and n.startswith("inst") for n in names)
|
| 429 |
+
typ = fix(typ)
|
| 430 |
+
for n in names:
|
| 431 |
+
if _INACC.search(n):
|
| 432 |
+
rename.append("_" if inst else mp[n])
|
| 433 |
+
if inst:
|
| 434 |
+
binders += [f"[{typ}]" for _ in names]
|
| 435 |
+
else:
|
| 436 |
+
binders.append(f"({' '.join(mp.get(n, n) for n in names)} : {typ})")
|
| 437 |
+
head = " ".join(["theorem", name] + binders)
|
| 438 |
+
return Lemma(name, f"{head} :\n {fix(gs.goal)} := by sorry", rename)
|
| 439 |
+
|
| 440 |
+
|
| 441 |
+
# --------------------------------------------------------------------------- the plan
|
| 442 |
+
|
| 443 |
+
|
| 444 |
+
@dataclass
|
| 445 |
+
class Gap:
|
| 446 |
+
target: Target
|
| 447 |
+
sorry_pos: tuple[int, int] # in the skeleton, full-code coordinates
|
| 448 |
+
goal_state: Optional[str] = None
|
| 449 |
+
lemma: Optional[Lemma] = None
|
| 450 |
+
why_not: Optional[str] = None
|
| 451 |
+
|
| 452 |
+
|
| 453 |
+
@dataclass
|
| 454 |
+
class SubgoalPlan:
|
| 455 |
+
code: str
|
| 456 |
+
skeleton: str
|
| 457 |
+
gaps: list[Gap]
|
| 458 |
+
unrepairable: list[dict]
|
| 459 |
+
residual_errors: list[dict] # errors the skeleton still has (outer failures: next round)
|
| 460 |
+
header: str # the header every lemma problem needs (comment-free)
|
| 461 |
+
|
| 462 |
+
@property
|
| 463 |
+
def lemmas(self) -> list[Gap]:
|
| 464 |
+
return [g for g in self.gaps if g.lemma is not None]
|
| 465 |
+
|
| 466 |
+
@property
|
| 467 |
+
def complete(self) -> bool:
|
| 468 |
+
"""Every gap has a lemma and the skeleton has no error of its own."""
|
| 469 |
+
return bool(self.gaps) and all(g.lemma for g in self.gaps) and not self.residual_errors and not self.unrepairable
|
| 470 |
+
|
| 471 |
+
|
| 472 |
+
Probe = Callable[[str], dict]
|
| 473 |
+
"""`probe(code) -> {"errors": [...], "sorries": [{"pos": {"line","column"}, "goal": str}, ...], "view_lines": int}`: runs
|
| 474 |
+
`compile_view(code)` in the statement's header environment (one REPL command) and returns the error messages and the
|
| 475 |
+
`sorries` the REPL reports. `repl_probe` builds one from a pool worker."""
|
| 476 |
+
|
| 477 |
+
MAX_GAPS = 6
|
| 478 |
+
|
| 479 |
+
|
| 480 |
+
def header_of(statement: str) -> str:
|
| 481 |
+
"""Everything before the first `theorem`/`lemma`, comments removed: the header every derived lemma must carry."""
|
| 482 |
+
m = re.search(r"\b(?:theorem|lemma)\b", statement)
|
| 483 |
+
head = statement[:m.start()] if m else statement
|
| 484 |
+
return strip_comments(head).rstrip() + "\n\n"
|
| 485 |
+
|
| 486 |
+
|
| 487 |
+
def _sorry_map(resp: dict, skeleton: str) -> dict:
|
| 488 |
+
vmap = view_line_map(skeleton)
|
| 489 |
+
out = {}
|
| 490 |
+
for s in resp.get("sorries", []):
|
| 491 |
+
p = s.get("pos") or {}
|
| 492 |
+
if "line" in p and 0 <= int(p["line"]) - 1 < len(vmap):
|
| 493 |
+
out[(vmap[int(p["line"]) - 1], int(p["column"]))] = s.get("goal", "")
|
| 494 |
+
return out
|
| 495 |
+
|
| 496 |
+
|
| 497 |
+
def plan_subgoals(statement: str, code: str, errors: list[dict], probe: Probe, parent_id: str = "p",
|
| 498 |
+
repl_code: Optional[str] = None, max_gaps: int = MAX_GAPS) -> SubgoalPlan:
|
| 499 |
+
"""Cut a failed proof into gaps (see the module docstring). Two passes: the have / tactic cuts first; the "proof ended
|
| 500 |
+
with goals left" tail cut only when the skeleton still shows that error (after a parse error Lean reports a cascade
|
| 501 |
+
of `unsolved goals` at the theorem's `by` that vanishes once the broken step is cut)."""
|
| 502 |
+
targets, bad = locate_targets(code, errors, repl_code)
|
| 503 |
+
header = header_of(statement)
|
| 504 |
+
if not targets or len(targets) > max_gaps:
|
| 505 |
+
return SubgoalPlan(code, code, [], bad + ([{"why": f"too_many_gaps:{len(targets)}"}] if targets else []), [], header)
|
| 506 |
+
main = [t for t in targets if t.kind != "tail"]
|
| 507 |
+
tails = [t for t in targets if t.kind == "tail"]
|
| 508 |
+
first = main or tails
|
| 509 |
+
skeleton, pos = build_skeleton(code, first)
|
| 510 |
+
resp = probe(skeleton)
|
| 511 |
+
tail_gap = None
|
| 512 |
+
if main and tails:
|
| 513 |
+
L2 = _analyse(skeleton)
|
| 514 |
+
tb2 = theorem_by_line(L2)
|
| 515 |
+
vmap2 = view_line_map(skeleton)
|
| 516 |
+
at_by = [e for e in resp.get("errors", []) if e.get("pos") and tb2 is not None
|
| 517 |
+
and 0 <= int(e["pos"]["line"]) - 1 < len(vmap2) and vmap2[int(e["pos"]["line"]) - 1] == tb2[0]]
|
| 518 |
+
if at_by:
|
| 519 |
+
tt = tail_target(L2, tb2)
|
| 520 |
+
tt.errors = at_by
|
| 521 |
+
skeleton2, pos2 = build_skeleton(skeleton, [tt])
|
| 522 |
+
tail_gap = (tt, pos2[0])
|
| 523 |
+
skeleton = skeleton2
|
| 524 |
+
resp = probe(skeleton)
|
| 525 |
+
sorries = _sorry_map(resp, skeleton)
|
| 526 |
+
gaps = []
|
| 527 |
+
for i, t in enumerate(first):
|
| 528 |
+
gaps.append(_make_gap(t, pos[i], sorries, parent_id))
|
| 529 |
+
if tail_gap is not None:
|
| 530 |
+
gaps.append(_make_gap(tail_gap[0], tail_gap[1], sorries, parent_id))
|
| 531 |
+
resid = [e for e in resp.get("errors", []) if isinstance(e, dict)]
|
| 532 |
+
return SubgoalPlan(code, skeleton, gaps, bad, resid, header)
|
| 533 |
+
|
| 534 |
+
|
| 535 |
+
def _make_gap(t: Target, pos: tuple[int, int], sorries: dict, parent_id: str) -> Gap:
|
| 536 |
+
g = Gap(t, pos)
|
| 537 |
+
goal = sorries.get(pos)
|
| 538 |
+
if goal is None:
|
| 539 |
+
g.why_not = "no_sorry_reported"
|
| 540 |
+
return g
|
| 541 |
+
g.goal_state = goal
|
| 542 |
+
h = hashlib.sha256((parent_id + "|" + goal).encode()).hexdigest()[:10]
|
| 543 |
+
lem = lemma_from_goal(parse_goal_state(goal), f"k5_sg_{h}")
|
| 544 |
+
if lem is None:
|
| 545 |
+
g.why_not = "unsupported_goal_state"
|
| 546 |
+
else:
|
| 547 |
+
g.lemma = lem
|
| 548 |
+
return g
|
| 549 |
+
|
| 550 |
+
|
| 551 |
+
def lemma_problem_statement(plan: SubgoalPlan, gap: Gap) -> str:
|
| 552 |
+
"""The reference statement for the prover: header + the lemma ending `:= by sorry` (what `lean_gen` problems carry)."""
|
| 553 |
+
assert gap.lemma is not None
|
| 554 |
+
return plan.header + gap.lemma.statement
|
| 555 |
+
|
| 556 |
+
|
| 557 |
+
# --------------------------------------------------------------------------- reassembly
|
| 558 |
+
|
| 559 |
+
|
| 560 |
+
_LEMMA_HEAD = re.compile(r"theorem\s+(\S+)[\s\S]*?:=\s*by", re.M)
|
| 561 |
+
|
| 562 |
+
|
| 563 |
+
def lemma_body(lemma_code: str, lemma_name: str) -> Optional[str]:
|
| 564 |
+
"""The tactic text after the lemma's own `:= by` in a prover-produced code (`lean_code` of a record), or None when
|
| 565 |
+
the code is not a proof of exactly that lemma (another declaration, no body). Comments are masked for the search."""
|
| 566 |
+
mk = mask(lemma_code)
|
| 567 |
+
m = None
|
| 568 |
+
for cand in re.finditer(r"theorem\s+" + re.escape(lemma_name) + r"\b[\s\S]*?:=\s*by", mk):
|
| 569 |
+
m = cand
|
| 570 |
+
break
|
| 571 |
+
if m is None:
|
| 572 |
+
return None
|
| 573 |
+
body = lemma_code[m.end():]
|
| 574 |
+
if re.search(r"^\s*(?:theorem|lemma)\s", mask(body), re.M):
|
| 575 |
+
return None # a second declaration: not an inline-able body
|
| 576 |
+
return body
|
| 577 |
+
|
| 578 |
+
|
| 579 |
+
def _indent_block(body: str, indent: int) -> list[str]:
|
| 580 |
+
ls = body.split("\n")
|
| 581 |
+
while ls and not ls[0].strip():
|
| 582 |
+
ls.pop(0)
|
| 583 |
+
while ls and not ls[-1].strip():
|
| 584 |
+
ls.pop()
|
| 585 |
+
if not ls:
|
| 586 |
+
return []
|
| 587 |
+
first = ls[0]
|
| 588 |
+
# `:= by simp` leaves the first tactic on the lemma's line: its text starts after one space
|
| 589 |
+
if body[:1] not in ("\n", "\r") and body.strip():
|
| 590 |
+
ls[0] = first.strip()
|
| 591 |
+
rest = [x for x in ls[1:]]
|
| 592 |
+
mi = min((len(x) - len(x.lstrip(" ")) for x in rest if x.strip()), default=0)
|
| 593 |
+
return [" " * indent + ls[0]] + [" " * indent + x[mi:] if x.strip() else "" for x in rest]
|
| 594 |
+
mi = min(len(x) - len(x.lstrip(" ")) for x in ls if x.strip())
|
| 595 |
+
return [" " * indent + x[mi:] if x.strip() else "" for x in ls]
|
| 596 |
+
|
| 597 |
+
|
| 598 |
+
def reassemble(plan: SubgoalPlan, bodies: dict[int, str], restore_unproven: bool = True) -> Optional[str]:
|
| 599 |
+
"""Full code with each proven gap's tactic body spliced in.
|
| 600 |
+
|
| 601 |
+
`bodies[i]` = tactic text for `plan.gaps[i]` (from `lemma_body`). An unproven gap gets its original text back when
|
| 602 |
+
`restore_unproven` (the result is then still a failing proof, but with every proven cut fixed: a better parent for
|
| 603 |
+
the next round), else the function returns None as soon as one is missing."""
|
| 604 |
+
raw = plan.skeleton.split("\n")
|
| 605 |
+
for i in sorted(range(len(plan.gaps)), key=lambda k: plan.gaps[k].sorry_pos, reverse=True):
|
| 606 |
+
g = plan.gaps[i]
|
| 607 |
+
ln, col = g.sorry_pos
|
| 608 |
+
line = raw[ln]
|
| 609 |
+
assert line[col:col + 5] == "sorry", "skeleton drifted from the plan"
|
| 610 |
+
body = bodies.get(i)
|
| 611 |
+
if body is None or not body.strip():
|
| 612 |
+
if not restore_unproven:
|
| 613 |
+
return None
|
| 614 |
+
if g.target.kind == "have": # the skeleton holds `... := by sorry`: put the original rhs back
|
| 615 |
+
raw[ln] = line[:col - len(" by ")] + g.target.original + line[col + 5:]
|
| 616 |
+
elif g.target.kind == "tactic":
|
| 617 |
+
raw[ln] = line[:col] + g.target.original + line[col + 5:]
|
| 618 |
+
else:
|
| 619 |
+
del raw[ln]
|
| 620 |
+
continue
|
| 621 |
+
pre = ["rename_i " + " ".join(g.lemma.rename)] if g.lemma is not None and g.lemma.rename else []
|
| 622 |
+
if g.target.kind == "have":
|
| 623 |
+
ind = g.target.indent + 2
|
| 624 |
+
raw[ln:ln + 1] = [line[:col].rstrip()] + [" " * ind + p for p in pre] + _indent_block(body, ind)
|
| 625 |
+
elif g.target.kind == "tactic":
|
| 626 |
+
blk = [" " * g.target.indent + p for p in pre] + _indent_block(body, g.target.indent)
|
| 627 |
+
raw[ln:ln + 1] = [line[:col] + blk[0].lstrip()] + blk[1:]
|
| 628 |
+
else: # tail: the inserted line holds only the gap
|
| 629 |
+
raw[ln:ln + 1] = [" " * g.target.indent + p for p in pre] + _indent_block(body, g.target.indent)
|
| 630 |
+
return "\n".join(raw)
|
| 631 |
+
|
| 632 |
+
|
| 633 |
+
# --------------------------------------------------------------------------- Lean (read-only use of align.verify.lean)
|
| 634 |
+
|
| 635 |
+
|
| 636 |
+
def repl_probe(worker: Any, statement: str, timeout_s: float = 120.0) -> Probe:
|
| 637 |
+
"""A `Probe` over one pool worker (a `Worker` from `ReplPool.acquire`/`map`): the statement's header environment is
|
| 638 |
+
built (cached by the worker) and `compile_view(code)` is run in it, exactly as `verify_generations.collect_feedback`
|
| 639 |
+
does. Read-only: no change to the verifier, the pool or its limits."""
|
| 640 |
+
from align.verify.lean.strict import parse_statement
|
| 641 |
+
|
| 642 |
+
ref = parse_statement(statement)
|
| 643 |
+
if not ref.ok:
|
| 644 |
+
raise ValueError(f"statement not supported by the strict parser: {ref.problem}")
|
| 645 |
+
|
| 646 |
+
def probe(code: str) -> dict:
|
| 647 |
+
view = "\n".join(code.split("\n")[i] for i in view_line_map(code))
|
| 648 |
+
env = worker.header_env(ref.header_text, "")
|
| 649 |
+
resp = worker.command(view, env, timeout_s)
|
| 650 |
+
errs = [{"severity": "error", "pos": m.get("pos"), "endPos": m.get("endPos"), "data": m.get("data", "")}
|
| 651 |
+
for m in resp.get("messages", []) if isinstance(m, dict) and m.get("severity") == "error"]
|
| 652 |
+
return {"errors": errs, "sorries": resp.get("sorries", []), "view": view}
|
| 653 |
+
|
| 654 |
+
return probe
|
| 655 |
+
|
| 656 |
+
|
| 657 |
+
def check_lemma_elaborates(worker: Any, plan: SubgoalPlan, gap: Gap, timeout_s: float = 120.0) -> bool:
|
| 658 |
+
"""True iff `lemma_problem_statement` elaborates (no error; the `sorry` warning is expected). Gaps whose lemma does
|
| 659 |
+
not elaborate (pretty-printer round-trip failures) are dropped by the caller, not sent to the prover."""
|
| 660 |
+
from align.verify.lean.strict import parse_statement
|
| 661 |
+
|
| 662 |
+
stmt = lemma_problem_statement(plan, gap)
|
| 663 |
+
ref = parse_statement(stmt)
|
| 664 |
+
if not ref.ok:
|
| 665 |
+
return False
|
| 666 |
+
env = worker.header_env(ref.header_text, "")
|
| 667 |
+
view = stmt[len(plan.header):]
|
| 668 |
+
resp = worker.command(view, env, timeout_s)
|
| 669 |
+
return not any(isinstance(m, dict) and m.get("severity") == "error" for m in resp.get("messages", []))
|
| 670 |
+
|
| 671 |
+
|
| 672 |
+
# --------------------------------------------------------------------------- (de)serialisation (route -> assemble hand-off)
|
| 673 |
+
|
| 674 |
+
|
| 675 |
+
def plan_to_dict(plan: SubgoalPlan) -> dict:
|
| 676 |
+
return {
|
| 677 |
+
"code": plan.code, "skeleton": plan.skeleton, "header": plan.header,
|
| 678 |
+
"unrepairable": plan.unrepairable, "residual_errors": plan.residual_errors,
|
| 679 |
+
"gaps": [{
|
| 680 |
+
"kind": g.target.kind, "start": list(g.target.start), "end": list(g.target.end), "original": g.target.original,
|
| 681 |
+
"indent": g.target.indent, "name": g.target.name, "sorry_pos": list(g.sorry_pos), "goal_state": g.goal_state,
|
| 682 |
+
"why_not": g.why_not,
|
| 683 |
+
"lemma": None if g.lemma is None else {"name": g.lemma.name, "statement": g.lemma.statement, "rename": g.lemma.rename},
|
| 684 |
+
} for g in plan.gaps],
|
| 685 |
+
}
|
| 686 |
+
|
| 687 |
+
|
| 688 |
+
def plan_from_dict(d: dict) -> SubgoalPlan:
|
| 689 |
+
gaps = []
|
| 690 |
+
for g in d["gaps"]:
|
| 691 |
+
t = Target(g["kind"], tuple(g["start"]), tuple(g["end"]), g["original"], g["indent"], g.get("name", ""))
|
| 692 |
+
lem = None if g["lemma"] is None else Lemma(g["lemma"]["name"], g["lemma"]["statement"], list(g["lemma"]["rename"]))
|
| 693 |
+
gaps.append(Gap(t, tuple(g["sorry_pos"]), g.get("goal_state"), lem, g.get("why_not")))
|
| 694 |
+
return SubgoalPlan(d["code"], d["skeleton"], gaps, d.get("unrepairable", []), d.get("residual_errors", []), d["header"])
|
code/align/rl/lean_rl.py
ADDED
|
@@ -0,0 +1,659 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Single-turn on-policy RL for Lean provers (lane R): config, rollout->batch, losses, gradient step.
|
| 2 |
+
|
| 3 |
+
The run loop, checkpoints, ledger and PEFT export live in `align.rl.lean_rl_run`; the CLI is
|
| 4 |
+
`scripts/p2/rl_lean.py`. Nothing here imports vLLM or runs Lean: generation and verification are
|
| 5 |
+
file contracts (the P2 Generation / Verification JSONL records), so this module is testable on CPU
|
| 6 |
+
with a fake generator and a fake verifier.
|
| 7 |
+
|
| 8 |
+
THREE LOGPROB BASES, kept apart and stamped (Codex P13-32, audit U04-3):
|
| 9 |
+
behaviour log pi_infer(token) recorded by the sampler at sampling time (`sampling_logprobs`).
|
| 10 |
+
Requires top_p == 1.0 and top_k disabled: under a truncated sampler the sampled
|
| 11 |
+
distribution is NOT softmax(logits/T), so no trainer-side ratio against it is meaningful.
|
| 12 |
+
old trainer logprobs at the rollout-time weights. Single optimizer step per iteration: this is
|
| 13 |
+
`current.detach()` (no extra forward); it is used ONLY for the engine-mismatch telemetry
|
| 14 |
+
|old - behaviour|, never as the importance denominator.
|
| 15 |
+
current the differentiable forward of the step.
|
| 16 |
+
The importance ratio is r = pi_current / pi_BEHAVIOUR, so the CISPO clamp (and the PPO clip of
|
| 17 |
+
`dr_grpo`) is a trust region against the policy that actually produced the tokens, including checkpoint
|
| 18 |
+
lag and the sampler/trainer kernel mismatch (U04-3). No separate rho factor exists to double count.
|
| 19 |
+
|
| 20 |
+
LOSS MODES (all token-level, all normalised by an EXPLICIT global token count, see `norm_count`):
|
| 21 |
+
cispo -A * sg(min(r, 1+eps_high)[, floor 1-eps_low]) * log pi (MiniMax-M1 Eq. 4-5)
|
| 22 |
+
dr_grpo PPO clip max(-A r, -A clip(r, 1-lo, 1+hi)), centered advantages, no std, no length norm
|
| 23 |
+
ei -log pi on ACCEPTED samples only, weight 1: the same pipeline with negatives dropped
|
| 24 |
+
(K1: positive-only SFT vs signed updates on the SAME cached rollouts, Codex P13-12)
|
| 25 |
+
Optional `neg_seq_ratio_floor` (cispo / dr_grpo): a sequence with A < 0 whose geometric-mean ratio is below
|
| 26 |
+
the floor is masked whole (pushing down what the policy already made unlikely is the unstable direction).
|
| 27 |
+
|
| 28 |
+
LOSS NORMALISATION (Codex P13-31). loss = sum_tokens(per-token loss) / N, N = the number of RESPONSE tokens
|
| 29 |
+
of every sample the batch builder kept, over ALL micro-batches of ALL ranks (each rank all-reduces its local
|
| 30 |
+
count and the result is checked against the count of the full list). Loss-time masks (negative-sequence
|
| 31 |
+
mask, ratio clamp) do not shrink N: a masked token still counts, exactly as a clipped token does in DAPO.
|
| 32 |
+
Consequence tested: the gradient is invariant to micro-batch partitioning and to the number of ranks.
|
| 33 |
+
|
| 34 |
+
REWARDS. accepted -> 1.0, rejected -> 0.0. A verdict whose reason is in `no_signal_reasons` (timeout, and
|
| 35 |
+
infra deaths) is MASKED: dropped from the batch AND from its group's baseline, never scored 0.
|
| 36 |
+
Advantages are group-centered over the valid samples only (Dr.GRPO: no /std).
|
| 37 |
+
"""
|
| 38 |
+
|
| 39 |
+
from __future__ import annotations
|
| 40 |
+
|
| 41 |
+
import dataclasses
|
| 42 |
+
import hashlib
|
| 43 |
+
import json
|
| 44 |
+
import math
|
| 45 |
+
import random
|
| 46 |
+
from dataclasses import dataclass, field
|
| 47 |
+
from typing import Any, Optional, Sequence
|
| 48 |
+
|
| 49 |
+
import numpy as np
|
| 50 |
+
import torch
|
| 51 |
+
|
| 52 |
+
from align.rl.advantages import DEAD_ATOL, GroupFilterStats
|
| 53 |
+
from align.rl.cispo import cispo_policy_loss, fp32_head_forward
|
| 54 |
+
from align.rl.ops import log_softmax_and_gather
|
| 55 |
+
|
| 56 |
+
LOSS_MODES = ("cispo", "dr_grpo", "ei")
|
| 57 |
+
NORMS = ("global_token_mean", "constant")
|
| 58 |
+
#: Verdict reasons that carry NO reward signal. `memory` / `output_too_large` are deliberately absent: a
|
| 59 |
+
#: proof that kills its REPL is the policy's doing and scores 0 (scripts/p2/verify_generations.py).
|
| 60 |
+
DEFAULT_NO_SIGNAL = ("timeout", "crash", "protocol")
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class RLConfigError(ValueError):
|
| 64 |
+
"""A config that cannot launch."""
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
# ---------------------------------------------------------------------------------------- config
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
@dataclass
|
| 71 |
+
class LossCfg:
|
| 72 |
+
mode: str = "cispo"
|
| 73 |
+
eps_high: float = 4.0 # CISPO IS-weight clamp (pre-registered sweep knob; paper does not state it)
|
| 74 |
+
eps_low: Optional[float] = None # None = no lower clamp (the paper's setting)
|
| 75 |
+
clip_lower: float = 0.2 # dr_grpo PPO clip
|
| 76 |
+
clip_higher: float = 0.28
|
| 77 |
+
neg_seq_ratio_floor: Optional[float] = None
|
| 78 |
+
norm: str = "global_token_mean"
|
| 79 |
+
constant: float = 0.0 # denominator when norm == "constant"
|
| 80 |
+
fp32_head: bool = False
|
| 81 |
+
fp32_head_chunk_tokens: int = 2048
|
| 82 |
+
temperature: float = 1.0
|
| 83 |
+
max_grad_norm: float = 1.0
|
| 84 |
+
|
| 85 |
+
def validate(self) -> None:
|
| 86 |
+
if self.mode not in LOSS_MODES:
|
| 87 |
+
raise RLConfigError(f"loss.mode={self.mode!r}: one of {LOSS_MODES}")
|
| 88 |
+
if self.norm not in NORMS:
|
| 89 |
+
raise RLConfigError(f"loss.norm={self.norm!r}: one of {NORMS}")
|
| 90 |
+
if self.norm == "constant" and not self.constant > 0:
|
| 91 |
+
raise RLConfigError("loss.norm=constant needs loss.constant > 0")
|
| 92 |
+
if not (self.eps_high > 0 and math.isfinite(self.eps_high)):
|
| 93 |
+
raise RLConfigError("loss.eps_high must be finite and > 0")
|
| 94 |
+
if self.eps_low is not None and not (self.eps_low > 0 and math.isfinite(self.eps_low)):
|
| 95 |
+
raise RLConfigError("loss.eps_low must be None or finite > 0")
|
| 96 |
+
if self.neg_seq_ratio_floor is not None and not (0 < self.neg_seq_ratio_floor < 1):
|
| 97 |
+
raise RLConfigError("loss.neg_seq_ratio_floor must be in (0, 1)")
|
| 98 |
+
if self.neg_seq_ratio_floor is not None and self.mode == "ei":
|
| 99 |
+
raise RLConfigError("neg_seq_ratio_floor is meaningless in ei mode (no negatives)")
|
| 100 |
+
if not self.temperature > 0:
|
| 101 |
+
raise RLConfigError("loss.temperature must be > 0")
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
@dataclass
|
| 105 |
+
class BatchCfg:
|
| 106 |
+
group_size: int = 8 # G rollouts per prompt
|
| 107 |
+
min_valid_per_group: int = 2 # after masking timeouts; fewer -> group dropped
|
| 108 |
+
no_signal_reasons: tuple = DEFAULT_NO_SIGNAL
|
| 109 |
+
#: ei only: keep the positives of all-solved groups (rejection-sampling FT keeps them); signed modes always
|
| 110 |
+
#: drop dead groups (zero advantage = zero gradient, wasted tokens).
|
| 111 |
+
ei_keep_all_solved: bool = True
|
| 112 |
+
micro_tokens: int = 16384 # padded tokens per micro-batch (rows x max len)
|
| 113 |
+
max_total_len: int = 0 # >0: drop samples whose prompt+completion exceeds this (counted)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
@dataclass
|
| 117 |
+
class RolloutCfg:
|
| 118 |
+
top_p: float = 1.0 # REQUIRED 1.0 (Codex P13-32)
|
| 119 |
+
top_k: int = 0
|
| 120 |
+
temperature: float = 1.0
|
| 121 |
+
cap_tokens: int = 16384
|
| 122 |
+
max_model_len: int = 20480
|
| 123 |
+
tp: int = 4
|
| 124 |
+
adapter_mode: str = "unmerged" # "unmerged" (vLLM LoRARequest) | "merged_export" (fallback)
|
| 125 |
+
rollout_python: str = ".venv-rollout/bin/python"
|
| 126 |
+
gen_extra_args: tuple = ()
|
| 127 |
+
|
| 128 |
+
def validate(self) -> None:
|
| 129 |
+
if self.top_p != 1.0:
|
| 130 |
+
raise RLConfigError(f"rollout.top_p={self.top_p}: RL rollouts need top_p == 1.0 (Codex P13-32)")
|
| 131 |
+
if self.top_k not in (0, -1):
|
| 132 |
+
raise RLConfigError(f"rollout.top_k={self.top_k}: must be 0/-1 (disabled)")
|
| 133 |
+
if self.adapter_mode not in ("unmerged", "merged_export"):
|
| 134 |
+
raise RLConfigError(f"rollout.adapter_mode={self.adapter_mode!r}")
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
@dataclass
|
| 138 |
+
class OptimCfg:
|
| 139 |
+
lr: float = 1e-5
|
| 140 |
+
betas: tuple = (0.9, 0.95)
|
| 141 |
+
weight_decay: float = 0.0
|
| 142 |
+
eps: float = 1e-8
|
| 143 |
+
warmup_iters: int = 0
|
| 144 |
+
steps_per_iteration: int = 1 # optimizer steps taken on one rollout batch
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
@dataclass
|
| 148 |
+
class LoraCfg:
|
| 149 |
+
r: int = 64
|
| 150 |
+
alpha: float = 128.0
|
| 151 |
+
rslora: bool = False
|
| 152 |
+
targets: tuple = ("q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj")
|
| 153 |
+
adapter: str = "policy"
|
| 154 |
+
param_dtype: str = "float32"
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
@dataclass
|
| 158 |
+
class RLConfig:
|
| 159 |
+
run_name: str = "p2-rl-lean"
|
| 160 |
+
run_dir: str = ""
|
| 161 |
+
model_snapshot: str = "" # HF snapshot dir of the base (Goedel-8B / Kimina-Distill-8B)
|
| 162 |
+
problems: str = "" # P2 problem jsonl (training pool)
|
| 163 |
+
iterations: int = 1
|
| 164 |
+
prompts_per_iter: int = 8
|
| 165 |
+
seed: int = 0
|
| 166 |
+
keep_checkpoints: int = 2
|
| 167 |
+
export_merged: bool = False
|
| 168 |
+
replay_from: str = ""
|
| 169 |
+
verify_workers: int = 8
|
| 170 |
+
verify_budget_s: float = 180.0
|
| 171 |
+
verify_python: str = ".venv/bin/python"
|
| 172 |
+
loss: LossCfg = field(default_factory=LossCfg)
|
| 173 |
+
batch: BatchCfg = field(default_factory=BatchCfg)
|
| 174 |
+
rollout: RolloutCfg = field(default_factory=RolloutCfg)
|
| 175 |
+
optim: OptimCfg = field(default_factory=OptimCfg)
|
| 176 |
+
lora: LoraCfg = field(default_factory=LoraCfg)
|
| 177 |
+
|
| 178 |
+
def validate(self) -> None:
|
| 179 |
+
self.loss.validate()
|
| 180 |
+
self.rollout.validate()
|
| 181 |
+
if self.loss.temperature != self.rollout.temperature:
|
| 182 |
+
raise RLConfigError(
|
| 183 |
+
f"loss.temperature={self.loss.temperature} != rollout.temperature={self.rollout.temperature}: the "
|
| 184 |
+
"trainer must score tokens under the distribution that sampled them"
|
| 185 |
+
)
|
| 186 |
+
if self.batch.group_size < 2:
|
| 187 |
+
raise RLConfigError("batch.group_size must be >= 2 (a group of 1 has no baseline)")
|
| 188 |
+
if self.batch.min_valid_per_group < 2:
|
| 189 |
+
raise RLConfigError("batch.min_valid_per_group must be >= 2")
|
| 190 |
+
if self.prompts_per_iter < 1 or self.iterations < 1:
|
| 191 |
+
raise RLConfigError("prompts_per_iter and iterations must be >= 1")
|
| 192 |
+
if self.optim.steps_per_iteration < 1:
|
| 193 |
+
raise RLConfigError("optim.steps_per_iteration must be >= 1")
|
| 194 |
+
if self.loss.mode == "cispo" and self.loss.fp32_head and self.loss.fp32_head_chunk_tokens < 1:
|
| 195 |
+
raise RLConfigError("fp32_head_chunk_tokens must be >= 1")
|
| 196 |
+
|
| 197 |
+
def fingerprint(self) -> str:
|
| 198 |
+
"""Hash of everything that changes the experiment (not paths of scratch). A resume under a different
|
| 199 |
+
fingerprint is a different run and is refused."""
|
| 200 |
+
d = dataclasses.asdict(self)
|
| 201 |
+
for k in ("run_dir", "run_name", "iterations", "replay_from", "verify_workers", "keep_checkpoints", "export_merged"):
|
| 202 |
+
d.pop(k, None)
|
| 203 |
+
return hashlib.sha256(json.dumps(d, sort_keys=True, default=str).encode()).hexdigest()
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
_SECTIONS = {"loss": LossCfg, "batch": BatchCfg, "rollout": RolloutCfg, "optim": OptimCfg, "lora": LoraCfg}
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def config_from_dict(d: dict) -> RLConfig:
|
| 210 |
+
"""Strict: an unknown key anywhere is an error (a typo'd knob silently keeping its default is how a
|
| 211 |
+
pre-registered setting gets lost)."""
|
| 212 |
+
d = dict(d)
|
| 213 |
+
kw: dict[str, Any] = {}
|
| 214 |
+
for name, cls in _SECTIONS.items():
|
| 215 |
+
sec = dict(d.pop(name, {}))
|
| 216 |
+
names = {f.name for f in dataclasses.fields(cls)}
|
| 217 |
+
bad = sorted(set(sec) - names)
|
| 218 |
+
if bad:
|
| 219 |
+
raise RLConfigError(f"[{name}] unknown keys {bad}")
|
| 220 |
+
for k, v in list(sec.items()):
|
| 221 |
+
if isinstance(v, list):
|
| 222 |
+
sec[k] = tuple(v)
|
| 223 |
+
kw[name] = cls(**sec)
|
| 224 |
+
names = {f.name for f in dataclasses.fields(RLConfig)} - set(_SECTIONS)
|
| 225 |
+
bad = sorted(set(d) - names)
|
| 226 |
+
if bad:
|
| 227 |
+
raise RLConfigError(f"unknown top-level keys {bad}")
|
| 228 |
+
cfg = RLConfig(**d, **kw)
|
| 229 |
+
cfg.validate()
|
| 230 |
+
return cfg
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def load_config(path: str, overrides: Sequence[str] = ()) -> RLConfig:
|
| 234 |
+
import tomllib
|
| 235 |
+
|
| 236 |
+
with open(path, "rb") as f:
|
| 237 |
+
d = tomllib.load(f)
|
| 238 |
+
for ov in overrides:
|
| 239 |
+
key, _, raw = ov.partition("=")
|
| 240 |
+
try:
|
| 241 |
+
val = tomllib.loads(f"v = {raw}")["v"]
|
| 242 |
+
except tomllib.TOMLDecodeError:
|
| 243 |
+
val = raw
|
| 244 |
+
cur = d
|
| 245 |
+
parts = key.split(".")
|
| 246 |
+
for p in parts[:-1]:
|
| 247 |
+
cur = cur.setdefault(p, {})
|
| 248 |
+
cur[parts[-1]] = val
|
| 249 |
+
return config_from_dict(d)
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
# ------------------------------------------------------------------------- prompt stream (U04-2)
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def prompt_block(ids: Sequence[str], seed: int, per_iter: int, k: int) -> list[str]:
|
| 256 |
+
"""The prompt ids of iteration `k`: a PURE function of (ids, seed, per_iter, k).
|
| 257 |
+
|
| 258 |
+
Replays the stream from the start (cheap: id lists). Each pass over the set is an independent shuffle
|
| 259 |
+
seeded by (seed, pass index) -- epoch-indexed, so passes differ -- and the tail of one pass is carried
|
| 260 |
+
into the next block (nothing is dropped, every prompt is drawn once per pass). A block never repeats a
|
| 261 |
+
prompt unless it is larger than the pool.
|
| 262 |
+
"""
|
| 263 |
+
n = len(ids)
|
| 264 |
+
if n == 0:
|
| 265 |
+
raise ValueError("empty prompt pool")
|
| 266 |
+
order: list[int] = []
|
| 267 |
+
cursor = 0
|
| 268 |
+
passes = 0
|
| 269 |
+
out: list[str] = []
|
| 270 |
+
for it in range(k + 1):
|
| 271 |
+
block: list[int] = []
|
| 272 |
+
while len(block) < per_iter:
|
| 273 |
+
if cursor >= len(order):
|
| 274 |
+
order = list(range(n))
|
| 275 |
+
random.Random(seed * 1000003 + passes * 7919 + 11).shuffle(order)
|
| 276 |
+
passes += 1
|
| 277 |
+
cursor = 0
|
| 278 |
+
i = order[cursor]
|
| 279 |
+
if per_iter <= n and i in block:
|
| 280 |
+
for j in range(cursor + 1, len(order)):
|
| 281 |
+
if order[j] not in block:
|
| 282 |
+
order[cursor], order[j] = order[j], order[cursor]
|
| 283 |
+
i = order[cursor]
|
| 284 |
+
break
|
| 285 |
+
cursor += 1
|
| 286 |
+
block.append(i)
|
| 287 |
+
out = [ids[i] for i in block]
|
| 288 |
+
return out
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
def stream_position(n_pool: int, per_iter: int, k: int) -> dict:
|
| 292 |
+
"""Position stored in the checkpoint (draws made, pass index); recomputable, kept as a cross-check."""
|
| 293 |
+
draws = k * per_iter
|
| 294 |
+
return {"draws": draws, "pass": draws // n_pool, "offset": draws % n_pool}
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
# -------------------------------------------------------------------------------- rollout -> batch
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
@dataclass
|
| 301 |
+
class Sample:
|
| 302 |
+
key: tuple # (problem_id, sample_idx, round, cap_tokens)
|
| 303 |
+
group: str # problem_id
|
| 304 |
+
prompt_ids: list
|
| 305 |
+
completion_ids: list
|
| 306 |
+
behaviour_lp: list # one per completion token
|
| 307 |
+
reward: float
|
| 308 |
+
adv: float = 0.0
|
| 309 |
+
|
| 310 |
+
@property
|
| 311 |
+
def n_resp(self) -> int:
|
| 312 |
+
return len(self.completion_ids)
|
| 313 |
+
|
| 314 |
+
@property
|
| 315 |
+
def total_len(self) -> int:
|
| 316 |
+
return len(self.prompt_ids) + len(self.completion_ids)
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
def validate_rl_generation(rec: dict) -> None:
|
| 320 |
+
"""The rollout-time contract an RL record must meet (raises with the key named)."""
|
| 321 |
+
key = (rec.get("problem_id"), rec.get("sample_idx"))
|
| 322 |
+
s = rec.get("sampling") or {}
|
| 323 |
+
if s.get("top_p") != 1.0:
|
| 324 |
+
raise RLConfigError(f"{key}: sampling.top_p={s.get('top_p')!r}; RL rollouts require top_p == 1.0")
|
| 325 |
+
if s.get("top_k") not in (0, -1, None):
|
| 326 |
+
raise RLConfigError(f"{key}: sampling.top_k={s.get('top_k')!r}; RL rollouts require top_k disabled")
|
| 327 |
+
lp = rec.get("sampling_logprobs")
|
| 328 |
+
comp = rec.get("completion_ids")
|
| 329 |
+
if not isinstance(lp, list) or not isinstance(comp, list) or len(lp) != len(comp) or not comp:
|
| 330 |
+
raise RLConfigError(f"{key}: sampling_logprobs must be a list aligned with completion_ids")
|
| 331 |
+
arr = np.asarray(lp, dtype=np.float64)
|
| 332 |
+
if not np.all(np.isfinite(arr)) or np.any(arr > 1e-4):
|
| 333 |
+
raise RLConfigError(f"{key}: sampling_logprobs must be finite and <= 0")
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
def reward_of(ver: dict, no_signal: Sequence[str]) -> tuple[Optional[float], Optional[str]]:
|
| 337 |
+
"""(reward, None) or (None, why-masked). Timeouts / infra deaths carry NO signal."""
|
| 338 |
+
reasons = list(ver.get("reasons") or [])
|
| 339 |
+
if ver.get("accepted"):
|
| 340 |
+
return 1.0, None
|
| 341 |
+
hit = [r for r in reasons if r in no_signal]
|
| 342 |
+
if hit:
|
| 343 |
+
return None, hit[0]
|
| 344 |
+
return 0.0, None
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
def build_samples(gens: Sequence[dict], vers: dict, cfg: RLConfig) -> tuple[list[Sample], dict]:
|
| 348 |
+
"""Join generations with verdicts -> trainable samples + telemetry.
|
| 349 |
+
|
| 350 |
+
Dead groups (all-rewards-equal over the VALID samples) are dropped in signed modes; `ei` keeps the
|
| 351 |
+
positives of all-solved groups when `batch.ei_keep_all_solved`. Masked (no-signal) samples are removed
|
| 352 |
+
before the baseline is computed.
|
| 353 |
+
"""
|
| 354 |
+
b = cfg.batch
|
| 355 |
+
stats = {"samples": len(gens), "masked": collections_counter(), "groups": 0, "groups_too_few_valid": 0,
|
| 356 |
+
"dropped_overlength": 0, "accepted": 0, "valid": 0}
|
| 357 |
+
by_group: dict[str, list[Sample]] = {}
|
| 358 |
+
for g in gens:
|
| 359 |
+
validate_rl_generation(g)
|
| 360 |
+
key = (g["problem_id"], g["sample_idx"], g["round"], g["cap_tokens"])
|
| 361 |
+
v = vers.get(key)
|
| 362 |
+
if v is None:
|
| 363 |
+
raise RLConfigError(f"{key}: generation has no verification record (verify before training)")
|
| 364 |
+
r, why = reward_of(v, b.no_signal_reasons)
|
| 365 |
+
if r is None:
|
| 366 |
+
stats["masked"][why] += 1
|
| 367 |
+
continue
|
| 368 |
+
s = Sample(key, g["problem_id"], list(g["prompt_ids"]), list(g["completion_ids"]),
|
| 369 |
+
[float(x) for x in g["sampling_logprobs"]], r)
|
| 370 |
+
if b.max_total_len and s.total_len > b.max_total_len:
|
| 371 |
+
stats["dropped_overlength"] += 1
|
| 372 |
+
continue
|
| 373 |
+
stats["valid"] += 1
|
| 374 |
+
stats["accepted"] += int(r == 1.0)
|
| 375 |
+
by_group.setdefault(g["problem_id"], []).append(s)
|
| 376 |
+
fstats = GroupFilterStats(max_possible_score=1.0)
|
| 377 |
+
out: list[Sample] = []
|
| 378 |
+
for pid in sorted(by_group):
|
| 379 |
+
grp = sorted(by_group[pid], key=lambda s: s.key)
|
| 380 |
+
stats["groups"] += 1
|
| 381 |
+
if len(grp) < b.min_valid_per_group:
|
| 382 |
+
stats["groups_too_few_valid"] += 1
|
| 383 |
+
continue
|
| 384 |
+
rewards = np.array([s.reward for s in grp])
|
| 385 |
+
dead = fstats.observe(rewards)
|
| 386 |
+
if cfg.loss.mode == "ei":
|
| 387 |
+
keep = [s for s in grp if s.reward == 1.0]
|
| 388 |
+
all_solved = bool(np.all(rewards == 1.0))
|
| 389 |
+
if not keep or (all_solved and not b.ei_keep_all_solved):
|
| 390 |
+
continue
|
| 391 |
+
for s in keep:
|
| 392 |
+
s.adv = 1.0
|
| 393 |
+
out.extend(keep)
|
| 394 |
+
continue
|
| 395 |
+
if dead:
|
| 396 |
+
continue
|
| 397 |
+
mean = float(rewards.mean())
|
| 398 |
+
for s in grp:
|
| 399 |
+
s.adv = s.reward - mean
|
| 400 |
+
out.extend(grp)
|
| 401 |
+
stats["masked"] = dict(stats["masked"])
|
| 402 |
+
stats["group_filter"] = fstats.as_log_dict()
|
| 403 |
+
stats["kept_samples"] = len(out)
|
| 404 |
+
stats["kept_resp_tokens"] = sum(s.n_resp for s in out)
|
| 405 |
+
return out, stats
|
| 406 |
+
|
| 407 |
+
|
| 408 |
+
def collections_counter():
|
| 409 |
+
import collections
|
| 410 |
+
|
| 411 |
+
return collections.Counter()
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
# ------------------------------------------------------------------------------- sharding / packing
|
| 415 |
+
|
| 416 |
+
|
| 417 |
+
def shard_samples(samples: Sequence[Sample], world: int) -> list[list[Sample]]:
|
| 418 |
+
"""Deterministic length-balanced split (longest-first onto the lightest rank)."""
|
| 419 |
+
order = sorted(samples, key=lambda s: (-s.total_len, s.key))
|
| 420 |
+
loads = [0] * world
|
| 421 |
+
shards: list[list[Sample]] = [[] for _ in range(world)]
|
| 422 |
+
for s in order:
|
| 423 |
+
r = min(range(world), key=lambda i: (loads[i], i))
|
| 424 |
+
shards[r].append(s)
|
| 425 |
+
loads[r] += s.total_len
|
| 426 |
+
return shards
|
| 427 |
+
|
| 428 |
+
|
| 429 |
+
@dataclass
|
| 430 |
+
class Micro:
|
| 431 |
+
input_ids: torch.Tensor # [B, T]
|
| 432 |
+
attention_mask: torch.Tensor # [B, T]
|
| 433 |
+
resp: torch.Tensor # [B, T-1] bool, label space
|
| 434 |
+
behaviour: torch.Tensor # [B, T-1]
|
| 435 |
+
adv: torch.Tensor # [B]
|
| 436 |
+
dummy: bool = False
|
| 437 |
+
|
| 438 |
+
@property
|
| 439 |
+
def n_resp(self) -> int:
|
| 440 |
+
return 0 if self.dummy else int(self.resp.sum())
|
| 441 |
+
|
| 442 |
+
|
| 443 |
+
def pack_micros(samples: Sequence[Sample], micro_tokens: int, pad_id: int = 0, dtype=torch.float32) -> list[Micro]:
|
| 444 |
+
"""Token-budget micro-batches (rows x padded max length <= micro_tokens; a longer lone row gets its own)."""
|
| 445 |
+
rows = sorted(samples, key=lambda s: (s.total_len, s.key))
|
| 446 |
+
micros: list[Micro] = []
|
| 447 |
+
cur: list[Sample] = []
|
| 448 |
+
cur_max = 0
|
| 449 |
+
for s in rows:
|
| 450 |
+
m = max(cur_max, s.total_len)
|
| 451 |
+
if cur and m * (len(cur) + 1) > micro_tokens:
|
| 452 |
+
micros.append(_collate(cur, pad_id, dtype))
|
| 453 |
+
cur, m = [], s.total_len
|
| 454 |
+
cur.append(s)
|
| 455 |
+
cur_max = m
|
| 456 |
+
if cur:
|
| 457 |
+
micros.append(_collate(cur, pad_id, dtype))
|
| 458 |
+
return micros
|
| 459 |
+
|
| 460 |
+
|
| 461 |
+
def _collate(rows: Sequence[Sample], pad_id: int, dtype) -> Micro:
|
| 462 |
+
T = max(s.total_len for s in rows)
|
| 463 |
+
B = len(rows)
|
| 464 |
+
ids = torch.full((B, T), pad_id, dtype=torch.int64)
|
| 465 |
+
att = torch.zeros((B, T), dtype=torch.int64)
|
| 466 |
+
resp = torch.zeros((B, T - 1), dtype=torch.bool)
|
| 467 |
+
beh = torch.zeros((B, T - 1), dtype=dtype)
|
| 468 |
+
adv = torch.zeros(B, dtype=dtype)
|
| 469 |
+
for i, s in enumerate(rows):
|
| 470 |
+
n = s.total_len
|
| 471 |
+
ids[i, :n] = torch.tensor(s.prompt_ids + s.completion_ids)
|
| 472 |
+
att[i, :n] = 1
|
| 473 |
+
p = len(s.prompt_ids)
|
| 474 |
+
resp[i, p - 1 : n - 1] = True # label t+1 in the completion
|
| 475 |
+
beh[i, p - 1 : n - 1] = torch.tensor(s.behaviour_lp, dtype=dtype)
|
| 476 |
+
adv[i] = s.adv
|
| 477 |
+
return Micro(ids, att, resp, beh, adv)
|
| 478 |
+
|
| 479 |
+
|
| 480 |
+
def dummy_micro(like: Optional[Micro], pad_id: int = 0, dtype=torch.float32) -> Micro:
|
| 481 |
+
"""A micro that trains on nothing but runs the same forward/backward (so every rank issues the same
|
| 482 |
+
collectives): one row, empty response mask, zero advantage."""
|
| 483 |
+
if like is not None:
|
| 484 |
+
n = int(like.attention_mask[0].sum())
|
| 485 |
+
ids = like.input_ids[:1, :n].clone()
|
| 486 |
+
else:
|
| 487 |
+
ids = torch.full((1, 2), pad_id, dtype=torch.int64)
|
| 488 |
+
T = ids.shape[1]
|
| 489 |
+
return Micro(ids, torch.ones_like(ids), torch.zeros((1, T - 1), dtype=torch.bool),
|
| 490 |
+
torch.zeros((1, T - 1), dtype=dtype), torch.zeros(1, dtype=dtype), dummy=True)
|
| 491 |
+
|
| 492 |
+
|
| 493 |
+
# ---------------------------------------------------------------------------------------- the loss
|
| 494 |
+
|
| 495 |
+
|
| 496 |
+
def token_loss(new_lp: torch.Tensor, behaviour: torch.Tensor, adv: torch.Tensor, resp: torch.Tensor,
|
| 497 |
+
cfg: LossCfg) -> tuple[torch.Tensor, dict]:
|
| 498 |
+
"""Unreduced per-token loss [B, T-1] (zero off the response mask) + diagnostic tensors.
|
| 499 |
+
|
| 500 |
+
ratio = exp(new - behaviour): importance weight against the BEHAVIOUR policy (U04-3).
|
| 501 |
+
"""
|
| 502 |
+
m = resp.to(new_lp.dtype)
|
| 503 |
+
A = adv.to(new_lp.dtype).unsqueeze(-1).expand_as(new_lp)
|
| 504 |
+
diff = (new_lp - behaviour).clamp(-20.0, 20.0)
|
| 505 |
+
ratio = torch.exp(diff)
|
| 506 |
+
clipfrac = torch.zeros_like(new_lp)
|
| 507 |
+
row_w = torch.ones(new_lp.shape[0], dtype=new_lp.dtype, device=new_lp.device)
|
| 508 |
+
if cfg.mode == "ei":
|
| 509 |
+
loss = -new_lp
|
| 510 |
+
else:
|
| 511 |
+
if cfg.mode == "cispo":
|
| 512 |
+
loss, clipfrac = cispo_policy_loss(new_lp, ratio, A, cfg.eps_high, cfg.eps_low)
|
| 513 |
+
else: # dr_grpo
|
| 514 |
+
l1 = -A * ratio
|
| 515 |
+
l2 = -A * torch.clamp(ratio, 1.0 - cfg.clip_lower, 1.0 + cfg.clip_higher)
|
| 516 |
+
loss = torch.max(l1, l2)
|
| 517 |
+
clipfrac = (l2 > l1).to(new_lp.dtype)
|
| 518 |
+
if cfg.neg_seq_ratio_floor is not None:
|
| 519 |
+
n = m.sum(-1).clamp_min(1.0)
|
| 520 |
+
geo = torch.exp((diff.detach() * m).sum(-1) / n)
|
| 521 |
+
drop = (adv < 0) & (geo < cfg.neg_seq_ratio_floor)
|
| 522 |
+
row_w = (~drop).to(new_lp.dtype)
|
| 523 |
+
loss = loss * m * row_w.unsqueeze(-1)
|
| 524 |
+
return loss, {"ratio": ratio.detach(), "clipfrac": clipfrac.detach() * m, "row_w": row_w.detach()}
|
| 525 |
+
|
| 526 |
+
|
| 527 |
+
def policy_logprobs(model, mb: Micro, cfg: LossCfg) -> torch.Tensor:
|
| 528 |
+
"""Label-aligned [B, T-1] logprobs of the batch's own tokens under the CURRENT policy."""
|
| 529 |
+
if cfg.fp32_head:
|
| 530 |
+
return fp32_head_forward(model, mb.input_ids, mb.attention_mask, cfg.temperature, cfg.fp32_head_chunk_tokens)
|
| 531 |
+
out = model(input_ids=mb.input_ids, attention_mask=mb.attention_mask)
|
| 532 |
+
logits = getattr(out, "logits", out)
|
| 533 |
+
if cfg.temperature != 1.0:
|
| 534 |
+
logits = logits / cfg.temperature
|
| 535 |
+
return log_softmax_and_gather(logits[:, :-1], mb.input_ids[:, 1:])
|
| 536 |
+
|
| 537 |
+
|
| 538 |
+
# ------------------------------------------------------------------------------- distributed bits
|
| 539 |
+
|
| 540 |
+
|
| 541 |
+
def _dist_on() -> bool:
|
| 542 |
+
import torch.distributed as dist
|
| 543 |
+
|
| 544 |
+
return dist.is_available() and dist.is_initialized()
|
| 545 |
+
|
| 546 |
+
|
| 547 |
+
def world_size() -> int:
|
| 548 |
+
import torch.distributed as dist
|
| 549 |
+
|
| 550 |
+
return dist.get_world_size() if _dist_on() else 1
|
| 551 |
+
|
| 552 |
+
|
| 553 |
+
def all_reduce_ints(vals: list[int], op: str = "sum") -> list[int]:
|
| 554 |
+
if not _dist_on():
|
| 555 |
+
return [int(v) for v in vals]
|
| 556 |
+
import torch.distributed as dist
|
| 557 |
+
|
| 558 |
+
from align.utils.dist import gloo_group
|
| 559 |
+
|
| 560 |
+
g = gloo_group() or dist.group.WORLD
|
| 561 |
+
t = torch.tensor([int(v) for v in vals], dtype=torch.int64)
|
| 562 |
+
dist.all_reduce(t, op=dist.ReduceOp.SUM if op == "sum" else dist.ReduceOp.MAX, group=g)
|
| 563 |
+
return [int(v) for v in t.tolist()]
|
| 564 |
+
|
| 565 |
+
|
| 566 |
+
def all_reduce_floats(vals: list[float]) -> list[float]:
|
| 567 |
+
if not _dist_on():
|
| 568 |
+
return [float(v) for v in vals]
|
| 569 |
+
import torch.distributed as dist
|
| 570 |
+
|
| 571 |
+
from align.utils.dist import gloo_group
|
| 572 |
+
|
| 573 |
+
g = gloo_group() or dist.group.WORLD
|
| 574 |
+
t = torch.tensor([float(v) for v in vals], dtype=torch.float64)
|
| 575 |
+
dist.all_reduce(t, op=dist.ReduceOp.SUM, group=g)
|
| 576 |
+
return [float(v) for v in t.tolist()]
|
| 577 |
+
|
| 578 |
+
|
| 579 |
+
def norm_count(local_resp_tokens: int, cfg: LossCfg, expect_total: Optional[int] = None) -> float:
|
| 580 |
+
"""The explicit loss denominator N (module docstring). Global = SUM over ranks of the local response-token
|
| 581 |
+
counts, verified against the full-list count when the caller can supply it."""
|
| 582 |
+
if cfg.norm == "constant":
|
| 583 |
+
return float(cfg.constant)
|
| 584 |
+
total = all_reduce_ints([local_resp_tokens])[0]
|
| 585 |
+
if expect_total is not None and total != expect_total:
|
| 586 |
+
raise RuntimeError(f"global token count {total} != full-batch count {expect_total}: ranks hold different data")
|
| 587 |
+
if total <= 0:
|
| 588 |
+
raise RuntimeError("global response-token count is 0")
|
| 589 |
+
return float(total)
|
| 590 |
+
|
| 591 |
+
|
| 592 |
+
def _is_sharded(params: Sequence[torch.nn.Parameter]) -> bool:
|
| 593 |
+
return any(hasattr(p, "to_local") for p in params)
|
| 594 |
+
|
| 595 |
+
|
| 596 |
+
def accumulate_gradients(model, micros: Sequence[Micro], cfg: LossCfg, denom: float,
|
| 597 |
+
params: Sequence[torch.nn.Parameter], pad_id: int = 0) -> dict:
|
| 598 |
+
"""Forward+backward over this rank's micro-batches with the GLOBAL denominator; returns summed stats.
|
| 599 |
+
|
| 600 |
+
Every rank runs the same number of micro-steps (agreed by MAX; short ranks run zero-weight dummies).
|
| 601 |
+
Gradient scale: loss_i / N. FSDP2 reduce-scatter AVERAGES over ranks, so a sharded model multiplies by the
|
| 602 |
+
world size; a replicated model (CPU tests, single GPU) sums gradients explicitly with an all-reduce SUM.
|
| 603 |
+
Either way grad = d/dtheta [ sum over ALL ranks/micros/tokens of per-token loss / N ].
|
| 604 |
+
"""
|
| 605 |
+
world = world_size()
|
| 606 |
+
n_agreed = all_reduce_ints([len(micros)], "max")[0]
|
| 607 |
+
sharded = _is_sharded(params)
|
| 608 |
+
scale = float(world) if (sharded and world > 1) else 1.0
|
| 609 |
+
like = micros[0] if micros else None
|
| 610 |
+
dtype = like.behaviour.dtype if like is not None else torch.float32
|
| 611 |
+
acc = {"loss_sum": 0.0, "resp_tokens": 0.0, "clip_tokens": 0.0, "ratio_sum": 0.0, "rows": 0.0,
|
| 612 |
+
"rows_masked_negseq": 0.0, "mismatch_abs_sum": 0.0, "adv_pos_rows": 0.0, "entropy_proxy": 0.0}
|
| 613 |
+
for i in range(n_agreed):
|
| 614 |
+
mb = micros[i] if i < len(micros) else dummy_micro(like, pad_id, dtype)
|
| 615 |
+
new_lp = policy_logprobs(model, mb, cfg)
|
| 616 |
+
tok, d = token_loss(new_lp, mb.behaviour.to(new_lp.dtype), mb.adv, mb.resp, cfg)
|
| 617 |
+
loss = tok.sum() / denom * scale
|
| 618 |
+
loss.backward()
|
| 619 |
+
with torch.no_grad():
|
| 620 |
+
m = mb.resp.to(torch.float64)
|
| 621 |
+
acc["loss_sum"] += float(tok.detach().double().sum())
|
| 622 |
+
acc["resp_tokens"] += float(m.sum())
|
| 623 |
+
acc["clip_tokens"] += float((d["clipfrac"].double() * m).sum())
|
| 624 |
+
acc["ratio_sum"] += float((d["ratio"].double() * m).sum())
|
| 625 |
+
acc["mismatch_abs_sum"] += float(((new_lp.detach() - mb.behaviour.to(new_lp.dtype)).abs().double() * m).sum())
|
| 626 |
+
if not mb.dummy:
|
| 627 |
+
acc["rows"] += float(mb.input_ids.shape[0])
|
| 628 |
+
acc["rows_masked_negseq"] += float((d["row_w"] == 0).sum())
|
| 629 |
+
acc["adv_pos_rows"] += float((mb.adv > 0).sum())
|
| 630 |
+
if world > 1 and not sharded:
|
| 631 |
+
import torch.distributed as dist
|
| 632 |
+
|
| 633 |
+
from align.utils.dist import gloo_group
|
| 634 |
+
|
| 635 |
+
g = gloo_group() or dist.group.WORLD
|
| 636 |
+
for p in params:
|
| 637 |
+
if p.grad is None:
|
| 638 |
+
p.grad = torch.zeros_like(p)
|
| 639 |
+
dist.all_reduce(p.grad, op=dist.ReduceOp.SUM, group=g)
|
| 640 |
+
keys = sorted(acc)
|
| 641 |
+
red = all_reduce_floats([acc[k] for k in keys])
|
| 642 |
+
return dict(zip(keys, red))
|
| 643 |
+
|
| 644 |
+
|
| 645 |
+
def summarize(stats: dict, denom: float, grad_norm: float, cfg: LossCfg) -> dict:
|
| 646 |
+
t = max(stats["resp_tokens"], 1.0)
|
| 647 |
+
return {
|
| 648 |
+
"loss": stats["loss_sum"] / denom,
|
| 649 |
+
"denominator": denom,
|
| 650 |
+
"resp_tokens": stats["resp_tokens"],
|
| 651 |
+
"rows": stats["rows"],
|
| 652 |
+
"clipfrac": stats["clip_tokens"] / t,
|
| 653 |
+
"ratio_mean": stats["ratio_sum"] / t,
|
| 654 |
+
# engine mismatch |log pi_current(=old at step 0) - log pi_behaviour|, mean over response tokens
|
| 655 |
+
"old_vs_behaviour_abs_logratio": stats["mismatch_abs_sum"] / t,
|
| 656 |
+
"negseq_masked_rows": stats["rows_masked_negseq"],
|
| 657 |
+
"grad_norm": grad_norm,
|
| 658 |
+
"loss_mode": cfg.mode,
|
| 659 |
+
}
|
code/align/rl/lean_rl_run.py
ADDED
|
@@ -0,0 +1,716 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Lean RL run loop: iteration stages, atomic checkpoints, PEFT export, budget ledger, backends.
|
| 2 |
+
|
| 3 |
+
iteration k (policy = ckpt/iter_k's adapter):
|
| 4 |
+
gen generator.generate -> iters/iter_k/gens.jsonl (CURRENT policy; behaviour logprobs recorded)
|
| 5 |
+
verify verifier.verify -> iters/iter_k/ver.jsonl
|
| 6 |
+
train trainer.train(k) -> ckpt/iter_{k+1}/ (adapter, optimizer, RNG, stream, ledger)
|
| 7 |
+
|
| 8 |
+
Every stage is idempotent and commits with a marker (`gen.done`, `ver.done`) or a directory rename
|
| 9 |
+
(checkpoints), so a kill at any point resumes into the same state: a killed `train` leaves only a `.tmp_*`
|
| 10 |
+
directory, which is deleted, and the stage re-runs from the same gens/ver files and the same ckpt k.
|
| 11 |
+
|
| 12 |
+
GPU HANDOFF. The default real flow keeps the loop process CPU-only: generation (vLLM, tp=4) and training
|
| 13 |
+
(torchrun, FSDP2) are separate child processes that run one after the other, so neither holds the GPUs while the
|
| 14 |
+
other runs; the loop itself never imports a model. `InProcessTrainer` (tests, single-device runs) calls
|
| 15 |
+
`train_iteration` directly. This module imports torch/numpy/safetensors/stdlib/align only.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import contextlib
|
| 21 |
+
import hashlib
|
| 22 |
+
import json
|
| 23 |
+
import os
|
| 24 |
+
import shutil
|
| 25 |
+
import subprocess
|
| 26 |
+
import sys
|
| 27 |
+
import time
|
| 28 |
+
from typing import Callable, Optional
|
| 29 |
+
|
| 30 |
+
import torch
|
| 31 |
+
|
| 32 |
+
from align.rl import lean_rl as L
|
| 33 |
+
from align.utils.dist import capture_rng_state, restore_rng_state
|
| 34 |
+
|
| 35 |
+
ADAPTER_NAME_PEFT = "adapter_model.safetensors"
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
# ------------------------------------------------------------------------------------ fs helpers
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def fsync_dir(path: str) -> None:
|
| 42 |
+
fd = os.open(path, os.O_RDONLY)
|
| 43 |
+
try:
|
| 44 |
+
os.fsync(fd)
|
| 45 |
+
finally:
|
| 46 |
+
os.close(fd)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def atomic_write_text(path: str, text: str) -> None:
|
| 50 |
+
tmp = path + ".partial"
|
| 51 |
+
with open(tmp, "w") as f:
|
| 52 |
+
f.write(text)
|
| 53 |
+
f.flush()
|
| 54 |
+
os.fsync(f.fileno())
|
| 55 |
+
os.replace(tmp, path)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def read_jsonl(path: str) -> list[dict]:
|
| 59 |
+
out = []
|
| 60 |
+
with open(path, encoding="utf-8") as f:
|
| 61 |
+
for n, line in enumerate(f, 1):
|
| 62 |
+
if line.strip():
|
| 63 |
+
try:
|
| 64 |
+
out.append(json.loads(line))
|
| 65 |
+
except json.JSONDecodeError as e:
|
| 66 |
+
raise ValueError(f"{path}:{n}: {e}") from e
|
| 67 |
+
return out
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def write_jsonl_atomic(path: str, rows) -> None:
|
| 71 |
+
atomic_write_text(path, "".join(json.dumps(r, sort_keys=True) + "\n" for r in rows))
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def sha256_file(path: str) -> str:
|
| 75 |
+
h = hashlib.sha256()
|
| 76 |
+
with open(path, "rb") as f:
|
| 77 |
+
for c in iter(lambda: f.read(1 << 20), b""):
|
| 78 |
+
h.update(c)
|
| 79 |
+
return h.hexdigest()
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
# --------------------------------------------------------------------------------- PEFT export
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def peft_key_map(qcfg) -> dict[str, str]:
|
| 86 |
+
"""align module path -> HF module path, for every projection LoRA can target."""
|
| 87 |
+
from align.model.qwen3_checkpoint import _table
|
| 88 |
+
|
| 89 |
+
out = {}
|
| 90 |
+
for hf_w, al_w in _table(qcfg).items():
|
| 91 |
+
if hf_w.endswith(".weight") and ".layers." in hf_w:
|
| 92 |
+
out[al_w[: -len(".weight")]] = hf_w[: -len(".weight")]
|
| 93 |
+
return out
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def lora_to_peft_tensors(lora_state: dict, qcfg) -> dict[str, torch.Tensor]:
|
| 97 |
+
m = peft_key_map(qcfg)
|
| 98 |
+
out = {}
|
| 99 |
+
for k, v in lora_state.items():
|
| 100 |
+
path, ab = k.rsplit(".", 1)
|
| 101 |
+
if path not in m:
|
| 102 |
+
raise KeyError(f"adapter path {path!r} has no HF counterpart")
|
| 103 |
+
out[f"base_model.model.{m[path]}.lora_{ab}.weight"] = v.detach().to("cpu").contiguous()
|
| 104 |
+
return out
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def peft_to_lora_state(tensors: dict, qcfg) -> dict[str, torch.Tensor]:
|
| 108 |
+
inv = {v: k for k, v in peft_key_map(qcfg).items()}
|
| 109 |
+
out = {}
|
| 110 |
+
for k, v in tensors.items():
|
| 111 |
+
assert k.startswith("base_model.model.")
|
| 112 |
+
body = k[len("base_model.model."):]
|
| 113 |
+
hf_path, lora_ab = body.rsplit(".lora_", 1)
|
| 114 |
+
out[f"{inv[hf_path]}.{lora_ab.split('.')[0]}"] = v
|
| 115 |
+
return out
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def write_peft_dir(out_dir: str, lora_state: dict, qcfg, lcfg: L.LoraCfg, base_name: str, dtype=torch.float32) -> None:
|
| 119 |
+
"""A PEFT adapter directory (adapter_config.json + adapter_model.safetensors), written to a sibling temp dir
|
| 120 |
+
and renamed in, so a reader (vLLM) never sees half of it."""
|
| 121 |
+
import safetensors.torch as st
|
| 122 |
+
|
| 123 |
+
tmp = out_dir.rstrip("/") + ".partial"
|
| 124 |
+
shutil.rmtree(tmp, ignore_errors=True)
|
| 125 |
+
os.makedirs(tmp)
|
| 126 |
+
tens = {k: v.to(dtype) for k, v in lora_to_peft_tensors(lora_state, qcfg).items()}
|
| 127 |
+
st.save_file(tens, os.path.join(tmp, ADAPTER_NAME_PEFT), metadata={"format": "pt"})
|
| 128 |
+
cfg = {
|
| 129 |
+
"peft_type": "LORA", "task_type": "CAUSAL_LM", "r": lcfg.r, "lora_alpha": lcfg.alpha, "lora_dropout": 0.0,
|
| 130 |
+
"bias": "none", "use_rslora": bool(lcfg.rslora), "target_modules": list(lcfg.targets),
|
| 131 |
+
"base_model_name_or_path": base_name, "inference_mode": True, "fan_in_fan_out": False,
|
| 132 |
+
"init_lora_weights": True, "modules_to_save": None,
|
| 133 |
+
}
|
| 134 |
+
with open(os.path.join(tmp, "adapter_config.json"), "w") as f:
|
| 135 |
+
json.dump(cfg, f, indent=1, sort_keys=True)
|
| 136 |
+
f.flush()
|
| 137 |
+
os.fsync(f.fileno())
|
| 138 |
+
if os.path.isdir(out_dir):
|
| 139 |
+
shutil.rmtree(out_dir)
|
| 140 |
+
os.replace(tmp, out_dir)
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def read_peft_dir(path: str, qcfg) -> dict[str, torch.Tensor]:
|
| 144 |
+
import safetensors.torch as st
|
| 145 |
+
|
| 146 |
+
return peft_to_lora_state(st.load_file(os.path.join(path, ADAPTER_NAME_PEFT)), qcfg)
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def export_merged_checkpoint(model, adapter: str, qcfg, out_dir: str, tokenizer_dir: Optional[str] = None,
|
| 150 |
+
dtype=torch.bfloat16) -> dict:
|
| 151 |
+
"""FALLBACK for serving without un-merged LoRA: base+adapter folded into an HF checkpoint. Lossy by
|
| 152 |
+
construction (bf16 cast drops small delta elements; the fidelity report is returned). Collective under FSDP:
|
| 153 |
+
every rank must call; rank 0 writes."""
|
| 154 |
+
from align.model.qwen3_checkpoint import save_hf_qwen3
|
| 155 |
+
from align.ppo.lora import export_merged_state_dict
|
| 156 |
+
from align.utils.dist import is_rank0
|
| 157 |
+
|
| 158 |
+
if L._is_sharded(list(model.parameters())):
|
| 159 |
+
from torch.distributed.checkpoint.state_dict import StateDictOptions, get_model_state_dict
|
| 160 |
+
|
| 161 |
+
sd = get_model_state_dict(model, options=StateDictOptions(full_state_dict=True, cpu_offload=True))
|
| 162 |
+
else:
|
| 163 |
+
sd = {k: v.detach().to("cpu") for k, v in model.state_dict().items()}
|
| 164 |
+
report: dict = {}
|
| 165 |
+
if sd:
|
| 166 |
+
merged = export_merged_state_dict(model, sd, adapter, report=report)
|
| 167 |
+
if is_rank0():
|
| 168 |
+
tmp = out_dir.rstrip("/") + ".partial"
|
| 169 |
+
shutil.rmtree(tmp, ignore_errors=True)
|
| 170 |
+
save_hf_qwen3(merged, tmp, cfg=qcfg, dtype=dtype, tokenizer_dir=tokenizer_dir)
|
| 171 |
+
if os.path.isdir(out_dir):
|
| 172 |
+
shutil.rmtree(out_dir)
|
| 173 |
+
os.replace(tmp, out_dir)
|
| 174 |
+
return report
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
# --------------------------------------------------------------------------------- checkpoints
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def ckpt_path(run_dir: str, k: int) -> str:
|
| 181 |
+
return os.path.join(run_dir, "ckpt", f"iter_{k:06d}")
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def latest_complete(run_dir: str) -> Optional[int]:
|
| 185 |
+
root = os.path.join(run_dir, "ckpt")
|
| 186 |
+
if not os.path.isdir(root):
|
| 187 |
+
return None
|
| 188 |
+
best = None
|
| 189 |
+
for name in os.listdir(root):
|
| 190 |
+
if name.startswith("iter_") and os.path.isfile(os.path.join(root, name, "COMPLETE")):
|
| 191 |
+
k = int(name[5:])
|
| 192 |
+
best = k if best is None else max(best, k)
|
| 193 |
+
return best
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def clean_partial(run_dir: str) -> list[str]:
|
| 197 |
+
"""Delete half-written checkpoints (`.tmp_*`) and incomplete `iter_*` dirs: they look resumable and are not."""
|
| 198 |
+
root = os.path.join(run_dir, "ckpt")
|
| 199 |
+
gone = []
|
| 200 |
+
if not os.path.isdir(root):
|
| 201 |
+
return gone
|
| 202 |
+
for name in sorted(os.listdir(root)):
|
| 203 |
+
p = os.path.join(root, name)
|
| 204 |
+
if name.startswith(".tmp_") or (name.startswith("iter_") and not os.path.isfile(os.path.join(p, "COMPLETE"))):
|
| 205 |
+
shutil.rmtree(p, ignore_errors=True)
|
| 206 |
+
gone.append(name)
|
| 207 |
+
return gone
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def _rank() -> int:
|
| 211 |
+
import torch.distributed as dist
|
| 212 |
+
|
| 213 |
+
return dist.get_rank() if L._dist_on() else 0
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def _barrier() -> None:
|
| 217 |
+
import torch.distributed as dist
|
| 218 |
+
|
| 219 |
+
if L._dist_on():
|
| 220 |
+
dist.barrier()
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def save_checkpoint(run_dir: str, k: int, *, model, optimizer, lora_state: dict, qcfg, cfg: L.RLConfig, ledger: list,
|
| 224 |
+
bases: dict, base_name: str, base_fingerprint: Optional[str]) -> str:
|
| 225 |
+
"""Commit checkpoint k atomically: write everything into `.tmp_iter_k`, then rename. `COMPLETE` is the
|
| 226 |
+
last file written inside; the rename is the commit. `lora_state` must already be gathered (collective done
|
| 227 |
+
by the caller on every rank)."""
|
| 228 |
+
final = ckpt_path(run_dir, k)
|
| 229 |
+
tmp = os.path.join(run_dir, "ckpt", f".tmp_iter_{k:06d}")
|
| 230 |
+
r = _rank()
|
| 231 |
+
if r == 0:
|
| 232 |
+
os.makedirs(os.path.join(run_dir, "ckpt"), exist_ok=True)
|
| 233 |
+
shutil.rmtree(tmp, ignore_errors=True)
|
| 234 |
+
os.makedirs(tmp)
|
| 235 |
+
_barrier()
|
| 236 |
+
optim_sd = optimizer_state_for_save(model, optimizer) # collective on every rank
|
| 237 |
+
if r == 0:
|
| 238 |
+
torch.save(_cpu_tree(optim_sd), os.path.join(tmp, "optim.pt"))
|
| 239 |
+
state = {
|
| 240 |
+
"iteration": k, "cfg_fingerprint": cfg.fingerprint(), "world_size": L.world_size(),
|
| 241 |
+
"adapter": {a: t.detach().cpu() for a, t in lora_state.items()}, "rng": capture_rng_state(),
|
| 242 |
+
"stream": L.stream_position(_pool_size(cfg), cfg.prompts_per_iter, k), "ledger": ledger,
|
| 243 |
+
"bases": bases, "base_fingerprint": base_fingerprint,
|
| 244 |
+
}
|
| 245 |
+
torch.save(state, os.path.join(tmp, "state.pt"))
|
| 246 |
+
write_peft_dir(os.path.join(tmp, "adapter"), lora_state, qcfg, cfg.lora, base_name)
|
| 247 |
+
write_jsonl_atomic(os.path.join(tmp, "ledger.jsonl"), ledger)
|
| 248 |
+
atomic_write_text(os.path.join(tmp, "COMPLETE"), json.dumps({"iteration": k, "t": time.time()}))
|
| 249 |
+
_barrier()
|
| 250 |
+
if r == 0:
|
| 251 |
+
if os.path.isdir(final):
|
| 252 |
+
shutil.rmtree(final)
|
| 253 |
+
fsync_dir(tmp)
|
| 254 |
+
os.replace(tmp, final)
|
| 255 |
+
fsync_dir(os.path.dirname(final))
|
| 256 |
+
write_jsonl_atomic(os.path.join(run_dir, "ledger.jsonl"), ledger)
|
| 257 |
+
_prune(run_dir, cfg.keep_checkpoints)
|
| 258 |
+
_barrier()
|
| 259 |
+
return final
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def _prune(run_dir: str, keep: int) -> None:
|
| 263 |
+
root = os.path.join(run_dir, "ckpt")
|
| 264 |
+
done = sorted(n for n in os.listdir(root) if n.startswith("iter_") and os.path.isfile(os.path.join(root, n, "COMPLETE")))
|
| 265 |
+
for n in done[:-max(keep, 1)]:
|
| 266 |
+
shutil.rmtree(os.path.join(root, n), ignore_errors=True)
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
def load_checkpoint_state(run_dir: str, k: int, cfg: L.RLConfig) -> dict:
|
| 270 |
+
d = ckpt_path(run_dir, k)
|
| 271 |
+
if not os.path.isfile(os.path.join(d, "COMPLETE")):
|
| 272 |
+
raise FileNotFoundError(f"no complete checkpoint {d}")
|
| 273 |
+
st = torch.load(os.path.join(d, "state.pt"), weights_only=True)
|
| 274 |
+
if st["cfg_fingerprint"] != cfg.fingerprint():
|
| 275 |
+
raise RuntimeError("checkpoint was written under a different config fingerprint; refusing to resume")
|
| 276 |
+
return st
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
def optimizer_state_for_save(model, optimizer) -> dict:
|
| 280 |
+
"""Optimizer state in a world-size-independent form. Replicated model: the local state (identical on every
|
| 281 |
+
rank). FSDP2-sharded: the FULL state via DCP (a collective; rank 0 receives it, the others {})."""
|
| 282 |
+
if L._is_sharded(list(model.parameters())):
|
| 283 |
+
from torch.distributed.checkpoint.state_dict import StateDictOptions, get_optimizer_state_dict
|
| 284 |
+
|
| 285 |
+
return get_optimizer_state_dict(model, optimizer, options=StateDictOptions(full_state_dict=True, cpu_offload=True))
|
| 286 |
+
return optimizer.state_dict()
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
def load_optimizer(run_dir: str, k: int, model, optimizer) -> None:
|
| 290 |
+
p = os.path.join(ckpt_path(run_dir, k), "optim.pt")
|
| 291 |
+
sd = torch.load(p, weights_only=True)
|
| 292 |
+
if L._is_sharded(list(model.parameters())):
|
| 293 |
+
from torch.distributed.checkpoint.state_dict import StateDictOptions, set_optimizer_state_dict
|
| 294 |
+
|
| 295 |
+
set_optimizer_state_dict(model, optimizer, optim_state_dict=sd,
|
| 296 |
+
options=StateDictOptions(full_state_dict=True, broadcast_from_rank0=False))
|
| 297 |
+
else:
|
| 298 |
+
optimizer.load_state_dict(sd)
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
def _cpu_tree(x):
|
| 302 |
+
if isinstance(x, torch.Tensor):
|
| 303 |
+
return x.detach().cpu()
|
| 304 |
+
if isinstance(x, dict):
|
| 305 |
+
return {k: _cpu_tree(v) for k, v in x.items()}
|
| 306 |
+
if isinstance(x, (list, tuple)):
|
| 307 |
+
return type(x)(_cpu_tree(v) for v in x)
|
| 308 |
+
return x
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
def _pool_size(cfg: L.RLConfig) -> int:
|
| 312 |
+
return len(read_jsonl(cfg.problems))
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
def load_problems(path: str) -> list[dict]:
|
| 316 |
+
rows = read_jsonl(path)
|
| 317 |
+
ids = [r["problem_id"] for r in rows]
|
| 318 |
+
if len(set(ids)) != len(ids):
|
| 319 |
+
raise ValueError(f"{path}: duplicate problem_id")
|
| 320 |
+
return rows
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
# ------------------------------------------------------------------------------- iteration files
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
def iter_dir(run_dir: str, k: int) -> str:
|
| 327 |
+
return os.path.join(run_dir, "iters", f"iter_{k:04d}")
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
def stage_marker(d: str, name: str) -> str:
|
| 331 |
+
return os.path.join(d, f"{name}.done")
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
def add_stage_time(d: str, stage: str, wall_s: float) -> None:
|
| 335 |
+
p = os.path.join(d, "stage_times.json")
|
| 336 |
+
cur = json.load(open(p)) if os.path.exists(p) else {}
|
| 337 |
+
cur[f"{stage}_wall_s"] = cur.get(f"{stage}_wall_s", 0.0) + wall_s
|
| 338 |
+
cur[f"{stage}_attempts"] = cur.get(f"{stage}_attempts", 0) + 1
|
| 339 |
+
atomic_write_text(p, json.dumps(cur, sort_keys=True))
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
def ledger_entry(cfg: L.RLConfig, k: int, d: str, bstats: dict, metrics: dict, train_wall_s: float,
|
| 343 |
+
padded_tokens: float, prev: list) -> dict:
|
| 344 |
+
gens = read_jsonl(os.path.join(d, "gens.jsonl"))
|
| 345 |
+
vers = read_jsonl(os.path.join(d, "ver.jsonl"))
|
| 346 |
+
st = json.load(open(os.path.join(d, "stage_times.json"))) if os.path.exists(os.path.join(d, "stage_times.json")) else {}
|
| 347 |
+
gen_tokens = sum(len(g["completion_ids"]) for g in gens)
|
| 348 |
+
ver_s = sum(float(v.get("verify_s", 0.0)) for v in vers)
|
| 349 |
+
e = {
|
| 350 |
+
"iteration": k, "loss_mode": cfg.loss.mode, "prompts": cfg.prompts_per_iter, "samples": len(gens),
|
| 351 |
+
"generated_tokens": gen_tokens, "prompt_tokens": sum(len(g["prompt_ids"]) for g in gens),
|
| 352 |
+
"verifier_seconds": ver_s, "verifier_wall_s": st.get("verify_wall_s", 0.0),
|
| 353 |
+
"generate_wall_s": st.get("gen_wall_s", 0.0), "train_wall_s": train_wall_s,
|
| 354 |
+
"train_tokens": metrics.get("resp_tokens_total", 0.0), "train_padded_tokens": padded_tokens,
|
| 355 |
+
"stage_attempts": {s: st.get(f"{s}_attempts", 0) for s in ("gen", "verify")},
|
| 356 |
+
"updated": bool(metrics.get("updated", False)), "batch": bstats, "metrics": metrics,
|
| 357 |
+
"bases": metrics.get("bases", {}),
|
| 358 |
+
}
|
| 359 |
+
e["wall_s"] = e["generate_wall_s"] + e["verifier_wall_s"] + train_wall_s
|
| 360 |
+
keys = ("generated_tokens", "verifier_seconds", "train_tokens", "train_padded_tokens", "wall_s")
|
| 361 |
+
e["cumulative"] = {c: (prev[-1]["cumulative"][c] if prev else 0.0) + e[c] for c in keys}
|
| 362 |
+
return e
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
# ---------------------------------------------------------------------------- the train iteration
|
| 366 |
+
|
| 367 |
+
|
| 368 |
+
def bases_stamp(cfg: L.RLConfig, gens: list) -> dict:
|
| 369 |
+
g0 = gens[0] if gens else {}
|
| 370 |
+
s = g0.get("sampling", {})
|
| 371 |
+
return {
|
| 372 |
+
"behaviour": {"source": "sampler", "field": "sampling_logprobs", "temperature": s.get("temperature"),
|
| 373 |
+
"top_p": s.get("top_p"), "top_k": s.get("top_k"), "model_rev": g0.get("model_rev"),
|
| 374 |
+
"serving": (g0.get("adapter") or {}).get("serving", "merged_or_base")},
|
| 375 |
+
"old": {"source": "trainer", "weights": "rollout-time (= current at the first optimizer step)",
|
| 376 |
+
"role": "mismatch telemetry only; NOT the importance denominator", "fp32_head": cfg.loss.fp32_head},
|
| 377 |
+
"current": {"source": "trainer", "differentiable": True, "fp32_head": cfg.loss.fp32_head,
|
| 378 |
+
"temperature": cfg.loss.temperature},
|
| 379 |
+
"importance_ratio": "pi_current / pi_behaviour",
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
def lr_at(cfg: L.RLConfig, k: int) -> float:
|
| 384 |
+
w = cfg.optim.warmup_iters
|
| 385 |
+
return cfg.optim.lr * (min(1.0, (k + 1) / w) if w > 0 else 1.0)
|
| 386 |
+
|
| 387 |
+
|
| 388 |
+
def train_iteration(cfg: L.RLConfig, k: int, *, model, params, optimizer, qcfg, pad_id: int, base_name: str,
|
| 389 |
+
base_fingerprint: Optional[str] = None, tokenizer_dir: Optional[str] = None) -> dict:
|
| 390 |
+
"""One training iteration on iteration k's cached gens/ver, committing checkpoint k+1.
|
| 391 |
+
|
| 392 |
+
`model` carries the injected LoRA adapter `cfg.lora.adapter`; `params` are its trainable parameters;
|
| 393 |
+
`optimizer` is built over them. State is loaded from checkpoint k (adapter, optimizer, RNG)."""
|
| 394 |
+
from align.ppo.lora import load_lora_state_dict, lora_state_dict
|
| 395 |
+
|
| 396 |
+
run = cfg.run_dir
|
| 397 |
+
d = iter_dir(run, k)
|
| 398 |
+
st = load_checkpoint_state(run, k, cfg)
|
| 399 |
+
load_lora_state_dict(model, cfg.lora.adapter, st["adapter"], strict=True)
|
| 400 |
+
load_optimizer(run, k, model, optimizer)
|
| 401 |
+
restore_rng_state(st["rng"])
|
| 402 |
+
ledger = list(st["ledger"])
|
| 403 |
+
if len(ledger) != k:
|
| 404 |
+
raise RuntimeError(f"checkpoint {k} carries {len(ledger)} ledger rows")
|
| 405 |
+
pos = L.stream_position(_pool_size(cfg), cfg.prompts_per_iter, k)
|
| 406 |
+
if st["stream"] != pos:
|
| 407 |
+
raise RuntimeError(f"prompt stream position {st['stream']} != recomputed {pos}")
|
| 408 |
+
|
| 409 |
+
gens = read_jsonl(os.path.join(d, "gens.jsonl"))
|
| 410 |
+
vers = {(v["problem_id"], v["sample_idx"], v["round"], v["cap_tokens"]): v for v in read_jsonl(os.path.join(d, "ver.jsonl"))}
|
| 411 |
+
samples, bstats = L.build_samples(gens, vers, cfg)
|
| 412 |
+
world = L.world_size()
|
| 413 |
+
r = _rank()
|
| 414 |
+
t0 = time.perf_counter()
|
| 415 |
+
metrics: dict = {"updated": False, "bases": bases_stamp(cfg, gens), "resp_tokens_total": 0.0}
|
| 416 |
+
padded = 0.0
|
| 417 |
+
if samples:
|
| 418 |
+
dtype = next(iter(params)).dtype
|
| 419 |
+
steps = cfg.optim.steps_per_iteration
|
| 420 |
+
groups = sorted({s.group for s in samples})
|
| 421 |
+
chunks = [[s for s in samples if groups.index(s.group) % steps == i] for i in range(steps)]
|
| 422 |
+
step_metrics = []
|
| 423 |
+
for i, chunk in enumerate(chunks):
|
| 424 |
+
if not chunk:
|
| 425 |
+
continue
|
| 426 |
+
mine = L.shard_samples(chunk, world)[r]
|
| 427 |
+
micros = L.pack_micros(mine, cfg.batch.micro_tokens, pad_id, dtype)
|
| 428 |
+
expect = sum(s.n_resp for s in chunk)
|
| 429 |
+
denom = L.norm_count(sum(m.n_resp for m in micros), cfg.loss, expect)
|
| 430 |
+
for p in params:
|
| 431 |
+
p.grad = None
|
| 432 |
+
t_step = time.perf_counter()
|
| 433 |
+
acc = L.accumulate_gradients(model, micros, cfg.loss, denom, params, pad_id)
|
| 434 |
+
total = torch.nn.utils.clip_grad_norm_(list(params), cfg.loss.max_grad_norm)
|
| 435 |
+
if hasattr(total, "full_tensor"):
|
| 436 |
+
total = total.full_tensor()
|
| 437 |
+
for g in optimizer.param_groups:
|
| 438 |
+
g["lr"] = lr_at(cfg, k)
|
| 439 |
+
optimizer.step()
|
| 440 |
+
if torch.cuda.is_available() and torch.cuda.is_initialized():
|
| 441 |
+
torch.cuda.synchronize()
|
| 442 |
+
for p in params:
|
| 443 |
+
p.grad = None
|
| 444 |
+
padded += L.all_reduce_floats([float(sum(m.input_ids.numel() for m in micros))])[0]
|
| 445 |
+
sm = L.summarize(acc, denom, float(total), cfg.loss)
|
| 446 |
+
sm["step_wall_s"] = time.perf_counter() - t_step
|
| 447 |
+
sm["n_micro_this_rank"] = len(micros)
|
| 448 |
+
if torch.cuda.is_available() and torch.cuda.is_initialized():
|
| 449 |
+
sm["cuda_peak_alloc_gb"] = torch.cuda.max_memory_allocated() / 2**30
|
| 450 |
+
sm["cuda_peak_reserved_gb"] = torch.cuda.max_memory_reserved() / 2**30
|
| 451 |
+
step_metrics.append(sm)
|
| 452 |
+
metrics.update(step_metrics[-1])
|
| 453 |
+
metrics["steps"] = step_metrics
|
| 454 |
+
metrics["updated"] = True
|
| 455 |
+
metrics["resp_tokens_total"] = float(sum(m["resp_tokens"] for m in step_metrics))
|
| 456 |
+
train_wall = time.perf_counter() - t0
|
| 457 |
+
lora = lora_state_dict(model, cfg.lora.adapter) # collective under FSDP
|
| 458 |
+
entry = ledger_entry(cfg, k, d, bstats, metrics, train_wall, padded, ledger)
|
| 459 |
+
ledger.append(entry)
|
| 460 |
+
if cfg.export_merged:
|
| 461 |
+
entry["merged_export"] = export_merged_checkpoint(model, cfg.lora.adapter, qcfg,
|
| 462 |
+
os.path.join(ckpt_path(run, k + 1) + ".merged_tmp"), tokenizer_dir)
|
| 463 |
+
save_checkpoint(run, k + 1, model=model, optimizer=optimizer, lora_state=lora, qcfg=qcfg, cfg=cfg, ledger=ledger,
|
| 464 |
+
bases=metrics["bases"], base_name=base_name, base_fingerprint=base_fingerprint)
|
| 465 |
+
if cfg.export_merged and _rank() == 0:
|
| 466 |
+
src = ckpt_path(run, k + 1) + ".merged_tmp"
|
| 467 |
+
if os.path.isdir(src):
|
| 468 |
+
os.replace(src, os.path.join(ckpt_path(run, k + 1), "merged"))
|
| 469 |
+
_barrier()
|
| 470 |
+
return entry
|
| 471 |
+
|
| 472 |
+
|
| 473 |
+
def init_checkpoint(cfg: L.RLConfig, *, model, optimizer, qcfg, base_name: str, base_fingerprint: Optional[str] = None) -> None:
|
| 474 |
+
"""Checkpoint 0 = the freshly initialised adapter (B = 0, so it IS the base policy) and a fresh optimizer."""
|
| 475 |
+
from align.ppo.lora import lora_state_dict
|
| 476 |
+
|
| 477 |
+
if latest_complete(cfg.run_dir) is not None:
|
| 478 |
+
return
|
| 479 |
+
save_checkpoint(cfg.run_dir, 0, model=model, optimizer=optimizer, lora_state=lora_state_dict(model, cfg.lora.adapter), qcfg=qcfg,
|
| 480 |
+
cfg=cfg, ledger=[], bases={}, base_fingerprint=base_fingerprint, base_name=base_name)
|
| 481 |
+
|
| 482 |
+
|
| 483 |
+
def make_optimizer(cfg: L.RLConfig, params):
|
| 484 |
+
return torch.optim.AdamW(list(params), lr=cfg.optim.lr, betas=tuple(cfg.optim.betas), eps=cfg.optim.eps,
|
| 485 |
+
weight_decay=cfg.optim.weight_decay)
|
| 486 |
+
|
| 487 |
+
|
| 488 |
+
# ----------------------------------------------------------------------------- real policy build
|
| 489 |
+
|
| 490 |
+
|
| 491 |
+
def build_real_policy(cfg: L.RLConfig) -> tuple:
|
| 492 |
+
"""Rank-serial load of the frozen bf16 base -> GPU -> LoRA (identical init on every rank, B = 0) -> adapter-only
|
| 493 |
+
trainable -> activation checkpointing -> FSDP2 (world > 1). Same recipe as `align.train.qwen3_stage`, without
|
| 494 |
+
its RunConfig. Returns the tuple `InProcessTrainer` expects."""
|
| 495 |
+
import gc
|
| 496 |
+
|
| 497 |
+
from align.model import qwen3 as q3
|
| 498 |
+
from align.model.checkpoint import load_hf_safetensors, resolve_snapshot_dir
|
| 499 |
+
from align.model.qwen3_checkpoint import hf_to_align, read_qwen3_config
|
| 500 |
+
from align.ppo.lora import LoRAConfig, inject_lora, mark_only_adapter_trainable
|
| 501 |
+
from align.train import fsdp
|
| 502 |
+
from align.train.qwen3_stage import base_fingerprint
|
| 503 |
+
from align.utils.dist import barrier, local_rank, rank, world_size
|
| 504 |
+
|
| 505 |
+
snapshot = resolve_snapshot_dir(cfg.model_snapshot)
|
| 506 |
+
qcfg = read_qwen3_config(snapshot).replace(attn_implementation="sdpa")
|
| 507 |
+
device = torch.device("cuda", local_rank())
|
| 508 |
+
torch.cuda.set_device(device)
|
| 509 |
+
model = None
|
| 510 |
+
for r in range(world_size()):
|
| 511 |
+
if rank() == r:
|
| 512 |
+
sd = hf_to_align(load_hf_safetensors(snapshot, dtype=torch.bfloat16), qcfg)
|
| 513 |
+
model = q3.Qwen3ForCausalLM.from_state_dict(qcfg, sd, device="cuda").to(device)
|
| 514 |
+
del sd
|
| 515 |
+
gc.collect()
|
| 516 |
+
barrier()
|
| 517 |
+
fp = base_fingerprint(model.state_dict()) # whole tensors, before inject/FSDP
|
| 518 |
+
lc = cfg.lora
|
| 519 |
+
gen = torch.Generator().manual_seed(cfg.seed) # identical A on every rank: FSDP2 shards, never broadcasts
|
| 520 |
+
hit = inject_lora(model, LoRAConfig(r=lc.r, alpha=lc.alpha, target_modules=tuple(lc.targets), rslora=lc.rslora),
|
| 521 |
+
adapter=lc.adapter, generator=gen)
|
| 522 |
+
expected = len(lc.targets) * qcfg.n_layers
|
| 523 |
+
if len(hit) != expected:
|
| 524 |
+
raise RuntimeError(f"LoRA hit {len(hit)} layers, expected {expected}")
|
| 525 |
+
mark_only_adapter_trainable(model, lc.adapter)
|
| 526 |
+
fsdp.apply_activation_checkpointing(model)
|
| 527 |
+
if world_size() > 1:
|
| 528 |
+
fsdp.apply_fsdp(model)
|
| 529 |
+
params = [p for p in model.parameters() if p.requires_grad]
|
| 530 |
+
pad = qcfg.pad_token_id if qcfg.pad_token_id is not None else (qcfg.eos_token_id or 0)
|
| 531 |
+
return model, params, qcfg, pad, snapshot, fp, snapshot
|
| 532 |
+
|
| 533 |
+
|
| 534 |
+
# --------------------------------------------------------------------------------------- backends
|
| 535 |
+
|
| 536 |
+
|
| 537 |
+
class InProcessTrainer:
|
| 538 |
+
"""Trainer backend that runs `train_iteration` in this process. `builder()` -> (model, params, qcfg, pad_id,
|
| 539 |
+
base_name, base_fp, tokenizer_dir)."""
|
| 540 |
+
|
| 541 |
+
def __init__(self, cfg: L.RLConfig, builder: Callable[[], tuple]):
|
| 542 |
+
self.cfg, self.builder = cfg, builder
|
| 543 |
+
|
| 544 |
+
def _build(self):
|
| 545 |
+
model, params, qcfg, pad_id, base_name, base_fp, tok = self.builder()
|
| 546 |
+
return model, params, make_optimizer(self.cfg, params), qcfg, pad_id, base_name, base_fp, tok
|
| 547 |
+
|
| 548 |
+
def init(self) -> None:
|
| 549 |
+
model, params, opt, qcfg, pad_id, base_name, base_fp, _ = self._build()
|
| 550 |
+
init_checkpoint(self.cfg, model=model, optimizer=opt, qcfg=qcfg, base_name=base_name, base_fingerprint=base_fp)
|
| 551 |
+
|
| 552 |
+
def train(self, k: int) -> dict:
|
| 553 |
+
model, params, opt, qcfg, pad_id, base_name, base_fp, tok = self._build()
|
| 554 |
+
return train_iteration(self.cfg, k, model=model, params=params, optimizer=opt, qcfg=qcfg, pad_id=pad_id,
|
| 555 |
+
base_name=base_name, base_fingerprint=base_fp, tokenizer_dir=tok)
|
| 556 |
+
|
| 557 |
+
|
| 558 |
+
def repo_root() -> str:
|
| 559 |
+
return os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 560 |
+
|
| 561 |
+
|
| 562 |
+
class LeanGenCliGenerator:
|
| 563 |
+
"""Real generator: `extras/vllm_rollout/lean_gen.py` as a child process (needs the genharness flags
|
| 564 |
+
`--lora-adapter`, `--top-p`, `--temperature` and `--record-logprobs`)."""
|
| 565 |
+
|
| 566 |
+
def __init__(self, cfg: L.RLConfig, config_path: str = ""):
|
| 567 |
+
self.cfg = cfg
|
| 568 |
+
|
| 569 |
+
def command(self, k: int, d: str, problems_path: str, adapter_dir: str, merged_dir: Optional[str], seed: int) -> list[str]:
|
| 570 |
+
c, ro = self.cfg, self.cfg.rollout
|
| 571 |
+
root = repo_root()
|
| 572 |
+
py = ro.rollout_python if os.path.isabs(ro.rollout_python) else os.path.join(root, ro.rollout_python)
|
| 573 |
+
model = c.model_snapshot
|
| 574 |
+
cmd = [py, os.path.join(root, "extras", "vllm_rollout", "lean_gen.py"), "--problems", problems_path,
|
| 575 |
+
"--out", os.path.join(d, "gens.jsonl"), "--n", str(c.batch.group_size), "--cap-tokens", str(ro.cap_tokens),
|
| 576 |
+
"--round", "0", "--tp", str(ro.tp), "--wait-lock", "--seed", str(seed), "--max-model-len", str(ro.max_model_len),
|
| 577 |
+
"--temperature", str(ro.temperature), "--top-p", "1.0", "--record-logprobs",
|
| 578 |
+
"--label", f"rl-lean-gen-{k}"]
|
| 579 |
+
if ro.adapter_mode == "unmerged":
|
| 580 |
+
cmd += ["--model", model, "--lora-adapter", adapter_dir]
|
| 581 |
+
else:
|
| 582 |
+
if not merged_dir:
|
| 583 |
+
raise L.RLConfigError("adapter_mode=merged_export needs export_merged=true")
|
| 584 |
+
cmd += ["--model", merged_dir]
|
| 585 |
+
return cmd + list(ro.gen_extra_args)
|
| 586 |
+
|
| 587 |
+
def generate(self, k: int, d: str, problems_path: str, adapter_dir: str, merged_dir: Optional[str], seed: int) -> None:
|
| 588 |
+
subprocess.run(self.command(k, d, problems_path, adapter_dir, merged_dir, seed), check=True)
|
| 589 |
+
|
| 590 |
+
|
| 591 |
+
class VerifyCliVerifier:
|
| 592 |
+
def __init__(self, cfg: L.RLConfig):
|
| 593 |
+
self.cfg = cfg
|
| 594 |
+
|
| 595 |
+
def command(self, d: str, problems_path: str) -> list[str]:
|
| 596 |
+
c, root = self.cfg, repo_root()
|
| 597 |
+
py = c.verify_python if os.path.isabs(c.verify_python) else os.path.join(root, c.verify_python)
|
| 598 |
+
return [py, os.path.join(root, "scripts", "p2", "verify_generations.py"), "--generations", os.path.join(d, "gens.jsonl"),
|
| 599 |
+
"--problems", problems_path, "--out", os.path.join(d, "ver.jsonl"), "--workers", str(c.verify_workers),
|
| 600 |
+
"--budget-s", str(c.verify_budget_s)]
|
| 601 |
+
|
| 602 |
+
def verify(self, d: str, problems_path: str) -> None:
|
| 603 |
+
subprocess.run(self.command(d, problems_path), check=True)
|
| 604 |
+
|
| 605 |
+
|
| 606 |
+
class ReplayBackend:
|
| 607 |
+
"""Replay cached rollouts + verdicts of another run (K1: positive-only vs signed on the SAME rollouts).
|
| 608 |
+
Rollouts beyond iteration 0 were sampled by the SOURCE arm's policy, so they are off-policy for this arm;
|
| 609 |
+
the importance ratio against the recorded behaviour logprobs keeps the update well defined."""
|
| 610 |
+
|
| 611 |
+
def __init__(self, src_run_dir: str):
|
| 612 |
+
self.src = src_run_dir
|
| 613 |
+
|
| 614 |
+
def generate(self, k, d, problems_path, adapter_dir, merged_dir, seed) -> None:
|
| 615 |
+
shutil.copyfile(os.path.join(iter_dir(self.src, k), "gens.jsonl"), os.path.join(d, "gens.jsonl"))
|
| 616 |
+
|
| 617 |
+
def verify(self, d, problems_path) -> None:
|
| 618 |
+
# k is encoded in d's name; copy the sibling verdicts
|
| 619 |
+
k = int(os.path.basename(d).split("_")[1])
|
| 620 |
+
shutil.copyfile(os.path.join(iter_dir(self.src, k), "ver.jsonl"), os.path.join(d, "ver.jsonl"))
|
| 621 |
+
|
| 622 |
+
|
| 623 |
+
def torchrun_command(cfg_path: str, k: int, nproc: int, overrides=()) -> list[str]:
|
| 624 |
+
return ["torchrun", "--standalone", f"--nproc_per_node={nproc}", os.path.join(repo_root(), "scripts", "p2", "rl_lean.py"),
|
| 625 |
+
"train-iter", cfg_path, "--iteration", str(k), *[f"--set={o}" for o in overrides]]
|
| 626 |
+
|
| 627 |
+
|
| 628 |
+
class TorchrunTrainer:
|
| 629 |
+
def __init__(self, cfg: L.RLConfig, cfg_path: str, nproc: int, overrides=()):
|
| 630 |
+
self.cfg, self.cfg_path, self.nproc, self.overrides = cfg, cfg_path, nproc, tuple(overrides)
|
| 631 |
+
|
| 632 |
+
def init(self) -> None:
|
| 633 |
+
subprocess.run(torchrun_command(self.cfg_path, -1, self.nproc, self.overrides), check=True)
|
| 634 |
+
|
| 635 |
+
def train(self, k: int) -> dict:
|
| 636 |
+
subprocess.run(torchrun_command(self.cfg_path, k, self.nproc, self.overrides), check=True)
|
| 637 |
+
return {}
|
| 638 |
+
|
| 639 |
+
|
| 640 |
+
# ------------------------------------------------------------------------------------- the loop
|
| 641 |
+
|
| 642 |
+
|
| 643 |
+
def derive_seed(seed: int, k: int) -> int:
|
| 644 |
+
return int.from_bytes(hashlib.sha256(f"rl|{seed}|{k}".encode()).digest()[:8], "big") % (2**31)
|
| 645 |
+
|
| 646 |
+
|
| 647 |
+
def run_loop(cfg: L.RLConfig, generator, verifier, trainer, hook: Optional[Callable[[str, int], None]] = None,
|
| 648 |
+
until: Optional[int] = None) -> list[dict]:
|
| 649 |
+
"""Run (or resume) iterations up to `until` (default cfg.iterations). `hook(stage, k)` fires after each
|
| 650 |
+
committed stage (tests inject crashes there). Returns the committed ledger."""
|
| 651 |
+
run = cfg.run_dir
|
| 652 |
+
os.makedirs(run, exist_ok=True)
|
| 653 |
+
cfg_json = os.path.join(run, "run_config.json")
|
| 654 |
+
import dataclasses
|
| 655 |
+
|
| 656 |
+
fp = cfg.fingerprint()
|
| 657 |
+
if os.path.exists(cfg_json):
|
| 658 |
+
old = json.load(open(cfg_json))["fingerprint"]
|
| 659 |
+
if old != fp:
|
| 660 |
+
raise RuntimeError(f"{run} was started under config {old[:12]}, this is {fp[:12]}: refusing to mix runs")
|
| 661 |
+
else:
|
| 662 |
+
atomic_write_text(cfg_json, json.dumps({"fingerprint": fp, "config": dataclasses.asdict(cfg)}, sort_keys=True, default=str, indent=1))
|
| 663 |
+
clean_partial(run)
|
| 664 |
+
problems = load_problems(cfg.problems)
|
| 665 |
+
by_id = {p["problem_id"]: p for p in problems}
|
| 666 |
+
ids = [p["problem_id"] for p in problems]
|
| 667 |
+
if latest_complete(run) is None:
|
| 668 |
+
trainer.init()
|
| 669 |
+
k0 = latest_complete(run)
|
| 670 |
+
if k0 is None:
|
| 671 |
+
raise RuntimeError("no checkpoint after init")
|
| 672 |
+
hook = hook or (lambda stage, k: None)
|
| 673 |
+
end = cfg.iterations if until is None else min(until, cfg.iterations)
|
| 674 |
+
for k in range(k0, end):
|
| 675 |
+
d = iter_dir(run, k)
|
| 676 |
+
os.makedirs(d, exist_ok=True)
|
| 677 |
+
probs_path = os.path.join(d, "problems.jsonl")
|
| 678 |
+
if not os.path.exists(probs_path):
|
| 679 |
+
block = L.prompt_block(ids, cfg.seed, cfg.prompts_per_iter, k)
|
| 680 |
+
write_jsonl_atomic(probs_path, [by_id[i] for i in block])
|
| 681 |
+
if not os.path.exists(stage_marker(d, "gen")):
|
| 682 |
+
gp = os.path.join(d, "gens.jsonl")
|
| 683 |
+
adapter_dir = os.path.join(ckpt_path(run, k), "adapter")
|
| 684 |
+
merged = os.path.join(ckpt_path(run, k), "merged") if cfg.export_merged else None
|
| 685 |
+
t0 = time.perf_counter()
|
| 686 |
+
gen = ReplayBackend(cfg.replay_from) if cfg.replay_from else generator
|
| 687 |
+
gen.generate(k, d, probs_path, adapter_dir, merged, derive_seed(cfg.seed, k))
|
| 688 |
+
add_stage_time(d, "gen", time.perf_counter() - t0)
|
| 689 |
+
_check_generations(gp, cfg, [by_id_id["problem_id"] for by_id_id in read_jsonl(probs_path)])
|
| 690 |
+
atomic_write_text(stage_marker(d, "gen"), json.dumps({"sha256": sha256_file(gp)}))
|
| 691 |
+
hook("gen", k)
|
| 692 |
+
if not os.path.exists(stage_marker(d, "verify")):
|
| 693 |
+
vp = os.path.join(d, "ver.jsonl")
|
| 694 |
+
t0 = time.perf_counter()
|
| 695 |
+
(ReplayBackend(cfg.replay_from) if cfg.replay_from else verifier).verify(d, probs_path)
|
| 696 |
+
add_stage_time(d, "verify", time.perf_counter() - t0)
|
| 697 |
+
n_g, n_v = len(read_jsonl(os.path.join(d, "gens.jsonl"))), len(read_jsonl(vp))
|
| 698 |
+
if n_g != n_v:
|
| 699 |
+
raise RuntimeError(f"{n_v} verdicts for {n_g} generations")
|
| 700 |
+
atomic_write_text(stage_marker(d, "verify"), json.dumps({"sha256": sha256_file(vp)}))
|
| 701 |
+
hook("verify", k)
|
| 702 |
+
t0 = time.perf_counter()
|
| 703 |
+
trainer.train(k)
|
| 704 |
+
add_stage_time(d, "train_proc", time.perf_counter() - t0) # whole child: model load + step + commit
|
| 705 |
+
hook("train", k)
|
| 706 |
+
last = latest_complete(run)
|
| 707 |
+
return read_jsonl(os.path.join(run, "ledger.jsonl")) if last else []
|
| 708 |
+
|
| 709 |
+
|
| 710 |
+
def _check_generations(path: str, cfg: L.RLConfig, expect_ids: list[str]) -> None:
|
| 711 |
+
gens = read_jsonl(path)
|
| 712 |
+
want = len(expect_ids) * cfg.batch.group_size
|
| 713 |
+
if len(gens) != want:
|
| 714 |
+
raise RuntimeError(f"{path}: {len(gens)} generations, expected {want} ({len(expect_ids)} prompts x G={cfg.batch.group_size})")
|
| 715 |
+
for g in gens:
|
| 716 |
+
L.validate_rl_generation(g)
|
code/align/rl/lean_rl_testing.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""TEST SUPPORT for the Lean RL loop (CPU only): a tiny Qwen3-shaped model, a FAKE generator that really
|
| 2 |
+
samples from it (so the loop is genuinely on-policy), and a FAKE verifier. No vLLM, no Lean.
|
| 3 |
+
|
| 4 |
+
Not used by any real run; it lives in the package so the tests and `scripts/p2/rl_lean.py --selftest` share it.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import hashlib
|
| 10 |
+
import json
|
| 11 |
+
import os
|
| 12 |
+
import random
|
| 13 |
+
|
| 14 |
+
import torch
|
| 15 |
+
|
| 16 |
+
from align.model.qwen3 import Qwen3Config, Qwen3ForCausalLM
|
| 17 |
+
from align.ppo.lora import LoRAConfig, inject_lora, lora_modules, mark_only_adapter_trainable
|
| 18 |
+
from align.rl import lean_rl as L
|
| 19 |
+
from align.rl import lean_rl_run as R
|
| 20 |
+
|
| 21 |
+
TINY = Qwen3Config(name="tiny", vocab_size=48, hidden_size=32, intermediate_size=64, n_layers=2, n_heads=4,
|
| 22 |
+
n_kv_heads=2, head_dim=8, max_position_embeddings=256, bos_token_id=1, eos_token_id=2,
|
| 23 |
+
pad_token_id=0)
|
| 24 |
+
EOS = 2
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def tiny_base(seed: int = 0, dtype=torch.float32) -> Qwen3ForCausalLM:
|
| 28 |
+
torch.manual_seed(seed)
|
| 29 |
+
m = Qwen3ForCausalLM(TINY, attn_implementation="eager", device="cpu")
|
| 30 |
+
return m.to(dtype)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def tiny_policy(lcfg: L.LoraCfg, seed: int = 0, dtype=torch.float32, adapter_seed: int = 1234):
|
| 34 |
+
"""(model, params) with LoRA injected (B = 0 unless randomised) and only the adapter trainable."""
|
| 35 |
+
m = tiny_base(seed, torch.float32)
|
| 36 |
+
gen = torch.Generator().manual_seed(adapter_seed)
|
| 37 |
+
inject_lora(m, LoRAConfig(r=lcfg.r, alpha=lcfg.alpha, target_modules=tuple(lcfg.targets), rslora=lcfg.rslora),
|
| 38 |
+
adapter=lcfg.adapter, generator=gen)
|
| 39 |
+
mark_only_adapter_trainable(m, lcfg.adapter)
|
| 40 |
+
m = m.to(dtype)
|
| 41 |
+
params = [p for p in m.parameters() if p.requires_grad]
|
| 42 |
+
return m, params
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def randomize_adapter(model, adapter: str, seed: int, std: float = 0.05) -> None:
|
| 46 |
+
g = torch.Generator().manual_seed(seed)
|
| 47 |
+
with torch.no_grad():
|
| 48 |
+
for _, mod in lora_modules(model):
|
| 49 |
+
a = mod.adapters[adapter]
|
| 50 |
+
a.A.copy_(torch.randn(a.A.shape, generator=g) * std)
|
| 51 |
+
a.B.copy_(torch.randn(a.B.shape, generator=g) * std)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def tiny_builder(cfg: L.RLConfig, dtype=torch.float32):
|
| 55 |
+
def build():
|
| 56 |
+
model, params = tiny_policy(cfg.lora, dtype=dtype)
|
| 57 |
+
return model, params, TINY, 0, "tiny-base", None, None
|
| 58 |
+
|
| 59 |
+
return build
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def synthetic_problems(n: int) -> list[dict]:
|
| 63 |
+
rows = []
|
| 64 |
+
for i in range(n):
|
| 65 |
+
fs = f"theorem t{i} : {i} = {i} := by"
|
| 66 |
+
rows.append({"problem_id": f"p{i:03d}", "source": "synthetic", "split": "train", "formal_statement": fs,
|
| 67 |
+
"informal_prefix": "", "statement_sha": hashlib.sha256(fs.encode()).hexdigest()})
|
| 68 |
+
return rows
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def prompt_ids_for(pid: str) -> list[int]:
|
| 72 |
+
h = int.from_bytes(hashlib.sha256(pid.encode()).digest()[:4], "big")
|
| 73 |
+
rng = random.Random(h)
|
| 74 |
+
return [1] + [rng.randrange(3, TINY.vocab_size) for _ in range(rng.randrange(2, 6))]
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _key_hash(*parts) -> int:
|
| 78 |
+
return int.from_bytes(hashlib.sha256("|".join(map(str, parts)).encode()).digest()[:8], "big")
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
class FakeGenerator:
|
| 82 |
+
"""Samples from the tiny model + the adapter in `adapter_dir` (the CURRENT policy), temperature 1, top_p 1,
|
| 83 |
+
recording the sampled-token logprobs. A real on-policy rollout, CPU-sized. `crash_in_gen` (set by a test)
|
| 84 |
+
kills the process mid-stage after writing nothing, like a vLLM crash before the output is complete."""
|
| 85 |
+
|
| 86 |
+
def __init__(self, cfg: L.RLConfig, max_new: int = 8, base_seed: int = 0, dtype=torch.float32):
|
| 87 |
+
self.cfg, self.max_new, self.base_seed, self.dtype = cfg, max_new, base_seed, dtype
|
| 88 |
+
self.calls = 0
|
| 89 |
+
|
| 90 |
+
def generate(self, k, d, problems_path, adapter_dir, merged_dir, seed) -> None:
|
| 91 |
+
from align.ppo.lora import load_lora_state_dict
|
| 92 |
+
|
| 93 |
+
self.calls += 1
|
| 94 |
+
model, _ = tiny_policy(self.cfg.lora, seed=self.base_seed, dtype=self.dtype)
|
| 95 |
+
load_lora_state_dict(model, self.cfg.lora.adapter, R.read_peft_dir(adapter_dir, TINY), strict=True)
|
| 96 |
+
model.eval()
|
| 97 |
+
recs = []
|
| 98 |
+
for prob in R.read_jsonl(problems_path):
|
| 99 |
+
pid = prob["problem_id"]
|
| 100 |
+
prompt = prompt_ids_for(pid)
|
| 101 |
+
for si in range(self.cfg.batch.group_size):
|
| 102 |
+
g = torch.Generator().manual_seed(_key_hash(seed, pid, si))
|
| 103 |
+
ids = list(prompt)
|
| 104 |
+
lps = []
|
| 105 |
+
finish = "length"
|
| 106 |
+
for _ in range(self.max_new):
|
| 107 |
+
with torch.no_grad():
|
| 108 |
+
logits = model(input_ids=torch.tensor([ids]))[0, -1].double()
|
| 109 |
+
lp = torch.log_softmax(logits, -1)
|
| 110 |
+
tok = int(torch.multinomial(lp.exp(), 1, generator=g))
|
| 111 |
+
ids.append(tok)
|
| 112 |
+
lps.append(float(lp[tok]))
|
| 113 |
+
if tok == EOS:
|
| 114 |
+
finish = "stop"
|
| 115 |
+
break
|
| 116 |
+
comp = ids[len(prompt):]
|
| 117 |
+
recs.append({
|
| 118 |
+
"problem_id": pid, "sample_idx": si, "round": 0, "cap_tokens": self.cfg.rollout.cap_tokens,
|
| 119 |
+
"prompt_ids": prompt, "completion_ids": comp, "prompt_text": "", "completion_text": " ".join(map(str, comp)),
|
| 120 |
+
"finish_reason": finish, "lean_code": "theorem x := by\n trivial", "messages": [],
|
| 121 |
+
"model_path": "tiny", "model_rev": "tiny+adapter",
|
| 122 |
+
"sampling": {"temperature": 1.0, "top_p": 1.0, "top_k": 0, "n": 1, "max_tokens": self.max_new, "seed": seed},
|
| 123 |
+
"context_clipped": False, "parent": None, "sampling_logprobs": lps,
|
| 124 |
+
"adapter": {"serving": "unmerged_vllm_lora"},
|
| 125 |
+
})
|
| 126 |
+
R.write_jsonl_atomic(os.path.join(d, "gens.jsonl"), recs)
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
class FakeVerifier:
|
| 130 |
+
"""accepted iff the completion contains an even token > 10 in its first 3 tokens (learnable, ~50% under a
|
| 131 |
+
random policy); every 9th sample by key hash is a TIMEOUT (no signal)."""
|
| 132 |
+
|
| 133 |
+
def __init__(self, timeout_mod: int = 9):
|
| 134 |
+
self.timeout_mod = timeout_mod
|
| 135 |
+
self.calls = 0
|
| 136 |
+
|
| 137 |
+
def verify(self, d, problems_path) -> None:
|
| 138 |
+
self.calls += 1
|
| 139 |
+
out = []
|
| 140 |
+
for g in R.read_jsonl(os.path.join(d, "gens.jsonl")):
|
| 141 |
+
key = (g["problem_id"], g["sample_idx"], g["round"], g["cap_tokens"])
|
| 142 |
+
timeout = self.timeout_mod and _key_hash(*key) % self.timeout_mod == 0
|
| 143 |
+
ok = any(t % 2 == 0 and t > 10 for t in g["completion_ids"][:3])
|
| 144 |
+
out.append({"problem_id": key[0], "sample_idx": key[1], "round": key[2], "cap_tokens": key[3],
|
| 145 |
+
"accepted": bool(ok and not timeout), "reasons": ["timeout"] if timeout else ([] if ok else ["compile_error"]),
|
| 146 |
+
"verify_s": 0.25, "budget_s": 1.0, "verifier": {"fake": True},
|
| 147 |
+
"code_sha256": hashlib.sha256(g["lean_code"].encode()).hexdigest(), "stage": "fake", "diagnostics": {}})
|
| 148 |
+
R.write_jsonl_atomic(os.path.join(d, "ver.jsonl"), out)
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def synthetic_rollouts(n_prompts: int, G: int, seed: int, timeouts: int = 0, dead_prompts: int = 0,
|
| 152 |
+
cap: int = 64) -> tuple[list[dict], dict]:
|
| 153 |
+
"""Random gens + verdicts with UNEVEN lengths, behaviour logprobs unrelated to any model (so the importance
|
| 154 |
+
ratio spans well below 1 and above the clamp), `dead_prompts` all-equal groups and `timeouts` masked samples."""
|
| 155 |
+
rng = random.Random(seed)
|
| 156 |
+
gens, vers = [], {}
|
| 157 |
+
for pi in range(n_prompts):
|
| 158 |
+
pid = f"p{pi:03d}"
|
| 159 |
+
prompt = prompt_ids_for(pid)
|
| 160 |
+
dead = pi < dead_prompts
|
| 161 |
+
for si in range(G):
|
| 162 |
+
n = rng.randrange(1, 10)
|
| 163 |
+
comp = [rng.randrange(3, TINY.vocab_size) for _ in range(n)]
|
| 164 |
+
lps = [-rng.uniform(0.05, 4.0) for _ in range(n)]
|
| 165 |
+
gens.append({"problem_id": pid, "sample_idx": si, "round": 0, "cap_tokens": cap, "prompt_ids": prompt,
|
| 166 |
+
"completion_ids": comp, "finish_reason": "stop", "lean_code": "x",
|
| 167 |
+
"sampling": {"temperature": 1.0, "top_p": 1.0, "top_k": 0}, "sampling_logprobs": lps})
|
| 168 |
+
acc = False if dead else (rng.random() < 0.5)
|
| 169 |
+
reasons = [] if acc else ["compile_error"]
|
| 170 |
+
vers[(pid, si, 0, cap)] = {"accepted": acc, "reasons": reasons, "verify_s": 0.1}
|
| 171 |
+
keys = list(vers)
|
| 172 |
+
for key in rng.sample([k for k in keys if int(k[0][1:]) >= dead_prompts], timeouts):
|
| 173 |
+
vers[key] = {"accepted": False, "reasons": ["timeout"], "verify_s": 5.0}
|
| 174 |
+
return gens, vers
|
code/align/rl/multiturn_source.py
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Multi-turn Lean rollouts -> GRPO rows with PER-TOKEN advantages (opt-in).
|
| 2 |
+
|
| 3 |
+
`GRPOTrainModule` used to see one scalar advantage per row, so the credit the
|
| 4 |
+
multi-turn env computes per turn could not reach the loss. This module is the
|
| 5 |
+
producer side of the fix (the consumer side is `expand_advantages` in
|
| 6 |
+
grpo_loss.py): it runs `align/rl/envs/lean_multiturn.py` episodes, assigns
|
| 7 |
+
advantages, and emits rows whose ``advantages`` is [B, T] in INPUT space,
|
| 8 |
+
nonzero only on tokens the policy generated.
|
| 9 |
+
|
| 10 |
+
Every design choice below names the failure it prevents.
|
| 11 |
+
|
| 12 |
+
ROW LAYOUT: one row per TURN (the env's ``layout="per_turn"``, the default).
|
| 13 |
+
* A packed row per episode is valid only when the chat template renders
|
| 14 |
+
turn k's prompt as an exact extension of turn k-1's prompt + completion.
|
| 15 |
+
Qwen3-family templates (Goedel-Prover-V2 is one) strip earlier <think>
|
| 16 |
+
blocks, so the env raises `PrefixBreak` on them; per-turn rows are exact on
|
| 17 |
+
every template because each row carries the exact prompt ids the policy
|
| 18 |
+
saw at that turn.
|
| 19 |
+
* Memory: a per-turn row is bounded by one turn's prompt + completion, so
|
| 20 |
+
micro-batch padding is bounded by the single longest TURN. A packed row is
|
| 21 |
+
the SUM of all turns' context and pads every micro-batch row to that sum.
|
| 22 |
+
Per-turn rows pay for it by re-running the shared prefix (~K/2 x extra
|
| 23 |
+
forward tokens at K turns) -- accepted: it is bounded and the alternative
|
| 24 |
+
cannot always be built.
|
| 25 |
+
* Rows are independent in the loss. The Dr.GRPO denominator is a constant
|
| 26 |
+
and advantages are already per-token, so no statistic crosses rows: a
|
| 27 |
+
micro-slice or rank boundary that splits one episode's rows changes
|
| 28 |
+
nothing (a test pins that the summed loss is invariant to row order and
|
| 29 |
+
split). The packed layout is still accepted (the env's ``layout=
|
| 30 |
+
"concatenated"``) when the template permits it; the same masks and
|
| 31 |
+
advantages come out.
|
| 32 |
+
* DENOMINATOR: the constant D is per OPTIMIZER STEP. A multi-turn episode
|
| 33 |
+
can generate max_turns x max_response_len tokens, so D should be sized
|
| 34 |
+
prompts_per_step x G x max_turns x max_response_len
|
| 35 |
+
(`recommended_loss_denominator`); sizing it for one turn silently scales
|
| 36 |
+
every gradient by up to max_turns. The A3 gate still only requires a
|
| 37 |
+
positive finite float; the value is the caller's pre-registered choice.
|
| 38 |
+
|
| 39 |
+
CREDIT (`MultiTurnCreditConfig`):
|
| 40 |
+
mode="episode" (DEFAULT): one number per episode, A_ep = R_ep - mean_group(R),
|
| 41 |
+
the Dr.GRPO centered advantage over the episode's TOTAL reward, broadcast
|
| 42 |
+
to EVERY generated token of EVERY turn. Prevents: a baseline computed on a
|
| 43 |
+
thinned sample (see below) and the variance of fitting a per-turn value
|
| 44 |
+
function from G <= 16 episodes. It is the exact single-turn algebra, so
|
| 45 |
+
group centering, dead-group semantics and A3 all carry over unchanged.
|
| 46 |
+
Its cost is credit blur: a failed first draft is rewarded for the repair
|
| 47 |
+
that followed. That is wrong only if the draft did not make the repair
|
| 48 |
+
possible; with turn_discount < 1 the solved-late episode already scores
|
| 49 |
+
lower than a solved-first one, which is the ordering we want taught.
|
| 50 |
+
mode="per_turn_loo": turn t gets G_t - b_t, G_t = sum_{s>=t} gamma^(s-t) u_s
|
| 51 |
+
(the env's return; gamma=0 -> each turn judged on its own outcome).
|
| 52 |
+
turn 0 : b_0 = mean over the WHOLE group. Every episode has a turn 0,
|
| 53 |
+
so this is the ordinary group baseline and the turn-0
|
| 54 |
+
advantages are exactly centered.
|
| 55 |
+
turn t>=1: b_t = mean over the OTHER episodes of the group that reached
|
| 56 |
+
turn t (leave-one-out). The old rule averaged over ALL
|
| 57 |
+
episodes that reached turn t INCLUDING self; with one repair
|
| 58 |
+
in the group the baseline was the sample's own return, so the
|
| 59 |
+
lone repair that SOLVED the problem got advantage exactly 0
|
| 60 |
+
(and one that failed also 0). LOO removes self-inclusion, and
|
| 61 |
+
the LOO advantages of a cell sum to exactly 0 (x_i - mean_{-i}
|
| 62 |
+
= n/(n-1) (x_i - mean)).
|
| 63 |
+
lone cell: if no other episode of the group reached turn t there is no
|
| 64 |
+
within-group peer. The baseline is then the mean of the other
|
| 65 |
+
episodes at turn t POOLED across the other groups of the same
|
| 66 |
+
rollout step (>= ``min_pool_peers`` of them; still leave-one-
|
| 67 |
+
out and independent of the sample's own action, so the
|
| 68 |
+
estimator stays unbiased). With fewer, the cell's advantage
|
| 69 |
+
is 0 and counted (`credit/cells_no_baseline`) -- a baseline
|
| 70 |
+
invented from nothing would be a made-up gradient.
|
| 71 |
+
Pooled cells trade within-group centering for a baseline that exists; the
|
| 72 |
+
telemetry counts how many cells took each path.
|
| 73 |
+
|
| 74 |
+
DEAD GROUPS. "episode": dead iff the group's episode rewards have zero
|
| 75 |
+
variance, classified all_zero / all_solved / nonzero_std0 by the shared
|
| 76 |
+
`GroupFilterStats` (max_possible_score=1.0: the best episode reward). "per_turn
|
| 77 |
+
_loo": dead iff EVERY token advantage of the group is exactly 0 -- the group
|
| 78 |
+
contributes no gradient, so keeping it only spends compute; classified from
|
| 79 |
+
the rewards the same way. Dead groups are dropped whole, as in single-turn
|
| 80 |
+
assembly; an all-dead step returns None (the driver resamples).
|
| 81 |
+
|
| 82 |
+
No CUDA, no Lean here: the env, generator and verifier are injected.
|
| 83 |
+
"""
|
| 84 |
+
|
| 85 |
+
from __future__ import annotations
|
| 86 |
+
|
| 87 |
+
import hashlib
|
| 88 |
+
from collections import OrderedDict
|
| 89 |
+
from dataclasses import dataclass, field
|
| 90 |
+
from typing import Any, Callable, Optional, Sequence
|
| 91 |
+
|
| 92 |
+
import numpy as np
|
| 93 |
+
import torch
|
| 94 |
+
|
| 95 |
+
from align.rl.advantages import GroupFilterStats, compute_group_advantages
|
| 96 |
+
from align.rl.batch_source import GrpoBatchSource
|
| 97 |
+
from align.rl.envs.lean_multiturn import (
|
| 98 |
+
EpisodeRecord,
|
| 99 |
+
EpisodeSpec,
|
| 100 |
+
LeanMultiTurnEnv,
|
| 101 |
+
Problem,
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
CREDIT_MODES = ("episode", "per_turn_loo")
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
@dataclass(frozen=True)
|
| 108 |
+
class MultiTurnCreditConfig:
|
| 109 |
+
mode: str = "episode"
|
| 110 |
+
#: per-turn return discount (per_turn_loo only): 0 = a turn is judged on its own outcome,
|
| 111 |
+
#: >0 = an earlier turn shares in the later turns' outcomes
|
| 112 |
+
gamma: float = 0.0
|
| 113 |
+
#: pooled-baseline floor for a turn no group-mate reached (per_turn_loo only)
|
| 114 |
+
min_pool_peers: int = 2
|
| 115 |
+
|
| 116 |
+
def __post_init__(self) -> None:
|
| 117 |
+
if self.mode not in CREDIT_MODES:
|
| 118 |
+
raise ValueError(f"mode must be one of {CREDIT_MODES}, got {self.mode!r}")
|
| 119 |
+
if not 0.0 <= self.gamma <= 1.0:
|
| 120 |
+
raise ValueError(f"gamma must be in [0, 1], got {self.gamma}")
|
| 121 |
+
if self.min_pool_peers < 1:
|
| 122 |
+
raise ValueError(f"min_pool_peers must be >= 1, got {self.min_pool_peers}")
|
| 123 |
+
if self.mode == "episode" and self.gamma != 0.0:
|
| 124 |
+
raise ValueError("gamma is read only by mode='per_turn_loo'; it would be silently ignored")
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def recommended_loss_denominator(
|
| 128 |
+
prompts_per_step: int, n_samples: int, max_turns: int, max_response_len: int
|
| 129 |
+
) -> float:
|
| 130 |
+
"""Dr.GRPO constant sized for the worst-case GENERATED tokens of one optimizer step."""
|
| 131 |
+
return float(prompts_per_step * n_samples * max_turns * max_response_len)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
# ---------------------------------------------------------------------------- credit
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def turn_returns(u: Sequence[float], gamma: float) -> list[float]:
|
| 138 |
+
"""G_t = sum_{s>=t} gamma^(s-t) u_s (the env's definition)."""
|
| 139 |
+
out, acc = [0.0] * len(u), 0.0
|
| 140 |
+
for t in range(len(u) - 1, -1, -1):
|
| 141 |
+
acc = u[t] + gamma * acc
|
| 142 |
+
out[t] = acc
|
| 143 |
+
return out
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
@dataclass
|
| 147 |
+
class CreditResult:
|
| 148 |
+
#: per episode (input order), per turn advantage
|
| 149 |
+
turn_adv: list
|
| 150 |
+
#: path taken per (episode, turn) cell: "episode" | "group_mean" | "group_loo" | "pooled" | "none"
|
| 151 |
+
cell_path: list
|
| 152 |
+
counts: dict = field(default_factory=dict)
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def compute_turn_credit(episodes: Sequence[EpisodeRecord], credit: MultiTurnCreditConfig) -> CreditResult:
|
| 156 |
+
"""Advantage per (episode, turn). Episodes of one problem_id form a group."""
|
| 157 |
+
n = len(episodes)
|
| 158 |
+
groups: "OrderedDict[str, list[int]]" = OrderedDict()
|
| 159 |
+
for i, e in enumerate(episodes):
|
| 160 |
+
groups.setdefault(e.problem_id, []).append(i)
|
| 161 |
+
|
| 162 |
+
ep_adv = [0.0] * n
|
| 163 |
+
for idx in groups.values():
|
| 164 |
+
a = compute_group_advantages(
|
| 165 |
+
np.array([episodes[i].reward["reward/total"] for i in idx]), len(idx), "centered"
|
| 166 |
+
)
|
| 167 |
+
for i, v in zip(idx, a):
|
| 168 |
+
ep_adv[i] = float(v)
|
| 169 |
+
|
| 170 |
+
n_turns = [len(e.turns) for e in episodes]
|
| 171 |
+
if credit.mode == "episode":
|
| 172 |
+
return CreditResult(
|
| 173 |
+
[[ep_adv[i]] * n_turns[i] for i in range(n)],
|
| 174 |
+
[["episode"] * n_turns[i] for i in range(n)],
|
| 175 |
+
{"credit/cells_episode": sum(n_turns)},
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
G = [turn_returns(e.turn_rewards, credit.gamma) for e in episodes]
|
| 179 |
+
turn_adv = [[0.0] * n_turns[i] for i in range(n)]
|
| 180 |
+
path = [[""] * n_turns[i] for i in range(n)]
|
| 181 |
+
counts = {"credit/cells_group_mean": 0, "credit/cells_group_loo": 0,
|
| 182 |
+
"credit/cells_pooled": 0, "credit/cells_no_baseline": 0}
|
| 183 |
+
for idx in groups.values():
|
| 184 |
+
for i in idx: # turn 0: the ordinary group baseline (every episode has a turn 0)
|
| 185 |
+
base = sum(G[j][0] for j in idx) / len(idx)
|
| 186 |
+
turn_adv[i][0] = G[i][0] - base
|
| 187 |
+
path[i][0] = "group_mean"
|
| 188 |
+
counts["credit/cells_group_mean"] += 1
|
| 189 |
+
for gidx in groups.values():
|
| 190 |
+
for i in gidx:
|
| 191 |
+
for t in range(1, n_turns[i]):
|
| 192 |
+
peers = [G[j][t] for j in gidx if j != i and n_turns[j] > t]
|
| 193 |
+
if peers:
|
| 194 |
+
turn_adv[i][t] = G[i][t] - sum(peers) / len(peers)
|
| 195 |
+
path[i][t] = "group_loo"
|
| 196 |
+
counts["credit/cells_group_loo"] += 1
|
| 197 |
+
continue
|
| 198 |
+
pool = [G[j][t] for j in range(n) if j != i and n_turns[j] > t and j not in gidx]
|
| 199 |
+
if len(pool) >= credit.min_pool_peers:
|
| 200 |
+
turn_adv[i][t] = G[i][t] - sum(pool) / len(pool)
|
| 201 |
+
path[i][t] = "pooled"
|
| 202 |
+
counts["credit/cells_pooled"] += 1
|
| 203 |
+
else:
|
| 204 |
+
path[i][t] = "none"
|
| 205 |
+
counts["credit/cells_no_baseline"] += 1
|
| 206 |
+
return CreditResult(turn_adv, path, counts)
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
# ---------------------------------------------------------------------------- assembly
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
@dataclass
|
| 213 |
+
class AssembledMultiTurn:
|
| 214 |
+
"""Duck-types `AssembledRollout` (the fields `GrpoBatchSource._produce_step` reads),
|
| 215 |
+
except ``advantages`` is [B, T] in input space."""
|
| 216 |
+
|
| 217 |
+
input_ids: torch.Tensor
|
| 218 |
+
attention_mask: torch.Tensor
|
| 219 |
+
response_mask: torch.Tensor
|
| 220 |
+
advantages: torch.Tensor
|
| 221 |
+
vllm_logprobs: torch.Tensor
|
| 222 |
+
group_ids: torch.Tensor
|
| 223 |
+
rewards: dict
|
| 224 |
+
turn_ids: torch.Tensor
|
| 225 |
+
stats: dict = field(default_factory=dict)
|
| 226 |
+
kept_groups: int = 0
|
| 227 |
+
dropped_groups: int = 0
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
#: A group is dead below this spread. `np.std(...) == 0` (the single-turn rule) is an EXACT test, and
|
| 231 |
+
#: with discounted multi-turn rewards it misfires: seven episodes that all solved on turn 1 at
|
| 232 |
+
#: discount 0.9 have std 1.1e-16, not 0, because ``mean(x, x, ..., x) != x`` in float64. Such a group
|
| 233 |
+
#: was kept with advantages of +-1e-16 (a zero-gradient group paid for in full, and counted as
|
| 234 |
+
#: ``groups/kept``). Rewards live in [-1, 1] and real differences are >= ~1e-3, so 1e-9 separates
|
| 235 |
+
#: rounding noise from signal by six orders of magnitude on each side.
|
| 236 |
+
DEAD_ATOL = 1e-9
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def _observe_group(stats: GroupFilterStats, rewards: np.ndarray, live: bool) -> None:
|
| 240 |
+
"""Record one group in the three-way telemetry with an externally decided liveness."""
|
| 241 |
+
if live:
|
| 242 |
+
stats.kept += 1
|
| 243 |
+
return
|
| 244 |
+
if np.all(np.abs(rewards) <= DEAD_ATOL):
|
| 245 |
+
stats.all_zero += 1
|
| 246 |
+
elif np.all(np.abs(rewards - stats.max_possible_score) <= DEAD_ATOL):
|
| 247 |
+
stats.all_solved += 1
|
| 248 |
+
else:
|
| 249 |
+
stats.nonzero_std0 += 1
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
def assemble_multiturn(
|
| 253 |
+
episodes: Sequence[EpisodeRecord],
|
| 254 |
+
credit: MultiTurnCreditConfig,
|
| 255 |
+
pad_token_id: int,
|
| 256 |
+
filter_stats: Optional[GroupFilterStats] = None,
|
| 257 |
+
) -> Optional[AssembledMultiTurn]:
|
| 258 |
+
"""Episodes -> one training batch with per-token advantages; None if every group is dead."""
|
| 259 |
+
if not episodes:
|
| 260 |
+
raise ValueError("no episodes")
|
| 261 |
+
stats = filter_stats if filter_stats is not None else GroupFilterStats(max_possible_score=1.0)
|
| 262 |
+
res = compute_turn_credit(episodes, credit)
|
| 263 |
+
|
| 264 |
+
groups: "OrderedDict[str, list[int]]" = OrderedDict()
|
| 265 |
+
for i, e in enumerate(episodes):
|
| 266 |
+
groups.setdefault(e.problem_id, []).append(i)
|
| 267 |
+
|
| 268 |
+
keep: list[list[int]] = []
|
| 269 |
+
for idx in groups.values():
|
| 270 |
+
rewards = np.array([episodes[i].reward["reward/total"] for i in idx], dtype=np.float64)
|
| 271 |
+
if credit.mode == "episode":
|
| 272 |
+
# the single-turn rule (zero reward variance) with a rounding tolerance, see DEAD_ATOL
|
| 273 |
+
dead = float(np.ptp(rewards)) <= DEAD_ATOL
|
| 274 |
+
else:
|
| 275 |
+
dead = not any(abs(a) > DEAD_ATOL for i in idx for a in res.turn_adv[i])
|
| 276 |
+
_observe_group(stats, rewards, live=not dead)
|
| 277 |
+
if not dead:
|
| 278 |
+
keep.append(idx)
|
| 279 |
+
if not keep:
|
| 280 |
+
return None
|
| 281 |
+
|
| 282 |
+
rows = [] # (group_index, episode, row, per-token adv)
|
| 283 |
+
for gi, idx in enumerate(keep):
|
| 284 |
+
for i in idx:
|
| 285 |
+
ep = episodes[i]
|
| 286 |
+
for row in ep.rows:
|
| 287 |
+
if row.logprobs is None:
|
| 288 |
+
raise ValueError(
|
| 289 |
+
f"episode {ep.episode_key}: generation carries no logprobs; the rho "
|
| 290 |
+
"correction cannot align without the engine's per-token logprobs"
|
| 291 |
+
)
|
| 292 |
+
adv = [res.turn_adv[i][tid] if tid >= 0 else 0.0 for tid in row.turn_ids]
|
| 293 |
+
rows.append((gi, ep, row, adv))
|
| 294 |
+
B = len(rows)
|
| 295 |
+
T = max(len(r.input_ids) for _, _, r, _ in rows)
|
| 296 |
+
input_ids = torch.full((B, T), pad_token_id, dtype=torch.int64)
|
| 297 |
+
attention_mask = torch.zeros((B, T), dtype=torch.int64)
|
| 298 |
+
response_mask = torch.zeros((B, T), dtype=torch.int64)
|
| 299 |
+
turn_ids = torch.full((B, T), -1, dtype=torch.int64)
|
| 300 |
+
advantages = torch.zeros((B, T), dtype=torch.float32)
|
| 301 |
+
vllm_lp = torch.zeros((B, T - 1), dtype=torch.float32)
|
| 302 |
+
group_ids = torch.zeros((B,), dtype=torch.int64)
|
| 303 |
+
term_names = list(rows[0][1].reward)
|
| 304 |
+
reward_tensors = {k: torch.zeros(B, dtype=torch.float32) for k in term_names}
|
| 305 |
+
for b, (gi, ep, row, adv) in enumerate(rows):
|
| 306 |
+
L = len(row.input_ids)
|
| 307 |
+
if not (len(row.response_mask) == len(row.turn_ids) == len(row.logprobs) == L):
|
| 308 |
+
raise ValueError(f"row {b} of episode {ep.episode_key} is ragged across ids/mask/turn_ids/logprobs")
|
| 309 |
+
# Credit is placed by ``turn_ids`` and trained on by ``response_mask``. If the two disagree
|
| 310 |
+
# by even one position, the loss-side stray check (`expand_advantages`) catches it only
|
| 311 |
+
# when the misplaced credit lands on an excluded position that exists: a row whose last
|
| 312 |
+
# generated token is its last column (every micro-batch's longest per-turn row) has no
|
| 313 |
+
# position past it, so a right-shifted span slips through. Refuse here, where it is exact.
|
| 314 |
+
if [int(t >= 0) for t in row.turn_ids] != [int(m) for m in row.response_mask]:
|
| 315 |
+
raise ValueError(
|
| 316 |
+
f"row {b} of episode {ep.episode_key}: turn_ids >= 0 does not coincide with "
|
| 317 |
+
f"response_mask; per-token credit would be misaligned with the trained tokens"
|
| 318 |
+
)
|
| 319 |
+
input_ids[b, :L] = torch.tensor(row.input_ids, dtype=torch.int64)
|
| 320 |
+
attention_mask[b, :L] = 1
|
| 321 |
+
response_mask[b, :L] = torch.tensor(row.response_mask, dtype=torch.int64)
|
| 322 |
+
turn_ids[b, :L] = torch.tensor(row.turn_ids, dtype=torch.int64)
|
| 323 |
+
advantages[b, :L] = torch.tensor(adv, dtype=torch.float32)
|
| 324 |
+
# label position j <-> input token j+1
|
| 325 |
+
vllm_lp[b, : L - 1] = torch.tensor(row.logprobs[1:], dtype=torch.float32)
|
| 326 |
+
group_ids[b] = gi
|
| 327 |
+
for k in term_names:
|
| 328 |
+
reward_tensors[k][b] = float(ep.reward[k])
|
| 329 |
+
|
| 330 |
+
kept_eps = [episodes[i] for idx in keep for i in idx]
|
| 331 |
+
gsz = [len(idx) for idx in keep]
|
| 332 |
+
first = [e.solved_turn == 0 for e in kept_eps]
|
| 333 |
+
failed_first = [e for e in kept_eps if e.solved_turn != 0]
|
| 334 |
+
stat = {
|
| 335 |
+
"multiturn/episodes": float(len(kept_eps)),
|
| 336 |
+
"multiturn/rows": float(B),
|
| 337 |
+
"multiturn/turns_mean": float(np.mean([len(e.turns) for e in kept_eps])),
|
| 338 |
+
"multiturn/solved_turn0": float(np.mean(first)),
|
| 339 |
+
"multiturn/solved": float(np.mean([e.solved_turn is not None for e in kept_eps])),
|
| 340 |
+
"multiturn/repair_rate": (
|
| 341 |
+
float(np.mean([e.solved_turn is not None for e in failed_first])) if failed_first else float("nan")
|
| 342 |
+
),
|
| 343 |
+
"multiturn/generated_tokens_mean": float(np.mean([e.n_generated for e in kept_eps])),
|
| 344 |
+
"multiturn/group_size_min": float(min(gsz)),
|
| 345 |
+
"multiturn/group_size_max": float(max(gsz)),
|
| 346 |
+
**{k: float(v) for k, v in res.counts.items()},
|
| 347 |
+
**{k: float(v) for k, v in stats.as_log_dict().items()},
|
| 348 |
+
}
|
| 349 |
+
return AssembledMultiTurn(
|
| 350 |
+
input_ids, attention_mask, response_mask, advantages, vllm_lp, group_ids, reward_tensors,
|
| 351 |
+
turn_ids, stat, kept_groups=len(keep), dropped_groups=len(groups) - len(keep),
|
| 352 |
+
)
|
| 353 |
+
|
| 354 |
+
|
| 355 |
+
# ---------------------------------------------------------------------------- the batch source
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
class MultiTurnBatchSource(GrpoBatchSource):
|
| 359 |
+
"""`GrpoBatchSource` whose rollout is a batch of multi-turn Lean episodes.
|
| 360 |
+
|
| 361 |
+
Everything downstream of rollout production (rank-0 produce + broadcast,
|
| 362 |
+
row slicing, no-grad old_logprobs, the validated batch, the collapse
|
| 363 |
+
tripwire, the draw-position resume state) is inherited unchanged; only
|
| 364 |
+
`_rollout_once` differs. Rows are sliced along dim 0 and the [B, T]
|
| 365 |
+
advantages slice with them.
|
| 366 |
+
|
| 367 |
+
``prompt_rows``: dicts with ``formal_statement`` (or ``problem``) and a
|
| 368 |
+
UNIQUE ``problem_id`` (the group key; duplicates are refused -- two
|
| 369 |
+
problems sharing an id would be centered against each other).
|
| 370 |
+
``generate_factory(seed=..., weights_dir=...)`` returns the env's
|
| 371 |
+
``generate(list[GenRequest]) -> list[Generation]`` for this step.
|
| 372 |
+
"""
|
| 373 |
+
|
| 374 |
+
def __init__(
|
| 375 |
+
self,
|
| 376 |
+
prompt_rows: Sequence[dict[str, Any]],
|
| 377 |
+
*,
|
| 378 |
+
env: LeanMultiTurnEnv,
|
| 379 |
+
credit: MultiTurnCreditConfig = MultiTurnCreditConfig(),
|
| 380 |
+
prompts_per_step: int,
|
| 381 |
+
n_samples_per_prompt: int,
|
| 382 |
+
max_response_len: int,
|
| 383 |
+
temperature: float,
|
| 384 |
+
optimizer_steps: int,
|
| 385 |
+
grad_accum: int,
|
| 386 |
+
seed: int,
|
| 387 |
+
dead_group_retries: int = 5,
|
| 388 |
+
):
|
| 389 |
+
rows, seen = [], set()
|
| 390 |
+
for i, r in enumerate(prompt_rows):
|
| 391 |
+
stmt = r.get("formal_statement", r.get("problem"))
|
| 392 |
+
if not isinstance(stmt, str) or not stmt:
|
| 393 |
+
raise ValueError(f"prompt row {i} lacks formal_statement/problem: {sorted(r)}")
|
| 394 |
+
pid = str(r.get("problem_id") or hashlib.sha256(stmt.encode()).hexdigest()[:12])
|
| 395 |
+
if pid in seen:
|
| 396 |
+
raise ValueError(f"duplicate problem_id {pid!r}: it is the advantage group key")
|
| 397 |
+
seen.add(pid)
|
| 398 |
+
rows.append({"problem": stmt, "answer": "", "problem_id": pid})
|
| 399 |
+
super().__init__(
|
| 400 |
+
rows,
|
| 401 |
+
prompts_per_step=prompts_per_step,
|
| 402 |
+
n_samples_per_prompt=n_samples_per_prompt,
|
| 403 |
+
max_response_len=max_response_len,
|
| 404 |
+
temperature=temperature,
|
| 405 |
+
optimizer_steps=optimizer_steps,
|
| 406 |
+
grad_accum=grad_accum,
|
| 407 |
+
seed=seed,
|
| 408 |
+
prompt_builder=lambda p: p, # unused: the env builds the Goedel prompts
|
| 409 |
+
dead_group_retries=dead_group_retries,
|
| 410 |
+
)
|
| 411 |
+
self.env = env
|
| 412 |
+
self.credit = credit
|
| 413 |
+
# Multi-turn episode reward is <= 1.0 (turn_discount ** 0); the single-turn
|
| 414 |
+
# source's 1.1 (accuracy + format bonus) would misfile all-solved groups.
|
| 415 |
+
self.filter_stats.max_possible_score = 1.0
|
| 416 |
+
self.last_rollout_stats: dict[str, float] = {}
|
| 417 |
+
|
| 418 |
+
def bind( # noqa: D401 - deliberately narrower than the parent's (no client/reward/verifier)
|
| 419 |
+
self,
|
| 420 |
+
*,
|
| 421 |
+
generate_factory: Callable[..., Callable[[list], list]],
|
| 422 |
+
old_logprob_fn,
|
| 423 |
+
weights_dir_fn,
|
| 424 |
+
rank: int,
|
| 425 |
+
world_size: int,
|
| 426 |
+
device: str = "cpu",
|
| 427 |
+
canary=None,
|
| 428 |
+
canary_generate_fn=None,
|
| 429 |
+
collapse_tripwire=None,
|
| 430 |
+
trainer=None,
|
| 431 |
+
canary_log_path=None,
|
| 432 |
+
) -> None:
|
| 433 |
+
self.generate_factory = generate_factory
|
| 434 |
+
super().bind(
|
| 435 |
+
client=None, reward=None, verifier=None, old_logprob_fn=old_logprob_fn,
|
| 436 |
+
weights_dir_fn=weights_dir_fn, rank=rank, world_size=world_size, device=device,
|
| 437 |
+
canary=canary, canary_generate_fn=canary_generate_fn, collapse_tripwire=collapse_tripwire,
|
| 438 |
+
trainer=trainer, canary_log_path=canary_log_path,
|
| 439 |
+
)
|
| 440 |
+
|
| 441 |
+
def _rollout_once(self, indices: list[int], optimizer_step: int = 0) -> Optional[AssembledMultiTurn]:
|
| 442 |
+
specs = [
|
| 443 |
+
EpisodeSpec(Problem(self.prompt_rows[i]["problem_id"], self.prompt_rows[i]["problem"]), s)
|
| 444 |
+
for i in indices
|
| 445 |
+
for s in range(self.n_samples_per_prompt)
|
| 446 |
+
]
|
| 447 |
+
generate = self.generate_factory(
|
| 448 |
+
seed=self.rollout_seed(optimizer_step), weights_dir=self.weights_dir_fn()
|
| 449 |
+
)
|
| 450 |
+
episodes = self.env.run_group(specs, generate)
|
| 451 |
+
assembled = assemble_multiturn(episodes, self.credit, self.pad_token_id, self.filter_stats)
|
| 452 |
+
if assembled is not None:
|
| 453 |
+
self.last_rollout_stats = assembled.stats
|
| 454 |
+
return assembled
|
code/align/rl/ops.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pure tensor ops for the GRPO layer.
|
| 2 |
+
|
| 3 |
+
Vendored from allenai/open-instruct @ 8759c91:
|
| 4 |
+
masked_mean <- open_instruct/rl_utils.py:426-448
|
| 5 |
+
entropy_from_logits <- open_instruct/model_utils.py:326-334
|
| 6 |
+
log_softmax_and_gather <- open_instruct/model_utils.py:588-601
|
| 7 |
+
estimate_kl <- open_instruct/model_utils.py:793-819
|
| 8 |
+
|
| 9 |
+
Bodies are verbatim; only the module docstrings/imports differ. Any edit to
|
| 10 |
+
these functions breaks parity with the upstream line citations in
|
| 11 |
+
docs/research/nmo-research/docs/04_ALLENAI_CODEBASE_MAP.md and must be
|
| 12 |
+
recorded as a deliberate divergence, not slipped in.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
from typing import Optional, Union
|
| 18 |
+
|
| 19 |
+
import torch
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def masked_mean(
|
| 23 |
+
values: torch.Tensor,
|
| 24 |
+
mask: torch.Tensor,
|
| 25 |
+
axis: Optional[int] = None,
|
| 26 |
+
denominator: Optional[float] = None,
|
| 27 |
+
) -> torch.Tensor:
|
| 28 |
+
"""Compute mean of tensor with masked values.
|
| 29 |
+
|
| 30 |
+
Returns 0 if mask is empty (no valid elements) to avoid division by zero.
|
| 31 |
+
This can happen with sequence parallel when a chunk contains only
|
| 32 |
+
query/padding tokens.
|
| 33 |
+
|
| 34 |
+
A3 note: passing a constant float ``denominator`` is what realises the
|
| 35 |
+
Dr.GRPO constant-global-denominator form; leaving it None realises the
|
| 36 |
+
DAPO token-mean. align's conformance gate requires the float.
|
| 37 |
+
"""
|
| 38 |
+
extra_dims = values.ndim - mask.ndim
|
| 39 |
+
if axis is None:
|
| 40 |
+
sum_dims: int | tuple[int, ...] = tuple(range(extra_dims, values.ndim))
|
| 41 |
+
elif axis >= 0:
|
| 42 |
+
sum_dims = axis + extra_dims
|
| 43 |
+
else:
|
| 44 |
+
sum_dims = axis
|
| 45 |
+
numerator = (values * mask).sum(dim=sum_dims)
|
| 46 |
+
denom = mask.sum(dim=axis) if denominator is None else denominator
|
| 47 |
+
if isinstance(denom, torch.Tensor):
|
| 48 |
+
result = torch.where(denom > 0, numerator / denom, torch.zeros_like(numerator))
|
| 49 |
+
else:
|
| 50 |
+
result = numerator / denom if denom > 0 else torch.zeros_like(numerator)
|
| 51 |
+
return result.flatten(extra_dims).mean(-1) if result.ndim > extra_dims else result
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def entropy_from_logits(logits: torch.Tensor) -> torch.Tensor:
|
| 55 |
+
"""Compute the entropy of the logits.
|
| 56 |
+
|
| 57 |
+
Upstream credit chain: open-instruct borrowed this from verl
|
| 58 |
+
(verl/utils/torch_functional.py). logsumexp form avoids materialising
|
| 59 |
+
log-probabilities next to probabilities.
|
| 60 |
+
"""
|
| 61 |
+
pd = torch.nn.functional.softmax(logits, dim=-1)
|
| 62 |
+
entropy = torch.logsumexp(logits, dim=-1) - torch.sum(pd * logits, dim=-1)
|
| 63 |
+
return entropy
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def log_softmax_and_gather(logits: torch.Tensor, index: torch.Tensor) -> torch.Tensor:
|
| 67 |
+
"""`log_softmax -> gather` without allocating a [B, T, V] logprob tensor.
|
| 68 |
+
|
| 69 |
+
Upstream compiles this (open-instruct PR #584); align leaves compilation to
|
| 70 |
+
the caller because torch.compile on this box requires the vendored-header
|
| 71 |
+
toolchain fix (align/kernels/_toolchain.py) and is opted into per-site.
|
| 72 |
+
"""
|
| 73 |
+
logprobs = logits.log_softmax(dim=-1)
|
| 74 |
+
return torch.gather(logprobs, dim=-1, index=index.unsqueeze(-1)).squeeze(-1)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def estimate_kl(ref_logprobs_diff: torch.Tensor, ratio: torch.Tensor) -> torch.Tensor:
|
| 78 |
+
"""Compute 4 KL divergence estimators between current and reference policies.
|
| 79 |
+
|
| 80 |
+
Args:
|
| 81 |
+
ref_logprobs_diff: (new_logprobs - ref_logprobs), pre-clamped to
|
| 82 |
+
[-40, 40] by the caller for numerical stability.
|
| 83 |
+
ratio: exp(new_logprobs - old_logprobs) vs the step-start policy.
|
| 84 |
+
|
| 85 |
+
Returns:
|
| 86 |
+
Tensor of shape [4, *ref_logprobs_diff.shape]:
|
| 87 |
+
[0] linear approximation
|
| 88 |
+
[1] quadratic approximation (diff^2 / 2)
|
| 89 |
+
[2] numerically stable form (expm1(-diff) + diff) -- default
|
| 90 |
+
[3] importance-weighted (ratio * diff)
|
| 91 |
+
"""
|
| 92 |
+
return torch.stack(
|
| 93 |
+
[
|
| 94 |
+
ref_logprobs_diff,
|
| 95 |
+
(ref_logprobs_diff) ** 2 / 2,
|
| 96 |
+
torch.expm1(-ref_logprobs_diff) + ref_logprobs_diff,
|
| 97 |
+
ratio * ref_logprobs_diff,
|
| 98 |
+
]
|
| 99 |
+
)
|
code/align/rl/rewards.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Reward composition -- the ONE genuinely net-new A3 piece.
|
| 2 |
+
|
| 3 |
+
The corpus's conformance table (04_ALLENAI_CODEBASE_MAP.md item 9) found soft
|
| 4 |
+
overlong punishment absent from open-instruct entirely (its
|
| 5 |
+
``mask_truncated_completions`` is DAPO's HARD binary filter, a different
|
| 6 |
+
mechanism). Per the same table's architectural note: this lives in the
|
| 7 |
+
REWARD-COMPOSITION layer, never in the loss -- keeping compute_grpo_loss
|
| 8 |
+
provably unmodified from upstream is an auditability property of the
|
| 9 |
+
pre-registration, not a style preference.
|
| 10 |
+
|
| 11 |
+
soft_overlong_punishment implements DAPO eq. (arXiv:2503.14476, "Soft
|
| 12 |
+
Overlong Punishment"): zero inside the budget, linear ramp to -1 across the
|
| 13 |
+
final ``cache`` tokens, -1 beyond max length.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
from dataclasses import dataclass
|
| 19 |
+
from typing import Optional, Sequence
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def soft_overlong_punishment(response_len: int, max_len: int, cache_len: int) -> float:
|
| 23 |
+
"""DAPO soft length penalty in [-1, 0].
|
| 24 |
+
|
| 25 |
+
0 for len <= max_len - cache_len
|
| 26 |
+
((max_len - cache_len) - len)/cache_len inside the buffer (linear to -1)
|
| 27 |
+
-1 for len > max_len
|
| 28 |
+
"""
|
| 29 |
+
if cache_len <= 0 or cache_len >= max_len:
|
| 30 |
+
raise ValueError(f"need 0 < cache_len < max_len, got cache={cache_len} max={max_len}")
|
| 31 |
+
soft_start = max_len - cache_len
|
| 32 |
+
if response_len <= soft_start:
|
| 33 |
+
return 0.0
|
| 34 |
+
if response_len > max_len:
|
| 35 |
+
return -1.0
|
| 36 |
+
return (soft_start - response_len) / cache_len
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@dataclass
|
| 40 |
+
class CompositeReward:
|
| 41 |
+
"""A3 reward: verifier accuracy + soft format term + soft overlong penalty.
|
| 42 |
+
|
| 43 |
+
``accuracy`` and ``format_score`` arrive from the verifier layer (rule
|
| 44 |
+
verifier only -- no PRM/neural RM, A3's hardest lock and the field's
|
| 45 |
+
strongest cross-lab consensus per doc 08). This class only composes and
|
| 46 |
+
itemises them so history.jsonl can log each term separately -- the
|
| 47 |
+
format/structural-consistency instrument (doc 09 amendment) reads the
|
| 48 |
+
per-term stream, not the sum.
|
| 49 |
+
"""
|
| 50 |
+
|
| 51 |
+
format_weight: float = 0.1
|
| 52 |
+
overlong_max_len: Optional[int] = None
|
| 53 |
+
overlong_cache_len: Optional[int] = None
|
| 54 |
+
|
| 55 |
+
def __post_init__(self) -> None:
|
| 56 |
+
# Fail at CONSTRUCTION, not at the first rollout. The dangerous half is
|
| 57 |
+
# cache_len set with max_len unset: __call__ takes the "overlong off"
|
| 58 |
+
# branch and the soft length penalty silently never applies while the
|
| 59 |
+
# config reads as if it were armed (a disarmed-instrument shape).
|
| 60 |
+
if self.overlong_max_len is None and self.overlong_cache_len is not None:
|
| 61 |
+
raise ValueError(
|
| 62 |
+
f"overlong_cache_len={self.overlong_cache_len} given without overlong_max_len: "
|
| 63 |
+
"the soft overlong penalty would be silently OFF"
|
| 64 |
+
)
|
| 65 |
+
if self.overlong_max_len is not None:
|
| 66 |
+
if self.overlong_cache_len is None:
|
| 67 |
+
raise ValueError("overlong_cache_len required when overlong_max_len is set")
|
| 68 |
+
if not 0 < self.overlong_cache_len < self.overlong_max_len:
|
| 69 |
+
raise ValueError(
|
| 70 |
+
f"need 0 < overlong_cache_len < overlong_max_len, got "
|
| 71 |
+
f"cache={self.overlong_cache_len} max={self.overlong_max_len}"
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
def __call__(self, accuracy: float, format_score: float, response_len: int) -> dict[str, float]:
|
| 75 |
+
terms: dict[str, float] = {
|
| 76 |
+
"reward/accuracy": float(accuracy),
|
| 77 |
+
"reward/format": self.format_weight * float(format_score),
|
| 78 |
+
}
|
| 79 |
+
if self.overlong_max_len is not None:
|
| 80 |
+
if self.overlong_cache_len is None:
|
| 81 |
+
raise ValueError("overlong_cache_len required when overlong_max_len is set")
|
| 82 |
+
terms["reward/overlong"] = soft_overlong_punishment(
|
| 83 |
+
response_len, self.overlong_max_len, self.overlong_cache_len
|
| 84 |
+
)
|
| 85 |
+
else:
|
| 86 |
+
terms["reward/overlong"] = 0.0
|
| 87 |
+
terms["reward/total"] = (
|
| 88 |
+
terms["reward/accuracy"] + terms["reward/format"] + terms["reward/overlong"]
|
| 89 |
+
)
|
| 90 |
+
return terms
|
| 91 |
+
|
| 92 |
+
def batch(
|
| 93 |
+
self, accuracies: Sequence[float], format_scores: Sequence[float], lengths: Sequence[int]
|
| 94 |
+
) -> list[float]:
|
| 95 |
+
return [
|
| 96 |
+
self(a, f, n)["reward/total"]
|
| 97 |
+
for a, f, n in zip(accuracies, format_scores, lengths, strict=True)
|
| 98 |
+
]
|
code/align/rl/stage_config.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Typed stage-b config: configs/grpo_stageb.toml -> dataclasses + launch refusals.
|
| 2 |
+
|
| 3 |
+
Deliberately NOT align.config.RunConfig: that is the trainer's surface, this is
|
| 4 |
+
the stage's pre-registration. Collapsing them would let a trainer default
|
| 5 |
+
silently become an experimental parameter (the same separation dpo_stage
|
| 6 |
+
draws). The trainer still gets a RunConfig -- built FROM this, explicitly,
|
| 7 |
+
in scripts/grpo.py.
|
| 8 |
+
|
| 9 |
+
Every check in `validate()` is a launch refusal for a failure phase-1 paid
|
| 10 |
+
for: the denominator arithmetic (obligation a's config half), the
|
| 11 |
+
save-cadence/weight-sync alignment (a drifted engine trains against stale
|
| 12 |
+
rho baselines silently), the ceiling receipt (the 0/100-perfect-policy
|
| 13 |
+
trap), max_grad_norm (R3), a live canary cadence (R4).
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import json
|
| 19 |
+
import os
|
| 20 |
+
from dataclasses import dataclass, field
|
| 21 |
+
|
| 22 |
+
from align.rl.grpo_loss import GRPOLossConfig, GRPOLossType
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@dataclass
|
| 26 |
+
class RolloutShape:
|
| 27 |
+
prompts_per_step: int
|
| 28 |
+
max_response_len: int
|
| 29 |
+
temperature: float = 1.0
|
| 30 |
+
overlong_cache_len: int = 128
|
| 31 |
+
#: Where completions come from. "spool" = the external vLLM worker (own venv,
|
| 32 |
+
#: extras/vllm_rollout; the backend the pre-registration describes) and is
|
| 33 |
+
#: the default so an existing config keeps its generation engine. "native" =
|
| 34 |
+
#: the in-repo torch sampler on a STANDALONE engine copy on a dedicated GPU
|
| 35 |
+
#: (NMO_ROLLOUT_GPU), synced by reloading the latest checkpoint; GPU-unverified
|
| 36 |
+
#: at 7B at the time of the port, so it is opt-in. A silent switch changes
|
| 37 |
+
#: throughput by an order of magnitude and the logprob basis (native logprobs
|
| 38 |
+
#: are of the temperature-scaled distribution), hence never inferred.
|
| 39 |
+
backend: str = "spool"
|
| 40 |
+
#: INFORMATIONAL ONLY: configs/grpo_stageb.toml carries `vllm_env` as a note of the
|
| 41 |
+
#: worker environment the pre-registration ran under. Nothing reads it -- the env is
|
| 42 |
+
#: pinned in code (align/rollout/client.py) -- so it is declared here, rather than
|
| 43 |
+
#: left to the unknown-key refusal, purely so that refusal can be strict for every
|
| 44 |
+
#: OTHER key. A value here does NOT change the worker environment.
|
| 45 |
+
vllm_env: str = ""
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@dataclass
|
| 49 |
+
class RewardCfg:
|
| 50 |
+
format_weight: float = 0.1
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
@dataclass
|
| 54 |
+
class DataCfg:
|
| 55 |
+
band_file: str
|
| 56 |
+
banding_manifest: str
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
@dataclass
|
| 60 |
+
class TrainCfg:
|
| 61 |
+
lr: float
|
| 62 |
+
optimizer_steps: int
|
| 63 |
+
warmup_frac: float = 0.03
|
| 64 |
+
micro_batch_size: int = 1
|
| 65 |
+
save_every_micro_steps: int = 100
|
| 66 |
+
keep_checkpoints: int = 2
|
| 67 |
+
#: Per-rank optimizer shards (TrainConfig.per_rank_optimizer_state); forwarded
|
| 68 |
+
#: by scripts/grpo.py::_run_config. Default False = gathered layout.
|
| 69 |
+
per_rank_optimizer_state: bool = False
|
| 70 |
+
max_grad_norm: float | None = None
|
| 71 |
+
seed: int = 41
|
| 72 |
+
optimizer_name: str = "adamw8bit"
|
| 73 |
+
grad_accum: int = 8
|
| 74 |
+
attn_implementation: str = "sdpa"
|
| 75 |
+
|
| 76 |
+
# -- run-health bounds (G0), forwarded to TrainConfig by the stage script's
|
| 77 |
+
# _run_config: a field that is not copied trains at the default while the
|
| 78 |
+
# TOML says otherwise. Semantics: align/config.py::TrainConfig.
|
| 79 |
+
max_wall_seconds: float | None = None
|
| 80 |
+
max_consecutive_skipped_steps: int = 3
|
| 81 |
+
max_skipped_fraction: float = 0.05
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
@dataclass
|
| 85 |
+
class CanaryCfg:
|
| 86 |
+
every: int
|
| 87 |
+
n_problems: int
|
| 88 |
+
source: str = "gsm8k"
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
@dataclass
|
| 92 |
+
class GrpoStageConfig:
|
| 93 |
+
sft_checkpoint: str
|
| 94 |
+
run_dir: str
|
| 95 |
+
grpo: GRPOLossConfig
|
| 96 |
+
rollout: RolloutShape
|
| 97 |
+
reward: RewardCfg
|
| 98 |
+
data: DataCfg
|
| 99 |
+
train: TrainCfg
|
| 100 |
+
canary: CanaryCfg
|
| 101 |
+
armed_tripwires: tuple[str, ...] = ("grad_norm",)
|
| 102 |
+
ceiling_receipt: str = "outputs/eval/ceiling_test.json"
|
| 103 |
+
rollout_timeout_s: float = 1800.0
|
| 104 |
+
dead_group_retries: int = 5
|
| 105 |
+
extras: dict = field(default_factory=dict)
|
| 106 |
+
|
| 107 |
+
def validate(self) -> None:
|
| 108 |
+
g = self.grpo
|
| 109 |
+
if self.rollout.backend not in ("spool", "native"):
|
| 110 |
+
raise ValueError(
|
| 111 |
+
f"rollout.backend {self.rollout.backend!r}: choose 'spool' (external vLLM "
|
| 112 |
+
"worker) or 'native' (in-repo torch sampler)"
|
| 113 |
+
)
|
| 114 |
+
expected = float(
|
| 115 |
+
self.rollout.prompts_per_step
|
| 116 |
+
* g.num_samples_per_prompt_rollout
|
| 117 |
+
* self.rollout.max_response_len
|
| 118 |
+
)
|
| 119 |
+
if not isinstance(g.loss_denominator, float) or g.loss_denominator != expected:
|
| 120 |
+
raise ValueError(
|
| 121 |
+
f"loss_denominator {g.loss_denominator!r} != prompts_per_step x G x "
|
| 122 |
+
f"max_response_len = {self.rollout.prompts_per_step} x "
|
| 123 |
+
f"{g.num_samples_per_prompt_rollout} x {self.rollout.max_response_len} "
|
| 124 |
+
f"= {expected}. The Dr.GRPO constant is DERIVED, never hand-typed."
|
| 125 |
+
)
|
| 126 |
+
if not os.path.isfile(self.data.band_file):
|
| 127 |
+
raise FileNotFoundError(
|
| 128 |
+
f"band file missing: {self.data.band_file}. Run "
|
| 129 |
+
"scripts/build_stageb_data.py first; the driver never builds data."
|
| 130 |
+
)
|
| 131 |
+
if self.train.save_every_micro_steps % self.train.grad_accum != 0:
|
| 132 |
+
raise ValueError(
|
| 133 |
+
f"save_every_micro_steps={self.train.save_every_micro_steps} is not a "
|
| 134 |
+
f"multiple of grad_accum={self.train.grad_accum}: checkpoint saves ARE "
|
| 135 |
+
"the weight sync (the engine reloads the latest checkpoint), so a save "
|
| 136 |
+
"landing mid-accumulation would sync half-stepped weights."
|
| 137 |
+
)
|
| 138 |
+
if self.train.max_grad_norm is None or self.train.max_grad_norm <= 0:
|
| 139 |
+
raise ValueError(
|
| 140 |
+
"train.max_grad_norm must be a positive float: R3 (2026-08-23 audit) "
|
| 141 |
+
"-- the recorded phase-1 rerun blew up to grad_norm 355,830 with no "
|
| 142 |
+
"clipping anywhere. Unclipped RL is not a configuration this stage "
|
| 143 |
+
"accepts."
|
| 144 |
+
)
|
| 145 |
+
if self.canary.every <= 0 or self.canary.n_problems <= 0:
|
| 146 |
+
raise ValueError(
|
| 147 |
+
"canary cadence/n must be positive: R4 -- 727 optimizer steps of "
|
| 148 |
+
"phase-1 preference training ran with zero task-accuracy signal."
|
| 149 |
+
)
|
| 150 |
+
if "grad_norm" not in self.armed_tripwires:
|
| 151 |
+
raise ValueError("stage-b requires the grad_norm HALT tripwire armed (R3)")
|
| 152 |
+
if not g.record_entropy:
|
| 153 |
+
raise ValueError("record_entropy must be true: the collapse tripwire reads entropy")
|
| 154 |
+
|
| 155 |
+
def validate_ceiling_receipt(self) -> dict:
|
| 156 |
+
"""Separate from validate(): tests exercise config logic without a receipt."""
|
| 157 |
+
if not os.path.isfile(self.ceiling_receipt):
|
| 158 |
+
raise FileNotFoundError(
|
| 159 |
+
f"ceiling receipt missing: {self.ceiling_receipt}. No GRPO rollout "
|
| 160 |
+
"launches until the reward path is certified (the 0/100-perfect-policy "
|
| 161 |
+
"incident, mistral-prm-ppo 2026-08-14)."
|
| 162 |
+
)
|
| 163 |
+
with open(self.ceiling_receipt) as f:
|
| 164 |
+
receipt = json.load(f)
|
| 165 |
+
if not receipt.get("overall_pass"):
|
| 166 |
+
raise RuntimeError(f"ceiling receipt {self.ceiling_receipt} is not a PASS")
|
| 167 |
+
return receipt
|
| 168 |
+
|
| 169 |
+
def validate_world(self, world_size: int) -> None:
|
| 170 |
+
"""Checks that need the launch topology."""
|
| 171 |
+
rows_per_step = self.rollout.prompts_per_step * self.grpo.num_samples_per_prompt_rollout
|
| 172 |
+
per_micro = rows_per_step // (world_size * self.train.grad_accum)
|
| 173 |
+
if per_micro < 1:
|
| 174 |
+
raise ValueError(
|
| 175 |
+
f"rollout shape too small for the topology: {rows_per_step} completions "
|
| 176 |
+
f"per step cannot fill world_size({world_size}) x "
|
| 177 |
+
f"grad_accum({self.train.grad_accum}) micro-slices"
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def load_stage_config(path: str, smoke: bool = False) -> GrpoStageConfig:
|
| 182 |
+
from align.config import load_toml
|
| 183 |
+
|
| 184 |
+
raw = load_toml(path)
|
| 185 |
+
g = dict(raw["grpo"])
|
| 186 |
+
g["loss_fn"] = GRPOLossType(g.get("loss_fn", "dapo"))
|
| 187 |
+
# TOML floats survive; TOML ints for float fields do not -- coerce the one
|
| 188 |
+
# field whose TYPE is load-bearing (the conformance gate refuses non-float).
|
| 189 |
+
if "loss_denominator" in g:
|
| 190 |
+
g["loss_denominator"] = float(g["loss_denominator"])
|
| 191 |
+
t = dict(raw["train"])
|
| 192 |
+
t["optimizer_name"] = raw.get("optimizer", {}).get("name", t.pop("optimizer_name", "adamw8bit"))
|
| 193 |
+
t["grad_accum"] = raw.get("optimizer", {}).get("grad_accum", t.pop("grad_accum", 8))
|
| 194 |
+
# STRUCT MODE for [train]. This used to filter through
|
| 195 |
+
# TrainCfg.__dataclass_fields__, which made a typo'd or not-yet-plumbed key
|
| 196 |
+
# (e.g. per_rank_optimizer_state before TrainCfg had it) ACCEPTED AND
|
| 197 |
+
# SILENTLY IGNORED: the TOML looked armed and the run was not. Raise at
|
| 198 |
+
# launch, naming the key, like DpoStageConfig's struct-mode loader.
|
| 199 |
+
unknown = sorted(k for k in t if k not in TrainCfg.__dataclass_fields__)
|
| 200 |
+
if unknown:
|
| 201 |
+
raise ValueError(
|
| 202 |
+
f"unknown key(s) {unknown} in [train] are not fields of TrainCfg "
|
| 203 |
+
f"(struct mode: a silently ignored key is a disarmed config). "
|
| 204 |
+
f"Known fields: {sorted(TrainCfg.__dataclass_fields__)}"
|
| 205 |
+
)
|
| 206 |
+
# STRUCT MODE for [rollout] too. The same filter-through-__dataclass_fields__
|
| 207 |
+
# idiom that [train] shed above survived here: `[rollout] max_reponse_len = 512`
|
| 208 |
+
# (typo) or a not-yet-plumbed `top_p = 0.9` was DROPPED, the shape kept its
|
| 209 |
+
# default, and `validate()` then checked the loss denominator against the
|
| 210 |
+
# default -- the pre-registered rollout looked armed and was not.
|
| 211 |
+
r = dict(raw["rollout"])
|
| 212 |
+
unknown_rollout = sorted(k for k in r if k not in RolloutShape.__dataclass_fields__)
|
| 213 |
+
if unknown_rollout:
|
| 214 |
+
raise ValueError(
|
| 215 |
+
f"unknown key(s) {unknown_rollout} in [rollout] are not fields of RolloutShape "
|
| 216 |
+
f"(struct mode: a silently ignored key is a disarmed config). "
|
| 217 |
+
f"Known fields: {sorted(RolloutShape.__dataclass_fields__)}"
|
| 218 |
+
)
|
| 219 |
+
cfg = GrpoStageConfig(
|
| 220 |
+
sft_checkpoint=raw["sft_checkpoint"],
|
| 221 |
+
run_dir=raw["run_dir"],
|
| 222 |
+
grpo=GRPOLossConfig(**g),
|
| 223 |
+
rollout=RolloutShape(**r),
|
| 224 |
+
reward=RewardCfg(**raw.get("reward", {})),
|
| 225 |
+
data=DataCfg(**raw["data"]),
|
| 226 |
+
train=TrainCfg(**t),
|
| 227 |
+
canary=CanaryCfg(**raw["canary"]),
|
| 228 |
+
armed_tripwires=tuple(raw.get("tripwires", {}).get("armed", ("grad_norm",))),
|
| 229 |
+
)
|
| 230 |
+
if smoke:
|
| 231 |
+
cfg = _smokeify(cfg)
|
| 232 |
+
cfg.validate()
|
| 233 |
+
return cfg
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def _smokeify(cfg: GrpoStageConfig) -> GrpoStageConfig:
|
| 237 |
+
"""Tiny everything; the derived denominator is recomputed, not hand-edited."""
|
| 238 |
+
cfg.rollout.prompts_per_step = 2
|
| 239 |
+
cfg.grpo.num_samples_per_prompt_rollout = 8 # A3 floor is G >= 8, smoke included
|
| 240 |
+
# 256 tokens, not 32: the sixth smoke run proved a cap real answers
|
| 241 |
+
# cannot fit under produces zero reward variance -> every group dead ->
|
| 242 |
+
# the (correct) band-mischosen refusal. The smoke needs a policy with
|
| 243 |
+
# SOME task signal (the real SFT substrate) and room to finish answers.
|
| 244 |
+
cfg.rollout.max_response_len = 256
|
| 245 |
+
cfg.rollout.overlong_cache_len = 64
|
| 246 |
+
cfg.grpo.loss_denominator = float(2 * 8 * 256)
|
| 247 |
+
cfg.train.optimizer_steps = 10
|
| 248 |
+
cfg.train.grad_accum = 2
|
| 249 |
+
cfg.train.save_every_micro_steps = 10 # sync twice in a 20-micro smoke
|
| 250 |
+
cfg.canary.every = 5
|
| 251 |
+
cfg.canary.n_problems = 4
|
| 252 |
+
cfg.run_dir = cfg.run_dir + "_smoke"
|
| 253 |
+
return cfg
|
code/align/rl/tripwire.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Entropy/length collapse tripwire -- the second net-new A3 piece.
|
| 2 |
+
|
| 3 |
+
Upstream has the loggable INPUTS (record_entropy=True) but no tripwire logic
|
| 4 |
+
(corpus conformance table item 7: "the knob exists, the tripwire mechanism
|
| 5 |
+
does not"). This is the GRPO sibling of the DPO chosen-logprob tripwire that
|
| 6 |
+
fired -- and autonomously launched its pre-registered response -- on Arm B
|
| 7 |
+
(2026-08-22, runs/dpo_arm_b_response/).
|
| 8 |
+
|
| 9 |
+
Design contract, identical to the DPO one:
|
| 10 |
+
* The tripwire DETECTS and RECORDS. The stage script owns the response,
|
| 11 |
+
and the only admissible response is the pre-registered one
|
| 12 |
+
(KL=0 collapse -> ONE rerun with beta=1e-4, recorded AS the result;
|
| 13 |
+
no third configuration). No improvisation lives here by construction.
|
| 14 |
+
* Fed from the same metrics stream that history.jsonl records, so a fire
|
| 15 |
+
is always reproducible from the run's own log after the fact.
|
| 16 |
+
|
| 17 |
+
Signals (cross-lab consensus per doc 08: entropy collapse is the 2025
|
| 18 |
+
field-wide GRPO failure mode; length collapse is its usual companion):
|
| 19 |
+
E: policy entropy trend -- median over the trailing window falls below
|
| 20 |
+
``entropy_floor_frac`` x the median over the baseline window.
|
| 21 |
+
L: mean response length trend -- same construction, lengths shrinking
|
| 22 |
+
below ``length_floor_frac`` of baseline (degenerate short answers).
|
| 23 |
+
Either alone fires; both are cheap medians over deques, no model access.
|
| 24 |
+
|
| 25 |
+
Resume (2026-10-12 review). The instrument is STATEFUL -- a baseline measured
|
| 26 |
+
over the first ``baseline_steps`` observations, a trailing window, a step
|
| 27 |
+
counter and a latch -- so a resume that rebuilds it fresh silently changes the
|
| 28 |
+
experiment: the baseline is re-measured on the POST-resume (possibly already
|
| 29 |
+
collapsed) policy, which makes the collapse the new "normal", and
|
| 30 |
+
``min_steps_before_fire`` restarts at 0, blinding the guard for ~100 steps.
|
| 31 |
+
``state_dict()`` / ``load_state_dict()`` carry all of it through the Trainer's
|
| 32 |
+
``extra_state`` slot in resume.pt (``GRPOTrainModule.extra_state``).
|
| 33 |
+
"""
|
| 34 |
+
|
| 35 |
+
from __future__ import annotations
|
| 36 |
+
|
| 37 |
+
import logging
|
| 38 |
+
import math
|
| 39 |
+
from collections import deque
|
| 40 |
+
from dataclasses import dataclass, field
|
| 41 |
+
from statistics import median
|
| 42 |
+
from typing import Optional
|
| 43 |
+
|
| 44 |
+
logger = logging.getLogger(__name__)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@dataclass
|
| 48 |
+
class CollapseTripwire:
|
| 49 |
+
baseline_steps: int = 50
|
| 50 |
+
window_steps: int = 50
|
| 51 |
+
entropy_floor_frac: float = 0.2
|
| 52 |
+
length_floor_frac: float = 0.4
|
| 53 |
+
min_steps_before_fire: int = 100
|
| 54 |
+
|
| 55 |
+
_baseline_entropy: list[float] = field(default_factory=list)
|
| 56 |
+
_baseline_length: list[float] = field(default_factory=list)
|
| 57 |
+
_entropy: deque = field(default_factory=deque)
|
| 58 |
+
_length: deque = field(default_factory=deque)
|
| 59 |
+
_steps: int = 0
|
| 60 |
+
fired: bool = False
|
| 61 |
+
fire_reason: Optional[str] = None
|
| 62 |
+
fire_step: Optional[int] = None
|
| 63 |
+
|
| 64 |
+
def __post_init__(self) -> None:
|
| 65 |
+
self._entropy = deque(maxlen=self.window_steps)
|
| 66 |
+
self._length = deque(maxlen=self.window_steps)
|
| 67 |
+
|
| 68 |
+
def observe(self, step: int, entropy: float, mean_response_len: float) -> bool:
|
| 69 |
+
"""Feed one optimizer step's metrics; returns True the step it fires.
|
| 70 |
+
|
| 71 |
+
Once fired it stays fired (a latched instrument, like the DPO one) --
|
| 72 |
+
the stage script reads ``fired``/``fire_reason`` and executes the
|
| 73 |
+
pre-registered response exactly once.
|
| 74 |
+
"""
|
| 75 |
+
if not (math.isfinite(entropy) and math.isfinite(mean_response_len)):
|
| 76 |
+
# NaN compares False against every threshold below (`nan > 0`,
|
| 77 |
+
# `nan < x`), so a NaN entropy stream would leave the entropy arm
|
| 78 |
+
# permanently inert while the object reads as armed -- the
|
| 79 |
+
# disarmed-tripwire shape (audit D1). `_on_step_boundary` feeds NaN
|
| 80 |
+
# when a metric key is missing. Refuse loudly instead.
|
| 81 |
+
raise ValueError(
|
| 82 |
+
f"CollapseTripwire.observe(step={step}): non-finite input "
|
| 83 |
+
f"(entropy={entropy}, mean_response_len={mean_response_len}); a guard "
|
| 84 |
+
"fed NaN can never fire"
|
| 85 |
+
)
|
| 86 |
+
self._steps += 1
|
| 87 |
+
if len(self._baseline_entropy) < self.baseline_steps:
|
| 88 |
+
self._baseline_entropy.append(entropy)
|
| 89 |
+
self._baseline_length.append(mean_response_len)
|
| 90 |
+
return False
|
| 91 |
+
self._entropy.append(entropy)
|
| 92 |
+
self._length.append(mean_response_len)
|
| 93 |
+
|
| 94 |
+
if (
|
| 95 |
+
self.fired
|
| 96 |
+
or self._steps < self.min_steps_before_fire
|
| 97 |
+
or len(self._entropy) < self.window_steps
|
| 98 |
+
):
|
| 99 |
+
return False
|
| 100 |
+
|
| 101 |
+
base_e = median(self._baseline_entropy)
|
| 102 |
+
base_l = median(self._baseline_length)
|
| 103 |
+
cur_e = median(self._entropy)
|
| 104 |
+
cur_l = median(self._length)
|
| 105 |
+
reasons = []
|
| 106 |
+
if base_e > 0 and cur_e < self.entropy_floor_frac * base_e:
|
| 107 |
+
reasons.append(
|
| 108 |
+
f"entropy collapse: trailing median {cur_e:.4f} < {self.entropy_floor_frac} x baseline {base_e:.4f}"
|
| 109 |
+
)
|
| 110 |
+
if base_l > 0 and cur_l < self.length_floor_frac * base_l:
|
| 111 |
+
reasons.append(
|
| 112 |
+
f"length collapse: trailing median {cur_l:.1f} < {self.length_floor_frac} x baseline {base_l:.1f}"
|
| 113 |
+
)
|
| 114 |
+
if reasons:
|
| 115 |
+
self.fired = True
|
| 116 |
+
self.fire_step = step
|
| 117 |
+
self.fire_reason = "; ".join(reasons)
|
| 118 |
+
logger.error(
|
| 119 |
+
"TRIPWIRE (GRPO collapse) at step %d: %s -- pre-registered response: ONE beta=1e-4 "
|
| 120 |
+
"rerun recorded AS the result; no third configuration.",
|
| 121 |
+
step,
|
| 122 |
+
self.fire_reason,
|
| 123 |
+
)
|
| 124 |
+
return True
|
| 125 |
+
return False
|
| 126 |
+
|
| 127 |
+
#: Bump on any change to the persisted layout; a mismatch refuses to load.
|
| 128 |
+
STATE_VERSION = 1
|
| 129 |
+
|
| 130 |
+
def _config_dict(self) -> dict:
|
| 131 |
+
return {
|
| 132 |
+
"baseline_steps": self.baseline_steps,
|
| 133 |
+
"window_steps": self.window_steps,
|
| 134 |
+
"entropy_floor_frac": self.entropy_floor_frac,
|
| 135 |
+
"length_floor_frac": self.length_floor_frac,
|
| 136 |
+
"min_steps_before_fire": self.min_steps_before_fire,
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
def state_dict(self) -> dict:
|
| 140 |
+
"""Everything `observe` reads, as primitives only.
|
| 141 |
+
|
| 142 |
+
Plain dict/list/float/int/bool/None/str so it survives the Trainer's
|
| 143 |
+
``torch.load(weights_only=True)`` of resume.pt -- unpickling arbitrary
|
| 144 |
+
objects to restore a counter would be a code-execution path for nothing.
|
| 145 |
+
The thresholds are included so `load_state_dict` can refuse a state cut
|
| 146 |
+
for a different configuration.
|
| 147 |
+
"""
|
| 148 |
+
return {
|
| 149 |
+
"version": self.STATE_VERSION,
|
| 150 |
+
"config": self._config_dict(),
|
| 151 |
+
"baseline_entropy": [float(x) for x in self._baseline_entropy],
|
| 152 |
+
"baseline_length": [float(x) for x in self._baseline_length],
|
| 153 |
+
"entropy": [float(x) for x in self._entropy],
|
| 154 |
+
"length": [float(x) for x in self._length],
|
| 155 |
+
"steps": int(self._steps),
|
| 156 |
+
"fired": bool(self.fired),
|
| 157 |
+
"fire_reason": self.fire_reason,
|
| 158 |
+
"fire_step": self.fire_step,
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
def load_state_dict(self, sd: dict) -> None:
|
| 162 |
+
"""Restore `state_dict()` output; refuse anything that is not exactly it.
|
| 163 |
+
|
| 164 |
+
A silently half-restored tripwire is the disarmed-instrument shape, so
|
| 165 |
+
every inconsistency raises: wrong version, thresholds that differ from
|
| 166 |
+
this instance's (a different experiment -- rebuild it deliberately),
|
| 167 |
+
or windows longer than the configured sizes. Validation happens BEFORE
|
| 168 |
+
any field is assigned, so a refused load leaves the instance untouched.
|
| 169 |
+
"""
|
| 170 |
+
if sd.get("version") != self.STATE_VERSION:
|
| 171 |
+
raise ValueError(
|
| 172 |
+
f"CollapseTripwire state version {sd.get('version')!r} != {self.STATE_VERSION}"
|
| 173 |
+
)
|
| 174 |
+
if sd.get("config") != self._config_dict():
|
| 175 |
+
raise ValueError(
|
| 176 |
+
f"CollapseTripwire state was saved under {sd.get('config')!r} but this "
|
| 177 |
+
f"instance is configured {self._config_dict()!r}; resuming under different "
|
| 178 |
+
"thresholds is a different experiment."
|
| 179 |
+
)
|
| 180 |
+
base_e, base_l = list(sd["baseline_entropy"]), list(sd["baseline_length"])
|
| 181 |
+
ent, length = list(sd["entropy"]), list(sd["length"])
|
| 182 |
+
if len(base_e) != len(base_l) or len(ent) != len(length):
|
| 183 |
+
raise ValueError("CollapseTripwire state has mismatched entropy/length series")
|
| 184 |
+
if len(base_e) > self.baseline_steps or len(ent) > self.window_steps:
|
| 185 |
+
raise ValueError("CollapseTripwire state windows exceed the configured sizes")
|
| 186 |
+
if not all(math.isfinite(x) for x in base_e + base_l + ent + length):
|
| 187 |
+
raise ValueError("CollapseTripwire state contains non-finite observations")
|
| 188 |
+
self._baseline_entropy = [float(x) for x in base_e]
|
| 189 |
+
self._baseline_length = [float(x) for x in base_l]
|
| 190 |
+
self._entropy = deque((float(x) for x in ent), maxlen=self.window_steps)
|
| 191 |
+
self._length = deque((float(x) for x in length), maxlen=self.window_steps)
|
| 192 |
+
self._steps = int(sd["steps"])
|
| 193 |
+
self.fired = bool(sd["fired"])
|
| 194 |
+
self.fire_reason = sd["fire_reason"]
|
| 195 |
+
self.fire_step = sd["fire_step"]
|
| 196 |
+
|
| 197 |
+
def as_log_dict(self) -> dict:
|
| 198 |
+
return {
|
| 199 |
+
"tripwire/fired": self.fired,
|
| 200 |
+
"tripwire/fire_step": self.fire_step,
|
| 201 |
+
"tripwire/fire_reason": self.fire_reason,
|
| 202 |
+
}
|
code/align/rl/types.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Rollout data types for stage-b GRPO.
|
| 2 |
+
|
| 3 |
+
The rollout ORCHESTRATOR (M-D, separate build) produces these; the
|
| 4 |
+
GRPOTrainModule consumes them. Alignment convention, stated once: per-token
|
| 5 |
+
logprob tensors are LABEL-ALIGNED -- position t holds the logprob of
|
| 6 |
+
input_ids[:, t+1], so they have length T-1 and pair with
|
| 7 |
+
`shifted_response_mask`. Getting this half-open boundary wrong is the same
|
| 8 |
+
off-by-one family as the decode-position incident (section-09) and the
|
| 9 |
+
completion-mask defect (audit R1); the validating constructor exists so the
|
| 10 |
+
mismatch fails at batch construction, not as a silently shifted objective.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
from dataclasses import dataclass, field
|
| 16 |
+
|
| 17 |
+
import torch
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@dataclass
|
| 21 |
+
class RolloutBatch:
|
| 22 |
+
"""One training micro-batch of rollout data.
|
| 23 |
+
|
| 24 |
+
Shapes (B = completions in the batch, T = padded sequence length):
|
| 25 |
+
input_ids [B, T] int64 prompt + completion, right-padded
|
| 26 |
+
attention_mask [B, T] int64 1 on real tokens
|
| 27 |
+
response_mask [B, T] int64 1 on COMPLETION tokens only (input space)
|
| 28 |
+
advantages [B] float per-sequence Dr.GRPO centered advantage
|
| 29 |
+
old_logprobs [B, T-1] policy-at-rollout logprobs (label-aligned)
|
| 30 |
+
vllm_logprobs [B, T-1] inference-engine logprobs (label-aligned)
|
| 31 |
+
ref_logprobs [B, T-1]? reference-policy logprobs; REQUIRED iff
|
| 32 |
+
the loss config has beta > 0 (the KL
|
| 33 |
+
ablation), absent otherwise
|
| 34 |
+
rewards {name: [B]} per-completion reward terms (reward/total,
|
| 35 |
+
reward/accuracy, ... -- itemised so the
|
| 36 |
+
format-consistency instrument reads terms,
|
| 37 |
+
not sums)
|
| 38 |
+
group_ids [B] int64 prompt-group id of each completion
|
| 39 |
+
"""
|
| 40 |
+
|
| 41 |
+
input_ids: torch.Tensor
|
| 42 |
+
attention_mask: torch.Tensor
|
| 43 |
+
response_mask: torch.Tensor
|
| 44 |
+
advantages: torch.Tensor
|
| 45 |
+
old_logprobs: torch.Tensor
|
| 46 |
+
vllm_logprobs: torch.Tensor
|
| 47 |
+
group_ids: torch.Tensor
|
| 48 |
+
rewards: dict[str, torch.Tensor] = field(default_factory=dict)
|
| 49 |
+
ref_logprobs: torch.Tensor | None = None
|
| 50 |
+
|
| 51 |
+
def __post_init__(self) -> None:
|
| 52 |
+
B, T = self.input_ids.shape
|
| 53 |
+
for name, t, shape in (
|
| 54 |
+
("attention_mask", self.attention_mask, (B, T)),
|
| 55 |
+
("response_mask", self.response_mask, (B, T)),
|
| 56 |
+
("advantages", self.advantages, (B,)),
|
| 57 |
+
("old_logprobs", self.old_logprobs, (B, T - 1)),
|
| 58 |
+
("vllm_logprobs", self.vllm_logprobs, (B, T - 1)),
|
| 59 |
+
("group_ids", self.group_ids, (B,)),
|
| 60 |
+
):
|
| 61 |
+
if tuple(t.shape) != shape:
|
| 62 |
+
raise ValueError(
|
| 63 |
+
f"RolloutBatch.{name} has shape {tuple(t.shape)}, expected {shape} "
|
| 64 |
+
f"(B={B}, T={T}; logprob tensors are label-aligned, length T-1)"
|
| 65 |
+
)
|
| 66 |
+
if self.ref_logprobs is not None and tuple(self.ref_logprobs.shape) != (B, T - 1):
|
| 67 |
+
raise ValueError(
|
| 68 |
+
f"RolloutBatch.ref_logprobs has shape {tuple(self.ref_logprobs.shape)}, "
|
| 69 |
+
f"expected {(B, T - 1)}"
|
| 70 |
+
)
|
| 71 |
+
for name, t in self.rewards.items():
|
| 72 |
+
if tuple(t.shape) != (B,):
|
| 73 |
+
raise ValueError(
|
| 74 |
+
f"RolloutBatch.rewards[{name!r}] has shape {tuple(t.shape)}, expected {(B,)}"
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
@property
|
| 78 |
+
def shifted_response_mask(self) -> torch.Tensor:
|
| 79 |
+
"""Response mask in LABEL space [B, T-1], pairing with the logprobs."""
|
| 80 |
+
return self.response_mask[:, 1:]
|
| 81 |
+
|
| 82 |
+
@property
|
| 83 |
+
def n_response_tokens(self) -> int:
|
| 84 |
+
return int(self.shifted_response_mask.sum())
|
code/align/rollout/__init__.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Rollout: request/result shapes, the native sampler, and the optional spool client.
|
| 2 |
+
|
| 3 |
+
Backends (selected by `align.rollout.client.make_rollout_client`):
|
| 4 |
+
|
| 5 |
+
'native' (default) `native.NativeRolloutBackend` -- in-process torch KV-cache
|
| 6 |
+
sampler; zero dependencies beyond torch.
|
| 7 |
+
'spool' `client.RolloutClient` -- file protocol to the optional
|
| 8 |
+
vLLM worker in extras/vllm_rollout/ (its own venv).
|
| 9 |
+
|
| 10 |
+
Nothing under align/ imports vllm or ray, and nothing under align/ imports
|
| 11 |
+
extras/ (tests/test_dependency_firewall.py enforces both). The extras worker
|
| 12 |
+
imports `align.rollout.protocol`, which is stdlib-only BY CONTRACT; this
|
| 13 |
+
package `__init__` therefore imports ONLY that module eagerly. The torch-using
|
| 14 |
+
pieces (`NativeRolloutBackend`, `make_rollout_client`) resolve lazily so the
|
| 15 |
+
worker's venv never pays for, or is constrained by, them.
|
| 16 |
+
|
| 17 |
+
Per the 2026-08-22 sovereignty amendment (RESULTS.md dated entry): vLLM is
|
| 18 |
+
permitted for GENERATION only, in an isolated venv (.venv-rollout), behind a
|
| 19 |
+
process boundary. The v1 topology (DESIGN v3 section B.1b, decided 2026-08-24):
|
| 20 |
+
3 FSDP train ranks + 1 dedicated engine GPU; weight handoff via checkpoint
|
| 21 |
+
directory + engine reload every `sync_every` optimizer steps;
|
| 22 |
+
use_rho_correction=True absorbs the resulting off-policyness -- that is the
|
| 23 |
+
term's entire purpose. The native backend honours the same contract
|
| 24 |
+
(`load_weights_fn` on a changed weights_dir).
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
from align.rollout.protocol import (
|
| 28 |
+
RolloutBackend,
|
| 29 |
+
RolloutRequest,
|
| 30 |
+
RolloutResult,
|
| 31 |
+
read_request,
|
| 32 |
+
read_result,
|
| 33 |
+
write_request,
|
| 34 |
+
write_result,
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
__all__ = [
|
| 38 |
+
"PagedEngine",
|
| 39 |
+
"PagedEngineConfig",
|
| 40 |
+
"PagedResult",
|
| 41 |
+
"SamplingParams",
|
| 42 |
+
"NativeRolloutBackend",
|
| 43 |
+
"NativeRolloutConfig",
|
| 44 |
+
"RolloutBackend",
|
| 45 |
+
"RolloutRequest",
|
| 46 |
+
"RolloutResult",
|
| 47 |
+
"checkpoint_weight_loader",
|
| 48 |
+
"make_rollout_client",
|
| 49 |
+
"read_request",
|
| 50 |
+
"read_result",
|
| 51 |
+
"write_request",
|
| 52 |
+
"write_result",
|
| 53 |
+
]
|
| 54 |
+
|
| 55 |
+
_LAZY = {
|
| 56 |
+
# P3 opt-in paged continuous-batching sampler (docs/P3_ENGINE.md). Nothing
|
| 57 |
+
# in the default GRPO/rollout path imports these.
|
| 58 |
+
"PagedEngine": "align.rollout.engine",
|
| 59 |
+
"PagedEngineConfig": "align.rollout.engine",
|
| 60 |
+
"PagedResult": "align.rollout.engine",
|
| 61 |
+
"SamplingParams": "align.rollout.engine",
|
| 62 |
+
"NativeRolloutBackend": "align.rollout.native",
|
| 63 |
+
"NativeRolloutConfig": "align.rollout.native",
|
| 64 |
+
"checkpoint_weight_loader": "align.rollout.native",
|
| 65 |
+
"make_rollout_client": "align.rollout.client",
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def __getattr__(name):
|
| 70 |
+
if name in _LAZY:
|
| 71 |
+
import importlib
|
| 72 |
+
|
| 73 |
+
return getattr(importlib.import_module(_LAZY[name]), name)
|
| 74 |
+
raise AttributeError(f"module 'align.rollout' has no attribute {name!r}")
|
code/align/rollout/client.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Trainer-side rollout clients and the backend selector.
|
| 2 |
+
|
| 3 |
+
Two backends serve the same `RolloutRequest -> RolloutResult` contract
|
| 4 |
+
(align/rollout/protocol.py), chosen by `make_rollout_client(backend=...)`:
|
| 5 |
+
|
| 6 |
+
'native' (DEFAULT) -- `native.NativeRolloutBackend`: in-process torch
|
| 7 |
+
KV-cache sampler on the training/passed model. Zero extra dependencies;
|
| 8 |
+
this is what makes GRPO runnable on torch + triton + numpy + safetensors.
|
| 9 |
+
'spool' -- `RolloutClient` below: file protocol to the optional
|
| 10 |
+
vLLM worker (extras/vllm_rollout/rollout_worker.py) running in its OWN
|
| 11 |
+
venv. A throughput option, never a requirement.
|
| 12 |
+
|
| 13 |
+
This module lives in the TRAINING venv and imports nothing from vLLM -- the
|
| 14 |
+
process boundary is the sovereignty line. `spawn_worker` launches the extras
|
| 15 |
+
worker under the ISOLATED rollout venv with the full required environment
|
| 16 |
+
baked in, so a caller cannot forget the flashinfer workaround or point a cache
|
| 17 |
+
at the 13GB root filesystem.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import os
|
| 23 |
+
import subprocess
|
| 24 |
+
from typing import Optional
|
| 25 |
+
|
| 26 |
+
from align.rollout.protocol import (
|
| 27 |
+
RolloutBackend,
|
| 28 |
+
RolloutRequest,
|
| 29 |
+
RolloutResult,
|
| 30 |
+
wait_for_result,
|
| 31 |
+
write_request,
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
#: The selectable backends. Data, so error messages and CLIs iterate it.
|
| 35 |
+
BACKENDS: tuple[str, ...] = ("native", "spool")
|
| 36 |
+
DEFAULT_BACKEND = "native"
|
| 37 |
+
|
| 38 |
+
#: Repo root, derived from this file's location -- the client and the worker
|
| 39 |
+
#: script it spawns always come from the same checkout.
|
| 40 |
+
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 41 |
+
ROLLOUT_PYTHON = os.path.join(REPO_ROOT, ".venv-rollout", "bin", "python")
|
| 42 |
+
#: The vLLM worker is an EXTRA (own venv, see extras/vllm_rollout/README.md),
|
| 43 |
+
#: not part of the align package: nothing under align/ may import it, but a
|
| 44 |
+
#: path string to launch it as a subprocess is the intended coupling.
|
| 45 |
+
WORKER_SCRIPT = os.path.join(REPO_ROOT, "extras", "vllm_rollout", "rollout_worker.py")
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class RolloutClient:
|
| 49 |
+
"""The 'spool' backend: submit rollout requests and block on their results.
|
| 50 |
+
|
| 51 |
+
Timeouts are the caller's budget decision (stage-b sets them from the
|
| 52 |
+
measured pilot throughput); the stale-heartbeat threshold distinguishes
|
| 53 |
+
a slow request from a dead worker -- see protocol.wait_for_result.
|
| 54 |
+
"""
|
| 55 |
+
|
| 56 |
+
def __init__(self, spool: str, timeout_s: float, stale_heartbeat_s: float = 600.0):
|
| 57 |
+
self.spool = spool
|
| 58 |
+
self.timeout_s = float(timeout_s)
|
| 59 |
+
self.stale_heartbeat_s = float(stale_heartbeat_s)
|
| 60 |
+
|
| 61 |
+
def submit(self, request: RolloutRequest) -> str:
|
| 62 |
+
write_request(self.spool, request)
|
| 63 |
+
return request.request_id
|
| 64 |
+
|
| 65 |
+
def collect(self, request_id: str) -> RolloutResult:
|
| 66 |
+
return wait_for_result(
|
| 67 |
+
self.spool,
|
| 68 |
+
request_id,
|
| 69 |
+
timeout_s=self.timeout_s,
|
| 70 |
+
stale_heartbeat_s=self.stale_heartbeat_s,
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
#: torchrun/torchelastic variables that MUST NOT reach the worker: vLLM's
|
| 75 |
+
#: engine initialises torch.distributed internally, and a child that inherits
|
| 76 |
+
#: the trainer's rendezvous (MASTER_ADDR/RANK/WORLD_SIZE/...) tries to JOIN
|
| 77 |
+
#: the trainer's process group -- observed on the 2026-08-24 smoke as the
|
| 78 |
+
#: engine core dialing torchrun's TCP store while ranks 1-2 timed out waiting
|
| 79 |
+
#: for rank 0. The worker is a separate world of size 1, always.
|
| 80 |
+
_POISON_ENV_KEYS = (
|
| 81 |
+
"MASTER_ADDR",
|
| 82 |
+
"MASTER_PORT",
|
| 83 |
+
"RANK",
|
| 84 |
+
"WORLD_SIZE",
|
| 85 |
+
"LOCAL_RANK",
|
| 86 |
+
"LOCAL_WORLD_SIZE",
|
| 87 |
+
"GROUP_RANK",
|
| 88 |
+
"GROUP_WORLD_SIZE",
|
| 89 |
+
"ROLE_RANK",
|
| 90 |
+
"ROLE_WORLD_SIZE",
|
| 91 |
+
"ROLE_NAME",
|
| 92 |
+
"NODE_RANK",
|
| 93 |
+
)
|
| 94 |
+
_POISON_ENV_PREFIXES = ("TORCHELASTIC_", "PET_", "TORCH_NCCL_")
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _worker_env() -> dict:
|
| 98 |
+
"""A copy of os.environ with every trainer-distributed variable scrubbed."""
|
| 99 |
+
return {
|
| 100 |
+
k: v
|
| 101 |
+
for k, v in os.environ.items()
|
| 102 |
+
if k not in _POISON_ENV_KEYS and not k.startswith(_POISON_ENV_PREFIXES)
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def spawn_worker(
|
| 107 |
+
spool: str,
|
| 108 |
+
gpu: int,
|
| 109 |
+
log_path: str,
|
| 110 |
+
gpu_mem_util: float = 0.85,
|
| 111 |
+
tp: int = 1,
|
| 112 |
+
idle_exit_s: float = 0.0,
|
| 113 |
+
rollout_python: Optional[str] = None,
|
| 114 |
+
) -> subprocess.Popen:
|
| 115 |
+
"""Launch the vLLM worker on ONE dedicated GPU with the required env.
|
| 116 |
+
|
| 117 |
+
The env block is the launch checklist, encoded: the flashinfer sampler
|
| 118 |
+
is disabled (its JIT ninja build fails on this box's toolchain, measured
|
| 119 |
+
2026-08-23), every cache/tmp path lands on /apps, and the HF hub cache is
|
| 120 |
+
the rlhf one the stage-b snapshots live in.
|
| 121 |
+
"""
|
| 122 |
+
python = rollout_python or ROLLOUT_PYTHON
|
| 123 |
+
if not os.path.isfile(python):
|
| 124 |
+
raise FileNotFoundError(
|
| 125 |
+
f"rollout venv python not found at {python}; build .venv-rollout first "
|
| 126 |
+
"(uv venv + torch==2.11.0 triton==3.6.0 vllm==0.26.0 ray pyarrow)."
|
| 127 |
+
)
|
| 128 |
+
env = _worker_env()
|
| 129 |
+
env.update(
|
| 130 |
+
{
|
| 131 |
+
"CUDA_VISIBLE_DEVICES": str(gpu),
|
| 132 |
+
"VLLM_USE_FLASHINFER_SAMPLER": "0",
|
| 133 |
+
"TMPDIR": os.path.join(REPO_ROOT, ".tmp"),
|
| 134 |
+
"XDG_CACHE_HOME": os.path.join(REPO_ROOT, ".cache"),
|
| 135 |
+
"HF_HUB_CACHE": "/apps/development/rlhf/hf_cache/hub",
|
| 136 |
+
# worker logs must flush per line or a killed worker leaves an
|
| 137 |
+
# empty log (first smoke run, 2026-08-24)
|
| 138 |
+
"PYTHONUNBUFFERED": "1",
|
| 139 |
+
}
|
| 140 |
+
)
|
| 141 |
+
os.makedirs(os.path.dirname(os.path.abspath(log_path)), exist_ok=True)
|
| 142 |
+
log = open(log_path, "a")
|
| 143 |
+
return subprocess.Popen(
|
| 144 |
+
[
|
| 145 |
+
python,
|
| 146 |
+
WORKER_SCRIPT,
|
| 147 |
+
"--spool",
|
| 148 |
+
spool,
|
| 149 |
+
"--gpu-mem-util",
|
| 150 |
+
str(gpu_mem_util),
|
| 151 |
+
"--tp",
|
| 152 |
+
str(tp),
|
| 153 |
+
"--idle-exit-s",
|
| 154 |
+
str(idle_exit_s),
|
| 155 |
+
],
|
| 156 |
+
stdout=log,
|
| 157 |
+
stderr=subprocess.STDOUT,
|
| 158 |
+
env=env,
|
| 159 |
+
cwd=REPO_ROOT,
|
| 160 |
+
# own process GROUP so teardown can reap vLLM's EngineCore child --
|
| 161 |
+
# terminating only the parent orphaned a 19.6GB engine core on GPU 3
|
| 162 |
+
# (fifth smoke run, 2026-08-24)
|
| 163 |
+
start_new_session=True,
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def terminate_worker(proc: subprocess.Popen, timeout_s: float = 30.0) -> None:
|
| 168 |
+
"""Kill the worker's WHOLE process group (worker + vLLM engine core).
|
| 169 |
+
|
| 170 |
+
proc.terminate() reaches only the parent; the engine core reparents to
|
| 171 |
+
init and keeps its VRAM. Group-SIGTERM, grace, then group-SIGKILL.
|
| 172 |
+
"""
|
| 173 |
+
import signal
|
| 174 |
+
|
| 175 |
+
try:
|
| 176 |
+
pgid = os.getpgid(proc.pid)
|
| 177 |
+
except ProcessLookupError:
|
| 178 |
+
return
|
| 179 |
+
try:
|
| 180 |
+
os.killpg(pgid, signal.SIGTERM)
|
| 181 |
+
proc.wait(timeout=timeout_s)
|
| 182 |
+
except subprocess.TimeoutExpired:
|
| 183 |
+
os.killpg(pgid, signal.SIGKILL)
|
| 184 |
+
proc.wait(timeout=10)
|
| 185 |
+
except ProcessLookupError:
|
| 186 |
+
pass
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
#: Explicit name for the spool backend; `RolloutClient` is kept as the
|
| 190 |
+
#: original spelling because existing callers and tests import it.
|
| 191 |
+
SpoolRolloutClient = RolloutClient
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def make_rollout_client(
|
| 195 |
+
backend: str = DEFAULT_BACKEND,
|
| 196 |
+
*,
|
| 197 |
+
# -- native -----------------------------------------------------------------
|
| 198 |
+
model=None,
|
| 199 |
+
tokenizer=None,
|
| 200 |
+
native_config=None,
|
| 201 |
+
load_weights_fn=None,
|
| 202 |
+
device=None,
|
| 203 |
+
# -- spool ------------------------------------------------------------------
|
| 204 |
+
spool: Optional[str] = None,
|
| 205 |
+
timeout_s: float = 3600.0,
|
| 206 |
+
stale_heartbeat_s: float = 600.0,
|
| 207 |
+
) -> RolloutBackend:
|
| 208 |
+
"""Build the selected rollout backend.
|
| 209 |
+
|
| 210 |
+
Each backend REFUSES the other's arguments' absence loudly instead of
|
| 211 |
+
falling back: 'native' without a model is not "spool by default", and
|
| 212 |
+
'spool' without a spool dir is not "native by default". A silent switch of
|
| 213 |
+
generation backend changes throughput by an order of magnitude and
|
| 214 |
+
off-policyness by a sync interval, and would show up first as a mystery in
|
| 215 |
+
the step time.
|
| 216 |
+
"""
|
| 217 |
+
if backend == "native":
|
| 218 |
+
if model is None or tokenizer is None:
|
| 219 |
+
raise ValueError(
|
| 220 |
+
"backend='native' needs model= and tokenizer= (the in-process sampler runs "
|
| 221 |
+
"on the model it is given); pass backend='spool' to use the external worker"
|
| 222 |
+
)
|
| 223 |
+
# lazy: keeps `import align.rollout.client` free of torch/engine for
|
| 224 |
+
# spool-only callers
|
| 225 |
+
from align.rollout.native import NativeRolloutBackend
|
| 226 |
+
|
| 227 |
+
return NativeRolloutBackend(
|
| 228 |
+
model, tokenizer, native_config, load_weights_fn=load_weights_fn, device=device
|
| 229 |
+
)
|
| 230 |
+
if backend == "spool":
|
| 231 |
+
if not spool:
|
| 232 |
+
raise ValueError("backend='spool' needs spool= (the request/result spool directory)")
|
| 233 |
+
return RolloutClient(spool, timeout_s=timeout_s, stale_heartbeat_s=stale_heartbeat_s)
|
| 234 |
+
raise ValueError(f"unknown rollout backend {backend!r}; choose one of {BACKENDS}")
|
code/align/rollout/engine.py
ADDED
|
@@ -0,0 +1,674 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Continuous-batching sampler over a paged KV cache (P3, opt-in).
|
| 2 |
+
|
| 3 |
+
This is the native-engine step ROADMAP Track L calls P3: many sequences of very
|
| 4 |
+
different lengths in flight at once, admitted and retired independently,
|
| 5 |
+
backed by `align.rollout.paged`'s block tables. It is model-agnostic over
|
| 6 |
+
align's causal LMs (Mistral, Qwen3, OLMoE) WITHOUT touching them: the engine
|
| 7 |
+
drives the model's own submodules (`embed_tokens`, per-block `attn_norm` /
|
| 8 |
+
`attn.{q,k,v,o}_proj` / optional `q_norm`,`k_norm` / `mlp_norm` / `mlp`,
|
| 9 |
+
`norm`, `lm_head`, `rope`) layer by layer and replaces only the attention
|
| 10 |
+
kernel, which is the one thing a paged cache changes. Qwen3's per-head QK-norm
|
| 11 |
+
and OLMoE's full-width QK-norm (+ `clip_qkv`) are told apart by the norm weight
|
| 12 |
+
width, the same distinction the model files' docstrings warn about.
|
| 13 |
+
|
| 14 |
+
THE SCHEDULER, in one paragraph. Every sequence is `tokens` (prompt + sampled)
|
| 15 |
+
and `num_computed` (how many have KV in the cache). A step schedules, per
|
| 16 |
+
sequence, the next `n_new = min(len(tokens) - num_computed, chunk_size,
|
| 17 |
+
budget)` tokens. Prefill, chunked prefill, decode and post-preemption
|
| 18 |
+
recompute are therefore ONE code path; a sequence samples exactly when its
|
| 19 |
+
scheduled tokens reach the end of `tokens`. Mixed prefill chunks and decode
|
| 20 |
+
tokens share a step (one flat ragged batch), so a long prompt cannot stall
|
| 21 |
+
the decoders behind it (the head-of-line blocking a prefill-then-decode engine
|
| 22 |
+
has).
|
| 23 |
+
|
| 24 |
+
MEMORY PRESSURE. Blocks are allocated lazily as sequences grow. When a running
|
| 25 |
+
sequence cannot get its next block the YOUNGEST unscheduled running sequence is
|
| 26 |
+
preempted by RECOMPUTE: its blocks are freed, `num_computed` returns to 0, and
|
| 27 |
+
it re-enters the front of the waiting queue with its tokens (and its sampler
|
| 28 |
+
state) intact, so it re-prefills and carries on. Recompute rather than swap
|
| 29 |
+
because there is no host swap space on this box by design. The generator is
|
| 30 |
+
only advanced when a token is actually sampled, so a preemption changes WHEN a
|
| 31 |
+
token is drawn, never WHICH.
|
| 32 |
+
|
| 33 |
+
BATCH INVARIANCE (the property RL rollouts care about). A sequence's tokens
|
| 34 |
+
and logprobs should not depend on who else is in the batch, when it was
|
| 35 |
+
admitted, how its prompt was chunked, or whether it was preempted: otherwise
|
| 36 |
+
`pi_infer_old` (the logprobs that feed the rho correction) is a function of the
|
| 37 |
+
scheduler's mood, and two reruns of the same rollout seed disagree. Floating
|
| 38 |
+
point breaks this through (a) GEMM kernels whose reduction order depends on the
|
| 39 |
+
number of rows, and (b) attention whose reduction length depends on padding or
|
| 40 |
+
chunk size. `numerics='invariant'` removes both by construction: every
|
| 41 |
+
row-wise module call (projections, norms, MLP, lm_head) is issued on one row
|
| 42 |
+
at a time, and attention is one SDPA call per query row over exactly its own
|
| 43 |
+
keys. Every operand the kernel sees then depends only on the sequence itself.
|
| 44 |
+
It is slow and is the reference/RL-safe mode. `numerics='fast'` batches rows
|
| 45 |
+
and pads decode attention: faster, equal to 'invariant' to rounding only
|
| 46 |
+
(fp64: ~1e-15 on logits and tokens identical; bf16: expect occasional
|
| 47 |
+
argmax/sample flips on near-ties -- docs/P3_ENGINE.md has the measured rate).
|
| 48 |
+
|
| 49 |
+
SAMPLING. One `torch.Generator` PER SEQUENCE, seeded from `SamplingParams.seed`
|
| 50 |
+
(or a stable hash of the request id), advanced only by that sequence's own
|
| 51 |
+
draws. A shared engine generator (what `align.inference.Engine` uses) makes a
|
| 52 |
+
sampled token depend on how many other rows drew before it in the step, i.e. on
|
| 53 |
+
batch composition. Temperature, top-k, then top-p (minimal nucleus) are applied
|
| 54 |
+
to the fp32 logits; the RETURNED logprob is `log_softmax(logits/T)[token]`
|
| 55 |
+
UNTRUNCATED, the same basis as `align.rollout.native.LOGPROB_BASIS`, so the
|
| 56 |
+
rho correction contract is unchanged.
|
| 57 |
+
|
| 58 |
+
PREFIX SHARING (opt-in). Requests whose prompts share leading FULL blocks with
|
| 59 |
+
a running sequence adopt that sequence's blocks by reference instead of
|
| 60 |
+
recomputing them (GRPO: G samples of one prompt prefill it once). A follower
|
| 61 |
+
waits until the leader has computed the shared region, never shares a partial
|
| 62 |
+
block, and always recomputes at least its last prompt token (it needs that
|
| 63 |
+
row's logits). The allocator's refcounts keep shared blocks alive until the
|
| 64 |
+
last owner leaves; `PagedKVCache.slots` refuses writes into a shared block.
|
| 65 |
+
"""
|
| 66 |
+
|
| 67 |
+
from __future__ import annotations
|
| 68 |
+
|
| 69 |
+
import hashlib
|
| 70 |
+
import math
|
| 71 |
+
import time
|
| 72 |
+
from dataclasses import dataclass, field
|
| 73 |
+
from typing import Callable, Optional, Sequence
|
| 74 |
+
|
| 75 |
+
import torch
|
| 76 |
+
import torch.nn as nn
|
| 77 |
+
|
| 78 |
+
from align.inference.engine import _sampling_distribution, find_stop
|
| 79 |
+
from align.model.mistral import apply_rotary, kv_head_index_map
|
| 80 |
+
from align.rollout.paged import (
|
| 81 |
+
OutOfBlocks,
|
| 82 |
+
PagedAttention,
|
| 83 |
+
PagedKVCache,
|
| 84 |
+
Segment,
|
| 85 |
+
StepBatch,
|
| 86 |
+
TorchPagedAttention,
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
FINISH_REASONS = ("eos", "stop_token", "stop_string", "max_new_tokens", "max_seq_len")
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
# ---------------------------------------------------------------------------
|
| 93 |
+
# Public dataclasses
|
| 94 |
+
# ---------------------------------------------------------------------------
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
@dataclass
|
| 98 |
+
class SamplingParams:
|
| 99 |
+
"""Per-sequence sampling. `temperature` 0/None = greedy."""
|
| 100 |
+
|
| 101 |
+
max_tokens: int = 64
|
| 102 |
+
temperature: Optional[float] = 0.0
|
| 103 |
+
top_p: float = 1.0
|
| 104 |
+
#: 0 disables. Applied BEFORE top-p (vLLM's order).
|
| 105 |
+
top_k: int = 0
|
| 106 |
+
#: None -> stable hash of the request id (NOT admission order, which would
|
| 107 |
+
#: make "unseeded" depend on batch composition).
|
| 108 |
+
seed: Optional[int] = None
|
| 109 |
+
stop_token_ids: tuple[int, ...] = ()
|
| 110 |
+
#: text stops need a tokenizer; the completion keeps the token that
|
| 111 |
+
#: completed the stop string (token ids and logprobs stay aligned).
|
| 112 |
+
stop: tuple[str, ...] = ()
|
| 113 |
+
|
| 114 |
+
def validate(self) -> None:
|
| 115 |
+
if self.max_tokens < 1:
|
| 116 |
+
raise ValueError(f"max_tokens={self.max_tokens} must be >= 1")
|
| 117 |
+
t = self.temperature
|
| 118 |
+
if t is not None and (math.isnan(t) or t < 0):
|
| 119 |
+
raise ValueError(f"temperature={t} must be >= 0")
|
| 120 |
+
if math.isnan(self.top_p) or not (0.0 < self.top_p <= 1.0):
|
| 121 |
+
raise ValueError(f"top_p={self.top_p} must be in (0, 1]")
|
| 122 |
+
if self.top_k < 0:
|
| 123 |
+
raise ValueError(f"top_k={self.top_k} must be >= 0")
|
| 124 |
+
|
| 125 |
+
@property
|
| 126 |
+
def greedy(self) -> bool:
|
| 127 |
+
return self.temperature is None or self.temperature == 0.0
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
@dataclass
|
| 131 |
+
class PagedEngineConfig:
|
| 132 |
+
#: Tokens per KV block. 16 is vLLM's default; smaller wastes less tail,
|
| 133 |
+
#: larger gathers fewer indices.
|
| 134 |
+
block_size: int = 16
|
| 135 |
+
num_blocks: int = 256
|
| 136 |
+
#: Max sequences resident (admitted) at once.
|
| 137 |
+
max_num_seqs: int = 32
|
| 138 |
+
#: Per-step token budget across all segments (decode tokens + prefill chunks).
|
| 139 |
+
max_num_batched_tokens: int = 512
|
| 140 |
+
#: Max prompt tokens one sequence contributes to a step. None -> the budget.
|
| 141 |
+
chunk_size: Optional[int] = None
|
| 142 |
+
#: Hard cap on prompt + completion. None -> the model's max_position_embeddings.
|
| 143 |
+
max_seq_len: Optional[int] = None
|
| 144 |
+
#: 'invariant' (bit-reproducible across batch compositions; slow) or 'fast'.
|
| 145 |
+
numerics: str = "invariant"
|
| 146 |
+
prefix_sharing: bool = False
|
| 147 |
+
eos_token_id: Optional[int] = None
|
| 148 |
+
#: KV dtype. None -> the model's parameter dtype.
|
| 149 |
+
kv_dtype: Optional[torch.dtype] = None
|
| 150 |
+
#: Replace the attention backend (e.g. a future Triton kernel).
|
| 151 |
+
attention: Optional[PagedAttention] = None
|
| 152 |
+
|
| 153 |
+
def validate(self) -> None:
|
| 154 |
+
if self.numerics not in ("invariant", "fast"):
|
| 155 |
+
raise ValueError(f"numerics={self.numerics!r}; expected 'invariant' or 'fast'")
|
| 156 |
+
if min(self.block_size, self.num_blocks, self.max_num_seqs, self.max_num_batched_tokens) < 1:
|
| 157 |
+
raise ValueError(f"bad PagedEngineConfig: {self}")
|
| 158 |
+
if self.chunk_size is not None and self.chunk_size < 1:
|
| 159 |
+
raise ValueError(f"chunk_size={self.chunk_size} must be >= 1")
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
@dataclass
|
| 163 |
+
class PagedResult:
|
| 164 |
+
rid: str
|
| 165 |
+
prompt_ids: list[int]
|
| 166 |
+
token_ids: list[int]
|
| 167 |
+
logprobs: list[float]
|
| 168 |
+
finish_reason: str
|
| 169 |
+
preemptions: int = 0
|
| 170 |
+
shared_prefix_tokens: int = 0
|
| 171 |
+
text: Optional[str] = None
|
| 172 |
+
step_logits: Optional[list[torch.Tensor]] = None
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
@dataclass
|
| 176 |
+
class _Seq:
|
| 177 |
+
rid: str
|
| 178 |
+
prompt: list[int]
|
| 179 |
+
params: SamplingParams
|
| 180 |
+
order: int # monotonically increasing arrival index (age for preemption)
|
| 181 |
+
generator: torch.Generator
|
| 182 |
+
tokens: list[int] = field(default_factory=list)
|
| 183 |
+
num_computed: int = 0
|
| 184 |
+
logprobs: list[float] = field(default_factory=list)
|
| 185 |
+
finish: Optional[str] = None
|
| 186 |
+
preemptions: int = 0
|
| 187 |
+
shared_tokens: int = 0
|
| 188 |
+
logits_log: Optional[list[torch.Tensor]] = None
|
| 189 |
+
|
| 190 |
+
@property
|
| 191 |
+
def remaining(self) -> int:
|
| 192 |
+
return len(self.tokens) - self.num_computed
|
| 193 |
+
|
| 194 |
+
@property
|
| 195 |
+
def n_generated(self) -> int:
|
| 196 |
+
return len(self.tokens) - len(self.prompt)
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
# ---------------------------------------------------------------------------
|
| 200 |
+
# Sampling
|
| 201 |
+
# ---------------------------------------------------------------------------
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def top_k_filter(logits: torch.Tensor, k: int) -> torch.Tensor:
|
| 205 |
+
"""Keep the k largest logits of a [V] / [B,V] tensor, others -> -inf.
|
| 206 |
+
|
| 207 |
+
Ties at the k-th value are resolved by `torch.topk`'s index order, which is
|
| 208 |
+
deterministic; the cut is by COUNT, never by value, so exactly k survive."""
|
| 209 |
+
if k <= 0 or k >= logits.shape[-1]:
|
| 210 |
+
return logits
|
| 211 |
+
idx = torch.topk(logits, k, dim=-1).indices
|
| 212 |
+
out = torch.full_like(logits, float("-inf"))
|
| 213 |
+
return out.scatter(-1, idx, logits.gather(-1, idx))
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def row_distribution(logits: torch.Tensor, p: SamplingParams) -> torch.Tensor:
|
| 217 |
+
"""The distribution a non-greedy draw is made from: softmax(logits/T) over the
|
| 218 |
+
top-k survivors, truncated to the minimal top-p nucleus, renormalised. [V] fp32.
|
| 219 |
+
Split out so tests can compare it with an independent fp64 reference instead
|
| 220 |
+
of inferring it from draw frequencies."""
|
| 221 |
+
masked = top_k_filter(logits.float(), p.top_k)
|
| 222 |
+
return _sampling_distribution(masked.unsqueeze(0), float(p.temperature), p.top_p)[0]
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def sample_row(
|
| 226 |
+
logits: torch.Tensor, p: SamplingParams, gen: torch.Generator
|
| 227 |
+
) -> tuple[int, float]:
|
| 228 |
+
"""One row [V] -> (token, logprob). Greedy = argmax. The logprob is of the
|
| 229 |
+
UNTRUNCATED tempered distribution (T=1 for greedy): top-k/top-p decide which
|
| 230 |
+
token is drawn, they are not part of the distribution the training objective
|
| 231 |
+
models (see align.rollout.native for why folding them in breaks rho)."""
|
| 232 |
+
x = logits.float()
|
| 233 |
+
if p.greedy:
|
| 234 |
+
tok = int(x.argmax())
|
| 235 |
+
return tok, float(torch.log_softmax(x, dim=-1)[tok])
|
| 236 |
+
lp = torch.log_softmax(x / float(p.temperature), dim=-1)
|
| 237 |
+
probs = row_distribution(logits, p)
|
| 238 |
+
tok = int(torch.multinomial(probs.unsqueeze(0), 1, generator=gen)[0, 0])
|
| 239 |
+
return tok, float(lp[tok])
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def _stable_seed(rid: str) -> int:
|
| 243 |
+
return int.from_bytes(hashlib.sha256(rid.encode()).digest()[:8], "little") & ((1 << 62) - 1)
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
# ---------------------------------------------------------------------------
|
| 247 |
+
# Model adapter
|
| 248 |
+
# ---------------------------------------------------------------------------
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
class _ModelView:
|
| 252 |
+
"""Reads align's causal-LM structure and runs it layer by layer on a flat
|
| 253 |
+
token batch. Raises at construction on anything it does not recognise, so a
|
| 254 |
+
new architecture fails at build, not as wrong logits."""
|
| 255 |
+
|
| 256 |
+
def __init__(self, model: nn.Module):
|
| 257 |
+
try:
|
| 258 |
+
inner = model.model
|
| 259 |
+
self.embed, self.layers, self.final_norm, self.rope = (
|
| 260 |
+
inner.embed_tokens, list(inner.layers), inner.norm, inner.rope,
|
| 261 |
+
)
|
| 262 |
+
self.lm_head = model.lm_head
|
| 263 |
+
except AttributeError as e:
|
| 264 |
+
raise TypeError(
|
| 265 |
+
f"{type(model).__name__} does not look like an align causal LM "
|
| 266 |
+
f"(needs .model.{{embed_tokens,layers,norm,rope}} and .lm_head): {e}"
|
| 267 |
+
) from None
|
| 268 |
+
a0 = self.layers[0].attn
|
| 269 |
+
for attr in ("n_heads", "n_kv_heads", "head_dim", "scale", "q_proj", "k_proj", "v_proj", "o_proj"):
|
| 270 |
+
if not hasattr(a0, attr):
|
| 271 |
+
raise TypeError(f"attention module lacks `{attr}`; unsupported architecture")
|
| 272 |
+
self.n_layers = len(self.layers)
|
| 273 |
+
self.n_heads, self.n_kv_heads, self.head_dim, self.scale = (
|
| 274 |
+
a0.n_heads, a0.n_kv_heads, a0.head_dim, a0.scale,
|
| 275 |
+
)
|
| 276 |
+
# The paged backends use SDPA's enable_gqa, i.e. the canonical BLOCKED map
|
| 277 |
+
# q_head // group -> kv_head. Anything else would be silently wrong.
|
| 278 |
+
group = self.n_heads // self.n_kv_heads
|
| 279 |
+
idx = kv_head_index_map(self.n_heads, self.n_kv_heads, device=torch.device("cpu"))
|
| 280 |
+
if not torch.equal(idx, torch.arange(self.n_heads) // group):
|
| 281 |
+
raise NotImplementedError("non-canonical kv-head map is not supported by the paged engine")
|
| 282 |
+
self.qk_norm = self._qk_norm_kind(a0)
|
| 283 |
+
self.clip = getattr(a0, "clip_qkv", None)
|
| 284 |
+
|
| 285 |
+
def _qk_norm_kind(self, attn) -> Optional[str]:
|
| 286 |
+
if not hasattr(attn, "q_norm"):
|
| 287 |
+
return None
|
| 288 |
+
w = attn.q_norm.weight.numel()
|
| 289 |
+
if w == self.head_dim:
|
| 290 |
+
return "head" # Qwen3
|
| 291 |
+
if w == self.n_heads * self.head_dim:
|
| 292 |
+
return "full" # OLMoE
|
| 293 |
+
raise NotImplementedError(f"q_norm width {w} matches neither head_dim nor n_heads*head_dim")
|
| 294 |
+
|
| 295 |
+
def qkv(self, attn, h: torch.Tensor):
|
| 296 |
+
"""h [1,t,D] -> q [1,t,H,Dh], k/v [1,t,Hkv,Dh] BEFORE RoPE."""
|
| 297 |
+
b, t, _ = h.shape
|
| 298 |
+
q, k, v = attn.q_proj(h), attn.k_proj(h), attn.v_proj(h)
|
| 299 |
+
if self.qk_norm == "full":
|
| 300 |
+
q, k = attn.q_norm(q), attn.k_norm(k)
|
| 301 |
+
if self.clip is not None:
|
| 302 |
+
c = self.clip
|
| 303 |
+
q, k, v = q.clamp(-c, c), k.clamp(-c, c), v.clamp(-c, c)
|
| 304 |
+
q = q.view(b, t, self.n_heads, self.head_dim)
|
| 305 |
+
k = k.view(b, t, self.n_kv_heads, self.head_dim)
|
| 306 |
+
v = v.view(b, t, self.n_kv_heads, self.head_dim)
|
| 307 |
+
if self.qk_norm == "head":
|
| 308 |
+
q, k = attn.q_norm(q), attn.k_norm(k)
|
| 309 |
+
return q, k, v
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
def _assert_finite_logits(logits: torch.Tensor, rids: list[str]) -> None:
|
| 313 |
+
"""Raise FloatingPointError if any sampled row holds NaN or +inf.
|
| 314 |
+
|
| 315 |
+
Sampled rows would fail loudly inside torch.multinomial, but GREEDY rows do not:
|
| 316 |
+
argmax of a row containing NaN returns the NaN's index and `log_softmax` returns
|
| 317 |
+
NaN, so a bf16 overflow silently becomes a plausible-looking token with a NaN
|
| 318 |
+
logprob that then poisons the rho correction. -inf is allowed (a masked vocab).
|
| 319 |
+
Called BEFORE `num_computed` advances, so the step is retryable and no sequence has
|
| 320 |
+
consumed a draw from its generator."""
|
| 321 |
+
ok = (logits == logits) & (logits != float("inf"))
|
| 322 |
+
if logits.numel() and not bool(ok.all()):
|
| 323 |
+
bad = [rids[i] for i in range(len(rids)) if not bool(ok[i].all())]
|
| 324 |
+
raise FloatingPointError(f"non-finite logits (NaN/+inf) for request(s) {bad}")
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
def _rows(fn: Callable, x: torch.Tensor, rowwise: bool):
|
| 328 |
+
"""Apply `fn` to x [1,T,D]; if `rowwise`, once per row [1,1,D] and re-join.
|
| 329 |
+
|
| 330 |
+
This is the whole invariance trick for non-attention ops: a GEMM with M=1
|
| 331 |
+
runs the same kernel on the same operands whatever the step contains."""
|
| 332 |
+
if not rowwise or x.shape[1] == 1:
|
| 333 |
+
return fn(x)
|
| 334 |
+
outs = [fn(x[:, i : i + 1]) for i in range(x.shape[1])]
|
| 335 |
+
if isinstance(outs[0], tuple):
|
| 336 |
+
return tuple(torch.cat([o[j] for o in outs], dim=1) for j in range(len(outs[0])))
|
| 337 |
+
return torch.cat(outs, dim=1)
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
# ---------------------------------------------------------------------------
|
| 341 |
+
# Engine
|
| 342 |
+
# ---------------------------------------------------------------------------
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
class PagedEngine:
|
| 346 |
+
"""Continuous-batching sampler. Usage:
|
| 347 |
+
|
| 348 |
+
eng = PagedEngine(model, PagedEngineConfig(num_blocks=64))
|
| 349 |
+
eng.add_request("a", prompt_ids, SamplingParams(max_tokens=32, seed=1))
|
| 350 |
+
while eng.has_unfinished():
|
| 351 |
+
eng.step() # may add_request between steps
|
| 352 |
+
res = eng.results["a"]
|
| 353 |
+
"""
|
| 354 |
+
|
| 355 |
+
def __init__(self, model: nn.Module, cfg: Optional[PagedEngineConfig] = None, *,
|
| 356 |
+
tokenizer=None, return_logits: bool = False):
|
| 357 |
+
self.cfg = cfg if cfg is not None else PagedEngineConfig()
|
| 358 |
+
self.cfg.validate()
|
| 359 |
+
self.model = model
|
| 360 |
+
self.view = _ModelView(model)
|
| 361 |
+
self.tokenizer = tokenizer
|
| 362 |
+
self.return_logits = return_logits
|
| 363 |
+
p0 = next(model.parameters())
|
| 364 |
+
self.device = p0.device
|
| 365 |
+
kv_dtype = self.cfg.kv_dtype or p0.dtype
|
| 366 |
+
mcfg = getattr(model, "cfg", None)
|
| 367 |
+
self.max_seq_len = self.cfg.max_seq_len or getattr(mcfg, "max_position_embeddings", 2048)
|
| 368 |
+
self.cache = PagedKVCache(
|
| 369 |
+
self.view.n_layers, self.cfg.num_blocks, self.cfg.block_size,
|
| 370 |
+
self.view.n_kv_heads, self.view.head_dim, kv_dtype, self.device,
|
| 371 |
+
)
|
| 372 |
+
self.rowwise = self.cfg.numerics == "invariant"
|
| 373 |
+
self.attention: PagedAttention = self.cfg.attention or TorchPagedAttention(
|
| 374 |
+
"row" if self.rowwise else "segment"
|
| 375 |
+
)
|
| 376 |
+
self.waiting: list[_Seq] = []
|
| 377 |
+
self.running: list[_Seq] = []
|
| 378 |
+
self.results: dict[str, PagedResult] = {}
|
| 379 |
+
self._known: set[str] = set()
|
| 380 |
+
self._arrivals = 0
|
| 381 |
+
self.stats = dict(steps=0, tokens_forward=0, preemptions=0, prefix_tokens_shared=0,
|
| 382 |
+
peak_blocks_used=0, tokens_sampled=0, forward_seconds=0.0)
|
| 383 |
+
|
| 384 |
+
# -- requests ---------------------------------------------------------------
|
| 385 |
+
|
| 386 |
+
def add_request(self, rid: str, prompt_ids: Sequence[int], params: Optional[SamplingParams] = None) -> None:
|
| 387 |
+
params = params if params is not None else SamplingParams()
|
| 388 |
+
params.validate()
|
| 389 |
+
if rid in self._known:
|
| 390 |
+
raise ValueError(f"duplicate request id {rid!r}")
|
| 391 |
+
prompt = [int(t) for t in prompt_ids]
|
| 392 |
+
if not prompt:
|
| 393 |
+
raise ValueError(f"{rid!r}: empty prompt")
|
| 394 |
+
# An out-of-range id fails INSIDE the embedding lookup during `step()`, long after
|
| 395 |
+
# add_request returned: on CPU it is an IndexError that re-fires on every later step
|
| 396 |
+
# (the poisoned request stays in `running`, so one bad prompt wedges every other
|
| 397 |
+
# request), on CUDA a device-side assert that corrupts the whole context. Refuse it here.
|
| 398 |
+
vocab = self.view.embed.weight.shape[0]
|
| 399 |
+
bad = next((t for t in prompt if not 0 <= t < vocab), None)
|
| 400 |
+
if bad is not None:
|
| 401 |
+
raise ValueError(f"{rid!r}: token id {bad} outside the model vocabulary [0, {vocab})")
|
| 402 |
+
if len(prompt) >= self.max_seq_len:
|
| 403 |
+
raise ValueError(f"{rid!r}: prompt of {len(prompt)} tokens reaches max_seq_len={self.max_seq_len}")
|
| 404 |
+
if params.stop and self.tokenizer is None:
|
| 405 |
+
raise ValueError("text stop sequences need a tokenizer")
|
| 406 |
+
# The LAST sampled token is never fed back, so it never gets KV: a request
|
| 407 |
+
# that ends with `worst` tokens holds `worst - 1` in the cache. Sizing the guard
|
| 408 |
+
# on `worst` refuses a request that fits exactly (prompt 3 + 6 new, block 4 ->
|
| 409 |
+
# 8 cached tokens = 2 blocks, yet a 2-block pool was rejected as needing 3).
|
| 410 |
+
worst = min(len(prompt) + params.max_tokens, self.max_seq_len) - 1
|
| 411 |
+
if self.cache.blocks_needed(worst) > self.cache.num_blocks:
|
| 412 |
+
raise ValueError(
|
| 413 |
+
f"{rid!r} can need {self.cache.blocks_needed(worst)} blocks but the pool has "
|
| 414 |
+
f"{self.cache.num_blocks}: it could never finish even running alone"
|
| 415 |
+
)
|
| 416 |
+
seed = params.seed if params.seed is not None else _stable_seed(rid)
|
| 417 |
+
gen = torch.Generator(device=self.device)
|
| 418 |
+
gen.manual_seed(int(seed))
|
| 419 |
+
seq = _Seq(rid=rid, prompt=prompt, params=params, order=self._arrivals, generator=gen,
|
| 420 |
+
tokens=list(prompt), logits_log=[] if self.return_logits else None)
|
| 421 |
+
self._arrivals += 1
|
| 422 |
+
self._known.add(rid)
|
| 423 |
+
self.waiting.append(seq)
|
| 424 |
+
|
| 425 |
+
def has_unfinished(self) -> bool:
|
| 426 |
+
return bool(self.waiting or self.running)
|
| 427 |
+
|
| 428 |
+
# -- scheduling -------------------------------------------------------------
|
| 429 |
+
|
| 430 |
+
def _lcp(self, a: list[int], b: list[int]) -> int:
|
| 431 |
+
n = min(len(a), len(b))
|
| 432 |
+
i = 0
|
| 433 |
+
while i < n and a[i] == b[i]:
|
| 434 |
+
i += 1
|
| 435 |
+
return i
|
| 436 |
+
|
| 437 |
+
def _find_share(self, seq: _Seq) -> Optional[tuple[list[int], bool]]:
|
| 438 |
+
"""-> (blocks to adopt, defer?) for the best leader among running seqs."""
|
| 439 |
+
bs = self.cfg.block_size
|
| 440 |
+
cap_blocks = (len(seq.tokens) - 1) // bs # always recompute the last token
|
| 441 |
+
best: list[int] = []
|
| 442 |
+
defer = False
|
| 443 |
+
for src in self.running:
|
| 444 |
+
lcp_blocks = min(self._lcp(seq.tokens, src.tokens), len(src.prompt)) // bs
|
| 445 |
+
lcp_blocks = min(lcp_blocks, cap_blocks)
|
| 446 |
+
if lcp_blocks == 0:
|
| 447 |
+
continue
|
| 448 |
+
ready = min(lcp_blocks, src.num_computed // bs)
|
| 449 |
+
if ready < lcp_blocks and src.num_computed < len(src.prompt):
|
| 450 |
+
defer = True # the leader is still computing blocks we could adopt
|
| 451 |
+
if ready > len(best):
|
| 452 |
+
best = list(self.cache.tables[src.rid][:ready])
|
| 453 |
+
return best, defer
|
| 454 |
+
|
| 455 |
+
@staticmethod
|
| 456 |
+
def _pick_victim(victims: list[_Seq]) -> _Seq:
|
| 457 |
+
"""The YOUNGEST (latest arrival): it has the least computed work to throw
|
| 458 |
+
away, and older sequences keep making progress, so the oldest request is
|
| 459 |
+
never starved by newer ones."""
|
| 460 |
+
return max(victims, key=lambda s: s.order)
|
| 461 |
+
|
| 462 |
+
def _preempt(self, seq: _Seq) -> None:
|
| 463 |
+
self.cache.free_seq(seq.rid)
|
| 464 |
+
self.running.remove(seq)
|
| 465 |
+
seq.num_computed = 0
|
| 466 |
+
seq.shared_tokens = 0
|
| 467 |
+
seq.preemptions += 1
|
| 468 |
+
self.stats["preemptions"] += 1
|
| 469 |
+
self.waiting.append(seq)
|
| 470 |
+
self.waiting.sort(key=lambda s: s.order)
|
| 471 |
+
|
| 472 |
+
def _schedule(self) -> list[tuple[_Seq, int]]:
|
| 473 |
+
cfg = self.cfg
|
| 474 |
+
chunk = cfg.chunk_size or cfg.max_num_batched_tokens
|
| 475 |
+
budget = cfg.max_num_batched_tokens
|
| 476 |
+
sched: list[tuple[_Seq, int]] = []
|
| 477 |
+
scheduled: set[str] = set()
|
| 478 |
+
|
| 479 |
+
# 1) resident sequences: decodes first, then prefill chunks, oldest first
|
| 480 |
+
for seq in sorted(self.running, key=lambda s: (s.remaining > 1, s.order)):
|
| 481 |
+
if seq not in self.running or budget <= 0:
|
| 482 |
+
continue
|
| 483 |
+
n = min(seq.remaining, chunk, budget)
|
| 484 |
+
while True:
|
| 485 |
+
try:
|
| 486 |
+
self.cache.ensure_capacity(seq.rid, seq.num_computed + n)
|
| 487 |
+
break
|
| 488 |
+
except OutOfBlocks:
|
| 489 |
+
victims = [s for s in self.running if s is not seq and s.rid not in scheduled]
|
| 490 |
+
if not victims:
|
| 491 |
+
n = 0
|
| 492 |
+
break
|
| 493 |
+
self._preempt(self._pick_victim(victims))
|
| 494 |
+
if n == 0:
|
| 495 |
+
continue
|
| 496 |
+
sched.append((seq, n))
|
| 497 |
+
scheduled.add(seq.rid)
|
| 498 |
+
budget -= n
|
| 499 |
+
|
| 500 |
+
# 2) admit waiting sequences (FIFO, deferred followers skipped)
|
| 501 |
+
for seq in list(self.waiting):
|
| 502 |
+
if budget <= 0 or len(self.running) >= cfg.max_num_seqs:
|
| 503 |
+
break
|
| 504 |
+
shared: list[int] = []
|
| 505 |
+
if cfg.prefix_sharing:
|
| 506 |
+
got = self._find_share(seq)
|
| 507 |
+
shared, defer = got
|
| 508 |
+
if defer:
|
| 509 |
+
continue
|
| 510 |
+
start = len(shared) * cfg.block_size
|
| 511 |
+
n = min(len(seq.tokens) - start, chunk, budget)
|
| 512 |
+
need = self.cache.blocks_needed(len(seq.tokens)) - len(shared)
|
| 513 |
+
if not self.cache.allocator.can_allocate(need):
|
| 514 |
+
break # keep FIFO: do not let a small request leapfrog a starving big one
|
| 515 |
+
self.waiting.remove(seq)
|
| 516 |
+
self.cache.add_seq(seq.rid, shared)
|
| 517 |
+
seq.num_computed = start
|
| 518 |
+
seq.shared_tokens = start
|
| 519 |
+
self.stats["prefix_tokens_shared"] += start
|
| 520 |
+
self.cache.ensure_capacity(seq.rid, start + n)
|
| 521 |
+
self.running.append(seq)
|
| 522 |
+
sched.append((seq, n))
|
| 523 |
+
scheduled.add(seq.rid)
|
| 524 |
+
budget -= n
|
| 525 |
+
return sched
|
| 526 |
+
|
| 527 |
+
# -- one step ---------------------------------------------------------------
|
| 528 |
+
|
| 529 |
+
def step(self) -> list[tuple[str, int, Optional[str]]]:
|
| 530 |
+
"""Run one scheduler step. Returns [(rid, token, finish_reason|None)] for
|
| 531 |
+
every token sampled this step."""
|
| 532 |
+
sched = self._schedule()
|
| 533 |
+
if not sched:
|
| 534 |
+
if self.running or self.waiting:
|
| 535 |
+
raise RuntimeError(
|
| 536 |
+
"scheduler made no progress: no sequence can get the blocks it needs "
|
| 537 |
+
f"(free={self.cache.allocator.num_free}, running={len(self.running)}, "
|
| 538 |
+
f"waiting={len(self.waiting)})"
|
| 539 |
+
)
|
| 540 |
+
return []
|
| 541 |
+
batch = StepBatch()
|
| 542 |
+
toks: list[int] = []
|
| 543 |
+
pos: list[int] = []
|
| 544 |
+
off = 0
|
| 545 |
+
for seq, n in sched:
|
| 546 |
+
batch.segments.append(Segment(seq.rid, off, n, seq.num_computed))
|
| 547 |
+
toks.extend(seq.tokens[seq.num_computed : seq.num_computed + n])
|
| 548 |
+
pos.extend(range(seq.num_computed, seq.num_computed + n))
|
| 549 |
+
off += n
|
| 550 |
+
# rows whose logits we need: the last row of every segment that reaches
|
| 551 |
+
# the end of its sequence's tokens
|
| 552 |
+
sample_rows = [
|
| 553 |
+
(seq, seg.offset + seg.n_new - 1)
|
| 554 |
+
for (seq, n), seg in zip(sched, batch.segments)
|
| 555 |
+
if seq.num_computed + n == len(seq.tokens)
|
| 556 |
+
]
|
| 557 |
+
t0 = time.perf_counter()
|
| 558 |
+
with torch.no_grad():
|
| 559 |
+
logits = self._forward(
|
| 560 |
+
torch.tensor(toks, dtype=torch.long, device=self.device),
|
| 561 |
+
torch.tensor(pos, dtype=torch.long, device=self.device),
|
| 562 |
+
batch,
|
| 563 |
+
[r for _, r in sample_rows],
|
| 564 |
+
)
|
| 565 |
+
# The finiteness check reads a device scalar, i.e. it synchronises: placing it BEFORE
|
| 566 |
+
# the timer stops makes `forward_seconds` include the GPU work (CUDA launches are
|
| 567 |
+
# async, so without a sync here the stat measured kernel-launch time only).
|
| 568 |
+
_assert_finite_logits(logits, [seq.rid for seq, _ in sample_rows])
|
| 569 |
+
self.stats["forward_seconds"] += time.perf_counter() - t0
|
| 570 |
+
self.stats["steps"] += 1
|
| 571 |
+
self.stats["tokens_forward"] += off
|
| 572 |
+
self.stats["peak_blocks_used"] = max(self.stats["peak_blocks_used"], self.cache.allocator.num_used)
|
| 573 |
+
|
| 574 |
+
for seq, n in sched:
|
| 575 |
+
seq.num_computed += n
|
| 576 |
+
self.cache.lengths[seq.rid] = seq.num_computed
|
| 577 |
+
|
| 578 |
+
events = []
|
| 579 |
+
for i, (seq, _) in enumerate(sample_rows):
|
| 580 |
+
row = logits[i]
|
| 581 |
+
tok, lp = sample_row(row, seq.params, seq.generator)
|
| 582 |
+
if seq.logits_log is not None:
|
| 583 |
+
seq.logits_log.append(row.detach().float().cpu())
|
| 584 |
+
seq.tokens.append(tok)
|
| 585 |
+
seq.logprobs.append(lp)
|
| 586 |
+
self.stats["tokens_sampled"] += 1
|
| 587 |
+
reason = self._finish_reason(seq)
|
| 588 |
+
events.append((seq.rid, tok, reason))
|
| 589 |
+
if reason is not None:
|
| 590 |
+
self._retire(seq, reason)
|
| 591 |
+
return events
|
| 592 |
+
|
| 593 |
+
def _finish_reason(self, seq: _Seq) -> Optional[str]:
|
| 594 |
+
p, gen = seq.params, seq.tokens[len(seq.prompt):]
|
| 595 |
+
last = gen[-1]
|
| 596 |
+
if self.cfg.eos_token_id is not None and last == self.cfg.eos_token_id:
|
| 597 |
+
return "eos"
|
| 598 |
+
if last in p.stop_token_ids:
|
| 599 |
+
return "stop_token"
|
| 600 |
+
if p.stop:
|
| 601 |
+
text = self.tokenizer.decode(gen, skip_special_tokens=True)
|
| 602 |
+
if find_stop(text, p.stop) is not None:
|
| 603 |
+
return "stop_string"
|
| 604 |
+
if len(gen) >= p.max_tokens:
|
| 605 |
+
return "max_new_tokens"
|
| 606 |
+
if len(seq.tokens) >= self.max_seq_len:
|
| 607 |
+
return "max_seq_len"
|
| 608 |
+
return None
|
| 609 |
+
|
| 610 |
+
def _retire(self, seq: _Seq, reason: str) -> None:
|
| 611 |
+
seq.finish = reason
|
| 612 |
+
self.cache.free_seq(seq.rid)
|
| 613 |
+
self.running.remove(seq)
|
| 614 |
+
gen = seq.tokens[len(seq.prompt):]
|
| 615 |
+
text = self.tokenizer.decode(gen, skip_special_tokens=True) if self.tokenizer is not None else None
|
| 616 |
+
self.results[seq.rid] = PagedResult(
|
| 617 |
+
rid=seq.rid, prompt_ids=list(seq.prompt), token_ids=list(gen), logprobs=list(seq.logprobs),
|
| 618 |
+
finish_reason=reason, preemptions=seq.preemptions, shared_prefix_tokens=seq.shared_tokens,
|
| 619 |
+
text=text, step_logits=seq.logits_log,
|
| 620 |
+
)
|
| 621 |
+
|
| 622 |
+
# -- the model, layer by layer -------------------------------------------------
|
| 623 |
+
|
| 624 |
+
def _forward(self, tokens: torch.Tensor, positions: torch.Tensor, batch: StepBatch,
|
| 625 |
+
sample_row_ids: list[int]) -> torch.Tensor:
|
| 626 |
+
v, rw = self.view, self.rowwise
|
| 627 |
+
x = v.embed(tokens).unsqueeze(0) # [1, T, D]
|
| 628 |
+
cos, sin = v.rope(positions.unsqueeze(0))
|
| 629 |
+
for li, blk in enumerate(v.layers):
|
| 630 |
+
attn = blk.attn
|
| 631 |
+
h = _rows(blk.attn_norm, x, rw)
|
| 632 |
+
q, k, val = _rows(lambda z: v.qkv(attn, z), h, rw)
|
| 633 |
+
qh, kh = apply_rotary(q.transpose(1, 2), k.transpose(1, 2), cos, sin) # [1,H,T,Dh]
|
| 634 |
+
out = self.attention(
|
| 635 |
+
li, qh[0].transpose(0, 1), kh[0].transpose(0, 1), val[0],
|
| 636 |
+
self.cache, batch, v.scale,
|
| 637 |
+
) # [T, H, Dh]
|
| 638 |
+
x = x + _rows(attn.o_proj, out.reshape(1, out.shape[0], -1), rw)
|
| 639 |
+
|
| 640 |
+
def mlp(z, blk=blk):
|
| 641 |
+
o = blk.mlp(blk.mlp_norm(z))
|
| 642 |
+
return o[0] if isinstance(o, tuple) else o
|
| 643 |
+
|
| 644 |
+
x = x + _rows(mlp, x, rw)
|
| 645 |
+
if not sample_row_ids:
|
| 646 |
+
return x.new_zeros((0, 1))
|
| 647 |
+
sel = x[:, torch.tensor(sample_row_ids, device=x.device)]
|
| 648 |
+
sel = _rows(v.final_norm, sel, rw)
|
| 649 |
+
return _rows(v.lm_head, sel, rw)[0]
|
| 650 |
+
|
| 651 |
+
# -- convenience ----------------------------------------------------------------
|
| 652 |
+
|
| 653 |
+
def generate(
|
| 654 |
+
self,
|
| 655 |
+
requests: Sequence[tuple[str, Sequence[int], SamplingParams]],
|
| 656 |
+
arrival_step: Optional[dict[str, int]] = None,
|
| 657 |
+
max_steps: int = 1_000_000,
|
| 658 |
+
) -> dict[str, PagedResult]:
|
| 659 |
+
"""Run `requests` to completion. `arrival_step[rid] = s` admits that
|
| 660 |
+
request to the queue just before step s (0 = immediately), which is how
|
| 661 |
+
tests stage 'admitted at different steps'."""
|
| 662 |
+
arrival_step = arrival_step or {}
|
| 663 |
+
pending = sorted(requests, key=lambda r: arrival_step.get(r[0], 0))
|
| 664 |
+
i, s = 0, 0
|
| 665 |
+
while i < len(pending) or self.has_unfinished():
|
| 666 |
+
while i < len(pending) and arrival_step.get(pending[i][0], 0) <= s:
|
| 667 |
+
self.add_request(*pending[i])
|
| 668 |
+
i += 1
|
| 669 |
+
if self.has_unfinished():
|
| 670 |
+
self.step()
|
| 671 |
+
s += 1
|
| 672 |
+
if s > max_steps:
|
| 673 |
+
raise RuntimeError("generate exceeded max_steps")
|
| 674 |
+
return {r[0]: self.results[r[0]] for r in requests}
|
code/align/rollout/native.py
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Native rollout: GRPO generation on torch alone, in-process, zero extra deps.
|
| 2 |
+
|
| 3 |
+
This is the CORE rollout backend. vLLM (extras/vllm_rollout/) is an optional
|
| 4 |
+
throughput upgrade behind a process boundary; this module is what makes the
|
| 5 |
+
GRPO stage runnable with exactly torch + triton + numpy + safetensors. It
|
| 6 |
+
satisfies the same `RolloutBackend` surface (submit/collect) and returns the
|
| 7 |
+
same `RolloutResult` the spool client returns, so `align.rl.batch_source`
|
| 8 |
+
cannot tell which backend served it.
|
| 9 |
+
|
| 10 |
+
What it does, in one paragraph: tokenize each prompt ONCE (those ids are
|
| 11 |
+
returned in `extras["prompt_token_ids"]` and are what assembly concatenates --
|
| 12 |
+
prompts are never re-tokenized), expand to P x G `GenerationRequest`s, run
|
| 13 |
+
them through `align.inference.engine.Engine` (varlen batched prefill,
|
| 14 |
+
contiguous per-slot KV, completion-based refill) with
|
| 15 |
+
`return_logprobs=True`, and regroup into completions[i][j] / logprobs[i][j] /
|
| 16 |
+
texts[i][j]. The per-token logprobs are captured AT SAMPLING TIME from the
|
| 17 |
+
very logits the token was drawn from; they are never recomputed from text.
|
| 18 |
+
|
| 19 |
+
THE RHO-CORRECTION CONTRACT (the reason logprobs travel with the rollout).
|
| 20 |
+
The GRPO module's `old_logprobs` is a teacher-forced pass over
|
| 21 |
+
(prompt + completion): `log_softmax(logits / T)` gathered at the label.
|
| 22 |
+
`vllm_logprobs` (pi^infer_old) must be the same quantity as seen by the
|
| 23 |
+
sampler. With the native engine they differ only by floating-point noise
|
| 24 |
+
(KV-cache decode vs full-sequence forward), so rho = exp(old - infer) == 1 up
|
| 25 |
+
to ~1e-6 in fp32 -- which `tests/test_rollout_native.py` pins by recomputing
|
| 26 |
+
the teacher-forced gather on the same model. The basis is therefore
|
| 27 |
+
log_softmax(logits / T), UNTRUNCATED: top-p shapes which token gets drawn but
|
| 28 |
+
is not a part of the distribution the training objective models, and folding
|
| 29 |
+
it into the logprob would make rho != 1 on-policy for every top_p < 1.
|
| 30 |
+
|
| 31 |
+
TOPOLOGY CAVEAT. The backend drives a model it can call standalone on its own
|
| 32 |
+
device. That is a dedicated engine copy (the v1 topology: one engine GPU
|
| 33 |
+
beside the FSDP train ranks) or a single-process run (CPU tests, 1-GPU
|
| 34 |
+
smoke). It is NOT valid to point it at an FSDP2-sharded module from one rank:
|
| 35 |
+
the decode loop's forwards are collectives and the other ranks would never
|
| 36 |
+
join them. `GrpoBatchSource` only generates on rank 0, which is exactly why the
|
| 37 |
+
sharded case needs a separate engine-side copy plus `load_weights_fn`.
|
| 38 |
+
|
| 39 |
+
WEIGHT SYNC. `request.weights_dir` is provenance, not an input, unless the
|
| 40 |
+
caller supplies `load_weights_fn`: then a CHANGED weights_dir triggers
|
| 41 |
+
`load_weights_fn(weights_dir)` (and `engine_reloaded=True`), mirroring the
|
| 42 |
+
spool worker's "checkpoint saves ARE the weight sync" contract. With no hook
|
| 43 |
+
the model is assumed live (shared parameters), so there is nothing to sync.
|
| 44 |
+
"""
|
| 45 |
+
|
| 46 |
+
from __future__ import annotations
|
| 47 |
+
|
| 48 |
+
import inspect
|
| 49 |
+
import logging
|
| 50 |
+
import time
|
| 51 |
+
from dataclasses import dataclass
|
| 52 |
+
from typing import Callable, Optional
|
| 53 |
+
|
| 54 |
+
import torch
|
| 55 |
+
|
| 56 |
+
from align.inference.engine import Engine, GenerationRequest, InferenceConfig
|
| 57 |
+
from align.rollout.protocol import RolloutRequest, RolloutResult
|
| 58 |
+
|
| 59 |
+
logger = logging.getLogger(__name__)
|
| 60 |
+
|
| 61 |
+
#: How the returned logprobs are defined; stamped into `extras` so a result
|
| 62 |
+
#: file is self-describing about the one thing the rho correction depends on.
|
| 63 |
+
LOGPROB_BASIS = "log_softmax(logits/T)[token], fp32, no top-p truncation (T=1 for greedy)"
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def checkpoint_weight_loader(model, dtype: Optional[torch.dtype] = None) -> Callable[[str], None]:
|
| 67 |
+
"""A `load_weights_fn` that refreshes a STANDALONE engine model from a
|
| 68 |
+
trainer checkpoint directory ("checkpoint saves ARE the weight sync").
|
| 69 |
+
|
| 70 |
+
Reads the HF-layout safetensors the Trainer wrote (single file or sharded
|
| 71 |
+
snapshot), converts to the align key layout and copies INTO the model's
|
| 72 |
+
existing parameters (`assign=False`): the Engine and any CUDA graphs hold
|
| 73 |
+
references to those tensors, so rebinding them would silently leave the
|
| 74 |
+
sampler on stale weights. `strict=True`: a checkpoint that does not cover
|
| 75 |
+
every parameter is a wrong sync, not a partial one.
|
| 76 |
+
"""
|
| 77 |
+
from align.model import checkpoint as ckpt
|
| 78 |
+
|
| 79 |
+
def _load(weights_dir: str) -> None:
|
| 80 |
+
if dtype is None:
|
| 81 |
+
target = next(model.parameters()).dtype
|
| 82 |
+
else:
|
| 83 |
+
target = dtype
|
| 84 |
+
state = ckpt.hf_to_nmo(ckpt.load_hf_safetensors(weights_dir, dtype=target))
|
| 85 |
+
with torch.no_grad():
|
| 86 |
+
model.load_state_dict(state, strict=True)
|
| 87 |
+
|
| 88 |
+
return _load
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
@dataclass
|
| 92 |
+
class NativeRolloutConfig:
|
| 93 |
+
"""Knobs of the native backend, one docstring per field."""
|
| 94 |
+
|
| 95 |
+
#: Concurrent sequences = KV slots. Each slot owns `max_seq_len` tokens of
|
| 96 |
+
#: KV for the engine's life (128 KiB/token at 7B bf16), so this multiplies
|
| 97 |
+
#: the cache budget directly. The engine is built with
|
| 98 |
+
#: min(n_slots, sequences-in-the-request) so a small request does not pay
|
| 99 |
+
#: for a big cache.
|
| 100 |
+
n_slots: int = 32
|
| 101 |
+
#: HARD cap on per-slot sequence length (prompt + completion). The engine
|
| 102 |
+
#: is sized to the request's actual need (longest prompt + max_tokens)
|
| 103 |
+
#: rounded up to `seq_len_granularity` and never above this; a sequence
|
| 104 |
+
#: that reaches the cap ends with finish_reason 'max_seq_len' (recorded in
|
| 105 |
+
#: extras), while a PROMPT that alone reaches it is refused loudly -- the
|
| 106 |
+
#: truncation of a prompt must be visible upstream, not done here.
|
| 107 |
+
max_seq_len_cap: int = 2048
|
| 108 |
+
#: Engine rebuilds are expensive (the KV is preallocated), so the length is
|
| 109 |
+
#: rounded up to a multiple of this to make growth rare.
|
| 110 |
+
seq_len_granularity: int = 64
|
| 111 |
+
#: Passed to the tokenizer when encoding prompts. True matches what vLLM's
|
| 112 |
+
#: `LLM.generate(prompts)` does (prepends BOS for Mistral), which keeps the
|
| 113 |
+
#: native and spool backends token-identical on the same prompt text.
|
| 114 |
+
add_special_tokens: bool = True
|
| 115 |
+
#: Used when a request carries `seed=None`: the n-th unseeded request gets
|
| 116 |
+
#: default_seed + n, so even "unseeded" rollouts are reproducible per run.
|
| 117 |
+
default_seed: int = 0
|
| 118 |
+
#: Ends a sequence when sampled. None -> take it from the tokenizer.
|
| 119 |
+
eos_token_id: Optional[int] = None
|
| 120 |
+
#: Filler for right-padded prefill rows; never read back.
|
| 121 |
+
pad_token_id: int = 0
|
| 122 |
+
#: 'length' admits shortest-first (measured default, see InferenceConfig).
|
| 123 |
+
admission_order: str = "length"
|
| 124 |
+
#: Run the engine's VRAM preflight when the KV cache is allocated on CUDA.
|
| 125 |
+
preflight: bool = True
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def _make_encoder(tokenizer, add_special_tokens: bool) -> Callable[[str], list[int]]:
|
| 129 |
+
"""prompt text -> token ids, over the minimal tokenizer contract.
|
| 130 |
+
|
| 131 |
+
The contract is `encode(text) -> list[int]` and
|
| 132 |
+
`decode(ids, skip_special_tokens=True) -> str`. If `encode` takes an
|
| 133 |
+
`add_special_tokens` keyword it is passed; otherwise the tokenizer is
|
| 134 |
+
taken to apply its own convention and the flag is not forwarded. Decided
|
| 135 |
+
once from the signature, not by catching TypeError per call -- a TypeError
|
| 136 |
+
raised INSIDE a tokenizer must surface, not be mistaken for "no such kwarg".
|
| 137 |
+
"""
|
| 138 |
+
enc = tokenizer.encode
|
| 139 |
+
try:
|
| 140 |
+
params = inspect.signature(enc).parameters
|
| 141 |
+
except (TypeError, ValueError): # builtins / odd callables
|
| 142 |
+
params = {}
|
| 143 |
+
accepts = "add_special_tokens" in params or any(
|
| 144 |
+
p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values()
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
def encode(text: str) -> list[int]:
|
| 148 |
+
out = enc(text, add_special_tokens=add_special_tokens) if accepts else enc(text)
|
| 149 |
+
ids = getattr(out, "ids", out) # tolerate an Encoding-like return
|
| 150 |
+
return [int(t) for t in ids]
|
| 151 |
+
|
| 152 |
+
return encode
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
class NativeRolloutBackend:
|
| 156 |
+
"""In-process rollout on a torch model via the repo's own KV-cache engine.
|
| 157 |
+
|
| 158 |
+
`submit(request)` runs the whole request synchronously and parks the
|
| 159 |
+
result; `collect(request_id)` hands it over. That keeps the two-call
|
| 160 |
+
surface of the spool client (so `GrpoBatchSource` is backend-agnostic)
|
| 161 |
+
while being honest that there is no worker to wait on.
|
| 162 |
+
"""
|
| 163 |
+
|
| 164 |
+
def __init__(
|
| 165 |
+
self,
|
| 166 |
+
model,
|
| 167 |
+
tokenizer,
|
| 168 |
+
cfg: Optional[NativeRolloutConfig] = None,
|
| 169 |
+
*,
|
| 170 |
+
load_weights_fn: Optional[Callable[[str], None]] = None,
|
| 171 |
+
device: Optional[torch.device] = None,
|
| 172 |
+
):
|
| 173 |
+
if tokenizer is None:
|
| 174 |
+
raise ValueError(
|
| 175 |
+
"native rollout needs a tokenizer: prompts must be encoded here (and their "
|
| 176 |
+
"ids returned) and completions decoded to the exact text the reward path scores"
|
| 177 |
+
)
|
| 178 |
+
self.model = model
|
| 179 |
+
self.tokenizer = tokenizer
|
| 180 |
+
self.cfg = cfg if cfg is not None else NativeRolloutConfig()
|
| 181 |
+
if self.cfg.n_slots < 1 or self.cfg.max_seq_len_cap < 2 or self.cfg.seq_len_granularity < 1:
|
| 182 |
+
raise ValueError(f"bad NativeRolloutConfig: {self.cfg}")
|
| 183 |
+
self.load_weights_fn = load_weights_fn
|
| 184 |
+
self.device = device
|
| 185 |
+
eos = self.cfg.eos_token_id
|
| 186 |
+
self.eos_token_id = eos if eos is not None else getattr(tokenizer, "eos_token_id", None)
|
| 187 |
+
self._encode = _make_encoder(tokenizer, self.cfg.add_special_tokens)
|
| 188 |
+
|
| 189 |
+
self._engine: Optional[Engine] = None
|
| 190 |
+
self._results: dict[str, RolloutResult] = {}
|
| 191 |
+
self._loaded_weights_dir: Optional[str] = None
|
| 192 |
+
self._unseeded = 0
|
| 193 |
+
|
| 194 |
+
# -- the RolloutBackend surface --------------------------------------------
|
| 195 |
+
|
| 196 |
+
def submit(self, request: RolloutRequest) -> str:
|
| 197 |
+
if request.request_id in self._results:
|
| 198 |
+
# same refusal as the spool protocol's write_request
|
| 199 |
+
raise FileExistsError(f"duplicate request_id {request.request_id!r}")
|
| 200 |
+
self._results[request.request_id] = self.generate(request)
|
| 201 |
+
return request.request_id
|
| 202 |
+
|
| 203 |
+
def collect(self, request_id: str) -> RolloutResult:
|
| 204 |
+
try:
|
| 205 |
+
return self._results.pop(request_id)
|
| 206 |
+
except KeyError:
|
| 207 |
+
raise KeyError(
|
| 208 |
+
f"no pending native result for {request_id!r} (never submitted, or already "
|
| 209 |
+
"collected: results are single-use, as the spool's consumed files are)"
|
| 210 |
+
) from None
|
| 211 |
+
|
| 212 |
+
# -- core -------------------------------------------------------------------
|
| 213 |
+
|
| 214 |
+
def generate(self, request: RolloutRequest) -> RolloutResult:
|
| 215 |
+
"""One request -> one RolloutResult. Exceptions propagate directly: there
|
| 216 |
+
is no worker boundary to convert them into an `error` result across."""
|
| 217 |
+
request.validate(check_weights_dir=False)
|
| 218 |
+
reloaded = self._maybe_sync_weights(request.weights_dir)
|
| 219 |
+
|
| 220 |
+
prompt_ids = [self._encode(p) for p in request.prompts]
|
| 221 |
+
n, g = len(request.prompts), request.n_samples_per_prompt
|
| 222 |
+
engine = self._engine_for(
|
| 223 |
+
need_len=max(len(p) for p in prompt_ids) + request.max_tokens, n_sequences=n * g
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
seed = request.seed
|
| 227 |
+
if seed is None:
|
| 228 |
+
seed = self.cfg.default_seed + self._unseeded
|
| 229 |
+
self._unseeded += 1
|
| 230 |
+
# Re-seed per request: the sample stream is a pure function of
|
| 231 |
+
# (weights, prompts, seed), not of how many requests came before.
|
| 232 |
+
engine.generator.manual_seed(int(seed))
|
| 233 |
+
|
| 234 |
+
gen_requests = [
|
| 235 |
+
GenerationRequest(
|
| 236 |
+
rid=f"{request.request_id}:{i}:{j}",
|
| 237 |
+
prompt_ids=prompt_ids[i],
|
| 238 |
+
max_new_tokens=request.max_tokens,
|
| 239 |
+
temperature=request.temperature,
|
| 240 |
+
top_p=request.top_p,
|
| 241 |
+
)
|
| 242 |
+
for i in range(n)
|
| 243 |
+
for j in range(g)
|
| 244 |
+
]
|
| 245 |
+
|
| 246 |
+
t0 = time.perf_counter()
|
| 247 |
+
with _rollout_mode(self.model):
|
| 248 |
+
results = engine.generate(gen_requests, return_logprobs=True)
|
| 249 |
+
gen_s = time.perf_counter() - t0
|
| 250 |
+
|
| 251 |
+
completions, logprobs, texts, finish = [], [], [], []
|
| 252 |
+
for i in range(n):
|
| 253 |
+
row = results[i * g : (i + 1) * g]
|
| 254 |
+
for r in row:
|
| 255 |
+
if r.logprobs is None or len(r.logprobs) != len(r.token_ids):
|
| 256 |
+
raise RuntimeError(
|
| 257 |
+
f"{r.rid}: engine returned {None if r.logprobs is None else len(r.logprobs)}"
|
| 258 |
+
f" logprobs for {len(r.token_ids)} tokens; the rho correction cannot run"
|
| 259 |
+
)
|
| 260 |
+
if r.text is None: # unreachable with a tokenizer; cheap to keep honest
|
| 261 |
+
raise RuntimeError(f"{r.rid}: engine returned no decoded text")
|
| 262 |
+
completions.append([r.token_ids for r in row])
|
| 263 |
+
logprobs.append([r.logprobs for r in row])
|
| 264 |
+
texts.append([r.text for r in row])
|
| 265 |
+
finish.append([r.finish_reason for r in row])
|
| 266 |
+
|
| 267 |
+
return RolloutResult(
|
| 268 |
+
request_id=request.request_id,
|
| 269 |
+
completions=completions,
|
| 270 |
+
logprobs=logprobs,
|
| 271 |
+
texts=texts,
|
| 272 |
+
weights_dir=request.weights_dir,
|
| 273 |
+
engine_reloaded=reloaded,
|
| 274 |
+
generate_seconds=gen_s,
|
| 275 |
+
extras={
|
| 276 |
+
# THE tokenization of each prompt that the completions continue
|
| 277 |
+
# from; assembly forbids re-deriving it (audit R1 class).
|
| 278 |
+
"prompt_token_ids": [list(p) for p in prompt_ids],
|
| 279 |
+
"finish_reasons": finish,
|
| 280 |
+
"backend": "native",
|
| 281 |
+
"seed": int(seed),
|
| 282 |
+
"logprob_basis": LOGPROB_BASIS,
|
| 283 |
+
"engine_max_seq_len": engine.cfg.max_seq_len,
|
| 284 |
+
"engine_n_slots": engine.cfg.n_slots,
|
| 285 |
+
},
|
| 286 |
+
)
|
| 287 |
+
|
| 288 |
+
# -- internals ----------------------------------------------------------------
|
| 289 |
+
|
| 290 |
+
def _maybe_sync_weights(self, weights_dir: str) -> bool:
|
| 291 |
+
if self.load_weights_fn is None or not weights_dir:
|
| 292 |
+
return False
|
| 293 |
+
if weights_dir == self._loaded_weights_dir:
|
| 294 |
+
return False
|
| 295 |
+
t0 = time.perf_counter()
|
| 296 |
+
self.load_weights_fn(weights_dir)
|
| 297 |
+
self._loaded_weights_dir = weights_dir
|
| 298 |
+
logger.info("native rollout: weights synced from %s in %.1fs", weights_dir,
|
| 299 |
+
time.perf_counter() - t0)
|
| 300 |
+
return True
|
| 301 |
+
|
| 302 |
+
def _engine_for(self, need_len: int, n_sequences: int) -> Engine:
|
| 303 |
+
"""The cached engine if it is big enough, else a (re)built one.
|
| 304 |
+
|
| 305 |
+
Grows only: a shorter later request reuses the bigger cache, so a run
|
| 306 |
+
converges to one engine after a few steps instead of thrashing on
|
| 307 |
+
prompt-length variance. The old engine's KV is freed BEFORE the new
|
| 308 |
+
one is allocated -- both alive at once would double the cache.
|
| 309 |
+
"""
|
| 310 |
+
gran = self.cfg.seq_len_granularity
|
| 311 |
+
want_len = min(self.cfg.max_seq_len_cap, -(-need_len // gran) * gran)
|
| 312 |
+
want_slots = min(self.cfg.n_slots, n_sequences)
|
| 313 |
+
eng = self._engine
|
| 314 |
+
if eng is not None and eng.cfg.max_seq_len >= want_len and eng.cfg.n_slots >= want_slots:
|
| 315 |
+
return eng
|
| 316 |
+
if eng is not None:
|
| 317 |
+
want_len = max(want_len, eng.cfg.max_seq_len)
|
| 318 |
+
want_slots = max(want_slots, eng.cfg.n_slots)
|
| 319 |
+
self._engine = None
|
| 320 |
+
del eng
|
| 321 |
+
if torch.cuda.is_available():
|
| 322 |
+
torch.cuda.empty_cache()
|
| 323 |
+
icfg = InferenceConfig(
|
| 324 |
+
n_slots=want_slots,
|
| 325 |
+
max_seq_len=want_len,
|
| 326 |
+
max_new_tokens=1, # always overridden per request
|
| 327 |
+
temperature=1.0,
|
| 328 |
+
top_p=1.0,
|
| 329 |
+
seed=self.cfg.default_seed,
|
| 330 |
+
eos_token_id=self.eos_token_id,
|
| 331 |
+
pad_token_id=self.cfg.pad_token_id,
|
| 332 |
+
admission_order=self.cfg.admission_order,
|
| 333 |
+
)
|
| 334 |
+
self._engine = Engine(
|
| 335 |
+
self.model,
|
| 336 |
+
icfg,
|
| 337 |
+
tokenizer=self.tokenizer,
|
| 338 |
+
device=self.device,
|
| 339 |
+
preflight=self.cfg.preflight,
|
| 340 |
+
)
|
| 341 |
+
return self._engine
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
class _rollout_mode:
|
| 345 |
+
"""Borrow a TRAINING model for sampling, then give it back untouched.
|
| 346 |
+
|
| 347 |
+
The engine flips every attention module's backend per phase (prefill vs
|
| 348 |
+
decode) and runs the model as-is; on a model that is also being trained
|
| 349 |
+
that would leak: a decode-phase 'sdpa' left on a `triton_fa2` model would
|
| 350 |
+
silently change the next TRAIN step's kernel, and a model left in eval()
|
| 351 |
+
would stay there. Both are restored in `__exit__`, including on error.
|
| 352 |
+
"""
|
| 353 |
+
|
| 354 |
+
def __init__(self, model):
|
| 355 |
+
self.model = model
|
| 356 |
+
|
| 357 |
+
def __enter__(self):
|
| 358 |
+
m = self.model
|
| 359 |
+
self.was_training = m.training
|
| 360 |
+
layers = getattr(getattr(m, "model", None), "layers", None)
|
| 361 |
+
self.backends = (
|
| 362 |
+
[(blk.attn, blk.attn.attn_implementation) for blk in layers] if layers else []
|
| 363 |
+
)
|
| 364 |
+
m.eval()
|
| 365 |
+
return m
|
| 366 |
+
|
| 367 |
+
def __exit__(self, *exc):
|
| 368 |
+
for attn, impl in self.backends:
|
| 369 |
+
attn.attn_implementation = impl
|
| 370 |
+
if self.was_training:
|
| 371 |
+
self.model.train()
|
| 372 |
+
return False
|
code/align/rollout/paged.py
ADDED
|
@@ -0,0 +1,494 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Paged KV cache for the native continuous-batching sampler (P3).
|
| 2 |
+
|
| 3 |
+
WHY PAGING (the failure mode this replaces). `align.inference.engine.Engine`
|
| 4 |
+
gives every slot a CONTIGUOUS `max_seq_len` strip of KV for the engine's whole
|
| 5 |
+
life. For a prover-RL rollout the length distribution is brutal: most
|
| 6 |
+
completions stop at a few thousand tokens, a few run to the 16k/40k cap. A
|
| 7 |
+
strip sized for the cap wastes ~all of it on the short ones, so concurrency is
|
| 8 |
+
`budget / cap` regardless of what is actually in flight. On the 23 GB A10G
|
| 9 |
+
that is the difference between "a handful of sequences" and "as many as the
|
| 10 |
+
live tokens allow" (numbers: `plan_capacity`, docs/P3_ENGINE.md). Paging cuts
|
| 11 |
+
the cache into fixed `block_size`-token blocks handed out on demand from a
|
| 12 |
+
free list; a sequence owns a *block table* (list of block ids) and the waste
|
| 13 |
+
is bounded by one partially filled block per sequence.
|
| 14 |
+
|
| 15 |
+
What lives here:
|
| 16 |
+
|
| 17 |
+
* `BlockAllocator` free-list allocator with refcounts. The refcount is what
|
| 18 |
+
makes prefix sharing safe: a block returns to the free
|
| 19 |
+
list only when its LAST owner releases it.
|
| 20 |
+
* `PagedKVCache` per-layer `[num_blocks, block_size, Hkv, D]` tensors +
|
| 21 |
+
block tables. Append is COPY-FREE: the new token's K/V is
|
| 22 |
+
written in place at (block, offset); nothing is moved or
|
| 23 |
+
re-concatenated when a sequence grows (the dense-cache
|
| 24 |
+
`torch.cat` path is O(L) per token per layer).
|
| 25 |
+
* `StepBatch` the flat, ragged description of one engine step.
|
| 26 |
+
* `TorchPagedAttention` the pure-torch attention backend (gather from the
|
| 27 |
+
block table, SDPA on the gathered K/V). Correctness
|
| 28 |
+
first. Three granularities, see its docstring.
|
| 29 |
+
* `TritonPagedAttention` INTERFACE ONLY (raises NotImplementedError): the
|
| 30 |
+
signature a fused kernel must satisfy, so the engine
|
| 31 |
+
does not change when the kernel lands.
|
| 32 |
+
* `kv_bytes_per_token`, `plan_capacity` the memory accounting.
|
| 33 |
+
|
| 34 |
+
Nothing here imports anything outside torch + stdlib (+ `align.model` for the
|
| 35 |
+
GQA map check), so the dependency firewall is untouched.
|
| 36 |
+
"""
|
| 37 |
+
|
| 38 |
+
from __future__ import annotations
|
| 39 |
+
|
| 40 |
+
import math
|
| 41 |
+
from dataclasses import dataclass, field
|
| 42 |
+
from typing import Optional, Protocol, Sequence
|
| 43 |
+
|
| 44 |
+
import torch
|
| 45 |
+
import torch.nn.functional as F
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class OutOfBlocks(RuntimeError):
|
| 49 |
+
"""The free list cannot satisfy an allocation. The engine catches exactly this
|
| 50 |
+
to decide on preemption; any other error is a bug and must propagate."""
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
# ---------------------------------------------------------------------------
|
| 54 |
+
# Allocator
|
| 55 |
+
# ---------------------------------------------------------------------------
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class BlockAllocator:
|
| 59 |
+
"""Free-list block allocator with reference counts.
|
| 60 |
+
|
| 61 |
+
LIFO free list: a just-freed block is the next one handed out, which keeps
|
| 62 |
+
the working set of block ids small and -- more usefully for tests --
|
| 63 |
+
deterministic, so a failing property run replays exactly.
|
| 64 |
+
|
| 65 |
+
Failure modes each check prevents:
|
| 66 |
+
* double free -> the block would sit on the free list TWICE and be handed
|
| 67 |
+
to two sequences at once; their KV would silently overwrite each other.
|
| 68 |
+
`free` raises on refcount 0.
|
| 69 |
+
* free of an out-of-range / never-issued id -> same corruption, one hop
|
| 70 |
+
later.
|
| 71 |
+
* leaks -> `check_consistent` proves `free + referenced == total` and that
|
| 72 |
+
the free list has no duplicates; the property test calls it after every
|
| 73 |
+
event.
|
| 74 |
+
"""
|
| 75 |
+
|
| 76 |
+
def __init__(self, num_blocks: int):
|
| 77 |
+
if num_blocks < 1:
|
| 78 |
+
raise ValueError(f"num_blocks={num_blocks} must be >= 1")
|
| 79 |
+
self.num_blocks = int(num_blocks)
|
| 80 |
+
# reversed so pop() yields block 0 first
|
| 81 |
+
self._free: list[int] = list(range(self.num_blocks - 1, -1, -1))
|
| 82 |
+
self._ref: list[int] = [0] * self.num_blocks
|
| 83 |
+
|
| 84 |
+
@property
|
| 85 |
+
def num_free(self) -> int:
|
| 86 |
+
return len(self._free)
|
| 87 |
+
|
| 88 |
+
@property
|
| 89 |
+
def num_used(self) -> int:
|
| 90 |
+
return self.num_blocks - len(self._free)
|
| 91 |
+
|
| 92 |
+
def refcount(self, block: int) -> int:
|
| 93 |
+
return self._ref[block]
|
| 94 |
+
|
| 95 |
+
def can_allocate(self, n: int) -> bool:
|
| 96 |
+
return n <= len(self._free)
|
| 97 |
+
|
| 98 |
+
def allocate(self) -> int:
|
| 99 |
+
if not self._free:
|
| 100 |
+
raise OutOfBlocks("no free KV blocks")
|
| 101 |
+
b = self._free.pop()
|
| 102 |
+
self._ref[b] = 1
|
| 103 |
+
return b
|
| 104 |
+
|
| 105 |
+
def incref(self, block: int) -> None:
|
| 106 |
+
if not (0 <= block < self.num_blocks) or self._ref[block] <= 0:
|
| 107 |
+
raise ValueError(f"incref of block {block} which is not allocated")
|
| 108 |
+
self._ref[block] += 1
|
| 109 |
+
|
| 110 |
+
def free(self, block: int) -> None:
|
| 111 |
+
if not (0 <= block < self.num_blocks):
|
| 112 |
+
raise ValueError(f"free of out-of-range block {block}")
|
| 113 |
+
if self._ref[block] <= 0:
|
| 114 |
+
raise ValueError(f"double free of block {block}")
|
| 115 |
+
self._ref[block] -= 1
|
| 116 |
+
if self._ref[block] == 0:
|
| 117 |
+
self._free.append(block)
|
| 118 |
+
|
| 119 |
+
def check_consistent(self) -> None:
|
| 120 |
+
"""Raise AssertionError unless the books balance. O(num_blocks)."""
|
| 121 |
+
free = self._free
|
| 122 |
+
assert len(set(free)) == len(free), "duplicate block on the free list"
|
| 123 |
+
for b in free:
|
| 124 |
+
assert self._ref[b] == 0, f"block {b} is free but has refcount {self._ref[b]}"
|
| 125 |
+
referenced = sum(1 for r in self._ref if r > 0)
|
| 126 |
+
assert referenced + len(free) == self.num_blocks, (
|
| 127 |
+
f"leak: {referenced} referenced + {len(free)} free != {self.num_blocks}"
|
| 128 |
+
)
|
| 129 |
+
assert all(r >= 0 for r in self._ref), "negative refcount"
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
# ---------------------------------------------------------------------------
|
| 133 |
+
# KV cache
|
| 134 |
+
# ---------------------------------------------------------------------------
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
class PagedKVCache:
|
| 138 |
+
"""Block-table KV cache. One K and one V tensor per layer, each
|
| 139 |
+
`[num_blocks, block_size, n_kv_heads, head_dim]`.
|
| 140 |
+
|
| 141 |
+
A sequence is `(block_table, length)`. Token `p` of a sequence lives at
|
| 142 |
+
block `table[p // block_size]`, offset `p % block_size`. Block 0 is NOT
|
| 143 |
+
reserved: nothing pads a table with a sentinel, because every reader slices
|
| 144 |
+
by `length` instead.
|
| 145 |
+
"""
|
| 146 |
+
|
| 147 |
+
def __init__(
|
| 148 |
+
self,
|
| 149 |
+
n_layers: int,
|
| 150 |
+
num_blocks: int,
|
| 151 |
+
block_size: int,
|
| 152 |
+
n_kv_heads: int,
|
| 153 |
+
head_dim: int,
|
| 154 |
+
dtype: torch.dtype = torch.float32,
|
| 155 |
+
device: Optional[torch.device | str] = None,
|
| 156 |
+
):
|
| 157 |
+
if block_size < 1:
|
| 158 |
+
raise ValueError(f"block_size={block_size} must be >= 1")
|
| 159 |
+
self.n_layers, self.block_size = n_layers, int(block_size)
|
| 160 |
+
self.n_kv_heads, self.head_dim = n_kv_heads, head_dim
|
| 161 |
+
self.dtype, self.device = dtype, torch.device(device) if device is not None else torch.device("cpu")
|
| 162 |
+
self.allocator = BlockAllocator(num_blocks)
|
| 163 |
+
shape = (num_blocks, self.block_size, n_kv_heads, head_dim)
|
| 164 |
+
self.k = [torch.zeros(shape, dtype=dtype, device=self.device) for _ in range(n_layers)]
|
| 165 |
+
self.v = [torch.zeros(shape, dtype=dtype, device=self.device) for _ in range(n_layers)]
|
| 166 |
+
self.tables: dict[str, list[int]] = {}
|
| 167 |
+
self.lengths: dict[str, int] = {}
|
| 168 |
+
|
| 169 |
+
# -- accounting -----------------------------------------------------------
|
| 170 |
+
|
| 171 |
+
@property
|
| 172 |
+
def num_blocks(self) -> int:
|
| 173 |
+
return self.allocator.num_blocks
|
| 174 |
+
|
| 175 |
+
def blocks_needed(self, n_tokens: int) -> int:
|
| 176 |
+
return -(-n_tokens // self.block_size)
|
| 177 |
+
|
| 178 |
+
def bytes_total(self) -> int:
|
| 179 |
+
itemsize = torch.empty((), dtype=self.dtype).element_size()
|
| 180 |
+
return self.n_layers * 2 * self.num_blocks * self.block_size * self.n_kv_heads * self.head_dim * itemsize
|
| 181 |
+
|
| 182 |
+
# -- sequence lifecycle ----------------------------------------------------
|
| 183 |
+
|
| 184 |
+
def add_seq(self, seq_id: str, shared_blocks: Sequence[int] = ()) -> None:
|
| 185 |
+
"""Register a sequence, optionally adopting FULL blocks of another one.
|
| 186 |
+
|
| 187 |
+
Only whole blocks can be shared: a partially filled tail block is still
|
| 188 |
+
being appended to by its owner, and a second writer (or even a reader
|
| 189 |
+
racing the owner's append) would see/clobber tokens that are not part
|
| 190 |
+
of the shared prefix. The shared blocks are incref'd, and the length
|
| 191 |
+
starts at the end of the last shared block.
|
| 192 |
+
"""
|
| 193 |
+
if seq_id in self.tables:
|
| 194 |
+
raise ValueError(f"sequence {seq_id!r} already registered")
|
| 195 |
+
for b in shared_blocks:
|
| 196 |
+
self.allocator.incref(b)
|
| 197 |
+
self.tables[seq_id] = list(shared_blocks)
|
| 198 |
+
self.lengths[seq_id] = len(shared_blocks) * self.block_size
|
| 199 |
+
|
| 200 |
+
def free_seq(self, seq_id: str) -> int:
|
| 201 |
+
"""Release every block of a sequence (shared blocks only drop a ref)."""
|
| 202 |
+
table = self.tables.pop(seq_id)
|
| 203 |
+
self.lengths.pop(seq_id)
|
| 204 |
+
for b in table:
|
| 205 |
+
self.allocator.free(b)
|
| 206 |
+
return len(table)
|
| 207 |
+
|
| 208 |
+
def ensure_capacity(self, seq_id: str, total_tokens: int) -> None:
|
| 209 |
+
"""Grow the table to hold `total_tokens`. ALL-OR-NOTHING: if the free
|
| 210 |
+
list cannot cover the whole growth nothing is allocated, so a failed
|
| 211 |
+
reservation never strands blocks on a sequence that is about to be
|
| 212 |
+
preempted (which would make the preemption free them again -- fine --
|
| 213 |
+
but a *skipped* sequence would hold them forever)."""
|
| 214 |
+
table = self.tables[seq_id]
|
| 215 |
+
need = self.blocks_needed(total_tokens) - len(table)
|
| 216 |
+
if need <= 0:
|
| 217 |
+
return
|
| 218 |
+
if not self.allocator.can_allocate(need):
|
| 219 |
+
raise OutOfBlocks(f"need {need} blocks for {seq_id!r}, {self.allocator.num_free} free")
|
| 220 |
+
for _ in range(need):
|
| 221 |
+
table.append(self.allocator.allocate())
|
| 222 |
+
|
| 223 |
+
def slots(self, seq_id: str, start: int, n: int) -> torch.Tensor:
|
| 224 |
+
"""Flat slot ids (block * block_size + offset) for positions [start, start+n).
|
| 225 |
+
|
| 226 |
+
Refuses to hand out a slot inside a block somebody else also references:
|
| 227 |
+
a write there would corrupt the sharer's KV with no error anywhere.
|
| 228 |
+
"""
|
| 229 |
+
table = self.tables[seq_id]
|
| 230 |
+
bs = self.block_size
|
| 231 |
+
if start + n > len(table) * bs:
|
| 232 |
+
raise ValueError(f"{seq_id!r}: positions up to {start + n} exceed reserved {len(table) * bs}")
|
| 233 |
+
pos = torch.arange(start, start + n)
|
| 234 |
+
blocks = torch.tensor(table, dtype=torch.long)[pos // bs]
|
| 235 |
+
for b in set(blocks.tolist()):
|
| 236 |
+
if self.allocator.refcount(b) != 1:
|
| 237 |
+
raise RuntimeError(
|
| 238 |
+
f"{seq_id!r}: write into shared block {b} (refcount {self.allocator.refcount(b)})"
|
| 239 |
+
)
|
| 240 |
+
return (blocks * bs + pos % bs).to(self.device)
|
| 241 |
+
|
| 242 |
+
# -- data movement ----------------------------------------------------------
|
| 243 |
+
|
| 244 |
+
def write(self, layer: int, slots: torch.Tensor, k: torch.Tensor, v: torch.Tensor) -> None:
|
| 245 |
+
"""In-place scatter of `k`,`v` [n, Hkv, D] at flat `slots`. No allocation,
|
| 246 |
+
no copy of existing KV (the 'copy-free append')."""
|
| 247 |
+
self.k[layer].view(-1, self.n_kv_heads, self.head_dim)[slots] = k.to(self.dtype)
|
| 248 |
+
self.v[layer].view(-1, self.n_kv_heads, self.head_dim)[slots] = v.to(self.dtype)
|
| 249 |
+
|
| 250 |
+
def gather(self, layer: int, seq_id: str, length: int) -> tuple[torch.Tensor, torch.Tensor]:
|
| 251 |
+
"""K, V of the first `length` tokens as contiguous `[1, Hkv, length, D]`.
|
| 252 |
+
|
| 253 |
+
Contiguity is deliberate: SDPA can pick different kernels / reduction
|
| 254 |
+
orders for strided inputs, and the batch-invariance guarantee needs the
|
| 255 |
+
SAME bytes in the SAME layout whatever else is in the batch."""
|
| 256 |
+
table = self.tables[seq_id]
|
| 257 |
+
idx = torch.tensor(table[: self.blocks_needed(length)], dtype=torch.long, device=self.device)
|
| 258 |
+
k = self.k[layer][idx].reshape(-1, self.n_kv_heads, self.head_dim)[:length]
|
| 259 |
+
v = self.v[layer][idx].reshape(-1, self.n_kv_heads, self.head_dim)[:length]
|
| 260 |
+
return (
|
| 261 |
+
k.permute(1, 0, 2).unsqueeze(0).contiguous(),
|
| 262 |
+
v.permute(1, 0, 2).unsqueeze(0).contiguous(),
|
| 263 |
+
)
|
| 264 |
+
|
| 265 |
+
def block_table_tensor(self, seq_ids: Sequence[str], pad: int = 0) -> torch.Tensor:
|
| 266 |
+
"""`[B, max_blocks]` int32 table (padded with `pad`; readers slice by
|
| 267 |
+
context length). This is the argument a fused kernel consumes."""
|
| 268 |
+
width = max((len(self.tables[s]) for s in seq_ids), default=0)
|
| 269 |
+
out = torch.full((len(seq_ids), max(width, 1)), pad, dtype=torch.int32)
|
| 270 |
+
for i, s in enumerate(seq_ids):
|
| 271 |
+
t = self.tables[s]
|
| 272 |
+
if t:
|
| 273 |
+
out[i, : len(t)] = torch.tensor(t, dtype=torch.int32)
|
| 274 |
+
return out.to(self.device)
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
# ---------------------------------------------------------------------------
|
| 278 |
+
# One engine step, flat and ragged
|
| 279 |
+
# ---------------------------------------------------------------------------
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
@dataclass
|
| 283 |
+
class Segment:
|
| 284 |
+
"""`n_new` consecutive tokens of one sequence in the flat batch.
|
| 285 |
+
|
| 286 |
+
`ctx` is the number of tokens of this sequence whose KV is ALREADY in the
|
| 287 |
+
cache before this step; the segment's token j sits at position `ctx + j`
|
| 288 |
+
and attends to positions `[0, ctx + j]`. A decode token is a segment with
|
| 289 |
+
n_new == 1; a prefill chunk is n_new > 1; they coexist in one step.
|
| 290 |
+
"""
|
| 291 |
+
|
| 292 |
+
seq_id: str
|
| 293 |
+
offset: int # row of the segment's first token in the flat batch
|
| 294 |
+
n_new: int
|
| 295 |
+
ctx: int
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
@dataclass
|
| 299 |
+
class StepBatch:
|
| 300 |
+
segments: list[Segment] = field(default_factory=list)
|
| 301 |
+
|
| 302 |
+
@property
|
| 303 |
+
def n_tokens(self) -> int:
|
| 304 |
+
return sum(s.n_new for s in self.segments)
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
# ---------------------------------------------------------------------------
|
| 308 |
+
# Attention backends
|
| 309 |
+
# ---------------------------------------------------------------------------
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
class PagedAttention(Protocol):
|
| 313 |
+
"""The seam between the engine and an attention implementation.
|
| 314 |
+
|
| 315 |
+
`q` is `[T, H, D]` (flat rows, RoPE already applied), `k_new`/`v_new` are
|
| 316 |
+
`[T, Hkv, D]` for the same rows. The backend must (1) write the new K/V into
|
| 317 |
+
`cache` for `layer` at the segments' positions, then (2) return the
|
| 318 |
+
attention output `[T, H, D]` where row r attends to the cache contents
|
| 319 |
+
`[0, position(r)]` of its own sequence. Splitting write from read inside the
|
| 320 |
+
backend lets a fused kernel do both in one launch.
|
| 321 |
+
"""
|
| 322 |
+
|
| 323 |
+
def __call__(
|
| 324 |
+
self, layer: int, q: torch.Tensor, k_new: torch.Tensor, v_new: torch.Tensor,
|
| 325 |
+
cache: PagedKVCache, batch: StepBatch, scale: float,
|
| 326 |
+
) -> torch.Tensor: ...
|
| 327 |
+
|
| 328 |
+
|
| 329 |
+
class TorchPagedAttention:
|
| 330 |
+
"""Pure-torch paged attention: gather K/V from the block table, run SDPA.
|
| 331 |
+
|
| 332 |
+
`mode`:
|
| 333 |
+
'row' one SDPA call per QUERY ROW, over exactly that row's keys
|
| 334 |
+
`[0, pos]`, no mask. The operands of every call depend only on
|
| 335 |
+
the sequence's own tokens, never on what else is in the step or
|
| 336 |
+
how the prompt was chunked, so the output bits are identical
|
| 337 |
+
across batch compositions and chunk schedules. Slow; it is the
|
| 338 |
+
correctness/invariance reference and the mode RL rollouts that
|
| 339 |
+
need bit-reproducible logprobs should use.
|
| 340 |
+
'segment' one SDPA per segment (a prefill chunk is one causal call with
|
| 341 |
+
an explicit bool mask; a decode token is mask-free) -- but all
|
| 342 |
+
decode segments of a step are *padded and batched* into one
|
| 343 |
+
call. Faster; the softmax/matmul reduction length now depends on
|
| 344 |
+
the longest sequence in the batch (padding) or the chunk size,
|
| 345 |
+
so results match 'row' to rounding only (see docs/P3_ENGINE.md
|
| 346 |
+
for the measured bf16 expectation).
|
| 347 |
+
"""
|
| 348 |
+
|
| 349 |
+
def __init__(self, mode: str = "row"):
|
| 350 |
+
if mode not in ("row", "segment"):
|
| 351 |
+
raise ValueError(f"mode={mode!r}; expected 'row' or 'segment'")
|
| 352 |
+
self.mode = mode
|
| 353 |
+
|
| 354 |
+
def __call__(self, layer, q, k_new, v_new, cache, batch, scale):
|
| 355 |
+
group = q.shape[1] // k_new.shape[1]
|
| 356 |
+
gqa = group > 1
|
| 357 |
+
out = torch.empty_like(q)
|
| 358 |
+
# 1) copy-free append of every segment's new K/V
|
| 359 |
+
for s in batch.segments:
|
| 360 |
+
sl = slice(s.offset, s.offset + s.n_new)
|
| 361 |
+
cache.write(layer, cache.slots(s.seq_id, s.ctx, s.n_new), k_new[sl], v_new[sl])
|
| 362 |
+
# 2) read
|
| 363 |
+
decode = [s for s in batch.segments if s.n_new == 1] if self.mode == "segment" else []
|
| 364 |
+
decode_ids = {id(s) for s in decode}
|
| 365 |
+
for s in batch.segments:
|
| 366 |
+
if id(s) in decode_ids:
|
| 367 |
+
continue
|
| 368 |
+
total = s.ctx + s.n_new
|
| 369 |
+
kk, vv = cache.gather(layer, s.seq_id, total)
|
| 370 |
+
if kk.dtype != q.dtype: # a cache stored narrower than the model (kv_dtype=bf16/fp8): SDPA
|
| 371 |
+
kk, vv = kk.to(q.dtype), vv.to(q.dtype) # insists on one dtype, so dequantise on read
|
| 372 |
+
if self.mode == "row":
|
| 373 |
+
for j in range(s.n_new):
|
| 374 |
+
ln = s.ctx + j + 1
|
| 375 |
+
qj = q[s.offset + j].view(1, q.shape[1], 1, q.shape[2])
|
| 376 |
+
o = F.scaled_dot_product_attention(
|
| 377 |
+
qj, kk[:, :, :ln].contiguous(), vv[:, :, :ln].contiguous(),
|
| 378 |
+
enable_gqa=gqa, scale=scale,
|
| 379 |
+
)
|
| 380 |
+
out[s.offset + j] = o[0, :, 0]
|
| 381 |
+
else:
|
| 382 |
+
qs = q[s.offset : s.offset + s.n_new].permute(1, 0, 2).unsqueeze(0)
|
| 383 |
+
qpos = torch.arange(s.ctx, total, device=q.device)[:, None]
|
| 384 |
+
keep = qpos >= torch.arange(total, device=q.device)[None, :]
|
| 385 |
+
o = F.scaled_dot_product_attention(qs, kk, vv, attn_mask=keep, enable_gqa=gqa, scale=scale)
|
| 386 |
+
out[s.offset : s.offset + s.n_new] = o[0].permute(1, 0, 2)
|
| 387 |
+
if decode:
|
| 388 |
+
lens = [s.ctx + 1 for s in decode]
|
| 389 |
+
lmax = max(lens)
|
| 390 |
+
kp = q.new_zeros(len(decode), k_new.shape[1], lmax, q.shape[2])
|
| 391 |
+
vp = torch.zeros_like(kp)
|
| 392 |
+
keep = torch.zeros(len(decode), 1, 1, lmax, dtype=torch.bool, device=q.device)
|
| 393 |
+
for i, s in enumerate(decode):
|
| 394 |
+
kk, vv = cache.gather(layer, s.seq_id, lens[i])
|
| 395 |
+
kp[i, :, : lens[i]] = kk[0]
|
| 396 |
+
vp[i, :, : lens[i]] = vv[0]
|
| 397 |
+
keep[i, ..., : lens[i]] = True
|
| 398 |
+
rows = torch.tensor([s.offset for s in decode], device=q.device)
|
| 399 |
+
qd = q[rows].unsqueeze(2) # [B, H, 1, D]
|
| 400 |
+
o = F.scaled_dot_product_attention(qd, kp, vp, attn_mask=keep, enable_gqa=gqa, scale=scale)
|
| 401 |
+
out[rows] = o[:, :, 0]
|
| 402 |
+
return out
|
| 403 |
+
|
| 404 |
+
|
| 405 |
+
class TritonPagedAttention:
|
| 406 |
+
"""INTERFACE ONLY. A later step replaces `TorchPagedAttention` with a fused
|
| 407 |
+
kernel; this class pins what that kernel receives so the engine is untouched.
|
| 408 |
+
|
| 409 |
+
Kernel contract (vLLM-style "paged attention v1/v2" over a ragged batch):
|
| 410 |
+
|
| 411 |
+
q [T, H, D] flat query rows (RoPE applied)
|
| 412 |
+
k_cache [NB, BS, Hkv, D] this layer's K blocks
|
| 413 |
+
v_cache [NB, BS, Hkv, D]
|
| 414 |
+
block_tables [B, max_blocks] i32 from `PagedKVCache.block_table_tensor`
|
| 415 |
+
cu_seqlens_q [B+1] i32 row offsets of each segment in q
|
| 416 |
+
ctx_lens [B] i32 tokens already cached BEFORE this step
|
| 417 |
+
scale float
|
| 418 |
+
out [T, H, D]
|
| 419 |
+
|
| 420 |
+
Row r of segment b (position ctx_lens[b] + j) attends to cached positions
|
| 421 |
+
[0, ctx_lens[b] + j]; the new K/V for the step must be written to the cache
|
| 422 |
+
BEFORE the launch (or fused in the same kernel via `slot_mapping`
|
| 423 |
+
[T] i64 = block * BS + offset). Bit-invariance requirement for RL use: the
|
| 424 |
+
reduction over keys for a given row must not depend on B, T or on other
|
| 425 |
+
segments (fixed KV-split count per context length, no atomics).
|
| 426 |
+
"""
|
| 427 |
+
|
| 428 |
+
def __call__(self, layer, q, k_new, v_new, cache, batch, scale): # pragma: no cover
|
| 429 |
+
raise NotImplementedError(
|
| 430 |
+
"TritonPagedAttention is an interface placeholder; use TorchPagedAttention. "
|
| 431 |
+
"See the class docstring for the kernel contract."
|
| 432 |
+
)
|
| 433 |
+
|
| 434 |
+
|
| 435 |
+
# ---------------------------------------------------------------------------
|
| 436 |
+
# Memory accounting
|
| 437 |
+
# ---------------------------------------------------------------------------
|
| 438 |
+
|
| 439 |
+
|
| 440 |
+
def kv_bytes_per_token(n_layers: int, n_kv_heads: int, head_dim: int, dtype: torch.dtype = torch.bfloat16,
|
| 441 |
+
tp_degree: int = 1) -> int:
|
| 442 |
+
"""Bytes of KV one token costs on ONE device: layers x 2 (K,V) x kv-heads/tp x head_dim x itemsize.
|
| 443 |
+
|
| 444 |
+
`tp_degree` shards the KV heads (vLLM's tensor parallelism does exactly this),
|
| 445 |
+
which is why a TP=4 probe's "tokens in cache" is not comparable to a
|
| 446 |
+
single-device engine's without it. Qwen3-8B (36, 8, 128, bf16) -> 147456 B
|
| 447 |
+
= 144 KiB = the '147 KB/token' figure.
|
| 448 |
+
"""
|
| 449 |
+
if n_kv_heads % tp_degree:
|
| 450 |
+
raise ValueError(f"n_kv_heads={n_kv_heads} not divisible by tp_degree={tp_degree}")
|
| 451 |
+
itemsize = torch.empty((), dtype=dtype).element_size()
|
| 452 |
+
return n_layers * 2 * (n_kv_heads // tp_degree) * head_dim * itemsize
|
| 453 |
+
|
| 454 |
+
|
| 455 |
+
@dataclass(frozen=True)
|
| 456 |
+
class CapacityPlan:
|
| 457 |
+
kv_bytes_per_token: int
|
| 458 |
+
kv_budget_bytes: int
|
| 459 |
+
num_blocks: int
|
| 460 |
+
block_size: int
|
| 461 |
+
kv_tokens: int
|
| 462 |
+
max_concurrent_at_cap: int # sequences that can each hold `cap` tokens simultaneously
|
| 463 |
+
cap: int
|
| 464 |
+
|
| 465 |
+
|
| 466 |
+
def plan_capacity(
|
| 467 |
+
device_total_bytes: int,
|
| 468 |
+
weights_bytes: int,
|
| 469 |
+
kv_bytes_per_tok: int,
|
| 470 |
+
*,
|
| 471 |
+
utilization: float = 0.9,
|
| 472 |
+
activation_bytes: int = 0,
|
| 473 |
+
other_bytes: int = 0,
|
| 474 |
+
block_size: int = 16,
|
| 475 |
+
cap: int = 16384,
|
| 476 |
+
) -> CapacityPlan:
|
| 477 |
+
"""How much KV fits, and how many `cap`-token sequences that sustains.
|
| 478 |
+
|
| 479 |
+
budget = utilization * device_total - weights - activation peak - other.
|
| 480 |
+
Paging rounds each sequence up to whole blocks, so `max_concurrent_at_cap`
|
| 481 |
+
= num_blocks // ceil(cap / block_size) (a dense per-slot strip would give
|
| 482 |
+
the same number for sequences that all reach the cap; paging's advantage is
|
| 483 |
+
for the ones that do not, which is why the engine also reports live usage).
|
| 484 |
+
"""
|
| 485 |
+
budget = int(utilization * device_total_bytes) - weights_bytes - activation_bytes - other_bytes
|
| 486 |
+
budget = max(budget, 0)
|
| 487 |
+
per_block = kv_bytes_per_tok * block_size
|
| 488 |
+
nb = budget // per_block
|
| 489 |
+
per_seq_blocks = math.ceil(cap / block_size)
|
| 490 |
+
return CapacityPlan(
|
| 491 |
+
kv_bytes_per_token=kv_bytes_per_tok, kv_budget_bytes=budget, num_blocks=int(nb),
|
| 492 |
+
block_size=block_size, kv_tokens=int(nb * block_size),
|
| 493 |
+
max_concurrent_at_cap=int(nb // per_seq_blocks), cap=cap,
|
| 494 |
+
)
|
code/align/rollout/protocol.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Request/result shapes for rollout, plus the file protocol the spool worker speaks.
|
| 2 |
+
|
| 3 |
+
DEPENDENCY-FREE ON PURPOSE: this module is imported by the training venv
|
| 4 |
+
(torch 2.11/cu128), by the native in-process backend (`native.py`), AND by the
|
| 5 |
+
optional vLLM worker in extras/vllm_rollout/ (its own venv, vllm/cu130); it
|
| 6 |
+
may import nothing beyond the standard library, or the environments' pin sets
|
| 7 |
+
start constraining each other -- the exact coupling the isolated-venv
|
| 8 |
+
design exists to prevent.
|
| 9 |
+
|
| 10 |
+
`RolloutRequest` / `RolloutResult` are the backend-neutral contract. The
|
| 11 |
+
default backend (`native.NativeRolloutBackend`) fills them in-process with no
|
| 12 |
+
file I/O; the 'spool' backend moves the SAME dataclasses through the files
|
| 13 |
+
described below. The trainer cannot tell which one served it, by design.
|
| 14 |
+
|
| 15 |
+
Mechanics: one JSON file per message, written atomically (.partial ->
|
| 16 |
+
os.replace) into a spool directory. The worker polls `requests/`, writes
|
| 17 |
+
into `results/`, and touches `heartbeat` every poll so the trainer can
|
| 18 |
+
distinguish "generating" from "dead" (mtime-based liveness, per the house
|
| 19 |
+
monitor-mtime-not-PIDs rule). Completions travel as token id lists plus
|
| 20 |
+
the engine's per-token logprobs -- the trainer needs vllm_logprobs for the
|
| 21 |
+
rho correction and must NOT re-derive them from text (re-tokenization is
|
| 22 |
+
not identity).
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
import json
|
| 28 |
+
import os
|
| 29 |
+
import time
|
| 30 |
+
from dataclasses import asdict, dataclass, field
|
| 31 |
+
from typing import Optional, Protocol, runtime_checkable
|
| 32 |
+
|
| 33 |
+
PROTOCOL_VERSION = 1
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@dataclass
|
| 37 |
+
class RolloutRequest:
|
| 38 |
+
request_id: str
|
| 39 |
+
weights_dir: str # HF-loadable checkpoint dir; worker reloads on change
|
| 40 |
+
prompts: list[str]
|
| 41 |
+
n_samples_per_prompt: int
|
| 42 |
+
max_tokens: int
|
| 43 |
+
temperature: float = 1.0
|
| 44 |
+
top_p: float = 1.0
|
| 45 |
+
seed: Optional[int] = None
|
| 46 |
+
protocol_version: int = PROTOCOL_VERSION
|
| 47 |
+
|
| 48 |
+
def validate(self, *, check_weights_dir: bool = True) -> None:
|
| 49 |
+
"""Shape checks always; the weights_dir existence check only for
|
| 50 |
+
backends that LOAD from it. The native backend samples from a live
|
| 51 |
+
in-process model, where weights_dir is provenance, not an input -- a
|
| 52 |
+
step-0 request legitimately names a checkpoint that has not been
|
| 53 |
+
written yet."""
|
| 54 |
+
if not self.prompts:
|
| 55 |
+
raise ValueError("empty prompt list")
|
| 56 |
+
if self.n_samples_per_prompt < 1 or self.max_tokens < 1:
|
| 57 |
+
raise ValueError(
|
| 58 |
+
f"bad shape: n={self.n_samples_per_prompt}, max_tokens={self.max_tokens}"
|
| 59 |
+
)
|
| 60 |
+
if check_weights_dir and not os.path.isdir(self.weights_dir):
|
| 61 |
+
raise FileNotFoundError(f"weights_dir does not exist: {self.weights_dir}")
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
@dataclass
|
| 65 |
+
class RolloutResult:
|
| 66 |
+
"""One request's samples, in the form `align.rl.assembly` consumes.
|
| 67 |
+
|
| 68 |
+
Whatever the backend, the contract the trainer relies on is:
|
| 69 |
+
logprobs[i][j][t] is the logprob of completions[i][j][t] under the
|
| 70 |
+
SAMPLING-TEMPERATURE-scaled, untruncated policy distribution, so
|
| 71 |
+
rho = exp(old - infer) is ~1 when the weights agree. And
|
| 72 |
+
`extras["prompt_token_ids"]` (one id list per prompt, request order) is
|
| 73 |
+
REQUIRED: completions continue from exactly those ids, and assembly
|
| 74 |
+
refuses to re-tokenize prompt text.
|
| 75 |
+
"""
|
| 76 |
+
|
| 77 |
+
request_id: str
|
| 78 |
+
#: completions[i][j] = j-th sample for prompt i: token ids WITHOUT the prompt.
|
| 79 |
+
completions: list[list[list[int]]]
|
| 80 |
+
#: logprobs[i][j][t] = engine logprob of completions[i][j][t] (the rho
|
| 81 |
+
#: correction's pi^infer_old side; label-aligned with the completion ids).
|
| 82 |
+
logprobs: list[list[list[float]]]
|
| 83 |
+
#: decoded completion texts, same nesting -- the reward path consumes text
|
| 84 |
+
#: and must see EXACTLY what the engine's tokenizer decodes, not a re-decode.
|
| 85 |
+
texts: list[list[str]]
|
| 86 |
+
weights_dir: str
|
| 87 |
+
engine_reloaded: bool
|
| 88 |
+
generate_seconds: float
|
| 89 |
+
error: Optional[str] = None
|
| 90 |
+
protocol_version: int = PROTOCOL_VERSION
|
| 91 |
+
extras: dict = field(default_factory=dict)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
@runtime_checkable
|
| 95 |
+
class RolloutBackend(Protocol):
|
| 96 |
+
"""What `GrpoBatchSource` consumes: submit a request, collect its result.
|
| 97 |
+
|
| 98 |
+
Implemented by `native.NativeRolloutBackend` (in-process, synchronous: the
|
| 99 |
+
work happens in `submit`) and `client.RolloutClient` (file spool to an
|
| 100 |
+
external worker: the work happens between `submit` and `collect`).
|
| 101 |
+
"""
|
| 102 |
+
|
| 103 |
+
def submit(self, request: RolloutRequest) -> str: ...
|
| 104 |
+
|
| 105 |
+
def collect(self, request_id: str) -> RolloutResult: ...
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def _atomic_write_json(payload: dict, path: str) -> None:
|
| 109 |
+
tmp = path + ".partial"
|
| 110 |
+
with open(tmp, "w") as f:
|
| 111 |
+
json.dump(payload, f)
|
| 112 |
+
os.replace(tmp, path)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def spool_dirs(spool: str) -> tuple[str, str]:
|
| 116 |
+
req = os.path.join(spool, "requests")
|
| 117 |
+
res = os.path.join(spool, "results")
|
| 118 |
+
os.makedirs(req, exist_ok=True)
|
| 119 |
+
os.makedirs(res, exist_ok=True)
|
| 120 |
+
return req, res
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def write_request(spool: str, request: RolloutRequest) -> str:
|
| 124 |
+
request.validate()
|
| 125 |
+
req_dir, _ = spool_dirs(spool)
|
| 126 |
+
path = os.path.join(req_dir, f"{request.request_id}.json")
|
| 127 |
+
if os.path.exists(path):
|
| 128 |
+
raise FileExistsError(f"duplicate request_id {request.request_id!r}")
|
| 129 |
+
_atomic_write_json(asdict(request), path)
|
| 130 |
+
return path
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def read_request(path: str) -> RolloutRequest:
|
| 134 |
+
with open(path) as f:
|
| 135 |
+
payload = json.load(f)
|
| 136 |
+
if payload.get("protocol_version") != PROTOCOL_VERSION:
|
| 137 |
+
raise RuntimeError(
|
| 138 |
+
f"protocol version mismatch: request carries "
|
| 139 |
+
f"{payload.get('protocol_version')!r}, this side speaks {PROTOCOL_VERSION}"
|
| 140 |
+
)
|
| 141 |
+
return RolloutRequest(**payload)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def write_result(spool: str, result: RolloutResult) -> str:
|
| 145 |
+
_, res_dir = spool_dirs(spool)
|
| 146 |
+
path = os.path.join(res_dir, f"{result.request_id}.json")
|
| 147 |
+
_atomic_write_json(asdict(result), path)
|
| 148 |
+
return path
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def read_result(spool: str, request_id: str) -> Optional[RolloutResult]:
|
| 152 |
+
"""Non-blocking: None until the result file exists (atomically complete)."""
|
| 153 |
+
_, res_dir = spool_dirs(spool)
|
| 154 |
+
path = os.path.join(res_dir, f"{request_id}.json")
|
| 155 |
+
if not os.path.exists(path):
|
| 156 |
+
return None
|
| 157 |
+
with open(path) as f:
|
| 158 |
+
payload = json.load(f)
|
| 159 |
+
if payload.get("protocol_version") != PROTOCOL_VERSION:
|
| 160 |
+
raise RuntimeError(
|
| 161 |
+
f"protocol version mismatch on result {request_id!r}: "
|
| 162 |
+
f"{payload.get('protocol_version')!r} vs {PROTOCOL_VERSION}"
|
| 163 |
+
)
|
| 164 |
+
return RolloutResult(**payload)
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def touch_heartbeat(spool: str) -> None:
|
| 168 |
+
path = os.path.join(spool, "heartbeat")
|
| 169 |
+
with open(path, "a"):
|
| 170 |
+
os.utime(path, None)
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def heartbeat_age_seconds(spool: str) -> float:
|
| 174 |
+
"""Inf when no heartbeat exists yet (worker never started != worker slow)."""
|
| 175 |
+
path = os.path.join(spool, "heartbeat")
|
| 176 |
+
if not os.path.exists(path):
|
| 177 |
+
return float("inf")
|
| 178 |
+
return time.time() - os.path.getmtime(path)
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def wait_for_result(
|
| 182 |
+
spool: str,
|
| 183 |
+
request_id: str,
|
| 184 |
+
timeout_s: float,
|
| 185 |
+
stale_heartbeat_s: float = 600.0,
|
| 186 |
+
poll_s: float = 2.0,
|
| 187 |
+
startup_grace_s: float = 300.0,
|
| 188 |
+
) -> RolloutResult:
|
| 189 |
+
"""Block until the result lands; raise on timeout, dead worker, or error.
|
| 190 |
+
|
| 191 |
+
THREE distinct failure signatures, kept distinct on purpose: a TIMEOUT
|
| 192 |
+
with a live heartbeat means the request is too big for the budget; a
|
| 193 |
+
STALE heartbeat means the worker died mid-service; and NO heartbeat at
|
| 194 |
+
all means the worker has not finished booting -- which is fatal only
|
| 195 |
+
after `startup_grace_s`, measured from this wait's start. The first
|
| 196 |
+
smoke run (2026-08-24) found the missing third case the hard way: a
|
| 197 |
+
step-0 canary raced the worker's ~30s vLLM import, age=inf compared
|
| 198 |
+
`> stale_heartbeat_s` as instantly dead, and the teardown killed a
|
| 199 |
+
perfectly healthy worker before its first log line flushed.
|
| 200 |
+
"""
|
| 201 |
+
start = time.time()
|
| 202 |
+
while True:
|
| 203 |
+
result = read_result(spool, request_id)
|
| 204 |
+
if result is not None:
|
| 205 |
+
if result.error:
|
| 206 |
+
raise RuntimeError(f"rollout worker reported error: {result.error}")
|
| 207 |
+
return result
|
| 208 |
+
age = heartbeat_age_seconds(spool)
|
| 209 |
+
if age == float("inf"):
|
| 210 |
+
if time.time() - start > startup_grace_s:
|
| 211 |
+
raise RuntimeError(
|
| 212 |
+
f"rollout worker produced NO heartbeat within {startup_grace_s:.0f}s "
|
| 213 |
+
f"of waiting on request {request_id!r}: it never started. Check its "
|
| 214 |
+
"log (spawn env, venv, import errors)."
|
| 215 |
+
)
|
| 216 |
+
elif age > stale_heartbeat_s:
|
| 217 |
+
raise RuntimeError(
|
| 218 |
+
f"rollout worker heartbeat is {age:.0f}s stale "
|
| 219 |
+
f"(> {stale_heartbeat_s:.0f}s) while request {request_id!r} is pending: "
|
| 220 |
+
"worker presumed dead. Check its log; do not resubmit blindly."
|
| 221 |
+
)
|
| 222 |
+
if time.time() - start > timeout_s:
|
| 223 |
+
raise TimeoutError(
|
| 224 |
+
f"rollout request {request_id!r} exceeded {timeout_s:.0f}s with a live "
|
| 225 |
+
"worker: the request shape is too big for the time budget."
|
| 226 |
+
)
|
| 227 |
+
time.sleep(poll_s)
|
code/align/rollout/tp_engine.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tensor-parallel driver for `PagedEngine` (P3, opt-in): same scheduler, same sampler, a TP-sharded model.
|
| 2 |
+
|
| 3 |
+
WHAT CHANGES vs the single-device engine, and what deliberately does not.
|
| 4 |
+
|
| 5 |
+
* The model is `align.model.tp`'s shard. `PagedEngine` drives it UNCHANGED through its own submodules, so the
|
| 6 |
+
paged KV cache is sharded by kv head for free: `_ModelView` reads the LOCAL `n_kv_heads` (Qwen3-8B at tp=4:
|
| 7 |
+
8 -> 2) and sizes `PagedKVCache` with it. Per-rank KV bytes are exactly 1/tp (tested), the block tables,
|
| 8 |
+
refcounts, prefix sharing and preemption logic are the original code, and attention is purely local (a rank's
|
| 9 |
+
q heads read only kv heads it owns, see `align.model.tp`).
|
| 10 |
+
|
| 11 |
+
* REPLICATED SCHEDULER, NOT A BROADCAST ONE. The task offered "scheduler on rank 0 broadcasting decisions".
|
| 12 |
+
Chosen instead: every rank runs the identical deterministic scheduler on identical inputs, and rank 0 is
|
| 13 |
+
authoritative for the INPUTS (the request stream, broadcast once by `generate`) and the VERIFIER of the
|
| 14 |
+
replication. Why:
|
| 15 |
+
- Broadcasting decisions means shipping a ragged object (segments, block tables, the sampled-row list) every
|
| 16 |
+
step: a pickle + host sync on the critical path, per step, for information every rank can recompute for
|
| 17 |
+
free from state it already holds.
|
| 18 |
+
- The scheduler is a pure function of (request stream, sampled tokens). Sampled tokens are identical on
|
| 19 |
+
every rank because the logits are: the lm_head ALL-GATHER hands every rank the same bytes, and sampling
|
| 20 |
+
(per-sequence seeded generators, `_stable_seed` = sha256 not `hash()`) is a deterministic function of the
|
| 21 |
+
logits row. So replicated sampling needs no extra collective; rank-0-only sampling would need a token
|
| 22 |
+
broadcast between every forward and the next (a serial hop) PLUS the full-logit row on rank 0 anyway.
|
| 23 |
+
- The one real risk of replication is SILENT DIVERGENCE (a single differing token or schedule makes ranks
|
| 24 |
+
issue different collectives: an NCCL hang for the timeout, or all-reduces of mismatched shapes). That is
|
| 25 |
+
closed by two tiny consensus all-gathers per step (8 bytes/rank each, vs 2*n_layers+1 all-reduces of
|
| 26 |
+
[rows, hidden]): `_schedule` checks a fingerprint of the step's schedule BEFORE any model collective, and
|
| 27 |
+
`has_unfinished` checks the previous step's events (tokens, finish reasons) and the loop
|
| 28 |
+
predicate. The check is SYMMETRIC (all-gather, every rank sees every fingerprint), so a divergence raises
|
| 29 |
+
`TPDesyncError` on ALL ranks instead of leaving the agreeing ones blocked in the next collective.
|
| 30 |
+
|
| 31 |
+
* Residual rounding. Under bf16 the row-parallel all-reduce sums per-rank partials each rounded to bf16, so TP
|
| 32 |
+
logits differ from the unsharded model's by bf16 rounding (measured in docs/P3_ENGINE.md); in fp64 they agree
|
| 33 |
+
to ~1e-15. `numerics='invariant'` keeps its meaning under TP: row-wise calls keep every all-reduce the same
|
| 34 |
+
shape whatever the batch holds, so a sequence's bits do not depend on its companions (tested bitwise).
|
| 35 |
+
|
| 36 |
+
Every rank must call the same methods with the same arguments in the same order (`add_request`, `step`, `generate`);
|
| 37 |
+
`generate` takes its requests from rank 0 and broadcasts them, so only rank 0 needs the real list.
|
| 38 |
+
"""
|
| 39 |
+
|
| 40 |
+
from __future__ import annotations
|
| 41 |
+
|
| 42 |
+
import hashlib
|
| 43 |
+
import struct
|
| 44 |
+
from typing import Optional, Sequence
|
| 45 |
+
|
| 46 |
+
import torch
|
| 47 |
+
|
| 48 |
+
from align.model.tp import TPGroup
|
| 49 |
+
from align.rollout.engine import PagedEngine, PagedEngineConfig, PagedResult, SamplingParams
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class TPDesyncError(RuntimeError):
|
| 53 |
+
"""Ranks disagree about the schedule, the sampled tokens or whether work remains. Raised on EVERY rank."""
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class _FullVocabEmbed:
|
| 57 |
+
"""What `PagedEngine.add_request` sees as the embedding: a `.weight` whose shape[0] is the FULL vocabulary
|
| 58 |
+
(the real weight is this rank's shard, whose row count would reject every valid id >= V/tp), and the sharded
|
| 59 |
+
lookup as the call. The meta tensor allocates nothing."""
|
| 60 |
+
|
| 61 |
+
def __init__(self, emb):
|
| 62 |
+
self._emb = emb
|
| 63 |
+
self.weight = torch.empty((emb.full_vocab, emb.weight.shape[1]), device="meta")
|
| 64 |
+
|
| 65 |
+
def __call__(self, ids):
|
| 66 |
+
return self._emb(ids)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _digest(parts: Sequence[bytes]) -> int:
|
| 70 |
+
h = hashlib.sha256()
|
| 71 |
+
for p in parts:
|
| 72 |
+
h.update(struct.pack("<Q", len(p)))
|
| 73 |
+
h.update(p)
|
| 74 |
+
return int.from_bytes(h.digest()[:8], "little") & ((1 << 62) - 1)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def schedule_fingerprint(sched) -> int:
|
| 78 |
+
"""Hash of everything that decides the step's collectives and shapes: for each scheduled sequence its id, how
|
| 79 |
+
many tokens it already has computed, how many it adds, and the token ids it feeds. Module-level so a test can
|
| 80 |
+
substitute a constant (divergence then goes undetected until a collective hangs)."""
|
| 81 |
+
parts = []
|
| 82 |
+
for seq, n in sched:
|
| 83 |
+
parts.append(f"{seq.rid}|{seq.num_computed}|{n}".encode())
|
| 84 |
+
parts.append(struct.pack(f"<{n}q", *seq.tokens[seq.num_computed : seq.num_computed + n]))
|
| 85 |
+
return _digest(parts)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def events_fingerprint(events) -> int:
|
| 89 |
+
"""Hash of a step's sampled events: request id, token and finish reason. (Logprobs need no hash of their own: they are
|
| 90 |
+
a deterministic function of the all-gathered logits row and the token, both already agreed.)"""
|
| 91 |
+
return _digest([f"{rid}|{tok}|{reason}".encode() for rid, tok, reason in events])
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
class TPPagedEngine(PagedEngine):
|
| 95 |
+
"""`PagedEngine` over a `align.model.tp` shard. `verify=False` drops the two consensus all-gathers (a
|
| 96 |
+
measured-cost opt-out for a job that has proven itself; the default keeps the hang-proofing)."""
|
| 97 |
+
|
| 98 |
+
def __init__(self, model: torch.nn.Module, tp: Optional[TPGroup] = None, cfg: Optional[PagedEngineConfig] = None, *,
|
| 99 |
+
tokenizer=None, return_logits: bool = False, verify: bool = True):
|
| 100 |
+
tp = tp if tp is not None else getattr(model, "tp", None)
|
| 101 |
+
if tp is None:
|
| 102 |
+
raise ValueError("TPPagedEngine needs a TPGroup (or a model built by align.model.tp, which carries one)")
|
| 103 |
+
super().__init__(model, cfg, tokenizer=tokenizer, return_logits=return_logits)
|
| 104 |
+
self.tp, self.verify = tp, verify and tp.world_size > 1
|
| 105 |
+
emb = self.view.embed
|
| 106 |
+
if not hasattr(emb, "full_vocab"):
|
| 107 |
+
raise TypeError("model is not a tensor-parallel shard (embed_tokens has no `full_vocab`)")
|
| 108 |
+
emb.check_range = False # add_request already validates ids; skip the per-step device sync
|
| 109 |
+
self.view.embed = _FullVocabEmbed(emb)
|
| 110 |
+
self._last_events = 0
|
| 111 |
+
self.stats.update(consensus_checks=0)
|
| 112 |
+
|
| 113 |
+
# -- replication guards ----------------------------------------------------------
|
| 114 |
+
|
| 115 |
+
def _agree(self, label: str, values: list[int]) -> None:
|
| 116 |
+
got = self.tp.all_gather_i64(values, self.device)
|
| 117 |
+
self.stats["consensus_checks"] += 1
|
| 118 |
+
if any(g != got[0] for g in got):
|
| 119 |
+
raise TPDesyncError(
|
| 120 |
+
f"tensor-parallel ranks diverged at {label}: per-rank fingerprints {got} (this is rank {self.tp.rank}). "
|
| 121 |
+
"Every rank must see the same requests in the same order with the same config."
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
def has_unfinished(self) -> bool:
|
| 125 |
+
local = super().has_unfinished()
|
| 126 |
+
if self.verify:
|
| 127 |
+
self._agree("loop predicate / previous step's events", [int(local), self._last_events])
|
| 128 |
+
return local
|
| 129 |
+
|
| 130 |
+
def _schedule(self):
|
| 131 |
+
sched = super()._schedule()
|
| 132 |
+
if self.verify:
|
| 133 |
+
self._agree("the step schedule", [schedule_fingerprint(sched)])
|
| 134 |
+
return sched
|
| 135 |
+
|
| 136 |
+
def step(self):
|
| 137 |
+
events = super().step()
|
| 138 |
+
self._last_events = events_fingerprint(events)
|
| 139 |
+
return events
|
| 140 |
+
|
| 141 |
+
# -- driver ---------------------------------------------------------------------------
|
| 142 |
+
|
| 143 |
+
def generate(self, requests=None, arrival_step: Optional[dict[str, int]] = None, max_steps: int = 1_000_000
|
| 144 |
+
) -> dict[str, PagedResult]:
|
| 145 |
+
"""As `PagedEngine.generate`, but rank 0's `requests`/`arrival_step` are broadcast to every rank (other
|
| 146 |
+
ranks may pass None): the request stream has exactly one author."""
|
| 147 |
+
payload = None
|
| 148 |
+
if self.tp.rank == 0:
|
| 149 |
+
# Built BEFORE the broadcast, so a malformed stream would raise on rank 0 alone and leave every other
|
| 150 |
+
# rank blocked in the broadcast until the process-group timeout (minutes to hours on NCCL). The error
|
| 151 |
+
# is therefore shipped as data and re-raised everywhere.
|
| 152 |
+
try:
|
| 153 |
+
payload = ("ok", ([(rid, list(ids), p) for rid, ids, p in requests], dict(arrival_step or {})))
|
| 154 |
+
except Exception as e: # noqa: BLE001
|
| 155 |
+
payload = ("err", f"{type(e).__name__}: {e}")
|
| 156 |
+
tag, body = self.tp.broadcast_object(payload)
|
| 157 |
+
if tag == "err":
|
| 158 |
+
raise ValueError(f"rank 0 could not build the request stream: {body}")
|
| 159 |
+
reqs, arrivals = body
|
| 160 |
+
reqs = [(rid, ids, p if isinstance(p, SamplingParams) else SamplingParams(**p)) for rid, ids, p in reqs]
|
| 161 |
+
return super().generate(reqs, arrivals, max_steps)
|
| 162 |
+
|
| 163 |
+
# -- accounting --------------------------------------------------------------------------
|
| 164 |
+
|
| 165 |
+
def kv_bytes_per_rank(self) -> int:
|
| 166 |
+
return self.cache.bytes_total()
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
__all__ = ["TPPagedEngine", "TPDesyncError", "schedule_fingerprint", "events_fingerprint"]
|
code/align/tokenizer/__init__.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""align.tokenizer -- pure-Python (stdlib-only) Mistral-7B-v0.3 BPE tokenizer.
|
| 2 |
+
|
| 3 |
+
from align.tokenizer import Tokenizer, load_tokenizer
|
| 4 |
+
tok = load_tokenizer("/path/to/dir/with/tokenizer.json")
|
| 5 |
+
tok("a\\nb")["input_ids"] # [1, 1032, <0x0A id>, 29494] (<s> ▁a <0x0A> b)
|
| 6 |
+
tok.decode(ids, skip_special_tokens=True)
|
| 7 |
+
|
| 8 |
+
It replaces `transformers.AutoTokenizer` for this codebase (dependency policy:
|
| 9 |
+
torch/triton/numpy/safetensors only). Parity is certified against the legacy
|
| 10 |
+
transformers tokenizer by the `oracle`-marked test in tests/test_tokenizer.py.
|
| 11 |
+
|
| 12 |
+
Two places pure reimplementations usually get wrong -- both are load-bearing:
|
| 13 |
+
|
| 14 |
+
1. Metaspace prepend_scheme="first".
|
| 15 |
+
The pre-tokenizer replaces ' ' with U+2581 and prepends U+2581 to split #0 of
|
| 16 |
+
the whole input -- and only if that split is NOT an added token. Added tokens
|
| 17 |
+
(<s>, [INST], [control_N], ...) are carved out of the raw text before the
|
| 18 |
+
pre-tokenizer runs; they occupy a split index but skip the pre-tokenizer, so
|
| 19 |
+
"first" is neither "first character of the string" nor "first segment after
|
| 20 |
+
each special token" nor "first non-added segment":
|
| 21 |
+
"hello<s>world" -> ▁hello <s> world (prefix on split 0 only)
|
| 22 |
+
"<s>hello" -> <s> <s> hello (bare 'hello': the leading <s> used
|
| 23 |
+
up split 0 -- measured, not guessed)
|
| 24 |
+
" hello" -> ▁hello (already starts with U+2581: no
|
| 25 |
+
second prefix)
|
| 26 |
+
With split=false the whole non-added segment is a single BPE "word", so
|
| 27 |
+
merges can run across spaces and newlines ('\\n' is not touched by Metaspace;
|
| 28 |
+
it has no vocab piece and falls back to the byte token <0x0A>, the id the vocab assigns it).
|
| 29 |
+
|
| 30 |
+
2. Strip(" ", start=1) at the END of the decoder chain.
|
| 31 |
+
decode = Replace(U+2581 -> ' ') / ByteFallback / Fuse / Strip. Strip removes
|
| 32 |
+
at most ONE leading space from the FUSED string. Consequences: decode of the
|
| 33 |
+
ids for "hello" is "hello" (the prepended ▁ is stripped, round-trip exact),
|
| 34 |
+
but decoding a mid-sequence chunk [▁world] gives "world", not " world" -- so
|
| 35 |
+
decoding token deltas and concatenating drops spaces. Decode the whole
|
| 36 |
+
sequence and diff the TEXT (the inference engine does exactly that). Also:
|
| 37 |
+
ByteFallback decodes each maximal run of <0xNN> pieces as one UTF-8 unit, and
|
| 38 |
+
an invalid run yields one U+FFFD per byte (not Python's errors="replace").
|
| 39 |
+
|
| 40 |
+
The loader refuses (NotImplementedError) any tokenizer.json whose pipeline
|
| 41 |
+
differs from the one above, rather than silently producing wrong ids.
|
| 42 |
+
|
| 43 |
+
Byte-level BPE (Qwen2/Qwen3, e.g. Goedel-Prover-V2-8B) lives in `.bytelevel`
|
| 44 |
+
(`ByteLevelTokenizer`); see that module's docstring. `load_tokenizer` dispatches on
|
| 45 |
+
tokenizer.json (model.type + pre_tokenizer): Metaspace -> the Mistral `Tokenizer`
|
| 46 |
+
above, completely unchanged; Split+ByteLevel -> `ByteLevelTokenizer`; anything else
|
| 47 |
+
-> NotImplementedError (never a guess).
|
| 48 |
+
"""
|
| 49 |
+
from __future__ import annotations
|
| 50 |
+
|
| 51 |
+
import json
|
| 52 |
+
import os
|
| 53 |
+
from typing import Union
|
| 54 |
+
|
| 55 |
+
from .bytelevel import ByteLevelTokenizer
|
| 56 |
+
from .core import Tokenizer, _resolve_dir
|
| 57 |
+
|
| 58 |
+
__all__ = ["Tokenizer", "ByteLevelTokenizer", "load_tokenizer"]
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _pre_tokenizer_kinds(pt) -> set:
|
| 62 |
+
if not pt:
|
| 63 |
+
return set()
|
| 64 |
+
if pt.get("type") == "Sequence":
|
| 65 |
+
return {p.get("type") for p in pt.get("pretokenizers", [])}
|
| 66 |
+
return {pt.get("type")}
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def load_tokenizer(path: Union[str, os.PathLike]):
|
| 70 |
+
"""Local-only loader. Mistral (Metaspace) -> Tokenizer; Qwen (ByteLevel) ->
|
| 71 |
+
ByteLevelTokenizer. The choice is made from tokenizer.json, never from the
|
| 72 |
+
directory name, and every other family fails loudly instead of being fed to a
|
| 73 |
+
pipeline that would encode it wrong."""
|
| 74 |
+
d = _resolve_dir(os.fspath(path))
|
| 75 |
+
with open(os.path.join(d, "tokenizer.json"), "rb") as f:
|
| 76 |
+
tj = json.loads(f.read())
|
| 77 |
+
kinds = _pre_tokenizer_kinds(tj.get("pre_tokenizer"))
|
| 78 |
+
if "ByteLevel" in kinds:
|
| 79 |
+
return ByteLevelTokenizer.from_pretrained(d)
|
| 80 |
+
if kinds == {"Metaspace"}:
|
| 81 |
+
return Tokenizer.from_pretrained(d)
|
| 82 |
+
raise NotImplementedError(
|
| 83 |
+
f"no tokenizer implementation for pre_tokenizer {sorted(k for k in kinds if k)!r} "
|
| 84 |
+
f"in {d!r}; supported: Metaspace (Mistral), Split+ByteLevel (Qwen2/3)"
|
| 85 |
+
)
|
code/align/tokenizer/_qwen2_unicode.py
ADDED
|
@@ -0,0 +1,875 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""GENERATED by tests/data/make_qwen2_unicode_tables.py -- do not edit.
|
| 2 |
+
|
| 3 |
+
Probed from transformers 5.19.0 / tokenizers 0.23.3 against Python unicodedata 15.0.0.
|
| 4 |
+
See that script for why these are frozen.
|
| 5 |
+
"""
|
| 6 |
+
PYTHON_UNIDATA_VERSION = '15.0.0' # the unicodedata these deltas were measured against
|
| 7 |
+
|
| 8 |
+
#: \p{L} / \p{N} exactly as the Rust regex engine sees them (inclusive ranges).
|
| 9 |
+
L_RANGES = (
|
| 10 |
+
(0x41, 0x5a),
|
| 11 |
+
(0x61, 0x7a),
|
| 12 |
+
(0xaa, 0xaa),
|
| 13 |
+
(0xb5, 0xb5),
|
| 14 |
+
(0xba, 0xba),
|
| 15 |
+
(0xc0, 0xd6),
|
| 16 |
+
(0xd8, 0xf6),
|
| 17 |
+
(0xf8, 0x2c1),
|
| 18 |
+
(0x2c6, 0x2d1),
|
| 19 |
+
(0x2e0, 0x2e4),
|
| 20 |
+
(0x2ec, 0x2ec),
|
| 21 |
+
(0x2ee, 0x2ee),
|
| 22 |
+
(0x370, 0x374),
|
| 23 |
+
(0x376, 0x377),
|
| 24 |
+
(0x37a, 0x37d),
|
| 25 |
+
(0x37f, 0x37f),
|
| 26 |
+
(0x386, 0x386),
|
| 27 |
+
(0x388, 0x38a),
|
| 28 |
+
(0x38c, 0x38c),
|
| 29 |
+
(0x38e, 0x3a1),
|
| 30 |
+
(0x3a3, 0x3f5),
|
| 31 |
+
(0x3f7, 0x481),
|
| 32 |
+
(0x48a, 0x52f),
|
| 33 |
+
(0x531, 0x556),
|
| 34 |
+
(0x559, 0x559),
|
| 35 |
+
(0x560, 0x588),
|
| 36 |
+
(0x5d0, 0x5ea),
|
| 37 |
+
(0x5ef, 0x5f2),
|
| 38 |
+
(0x620, 0x64a),
|
| 39 |
+
(0x66e, 0x66f),
|
| 40 |
+
(0x671, 0x6d3),
|
| 41 |
+
(0x6d5, 0x6d5),
|
| 42 |
+
(0x6e5, 0x6e6),
|
| 43 |
+
(0x6ee, 0x6ef),
|
| 44 |
+
(0x6fa, 0x6fc),
|
| 45 |
+
(0x6ff, 0x6ff),
|
| 46 |
+
(0x710, 0x710),
|
| 47 |
+
(0x712, 0x72f),
|
| 48 |
+
(0x74d, 0x7a5),
|
| 49 |
+
(0x7b1, 0x7b1),
|
| 50 |
+
(0x7ca, 0x7ea),
|
| 51 |
+
(0x7f4, 0x7f5),
|
| 52 |
+
(0x7fa, 0x7fa),
|
| 53 |
+
(0x800, 0x815),
|
| 54 |
+
(0x81a, 0x81a),
|
| 55 |
+
(0x824, 0x824),
|
| 56 |
+
(0x828, 0x828),
|
| 57 |
+
(0x840, 0x858),
|
| 58 |
+
(0x860, 0x86a),
|
| 59 |
+
(0x870, 0x887),
|
| 60 |
+
(0x889, 0x88e),
|
| 61 |
+
(0x8a0, 0x8c9),
|
| 62 |
+
(0x904, 0x939),
|
| 63 |
+
(0x93d, 0x93d),
|
| 64 |
+
(0x950, 0x950),
|
| 65 |
+
(0x958, 0x961),
|
| 66 |
+
(0x971, 0x980),
|
| 67 |
+
(0x985, 0x98c),
|
| 68 |
+
(0x98f, 0x990),
|
| 69 |
+
(0x993, 0x9a8),
|
| 70 |
+
(0x9aa, 0x9b0),
|
| 71 |
+
(0x9b2, 0x9b2),
|
| 72 |
+
(0x9b6, 0x9b9),
|
| 73 |
+
(0x9bd, 0x9bd),
|
| 74 |
+
(0x9ce, 0x9ce),
|
| 75 |
+
(0x9dc, 0x9dd),
|
| 76 |
+
(0x9df, 0x9e1),
|
| 77 |
+
(0x9f0, 0x9f1),
|
| 78 |
+
(0x9fc, 0x9fc),
|
| 79 |
+
(0xa05, 0xa0a),
|
| 80 |
+
(0xa0f, 0xa10),
|
| 81 |
+
(0xa13, 0xa28),
|
| 82 |
+
(0xa2a, 0xa30),
|
| 83 |
+
(0xa32, 0xa33),
|
| 84 |
+
(0xa35, 0xa36),
|
| 85 |
+
(0xa38, 0xa39),
|
| 86 |
+
(0xa59, 0xa5c),
|
| 87 |
+
(0xa5e, 0xa5e),
|
| 88 |
+
(0xa72, 0xa74),
|
| 89 |
+
(0xa85, 0xa8d),
|
| 90 |
+
(0xa8f, 0xa91),
|
| 91 |
+
(0xa93, 0xaa8),
|
| 92 |
+
(0xaaa, 0xab0),
|
| 93 |
+
(0xab2, 0xab3),
|
| 94 |
+
(0xab5, 0xab9),
|
| 95 |
+
(0xabd, 0xabd),
|
| 96 |
+
(0xad0, 0xad0),
|
| 97 |
+
(0xae0, 0xae1),
|
| 98 |
+
(0xaf9, 0xaf9),
|
| 99 |
+
(0xb05, 0xb0c),
|
| 100 |
+
(0xb0f, 0xb10),
|
| 101 |
+
(0xb13, 0xb28),
|
| 102 |
+
(0xb2a, 0xb30),
|
| 103 |
+
(0xb32, 0xb33),
|
| 104 |
+
(0xb35, 0xb39),
|
| 105 |
+
(0xb3d, 0xb3d),
|
| 106 |
+
(0xb5c, 0xb5d),
|
| 107 |
+
(0xb5f, 0xb61),
|
| 108 |
+
(0xb71, 0xb71),
|
| 109 |
+
(0xb83, 0xb83),
|
| 110 |
+
(0xb85, 0xb8a),
|
| 111 |
+
(0xb8e, 0xb90),
|
| 112 |
+
(0xb92, 0xb95),
|
| 113 |
+
(0xb99, 0xb9a),
|
| 114 |
+
(0xb9c, 0xb9c),
|
| 115 |
+
(0xb9e, 0xb9f),
|
| 116 |
+
(0xba3, 0xba4),
|
| 117 |
+
(0xba8, 0xbaa),
|
| 118 |
+
(0xbae, 0xbb9),
|
| 119 |
+
(0xbd0, 0xbd0),
|
| 120 |
+
(0xc05, 0xc0c),
|
| 121 |
+
(0xc0e, 0xc10),
|
| 122 |
+
(0xc12, 0xc28),
|
| 123 |
+
(0xc2a, 0xc39),
|
| 124 |
+
(0xc3d, 0xc3d),
|
| 125 |
+
(0xc58, 0xc5a),
|
| 126 |
+
(0xc5d, 0xc5d),
|
| 127 |
+
(0xc60, 0xc61),
|
| 128 |
+
(0xc80, 0xc80),
|
| 129 |
+
(0xc85, 0xc8c),
|
| 130 |
+
(0xc8e, 0xc90),
|
| 131 |
+
(0xc92, 0xca8),
|
| 132 |
+
(0xcaa, 0xcb3),
|
| 133 |
+
(0xcb5, 0xcb9),
|
| 134 |
+
(0xcbd, 0xcbd),
|
| 135 |
+
(0xcdd, 0xcde),
|
| 136 |
+
(0xce0, 0xce1),
|
| 137 |
+
(0xcf1, 0xcf2),
|
| 138 |
+
(0xd04, 0xd0c),
|
| 139 |
+
(0xd0e, 0xd10),
|
| 140 |
+
(0xd12, 0xd3a),
|
| 141 |
+
(0xd3d, 0xd3d),
|
| 142 |
+
(0xd4e, 0xd4e),
|
| 143 |
+
(0xd54, 0xd56),
|
| 144 |
+
(0xd5f, 0xd61),
|
| 145 |
+
(0xd7a, 0xd7f),
|
| 146 |
+
(0xd85, 0xd96),
|
| 147 |
+
(0xd9a, 0xdb1),
|
| 148 |
+
(0xdb3, 0xdbb),
|
| 149 |
+
(0xdbd, 0xdbd),
|
| 150 |
+
(0xdc0, 0xdc6),
|
| 151 |
+
(0xe01, 0xe30),
|
| 152 |
+
(0xe32, 0xe33),
|
| 153 |
+
(0xe40, 0xe46),
|
| 154 |
+
(0xe81, 0xe82),
|
| 155 |
+
(0xe84, 0xe84),
|
| 156 |
+
(0xe86, 0xe8a),
|
| 157 |
+
(0xe8c, 0xea3),
|
| 158 |
+
(0xea5, 0xea5),
|
| 159 |
+
(0xea7, 0xeb0),
|
| 160 |
+
(0xeb2, 0xeb3),
|
| 161 |
+
(0xebd, 0xebd),
|
| 162 |
+
(0xec0, 0xec4),
|
| 163 |
+
(0xec6, 0xec6),
|
| 164 |
+
(0xedc, 0xedf),
|
| 165 |
+
(0xf00, 0xf00),
|
| 166 |
+
(0xf40, 0xf47),
|
| 167 |
+
(0xf49, 0xf6c),
|
| 168 |
+
(0xf88, 0xf8c),
|
| 169 |
+
(0x1000, 0x102a),
|
| 170 |
+
(0x103f, 0x103f),
|
| 171 |
+
(0x1050, 0x1055),
|
| 172 |
+
(0x105a, 0x105d),
|
| 173 |
+
(0x1061, 0x1061),
|
| 174 |
+
(0x1065, 0x1066),
|
| 175 |
+
(0x106e, 0x1070),
|
| 176 |
+
(0x1075, 0x1081),
|
| 177 |
+
(0x108e, 0x108e),
|
| 178 |
+
(0x10a0, 0x10c5),
|
| 179 |
+
(0x10c7, 0x10c7),
|
| 180 |
+
(0x10cd, 0x10cd),
|
| 181 |
+
(0x10d0, 0x10fa),
|
| 182 |
+
(0x10fc, 0x1248),
|
| 183 |
+
(0x124a, 0x124d),
|
| 184 |
+
(0x1250, 0x1256),
|
| 185 |
+
(0x1258, 0x1258),
|
| 186 |
+
(0x125a, 0x125d),
|
| 187 |
+
(0x1260, 0x1288),
|
| 188 |
+
(0x128a, 0x128d),
|
| 189 |
+
(0x1290, 0x12b0),
|
| 190 |
+
(0x12b2, 0x12b5),
|
| 191 |
+
(0x12b8, 0x12be),
|
| 192 |
+
(0x12c0, 0x12c0),
|
| 193 |
+
(0x12c2, 0x12c5),
|
| 194 |
+
(0x12c8, 0x12d6),
|
| 195 |
+
(0x12d8, 0x1310),
|
| 196 |
+
(0x1312, 0x1315),
|
| 197 |
+
(0x1318, 0x135a),
|
| 198 |
+
(0x1380, 0x138f),
|
| 199 |
+
(0x13a0, 0x13f5),
|
| 200 |
+
(0x13f8, 0x13fd),
|
| 201 |
+
(0x1401, 0x166c),
|
| 202 |
+
(0x166f, 0x167f),
|
| 203 |
+
(0x1681, 0x169a),
|
| 204 |
+
(0x16a0, 0x16ea),
|
| 205 |
+
(0x16f1, 0x16f8),
|
| 206 |
+
(0x1700, 0x1711),
|
| 207 |
+
(0x171f, 0x1731),
|
| 208 |
+
(0x1740, 0x1751),
|
| 209 |
+
(0x1760, 0x176c),
|
| 210 |
+
(0x176e, 0x1770),
|
| 211 |
+
(0x1780, 0x17b3),
|
| 212 |
+
(0x17d7, 0x17d7),
|
| 213 |
+
(0x17dc, 0x17dc),
|
| 214 |
+
(0x1820, 0x1878),
|
| 215 |
+
(0x1880, 0x1884),
|
| 216 |
+
(0x1887, 0x18a8),
|
| 217 |
+
(0x18aa, 0x18aa),
|
| 218 |
+
(0x18b0, 0x18f5),
|
| 219 |
+
(0x1900, 0x191e),
|
| 220 |
+
(0x1950, 0x196d),
|
| 221 |
+
(0x1970, 0x1974),
|
| 222 |
+
(0x1980, 0x19ab),
|
| 223 |
+
(0x19b0, 0x19c9),
|
| 224 |
+
(0x1a00, 0x1a16),
|
| 225 |
+
(0x1a20, 0x1a54),
|
| 226 |
+
(0x1aa7, 0x1aa7),
|
| 227 |
+
(0x1b05, 0x1b33),
|
| 228 |
+
(0x1b45, 0x1b4c),
|
| 229 |
+
(0x1b83, 0x1ba0),
|
| 230 |
+
(0x1bae, 0x1baf),
|
| 231 |
+
(0x1bba, 0x1be5),
|
| 232 |
+
(0x1c00, 0x1c23),
|
| 233 |
+
(0x1c4d, 0x1c4f),
|
| 234 |
+
(0x1c5a, 0x1c7d),
|
| 235 |
+
(0x1c80, 0x1c8a),
|
| 236 |
+
(0x1c90, 0x1cba),
|
| 237 |
+
(0x1cbd, 0x1cbf),
|
| 238 |
+
(0x1ce9, 0x1cec),
|
| 239 |
+
(0x1cee, 0x1cf3),
|
| 240 |
+
(0x1cf5, 0x1cf6),
|
| 241 |
+
(0x1cfa, 0x1cfa),
|
| 242 |
+
(0x1d00, 0x1dbf),
|
| 243 |
+
(0x1e00, 0x1f15),
|
| 244 |
+
(0x1f18, 0x1f1d),
|
| 245 |
+
(0x1f20, 0x1f45),
|
| 246 |
+
(0x1f48, 0x1f4d),
|
| 247 |
+
(0x1f50, 0x1f57),
|
| 248 |
+
(0x1f59, 0x1f59),
|
| 249 |
+
(0x1f5b, 0x1f5b),
|
| 250 |
+
(0x1f5d, 0x1f5d),
|
| 251 |
+
(0x1f5f, 0x1f7d),
|
| 252 |
+
(0x1f80, 0x1fb4),
|
| 253 |
+
(0x1fb6, 0x1fbc),
|
| 254 |
+
(0x1fbe, 0x1fbe),
|
| 255 |
+
(0x1fc2, 0x1fc4),
|
| 256 |
+
(0x1fc6, 0x1fcc),
|
| 257 |
+
(0x1fd0, 0x1fd3),
|
| 258 |
+
(0x1fd6, 0x1fdb),
|
| 259 |
+
(0x1fe0, 0x1fec),
|
| 260 |
+
(0x1ff2, 0x1ff4),
|
| 261 |
+
(0x1ff6, 0x1ffc),
|
| 262 |
+
(0x2071, 0x2071),
|
| 263 |
+
(0x207f, 0x207f),
|
| 264 |
+
(0x2090, 0x209c),
|
| 265 |
+
(0x2102, 0x2102),
|
| 266 |
+
(0x2107, 0x2107),
|
| 267 |
+
(0x210a, 0x2113),
|
| 268 |
+
(0x2115, 0x2115),
|
| 269 |
+
(0x2119, 0x211d),
|
| 270 |
+
(0x2124, 0x2124),
|
| 271 |
+
(0x2126, 0x2126),
|
| 272 |
+
(0x2128, 0x2128),
|
| 273 |
+
(0x212a, 0x212d),
|
| 274 |
+
(0x212f, 0x2139),
|
| 275 |
+
(0x213c, 0x213f),
|
| 276 |
+
(0x2145, 0x2149),
|
| 277 |
+
(0x214e, 0x214e),
|
| 278 |
+
(0x2183, 0x2184),
|
| 279 |
+
(0x2c00, 0x2ce4),
|
| 280 |
+
(0x2ceb, 0x2cee),
|
| 281 |
+
(0x2cf2, 0x2cf3),
|
| 282 |
+
(0x2d00, 0x2d25),
|
| 283 |
+
(0x2d27, 0x2d27),
|
| 284 |
+
(0x2d2d, 0x2d2d),
|
| 285 |
+
(0x2d30, 0x2d67),
|
| 286 |
+
(0x2d6f, 0x2d6f),
|
| 287 |
+
(0x2d80, 0x2d96),
|
| 288 |
+
(0x2da0, 0x2da6),
|
| 289 |
+
(0x2da8, 0x2dae),
|
| 290 |
+
(0x2db0, 0x2db6),
|
| 291 |
+
(0x2db8, 0x2dbe),
|
| 292 |
+
(0x2dc0, 0x2dc6),
|
| 293 |
+
(0x2dc8, 0x2dce),
|
| 294 |
+
(0x2dd0, 0x2dd6),
|
| 295 |
+
(0x2dd8, 0x2dde),
|
| 296 |
+
(0x2e2f, 0x2e2f),
|
| 297 |
+
(0x3005, 0x3006),
|
| 298 |
+
(0x3031, 0x3035),
|
| 299 |
+
(0x303b, 0x303c),
|
| 300 |
+
(0x3041, 0x3096),
|
| 301 |
+
(0x309d, 0x309f),
|
| 302 |
+
(0x30a1, 0x30fa),
|
| 303 |
+
(0x30fc, 0x30ff),
|
| 304 |
+
(0x3105, 0x312f),
|
| 305 |
+
(0x3131, 0x318e),
|
| 306 |
+
(0x31a0, 0x31bf),
|
| 307 |
+
(0x31f0, 0x31ff),
|
| 308 |
+
(0x3400, 0x4dbf),
|
| 309 |
+
(0x4e00, 0xa48c),
|
| 310 |
+
(0xa4d0, 0xa4fd),
|
| 311 |
+
(0xa500, 0xa60c),
|
| 312 |
+
(0xa610, 0xa61f),
|
| 313 |
+
(0xa62a, 0xa62b),
|
| 314 |
+
(0xa640, 0xa66e),
|
| 315 |
+
(0xa67f, 0xa69d),
|
| 316 |
+
(0xa6a0, 0xa6e5),
|
| 317 |
+
(0xa717, 0xa71f),
|
| 318 |
+
(0xa722, 0xa788),
|
| 319 |
+
(0xa78b, 0xa7cd),
|
| 320 |
+
(0xa7d0, 0xa7d1),
|
| 321 |
+
(0xa7d3, 0xa7d3),
|
| 322 |
+
(0xa7d5, 0xa7dc),
|
| 323 |
+
(0xa7f2, 0xa801),
|
| 324 |
+
(0xa803, 0xa805),
|
| 325 |
+
(0xa807, 0xa80a),
|
| 326 |
+
(0xa80c, 0xa822),
|
| 327 |
+
(0xa840, 0xa873),
|
| 328 |
+
(0xa882, 0xa8b3),
|
| 329 |
+
(0xa8f2, 0xa8f7),
|
| 330 |
+
(0xa8fb, 0xa8fb),
|
| 331 |
+
(0xa8fd, 0xa8fe),
|
| 332 |
+
(0xa90a, 0xa925),
|
| 333 |
+
(0xa930, 0xa946),
|
| 334 |
+
(0xa960, 0xa97c),
|
| 335 |
+
(0xa984, 0xa9b2),
|
| 336 |
+
(0xa9cf, 0xa9cf),
|
| 337 |
+
(0xa9e0, 0xa9e4),
|
| 338 |
+
(0xa9e6, 0xa9ef),
|
| 339 |
+
(0xa9fa, 0xa9fe),
|
| 340 |
+
(0xaa00, 0xaa28),
|
| 341 |
+
(0xaa40, 0xaa42),
|
| 342 |
+
(0xaa44, 0xaa4b),
|
| 343 |
+
(0xaa60, 0xaa76),
|
| 344 |
+
(0xaa7a, 0xaa7a),
|
| 345 |
+
(0xaa7e, 0xaaaf),
|
| 346 |
+
(0xaab1, 0xaab1),
|
| 347 |
+
(0xaab5, 0xaab6),
|
| 348 |
+
(0xaab9, 0xaabd),
|
| 349 |
+
(0xaac0, 0xaac0),
|
| 350 |
+
(0xaac2, 0xaac2),
|
| 351 |
+
(0xaadb, 0xaadd),
|
| 352 |
+
(0xaae0, 0xaaea),
|
| 353 |
+
(0xaaf2, 0xaaf4),
|
| 354 |
+
(0xab01, 0xab06),
|
| 355 |
+
(0xab09, 0xab0e),
|
| 356 |
+
(0xab11, 0xab16),
|
| 357 |
+
(0xab20, 0xab26),
|
| 358 |
+
(0xab28, 0xab2e),
|
| 359 |
+
(0xab30, 0xab5a),
|
| 360 |
+
(0xab5c, 0xab69),
|
| 361 |
+
(0xab70, 0xabe2),
|
| 362 |
+
(0xac00, 0xd7a3),
|
| 363 |
+
(0xd7b0, 0xd7c6),
|
| 364 |
+
(0xd7cb, 0xd7fb),
|
| 365 |
+
(0xf900, 0xfa6d),
|
| 366 |
+
(0xfa70, 0xfad9),
|
| 367 |
+
(0xfb00, 0xfb06),
|
| 368 |
+
(0xfb13, 0xfb17),
|
| 369 |
+
(0xfb1d, 0xfb1d),
|
| 370 |
+
(0xfb1f, 0xfb28),
|
| 371 |
+
(0xfb2a, 0xfb36),
|
| 372 |
+
(0xfb38, 0xfb3c),
|
| 373 |
+
(0xfb3e, 0xfb3e),
|
| 374 |
+
(0xfb40, 0xfb41),
|
| 375 |
+
(0xfb43, 0xfb44),
|
| 376 |
+
(0xfb46, 0xfbb1),
|
| 377 |
+
(0xfbd3, 0xfd3d),
|
| 378 |
+
(0xfd50, 0xfd8f),
|
| 379 |
+
(0xfd92, 0xfdc7),
|
| 380 |
+
(0xfdf0, 0xfdfb),
|
| 381 |
+
(0xfe70, 0xfe74),
|
| 382 |
+
(0xfe76, 0xfefc),
|
| 383 |
+
(0xff21, 0xff3a),
|
| 384 |
+
(0xff41, 0xff5a),
|
| 385 |
+
(0xff66, 0xffbe),
|
| 386 |
+
(0xffc2, 0xffc7),
|
| 387 |
+
(0xffca, 0xffcf),
|
| 388 |
+
(0xffd2, 0xffd7),
|
| 389 |
+
(0xffda, 0xffdc),
|
| 390 |
+
(0x10000, 0x1000b),
|
| 391 |
+
(0x1000d, 0x10026),
|
| 392 |
+
(0x10028, 0x1003a),
|
| 393 |
+
(0x1003c, 0x1003d),
|
| 394 |
+
(0x1003f, 0x1004d),
|
| 395 |
+
(0x10050, 0x1005d),
|
| 396 |
+
(0x10080, 0x100fa),
|
| 397 |
+
(0x10280, 0x1029c),
|
| 398 |
+
(0x102a0, 0x102d0),
|
| 399 |
+
(0x10300, 0x1031f),
|
| 400 |
+
(0x1032d, 0x10340),
|
| 401 |
+
(0x10342, 0x10349),
|
| 402 |
+
(0x10350, 0x10375),
|
| 403 |
+
(0x10380, 0x1039d),
|
| 404 |
+
(0x103a0, 0x103c3),
|
| 405 |
+
(0x103c8, 0x103cf),
|
| 406 |
+
(0x10400, 0x1049d),
|
| 407 |
+
(0x104b0, 0x104d3),
|
| 408 |
+
(0x104d8, 0x104fb),
|
| 409 |
+
(0x10500, 0x10527),
|
| 410 |
+
(0x10530, 0x10563),
|
| 411 |
+
(0x10570, 0x1057a),
|
| 412 |
+
(0x1057c, 0x1058a),
|
| 413 |
+
(0x1058c, 0x10592),
|
| 414 |
+
(0x10594, 0x10595),
|
| 415 |
+
(0x10597, 0x105a1),
|
| 416 |
+
(0x105a3, 0x105b1),
|
| 417 |
+
(0x105b3, 0x105b9),
|
| 418 |
+
(0x105bb, 0x105bc),
|
| 419 |
+
(0x105c0, 0x105f3),
|
| 420 |
+
(0x10600, 0x10736),
|
| 421 |
+
(0x10740, 0x10755),
|
| 422 |
+
(0x10760, 0x10767),
|
| 423 |
+
(0x10780, 0x10785),
|
| 424 |
+
(0x10787, 0x107b0),
|
| 425 |
+
(0x107b2, 0x107ba),
|
| 426 |
+
(0x10800, 0x10805),
|
| 427 |
+
(0x10808, 0x10808),
|
| 428 |
+
(0x1080a, 0x10835),
|
| 429 |
+
(0x10837, 0x10838),
|
| 430 |
+
(0x1083c, 0x1083c),
|
| 431 |
+
(0x1083f, 0x10855),
|
| 432 |
+
(0x10860, 0x10876),
|
| 433 |
+
(0x10880, 0x1089e),
|
| 434 |
+
(0x108e0, 0x108f2),
|
| 435 |
+
(0x108f4, 0x108f5),
|
| 436 |
+
(0x10900, 0x10915),
|
| 437 |
+
(0x10920, 0x10939),
|
| 438 |
+
(0x10980, 0x109b7),
|
| 439 |
+
(0x109be, 0x109bf),
|
| 440 |
+
(0x10a00, 0x10a00),
|
| 441 |
+
(0x10a10, 0x10a13),
|
| 442 |
+
(0x10a15, 0x10a17),
|
| 443 |
+
(0x10a19, 0x10a35),
|
| 444 |
+
(0x10a60, 0x10a7c),
|
| 445 |
+
(0x10a80, 0x10a9c),
|
| 446 |
+
(0x10ac0, 0x10ac7),
|
| 447 |
+
(0x10ac9, 0x10ae4),
|
| 448 |
+
(0x10b00, 0x10b35),
|
| 449 |
+
(0x10b40, 0x10b55),
|
| 450 |
+
(0x10b60, 0x10b72),
|
| 451 |
+
(0x10b80, 0x10b91),
|
| 452 |
+
(0x10c00, 0x10c48),
|
| 453 |
+
(0x10c80, 0x10cb2),
|
| 454 |
+
(0x10cc0, 0x10cf2),
|
| 455 |
+
(0x10d00, 0x10d23),
|
| 456 |
+
(0x10d4a, 0x10d65),
|
| 457 |
+
(0x10d6f, 0x10d85),
|
| 458 |
+
(0x10e80, 0x10ea9),
|
| 459 |
+
(0x10eb0, 0x10eb1),
|
| 460 |
+
(0x10ec2, 0x10ec4),
|
| 461 |
+
(0x10f00, 0x10f1c),
|
| 462 |
+
(0x10f27, 0x10f27),
|
| 463 |
+
(0x10f30, 0x10f45),
|
| 464 |
+
(0x10f70, 0x10f81),
|
| 465 |
+
(0x10fb0, 0x10fc4),
|
| 466 |
+
(0x10fe0, 0x10ff6),
|
| 467 |
+
(0x11003, 0x11037),
|
| 468 |
+
(0x11071, 0x11072),
|
| 469 |
+
(0x11075, 0x11075),
|
| 470 |
+
(0x11083, 0x110af),
|
| 471 |
+
(0x110d0, 0x110e8),
|
| 472 |
+
(0x11103, 0x11126),
|
| 473 |
+
(0x11144, 0x11144),
|
| 474 |
+
(0x11147, 0x11147),
|
| 475 |
+
(0x11150, 0x11172),
|
| 476 |
+
(0x11176, 0x11176),
|
| 477 |
+
(0x11183, 0x111b2),
|
| 478 |
+
(0x111c1, 0x111c4),
|
| 479 |
+
(0x111da, 0x111da),
|
| 480 |
+
(0x111dc, 0x111dc),
|
| 481 |
+
(0x11200, 0x11211),
|
| 482 |
+
(0x11213, 0x1122b),
|
| 483 |
+
(0x1123f, 0x11240),
|
| 484 |
+
(0x11280, 0x11286),
|
| 485 |
+
(0x11288, 0x11288),
|
| 486 |
+
(0x1128a, 0x1128d),
|
| 487 |
+
(0x1128f, 0x1129d),
|
| 488 |
+
(0x1129f, 0x112a8),
|
| 489 |
+
(0x112b0, 0x112de),
|
| 490 |
+
(0x11305, 0x1130c),
|
| 491 |
+
(0x1130f, 0x11310),
|
| 492 |
+
(0x11313, 0x11328),
|
| 493 |
+
(0x1132a, 0x11330),
|
| 494 |
+
(0x11332, 0x11333),
|
| 495 |
+
(0x11335, 0x11339),
|
| 496 |
+
(0x1133d, 0x1133d),
|
| 497 |
+
(0x11350, 0x11350),
|
| 498 |
+
(0x1135d, 0x11361),
|
| 499 |
+
(0x11380, 0x11389),
|
| 500 |
+
(0x1138b, 0x1138b),
|
| 501 |
+
(0x1138e, 0x1138e),
|
| 502 |
+
(0x11390, 0x113b5),
|
| 503 |
+
(0x113b7, 0x113b7),
|
| 504 |
+
(0x113d1, 0x113d1),
|
| 505 |
+
(0x113d3, 0x113d3),
|
| 506 |
+
(0x11400, 0x11434),
|
| 507 |
+
(0x11447, 0x1144a),
|
| 508 |
+
(0x1145f, 0x11461),
|
| 509 |
+
(0x11480, 0x114af),
|
| 510 |
+
(0x114c4, 0x114c5),
|
| 511 |
+
(0x114c7, 0x114c7),
|
| 512 |
+
(0x11580, 0x115ae),
|
| 513 |
+
(0x115d8, 0x115db),
|
| 514 |
+
(0x11600, 0x1162f),
|
| 515 |
+
(0x11644, 0x11644),
|
| 516 |
+
(0x11680, 0x116aa),
|
| 517 |
+
(0x116b8, 0x116b8),
|
| 518 |
+
(0x11700, 0x1171a),
|
| 519 |
+
(0x11740, 0x11746),
|
| 520 |
+
(0x11800, 0x1182b),
|
| 521 |
+
(0x118a0, 0x118df),
|
| 522 |
+
(0x118ff, 0x11906),
|
| 523 |
+
(0x11909, 0x11909),
|
| 524 |
+
(0x1190c, 0x11913),
|
| 525 |
+
(0x11915, 0x11916),
|
| 526 |
+
(0x11918, 0x1192f),
|
| 527 |
+
(0x1193f, 0x1193f),
|
| 528 |
+
(0x11941, 0x11941),
|
| 529 |
+
(0x119a0, 0x119a7),
|
| 530 |
+
(0x119aa, 0x119d0),
|
| 531 |
+
(0x119e1, 0x119e1),
|
| 532 |
+
(0x119e3, 0x119e3),
|
| 533 |
+
(0x11a00, 0x11a00),
|
| 534 |
+
(0x11a0b, 0x11a32),
|
| 535 |
+
(0x11a3a, 0x11a3a),
|
| 536 |
+
(0x11a50, 0x11a50),
|
| 537 |
+
(0x11a5c, 0x11a89),
|
| 538 |
+
(0x11a9d, 0x11a9d),
|
| 539 |
+
(0x11ab0, 0x11af8),
|
| 540 |
+
(0x11bc0, 0x11be0),
|
| 541 |
+
(0x11c00, 0x11c08),
|
| 542 |
+
(0x11c0a, 0x11c2e),
|
| 543 |
+
(0x11c40, 0x11c40),
|
| 544 |
+
(0x11c72, 0x11c8f),
|
| 545 |
+
(0x11d00, 0x11d06),
|
| 546 |
+
(0x11d08, 0x11d09),
|
| 547 |
+
(0x11d0b, 0x11d30),
|
| 548 |
+
(0x11d46, 0x11d46),
|
| 549 |
+
(0x11d60, 0x11d65),
|
| 550 |
+
(0x11d67, 0x11d68),
|
| 551 |
+
(0x11d6a, 0x11d89),
|
| 552 |
+
(0x11d98, 0x11d98),
|
| 553 |
+
(0x11ee0, 0x11ef2),
|
| 554 |
+
(0x11f02, 0x11f02),
|
| 555 |
+
(0x11f04, 0x11f10),
|
| 556 |
+
(0x11f12, 0x11f33),
|
| 557 |
+
(0x11fb0, 0x11fb0),
|
| 558 |
+
(0x12000, 0x12399),
|
| 559 |
+
(0x12480, 0x12543),
|
| 560 |
+
(0x12f90, 0x12ff0),
|
| 561 |
+
(0x13000, 0x1342f),
|
| 562 |
+
(0x13441, 0x13446),
|
| 563 |
+
(0x13460, 0x143fa),
|
| 564 |
+
(0x14400, 0x14646),
|
| 565 |
+
(0x16100, 0x1611d),
|
| 566 |
+
(0x16800, 0x16a38),
|
| 567 |
+
(0x16a40, 0x16a5e),
|
| 568 |
+
(0x16a70, 0x16abe),
|
| 569 |
+
(0x16ad0, 0x16aed),
|
| 570 |
+
(0x16b00, 0x16b2f),
|
| 571 |
+
(0x16b40, 0x16b43),
|
| 572 |
+
(0x16b63, 0x16b77),
|
| 573 |
+
(0x16b7d, 0x16b8f),
|
| 574 |
+
(0x16d40, 0x16d6c),
|
| 575 |
+
(0x16e40, 0x16e7f),
|
| 576 |
+
(0x16f00, 0x16f4a),
|
| 577 |
+
(0x16f50, 0x16f50),
|
| 578 |
+
(0x16f93, 0x16f9f),
|
| 579 |
+
(0x16fe0, 0x16fe1),
|
| 580 |
+
(0x16fe3, 0x16fe3),
|
| 581 |
+
(0x17000, 0x187f7),
|
| 582 |
+
(0x18800, 0x18cd5),
|
| 583 |
+
(0x18cff, 0x18d08),
|
| 584 |
+
(0x1aff0, 0x1aff3),
|
| 585 |
+
(0x1aff5, 0x1affb),
|
| 586 |
+
(0x1affd, 0x1affe),
|
| 587 |
+
(0x1b000, 0x1b122),
|
| 588 |
+
(0x1b132, 0x1b132),
|
| 589 |
+
(0x1b150, 0x1b152),
|
| 590 |
+
(0x1b155, 0x1b155),
|
| 591 |
+
(0x1b164, 0x1b167),
|
| 592 |
+
(0x1b170, 0x1b2fb),
|
| 593 |
+
(0x1bc00, 0x1bc6a),
|
| 594 |
+
(0x1bc70, 0x1bc7c),
|
| 595 |
+
(0x1bc80, 0x1bc88),
|
| 596 |
+
(0x1bc90, 0x1bc99),
|
| 597 |
+
(0x1d400, 0x1d454),
|
| 598 |
+
(0x1d456, 0x1d49c),
|
| 599 |
+
(0x1d49e, 0x1d49f),
|
| 600 |
+
(0x1d4a2, 0x1d4a2),
|
| 601 |
+
(0x1d4a5, 0x1d4a6),
|
| 602 |
+
(0x1d4a9, 0x1d4ac),
|
| 603 |
+
(0x1d4ae, 0x1d4b9),
|
| 604 |
+
(0x1d4bb, 0x1d4bb),
|
| 605 |
+
(0x1d4bd, 0x1d4c3),
|
| 606 |
+
(0x1d4c5, 0x1d505),
|
| 607 |
+
(0x1d507, 0x1d50a),
|
| 608 |
+
(0x1d50d, 0x1d514),
|
| 609 |
+
(0x1d516, 0x1d51c),
|
| 610 |
+
(0x1d51e, 0x1d539),
|
| 611 |
+
(0x1d53b, 0x1d53e),
|
| 612 |
+
(0x1d540, 0x1d544),
|
| 613 |
+
(0x1d546, 0x1d546),
|
| 614 |
+
(0x1d54a, 0x1d550),
|
| 615 |
+
(0x1d552, 0x1d6a5),
|
| 616 |
+
(0x1d6a8, 0x1d6c0),
|
| 617 |
+
(0x1d6c2, 0x1d6da),
|
| 618 |
+
(0x1d6dc, 0x1d6fa),
|
| 619 |
+
(0x1d6fc, 0x1d714),
|
| 620 |
+
(0x1d716, 0x1d734),
|
| 621 |
+
(0x1d736, 0x1d74e),
|
| 622 |
+
(0x1d750, 0x1d76e),
|
| 623 |
+
(0x1d770, 0x1d788),
|
| 624 |
+
(0x1d78a, 0x1d7a8),
|
| 625 |
+
(0x1d7aa, 0x1d7c2),
|
| 626 |
+
(0x1d7c4, 0x1d7cb),
|
| 627 |
+
(0x1df00, 0x1df1e),
|
| 628 |
+
(0x1df25, 0x1df2a),
|
| 629 |
+
(0x1e030, 0x1e06d),
|
| 630 |
+
(0x1e100, 0x1e12c),
|
| 631 |
+
(0x1e137, 0x1e13d),
|
| 632 |
+
(0x1e14e, 0x1e14e),
|
| 633 |
+
(0x1e290, 0x1e2ad),
|
| 634 |
+
(0x1e2c0, 0x1e2eb),
|
| 635 |
+
(0x1e4d0, 0x1e4eb),
|
| 636 |
+
(0x1e5d0, 0x1e5ed),
|
| 637 |
+
(0x1e5f0, 0x1e5f0),
|
| 638 |
+
(0x1e7e0, 0x1e7e6),
|
| 639 |
+
(0x1e7e8, 0x1e7eb),
|
| 640 |
+
(0x1e7ed, 0x1e7ee),
|
| 641 |
+
(0x1e7f0, 0x1e7fe),
|
| 642 |
+
(0x1e800, 0x1e8c4),
|
| 643 |
+
(0x1e900, 0x1e943),
|
| 644 |
+
(0x1e94b, 0x1e94b),
|
| 645 |
+
(0x1ee00, 0x1ee03),
|
| 646 |
+
(0x1ee05, 0x1ee1f),
|
| 647 |
+
(0x1ee21, 0x1ee22),
|
| 648 |
+
(0x1ee24, 0x1ee24),
|
| 649 |
+
(0x1ee27, 0x1ee27),
|
| 650 |
+
(0x1ee29, 0x1ee32),
|
| 651 |
+
(0x1ee34, 0x1ee37),
|
| 652 |
+
(0x1ee39, 0x1ee39),
|
| 653 |
+
(0x1ee3b, 0x1ee3b),
|
| 654 |
+
(0x1ee42, 0x1ee42),
|
| 655 |
+
(0x1ee47, 0x1ee47),
|
| 656 |
+
(0x1ee49, 0x1ee49),
|
| 657 |
+
(0x1ee4b, 0x1ee4b),
|
| 658 |
+
(0x1ee4d, 0x1ee4f),
|
| 659 |
+
(0x1ee51, 0x1ee52),
|
| 660 |
+
(0x1ee54, 0x1ee54),
|
| 661 |
+
(0x1ee57, 0x1ee57),
|
| 662 |
+
(0x1ee59, 0x1ee59),
|
| 663 |
+
(0x1ee5b, 0x1ee5b),
|
| 664 |
+
(0x1ee5d, 0x1ee5d),
|
| 665 |
+
(0x1ee5f, 0x1ee5f),
|
| 666 |
+
(0x1ee61, 0x1ee62),
|
| 667 |
+
(0x1ee64, 0x1ee64),
|
| 668 |
+
(0x1ee67, 0x1ee6a),
|
| 669 |
+
(0x1ee6c, 0x1ee72),
|
| 670 |
+
(0x1ee74, 0x1ee77),
|
| 671 |
+
(0x1ee79, 0x1ee7c),
|
| 672 |
+
(0x1ee7e, 0x1ee7e),
|
| 673 |
+
(0x1ee80, 0x1ee89),
|
| 674 |
+
(0x1ee8b, 0x1ee9b),
|
| 675 |
+
(0x1eea1, 0x1eea3),
|
| 676 |
+
(0x1eea5, 0x1eea9),
|
| 677 |
+
(0x1eeab, 0x1eebb),
|
| 678 |
+
(0x20000, 0x2a6df),
|
| 679 |
+
(0x2a700, 0x2b739),
|
| 680 |
+
(0x2b740, 0x2b81d),
|
| 681 |
+
(0x2b820, 0x2cea1),
|
| 682 |
+
(0x2ceb0, 0x2ebe0),
|
| 683 |
+
(0x2ebf0, 0x2ee5d),
|
| 684 |
+
(0x2f800, 0x2fa1d),
|
| 685 |
+
(0x30000, 0x3134a),
|
| 686 |
+
(0x31350, 0x323af),
|
| 687 |
+
)
|
| 688 |
+
N_RANGES = (
|
| 689 |
+
(0x30, 0x39),
|
| 690 |
+
(0xb2, 0xb3),
|
| 691 |
+
(0xb9, 0xb9),
|
| 692 |
+
(0xbc, 0xbe),
|
| 693 |
+
(0x660, 0x669),
|
| 694 |
+
(0x6f0, 0x6f9),
|
| 695 |
+
(0x7c0, 0x7c9),
|
| 696 |
+
(0x966, 0x96f),
|
| 697 |
+
(0x9e6, 0x9ef),
|
| 698 |
+
(0x9f4, 0x9f9),
|
| 699 |
+
(0xa66, 0xa6f),
|
| 700 |
+
(0xae6, 0xaef),
|
| 701 |
+
(0xb66, 0xb6f),
|
| 702 |
+
(0xb72, 0xb77),
|
| 703 |
+
(0xbe6, 0xbf2),
|
| 704 |
+
(0xc66, 0xc6f),
|
| 705 |
+
(0xc78, 0xc7e),
|
| 706 |
+
(0xce6, 0xcef),
|
| 707 |
+
(0xd58, 0xd5e),
|
| 708 |
+
(0xd66, 0xd78),
|
| 709 |
+
(0xde6, 0xdef),
|
| 710 |
+
(0xe50, 0xe59),
|
| 711 |
+
(0xed0, 0xed9),
|
| 712 |
+
(0xf20, 0xf33),
|
| 713 |
+
(0x1040, 0x1049),
|
| 714 |
+
(0x1090, 0x1099),
|
| 715 |
+
(0x1369, 0x137c),
|
| 716 |
+
(0x16ee, 0x16f0),
|
| 717 |
+
(0x17e0, 0x17e9),
|
| 718 |
+
(0x17f0, 0x17f9),
|
| 719 |
+
(0x1810, 0x1819),
|
| 720 |
+
(0x1946, 0x194f),
|
| 721 |
+
(0x19d0, 0x19da),
|
| 722 |
+
(0x1a80, 0x1a89),
|
| 723 |
+
(0x1a90, 0x1a99),
|
| 724 |
+
(0x1b50, 0x1b59),
|
| 725 |
+
(0x1bb0, 0x1bb9),
|
| 726 |
+
(0x1c40, 0x1c49),
|
| 727 |
+
(0x1c50, 0x1c59),
|
| 728 |
+
(0x2070, 0x2070),
|
| 729 |
+
(0x2074, 0x2079),
|
| 730 |
+
(0x2080, 0x2089),
|
| 731 |
+
(0x2150, 0x2182),
|
| 732 |
+
(0x2185, 0x2189),
|
| 733 |
+
(0x2460, 0x249b),
|
| 734 |
+
(0x24ea, 0x24ff),
|
| 735 |
+
(0x2776, 0x2793),
|
| 736 |
+
(0x2cfd, 0x2cfd),
|
| 737 |
+
(0x3007, 0x3007),
|
| 738 |
+
(0x3021, 0x3029),
|
| 739 |
+
(0x3038, 0x303a),
|
| 740 |
+
(0x3192, 0x3195),
|
| 741 |
+
(0x3220, 0x3229),
|
| 742 |
+
(0x3248, 0x324f),
|
| 743 |
+
(0x3251, 0x325f),
|
| 744 |
+
(0x3280, 0x3289),
|
| 745 |
+
(0x32b1, 0x32bf),
|
| 746 |
+
(0xa620, 0xa629),
|
| 747 |
+
(0xa6e6, 0xa6ef),
|
| 748 |
+
(0xa830, 0xa835),
|
| 749 |
+
(0xa8d0, 0xa8d9),
|
| 750 |
+
(0xa900, 0xa909),
|
| 751 |
+
(0xa9d0, 0xa9d9),
|
| 752 |
+
(0xa9f0, 0xa9f9),
|
| 753 |
+
(0xaa50, 0xaa59),
|
| 754 |
+
(0xabf0, 0xabf9),
|
| 755 |
+
(0xff10, 0xff19),
|
| 756 |
+
(0x10107, 0x10133),
|
| 757 |
+
(0x10140, 0x10178),
|
| 758 |
+
(0x1018a, 0x1018b),
|
| 759 |
+
(0x102e1, 0x102fb),
|
| 760 |
+
(0x10320, 0x10323),
|
| 761 |
+
(0x10341, 0x10341),
|
| 762 |
+
(0x1034a, 0x1034a),
|
| 763 |
+
(0x103d1, 0x103d5),
|
| 764 |
+
(0x104a0, 0x104a9),
|
| 765 |
+
(0x10858, 0x1085f),
|
| 766 |
+
(0x10879, 0x1087f),
|
| 767 |
+
(0x108a7, 0x108af),
|
| 768 |
+
(0x108fb, 0x108ff),
|
| 769 |
+
(0x10916, 0x1091b),
|
| 770 |
+
(0x109bc, 0x109bd),
|
| 771 |
+
(0x109c0, 0x109cf),
|
| 772 |
+
(0x109d2, 0x109ff),
|
| 773 |
+
(0x10a40, 0x10a48),
|
| 774 |
+
(0x10a7d, 0x10a7e),
|
| 775 |
+
(0x10a9d, 0x10a9f),
|
| 776 |
+
(0x10aeb, 0x10aef),
|
| 777 |
+
(0x10b58, 0x10b5f),
|
| 778 |
+
(0x10b78, 0x10b7f),
|
| 779 |
+
(0x10ba9, 0x10baf),
|
| 780 |
+
(0x10cfa, 0x10cff),
|
| 781 |
+
(0x10d30, 0x10d39),
|
| 782 |
+
(0x10d40, 0x10d49),
|
| 783 |
+
(0x10e60, 0x10e7e),
|
| 784 |
+
(0x10f1d, 0x10f26),
|
| 785 |
+
(0x10f51, 0x10f54),
|
| 786 |
+
(0x10fc5, 0x10fcb),
|
| 787 |
+
(0x11052, 0x1106f),
|
| 788 |
+
(0x110f0, 0x110f9),
|
| 789 |
+
(0x11136, 0x1113f),
|
| 790 |
+
(0x111d0, 0x111d9),
|
| 791 |
+
(0x111e1, 0x111f4),
|
| 792 |
+
(0x112f0, 0x112f9),
|
| 793 |
+
(0x11450, 0x11459),
|
| 794 |
+
(0x114d0, 0x114d9),
|
| 795 |
+
(0x11650, 0x11659),
|
| 796 |
+
(0x116c0, 0x116c9),
|
| 797 |
+
(0x116d0, 0x116e3),
|
| 798 |
+
(0x11730, 0x1173b),
|
| 799 |
+
(0x118e0, 0x118f2),
|
| 800 |
+
(0x11950, 0x11959),
|
| 801 |
+
(0x11bf0, 0x11bf9),
|
| 802 |
+
(0x11c50, 0x11c6c),
|
| 803 |
+
(0x11d50, 0x11d59),
|
| 804 |
+
(0x11da0, 0x11da9),
|
| 805 |
+
(0x11f50, 0x11f59),
|
| 806 |
+
(0x11fc0, 0x11fd4),
|
| 807 |
+
(0x12400, 0x1246e),
|
| 808 |
+
(0x16130, 0x16139),
|
| 809 |
+
(0x16a60, 0x16a69),
|
| 810 |
+
(0x16ac0, 0x16ac9),
|
| 811 |
+
(0x16b50, 0x16b59),
|
| 812 |
+
(0x16b5b, 0x16b61),
|
| 813 |
+
(0x16d70, 0x16d79),
|
| 814 |
+
(0x16e80, 0x16e96),
|
| 815 |
+
(0x1ccf0, 0x1ccf9),
|
| 816 |
+
(0x1d2c0, 0x1d2d3),
|
| 817 |
+
(0x1d2e0, 0x1d2f3),
|
| 818 |
+
(0x1d360, 0x1d378),
|
| 819 |
+
(0x1d7ce, 0x1d7ff),
|
| 820 |
+
(0x1e140, 0x1e149),
|
| 821 |
+
(0x1e2f0, 0x1e2f9),
|
| 822 |
+
(0x1e4f0, 0x1e4f9),
|
| 823 |
+
(0x1e5f1, 0x1e5fa),
|
| 824 |
+
(0x1e8c7, 0x1e8cf),
|
| 825 |
+
(0x1e950, 0x1e959),
|
| 826 |
+
(0x1ec71, 0x1ecab),
|
| 827 |
+
(0x1ecad, 0x1ecaf),
|
| 828 |
+
(0x1ecb1, 0x1ecb4),
|
| 829 |
+
(0x1ed01, 0x1ed2d),
|
| 830 |
+
(0x1ed2f, 0x1ed3d),
|
| 831 |
+
(0x1f100, 0x1f10c),
|
| 832 |
+
(0x1fbf0, 0x1fbf9),
|
| 833 |
+
)
|
| 834 |
+
|
| 835 |
+
#: Code points the Rust NFC treats as combining-class 0 although unicodedata says otherwise,
|
| 836 |
+
#: plus second members of compositions the Rust side lacks. NFC is applied around them.
|
| 837 |
+
NFC_BARRIERS = (
|
| 838 |
+
(0x7fd, 0x7fd),
|
| 839 |
+
(0x898, 0x89f),
|
| 840 |
+
(0x8ca, 0x8d3),
|
| 841 |
+
(0x9fe, 0x9fe),
|
| 842 |
+
(0xc3c, 0xc3c),
|
| 843 |
+
(0xd3b, 0xd3c),
|
| 844 |
+
(0xeba, 0xeba),
|
| 845 |
+
(0x1715, 0x1715),
|
| 846 |
+
(0x1abf, 0x1ace),
|
| 847 |
+
(0x1df6, 0x1dfa),
|
| 848 |
+
(0xa82c, 0xa82c),
|
| 849 |
+
(0x10d24, 0x10d27),
|
| 850 |
+
(0x10eab, 0x10eac),
|
| 851 |
+
(0x10efd, 0x10eff),
|
| 852 |
+
(0x10f46, 0x10f50),
|
| 853 |
+
(0x10f82, 0x10f85),
|
| 854 |
+
(0x11070, 0x11070),
|
| 855 |
+
(0x1133b, 0x1133b),
|
| 856 |
+
(0x1145e, 0x1145e),
|
| 857 |
+
(0x11839, 0x1183a),
|
| 858 |
+
(0x11930, 0x11930),
|
| 859 |
+
(0x1193d, 0x1193e),
|
| 860 |
+
(0x11943, 0x11943),
|
| 861 |
+
(0x119e0, 0x119e0),
|
| 862 |
+
(0x11a34, 0x11a34),
|
| 863 |
+
(0x11a47, 0x11a47),
|
| 864 |
+
(0x11a99, 0x11a99),
|
| 865 |
+
(0x11d42, 0x11d42),
|
| 866 |
+
(0x11d44, 0x11d45),
|
| 867 |
+
(0x11d97, 0x11d97),
|
| 868 |
+
(0x11f41, 0x11f42),
|
| 869 |
+
(0x16ff0, 0x16ff1),
|
| 870 |
+
(0x1e08f, 0x1e08f),
|
| 871 |
+
(0x1e130, 0x1e136),
|
| 872 |
+
(0x1e2ae, 0x1e2ae),
|
| 873 |
+
(0x1e2ec, 0x1e2ef),
|
| 874 |
+
(0x1e4ec, 0x1e4ef),
|
| 875 |
+
)
|
code/align/tokenizer/bytelevel.py
ADDED
|
@@ -0,0 +1,693 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pure-Python (stdlib only) byte-level BPE tokenizer for Qwen2/Qwen3 (Goedel-Prover-V2-8B).
|
| 2 |
+
|
| 3 |
+
Why this exists. P2 (LoRA SFT on Goedel-Prover-V2-8B's own verified samples) needs
|
| 4 |
+
token ids for prompts and completions. Before this module the only way to get
|
| 5 |
+
them for a Qwen3 vocabulary was `transformers.AutoTokenizer`, i.e. the isolated
|
| 6 |
+
vLLM venv: the training process would have had to import (or shell out to) a
|
| 7 |
+
second Python environment just to turn text into ints, and a version skew
|
| 8 |
+
between that venv and the one that trained would silently change the ids
|
| 9 |
+
(measured elsewhere in this repo: transformers 5.x REBUILDS pipelines from the
|
| 10 |
+
config rather than from tokenizer.json). This module reads tokenizer.json
|
| 11 |
+
directly and is certified byte-identical (ids AND decode) against the HF tokenizer
|
| 12 |
+
on a frozen oracle corpus by tests/test_tokenizer_bytelevel.py.
|
| 13 |
+
|
| 14 |
+
Pipeline (encode), exactly what tokenizer.json declares and nothing else:
|
| 15 |
+
|
| 16 |
+
text
|
| 17 |
+
-> split on ADDED tokens (<|im_start|>, <think>, ...), leftmost-longest,
|
| 18 |
+
on the RAW text (all are normalized=false)
|
| 19 |
+
-> per remaining segment: NFC normalizer
|
| 20 |
+
-> Split(Isolated) with the Qwen2 regex
|
| 21 |
+
-> ByteLevel: UTF-8 bytes of each piece mapped through GPT-2 bytes_to_unicode
|
| 22 |
+
-> BPE (rank-ordered merges) per piece
|
| 23 |
+
-> post-processor ByteLevel: adds nothing (no BOS/EOS)
|
| 24 |
+
|
| 25 |
+
Decode: ids -> pieces (optionally drop special=true added tokens) -> join ->
|
| 26 |
+
byte-decode -> UTF-8 with U+FFFD replacement.
|
| 27 |
+
|
| 28 |
+
The three places a reimplementation gets this wrong -- each has a mutant in the
|
| 29 |
+
test file that must make parity FAIL:
|
| 30 |
+
|
| 31 |
+
1. NFC per SEGMENT, not per string. Added tokens are carved out first, so a
|
| 32 |
+
combining mark right after "<|im_end|>" has nothing to compose with. NFC on the
|
| 33 |
+
whole string would compose across the special token. Dropping NFC entirely
|
| 34 |
+
changes the ids of every decomposed-form string (e.g. "e" + U+0301).
|
| 35 |
+
2. The regex has `\\p{N}` (ONE digit per piece, so "2024" is four pieces) and
|
| 36 |
+
`\\p{L}+` / `[^\\r\\n\\p{L}\\p{N}]?` (a single non-letter prefix glues onto a word).
|
| 37 |
+
Python `re` has no \\p{..}: the classes are explicit ranges (_class_body)
|
| 38 |
+
built from the frozen Rust-side tables in _qwen2_unicode.py. A "\\d+" or "\\w+" shortcut is
|
| 39 |
+
not equivalent (superscripts are \\p{N} but not \\d; "_" is \\w but not \\p{L}).
|
| 40 |
+
3. BPE tie-break: lowest merge RANK wins, ties go to the LEFTMOST pair. Using
|
| 41 |
+
vocab id (or longest-match) instead of rank changes tokens inside long
|
| 42 |
+
identifiers and whitespace runs, silently.
|
| 43 |
+
|
| 44 |
+
Unicode tables (measured, pinned in tests): the Rust tokenizer's Unicode tables are not
|
| 45 |
+
those of any one Python. \\p{L}/\\p{N} are NEWER than CPython 3.12's unicodedata (5004
|
| 46 |
+
Unicode-16 code points are letters/digits there and unassigned here) and the NFC
|
| 47 |
+
normalizer is OLDER (108 combining marks of Unicode 10-15 keep combining class 0, and
|
| 48 |
+
U+11935 U+11930 does not compose). A unicodedata-derived translation therefore split
|
| 49 |
+
"X's" differently for those code points and canonically reordered marks HF leaves alone.
|
| 50 |
+
Both are now FROZEN from the real tokenizer (_qwen2_unicode.py, generated by
|
| 51 |
+
tests/data/make_qwen2_unicode_tables.py), so the result does not depend on the Python
|
| 52 |
+
version for the regex classes; NFC still uses unicodedata for everything else and is pinned
|
| 53 |
+
to the certified unicodedata version by a test.
|
| 54 |
+
|
| 55 |
+
The loader VALIDATES the whole pipeline configuration and raises
|
| 56 |
+
NotImplementedError on anything not implemented (a different regex, a missing
|
| 57 |
+
NFC, ignore_merges, normalized added tokens, clean_up_tokenization_spaces,
|
| 58 |
+
an unknown chat template ...). A different tokenizer.json must fail loudly, not
|
| 59 |
+
silently encode wrong ids.
|
| 60 |
+
"""
|
| 61 |
+
from __future__ import annotations
|
| 62 |
+
|
| 63 |
+
import hashlib
|
| 64 |
+
import heapq
|
| 65 |
+
import json
|
| 66 |
+
import os
|
| 67 |
+
import re
|
| 68 |
+
import unicodedata
|
| 69 |
+
from typing import Iterable, Optional, Sequence, Union
|
| 70 |
+
|
| 71 |
+
from .core import _TOKENIZER_FILES, _as_list, _content, _expect, _resolve_dir
|
| 72 |
+
from ._qwen2_unicode import L_RANGES, N_RANGES, NFC_BARRIERS
|
| 73 |
+
|
| 74 |
+
__all__ = ["ByteLevelTokenizer", "QWEN2_PRETOKENIZE_REGEX", "bytes_to_unicode", "render_qwen3_chat"]
|
| 75 |
+
|
| 76 |
+
#: The pre_tokenizer Split pattern the Qwen2/Qwen2.5/Qwen3 tokenizer.json files ship.
|
| 77 |
+
#: The loader requires tokenizer.json to carry EXACTLY this string: the Python
|
| 78 |
+
#: translation below (_build_pattern) is only valid for it.
|
| 79 |
+
QWEN2_PRETOKENIZE_REGEX = (
|
| 80 |
+
r"(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\r\n\p{L}\p{N}]?\p{L}+|\p{N}| ?[^\s\p{L}\p{N}]+[\r\n]*"
|
| 81 |
+
r"|\s*[\r\n]+|\s+(?!\S)|\s+"
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
#: sha256 of the Qwen3 chat_template string that render_qwen3_chat reimplements
|
| 85 |
+
#: (the subset without tools). A config carrying any other template makes
|
| 86 |
+
#: apply_chat_template raise instead of rendering with the wrong one.
|
| 87 |
+
QWEN3_CHAT_TEMPLATE_SHA256 = "a55ee1b1660128b7098723e0abcd92caa0788061051c62d51cbe87d9cf1974d8"
|
| 88 |
+
|
| 89 |
+
_CACHE_MAX_ENTRIES = 200_000
|
| 90 |
+
|
| 91 |
+
# Unicode White_Space property (what the Rust regex `\s` means). NOT Python's
|
| 92 |
+
# str.isspace(): that also accepts U+001C..U+001F, which are not White_Space.
|
| 93 |
+
_WHITE_SPACE = (
|
| 94 |
+
(0x09, 0x0D), (0x20, 0x20), (0x85, 0x85), (0xA0, 0xA0), (0x1680, 0x1680),
|
| 95 |
+
(0x2000, 0x200A), (0x2028, 0x2029), (0x202F, 0x202F), (0x205F, 0x205F), (0x3000, 0x3000),
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def bytes_to_unicode() -> dict[int, str]:
|
| 100 |
+
"""GPT-2 byte -> printable-char map (the ByteLevel alphabet): printable Latin-1
|
| 101 |
+
bytes map to themselves, the other 68 bytes to U+0100 and up, in byte order."""
|
| 102 |
+
keep = list(range(0x21, 0x7F)) + list(range(0xA1, 0xAD)) + list(range(0xAE, 0x100))
|
| 103 |
+
out: dict[int, str] = {}
|
| 104 |
+
n = 0
|
| 105 |
+
for b in range(256):
|
| 106 |
+
if b in keep:
|
| 107 |
+
out[b] = chr(b)
|
| 108 |
+
else:
|
| 109 |
+
out[b] = chr(256 + n)
|
| 110 |
+
n += 1
|
| 111 |
+
return out
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def _ranges(pred) -> list[tuple[int, int]]:
|
| 115 |
+
out: list[tuple[int, int]] = []
|
| 116 |
+
start = None
|
| 117 |
+
for cp in range(0x110000):
|
| 118 |
+
ok = pred(cp)
|
| 119 |
+
if ok and start is None:
|
| 120 |
+
start = cp
|
| 121 |
+
elif not ok and start is not None:
|
| 122 |
+
out.append((start, cp - 1))
|
| 123 |
+
start = None
|
| 124 |
+
if start is not None:
|
| 125 |
+
out.append((start, 0x10FFFF))
|
| 126 |
+
return out
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def _class_body(ranges: Iterable[tuple[int, int]]) -> str:
|
| 130 |
+
"""Body of a regex character class (without the brackets) for explicit ranges."""
|
| 131 |
+
parts = []
|
| 132 |
+
for a, b in ranges:
|
| 133 |
+
parts.append(f"\\U{a:08x}" if a == b else f"\\U{a:08x}-\\U{b:08x}")
|
| 134 |
+
return "".join(parts)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
_PATTERN: Optional[re.Pattern] = None
|
| 138 |
+
_BARRIER_RE: Optional[re.Pattern] = None
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def _build_pattern() -> re.Pattern:
|
| 142 |
+
"""Python-`re` translation of QWEN2_PRETOKENIZE_REGEX. \\p{L} / \\p{N} are the FROZEN
|
| 143 |
+
Rust-side tables (L_RANGES / N_RANGES), \\s = White_Space. The contraction group is
|
| 144 |
+
spelled out instead of (?i:...): Python's IGNORECASE is Unicode-aware and would also
|
| 145 |
+
accept U+017F (long s) for 's -- the oracle test pins what the reference does for it."""
|
| 146 |
+
global _PATTERN
|
| 147 |
+
if _PATTERN is not None:
|
| 148 |
+
return _PATTERN
|
| 149 |
+
L = _class_body(L_RANGES)
|
| 150 |
+
N = _class_body(N_RANGES)
|
| 151 |
+
S = _class_body(_WHITE_SPACE)
|
| 152 |
+
pat = (
|
| 153 |
+
r"'(?:[sS]|[tT]|[rR][eE]|[vV][eE]|[mM]|[lL][lL]|[dD])"
|
| 154 |
+
rf"|[^\r\n{L}{N}]?[{L}]+"
|
| 155 |
+
rf"|[{N}]"
|
| 156 |
+
rf"| ?[^{S}{L}{N}]+[\r\n]*"
|
| 157 |
+
rf"|[{S}]*[\r\n]+"
|
| 158 |
+
rf"|[{S}]+(?![^{S}])"
|
| 159 |
+
rf"|[{S}]+"
|
| 160 |
+
)
|
| 161 |
+
_PATTERN = re.compile(pat)
|
| 162 |
+
return _PATTERN
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def _barrier_re() -> re.Pattern:
|
| 166 |
+
global _BARRIER_RE
|
| 167 |
+
if _BARRIER_RE is None:
|
| 168 |
+
_BARRIER_RE = re.compile("([" + _class_body(NFC_BARRIERS) + "])")
|
| 169 |
+
return _BARRIER_RE
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
class ByteLevelTokenizer:
|
| 173 |
+
"""Qwen2/Qwen3 byte-level BPE, API-compatible with align.tokenizer.Tokenizer
|
| 174 |
+
for the calls this repo makes (encode / __call__ / decode / ids / save).
|
| 175 |
+
|
| 176 |
+
Build with `ByteLevelTokenizer.from_pretrained(dir)` or `load_tokenizer(dir)`.
|
| 177 |
+
"""
|
| 178 |
+
|
| 179 |
+
def __init__(
|
| 180 |
+
self,
|
| 181 |
+
tokenizer_json: dict,
|
| 182 |
+
tokenizer_config: Optional[dict] = None,
|
| 183 |
+
special_tokens_map: Optional[dict] = None,
|
| 184 |
+
*,
|
| 185 |
+
files: Optional[dict] = None,
|
| 186 |
+
):
|
| 187 |
+
self._files: dict[str, bytes] = dict(files or {})
|
| 188 |
+
self._config = dict(tokenizer_config or {})
|
| 189 |
+
self._special_map = dict(special_tokens_map or {})
|
| 190 |
+
self._tokenizer_json = tokenizer_json
|
| 191 |
+
self._validate_pipeline(tokenizer_json, self._config)
|
| 192 |
+
|
| 193 |
+
model = tokenizer_json["model"]
|
| 194 |
+
base_vocab: dict[str, int] = dict(model["vocab"])
|
| 195 |
+
self._base_vocab = base_vocab
|
| 196 |
+
vocab = dict(base_vocab)
|
| 197 |
+
|
| 198 |
+
self._added: dict[str, int] = {}
|
| 199 |
+
self._special_contents: set[str] = set()
|
| 200 |
+
for t in tokenizer_json.get("added_tokens", []):
|
| 201 |
+
_expect(
|
| 202 |
+
not (t.get("lstrip") or t.get("rstrip") or t.get("single_word")),
|
| 203 |
+
f"added token {t['content']!r} with lstrip/rstrip/single_word",
|
| 204 |
+
)
|
| 205 |
+
_expect(
|
| 206 |
+
not t.get("normalized"),
|
| 207 |
+
f"added token {t['content']!r} with normalized=true (NFC-before-match not implemented)",
|
| 208 |
+
)
|
| 209 |
+
vocab[t["content"]] = int(t["id"])
|
| 210 |
+
self._added[t["content"]] = int(t["id"])
|
| 211 |
+
if t.get("special"):
|
| 212 |
+
self._special_contents.add(t["content"])
|
| 213 |
+
self._vocab = vocab
|
| 214 |
+
size = max(vocab.values()) + 1
|
| 215 |
+
self._id_to_token: list[Optional[str]] = [None] * size
|
| 216 |
+
for tok, i in vocab.items():
|
| 217 |
+
self._id_to_token[i] = tok
|
| 218 |
+
|
| 219 |
+
self._b2u = bytes_to_unicode()
|
| 220 |
+
self._u2b = {c: b for b, c in self._b2u.items()}
|
| 221 |
+
missing = [b for b, c in self._b2u.items() if c not in base_vocab]
|
| 222 |
+
_expect(not missing, f"vocab lacks ByteLevel alphabet symbols for bytes {missing[:5]}")
|
| 223 |
+
|
| 224 |
+
self._ranks: dict[tuple[str, str], int] = {}
|
| 225 |
+
for rank, m in enumerate(model["merges"]):
|
| 226 |
+
a, b = (m[0], m[1]) if isinstance(m, (list, tuple)) else m.split(" ")
|
| 227 |
+
if (a + b) not in base_vocab or a not in base_vocab or b not in base_vocab:
|
| 228 |
+
raise ValueError(f"merge #{rank} {a!r}+{b!r} references a token outside the vocab")
|
| 229 |
+
self._ranks[(a, b)] = rank # last duplicate wins (Rust HashMap semantics)
|
| 230 |
+
|
| 231 |
+
if self._added:
|
| 232 |
+
alts = sorted(self._added, key=len, reverse=True)
|
| 233 |
+
self._added_re: Optional[re.Pattern] = re.compile("|".join(re.escape(a) for a in alts))
|
| 234 |
+
else:
|
| 235 |
+
self._added_re = None
|
| 236 |
+
self._cache: dict[str, tuple[int, ...]] = {}
|
| 237 |
+
self._id_bytes: list[Optional[bytes]] = [None] * size
|
| 238 |
+
|
| 239 |
+
def pick(name: str, default: Optional[str] = None) -> Optional[str]:
|
| 240 |
+
v = _content(self._config.get(f"{name}_token"))
|
| 241 |
+
if v is None:
|
| 242 |
+
v = _content(self._special_map.get(f"{name}_token"))
|
| 243 |
+
if v is None:
|
| 244 |
+
v = default
|
| 245 |
+
return v if (v is None or v in self._vocab) else None
|
| 246 |
+
|
| 247 |
+
self.bos_token = pick("bos")
|
| 248 |
+
self.eos_token = pick("eos")
|
| 249 |
+
self.unk_token = pick("unk")
|
| 250 |
+
self.pad_token = pick("pad")
|
| 251 |
+
|
| 252 |
+
# ------------------------------------------------------------------ config
|
| 253 |
+
|
| 254 |
+
@staticmethod
|
| 255 |
+
def _validate_pipeline(tj: dict, cfg: dict) -> None:
|
| 256 |
+
model = tj["model"]
|
| 257 |
+
_expect(model.get("type") == "BPE", f"model.type={model.get('type')!r}")
|
| 258 |
+
_expect(not model.get("dropout"), "BPE dropout")
|
| 259 |
+
_expect(not model.get("continuing_subword_prefix"), "continuing_subword_prefix")
|
| 260 |
+
_expect(not model.get("end_of_word_suffix"), "end_of_word_suffix")
|
| 261 |
+
_expect(not model.get("byte_fallback"), "byte_fallback")
|
| 262 |
+
_expect(not model.get("ignore_merges"), "ignore_merges")
|
| 263 |
+
_expect(not model.get("fuse_unk"), "fuse_unk")
|
| 264 |
+
_expect(model.get("unk_token") is None, "an unk_token")
|
| 265 |
+
_expect(tj.get("normalizer") == {"type": "NFC"}, f"normalizer {tj.get('normalizer')!r} (only NFC)")
|
| 266 |
+
pt = tj.get("pre_tokenizer") or {}
|
| 267 |
+
_expect(pt.get("type") == "Sequence", f"pre_tokenizer {pt.get('type')!r}")
|
| 268 |
+
seq = pt.get("pretokenizers") or []
|
| 269 |
+
_expect(len(seq) == 2, "pre_tokenizer Sequence other than [Split, ByteLevel]")
|
| 270 |
+
sp, bl = seq
|
| 271 |
+
_expect(sp.get("type") == "Split", f"pre_tokenizer[0] {sp.get('type')!r}")
|
| 272 |
+
_expect(
|
| 273 |
+
(sp.get("pattern") or {}).get("Regex") == QWEN2_PRETOKENIZE_REGEX,
|
| 274 |
+
"a Split regex other than the Qwen2 one",
|
| 275 |
+
)
|
| 276 |
+
_expect(sp.get("behavior") == "Isolated", f"Split behavior {sp.get('behavior')!r}")
|
| 277 |
+
_expect(sp.get("invert") is False, "Split invert=true")
|
| 278 |
+
_expect(bl.get("type") == "ByteLevel", f"pre_tokenizer[1] {bl.get('type')!r}")
|
| 279 |
+
_expect(not bl.get("add_prefix_space"), "ByteLevel add_prefix_space=true")
|
| 280 |
+
_expect(bl.get("use_regex") is False, "ByteLevel use_regex=true (GPT-2 regex on top)")
|
| 281 |
+
dec = tj.get("decoder") or {}
|
| 282 |
+
_expect(dec.get("type") == "ByteLevel", f"decoder {dec.get('type')!r}")
|
| 283 |
+
pp = tj.get("post_processor")
|
| 284 |
+
if pp is not None:
|
| 285 |
+
_expect(
|
| 286 |
+
pp.get("type") == "ByteLevel" and not pp.get("add_prefix_space") and not pp.get("trim_offsets"),
|
| 287 |
+
f"post_processor {pp.get('type')!r} other than a no-op ByteLevel",
|
| 288 |
+
)
|
| 289 |
+
# tokenizer.json can carry truncation/padding; the Rust encoder applies them
|
| 290 |
+
# (silently clipping every encode). Neither is implemented.
|
| 291 |
+
_expect(tj.get("truncation") is None, "a tokenizer.json truncation setting")
|
| 292 |
+
_expect(tj.get("padding") is None, "a tokenizer.json padding setting")
|
| 293 |
+
ByteLevelTokenizer._check_added_tokens_decoder(tj, cfg)
|
| 294 |
+
# transformers applies these from the CONFIG on top of tokenizer.json.
|
| 295 |
+
_expect(not cfg.get("add_prefix_space"), "tokenizer_config.json add_prefix_space=true")
|
| 296 |
+
_expect(not cfg.get("add_bos_token"), "tokenizer_config.json add_bos_token=true")
|
| 297 |
+
_expect(
|
| 298 |
+
not cfg.get("clean_up_tokenization_spaces"),
|
| 299 |
+
"tokenizer_config.json clean_up_tokenization_spaces=true",
|
| 300 |
+
)
|
| 301 |
+
_expect(not cfg.get("split_special_tokens"), "tokenizer_config.json split_special_tokens=true")
|
| 302 |
+
_expect(cfg.get("errors", "replace") == "replace", f"decode errors={cfg.get('errors')!r}")
|
| 303 |
+
|
| 304 |
+
@staticmethod
|
| 305 |
+
def _check_added_tokens_decoder(tj: dict, cfg: dict) -> None:
|
| 306 |
+
"""tokenizer_config.json `added_tokens_decoder` must AGREE with tokenizer.json's
|
| 307 |
+
added_tokens (same ids, contents and lstrip/rstrip/single_word/normalized/special).
|
| 308 |
+
|
| 309 |
+
Incident this prevents: transformers builds the added-token matcher from the
|
| 310 |
+
CONFIG as well as tokenizer.json. Measured (transformers 5.19.0, Goedel snapshot
|
| 311 |
+
with only `<think>.lstrip=true` flipped in tokenizer_config.json): HF encodes
|
| 312 |
+
"a <think> b" as [64, 151667, 293] (the space before the token is eaten) while a
|
| 313 |
+
reader of tokenizer.json alone gives [64, 220, 151667, 293]. A config/json pair
|
| 314 |
+
that disagrees must not load silently."""
|
| 315 |
+
adt = cfg.get("added_tokens_decoder")
|
| 316 |
+
if adt is None:
|
| 317 |
+
return
|
| 318 |
+
declared = {int(k): v for k, v in adt.items()}
|
| 319 |
+
in_json = {int(t["id"]): t for t in tj.get("added_tokens", [])}
|
| 320 |
+
_expect(
|
| 321 |
+
set(declared) == set(in_json),
|
| 322 |
+
"tokenizer_config.json added_tokens_decoder ids differ from tokenizer.json added_tokens",
|
| 323 |
+
)
|
| 324 |
+
for i, t in in_json.items():
|
| 325 |
+
c = declared[i]
|
| 326 |
+
_expect(c.get("content") == t["content"], f"added token {i} content differs between config and json")
|
| 327 |
+
for flag in ("special", "normalized", "lstrip", "rstrip", "single_word"):
|
| 328 |
+
_expect(
|
| 329 |
+
bool(c.get(flag)) == bool(t.get(flag)),
|
| 330 |
+
f"added token {t['content']!r}: {flag} differs between tokenizer_config.json and tokenizer.json",
|
| 331 |
+
)
|
| 332 |
+
|
| 333 |
+
# ------------------------------------------------------------- vocabulary
|
| 334 |
+
|
| 335 |
+
@property
|
| 336 |
+
def bos_token_id(self) -> Optional[int]:
|
| 337 |
+
return None if self.bos_token is None else self._vocab[self.bos_token]
|
| 338 |
+
|
| 339 |
+
@property
|
| 340 |
+
def eos_token_id(self) -> Optional[int]:
|
| 341 |
+
return None if self.eos_token is None else self._vocab[self.eos_token]
|
| 342 |
+
|
| 343 |
+
@property
|
| 344 |
+
def pad_token_id(self) -> Optional[int]:
|
| 345 |
+
return None if self.pad_token is None else self._vocab[self.pad_token]
|
| 346 |
+
|
| 347 |
+
@property
|
| 348 |
+
def unk_token_id(self) -> Optional[int]:
|
| 349 |
+
return None
|
| 350 |
+
|
| 351 |
+
@property
|
| 352 |
+
def vocab_size(self) -> int:
|
| 353 |
+
"""BPE base vocabulary only (HF semantics): 151643 for Qwen3; len(tok) adds
|
| 354 |
+
the added tokens. The model's embedding rows (151936) are padding on top."""
|
| 355 |
+
return len(self._base_vocab)
|
| 356 |
+
|
| 357 |
+
@property
|
| 358 |
+
def all_special_tokens(self) -> list[str]:
|
| 359 |
+
return sorted(self._special_contents, key=lambda t: self._vocab[t])
|
| 360 |
+
|
| 361 |
+
@property
|
| 362 |
+
def all_special_ids(self) -> list[int]:
|
| 363 |
+
return [self._vocab[t] for t in self.all_special_tokens]
|
| 364 |
+
|
| 365 |
+
def get_vocab(self) -> dict[str, int]:
|
| 366 |
+
return dict(self._vocab)
|
| 367 |
+
|
| 368 |
+
def get_added_vocab(self) -> dict[str, int]:
|
| 369 |
+
return dict(self._added)
|
| 370 |
+
|
| 371 |
+
def __len__(self) -> int:
|
| 372 |
+
return len(self._vocab)
|
| 373 |
+
|
| 374 |
+
def __repr__(self) -> str:
|
| 375 |
+
return f"ByteLevelTokenizer(n={len(self)}, merges={len(self._ranks)}, added={len(self._added)})"
|
| 376 |
+
|
| 377 |
+
def _piece(self, i: int) -> Optional[str]:
|
| 378 |
+
if i < 0:
|
| 379 |
+
raise ValueError(f"negative token id {i}: a label-mask value leaked into a token-id path")
|
| 380 |
+
return self._id_to_token[i] if i < len(self._id_to_token) else None
|
| 381 |
+
|
| 382 |
+
def convert_ids_to_tokens(self, ids: Union[int, Iterable[int]]):
|
| 383 |
+
if isinstance(ids, int):
|
| 384 |
+
return self._piece(ids)
|
| 385 |
+
return [self._piece(int(i)) for i in _as_list(ids)]
|
| 386 |
+
|
| 387 |
+
def convert_tokens_to_ids(self, tokens: Union[str, Iterable[str]]):
|
| 388 |
+
if isinstance(tokens, str):
|
| 389 |
+
return self._vocab.get(tokens)
|
| 390 |
+
return [self._vocab.get(t) for t in tokens]
|
| 391 |
+
|
| 392 |
+
# ------------------------------------------------------------------ encode
|
| 393 |
+
|
| 394 |
+
def __call__(self, text: Union[str, Sequence[str]], add_special_tokens: bool = True) -> dict:
|
| 395 |
+
if isinstance(text, str):
|
| 396 |
+
return {"input_ids": self.encode(text, add_special_tokens=add_special_tokens)}
|
| 397 |
+
return {"input_ids": [self.encode(t, add_special_tokens=add_special_tokens) for t in text]}
|
| 398 |
+
|
| 399 |
+
def encode(self, text: str, add_special_tokens: bool = True) -> list[int]:
|
| 400 |
+
"""`add_special_tokens` is accepted for API parity; Qwen3 adds none."""
|
| 401 |
+
if not isinstance(text, str):
|
| 402 |
+
raise TypeError(f"encode() takes str, got {type(text).__name__}")
|
| 403 |
+
try:
|
| 404 |
+
text.encode("utf-8")
|
| 405 |
+
except UnicodeEncodeError as e:
|
| 406 |
+
raise ValueError(f"text is not valid Unicode (lone surrogate at index {e.start})") from e
|
| 407 |
+
ids: list[int] = []
|
| 408 |
+
for is_added, seg in self._split_added(text):
|
| 409 |
+
if is_added:
|
| 410 |
+
ids.append(self._added[seg])
|
| 411 |
+
else:
|
| 412 |
+
ids.extend(self._encode_segment(self._normalize(seg)))
|
| 413 |
+
return ids
|
| 414 |
+
|
| 415 |
+
@staticmethod
|
| 416 |
+
def _normalize(seg: str) -> str:
|
| 417 |
+
"""NFC as the Rust normalizer computes it. ASCII is NFC-invariant. A code point in
|
| 418 |
+
NFC_BARRIERS is a starter there (never reordered, never composed) but not in
|
| 419 |
+
Python's newer table, so the text is normalized AROUND it, never across it.
|
| 420 |
+
|
| 421 |
+
Incident this prevents: "\\u0301\\U00011d45" (acute, then Masaram Gondi virama) is
|
| 422 |
+
kept in that order by HF, but unicodedata.normalize moves the virama (ccc 9) in
|
| 423 |
+
front of the acute (ccc 230): same text, different ids."""
|
| 424 |
+
if seg.isascii():
|
| 425 |
+
return seg
|
| 426 |
+
rx = _barrier_re()
|
| 427 |
+
if rx.search(seg) is None:
|
| 428 |
+
return unicodedata.normalize("NFC", seg)
|
| 429 |
+
parts = rx.split(seg) # [text, barrier, text, barrier, ..., text]
|
| 430 |
+
return "".join(p if k & 1 else unicodedata.normalize("NFC", p) for k, p in enumerate(parts))
|
| 431 |
+
|
| 432 |
+
def _split_added(self, text: str):
|
| 433 |
+
if self._added_re is None or not text:
|
| 434 |
+
if text:
|
| 435 |
+
yield False, text
|
| 436 |
+
return
|
| 437 |
+
pos = 0
|
| 438 |
+
for m in self._added_re.finditer(text):
|
| 439 |
+
if m.start() > pos:
|
| 440 |
+
yield False, text[pos : m.start()]
|
| 441 |
+
yield True, m.group(0)
|
| 442 |
+
pos = m.end()
|
| 443 |
+
if pos < len(text):
|
| 444 |
+
yield False, text[pos:]
|
| 445 |
+
|
| 446 |
+
def _pretokenize(self, seg: str):
|
| 447 |
+
"""Split(Isolated): matches AND the gaps between them become pieces (the
|
| 448 |
+
regex's last alternatives cover every character, so gaps cannot occur for
|
| 449 |
+
the Qwen2 pattern; kept so a pattern change cannot silently drop text)."""
|
| 450 |
+
pos = 0
|
| 451 |
+
for m in _build_pattern().finditer(seg):
|
| 452 |
+
if m.start() > pos:
|
| 453 |
+
yield seg[pos : m.start()]
|
| 454 |
+
yield m.group(0)
|
| 455 |
+
pos = m.end()
|
| 456 |
+
if pos < len(seg):
|
| 457 |
+
yield seg[pos:]
|
| 458 |
+
|
| 459 |
+
def _encode_segment(self, seg: str) -> list[int]:
|
| 460 |
+
out: list[int] = []
|
| 461 |
+
cache = self._cache
|
| 462 |
+
for piece in self._pretokenize(seg):
|
| 463 |
+
r = cache.get(piece)
|
| 464 |
+
if r is None:
|
| 465 |
+
r = self._bpe(piece)
|
| 466 |
+
if len(cache) >= _CACHE_MAX_ENTRIES:
|
| 467 |
+
cache.clear()
|
| 468 |
+
cache[piece] = r
|
| 469 |
+
out.extend(r)
|
| 470 |
+
return out
|
| 471 |
+
|
| 472 |
+
def _bpe(self, piece: str) -> tuple[int, ...]:
|
| 473 |
+
b2u = self._b2u
|
| 474 |
+
syms = [b2u[b] for b in piece.encode("utf-8")]
|
| 475 |
+
return self._merge(syms)
|
| 476 |
+
|
| 477 |
+
def _merge(self, syms: list[str]) -> tuple[int, ...]:
|
| 478 |
+
"""Rank-ordered pair merging (linked list + lazy-deletion min-heap, O(n log n)
|
| 479 |
+
so a multi-thousand-char whitespace run is not quadratic). Tie-break:
|
| 480 |
+
lowest merge rank, then leftmost position."""
|
| 481 |
+
n = len(syms)
|
| 482 |
+
vocab = self._base_vocab
|
| 483 |
+
if n < 2:
|
| 484 |
+
return tuple(vocab[s] for s in syms)
|
| 485 |
+
ranks = self._ranks
|
| 486 |
+
nxt = list(range(1, n + 1))
|
| 487 |
+
nxt[-1] = -1
|
| 488 |
+
prv = list(range(-1, n - 1))
|
| 489 |
+
heap: list[tuple[int, int, str, str]] = []
|
| 490 |
+
for i in range(n - 1):
|
| 491 |
+
r = ranks.get((syms[i], syms[i + 1]))
|
| 492 |
+
if r is not None:
|
| 493 |
+
heap.append((r, i, syms[i], syms[i + 1]))
|
| 494 |
+
heapq.heapify(heap)
|
| 495 |
+
pop, push = heapq.heappop, heapq.heappush
|
| 496 |
+
while heap:
|
| 497 |
+
_, i, left, right = pop(heap)
|
| 498 |
+
if syms[i] != left:
|
| 499 |
+
continue
|
| 500 |
+
j = nxt[i]
|
| 501 |
+
if j == -1 or syms[j] != right:
|
| 502 |
+
continue
|
| 503 |
+
new = left + right
|
| 504 |
+
syms[i] = new
|
| 505 |
+
syms[j] = None # type: ignore[call-overload]
|
| 506 |
+
nj = nxt[j]
|
| 507 |
+
nxt[i] = nj
|
| 508 |
+
if nj != -1:
|
| 509 |
+
prv[nj] = i
|
| 510 |
+
p = prv[i]
|
| 511 |
+
if p != -1:
|
| 512 |
+
r = ranks.get((syms[p], new))
|
| 513 |
+
if r is not None:
|
| 514 |
+
push(heap, (r, p, syms[p], new))
|
| 515 |
+
if nj != -1:
|
| 516 |
+
r = ranks.get((new, syms[nj]))
|
| 517 |
+
if r is not None:
|
| 518 |
+
push(heap, (r, i, new, syms[nj]))
|
| 519 |
+
return tuple(vocab[s] for s in syms if s is not None)
|
| 520 |
+
|
| 521 |
+
# ------------------------------------------------------------------ decode
|
| 522 |
+
|
| 523 |
+
def _bytes_of(self, i: int) -> Optional[bytes]:
|
| 524 |
+
b = self._id_bytes[i]
|
| 525 |
+
if b is None:
|
| 526 |
+
t = self._id_to_token[i]
|
| 527 |
+
u2b = self._u2b
|
| 528 |
+
# Same rule as the Rust ByteLevel decoder: a token whose every char is in
|
| 529 |
+
# the alphabet is byte-decoded; anything else passes through as literal text.
|
| 530 |
+
if all(c in u2b for c in t): # type: ignore[union-attr]
|
| 531 |
+
b = bytes(u2b[c] for c in t) # type: ignore[union-attr]
|
| 532 |
+
else:
|
| 533 |
+
b = t.encode("utf-8") # type: ignore[union-attr]
|
| 534 |
+
self._id_bytes[i] = b
|
| 535 |
+
return b
|
| 536 |
+
|
| 537 |
+
def decode(self, ids, skip_special_tokens: bool = False) -> str:
|
| 538 |
+
"""Join all tokens' bytes THEN decode UTF-8 once: a multi-byte character split
|
| 539 |
+
across tokens decodes correctly, a truncated one yields U+FFFD (never an
|
| 540 |
+
exception). Ids outside the vocab are skipped (HF behaviour). With
|
| 541 |
+
skip_special_tokens only added tokens flagged special=true are dropped
|
| 542 |
+
(<think>/</think>/<tool_call> are special=false and stay)."""
|
| 543 |
+
special = self._special_contents
|
| 544 |
+
buf = bytearray()
|
| 545 |
+
for i in _as_list(ids):
|
| 546 |
+
i = int(i)
|
| 547 |
+
t = self._piece(i)
|
| 548 |
+
if t is None:
|
| 549 |
+
continue
|
| 550 |
+
if skip_special_tokens and t in special:
|
| 551 |
+
continue
|
| 552 |
+
buf += self._bytes_of(i) # type: ignore[arg-type]
|
| 553 |
+
return bytes(buf).decode("utf-8", errors="replace")
|
| 554 |
+
|
| 555 |
+
# ------------------------------------------------------------ chat template
|
| 556 |
+
|
| 557 |
+
def apply_chat_template(
|
| 558 |
+
self,
|
| 559 |
+
messages: Sequence[dict],
|
| 560 |
+
*,
|
| 561 |
+
tokenize: bool = False,
|
| 562 |
+
add_generation_prompt: bool = False,
|
| 563 |
+
enable_thinking: Optional[bool] = None,
|
| 564 |
+
tools=None,
|
| 565 |
+
):
|
| 566 |
+
"""Qwen3 chat template, the system/user/assistant subset. See render_qwen3_chat.
|
| 567 |
+
Raises NotImplementedError if the shipped chat_template is not the one this
|
| 568 |
+
implements (checked by sha256) or if tools are requested.
|
| 569 |
+
|
| 570 |
+
`tokenize=True` returns a plain list[int]. transformers 5.x returns a BatchEncoding
|
| 571 |
+
({"input_ids", "attention_mask"}) there (measured, 5.19.0), so code written against
|
| 572 |
+
HF's tokenize=True result must index ["input_ids"] on HF but not here; callers in
|
| 573 |
+
this repo use tokenize=False + encode()."""
|
| 574 |
+
tpl = self._config.get("chat_template")
|
| 575 |
+
_expect(
|
| 576 |
+
tpl is not None and hashlib.sha256(tpl.encode("utf-8")).hexdigest() == QWEN3_CHAT_TEMPLATE_SHA256,
|
| 577 |
+
"a chat_template other than the Qwen3 one (sha256 mismatch)",
|
| 578 |
+
)
|
| 579 |
+
text = render_qwen3_chat(
|
| 580 |
+
messages, add_generation_prompt=add_generation_prompt, enable_thinking=enable_thinking, tools=tools
|
| 581 |
+
)
|
| 582 |
+
return self.encode(text, add_special_tokens=False) if tokenize else text
|
| 583 |
+
|
| 584 |
+
# -------------------------------------------------------------------- save
|
| 585 |
+
|
| 586 |
+
def save_pretrained(self, save_directory: str) -> tuple[str, ...]:
|
| 587 |
+
"""Copy-through of the source files (atomic tmp + os.replace per file)."""
|
| 588 |
+
os.makedirs(save_directory, exist_ok=True)
|
| 589 |
+
files = dict(self._files)
|
| 590 |
+
if "tokenizer.json" not in files:
|
| 591 |
+
files["tokenizer.json"] = json.dumps(self._tokenizer_json, ensure_ascii=False).encode("utf-8")
|
| 592 |
+
if "tokenizer_config.json" not in files and self._config:
|
| 593 |
+
files["tokenizer_config.json"] = json.dumps(self._config, indent=2).encode("utf-8")
|
| 594 |
+
if "special_tokens_map.json" not in files and self._special_map:
|
| 595 |
+
files["special_tokens_map.json"] = json.dumps(self._special_map, indent=2).encode("utf-8")
|
| 596 |
+
written = []
|
| 597 |
+
for name, blob in files.items():
|
| 598 |
+
dst = os.path.join(save_directory, name)
|
| 599 |
+
tmp = dst + f".tmp.{os.getpid()}"
|
| 600 |
+
with open(tmp, "wb") as f:
|
| 601 |
+
f.write(blob)
|
| 602 |
+
f.flush()
|
| 603 |
+
os.fsync(f.fileno())
|
| 604 |
+
os.replace(tmp, dst)
|
| 605 |
+
written.append(dst)
|
| 606 |
+
return tuple(written)
|
| 607 |
+
|
| 608 |
+
@classmethod
|
| 609 |
+
def from_pretrained(cls, path: Union[str, os.PathLike]) -> "ByteLevelTokenizer":
|
| 610 |
+
"""LOCAL ONLY: a dir holding tokenizer.json, a tokenizer.json path, or an HF
|
| 611 |
+
cache model dir. Never touches the network."""
|
| 612 |
+
d = _resolve_dir(os.fspath(path))
|
| 613 |
+
files: dict[str, bytes] = {}
|
| 614 |
+
for name in _TOKENIZER_FILES:
|
| 615 |
+
p = os.path.join(d, name)
|
| 616 |
+
if os.path.isfile(p):
|
| 617 |
+
with open(p, "rb") as f:
|
| 618 |
+
files[name] = f.read()
|
| 619 |
+
tj = json.loads(files["tokenizer.json"])
|
| 620 |
+
cfg = json.loads(files["tokenizer_config.json"]) if "tokenizer_config.json" in files else None
|
| 621 |
+
smap = json.loads(files["special_tokens_map.json"]) if "special_tokens_map.json" in files else None
|
| 622 |
+
return cls(tj, cfg, smap, files=files)
|
| 623 |
+
|
| 624 |
+
|
| 625 |
+
def render_qwen3_chat(
|
| 626 |
+
messages: Sequence[dict],
|
| 627 |
+
*,
|
| 628 |
+
add_generation_prompt: bool = False,
|
| 629 |
+
enable_thinking: Optional[bool] = None,
|
| 630 |
+
tools=None,
|
| 631 |
+
) -> str:
|
| 632 |
+
"""Render the Qwen3 chat template for system/user/assistant messages.
|
| 633 |
+
|
| 634 |
+
Incident this prevents: P2 trains on (prompt, completion) pairs whose prompt
|
| 635 |
+
bytes must equal what Goedel-Prover sampled under; correction rounds replay
|
| 636 |
+
earlier assistant turns, and the template REWRITES them (earlier turns lose
|
| 637 |
+
their <think> block; only turns after the last user query keep
|
| 638 |
+
"<think>\\n..\\n</think>\\n\\n"). Hand-rolled "<|im_start|>" concatenation
|
| 639 |
+
gets the multi-turn case wrong without any error. Verified against the HF
|
| 640 |
+
template output in tests/test_tokenizer_bytelevel.py.
|
| 641 |
+
|
| 642 |
+
Deliberate deviations from the Jinja (each raises instead of silently differing):
|
| 643 |
+
tools / role "tool" / tool_calls -> NotImplementedError; a role outside
|
| 644 |
+
system/user/assistant -> ValueError (Jinja drops the message); non-str content
|
| 645 |
+
-> TypeError (Jinja renders ''); empty messages -> ValueError.
|
| 646 |
+
"""
|
| 647 |
+
if tools:
|
| 648 |
+
raise NotImplementedError("Qwen3 chat template with tools is not implemented")
|
| 649 |
+
if not messages:
|
| 650 |
+
raise ValueError("messages is empty")
|
| 651 |
+
for k, m in enumerate(messages):
|
| 652 |
+
role = m.get("role")
|
| 653 |
+
if role == "tool" or m.get("tool_calls"):
|
| 654 |
+
raise NotImplementedError("tool messages / tool_calls are not implemented")
|
| 655 |
+
if role not in ("system", "user", "assistant"):
|
| 656 |
+
raise ValueError(f"message {k}: unsupported role {role!r}")
|
| 657 |
+
if not isinstance(m.get("content"), str):
|
| 658 |
+
raise TypeError(f"message {k}: content must be str, got {type(m.get('content')).__name__}")
|
| 659 |
+
out: list[str] = []
|
| 660 |
+
if messages[0]["role"] == "system":
|
| 661 |
+
out.append("<|im_start|>system\n" + messages[0]["content"] + "<|im_end|>\n")
|
| 662 |
+
last_query_index = len(messages) - 1
|
| 663 |
+
for idx in range(len(messages) - 1, -1, -1):
|
| 664 |
+
c = messages[idx]["content"]
|
| 665 |
+
if messages[idx]["role"] == "user" and not (c.startswith("<tool_response>") and c.endswith("</tool_response>")):
|
| 666 |
+
last_query_index = idx
|
| 667 |
+
break
|
| 668 |
+
n = len(messages)
|
| 669 |
+
for idx, m in enumerate(messages):
|
| 670 |
+
role, content = m["role"], m["content"]
|
| 671 |
+
if role == "user" or (role == "system" and idx != 0):
|
| 672 |
+
out.append("<|im_start|>" + role + "\n" + content + "<|im_end|>\n")
|
| 673 |
+
elif role == "assistant":
|
| 674 |
+
reasoning = ""
|
| 675 |
+
rc = m.get("reasoning_content")
|
| 676 |
+
if isinstance(rc, str):
|
| 677 |
+
reasoning = rc
|
| 678 |
+
elif "</think>" in content:
|
| 679 |
+
reasoning = content.split("</think>")[0].rstrip("\n").split("<think>")[-1].lstrip("\n")
|
| 680 |
+
content = content.split("</think>")[-1].lstrip("\n")
|
| 681 |
+
is_last = idx == n - 1
|
| 682 |
+
if idx > last_query_index and (is_last or reasoning):
|
| 683 |
+
out.append(
|
| 684 |
+
"<|im_start|>assistant\n<think>\n" + reasoning.strip("\n") + "\n</think>\n\n" + content.lstrip("\n")
|
| 685 |
+
)
|
| 686 |
+
else:
|
| 687 |
+
out.append("<|im_start|>assistant\n" + content)
|
| 688 |
+
out.append("<|im_end|>\n")
|
| 689 |
+
if add_generation_prompt:
|
| 690 |
+
out.append("<|im_start|>assistant\n")
|
| 691 |
+
if enable_thinking is False:
|
| 692 |
+
out.append("<think>\n\n</think>\n\n")
|
| 693 |
+
return "".join(out)
|
code/align/tokenizer/core.py
ADDED
|
@@ -0,0 +1,615 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pure-Python (stdlib only) BPE tokenizer for Mistral-7B-v0.3.
|
| 2 |
+
|
| 3 |
+
This is a from-scratch reimplementation of exactly the pipeline that
|
| 4 |
+
`tokenizers`/`transformers` run for the v0.3 `tokenizer.json`, and nothing
|
| 5 |
+
else. It exists so the training stack has zero dependency on `transformers`
|
| 6 |
+
(see docs/DEPENDENCY_POLICY.md); parity with the legacy stack is certified by
|
| 7 |
+
the `oracle`-marked test in tests/test_tokenizer.py (hundreds of strings,
|
| 8 |
+
ids AND decode byte-identical).
|
| 9 |
+
|
| 10 |
+
Pipeline (encode):
|
| 11 |
+
|
| 12 |
+
text
|
| 13 |
+
-> split on ADDED tokens (771: <unk> <s> </s> [INST] ... [control_N]),
|
| 14 |
+
leftmost-longest, raw text, BEFORE any BPE (_split_added)
|
| 15 |
+
-> per non-added segment: Metaspace pre-tokenizer (_metaspace)
|
| 16 |
+
' ' -> U+2581, prepend U+2581 per prepend_scheme
|
| 17 |
+
-> BPE over the WHOLE segment (split=false => ONE "word") (_bpe)
|
| 18 |
+
chars not in vocab -> <0xNN> byte-fallback pieces
|
| 19 |
+
-> post-processor: prepend <s> (id 1) iff add_special_tokens
|
| 20 |
+
|
| 21 |
+
Decode: ids -> pieces, optionally drop special pieces, then the decoder chain
|
| 22 |
+
Replace(U+2581 -> ' ') / ByteFallback / Fuse / Strip(' ', start=1).
|
| 23 |
+
|
| 24 |
+
The loader VALIDATES the pipeline configuration and raises
|
| 25 |
+
NotImplementedError on anything this module does not implement. A different
|
| 26 |
+
tokenizer.json must fail loudly, not silently encode wrong ids.
|
| 27 |
+
"""
|
| 28 |
+
from __future__ import annotations
|
| 29 |
+
|
| 30 |
+
import heapq
|
| 31 |
+
import json
|
| 32 |
+
import os
|
| 33 |
+
import re
|
| 34 |
+
from typing import Iterable, Optional, Sequence, Union
|
| 35 |
+
|
| 36 |
+
__all__ = ["Tokenizer", "load_tokenizer"]
|
| 37 |
+
|
| 38 |
+
#: U+2581 LOWER ONE EIGHTH BLOCK, the SentencePiece "space" glyph.
|
| 39 |
+
_SP = "▁"
|
| 40 |
+
_HEX = frozenset("0123456789abcdefABCDEF")
|
| 41 |
+
#: Cap on the per-segment encode cache. With split=false a "word" is a whole
|
| 42 |
+
#: text segment, so the cache only pays off for short, repeated strings
|
| 43 |
+
#: (stop strings, role tags, "\n"); long segments are never cached.
|
| 44 |
+
_CACHE_MAX_ENTRIES = 50_000
|
| 45 |
+
_CACHE_MAX_SEGMENT_CHARS = 128
|
| 46 |
+
|
| 47 |
+
_TOKENIZER_FILES = ("tokenizer.json", "tokenizer_config.json", "special_tokens_map.json")
|
| 48 |
+
#: Optional passthrough: AutoTokenizer on a LlamaTokenizer-class config wants it.
|
| 49 |
+
_OPTIONAL_FILES = ("tokenizer.model",)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _content(tok) -> Optional[str]:
|
| 53 |
+
"""HF configs store a special token as either a bare string or a dict
|
| 54 |
+
{"content": ...}; None means 'unset'."""
|
| 55 |
+
if tok is None:
|
| 56 |
+
return None
|
| 57 |
+
if isinstance(tok, dict):
|
| 58 |
+
return tok.get("content")
|
| 59 |
+
return str(tok)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _expect(cond: bool, what: str) -> None:
|
| 63 |
+
if not cond:
|
| 64 |
+
raise NotImplementedError(
|
| 65 |
+
f"tokenizer.json uses a configuration this pure-Python tokenizer does not "
|
| 66 |
+
f"implement: {what}. Refusing to load rather than silently encode wrong ids."
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class Tokenizer:
|
| 71 |
+
"""Drop-in for the subset of the HF tokenizer API this repo calls.
|
| 72 |
+
|
| 73 |
+
Build with `Tokenizer.from_pretrained(dir)` / `load_tokenizer(dir)`.
|
| 74 |
+
"""
|
| 75 |
+
|
| 76 |
+
def __init__(
|
| 77 |
+
self,
|
| 78 |
+
tokenizer_json: dict,
|
| 79 |
+
tokenizer_config: Optional[dict] = None,
|
| 80 |
+
special_tokens_map: Optional[dict] = None,
|
| 81 |
+
*,
|
| 82 |
+
files: Optional[dict] = None,
|
| 83 |
+
):
|
| 84 |
+
#: raw bytes of the source files, kept so save_pretrained is a true
|
| 85 |
+
#: copy-through that survives the source dir being pruned/rotated.
|
| 86 |
+
self._files: dict[str, bytes] = dict(files or {})
|
| 87 |
+
self._config = dict(tokenizer_config or {})
|
| 88 |
+
self._special_map = dict(special_tokens_map or {})
|
| 89 |
+
self._tokenizer_json = tokenizer_json
|
| 90 |
+
self._validate_pipeline(tokenizer_json)
|
| 91 |
+
# transformers REBUILDS the Metaspace pre-tokenizer from the config's
|
| 92 |
+
# add_prefix_space (measured on 5.15: False changes the ids, while
|
| 93 |
+
# add_bos_token/add_eos_token are ignored by the tokenizer.json path). The
|
| 94 |
+
# pure-Python pipeline follows tokenizer.json only, so refuse loudly rather
|
| 95 |
+
# than silently disagree with the legacy stack. Every v0.3 config has True.
|
| 96 |
+
_expect(
|
| 97 |
+
self._config.get("add_prefix_space") is not False,
|
| 98 |
+
"tokenizer_config.json add_prefix_space=false",
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
model = tokenizer_json["model"]
|
| 102 |
+
vocab: dict[str, int] = dict(model["vocab"])
|
| 103 |
+
|
| 104 |
+
# -- added tokens: override/extend the model vocab, and are matched first.
|
| 105 |
+
added = list(tokenizer_json.get("added_tokens", []))
|
| 106 |
+
self._added: dict[str, int] = {}
|
| 107 |
+
self._special_contents: set[str] = set()
|
| 108 |
+
for t in added:
|
| 109 |
+
_expect(
|
| 110 |
+
not (t.get("lstrip") or t.get("rstrip") or t.get("single_word")),
|
| 111 |
+
f"added token {t['content']!r} with lstrip/rstrip/single_word",
|
| 112 |
+
)
|
| 113 |
+
vocab[t["content"]] = int(t["id"])
|
| 114 |
+
self._added[t["content"]] = int(t["id"])
|
| 115 |
+
if t.get("special"):
|
| 116 |
+
self._special_contents.add(t["content"])
|
| 117 |
+
self._vocab = vocab
|
| 118 |
+
|
| 119 |
+
size = max(vocab.values()) + 1
|
| 120 |
+
self._id_to_token: list[Optional[str]] = [None] * size
|
| 121 |
+
for tok, i in vocab.items():
|
| 122 |
+
self._id_to_token[i] = tok
|
| 123 |
+
self._base_vocab = model["vocab"] # the BPE model's own vocab (ids for symbols)
|
| 124 |
+
|
| 125 |
+
# -- BPE tables. Merges may be [a, b] pairs (older saves) or "a b" strings.
|
| 126 |
+
self._ranks: dict[tuple[str, str], int] = {}
|
| 127 |
+
for rank, m in enumerate(model["merges"]):
|
| 128 |
+
a, b = (m[0], m[1]) if isinstance(m, (list, tuple)) else m.split(" ")
|
| 129 |
+
# Rust's BPE builder errors if the merge result is not in the vocab.
|
| 130 |
+
if (a + b) not in self._base_vocab or a not in self._base_vocab or b not in self._base_vocab:
|
| 131 |
+
raise ValueError(f"merge #{rank} {a!r}+{b!r} references a token outside the vocab")
|
| 132 |
+
# LAST duplicate wins: Rust collects merges into a HashMap, so a repeated
|
| 133 |
+
# pair keeps its final rank (measured against tokenizers 0.22). v0.3 has none.
|
| 134 |
+
self._ranks[(a, b)] = rank
|
| 135 |
+
self._byte_fallback = bool(model.get("byte_fallback"))
|
| 136 |
+
self._byte_pieces: list[Optional[str]] = [
|
| 137 |
+
(f"<0x{b:02X}>" if f"<0x{b:02X}>" in self._base_vocab else None) for b in range(256)
|
| 138 |
+
]
|
| 139 |
+
self._fuse_unk = bool(model.get("fuse_unk"))
|
| 140 |
+
self._model_unk = model.get("unk_token")
|
| 141 |
+
self._ignore_merges = bool(model.get("ignore_merges"))
|
| 142 |
+
self._cache: dict[str, tuple[int, ...]] = {}
|
| 143 |
+
|
| 144 |
+
# -- added-token splitter. Alternation sorted longest-first == leftmost-longest.
|
| 145 |
+
if self._added:
|
| 146 |
+
alts = sorted(self._added, key=len, reverse=True)
|
| 147 |
+
self._added_re: Optional[re.Pattern] = re.compile("|".join(re.escape(a) for a in alts))
|
| 148 |
+
else:
|
| 149 |
+
self._added_re = None
|
| 150 |
+
|
| 151 |
+
# -- Metaspace
|
| 152 |
+
pt = tokenizer_json["pre_tokenizer"]
|
| 153 |
+
self._prepend_scheme = pt.get("prepend_scheme", "always" if pt.get("add_prefix_space", True) else "never")
|
| 154 |
+
|
| 155 |
+
# -- special tokens (strings), resolved config -> special_tokens_map -> defaults
|
| 156 |
+
def pick(name: str, default: Optional[str] = None) -> Optional[str]:
|
| 157 |
+
v = _content(self._config.get(f"{name}_token"))
|
| 158 |
+
if v is None:
|
| 159 |
+
v = _content(self._special_map.get(f"{name}_token"))
|
| 160 |
+
if v is None:
|
| 161 |
+
v = default
|
| 162 |
+
return v if (v is None or v in self._vocab) else None
|
| 163 |
+
|
| 164 |
+
self.bos_token = pick("bos", "<s>")
|
| 165 |
+
self.eos_token = pick("eos", "</s>")
|
| 166 |
+
self.unk_token = pick("unk", self._model_unk)
|
| 167 |
+
self.pad_token = pick("pad") # v0.3 ships none; callers fall back to eos
|
| 168 |
+
self._post_bos = self._post_processor_bos(tokenizer_json.get("post_processor"))
|
| 169 |
+
|
| 170 |
+
# ------------------------------------------------------------------ config
|
| 171 |
+
|
| 172 |
+
@staticmethod
|
| 173 |
+
def _validate_pipeline(tj: dict) -> None:
|
| 174 |
+
model = tj["model"]
|
| 175 |
+
_expect(model.get("type") == "BPE", f"model.type={model.get('type')!r}")
|
| 176 |
+
_expect(not model.get("dropout"), "BPE dropout")
|
| 177 |
+
_expect(not model.get("continuing_subword_prefix"), "continuing_subword_prefix")
|
| 178 |
+
_expect(not model.get("end_of_word_suffix"), "end_of_word_suffix")
|
| 179 |
+
_expect(tj.get("normalizer") is None, "a non-null normalizer")
|
| 180 |
+
pt = tj.get("pre_tokenizer") or {}
|
| 181 |
+
_expect(pt.get("type") == "Metaspace", f"pre_tokenizer {pt.get('type')!r}")
|
| 182 |
+
_expect(pt.get("replacement") == _SP, "Metaspace replacement other than U+2581")
|
| 183 |
+
_expect(pt.get("split") is False, "Metaspace split=true")
|
| 184 |
+
_expect(
|
| 185 |
+
pt.get("prepend_scheme", "always") in ("first", "always", "never"),
|
| 186 |
+
f"Metaspace prepend_scheme {pt.get('prepend_scheme')!r}",
|
| 187 |
+
)
|
| 188 |
+
dec = tj.get("decoder") or {}
|
| 189 |
+
chain = dec.get("decoders") if dec.get("type") == "Sequence" else None
|
| 190 |
+
_expect(chain is not None and len(chain) == 4, "decoder other than the 4-step Sequence")
|
| 191 |
+
r, bf, fu, st = chain
|
| 192 |
+
_expect(
|
| 193 |
+
r.get("type") == "Replace"
|
| 194 |
+
and r.get("pattern") == {"String": _SP}
|
| 195 |
+
and r.get("content") == " ",
|
| 196 |
+
"decoder[0] other than Replace(U+2581 -> ' ')",
|
| 197 |
+
)
|
| 198 |
+
_expect(bf.get("type") == "ByteFallback", "decoder[1] other than ByteFallback")
|
| 199 |
+
_expect(fu.get("type") == "Fuse", "decoder[2] other than Fuse")
|
| 200 |
+
_expect(
|
| 201 |
+
st.get("type") == "Strip"
|
| 202 |
+
and st.get("content") == " "
|
| 203 |
+
and st.get("start") == 1
|
| 204 |
+
and st.get("stop") == 0,
|
| 205 |
+
"decoder[3] other than Strip(' ', start=1, stop=0)",
|
| 206 |
+
)
|
| 207 |
+
|
| 208 |
+
@staticmethod
|
| 209 |
+
def _post_processor_bos(pp: Optional[dict]) -> Optional[str]:
|
| 210 |
+
"""The single-sequence template must be exactly [<special>, A]; returns
|
| 211 |
+
that special token's content (the BOS), or None if there is no post-processor."""
|
| 212 |
+
if pp is None:
|
| 213 |
+
return None
|
| 214 |
+
_expect(pp.get("type") == "TemplateProcessing", f"post_processor {pp.get('type')!r}")
|
| 215 |
+
single = pp.get("single", [])
|
| 216 |
+
_expect(
|
| 217 |
+
len(single) == 2 and "SpecialToken" in single[0] and "Sequence" in single[1],
|
| 218 |
+
"post_processor single template other than [SpecialToken, Sequence]",
|
| 219 |
+
)
|
| 220 |
+
return single[0]["SpecialToken"]["id"]
|
| 221 |
+
|
| 222 |
+
# --------------------------------------------------------------- id access
|
| 223 |
+
|
| 224 |
+
@property
|
| 225 |
+
def bos_token_id(self) -> Optional[int]:
|
| 226 |
+
return self._vocab.get(self.bos_token) if self.bos_token is not None else None
|
| 227 |
+
|
| 228 |
+
@property
|
| 229 |
+
def eos_token_id(self) -> Optional[int]:
|
| 230 |
+
return self._vocab.get(self.eos_token) if self.eos_token is not None else None
|
| 231 |
+
|
| 232 |
+
@property
|
| 233 |
+
def pad_token_id(self) -> Optional[int]:
|
| 234 |
+
"""None on v0.3 -- callers (align.data.encoding.pad_token_id) fall back to eos."""
|
| 235 |
+
return self._vocab.get(self.pad_token) if self.pad_token is not None else None
|
| 236 |
+
|
| 237 |
+
@property
|
| 238 |
+
def unk_token_id(self) -> Optional[int]:
|
| 239 |
+
return self._vocab.get(self.unk_token) if self.unk_token is not None else None
|
| 240 |
+
|
| 241 |
+
@property
|
| 242 |
+
def vocab_size(self) -> int:
|
| 243 |
+
return len(self._base_vocab)
|
| 244 |
+
|
| 245 |
+
@property
|
| 246 |
+
def all_special_tokens(self) -> list[str]:
|
| 247 |
+
return sorted(self._special_contents, key=lambda t: self._vocab[t])
|
| 248 |
+
|
| 249 |
+
@property
|
| 250 |
+
def all_special_ids(self) -> list[int]:
|
| 251 |
+
return [self._vocab[t] for t in self.all_special_tokens]
|
| 252 |
+
|
| 253 |
+
def get_vocab(self) -> dict[str, int]:
|
| 254 |
+
return dict(self._vocab)
|
| 255 |
+
|
| 256 |
+
def get_added_vocab(self) -> dict[str, int]:
|
| 257 |
+
return dict(self._added)
|
| 258 |
+
|
| 259 |
+
def __len__(self) -> int:
|
| 260 |
+
return len(self._vocab)
|
| 261 |
+
|
| 262 |
+
def __repr__(self) -> str:
|
| 263 |
+
return f"Tokenizer(n={len(self)}, merges={len(self._ranks)}, added={len(self._added)})"
|
| 264 |
+
|
| 265 |
+
def convert_ids_to_tokens(self, ids: Union[int, Iterable[int]]):
|
| 266 |
+
"""Pieces for ids; None for ids outside the vocab (HF returns None too)."""
|
| 267 |
+
if isinstance(ids, int):
|
| 268 |
+
return self._piece(ids)
|
| 269 |
+
return [self._piece(int(i)) for i in _as_list(ids)]
|
| 270 |
+
|
| 271 |
+
def convert_tokens_to_ids(self, tokens: Union[str, Iterable[str]]):
|
| 272 |
+
"""Ids for pieces; unknown pieces map to the unk id (HF behaviour)."""
|
| 273 |
+
unk = self.unk_token_id
|
| 274 |
+
if isinstance(tokens, str):
|
| 275 |
+
return self._vocab.get(tokens, unk)
|
| 276 |
+
return [self._vocab.get(t, unk) for t in tokens]
|
| 277 |
+
|
| 278 |
+
def _piece(self, i: int) -> Optional[str]:
|
| 279 |
+
if i < 0:
|
| 280 |
+
raise ValueError(f"negative token id {i}: a label-mask value leaked into a token-id path")
|
| 281 |
+
return self._id_to_token[i] if i < len(self._id_to_token) else None
|
| 282 |
+
|
| 283 |
+
# ------------------------------------------------------------------ encode
|
| 284 |
+
|
| 285 |
+
def __call__(self, text: Union[str, Sequence[str]], add_special_tokens: bool = True) -> dict:
|
| 286 |
+
"""{"input_ids": ...}. A list of strings gives a list of id lists.
|
| 287 |
+
Padding/truncation/tensors are deliberately NOT supported (TypeError on
|
| 288 |
+
unknown kwargs) -- callers pad their own batches."""
|
| 289 |
+
if isinstance(text, str):
|
| 290 |
+
return {"input_ids": self.encode(text, add_special_tokens=add_special_tokens)}
|
| 291 |
+
return {"input_ids": [self.encode(t, add_special_tokens=add_special_tokens) for t in text]}
|
| 292 |
+
|
| 293 |
+
def encode(self, text: str, add_special_tokens: bool = True) -> list[int]:
|
| 294 |
+
if not isinstance(text, str):
|
| 295 |
+
raise TypeError(f"encode() takes str, got {type(text).__name__}")
|
| 296 |
+
try:
|
| 297 |
+
text.encode("utf-8")
|
| 298 |
+
except UnicodeEncodeError as e: # lone surrogate: Rust strings cannot hold one
|
| 299 |
+
raise ValueError(f"text is not valid Unicode (lone surrogate at index {e.start})") from e
|
| 300 |
+
ids: list[int] = []
|
| 301 |
+
if add_special_tokens and self._post_bos is not None:
|
| 302 |
+
ids.append(self._vocab[self._post_bos])
|
| 303 |
+
for k, (is_added, seg) in enumerate(self._split_added(text)):
|
| 304 |
+
if is_added:
|
| 305 |
+
ids.append(self._added[seg])
|
| 306 |
+
continue
|
| 307 |
+
piece = self._metaspace(seg, k == 0)
|
| 308 |
+
if piece:
|
| 309 |
+
ids.extend(self._bpe(piece))
|
| 310 |
+
return ids
|
| 311 |
+
|
| 312 |
+
def _split_added(self, text: str):
|
| 313 |
+
"""Yield (is_added, substring). Added tokens are matched on the RAW text,
|
| 314 |
+
leftmost-longest, and are never fed to BPE (so nothing merges across them).
|
| 315 |
+
No empty segments are produced (matches HF: "" encodes to no tokens)."""
|
| 316 |
+
if self._added_re is None or not text:
|
| 317 |
+
if text:
|
| 318 |
+
yield False, text
|
| 319 |
+
return
|
| 320 |
+
pos = 0
|
| 321 |
+
for m in self._added_re.finditer(text):
|
| 322 |
+
if m.start() > pos:
|
| 323 |
+
yield False, text[pos : m.start()]
|
| 324 |
+
yield True, m.group(0)
|
| 325 |
+
pos = m.end()
|
| 326 |
+
if pos < len(text):
|
| 327 |
+
yield False, text[pos:]
|
| 328 |
+
|
| 329 |
+
def _metaspace(self, seg: str, is_first_split: bool) -> str:
|
| 330 |
+
"""Metaspace(replacement=U+2581, prepend_scheme=..., split=false).
|
| 331 |
+
|
| 332 |
+
' ' -> U+2581 (ONLY the space char; '\\n' and '\\t' stay literal and later
|
| 333 |
+
byte-fall-back). prepend_scheme="first" prepends U+2581 only when this
|
| 334 |
+
segment is split #0 of the WHOLE input, i.e. the text does not begin with
|
| 335 |
+
an added token. Added-token splits are counted (they occupy an index)
|
| 336 |
+
but skip the pre-tokenizer, so a leading special token CONSUMES the
|
| 337 |
+
'first' slot and nothing after it is ever prefixed:
|
| 338 |
+
"hello<s>world" -> ▁hello <s> world
|
| 339 |
+
"<s>hello" -> <s> <s> hello (bare 'hello', NO ▁ -- measured
|
| 340 |
+
against the legacy tokenizer)
|
| 341 |
+
A segment that already starts with U+2581 (leading space / literal U+2581)
|
| 342 |
+
gets no second prefix. 'always' prefixes every such segment; 'never' none."""
|
| 343 |
+
s = seg.replace(" ", _SP)
|
| 344 |
+
scheme = self._prepend_scheme
|
| 345 |
+
if scheme == "never" or s.startswith(_SP):
|
| 346 |
+
return s
|
| 347 |
+
if scheme == "always" or is_first_split:
|
| 348 |
+
return _SP + s
|
| 349 |
+
return s
|
| 350 |
+
|
| 351 |
+
def _bpe(self, word: str) -> tuple[int, ...]:
|
| 352 |
+
cached = self._cache.get(word)
|
| 353 |
+
if cached is not None:
|
| 354 |
+
return cached
|
| 355 |
+
vocab = self._base_vocab
|
| 356 |
+
if self._ignore_merges and word in vocab:
|
| 357 |
+
res = (vocab[word],)
|
| 358 |
+
else:
|
| 359 |
+
res = self._merge(self._initial_symbols(word))
|
| 360 |
+
if len(word) <= _CACHE_MAX_SEGMENT_CHARS:
|
| 361 |
+
if len(self._cache) >= _CACHE_MAX_ENTRIES:
|
| 362 |
+
self._cache.clear()
|
| 363 |
+
self._cache[word] = res
|
| 364 |
+
return res
|
| 365 |
+
|
| 366 |
+
def _initial_symbols(self, word: str) -> list[str]:
|
| 367 |
+
"""One symbol per char if the char is a vocab piece; else its UTF-8 bytes
|
| 368 |
+
as <0xNN> pieces (byte_fallback); else <unk> (consecutive unks fuse).
|
| 369 |
+
|
| 370 |
+
Mirrors the Rust `BPE::merge_word` loop INCLUDING a quirk: an unknown char
|
| 371 |
+
only records a PENDING unk, and the pending unk is flushed solely by the
|
| 372 |
+
next in-vocab char (or end of word). A byte-fallback expansion in between
|
| 373 |
+
neither flushes nor resets it, so the unk lands AFTER those byte pieces
|
| 374 |
+
(and, with fuse_unk, unks on both sides of a byte-fallback char fuse into
|
| 375 |
+
one). Unreachable for v0.3 (all 256 <0xNN> pieces exist, so the unk path
|
| 376 |
+
never fires), but the loader accepts other byte_fallback tables."""
|
| 377 |
+
vocab = self._base_vocab
|
| 378 |
+
syms: list[str] = []
|
| 379 |
+
unk = self._model_unk
|
| 380 |
+
pending_unk = False
|
| 381 |
+
for ch in word:
|
| 382 |
+
if ch in vocab:
|
| 383 |
+
if pending_unk:
|
| 384 |
+
syms.append(unk) # type: ignore[arg-type]
|
| 385 |
+
pending_unk = False
|
| 386 |
+
syms.append(ch)
|
| 387 |
+
continue
|
| 388 |
+
if self._byte_fallback:
|
| 389 |
+
pieces = [self._byte_pieces[b] for b in ch.encode("utf-8")]
|
| 390 |
+
if None not in pieces:
|
| 391 |
+
syms.extend(pieces) # type: ignore[arg-type]
|
| 392 |
+
continue # pending unk is deliberately left untouched (Rust parity)
|
| 393 |
+
if unk is not None:
|
| 394 |
+
if pending_unk and not self._fuse_unk:
|
| 395 |
+
syms.append(unk)
|
| 396 |
+
pending_unk = True
|
| 397 |
+
if pending_unk:
|
| 398 |
+
syms.append(unk) # type: ignore[arg-type]
|
| 399 |
+
return syms
|
| 400 |
+
|
| 401 |
+
def _merge(self, syms: list[str]) -> tuple[int, ...]:
|
| 402 |
+
"""Rank-ordered pair merging over a doubly linked list with a lazy-deletion
|
| 403 |
+
min-heap: O(n log n), so a multi-thousand-char segment (split=false makes
|
| 404 |
+
every text segment ONE word) is not quadratic. Tie-break is lowest rank,
|
| 405 |
+
then leftmost position -- identical to the Rust implementation."""
|
| 406 |
+
n = len(syms)
|
| 407 |
+
vocab = self._base_vocab
|
| 408 |
+
if n < 2:
|
| 409 |
+
return tuple(vocab[s] for s in syms)
|
| 410 |
+
ranks = self._ranks
|
| 411 |
+
nxt = list(range(1, n + 1))
|
| 412 |
+
nxt[-1] = -1
|
| 413 |
+
prv = list(range(-1, n - 1))
|
| 414 |
+
heap: list[tuple[int, int, str, str]] = []
|
| 415 |
+
for i in range(n - 1):
|
| 416 |
+
r = ranks.get((syms[i], syms[i + 1]))
|
| 417 |
+
if r is not None:
|
| 418 |
+
heap.append((r, i, syms[i], syms[i + 1]))
|
| 419 |
+
heapq.heapify(heap)
|
| 420 |
+
pop, push = heapq.heappop, heapq.heappush
|
| 421 |
+
while heap:
|
| 422 |
+
_, i, left, right = pop(heap)
|
| 423 |
+
if syms[i] != left: # stale: left side was merged or absorbed (None)
|
| 424 |
+
continue
|
| 425 |
+
j = nxt[i]
|
| 426 |
+
if j == -1 or syms[j] != right: # stale: right neighbour changed
|
| 427 |
+
continue
|
| 428 |
+
new = left + right
|
| 429 |
+
syms[i] = new
|
| 430 |
+
syms[j] = None # type: ignore[call-overload]
|
| 431 |
+
nj = nxt[j]
|
| 432 |
+
nxt[i] = nj
|
| 433 |
+
if nj != -1:
|
| 434 |
+
prv[nj] = i
|
| 435 |
+
p = prv[i]
|
| 436 |
+
if p != -1:
|
| 437 |
+
r = ranks.get((syms[p], new))
|
| 438 |
+
if r is not None:
|
| 439 |
+
push(heap, (r, p, syms[p], new))
|
| 440 |
+
if nj != -1:
|
| 441 |
+
r = ranks.get((new, syms[nj]))
|
| 442 |
+
if r is not None:
|
| 443 |
+
push(heap, (r, i, new, syms[nj]))
|
| 444 |
+
out = []
|
| 445 |
+
i = 0
|
| 446 |
+
while i != -1:
|
| 447 |
+
out.append(vocab[syms[i]])
|
| 448 |
+
i = nxt[i]
|
| 449 |
+
return tuple(out)
|
| 450 |
+
|
| 451 |
+
# ------------------------------------------------------------------ decode
|
| 452 |
+
|
| 453 |
+
def decode(self, ids, skip_special_tokens: bool = False) -> str:
|
| 454 |
+
"""ids -> text. Default skip_special_tokens=False matches HF (callers that
|
| 455 |
+
want specials dropped, e.g. the inference engine, pass True explicitly).
|
| 456 |
+
|
| 457 |
+
Ids outside the vocab are silently dropped (HF/Rust behaviour); negative
|
| 458 |
+
ids raise (they are masks that leaked, never tokens).
|
| 459 |
+
|
| 460 |
+
Decoder chain, applied to the list of pieces:
|
| 461 |
+
Replace(U+2581 -> ' ') per piece;
|
| 462 |
+
ByteFallback: each maximal run of <0xNN> pieces is UTF-8 decoded as ONE
|
| 463 |
+
unit; an INVALID run becomes one U+FFFD **per byte** (Rust semantics,
|
| 464 |
+
not Python errors='replace', which collapses ill-formed subparts);
|
| 465 |
+
Fuse: concatenate;
|
| 466 |
+
Strip(' ', start=1): drop at most ONE leading space of the fused text.
|
| 467 |
+
The Strip is why decode([id_of('▁hello')]) == 'hello', not ' hello': a
|
| 468 |
+
chunk decoded in isolation loses its leading space. Never decode token
|
| 469 |
+
deltas and concatenate -- decode the whole sequence (the engine does).
|
| 470 |
+
"""
|
| 471 |
+
pieces: list[str] = []
|
| 472 |
+
special = self._special_contents
|
| 473 |
+
for i in _as_list(ids):
|
| 474 |
+
t = self._piece(int(i))
|
| 475 |
+
if t is None:
|
| 476 |
+
continue
|
| 477 |
+
if skip_special_tokens and t in special:
|
| 478 |
+
continue
|
| 479 |
+
pieces.append(t)
|
| 480 |
+
return self._decode_pieces(pieces)
|
| 481 |
+
|
| 482 |
+
@staticmethod
|
| 483 |
+
def _decode_pieces(pieces: list[str]) -> str:
|
| 484 |
+
out: list[str] = []
|
| 485 |
+
run = bytearray()
|
| 486 |
+
|
| 487 |
+
def flush() -> None:
|
| 488 |
+
if run:
|
| 489 |
+
try:
|
| 490 |
+
out.append(run.decode("utf-8"))
|
| 491 |
+
except UnicodeDecodeError:
|
| 492 |
+
out.append("�" * len(run))
|
| 493 |
+
run.clear()
|
| 494 |
+
|
| 495 |
+
for p in pieces:
|
| 496 |
+
p = p.replace(_SP, " ")
|
| 497 |
+
if len(p) == 6 and p.startswith("<0x") and p.endswith(">") and p[3] in _HEX and p[4] in _HEX:
|
| 498 |
+
run.append(int(p[3:5], 16))
|
| 499 |
+
else:
|
| 500 |
+
flush()
|
| 501 |
+
out.append(p)
|
| 502 |
+
flush()
|
| 503 |
+
text = "".join(out) # Fuse
|
| 504 |
+
return text[1:] if text.startswith(" ") else text # Strip(' ', start=1)
|
| 505 |
+
|
| 506 |
+
# -------------------------------------------------------------------- save
|
| 507 |
+
|
| 508 |
+
def save_pretrained(self, save_directory: str) -> tuple[str, ...]:
|
| 509 |
+
"""Write tokenizer.json, tokenizer_config.json, special_tokens_map.json
|
| 510 |
+
(+ tokenizer.model if the source had one). Copy-through of the source
|
| 511 |
+
bytes when loaded from a dir; synthesized (HF-shaped) otherwise. Each file
|
| 512 |
+
is written to a sibling temp name then os.replace()d, so a kill mid-save
|
| 513 |
+
leaves the old file or the new file, never a torn one."""
|
| 514 |
+
os.makedirs(save_directory, exist_ok=True)
|
| 515 |
+
files = dict(self._files)
|
| 516 |
+
if "tokenizer.json" not in files:
|
| 517 |
+
files["tokenizer.json"] = json.dumps(self._tokenizer_json, ensure_ascii=False).encode("utf-8")
|
| 518 |
+
if "tokenizer_config.json" not in files:
|
| 519 |
+
files["tokenizer_config.json"] = json.dumps(self._synth_config(), indent=2).encode("utf-8")
|
| 520 |
+
if "special_tokens_map.json" not in files:
|
| 521 |
+
files["special_tokens_map.json"] = json.dumps(self._synth_special_map(), indent=2).encode("utf-8")
|
| 522 |
+
written = []
|
| 523 |
+
for name, blob in files.items():
|
| 524 |
+
dst = os.path.join(save_directory, name)
|
| 525 |
+
tmp = dst + f".tmp.{os.getpid()}"
|
| 526 |
+
with open(tmp, "wb") as f:
|
| 527 |
+
f.write(blob)
|
| 528 |
+
f.flush()
|
| 529 |
+
os.fsync(f.fileno())
|
| 530 |
+
os.replace(tmp, dst)
|
| 531 |
+
written.append(dst)
|
| 532 |
+
return tuple(written)
|
| 533 |
+
|
| 534 |
+
def _synth_config(self) -> dict:
|
| 535 |
+
return {
|
| 536 |
+
"add_bos_token": self._post_bos is not None,
|
| 537 |
+
"add_eos_token": False,
|
| 538 |
+
"bos_token": self.bos_token,
|
| 539 |
+
"eos_token": self.eos_token,
|
| 540 |
+
"unk_token": self.unk_token,
|
| 541 |
+
"pad_token": self.pad_token,
|
| 542 |
+
"clean_up_tokenization_spaces": False,
|
| 543 |
+
"legacy": False,
|
| 544 |
+
"model_max_length": int(1e30),
|
| 545 |
+
"tokenizer_class": "LlamaTokenizer",
|
| 546 |
+
}
|
| 547 |
+
|
| 548 |
+
def _synth_special_map(self) -> dict:
|
| 549 |
+
d = {}
|
| 550 |
+
for name in ("bos", "eos", "unk", "pad"):
|
| 551 |
+
tok = getattr(self, f"{name}_token")
|
| 552 |
+
if tok is not None:
|
| 553 |
+
d[f"{name}_token"] = {
|
| 554 |
+
"content": tok,
|
| 555 |
+
"lstrip": False,
|
| 556 |
+
"normalized": False,
|
| 557 |
+
"rstrip": False,
|
| 558 |
+
"single_word": False,
|
| 559 |
+
}
|
| 560 |
+
return d
|
| 561 |
+
|
| 562 |
+
# -------------------------------------------------------------------- load
|
| 563 |
+
|
| 564 |
+
@classmethod
|
| 565 |
+
def from_pretrained(cls, path: Union[str, os.PathLike]) -> "Tokenizer":
|
| 566 |
+
"""Load from a directory holding tokenizer.json (a checkpoint dir, or an HF
|
| 567 |
+
snapshot dir), a path to a tokenizer.json, or an HF cache model dir
|
| 568 |
+
(`models--org--name`, newest snapshot with a tokenizer.json). LOCAL ONLY:
|
| 569 |
+
never touches the network, never downloads."""
|
| 570 |
+
d = _resolve_dir(os.fspath(path))
|
| 571 |
+
files: dict[str, bytes] = {}
|
| 572 |
+
for name in _TOKENIZER_FILES + _OPTIONAL_FILES:
|
| 573 |
+
p = os.path.join(d, name)
|
| 574 |
+
if os.path.isfile(p):
|
| 575 |
+
with open(p, "rb") as f:
|
| 576 |
+
files[name] = f.read()
|
| 577 |
+
tj = json.loads(files["tokenizer.json"])
|
| 578 |
+
cfg = json.loads(files["tokenizer_config.json"]) if "tokenizer_config.json" in files else None
|
| 579 |
+
smap = json.loads(files["special_tokens_map.json"]) if "special_tokens_map.json" in files else None
|
| 580 |
+
return cls(tj, cfg, smap, files=files)
|
| 581 |
+
|
| 582 |
+
|
| 583 |
+
def _as_list(ids) -> list:
|
| 584 |
+
"""Accept list/tuple/iterable, numpy arrays and torch tensors (via .tolist())."""
|
| 585 |
+
if hasattr(ids, "tolist"):
|
| 586 |
+
ids = ids.tolist()
|
| 587 |
+
if isinstance(ids, int):
|
| 588 |
+
return [ids]
|
| 589 |
+
return list(ids)
|
| 590 |
+
|
| 591 |
+
|
| 592 |
+
def _resolve_dir(path: str) -> str:
|
| 593 |
+
if os.path.isfile(path) and os.path.basename(path) == "tokenizer.json":
|
| 594 |
+
return os.path.dirname(os.path.abspath(path))
|
| 595 |
+
if os.path.isfile(os.path.join(path, "tokenizer.json")):
|
| 596 |
+
return path
|
| 597 |
+
snaps = os.path.join(path, "snapshots")
|
| 598 |
+
if os.path.isdir(snaps):
|
| 599 |
+
cands = [
|
| 600 |
+
os.path.join(snaps, s)
|
| 601 |
+
for s in os.listdir(snaps)
|
| 602 |
+
if os.path.isfile(os.path.join(snaps, s, "tokenizer.json"))
|
| 603 |
+
]
|
| 604 |
+
if cands:
|
| 605 |
+
return max(cands, key=os.path.getmtime)
|
| 606 |
+
raise FileNotFoundError(
|
| 607 |
+
f"no tokenizer.json at {path!r} (also tried {snaps!r}). align.tokenizer is "
|
| 608 |
+
"local-only by design: it never downloads. Pass a checkpoint dir or an HF "
|
| 609 |
+
"snapshot dir containing tokenizer.json."
|
| 610 |
+
)
|
| 611 |
+
|
| 612 |
+
|
| 613 |
+
def load_tokenizer(path: Union[str, os.PathLike]) -> Tokenizer:
|
| 614 |
+
"""Load a Tokenizer from a local dir/file. See Tokenizer.from_pretrained."""
|
| 615 |
+
return Tokenizer.from_pretrained(path)
|
code/align/train/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The distributed training core (section-07).
|
| 2 |
+
|
| 3 |
+
* `offload_adamw.py` -- optimizer-state-only CPU offload; params and grads
|
| 4 |
+
stay on the training device, moments and the fp32 master live in host RAM.
|
| 5 |
+
* `fsdp.py` -- FSDP2 wrapping + mixed-precision policy, called from
|
| 6 |
+
the launch scripts, never from the Trainer.
|
| 7 |
+
* `trainer.py` -- the one Trainer for SFT and DPO: micro-step loop,
|
| 8 |
+
seeded lossless resume, optimizer-step scheduler, checkpoint cadence.
|
| 9 |
+
* `probes.py` -- trunk-movement probe, run-health tripwires, the
|
| 10 |
+
20-step throughput probe.
|
| 11 |
+
* `sft_loss.py` -- adapter from a collated batch to section-05's
|
| 12 |
+
chunked fused CE.
|
| 13 |
+
|
| 14 |
+
Every module here imports torch at module scope, so nothing above the resource
|
| 15 |
+
guards may import this package.
|
| 16 |
+
"""
|
code/align/train/adamw8bit.py
ADDED
|
@@ -0,0 +1,630 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Block-wise 8-bit AdamW in pure torch: the optimizer that lets full-FT 7B launch.
|
| 2 |
+
|
| 3 |
+
WHY THIS EXISTS (the arithmetic, from .tmp/handoff/review_trainer.md): every
|
| 4 |
+
shipped full-FT config names `optimizer.name = "adamw8bit"`, and the two
|
| 5 |
+
alternatives are both unlaunchable on this box at 7.248B params / 4 ranks:
|
| 6 |
+
|
| 7 |
+
* fp32 AdamW on the device needs 14.5GB/rank of moments on top of a 9.6GB
|
| 8 |
+
activation peak against a 23GB A10G.
|
| 9 |
+
* `OffloadAdamW` pins the WHOLE rank master (n/world*4 B) while its staging
|
| 10 |
+
cap is two layer-shards (n/layers/world*4*2 B): required/cap = layers /
|
| 11 |
+
(world * shards) = 4, so it refuses to construct. Past that, its host RSS
|
| 12 |
+
projects to ~105GB against ~85GB MemAvailable, and the checkpoint clones
|
| 13 |
+
master + both moments (12 B/param).
|
| 14 |
+
|
| 15 |
+
This optimizer keeps both Adam moments as uint8 codes ON THE DEVICE and has no
|
| 16 |
+
master copy at all.
|
| 17 |
+
|
| 18 |
+
MEMORY BUDGET, per rank-local parameter element (block_size B = 2048):
|
| 19 |
+
|
| 20 |
+
exp_avg 1 byte code + 4/B byte absmax = 1.00195 B
|
| 21 |
+
exp_avg_sq 1 byte code + 4/B byte absmax = 1.00195 B
|
| 22 |
+
------------------------------------------------------------
|
| 23 |
+
total 2 + 8/B = 2.0039 B/param (fp32 AdamW: 8; OffloadAdamW: 12 host)
|
| 24 |
+
|
| 25 |
+
7.248e9 params / 4 ranks = 1.812e9 elements/rank -> 3.63 GB (3.38 GiB)/rank
|
| 26 |
+
14.5 GB across the job, all on the GPU, ZERO host RSS (which is what
|
| 27 |
+
`guard.projected_full_ft_rss_gb` already assumes for non-offload).
|
| 28 |
+
|
| 29 |
+
Tensors below `min_8bit_size` (4096) elements -- norm scales, biases -- keep
|
| 30 |
+
fp32 moments (8 B/param); there are ~65 of them in a 32-layer Mistral and
|
| 31 |
+
they total well under a megabyte. The step additionally holds one CHUNK of
|
| 32 |
+
fp32 transients (default 2**21 elements, < 100MB), never a whole tensor.
|
| 33 |
+
`projected_state_bytes` computes the exact figure from element counts and is
|
| 34 |
+
what the launch banner prints.
|
| 35 |
+
|
| 36 |
+
THE STEP, per chunk of whole blocks: dequantize both moments -> fp32 AdamW
|
| 37 |
+
update (decoupled weight decay, bias correction; the same op order as
|
| 38 |
+
`torch.optim.AdamW`'s single-tensor path, so the fp32 fallback is BIT-EXACT
|
| 39 |
+
against it) -> requantize -> store. The update uses the freshly computed
|
| 40 |
+
fp32 moments, not the requantized ones: the quantization error only enters
|
| 41 |
+
through the NEXT step's decayed moment, which is the same order bitsandbytes'
|
| 42 |
+
fused kernel uses (quantization is a storage format, not part of the rule).
|
| 43 |
+
|
| 44 |
+
KNOWN LIMITATION, measured and bounded (not a bug to chase): SECOND-MOMENT
|
| 45 |
+
UNDERFLOW. v is a square, so its dynamic range is the SQUARE of the
|
| 46 |
+
gradient's. The smallest nonzero unsigned code is 3.25e-7 of the block absmax,
|
| 47 |
+
so an element whose gradient is < ~5e-4 of its block's largest one stores v = 0.
|
| 48 |
+
The update never divides by that stored zero (it uses the fresh fp32 moment,
|
| 49 |
+
see above), but the decayed history of v is lost for those elements: right
|
| 50 |
+
after a cold start a tiny-gradient element sees only (1-beta2)*g^2 instead of
|
| 51 |
+
the running average, so its step is up to lr / sqrt(1 - beta2) (4.5 lr at
|
| 52 |
+
beta2 = 0.95) instead of ~lr. Measured on a Gaussian-gradient linear layer: a
|
| 53 |
+
handful of elements (~0.1%) are off by ~1-2 lr on the second step and the mean
|
| 54 |
+
error over all elements is ~1% of the travel -- and that is a COLD-START /
|
| 55 |
+
CHANGING-GRADIENT picture. It does NOT relax for an element whose gradient
|
| 56 |
+
stays below the threshold: v is re-floored to 0 every step, so the over-step is
|
| 57 |
+
PERMANENT, and it GROWS with beta2 (adversarial review, constant g = 1e-4 of the
|
| 58 |
+
block max, 300 steps, travel vs torch.optim.AdamW: 3.9x at beta2 = 0.95, 9.9x at
|
| 59 |
+
0.999 and still rising toward 1/sqrt(1-beta2) = 31.6x; g = 1e-3 of the max is
|
| 60 |
+
1.07x, g = 1e-2 is 1.00x). Clamping every positive v up to the smallest nonzero
|
| 61 |
+
code would turn that into a bounded UNDER-step (0.16x, independent of beta2) at
|
| 62 |
+
the price of freezing those elements; that is a different optimizer, so it is
|
| 63 |
+
the owner's call (`.tmp/review_adamw8bit/probe3.py`), not made here.
|
| 64 |
+
bitsandbytes has the same property
|
| 65 |
+
(same map, same nearest-code rounding); the alternative of storing sqrt(v)
|
| 66 |
+
doubles the relative error of the denominator in exchange for the range, which
|
| 67 |
+
is a different optimizer than the one specified, so it is not done here.
|
| 68 |
+
`tests/test_adamw8bit.py::test_second_moment_underflow_...` pins the bound.
|
| 69 |
+
|
| 70 |
+
NO MASTER COPY, AND WHAT THAT COSTS. The parameters are bf16 and the update is
|
| 71 |
+
tiny: at lr 1e-5 an update is ~1e-5 against a bf16 weight of ~2e-2 whose
|
| 72 |
+
spacing is 1.2e-4, so round-to-nearest drops almost every update -- a run
|
| 73 |
+
whose loss curve is flat and whose weights "never move" (the same
|
| 74 |
+
silent-no-train class as the ZeRO-3 + Adafactor incident). The fp32 master that
|
| 75 |
+
`OffloadAdamW` pays 4 B/param for exists to prevent exactly this. Here the
|
| 76 |
+
write-back to a bf16 parameter is STOCHASTIC ROUNDING by default: the fp32
|
| 77 |
+
result is rounded up or down with probability proportional to proximity, so the
|
| 78 |
+
parameter moves by the right amount IN EXPECTATION. It is deterministic given
|
| 79 |
+
the seed, and its generator state is checkpointed, so resume stays bit-exact.
|
| 80 |
+
Pass `stochastic_rounding=False` for round-to-nearest (what bnb does).
|
| 81 |
+
|
| 82 |
+
FSDP2 / DTENSOR (mirrors `OffloadAdamW`, same defect history): under
|
| 83 |
+
`fully_shard` the parameters are DTensors. AdamW is elementwise, so stepping
|
| 84 |
+
`param.to_local()` against the grad's local shard is EXACT; every access goes
|
| 85 |
+
through `_localize` (a storage-sharing view, so in-place updates reach the
|
| 86 |
+
DTensor). Numels are THIS RANK'S, never the global count.
|
| 87 |
+
|
| 88 |
+
GLOBAL GRAD NORM (mirrors `OffloadAdamW._grad_norm`): the sum of squares is
|
| 89 |
+
all-reduced across ranks before the root, so `last_grad_norm` is the true
|
| 90 |
+
global norm, identical on every rank, and is recorded BEFORE clipping --
|
| 91 |
+
the tripwires that read it must see an explosion, not its clipped image.
|
| 92 |
+
The same all-reduced value is the finiteness gate: a non-finite gradient on
|
| 93 |
+
ANY rank makes the global sum non-finite on EVERY rank, so all ranks skip
|
| 94 |
+
together with one collective instead of two. (In float64 the sum of squares
|
| 95 |
+
of finite fp32 values cannot overflow, so non-finite sum <=> non-finite grad.)
|
| 96 |
+
|
| 97 |
+
RESUME: `state_dict()` is a snapshot of the uint8 codes, the absmax, the step
|
| 98 |
+
counts, the hyper-parameters and the rounding RNG, all cloned to CPU. Reloading
|
| 99 |
+
copies them back byte for byte, so a resumed run is bit-identical to an
|
| 100 |
+
uninterrupted one. The state is SHARD-shaped: it records `world_size` and
|
| 101 |
+
every tensor's element count and REFUSES a mismatched resume rather than
|
| 102 |
+
aligning rank r's moments to a different slice of the parameter.
|
| 103 |
+
"""
|
| 104 |
+
|
| 105 |
+
from __future__ import annotations
|
| 106 |
+
|
| 107 |
+
import logging
|
| 108 |
+
import math
|
| 109 |
+
from typing import Iterable, Optional, Sequence
|
| 110 |
+
|
| 111 |
+
import torch
|
| 112 |
+
|
| 113 |
+
from ..utils.dist import rank, world_size
|
| 114 |
+
from .offload_adamw import OffloadAdamW
|
| 115 |
+
from .quant8 import (
|
| 116 |
+
codebook,
|
| 117 |
+
dequantize_rows,
|
| 118 |
+
quantize_rows,
|
| 119 |
+
zero_code,
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
logger = logging.getLogger(__name__)
|
| 123 |
+
|
| 124 |
+
STATE_FORMAT = "adamw8bit-blockwise-v1"
|
| 125 |
+
DEFAULT_BLOCK_SIZE = 2048
|
| 126 |
+
#: bnb's `min_8bit_size`: below this a tensor's moments stay fp32.
|
| 127 |
+
DEFAULT_MIN_8BIT_SIZE = 4096
|
| 128 |
+
#: Elements processed per fused dequant/update/requant pass (rounded down to
|
| 129 |
+
#: whole blocks). Bounds the fp32 transients; see the module docstring.
|
| 130 |
+
DEFAULT_CHUNK_ELEMS = 2**21
|
| 131 |
+
_FP32_MAX = float(torch.finfo(torch.float32).max)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def adamw_update_(
|
| 135 |
+
p: torch.Tensor,
|
| 136 |
+
g: torch.Tensor,
|
| 137 |
+
m: torch.Tensor,
|
| 138 |
+
v: torch.Tensor,
|
| 139 |
+
*,
|
| 140 |
+
lr: float,
|
| 141 |
+
beta1: float,
|
| 142 |
+
beta2: float,
|
| 143 |
+
eps: float,
|
| 144 |
+
weight_decay: float,
|
| 145 |
+
step: int,
|
| 146 |
+
) -> None:
|
| 147 |
+
"""One AdamW update on fp32 tensors, IN PLACE on `p`, `m`, `v`.
|
| 148 |
+
|
| 149 |
+
The op sequence is `torch.optim.AdamW._single_tensor_adamw` verbatim
|
| 150 |
+
(decoupled decay first, `lerp_` for the first moment, `addcmul_` for the
|
| 151 |
+
second, bias-correction folded into step_size and the denominator). That
|
| 152 |
+
is what makes "fp32 fallback == torch AdamW" a property of the
|
| 153 |
+
construction: a reordering that is algebraically identical is NOT
|
| 154 |
+
bit-identical in fp32, and a test that tolerates 1e-6 would hide the day
|
| 155 |
+
someone swaps `lerp_` for a hand-written EMA.
|
| 156 |
+
"""
|
| 157 |
+
if weight_decay != 0:
|
| 158 |
+
p.mul_(1 - lr * weight_decay)
|
| 159 |
+
m.lerp_(g, 1 - beta1)
|
| 160 |
+
v.mul_(beta2).addcmul_(g, g, value=1 - beta2)
|
| 161 |
+
bias_correction1 = 1 - beta1**step
|
| 162 |
+
bias_correction2 = 1 - beta2**step
|
| 163 |
+
step_size = lr / bias_correction1
|
| 164 |
+
bias_correction2_sqrt = bias_correction2**0.5
|
| 165 |
+
denom = (v.sqrt() / bias_correction2_sqrt).add_(eps)
|
| 166 |
+
p.addcdiv_(m, denom, value=-step_size)
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def stochastic_round_to_bf16(x: torch.Tensor, generator: Optional[torch.Generator]) -> torch.Tensor:
|
| 170 |
+
"""fp32 -> bf16 with stochastic rounding (unbiased: E[out] == x).
|
| 171 |
+
|
| 172 |
+
bf16 is exactly the top 16 bits of an fp32. Adding a uniform random
|
| 173 |
+
integer in [0, 2**16) to the raw bit pattern and truncating the low 16 bits
|
| 174 |
+
rounds UP with probability (low16 / 2**16) -- i.e. in proportion to how
|
| 175 |
+
close x is to the next representable value -- and an already-representable
|
| 176 |
+
x (low16 == 0) is returned unchanged. The add acts on magnitude for both
|
| 177 |
+
signs, because the sign is the top bit and the carry never reaches it for
|
| 178 |
+
finite inputs. Truncation back to bf16 is then exact (`.to(bfloat16)` of a
|
| 179 |
+
value whose low 16 bits are zero).
|
| 180 |
+
"""
|
| 181 |
+
x = x.contiguous()
|
| 182 |
+
noise = torch.randint(
|
| 183 |
+
0, 1 << 16, x.shape, dtype=torch.int32, device=x.device, generator=generator
|
| 184 |
+
)
|
| 185 |
+
bits = (x.view(torch.int32) + noise) & -65536
|
| 186 |
+
return bits.view(torch.float32).to(torch.bfloat16)
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
class BlockwiseAdamW8bit:
|
| 190 |
+
"""AdamW with block-wise 8-bit moments, stepping each rank's LOCAL shard.
|
| 191 |
+
|
| 192 |
+
`step()` returns False when the grad-skip gate refused the step (same
|
| 193 |
+
contract as `OffloadAdamW`), True when it applied.
|
| 194 |
+
|
| 195 |
+
Args:
|
| 196 |
+
params: the parameters (plain tensors or FSDP2 DTensors). Floating
|
| 197 |
+
point, contiguous locally, no duplicates.
|
| 198 |
+
lr, betas, eps, weight_decay: AdamW; decay is DECOUPLED (applied to
|
| 199 |
+
the weight, not folded into the gradient).
|
| 200 |
+
max_grad_norm: if set, gradients are scaled by
|
| 201 |
+
`min(1, max_grad_norm / (norm + 1e-6))` -- `clip_grad_norm_`'s rule
|
| 202 |
+
-- using the GLOBAL norm. `last_grad_norm` is the pre-clip norm.
|
| 203 |
+
The scale is applied inside the fused update; `p.grad` itself is
|
| 204 |
+
not rewritten (it is about to be zeroed, and rewriting a bf16 grad
|
| 205 |
+
in place would only add rounding).
|
| 206 |
+
block_size: elements per absmax block (default 2048).
|
| 207 |
+
min_8bit_size: tensors with fewer local elements keep fp32 moments.
|
| 208 |
+
chunk_elems: fp32-transient bound for the fused update.
|
| 209 |
+
stochastic_rounding: stochastic (default) vs nearest rounding when the
|
| 210 |
+
fp32 result is written back to a bf16 parameter. See module doc.
|
| 211 |
+
seed: base seed of the rounding RNG (offset by rank).
|
| 212 |
+
"""
|
| 213 |
+
|
| 214 |
+
def __init__(
|
| 215 |
+
self,
|
| 216 |
+
params: Iterable[torch.Tensor],
|
| 217 |
+
lr: float,
|
| 218 |
+
betas: tuple[float, float] = (0.9, 0.95),
|
| 219 |
+
eps: float = 1e-8,
|
| 220 |
+
weight_decay: float = 0.0,
|
| 221 |
+
max_grad_norm: Optional[float] = None,
|
| 222 |
+
block_size: int = DEFAULT_BLOCK_SIZE,
|
| 223 |
+
min_8bit_size: int = DEFAULT_MIN_8BIT_SIZE,
|
| 224 |
+
chunk_elems: int = DEFAULT_CHUNK_ELEMS,
|
| 225 |
+
stochastic_rounding: bool = True,
|
| 226 |
+
seed: int = 0,
|
| 227 |
+
) -> None:
|
| 228 |
+
self.params: list[torch.Tensor] = list(params)
|
| 229 |
+
if not self.params:
|
| 230 |
+
raise ValueError("BlockwiseAdamW8bit got an empty parameter list")
|
| 231 |
+
if len({id(p) for p in self.params}) != len(self.params):
|
| 232 |
+
raise ValueError(
|
| 233 |
+
"BlockwiseAdamW8bit got the same parameter object twice (tied weights?): "
|
| 234 |
+
"it would be stepped twice per step with one shared state slot"
|
| 235 |
+
)
|
| 236 |
+
if not (isinstance(block_size, int) and block_size >= 1):
|
| 237 |
+
raise ValueError(f"block_size must be a positive int, got {block_size!r}")
|
| 238 |
+
if min_8bit_size < 0 or chunk_elems < 1:
|
| 239 |
+
raise ValueError("min_8bit_size must be >= 0 and chunk_elems >= 1")
|
| 240 |
+
if lr < 0 or eps <= 0 or weight_decay < 0:
|
| 241 |
+
raise ValueError(f"invalid AdamW hyper-parameters lr={lr} eps={eps} wd={weight_decay}")
|
| 242 |
+
if not (0.0 <= betas[0] < 1.0 and 0.0 <= betas[1] < 1.0):
|
| 243 |
+
raise ValueError(f"betas must be in [0, 1), got {betas}")
|
| 244 |
+
if max_grad_norm is not None and not max_grad_norm > 0:
|
| 245 |
+
raise ValueError(f"max_grad_norm must be positive or None, got {max_grad_norm}")
|
| 246 |
+
|
| 247 |
+
self.block_size = block_size
|
| 248 |
+
self.min_8bit_size = min_8bit_size
|
| 249 |
+
self.chunk_blocks = max(1, chunk_elems // block_size)
|
| 250 |
+
self.stochastic_rounding = bool(stochastic_rounding)
|
| 251 |
+
self.seed = int(seed)
|
| 252 |
+
self.banner_tag = "adamw8bit"
|
| 253 |
+
self.skipped_steps = 0
|
| 254 |
+
self.last_grad_norm = float("nan")
|
| 255 |
+
#: One group, like OffloadAdamW: `Trainer._apply_scheduled_lr` writes
|
| 256 |
+
#: the scheduled lr into every group, and `step` reads it from here.
|
| 257 |
+
self.param_groups = [
|
| 258 |
+
{
|
| 259 |
+
"params": self.params,
|
| 260 |
+
"lr": float(lr),
|
| 261 |
+
"betas": (float(betas[0]), float(betas[1])),
|
| 262 |
+
"eps": float(eps),
|
| 263 |
+
"weight_decay": float(weight_decay),
|
| 264 |
+
"max_grad_norm": None if max_grad_norm is None else float(max_grad_norm),
|
| 265 |
+
}
|
| 266 |
+
]
|
| 267 |
+
self._generators: dict[str, torch.Generator] = {}
|
| 268 |
+
|
| 269 |
+
self._state: list[dict] = []
|
| 270 |
+
for i, p in enumerate(self.params):
|
| 271 |
+
if not p.dtype.is_floating_point:
|
| 272 |
+
raise ValueError(f"param {i} has non-floating dtype {p.dtype}")
|
| 273 |
+
local = self._localize(p)
|
| 274 |
+
if not local.is_contiguous():
|
| 275 |
+
raise ValueError(
|
| 276 |
+
f"param {i} (local shape {tuple(local.shape)}) is not contiguous: the "
|
| 277 |
+
"blockwise state is laid out over the flat local shard. FSDP2 dim-0 "
|
| 278 |
+
"shards are contiguous; a transposed/strided view is not supported."
|
| 279 |
+
)
|
| 280 |
+
self._state.append(self._init_slot(local))
|
| 281 |
+
|
| 282 |
+
logger.info(
|
| 283 |
+
"BlockwiseAdamW8bit: %d tensors, %.2fM local elements, state %.3fGiB "
|
| 284 |
+
"(block %d, min_8bit_size %d, stochastic_rounding=%s)",
|
| 285 |
+
len(self.params),
|
| 286 |
+
sum(s["numel"] for s in self._state) / 1e6,
|
| 287 |
+
self.state_bytes() / 2**30,
|
| 288 |
+
block_size,
|
| 289 |
+
min_8bit_size,
|
| 290 |
+
self.stochastic_rounding,
|
| 291 |
+
)
|
| 292 |
+
|
| 293 |
+
# -- layout --------------------------------------------------------------
|
| 294 |
+
|
| 295 |
+
@staticmethod
|
| 296 |
+
def _localize(t):
|
| 297 |
+
"""This rank's local shard of an FSDP2 DTensor (a storage-sharing
|
| 298 |
+
view), or `t` itself. Identical contract to OffloadAdamW._localize."""
|
| 299 |
+
return t.to_local() if hasattr(t, "to_local") else t
|
| 300 |
+
|
| 301 |
+
def _is_quantized(self, numel: int) -> bool:
|
| 302 |
+
return numel >= self.min_8bit_size and numel > 0
|
| 303 |
+
|
| 304 |
+
def _init_slot(self, local: torch.Tensor) -> dict:
|
| 305 |
+
n = local.numel()
|
| 306 |
+
dev = local.device
|
| 307 |
+
slot: dict = {"numel": n, "step": 0, "quantized": self._is_quantized(n)}
|
| 308 |
+
if slot["quantized"]:
|
| 309 |
+
nb = -(-n // self.block_size)
|
| 310 |
+
# Codes are allocated in WHOLE blocks (the last block is padded with
|
| 311 |
+
# the zero code) so every chunk is a clean [blocks, block_size] row
|
| 312 |
+
# matrix; the pad costs < one block per tensor.
|
| 313 |
+
slot["m_codes"] = torch.full(
|
| 314 |
+
(nb, self.block_size), zero_code(True), dtype=torch.uint8, device=dev
|
| 315 |
+
)
|
| 316 |
+
slot["v_codes"] = torch.full(
|
| 317 |
+
(nb, self.block_size), zero_code(False), dtype=torch.uint8, device=dev
|
| 318 |
+
)
|
| 319 |
+
slot["m_absmax"] = torch.zeros(nb, dtype=torch.float32, device=dev)
|
| 320 |
+
slot["v_absmax"] = torch.zeros(nb, dtype=torch.float32, device=dev)
|
| 321 |
+
else:
|
| 322 |
+
slot["m"] = torch.zeros(n, dtype=torch.float32, device=dev)
|
| 323 |
+
slot["v"] = torch.zeros(n, dtype=torch.float32, device=dev)
|
| 324 |
+
return slot
|
| 325 |
+
|
| 326 |
+
@staticmethod
|
| 327 |
+
def projected_state_bytes(
|
| 328 |
+
local_numels: Sequence[int],
|
| 329 |
+
block_size: int = DEFAULT_BLOCK_SIZE,
|
| 330 |
+
min_8bit_size: int = DEFAULT_MIN_8BIT_SIZE,
|
| 331 |
+
) -> int:
|
| 332 |
+
"""Exact optimizer-state bytes for these RANK-LOCAL element counts.
|
| 333 |
+
|
| 334 |
+
Large tensors: `2*n + 8*n/block_size` (two uint8 code arrays plus two
|
| 335 |
+
fp32 absmax arrays), with `n` rounded up to whole blocks because that
|
| 336 |
+
is how the codes are allocated. Small tensors: 8*n (two fp32 moments).
|
| 337 |
+
Computable BEFORE construction, so a launch banner or preflight can
|
| 338 |
+
state the number without allocating it.
|
| 339 |
+
"""
|
| 340 |
+
total = 0
|
| 341 |
+
for n in local_numels:
|
| 342 |
+
if n >= min_8bit_size and n > 0:
|
| 343 |
+
nb = -(-n // block_size)
|
| 344 |
+
total += 2 * nb * block_size + 8 * nb
|
| 345 |
+
else:
|
| 346 |
+
total += 8 * n
|
| 347 |
+
return int(total)
|
| 348 |
+
|
| 349 |
+
def state_bytes(self) -> int:
|
| 350 |
+
"""Bytes actually held by this optimizer's state tensors on this rank."""
|
| 351 |
+
total = 0
|
| 352 |
+
for s in self._state:
|
| 353 |
+
for k, v in s.items():
|
| 354 |
+
if isinstance(v, torch.Tensor):
|
| 355 |
+
total += v.numel() * v.element_size()
|
| 356 |
+
return total
|
| 357 |
+
|
| 358 |
+
def describe_placement(self) -> dict:
|
| 359 |
+
"""Where the state lives (device/dtype/bytes), for the banner and the
|
| 360 |
+
placement test -- a 'the state is 8-bit' claim nobody checks is how it
|
| 361 |
+
quietly stops being true."""
|
| 362 |
+
ents = []
|
| 363 |
+
for s in self._state:
|
| 364 |
+
for k, v in s.items():
|
| 365 |
+
if isinstance(v, torch.Tensor):
|
| 366 |
+
ents.append(
|
| 367 |
+
{"key": k, "device": v.device.type, "dtype": str(v.dtype),
|
| 368 |
+
"bytes": v.numel() * v.element_size()}
|
| 369 |
+
)
|
| 370 |
+
return {"state": ents, "state_bytes": self.state_bytes()}
|
| 371 |
+
|
| 372 |
+
# -- rounding RNG --------------------------------------------------------
|
| 373 |
+
|
| 374 |
+
def _generator(self, device: torch.device) -> torch.Generator:
|
| 375 |
+
key = device.type
|
| 376 |
+
gen = self._generators.get(key)
|
| 377 |
+
if gen is None:
|
| 378 |
+
gen = torch.Generator(device=device)
|
| 379 |
+
gen.manual_seed(self.seed + rank())
|
| 380 |
+
self._generators[key] = gen
|
| 381 |
+
return gen
|
| 382 |
+
|
| 383 |
+
def _write_param(self, dst: torch.Tensor, src32: torch.Tensor) -> None:
|
| 384 |
+
"""Write the fp32 result into the (possibly bf16) parameter chunk."""
|
| 385 |
+
if dst.dtype == torch.float32:
|
| 386 |
+
if dst.data_ptr() != src32.data_ptr():
|
| 387 |
+
dst.copy_(src32)
|
| 388 |
+
elif dst.dtype == torch.bfloat16 and self.stochastic_rounding:
|
| 389 |
+
dst.copy_(stochastic_round_to_bf16(src32, self._generator(dst.device)))
|
| 390 |
+
else:
|
| 391 |
+
dst.copy_(src32)
|
| 392 |
+
|
| 393 |
+
# -- the step ------------------------------------------------------------
|
| 394 |
+
|
| 395 |
+
@torch.no_grad()
|
| 396 |
+
def step(self) -> bool:
|
| 397 |
+
"""One optimizer step, or a refusal.
|
| 398 |
+
|
| 399 |
+
Order is load-bearing: the (global) norm / finiteness verdict is reached
|
| 400 |
+
BEFORE any moment is touched, so a skipped step leaves the state
|
| 401 |
+
byte-identical. A discovered-too-late NaN would already have poisoned
|
| 402 |
+
the second moment, and every later step is scaled by it.
|
| 403 |
+
"""
|
| 404 |
+
grads = [None if p.grad is None else self._localize(p.grad) for p in self.params]
|
| 405 |
+
present = [g for g in grads if g is not None]
|
| 406 |
+
if not present:
|
| 407 |
+
raise RuntimeError(
|
| 408 |
+
"BlockwiseAdamW8bit.step() called with no gradients on any parameter. "
|
| 409 |
+
"A step that silently does nothing is indistinguishable from a run "
|
| 410 |
+
"that trained."
|
| 411 |
+
)
|
| 412 |
+
for i, (g, s) in enumerate(zip(grads, self._state)):
|
| 413 |
+
if g is not None and g.numel() != s["numel"]:
|
| 414 |
+
raise ValueError(
|
| 415 |
+
f"param {i}: local grad has {g.numel()} elements but the state was "
|
| 416 |
+
f"built for {s['numel']} (grad of a different shard layout?)"
|
| 417 |
+
)
|
| 418 |
+
|
| 419 |
+
norm = OffloadAdamW._grad_norm(present)
|
| 420 |
+
if not math.isfinite(norm):
|
| 421 |
+
self.skipped_steps += 1
|
| 422 |
+
# NaN, not 0.0: a 0.0 in the log reads as 'perfectly converged'.
|
| 423 |
+
self.last_grad_norm = float("nan")
|
| 424 |
+
logger.warning(
|
| 425 |
+
"grad-skip gate: non-finite gradient, skipping optimizer step "
|
| 426 |
+
"(%d skipped so far). Moments and step counts left untouched.",
|
| 427 |
+
self.skipped_steps,
|
| 428 |
+
)
|
| 429 |
+
return False
|
| 430 |
+
self.last_grad_norm = norm # PRE-clip, on purpose
|
| 431 |
+
|
| 432 |
+
group = self.param_groups[0]
|
| 433 |
+
coef = 1.0
|
| 434 |
+
max_norm = group["max_grad_norm"]
|
| 435 |
+
if max_norm is not None:
|
| 436 |
+
coef = min(1.0, max_norm / (norm + 1e-6))
|
| 437 |
+
|
| 438 |
+
for p, g, st in zip(self.params, grads, self._state):
|
| 439 |
+
if g is None or st["numel"] == 0:
|
| 440 |
+
continue
|
| 441 |
+
st["step"] += 1
|
| 442 |
+
local = self._localize(p).detach().view(-1)
|
| 443 |
+
gflat = g.detach().reshape(-1)
|
| 444 |
+
if st["quantized"]:
|
| 445 |
+
self._step_quantized(local, gflat, st, group, coef)
|
| 446 |
+
else:
|
| 447 |
+
self._step_fp32(local, gflat, st, group, coef)
|
| 448 |
+
return True
|
| 449 |
+
|
| 450 |
+
def _hyper(self, group: dict, st: dict) -> dict:
|
| 451 |
+
return dict(
|
| 452 |
+
lr=group["lr"],
|
| 453 |
+
beta1=group["betas"][0],
|
| 454 |
+
beta2=group["betas"][1],
|
| 455 |
+
eps=group["eps"],
|
| 456 |
+
weight_decay=group["weight_decay"],
|
| 457 |
+
step=st["step"],
|
| 458 |
+
)
|
| 459 |
+
|
| 460 |
+
def _step_fp32(self, pflat, gflat, st, group, coef) -> None:
|
| 461 |
+
g32 = gflat.to(torch.float32)
|
| 462 |
+
if coef < 1.0:
|
| 463 |
+
g32 = g32 * coef # out of place: g32 may alias the live .grad
|
| 464 |
+
p32 = pflat.to(torch.float32) # aliases pflat when it is already fp32
|
| 465 |
+
adamw_update_(p32, g32, st["m"], st["v"], **self._hyper(group, st))
|
| 466 |
+
self._write_param(pflat, p32)
|
| 467 |
+
|
| 468 |
+
def _step_quantized(self, pflat, gflat, st, group, coef) -> None:
|
| 469 |
+
B = self.block_size
|
| 470 |
+
n = st["numel"]
|
| 471 |
+
n_blocks = st["m_codes"].shape[0]
|
| 472 |
+
cb_m = codebook(True, pflat.device)
|
| 473 |
+
cb_v = codebook(False, pflat.device)
|
| 474 |
+
hyper = self._hyper(group, st)
|
| 475 |
+
for b0 in range(0, n_blocks, self.chunk_blocks):
|
| 476 |
+
b1 = min(n_blocks, b0 + self.chunk_blocks)
|
| 477 |
+
lo, hi = b0 * B, min(n, b1 * B)
|
| 478 |
+
ln = hi - lo
|
| 479 |
+
g32 = gflat[lo:hi].to(torch.float32)
|
| 480 |
+
if coef < 1.0:
|
| 481 |
+
g32 = g32 * coef
|
| 482 |
+
m_full = dequantize_rows(st["m_codes"][b0:b1], st["m_absmax"][b0:b1], cb_m).view(-1)
|
| 483 |
+
v_full = dequantize_rows(st["v_codes"][b0:b1], st["v_absmax"][b0:b1], cb_v).view(-1)
|
| 484 |
+
pchunk = pflat[lo:hi]
|
| 485 |
+
p32 = pchunk.to(torch.float32)
|
| 486 |
+
# [:ln] are views of the block-padded buffers: the pad tail is never
|
| 487 |
+
# touched by the update (it stays exactly 0), then the whole padded
|
| 488 |
+
# rows are requantized.
|
| 489 |
+
adamw_update_(p32, g32, m_full[:ln], v_full[:ln], **hyper)
|
| 490 |
+
self._write_param(pchunk, p32)
|
| 491 |
+
# THE V-OVERFLOW POISON (adversarial review, 2026-10-12). The finiteness
|
| 492 |
+
# gate in `step` tests the NORM, which is accumulated in float64 and so
|
| 493 |
+
# stays finite for a gradient the fp32 SQUARE cannot hold: one element of
|
| 494 |
+
# 1e21 gives v = (1-beta2)*g^2 = inf. torch.optim.AdamW survives that
|
| 495 |
+
# (m / (inf + eps) == 0), but here inf becomes the block's absmax, the
|
| 496 |
+
# requantize divides inf/inf = NaN, the NaN lands in the uint8 codes,
|
| 497 |
+
# and the block's weights go NaN on the NEXT step, with the gate seeing
|
| 498 |
+
# only finite gradients. Clamping v to the largest finite fp32 keeps the
|
| 499 |
+
# update identical (m / 1.8e19 is still 0 for any sane m) and the stored
|
| 500 |
+
# state finite. Only v can overflow: m is a convex mix of finite values.
|
| 501 |
+
v_full.clamp_(max=_FP32_MAX)
|
| 502 |
+
codes, absmax = quantize_rows(m_full.view(-1, B), cb_m)
|
| 503 |
+
st["m_codes"][b0:b1] = codes
|
| 504 |
+
st["m_absmax"][b0:b1] = absmax
|
| 505 |
+
codes, absmax = quantize_rows(v_full.view(-1, B), cb_v)
|
| 506 |
+
st["v_codes"][b0:b1] = codes
|
| 507 |
+
st["v_absmax"][b0:b1] = absmax
|
| 508 |
+
|
| 509 |
+
def zero_grad(self, set_to_none: bool = True) -> None:
|
| 510 |
+
for p in self.params:
|
| 511 |
+
if set_to_none:
|
| 512 |
+
p.grad = None
|
| 513 |
+
elif p.grad is not None:
|
| 514 |
+
p.grad.zero_()
|
| 515 |
+
|
| 516 |
+
# -- resume --------------------------------------------------------------
|
| 517 |
+
|
| 518 |
+
def state_dict(self) -> dict:
|
| 519 |
+
"""A DETACHED CPU COPY of everything the next step depends on.
|
| 520 |
+
|
| 521 |
+
`.to("cpu", copy=True)`, not `.to("cpu")`: on a CPU tensor the latter
|
| 522 |
+
returns the SAME object, and a "snapshot" that aliases the live codes
|
| 523 |
+
is mutated by the very next step -- the shared-state aliasing incident
|
| 524 |
+
OffloadAdamW documents, here with uint8 codes that no comparison would
|
| 525 |
+
flag as drifting. Everything is copied so the dict is safe to pickle
|
| 526 |
+
straight into the checkpoint gather.
|
| 527 |
+
"""
|
| 528 |
+
slots = []
|
| 529 |
+
for s in self._state:
|
| 530 |
+
slots.append(
|
| 531 |
+
{
|
| 532 |
+
k: (v.detach().to("cpu", copy=True) if isinstance(v, torch.Tensor) else v)
|
| 533 |
+
for k, v in s.items()
|
| 534 |
+
}
|
| 535 |
+
)
|
| 536 |
+
group = self.param_groups[0]
|
| 537 |
+
return {
|
| 538 |
+
"format": STATE_FORMAT,
|
| 539 |
+
"world_size": world_size(),
|
| 540 |
+
"block_size": self.block_size,
|
| 541 |
+
"min_8bit_size": self.min_8bit_size,
|
| 542 |
+
"stochastic_rounding": self.stochastic_rounding,
|
| 543 |
+
"param_group": {k: v for k, v in group.items() if k != "params"},
|
| 544 |
+
"state": slots,
|
| 545 |
+
"skipped_steps": self.skipped_steps,
|
| 546 |
+
"last_grad_norm": self.last_grad_norm,
|
| 547 |
+
"sr_rng": {k: g.get_state().clone() for k, g in self._generators.items()},
|
| 548 |
+
}
|
| 549 |
+
|
| 550 |
+
def load_state_dict(self, sd: dict) -> None:
|
| 551 |
+
"""Restore the state byte for byte. Validates EVERYTHING before writing
|
| 552 |
+
anything, so a refused load leaves the optimizer exactly as it was."""
|
| 553 |
+
if sd.get("format") != STATE_FORMAT:
|
| 554 |
+
raise ValueError(
|
| 555 |
+
f"optimizer state format {sd.get('format')!r} is not {STATE_FORMAT!r}; "
|
| 556 |
+
"refusing to interpret it (a 32-bit or bnb state is not loadable here)"
|
| 557 |
+
)
|
| 558 |
+
saved_ws, now_ws = sd.get("world_size"), world_size()
|
| 559 |
+
if saved_ws is not None and saved_ws != now_ws:
|
| 560 |
+
raise ValueError(
|
| 561 |
+
f"this optimizer state was saved at world_size={saved_ws} but the current "
|
| 562 |
+
f"run has world_size={now_ws}. The 8-bit moments are SHARD-shaped: loading "
|
| 563 |
+
"them under a different world size would align each rank's state to a "
|
| 564 |
+
"different slice of the parameter, and training would continue plausibly "
|
| 565 |
+
"on corrupted state. Resume at the original world size, or start fresh."
|
| 566 |
+
)
|
| 567 |
+
if sd["block_size"] != self.block_size or sd["min_8bit_size"] != self.min_8bit_size:
|
| 568 |
+
raise ValueError(
|
| 569 |
+
f"state was built with block_size={sd['block_size']}, min_8bit_size="
|
| 570 |
+
f"{sd['min_8bit_size']} but this optimizer has {self.block_size}, "
|
| 571 |
+
f"{self.min_8bit_size}: the absmax blocks would not line up"
|
| 572 |
+
)
|
| 573 |
+
saved = sd["state"]
|
| 574 |
+
if len(saved) != len(self._state):
|
| 575 |
+
raise ValueError(
|
| 576 |
+
f"checkpoint holds {len(saved)} tensors but this optimizer has {len(self._state)}"
|
| 577 |
+
)
|
| 578 |
+
for i, (cur, new) in enumerate(zip(self._state, saved)):
|
| 579 |
+
if cur["numel"] != new["numel"] or cur["quantized"] != new["quantized"]:
|
| 580 |
+
raise ValueError(
|
| 581 |
+
f"tensor {i}: checkpoint has numel={new['numel']} quantized="
|
| 582 |
+
f"{new['quantized']}, this optimizer has numel={cur['numel']} quantized="
|
| 583 |
+
f"{cur['quantized']} (a different shard layout)"
|
| 584 |
+
)
|
| 585 |
+
for k, v in cur.items():
|
| 586 |
+
if isinstance(v, torch.Tensor):
|
| 587 |
+
nv = new.get(k)
|
| 588 |
+
if nv is None or nv.shape != v.shape or nv.dtype != v.dtype:
|
| 589 |
+
raise ValueError(
|
| 590 |
+
f"tensor {i} field {k!r}: checkpoint "
|
| 591 |
+
f"{None if nv is None else (tuple(nv.shape), nv.dtype)} vs "
|
| 592 |
+
f"expected {(tuple(v.shape), v.dtype)}"
|
| 593 |
+
)
|
| 594 |
+
if bool(sd.get("stochastic_rounding", self.stochastic_rounding)) != self.stochastic_rounding:
|
| 595 |
+
logger.warning(
|
| 596 |
+
"resuming with stochastic_rounding=%s but the checkpoint was written with %s: "
|
| 597 |
+
"the trajectory will differ from an uninterrupted run",
|
| 598 |
+
self.stochastic_rounding,
|
| 599 |
+
sd.get("stochastic_rounding"),
|
| 600 |
+
)
|
| 601 |
+
|
| 602 |
+
# The rounding RNG is part of the state being restored. A generator that
|
| 603 |
+
# already exists here (this optimizer has stepped) and is ABSENT from `sd`
|
| 604 |
+
# (saved before any bf16 step, or from a fp32 run) would otherwise keep its
|
| 605 |
+
# advanced stream, and the rewound optimizer would round differently from an
|
| 606 |
+
# uninterrupted one -- "byte for byte" quietly untrue (adversarial review,
|
| 607 |
+
# 2026-10-12). Dropping them re-seeds lazily from `seed + rank`, which is
|
| 608 |
+
# exactly the state a never-stepped optimizer has.
|
| 609 |
+
self._generators.clear()
|
| 610 |
+
|
| 611 |
+
# copy_ INTO the existing tensors: keeps their device, and means loading
|
| 612 |
+
# one checkpoint dict into two optimizers cannot make them share state.
|
| 613 |
+
for cur, new in zip(self._state, saved):
|
| 614 |
+
for k, v in cur.items():
|
| 615 |
+
if isinstance(v, torch.Tensor):
|
| 616 |
+
v.copy_(new[k])
|
| 617 |
+
cur["step"] = int(new["step"])
|
| 618 |
+
group = self.param_groups[0]
|
| 619 |
+
for k, v in sd["param_group"].items():
|
| 620 |
+
group[k] = tuple(v) if k == "betas" else v
|
| 621 |
+
self.skipped_steps = int(sd.get("skipped_steps", 0))
|
| 622 |
+
self.last_grad_norm = float(sd.get("last_grad_norm", float("nan")))
|
| 623 |
+
for key, st in sd.get("sr_rng", {}).items():
|
| 624 |
+
first = self.params[0].device
|
| 625 |
+
gen = self._generator(first if first.type == key else torch.device(key))
|
| 626 |
+
gen.set_state(st.detach().to("cpu", copy=True))
|
| 627 |
+
|
| 628 |
+
|
| 629 |
+
#: The name the factory and docs use.
|
| 630 |
+
AdamW8bit = BlockwiseAdamW8bit
|
code/align/train/ckpt_format.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Weight-file formats the Trainer can write and read: the model-family seam.
|
| 2 |
+
|
| 3 |
+
Why this exists. `Trainer.save_checkpoint` / `resume_from` called the dense
|
| 4 |
+
Mistral converters (`save_nmo_as_hf`, `hf_to_nmo(expected_n_layers=
|
| 5 |
+
cfg.model.n_layers)`) directly. Those tables know the 7B dense key names and
|
| 6 |
+
nothing about routers or stacked 3-D expert tensors, so an OLMoE run could be
|
| 7 |
+
built and stepped but never checkpointed -- the first save would raise on a
|
| 8 |
+
key it cannot map (or, worse for a model family added later, silently drop
|
| 9 |
+
parameters if a converter ever became lenient). The seam makes the family an
|
| 10 |
+
explicit, injected choice with ONE contract:
|
| 11 |
+
|
| 12 |
+
save_weights(align_state_dict, out_dir, dtype) -> writes model weights +
|
| 13 |
+
config.json, atomically
|
| 14 |
+
load_weights(path) -> align state dict
|
| 15 |
+
|
| 16 |
+
and nothing else about the checkpoint changes: optimizer state, `resume.pt`,
|
| 17 |
+
fsync/rename/completeness all stay in the Trainer and are family-independent.
|
| 18 |
+
|
| 19 |
+
The default is the dense format built from `cfg.model`, so every existing
|
| 20 |
+
caller is unchanged. A family is selected by construction (`Trainer(...,
|
| 21 |
+
weight_format=OlmoeFormat(olmoe_cfg))`), never sniffed from the state dict:
|
| 22 |
+
guessing the family from key names is how a misconfigured run loads the wrong
|
| 23 |
+
converter and half-works.
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
from __future__ import annotations
|
| 27 |
+
|
| 28 |
+
from typing import Any, Mapping, Optional, Protocol
|
| 29 |
+
|
| 30 |
+
import torch
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class WeightFormat(Protocol):
|
| 34 |
+
def save_weights(
|
| 35 |
+
self, state: Mapping[str, torch.Tensor], out_dir: str, dtype: Optional[torch.dtype]
|
| 36 |
+
) -> None: ...
|
| 37 |
+
|
| 38 |
+
def load_weights(self, path: str) -> dict[str, torch.Tensor]: ...
|
| 39 |
+
|
| 40 |
+
def copy_tokenizer(self, snapshot: str, dst_dir: str) -> list[str]:
|
| 41 |
+
"""Copy the tokenizer files that belong to THIS family into `dst_dir`;
|
| 42 |
+
return the names copied (empty = weights-only checkpoint)."""
|
| 43 |
+
...
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class DenseFormat:
|
| 47 |
+
"""The original behaviour: dense Mistral-family HF checkpoints."""
|
| 48 |
+
|
| 49 |
+
def __init__(self, model_cfg: Any):
|
| 50 |
+
self.model_cfg = model_cfg
|
| 51 |
+
|
| 52 |
+
def save_weights(self, state, out_dir, dtype):
|
| 53 |
+
from ..model import checkpoint as ckpt
|
| 54 |
+
|
| 55 |
+
ckpt.save_nmo_as_hf(dict(state), self.model_cfg, out_dir, dtype=dtype)
|
| 56 |
+
|
| 57 |
+
def load_weights(self, path):
|
| 58 |
+
from ..model import checkpoint as ckpt
|
| 59 |
+
|
| 60 |
+
return ckpt.hf_to_nmo(
|
| 61 |
+
ckpt.load_hf_safetensors(path), expected_n_layers=self.model_cfg.n_layers
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
def copy_tokenizer(self, snapshot, dst_dir):
|
| 65 |
+
from ..model import checkpoint as ckpt
|
| 66 |
+
|
| 67 |
+
return ckpt.copy_tokenizer_files(snapshot, dst_dir)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
#: Files that make up an HF tokenizer, in either of the layouts OLMoE-class
|
| 71 |
+
#: models ship (BPE `tokenizer.json`, or `vocab.json` + `merges.txt`).
|
| 72 |
+
OLMOE_TOKENIZER_FILES = (
|
| 73 |
+
"tokenizer.json",
|
| 74 |
+
"tokenizer_config.json",
|
| 75 |
+
"special_tokens_map.json",
|
| 76 |
+
"vocab.json",
|
| 77 |
+
"merges.txt",
|
| 78 |
+
"tokenizer.model",
|
| 79 |
+
"generation_config.json",
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
class OlmoeFormat:
|
| 84 |
+
"""OLMoE-architecture checkpoints (routers + stacked experts), HF-compatible.
|
| 85 |
+
|
| 86 |
+
TOKENIZER. The dense path copies the pinned Mistral v0.3 tokenizer into
|
| 87 |
+
every checkpoint (same vocab by construction); doing that for an OLMoE
|
| 88 |
+
checkpoint would stamp a 32768-token tokenizer onto a 50304-token model --
|
| 89 |
+
loads fine, tokenizes wrong. So this format copies tokenizer files only
|
| 90 |
+
from an EXPLICIT `tokenizer_dir`, and without one writes a weights-only
|
| 91 |
+
checkpoint (and says so), never a guess.
|
| 92 |
+
"""
|
| 93 |
+
|
| 94 |
+
def __init__(self, olmoe_cfg: Any, tokenizer_dir: Optional[str] = None):
|
| 95 |
+
self.olmoe_cfg = olmoe_cfg
|
| 96 |
+
self.tokenizer_dir = tokenizer_dir
|
| 97 |
+
|
| 98 |
+
def save_weights(self, state, out_dir, dtype):
|
| 99 |
+
from ..model.olmoe_checkpoint import save_olmoe_as_hf
|
| 100 |
+
|
| 101 |
+
save_olmoe_as_hf(dict(state), self.olmoe_cfg, out_dir, dtype=dtype)
|
| 102 |
+
|
| 103 |
+
def load_weights(self, path):
|
| 104 |
+
from ..model.olmoe_checkpoint import hf_to_align
|
| 105 |
+
from ..model.checkpoint import load_hf_safetensors
|
| 106 |
+
|
| 107 |
+
return hf_to_align(load_hf_safetensors(path), self.olmoe_cfg)
|
| 108 |
+
|
| 109 |
+
def copy_tokenizer(self, snapshot, dst_dir): # noqa: ARG002 -- the dense snapshot is not ours
|
| 110 |
+
import os
|
| 111 |
+
import shutil
|
| 112 |
+
|
| 113 |
+
if not self.tokenizer_dir:
|
| 114 |
+
return []
|
| 115 |
+
copied = []
|
| 116 |
+
for name in OLMOE_TOKENIZER_FILES:
|
| 117 |
+
src = os.path.join(self.tokenizer_dir, name)
|
| 118 |
+
if os.path.isfile(src):
|
| 119 |
+
shutil.copyfile(src, os.path.join(dst_dir, name))
|
| 120 |
+
copied.append(name)
|
| 121 |
+
return copied
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
__all__ = ["WeightFormat", "DenseFormat", "OlmoeFormat"]
|
code/align/train/dpo_loss.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""DPO loss adapter: paired batch -> section-05's chunked fused DPO loss.
|
| 2 |
+
|
| 3 |
+
Mirror of `sft_loss.py`: this module owns plumbing only -- unpacking the paired
|
| 4 |
+
batch, the caller-side label shift, and packaging metrics -- while the kernel in
|
| 5 |
+
`align.kernels.fused_dpo` owns the objective.
|
| 6 |
+
|
| 7 |
+
The one thing worth stating here rather than in the kernel: the reference
|
| 8 |
+
log-probabilities arrive as PRECOMPUTED CONSTANTS, produced offline by
|
| 9 |
+
section-12 in a single no-grad pass over the SFT checkpoint. There is no
|
| 10 |
+
reference model resident at DPO time, which is what makes 7B DPO fit on 4x A10G
|
| 11 |
+
at all. The kernel detaches them on entry and a test proves no gradient path
|
| 12 |
+
survives; this adapter's job is simply never to build one, so it refuses a
|
| 13 |
+
batch that does not carry them rather than silently substituting zeros -- a
|
| 14 |
+
zero reference turns DPO into plain likelihood maximisation on the chosen side,
|
| 15 |
+
which trains, converges, and is not DPO.
|
| 16 |
+
|
| 17 |
+
`DpoLossConfig` is the trainer-facing default surface. Note `token_chunk_size`:
|
| 18 |
+
the pre-registered config is micro_batch 1 x seq 1024, i.e. ONE pair per step,
|
| 19 |
+
and at one pair the pair-chunking does nothing at all. Leaving it None there
|
| 20 |
+
would silently restore the [1024, 32768] logit tensor the fused loss exists to
|
| 21 |
+
remove, so the trainer-facing default sets it.
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
from __future__ import annotations
|
| 25 |
+
|
| 26 |
+
import logging
|
| 27 |
+
from dataclasses import dataclass
|
| 28 |
+
from typing import Any, Optional
|
| 29 |
+
|
| 30 |
+
import torch
|
| 31 |
+
|
| 32 |
+
from .trainer import LossOutput
|
| 33 |
+
|
| 34 |
+
logger = logging.getLogger(__name__)
|
| 35 |
+
|
| 36 |
+
#: Section-05's kernel module, named so the both-directions test can block the
|
| 37 |
+
#: import and exercise the refusal branch (same pattern as `sft_loss`).
|
| 38 |
+
FUSED_DPO_MODULE = "align.kernels.fused_dpo"
|
| 39 |
+
|
| 40 |
+
#: Fallback only; the authority is the kernel's constant and section-03's
|
| 41 |
+
#: collator, which both use -100.
|
| 42 |
+
IGNORE_INDEX = -100
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@dataclass
|
| 46 |
+
class DpoLossConfig:
|
| 47 |
+
"""Trainer-facing DPO loss settings. Section-12 nests this in its stage
|
| 48 |
+
config; every default that encodes a decision says why."""
|
| 49 |
+
|
| 50 |
+
#: Preference sharpness. 0.1 is the pre-registered value for both arms.
|
| 51 |
+
beta: float = 0.1
|
| 52 |
+
#: One of align.kernels.fused_dpo.DPO_VARIANTS. `dpop` is the pre-registered
|
| 53 |
+
#: tripwire RESPONSE for logps/chosen declining while the margin rises; it
|
| 54 |
+
#: is not the primary configuration and switching to it is a documented
|
| 55 |
+
#: deviation, not a tuning knob.
|
| 56 |
+
variant: str = "sigmoid"
|
| 57 |
+
#: Pairs per chunk.
|
| 58 |
+
chunk_size: int = 1
|
| 59 |
+
#: Tokens per inner chunk. NOT None: at micro_batch 1 there is one pair per
|
| 60 |
+
#: step and pair-chunking alone would materialise the full logit tensor.
|
| 61 |
+
#: 128 rather than 256, DERIVED from `fused_ce.chunked_peak_bound` at the
|
| 62 |
+
#: 7B shapes (V 32768, H 4096, 2x1024 rows): 256 predicts a 192 MiB peak
|
| 63 |
+
#: against a 256 MiB budget (0.75, too tight to survive an allocator
|
| 64 |
+
#: fluctuation in the M5 smoke), while 128 predicts 128 MiB (0.50). The
|
| 65 |
+
#: matmul is still large enough to be efficient at 128 x 32768.
|
| 66 |
+
token_chunk_size: Optional[int] = 128
|
| 67 |
+
#: NLL auxiliary on the chosen completion. 0.0 is provably inert (the term
|
| 68 |
+
#: is skipped structurally, not multiplied by zero), so the primary
|
| 69 |
+
#: pre-registered run is unaffected by this field existing.
|
| 70 |
+
nll_coeff: float = 0.0
|
| 71 |
+
#: DPO-Positive penalty weight; read only when variant == "dpop".
|
| 72 |
+
dpop_lambda: float = 50.0
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _load_fused():
|
| 76 |
+
try:
|
| 77 |
+
import importlib
|
| 78 |
+
|
| 79 |
+
return importlib.import_module(FUSED_DPO_MODULE)
|
| 80 |
+
except Exception: # noqa: BLE001
|
| 81 |
+
return None
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def fused_dpo_available() -> bool:
|
| 85 |
+
return _load_fused() is not None
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _unpack(batch: Any):
|
| 89 |
+
"""Accept a paired batch object or a mapping. Six tensors are mandatory and
|
| 90 |
+
none of them has a defensible default."""
|
| 91 |
+
required = (
|
| 92 |
+
"input_ids_chosen",
|
| 93 |
+
"labels_chosen",
|
| 94 |
+
"input_ids_rejected",
|
| 95 |
+
"labels_rejected",
|
| 96 |
+
"ref_logp_chosen",
|
| 97 |
+
"ref_logp_rejected",
|
| 98 |
+
)
|
| 99 |
+
if isinstance(batch, dict):
|
| 100 |
+
got = {k: batch.get(k) for k in required}
|
| 101 |
+
else:
|
| 102 |
+
got = {k: getattr(batch, k, None) for k in required}
|
| 103 |
+
missing = [k for k, v in got.items() if v is None]
|
| 104 |
+
if missing:
|
| 105 |
+
raise TypeError(
|
| 106 |
+
f"DPO batch is missing {missing}. The reference log-probabilities "
|
| 107 |
+
"in particular have no default: substituting zeros would turn DPO "
|
| 108 |
+
"into likelihood maximisation on the chosen completion, which "
|
| 109 |
+
"trains and converges and is not DPO. Section-12's "
|
| 110 |
+
"precompute step produces them."
|
| 111 |
+
)
|
| 112 |
+
return got
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def make_dpo_loss_adapter(
|
| 116 |
+
config: Optional[DpoLossConfig] = None,
|
| 117 |
+
require_fused: bool = False,
|
| 118 |
+
decoupled_backward: bool = False,
|
| 119 |
+
grad_scale: float = 1.0,
|
| 120 |
+
):
|
| 121 |
+
"""Build the DPO loss adapter the Trainer calls once per micro-step.
|
| 122 |
+
|
| 123 |
+
`require_fused=True` refuses the reference path, which is what the 7B stage
|
| 124 |
+
scripts pass: the eager reference materialises BOTH [P, T, 32768] logit
|
| 125 |
+
tensors and is a memory profile no A10G survives at 7B.
|
| 126 |
+
|
| 127 |
+
`decoupled_backward=True` (the 7B stage setting) backwards each side's
|
| 128 |
+
trunk separately so both checkpointed graphs are never resident at once;
|
| 129 |
+
`grad_scale` must then be 1/grad_accum, matching the scaling the Trainer
|
| 130 |
+
applies to the loss it backwards.
|
| 131 |
+
"""
|
| 132 |
+
cfg = config or DpoLossConfig()
|
| 133 |
+
module = _load_fused()
|
| 134 |
+
if module is None:
|
| 135 |
+
if require_fused:
|
| 136 |
+
raise RuntimeError(
|
| 137 |
+
f"the fused chunked DPO loss ({FUSED_DPO_MODULE}."
|
| 138 |
+
"fused_dpo_loss) is not importable. Refusing to fall back to "
|
| 139 |
+
"the reference path for a full-scale run: it materialises both "
|
| 140 |
+
"[P, T, 32768] logit tensors and their gradients."
|
| 141 |
+
)
|
| 142 |
+
raise RuntimeError(
|
| 143 |
+
f"{FUSED_DPO_MODULE} is not importable and there is no reference "
|
| 144 |
+
"fallback worth offering for DPO at any useful scale."
|
| 145 |
+
)
|
| 146 |
+
if cfg.variant not in module.DPO_VARIANTS:
|
| 147 |
+
raise ValueError(f"unknown DPO variant {cfg.variant!r}; known: {list(module.DPO_VARIANTS)}")
|
| 148 |
+
ignore_index = module.IGNORE_INDEX
|
| 149 |
+
|
| 150 |
+
def _kernel(h_c, h_r, w, got, target_c, target_r):
|
| 151 |
+
return module.fused_dpo_loss(
|
| 152 |
+
h_c,
|
| 153 |
+
h_r,
|
| 154 |
+
w,
|
| 155 |
+
target_c,
|
| 156 |
+
target_r,
|
| 157 |
+
got["ref_logp_chosen"],
|
| 158 |
+
got["ref_logp_rejected"],
|
| 159 |
+
beta=cfg.beta,
|
| 160 |
+
variant=cfg.variant,
|
| 161 |
+
chunk_size=cfg.chunk_size,
|
| 162 |
+
nll_coeff=cfg.nll_coeff,
|
| 163 |
+
dpop_lambda=cfg.dpop_lambda,
|
| 164 |
+
ignore_index=ignore_index,
|
| 165 |
+
token_chunk_size=cfg.token_chunk_size,
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
def _full_w(model):
|
| 169 |
+
# FSDP2 params are sharded DTensors OUTSIDE a module __call__; passing
|
| 170 |
+
# one into the fused kernel's mm mixes Tensor and DTensor (the
|
| 171 |
+
# hook-bypass class, DPO edition). full_tensor() is the DIFFERENTIABLE
|
| 172 |
+
# all-gather: backward reduce-scatters d_weight onto the shards.
|
| 173 |
+
w = model.lm_head.weight
|
| 174 |
+
return w.full_tensor() if hasattr(w, "full_tensor") else w
|
| 175 |
+
|
| 176 |
+
def adapter(model, batch) -> LossOutput:
|
| 177 |
+
got = _unpack(batch)
|
| 178 |
+
# Caller-shifted, exactly as in sft_loss: position t predicts t+1.
|
| 179 |
+
target_c = got["labels_chosen"][:, 1:]
|
| 180 |
+
target_r = got["labels_rejected"][:, 1:]
|
| 181 |
+
|
| 182 |
+
if not decoupled_backward:
|
| 183 |
+
hidden_c = model.forward_hidden_states(input_ids=got["input_ids_chosen"])[:, :-1]
|
| 184 |
+
hidden_r = model.forward_hidden_states(input_ids=got["input_ids_rejected"])[:, :-1]
|
| 185 |
+
loss, metrics = _kernel(hidden_c, hidden_r, _full_w(model), got, target_c, target_r)
|
| 186 |
+
module.require_metrics(metrics)
|
| 187 |
+
n_tokens = int((target_c != ignore_index).sum() + (target_r != ignore_index).sum())
|
| 188 |
+
return LossOutput(loss=loss, n_tokens=n_tokens, metrics=dict(metrics))
|
| 189 |
+
|
| 190 |
+
# DECOUPLED BACKWARD -- one trunk graph at a time. Holding BOTH
|
| 191 |
+
# sides' checkpointed 7B graphs plus fp32 shard gradients peaked at
|
| 192 |
+
# 21.5GB and OOM'd every rank (measured; batch-size independent).
|
| 193 |
+
# DPO's coupling between the sides is one scalar per pair, so:
|
| 194 |
+
# 1. no-grad trunk forwards; the kernel runs over LEAF copies of
|
| 195 |
+
# the hiddens plus the gathered lm_head, and one backward on
|
| 196 |
+
# that small graph yields d_hidden per side analytically and
|
| 197 |
+
# accumulates the lm_head shard grad;
|
| 198 |
+
# 2. each side then re-forwards WITH grad and backwards against
|
| 199 |
+
# its saved d_hidden -- never two trunk graphs at once.
|
| 200 |
+
# The re-forward recomputes hiddens that differ from the no-grad
|
| 201 |
+
# pass only by kernel-dispatch noise (~1e-7 relative, the same class
|
| 202 |
+
# the batch-invariance receipt records); act-ckpt already relies on
|
| 203 |
+
# this determinism. The trainer's `(loss/accum).backward()` contract
|
| 204 |
+
# is honoured with a detached leaf proxy; the REAL grads are scaled
|
| 205 |
+
# here by grad_scale (1/accum), so nothing is double-counted.
|
| 206 |
+
with torch.no_grad():
|
| 207 |
+
h_c0 = model.forward_hidden_states(input_ids=got["input_ids_chosen"])[:, :-1]
|
| 208 |
+
h_r0 = model.forward_hidden_states(input_ids=got["input_ids_rejected"])[:, :-1]
|
| 209 |
+
h_c = h_c0.detach().requires_grad_(True)
|
| 210 |
+
h_r = h_r0.detach().requires_grad_(True)
|
| 211 |
+
loss, metrics = _kernel(h_c, h_r, _full_w(model), got, target_c, target_r)
|
| 212 |
+
module.require_metrics(metrics)
|
| 213 |
+
(loss * grad_scale).backward()
|
| 214 |
+
g_c, g_r = h_c.grad, h_r.grad
|
| 215 |
+
if g_c is None or g_r is None:
|
| 216 |
+
raise RuntimeError(
|
| 217 |
+
"decoupled DPO backward produced no hidden gradients; the "
|
| 218 |
+
"kernel did not differentiate w.r.t. the hidden leaves"
|
| 219 |
+
)
|
| 220 |
+
del h_c, h_r, h_c0, h_r0
|
| 221 |
+
|
| 222 |
+
hc = model.forward_hidden_states(input_ids=got["input_ids_chosen"])[:, :-1]
|
| 223 |
+
hc.backward(gradient=g_c)
|
| 224 |
+
del hc, g_c
|
| 225 |
+
hr = model.forward_hidden_states(input_ids=got["input_ids_rejected"])[:, :-1]
|
| 226 |
+
hr.backward(gradient=g_r)
|
| 227 |
+
del hr, g_r
|
| 228 |
+
|
| 229 |
+
n_tokens = int((target_c != ignore_index).sum() + (target_r != ignore_index).sum())
|
| 230 |
+
# Leaf proxy: the trainer's (loss/accum).backward() runs on THIS and
|
| 231 |
+
# touches no model gradient; the value is the real loss for the log.
|
| 232 |
+
proxy = loss.detach().clone().requires_grad_(True)
|
| 233 |
+
return LossOutput(loss=proxy, n_tokens=n_tokens, metrics=dict(metrics))
|
| 234 |
+
|
| 235 |
+
adapter.uses_fused = True
|
| 236 |
+
adapter.config = cfg
|
| 237 |
+
return adapter
|
code/align/train/dpo_stage.py
ADDED
|
@@ -0,0 +1,1362 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""DPO stage: config, entry gates, banner, tripwire response, arm manifest.
|
| 2 |
+
|
| 3 |
+
Section-12 is the second and last GPU training stage, run as TWO
|
| 4 |
+
pre-registered arms. Everything numerical belongs to other sections -- the
|
| 5 |
+
objective is section-05's `fused_dpo`, the loop is section-07's `Trainer`, the
|
| 6 |
+
tripwire DETECTORS are section-07's `probes.py`, the reference constants are
|
| 7 |
+
`ref_cache.py`. What lives here is the part that decides whether a run is
|
| 8 |
+
allowed to start and what its result is permitted to be.
|
| 9 |
+
|
| 10 |
+
That sounds like bureaucracy and is not. The predecessor project's expensive
|
| 11 |
+
failures were never wrong arithmetic; they were runs that completed, produced
|
| 12 |
+
publishable-looking numbers, and measured something other than what was
|
| 13 |
+
claimed. So:
|
| 14 |
+
|
| 15 |
+
* **Entry gates run in a fixed order and any failure blocks everything
|
| 16 |
+
after it, including the model load.** Ordered by cost, so a stage that was
|
| 17 |
+
never going to be valid dies in milliseconds instead of after putting
|
| 18 |
+
14.5 GB onto a shared no-swap box.
|
| 19 |
+
* **The tripwire response is pre-registered.** `logps/chosen` declining
|
| 20 |
+
while the margin rises is the DPO chosen-likelihood-collapse mode, and it
|
| 21 |
+
is *maximal for Arm B by design*: pi_SFT already assigns the chosen
|
| 22 |
+
continuations high likelihood, so the optimizer's cheapest margin comes
|
| 23 |
+
from crushing both logprobs. The response -- rerun with `dpop` or the NLL
|
| 24 |
+
auxiliary, and record THAT as the arm's result -- was written down before
|
| 25 |
+
any run, and no third configuration is reachable without a new
|
| 26 |
+
pre-registration.
|
| 27 |
+
* **The primary metrics are margin and held-out preference accuracy**, not
|
| 28 |
+
benchmark accuracy. The pre-registered behavioural expectations are Arm A
|
| 29 |
+
within +-2 pp of SFT (below floor is the EXPECTED outcome) and Arm B's
|
| 30 |
+
honest range INCLUDING ZERO. Section-13 owns the paired benchmark
|
| 31 |
+
evaluation; a stage that graded itself on benchmarks would be choosing its
|
| 32 |
+
own success criterion after seeing the data.
|
| 33 |
+
"""
|
| 34 |
+
|
| 35 |
+
from __future__ import annotations
|
| 36 |
+
|
| 37 |
+
import collections
|
| 38 |
+
import json
|
| 39 |
+
import logging
|
| 40 |
+
import math
|
| 41 |
+
import os
|
| 42 |
+
from dataclasses import dataclass, field, fields, replace
|
| 43 |
+
from typing import Any, Callable, Mapping, Optional, Sequence
|
| 44 |
+
|
| 45 |
+
logger = logging.getLogger(__name__)
|
| 46 |
+
|
| 47 |
+
NAN = float("nan")
|
| 48 |
+
|
| 49 |
+
#: Entry gates, in the order they run. Ordered by COST: the cheap file reads
|
| 50 |
+
#: come first so an invalid stage fails before it touches a GPU. Data, so the
|
| 51 |
+
#: test iterates it instead of copying a list.
|
| 52 |
+
GATE_ORDER: tuple[str, ...] = (
|
| 53 |
+
"sft_quality_gate", # section-11 ran and passed its own gate
|
| 54 |
+
"build_manifest", # shard counts match the pinned values; Arm A DESCOPED
|
| 55 |
+
"ref_cache_key", # the reference constants belong to THIS run
|
| 56 |
+
"path_bias", # measured, and its decision honored
|
| 57 |
+
"throughput_probe", # 20 steps -> banner wall-clock (never asserted ahead)
|
| 58 |
+
"rss_preflight", # host RSS headroom for the full-FT job class
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
#: Banner keys this stage adds on top of `registry.MANDATORY_BANNER_KEYS`.
|
| 62 |
+
#:
|
| 63 |
+
#: Deliberately NOT registered into the global registry at import time.
|
| 64 |
+
#: `registry.register_mandatory_key` mutates a module-level list shared by
|
| 65 |
+
#: every script in the process; importing this module would then make an
|
| 66 |
+
#: unrelated banner (trace, generate) incomplete. The contract is identical --
|
| 67 |
+
#: a missing key raises, a partial banner never prints -- it is just enforced
|
| 68 |
+
#: without a global side effect.
|
| 69 |
+
DPO_BANNER_KEYS: tuple[str, ...] = (
|
| 70 |
+
"arm",
|
| 71 |
+
"loss_type",
|
| 72 |
+
"beta",
|
| 73 |
+
"variant",
|
| 74 |
+
"lr",
|
| 75 |
+
"epochs",
|
| 76 |
+
"global_batch",
|
| 77 |
+
"grad_accum",
|
| 78 |
+
"expected_pairs",
|
| 79 |
+
"optimizer_step_formula",
|
| 80 |
+
"scheduler_horizon_optimizer_steps",
|
| 81 |
+
"dataset_hashes",
|
| 82 |
+
"ref_cache_key",
|
| 83 |
+
"path_bias",
|
| 84 |
+
"probe_step_time_s",
|
| 85 |
+
"probe_derived_wall_clock_h",
|
| 86 |
+
"rss_budget",
|
| 87 |
+
"attention_backend",
|
| 88 |
+
"length_drift_baseline",
|
| 89 |
+
"length_drift_baseline_source",
|
| 90 |
+
"sft_checkpoint",
|
| 91 |
+
"pre_registration",
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
#: The only configurations the pre-registration permits when the
|
| 95 |
+
#: chosen-logprob tripwire fires. Anything else requires a new dated entry in
|
| 96 |
+
#: docs/RESULTS.md written BEFORE the rerun, not after seeing it.
|
| 97 |
+
#:
|
| 98 |
+
#: `dpop_nll_aux` is the default because it is what the frozen
|
| 99 |
+
#: pre-registration literally says: "rerun THAT arm once with dpop + NLL
|
| 100 |
+
#: auxiliary". The section-12 spec phrases the same response as "the `dpop`
|
| 101 |
+
#: variant OR the NLL auxiliary", so the two single-knob forms are kept
|
| 102 |
+
#: available -- but docs/RESULTS.md is the frozen document and the conjunction
|
| 103 |
+
#: is what it froze, so the conjunction is the default. (Discrepancy raised
|
| 104 |
+
#: with the lead rather than resolved silently in either direction.)
|
| 105 |
+
PRE_REGISTERED_RESPONSES: tuple[str, ...] = ("dpop_nll_aux", "dpop", "nll_aux")
|
| 106 |
+
|
| 107 |
+
#: NLL auxiliary weight used when the response is `nll_aux`. Small: the term
|
| 108 |
+
#: exists to put a floor under the chosen logprob, not to turn DPO into SFT.
|
| 109 |
+
NLL_AUX_COEFF = 0.1
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
# ---------------------------------------------------------------------------
|
| 113 |
+
# Config
|
| 114 |
+
# ---------------------------------------------------------------------------
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
@dataclass
|
| 118 |
+
class DpoStageConfig:
|
| 119 |
+
"""Stage settings for one arm. Per-field docstrings, per repo convention:
|
| 120 |
+
the config file is where someone will try to change these at 2am."""
|
| 121 |
+
|
| 122 |
+
#: `arm_a` (argilla, response-level, 2,310 pairs) or `arm_b` (curated
|
| 123 |
+
#: PRM800K shared-prefix step-local pairs). Selects everything else.
|
| 124 |
+
arm: str = "arm_a"
|
| 125 |
+
#: Section-11's SFT checkpoint. Both arms fine-tune from the SAME one, so
|
| 126 |
+
#: any arm-vs-arm difference is the preference data, not the start point.
|
| 127 |
+
sft_checkpoint: str = ""
|
| 128 |
+
#: Section-11's quality-gate receipt. The stage refuses to launch unless
|
| 129 |
+
#: it exists AND records a pass.
|
| 130 |
+
sft_quality_gate_path: str = ""
|
| 131 |
+
#: Pair shards for this arm (train split) and the problem-level held-out
|
| 132 |
+
#: split used for preference accuracy.
|
| 133 |
+
shard_dir: str = ""
|
| 134 |
+
heldout_shard_dir: str = ""
|
| 135 |
+
#: Reference-logprob caches written by scripts/precompute_ref_logprobs.py.
|
| 136 |
+
ref_cache_path: str = ""
|
| 137 |
+
heldout_ref_cache_path: str = ""
|
| 138 |
+
#: The mandatory pre-launch path-bias measurement's report.
|
| 139 |
+
path_bias_report_path: str = ""
|
| 140 |
+
#: Section-03's build manifest. Its hash is part of the ref-cache key, so
|
| 141 |
+
#: a shard rebuild cannot leave a stale cache looking valid.
|
| 142 |
+
manifest_path: str = "/apps/development/rlhf/align/data/manifest.json"
|
| 143 |
+
#: docs/RESULTS.md must already carry this arm's pre-registration
|
| 144 |
+
#: (expectations + tripwire response) before the run starts.
|
| 145 |
+
#: The pre-registration is a FROZEN, dated document that lives with the
|
| 146 |
+
#: predecessor experiment record (read-only; the gate only greps it).
|
| 147 |
+
#: Copying it would fork the thing that is supposed to be frozen, so the
|
| 148 |
+
#: default stays on the original until it is migrated deliberately.
|
| 149 |
+
results_doc: str = "/apps/development/rlhf/nano-mistral-o1/docs/RESULTS.md"
|
| 150 |
+
run_dir: str = ""
|
| 151 |
+
|
| 152 |
+
#: Post-filter pair count for this arm, from the build manifest. Drives
|
| 153 |
+
#: the optimizer-step horizon, so it is a measured number, never a guess.
|
| 154 |
+
expected_pairs: int = 2310
|
| 155 |
+
|
| 156 |
+
# -- objective ---------------------------------------------------------
|
| 157 |
+
#: Pre-registered for both arms. `dpop`/`nll_aux` are reachable only as
|
| 158 |
+
#: the tripwire RESPONSE, and switching is a recorded deviation.
|
| 159 |
+
variant: str = "sigmoid"
|
| 160 |
+
#: Preference sharpness. 0.1, both arms, pre-registered.
|
| 161 |
+
beta: float = 0.1
|
| 162 |
+
#: NLL auxiliary weight. 0.0 is provably inert in section-05's kernel (the
|
| 163 |
+
#: term is skipped structurally, not multiplied by zero).
|
| 164 |
+
nll_coeff: float = 0.0
|
| 165 |
+
dpop_lambda: float = 50.0
|
| 166 |
+
#: Which pre-registered response to apply if the chosen-logprob tripwire
|
| 167 |
+
#: fires. Defaults to the conjunction docs/RESULTS.md froze; the two
|
| 168 |
+
#: single-knob forms are permitted, anything else raises.
|
| 169 |
+
tripwire_response: str = "dpop_nll_aux"
|
| 170 |
+
|
| 171 |
+
# -- model -------------------------------------------------------------
|
| 172 |
+
#: Attention backend for the policy model. NEVER handed raw to a
|
| 173 |
+
#: constructor: `NmoMistralForCausalLM.__init__` routes it through
|
| 174 |
+
#: `align.model.attention.select_attn_implementation`, which RAISES if the
|
| 175 |
+
#: request cannot be honoured -- for `triton_fa2` that means section-06's
|
| 176 |
+
#: equivalence-gate receipt must be valid for THIS kernel source on THIS
|
| 177 |
+
#: GPU. The pre-registered value for BOTH arms is `triton_fa2`
|
| 178 |
+
#: (docs/RESULTS.md dated entry, 2026-08-17); the neutral default here is
|
| 179 |
+
#: sdpa so the pin lives in the arm configs, where changing it is a
|
| 180 |
+
#: visible diff against the pre-registration. Membership is validated at
|
| 181 |
+
#: config load: a typo'd backend raises at launch, in milliseconds, not
|
| 182 |
+
#: as a selector error with four GPUs claimed.
|
| 183 |
+
attn_implementation: str = "sdpa"
|
| 184 |
+
|
| 185 |
+
# -- optimization ------------------------------------------------------
|
| 186 |
+
#: 5e-7, pre-registered. Two orders below SFT's: DPO moves a model that is
|
| 187 |
+
#: already where it should be.
|
| 188 |
+
lr: float = 5e-7
|
| 189 |
+
#: 2 pre-registered, 3 maximum.
|
| 190 |
+
epochs: int = 2
|
| 191 |
+
#: ~32 via gradient accumulation (e.g. 4 ranks x micro 2 x accum 4).
|
| 192 |
+
global_batch: int = 32
|
| 193 |
+
#: >= 4 is MANDATORY for full-FT with offloaded AdamW (section-07 refuses
|
| 194 |
+
#: less): at accum 1 the CPU optimizer step dominates wall-clock.
|
| 195 |
+
grad_accum: int = 4
|
| 196 |
+
micro_batch_size: int = 2
|
| 197 |
+
num_workers: int = 2
|
| 198 |
+
save_every_micro_steps: int = 100
|
| 199 |
+
keep_checkpoints: int = 2
|
| 200 |
+
#: Each rank writes its own optimizer_rank{r}.pt instead of gathering the
|
| 201 |
+
#: full optimizer state (see TrainConfig.per_rank_optimizer_state). Without
|
| 202 |
+
#: this field the key was REJECTED by struct mode in DPO TOMLs and, worse,
|
| 203 |
+
#: scripts/dpo.py built its RunConfig without ever setting it -- so the
|
| 204 |
+
#: trainer-level flag was unreachable from a DPO run. Default False keeps
|
| 205 |
+
#: the gathered layout; a resume at a different world_size is refused by
|
| 206 |
+
#: the trainer when True.
|
| 207 |
+
per_rank_optimizer_state: bool = False
|
| 208 |
+
|
| 209 |
+
#: Global-norm gradient clip (OptimizerConfig.max_grad_norm), forwarded by
|
| 210 |
+
#: scripts/dpo.py::_run_config. Declared here because the DPO RunConfig is
|
| 211 |
+
#: built from defaults: before this field the clip could not be stated in a
|
| 212 |
+
#: DPO TOML at all, only inherited. The recorded grad norm stays PRE-clip.
|
| 213 |
+
max_grad_norm: Optional[float] = 1.0
|
| 214 |
+
|
| 215 |
+
# -- run-health bounds (G0), forwarded to TrainConfig by the stage script's
|
| 216 |
+
# _run_config: a field that is not copied trains at the default while the
|
| 217 |
+
# TOML says otherwise. Semantics: align/config.py::TrainConfig.
|
| 218 |
+
max_wall_seconds: Optional[float] = None
|
| 219 |
+
max_consecutive_skipped_steps: int = 3
|
| 220 |
+
max_skipped_fraction: float = 0.05
|
| 221 |
+
|
| 222 |
+
# -- measurement -------------------------------------------------------
|
| 223 |
+
#: Fires the path-bias rerun when max|delta ref-margin| exceeds this
|
| 224 |
+
#: fraction of the early-margin scale. 0.05 = a 5% perturbation of the
|
| 225 |
+
#: quantity DPO reads. Chosen as a threshold to MEASURE against, not a
|
| 226 |
+
#: tolerance to pass: the measured single-GPU-vs-sharded deltas are
|
| 227 |
+
#: expected orders below it, and if they are not, the cache is rebuilt
|
| 228 |
+
#: through the sharded forward rather than argued about.
|
| 229 |
+
path_bias_threshold: float = 0.05
|
| 230 |
+
#: Denominator of that ratio: the typical magnitude of the implicit margin
|
| 231 |
+
#: early in the run, where the loss argument sits near zero and a
|
| 232 |
+
#: reference bias is least attenuated.
|
| 233 |
+
path_bias_early_margin_scale: float = 0.5
|
| 234 |
+
#: Held-out preference accuracy cadence, in optimizer steps.
|
| 235 |
+
heldout_every_optimizer_steps: int = 50
|
| 236 |
+
#: Pairs the chosen-longer fraction is computed over, as a ROLLING
|
| 237 |
+
#: WINDOW. Not per micro-batch, which is what the statistic naively is:
|
| 238 |
+
#: at the pre-registered micro_batch 2 the per-batch fraction can only be
|
| 239 |
+
#: 0, 0.5 or 1, so against a +-0.10 band it false-fires ~78% of the time.
|
| 240 |
+
#:
|
| 241 |
+
#: 512 is DERIVED, not chosen. SE of a fraction near the 0.5489 baseline
|
| 242 |
+
#: is sqrt(p(1-p)/k), so the band sits at 0.10/SE standard errors:
|
| 243 |
+
#: k=2 -> 0.28 SE (78% false-fire) k=128 -> 2.27 SE (2.3e-02)
|
| 244 |
+
#: k=32 -> 1.14 SE (26%) k=256 -> 3.22 SE (1.3e-03)
|
| 245 |
+
#: k=512 -> 4.55 SE (5.4e-06)
|
| 246 |
+
#: At 512 the per-check false-fire rate is 5e-06; over Arm B's ~3,000
|
| 247 |
+
#: logged micro-steps that is ~1.6e-02 expected spurious alarms for the
|
| 248 |
+
#: whole run. 256 would give ~4 -- enough to bury a real one.
|
| 249 |
+
length_window_pairs: int = 512
|
| 250 |
+
#: Which tripwires this arm ARMS, copied into `RunConfig.tripwires.armed`.
|
| 251 |
+
#: Both, for both arms: the collapse tripwire is the one with a
|
| 252 |
+
#: pre-registered response, and the length-drift one guards the 0.5489
|
| 253 |
+
#: baseline. Section-07 halts at the first log step if an armed tripwire's
|
| 254 |
+
#: inputs are missing or NaN, so arming here is a commitment that this
|
| 255 |
+
#: stage's loss adapter emits them -- not a hopeful setting.
|
| 256 |
+
armed_tripwires: tuple[str, ...] = ("logps_chosen", "length_drift", "grad_norm")
|
| 257 |
+
#: Length-drift band around the arm's MEASURED chosen-longer baseline
|
| 258 |
+
#: (Arm A: 0.5489 from the build manifest -- never hardcoded here).
|
| 259 |
+
length_band: float = 0.10
|
| 260 |
+
logps_window: int = 20
|
| 261 |
+
|
| 262 |
+
def __post_init__(self) -> None:
|
| 263 |
+
if self.arm not in ("arm_a", "arm_b"):
|
| 264 |
+
raise ValueError(f"arm={self.arm!r} must be 'arm_a' or 'arm_b'")
|
| 265 |
+
if self.tripwire_response not in PRE_REGISTERED_RESPONSES:
|
| 266 |
+
raise ValueError(
|
| 267 |
+
f"tripwire_response={self.tripwire_response!r} is not "
|
| 268 |
+
f"pre-registered; permitted: {list(PRE_REGISTERED_RESPONSES)}"
|
| 269 |
+
)
|
| 270 |
+
# Imported here, not at module top: align.model.attention pulls torch,
|
| 271 |
+
# and this dataclass must stay constructible before the resource
|
| 272 |
+
# guards have configured the process.
|
| 273 |
+
from ..model.attention import NMO_IMPLEMENTED
|
| 274 |
+
|
| 275 |
+
if self.attn_implementation not in NMO_IMPLEMENTED:
|
| 276 |
+
raise ValueError(
|
| 277 |
+
f"attn_implementation={self.attn_implementation!r} is not an "
|
| 278 |
+
f"align attention code path; permitted: {sorted(NMO_IMPLEMENTED)}. "
|
| 279 |
+
"A typo must raise at config load, never fall through to a "
|
| 280 |
+
"default backend -- a run that reports one backend while "
|
| 281 |
+
"executing another poisons every downstream comparison."
|
| 282 |
+
)
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
def load_stage_config(path: str, overrides: Optional[Sequence[str]] = None) -> DpoStageConfig:
|
| 286 |
+
"""TOML (+ dotted `key=value` overrides) -> DpoStageConfig, struct mode on:
|
| 287 |
+
a typo'd key raises at launch instead of silently training with the
|
| 288 |
+
default it failed to override."""
|
| 289 |
+
from align.config import load_config
|
| 290 |
+
|
| 291 |
+
return load_config(DpoStageConfig, path, list(overrides or []))
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def rotate_stale_checkpoints(ckpt_dir: str) -> Optional[str]:
|
| 295 |
+
"""Move a previous run's checkpoint dir aside, ONCE, for a fresh arm.
|
| 296 |
+
|
| 297 |
+
COLLECTIVE: every rank calls it. Only rank 0 touches the filesystem and
|
| 298 |
+
every rank then waits at a barrier, so all ranks proceed with the same
|
| 299 |
+
directory state. Run on every rank, the bare `os.rename` let exactly one
|
| 300 |
+
rank win and the other three die with FileNotFoundError (ranks straddling
|
| 301 |
+
a second boundary even computed different destinations); the survivors
|
| 302 |
+
had already entered Trainer construction. Returns the destination on
|
| 303 |
+
rank 0 when a rotation happened, else None.
|
| 304 |
+
"""
|
| 305 |
+
import time
|
| 306 |
+
|
| 307 |
+
from ..utils.dist import barrier, is_rank0
|
| 308 |
+
|
| 309 |
+
dst = None
|
| 310 |
+
if is_rank0() and os.path.isdir(ckpt_dir) and os.listdir(ckpt_dir):
|
| 311 |
+
dst = f"{ckpt_dir}_stale_{time.strftime('%Y%m%d-%H%M%S')}"
|
| 312 |
+
os.rename(ckpt_dir, dst)
|
| 313 |
+
logger.warning("rotated stale checkpoints to %s", dst)
|
| 314 |
+
barrier()
|
| 315 |
+
return dst
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
def probe_micro_cap(micro_step: int, probe_steps: int) -> int:
|
| 319 |
+
"""The `max_micro_steps` cap for the launch probe, on the ABSOLUTE counter.
|
| 320 |
+
|
| 321 |
+
The Trainer tests `micro_step >= limit` against the absolute micro-step
|
| 322 |
+
count, which a resume restores. A bare `probe_steps` cap on a run resumed
|
| 323 |
+
at micro N >= probe_steps therefore trained exactly one micro-step and
|
| 324 |
+
left `throughput_probe` one time to aggregate (ValueError), so every
|
| 325 |
+
supervisor retry died here without a checkpoint. The probe is the first
|
| 326 |
+
`probe_steps` micro-steps of THIS process, wherever it starts.
|
| 327 |
+
"""
|
| 328 |
+
return int(micro_step) + int(probe_steps)
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
def reduce_tripwire_alarms(alarms: Sequence[Mapping]) -> list[dict]:
|
| 332 |
+
"""Every rank's tripwire alarms, unioned: the same list on all ranks.
|
| 333 |
+
|
| 334 |
+
The pre-registered rerun is a COLLECTIVE decision (a second model load, a
|
| 335 |
+
new FSDP wrap, a new Trainer). The trainer's alarms are built from
|
| 336 |
+
rank-local metrics over different pairs, and only HALT verdicts are
|
| 337 |
+
reduced inside the trainer, so a non-HALT alarm (the chosen-logprob
|
| 338 |
+
decline) can fire on some ranks only. Acting on the local list sends the
|
| 339 |
+
firing ranks into the rerun's collectives while the rest write the
|
| 340 |
+
manifest and exit. COLLECTIVE: every rank must call it. Each alarm gains
|
| 341 |
+
a `rank` key naming the rank that raised it.
|
| 342 |
+
"""
|
| 343 |
+
from ..utils.dist import all_gather_objects
|
| 344 |
+
|
| 345 |
+
union: list[dict] = []
|
| 346 |
+
for r, rank_alarms in enumerate(all_gather_objects([dict(a) for a in alarms])):
|
| 347 |
+
for a in rank_alarms:
|
| 348 |
+
union.append({**a, "rank": r})
|
| 349 |
+
return union
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
def resolve_pair_split_dir(shard_dir: str, split: str) -> str:
|
| 353 |
+
"""The directory holding `<split>-NNNN.receipt.json`, found by DISCOVERY.
|
| 354 |
+
|
| 355 |
+
THE HAND-COMPOSED-PATH DEFECT, DPO edition. The attempt-1 configs pointed
|
| 356 |
+
at `data/shards/arm_a-train`, composed from a naming convention; the store
|
| 357 |
+
actually writes `data/shards/arm_a/train/arm_a-train-0000.receipt.json`.
|
| 358 |
+
Section-11 hit the identical defect and fixed it with
|
| 359 |
+
`stage_smoke.resolve_split_dir`, which searches for the receipts the store
|
| 360 |
+
itself wrote; this delegates to that same function (read-only) rather than
|
| 361 |
+
growing a second, drifting copy. Accepts either the shard root or the
|
| 362 |
+
exact split directory -- both contain the split's receipts -- and a
|
| 363 |
+
missing split reports the splits that ARE present.
|
| 364 |
+
"""
|
| 365 |
+
from .stage_smoke import resolve_split_dir
|
| 366 |
+
|
| 367 |
+
return str(resolve_split_dir(shard_dir, split))
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
def is_hf_checkpoint(path: Optional[str]) -> bool:
|
| 371 |
+
"""A directory is a loadable checkpoint iff it carries `config.json` --
|
| 372 |
+
the file `AutoConfig.from_pretrained` reads first. A run dir that merely
|
| 373 |
+
CONTAINS checkpoints is not itself one."""
|
| 374 |
+
return bool(path) and os.path.isfile(os.path.join(str(path), "config.json"))
|
| 375 |
+
|
| 376 |
+
|
| 377 |
+
def resolve_sft_checkpoint(configured: str, receipt_checkpoint: Optional[str]) -> tuple[str, str]:
|
| 378 |
+
"""The policy's starting checkpoint: `(path, source)`, or a raise.
|
| 379 |
+
|
| 380 |
+
Section-11's Trainer writes `<run_dir>/checkpoints/micro_NNNNNNNN`
|
| 381 |
+
(trainer.save_checkpoint) and there is NO stable `final` alias -- the
|
| 382 |
+
attempt-1 configs pointed at `checkpoints/sft/final`, a path nothing ever
|
| 383 |
+
writes, which would have failed at model load with the gates already
|
| 384 |
+
passed. What IS stable is the quality-gate receipt: section-11 records
|
| 385 |
+
WHICH micro-step checkpoint passed its gate in the receipt's `checkpoint`
|
| 386 |
+
field, so that is the discovery mechanism. A configured path that is
|
| 387 |
+
itself a checkpoint wins, as the explicit override for rerunning against
|
| 388 |
+
a pinned checkpoint.
|
| 389 |
+
"""
|
| 390 |
+
if is_hf_checkpoint(configured):
|
| 391 |
+
return configured, "config"
|
| 392 |
+
if is_hf_checkpoint(receipt_checkpoint):
|
| 393 |
+
return str(receipt_checkpoint), "sft_quality_gate_receipt"
|
| 394 |
+
raise FileNotFoundError(
|
| 395 |
+
f"no SFT checkpoint found. Configured sft_checkpoint={configured!r} "
|
| 396 |
+
"is not a checkpoint directory (no config.json), and the quality-gate "
|
| 397 |
+
f"receipt records checkpoint={receipt_checkpoint!r}, which is not one "
|
| 398 |
+
"either. Section-11's trainer writes "
|
| 399 |
+
"<run_dir>/checkpoints/micro_NNNNNNNN and its receipt names the one "
|
| 400 |
+
"that passed the gate; refusing to guess a starting point for a "
|
| 401 |
+
"full-FT arm."
|
| 402 |
+
)
|
| 403 |
+
|
| 404 |
+
|
| 405 |
+
# ---------------------------------------------------------------------------
|
| 406 |
+
# Step arithmetic
|
| 407 |
+
# ---------------------------------------------------------------------------
|
| 408 |
+
|
| 409 |
+
|
| 410 |
+
def optimizer_steps(pairs: int, global_batch: int, epochs: int) -> int:
|
| 411 |
+
"""epochs * ceil(pairs / global_batch).
|
| 412 |
+
|
| 413 |
+
Written once, here, because the iterations-vs-steps confusion is a
|
| 414 |
+
documented predecessor incident: an LR horizon quoted in MICRO-steps
|
| 415 |
+
schedules the cosine over 4-8x the real run, so the LR never decays and
|
| 416 |
+
the run ends at nearly its peak rate with no sign anything was wrong.
|
| 417 |
+
Ceiling, not floor -- a trailing partial batch is still an optimizer step.
|
| 418 |
+
"""
|
| 419 |
+
if pairs < 1:
|
| 420 |
+
raise ValueError(f"pairs={pairs} must be >= 1")
|
| 421 |
+
if global_batch < 1:
|
| 422 |
+
raise ValueError(f"global_batch={global_batch} must be >= 1")
|
| 423 |
+
if epochs < 1:
|
| 424 |
+
raise ValueError(f"epochs={epochs} must be >= 1")
|
| 425 |
+
return epochs * math.ceil(pairs / global_batch)
|
| 426 |
+
|
| 427 |
+
|
| 428 |
+
# ---------------------------------------------------------------------------
|
| 429 |
+
# Entry gates
|
| 430 |
+
# ---------------------------------------------------------------------------
|
| 431 |
+
|
| 432 |
+
|
| 433 |
+
def run_entry_gates(
|
| 434 |
+
cfg: DpoStageConfig, gates: Mapping[str, Callable[[DpoStageConfig], dict]]
|
| 435 |
+
) -> list[dict]:
|
| 436 |
+
"""Run every gate in `GATE_ORDER`, stopping at the first failure.
|
| 437 |
+
|
| 438 |
+
Returns one record per gate that ran. A gate absent from `gates` RAISES:
|
| 439 |
+
an unimplemented gate treated as passed is how a run launches with an
|
| 440 |
+
unchecked reference cache, and the whole point of the ordered sequence is
|
| 441 |
+
that reaching gate N means gates 1..N-1 held.
|
| 442 |
+
"""
|
| 443 |
+
report: list[dict] = []
|
| 444 |
+
for name in GATE_ORDER:
|
| 445 |
+
fn = gates.get(name)
|
| 446 |
+
if fn is None:
|
| 447 |
+
raise RuntimeError(
|
| 448 |
+
f"entry gate {name!r} has no implementation. A missing gate is "
|
| 449 |
+
"not a passed gate; refusing to launch."
|
| 450 |
+
)
|
| 451 |
+
result = fn(cfg)
|
| 452 |
+
record = {"gate": name}
|
| 453 |
+
if isinstance(result, Mapping):
|
| 454 |
+
record.update(result)
|
| 455 |
+
report.append(record)
|
| 456 |
+
logger.info("entry gate ok: %s", name)
|
| 457 |
+
return report
|
| 458 |
+
|
| 459 |
+
|
| 460 |
+
#: The gate whose pass this stage depends on, named in section-11's own
|
| 461 |
+
#: `stage_gates.GATE_ORDER` rather than spelled here as a string this module
|
| 462 |
+
#: invented.
|
| 463 |
+
SFT_GATE_NAME = "quality_gate"
|
| 464 |
+
|
| 465 |
+
|
| 466 |
+
def read_sft_quality_gate(path: str) -> dict:
|
| 467 |
+
"""Section-11's quality-gate verdict: exists, and records a PASS.
|
| 468 |
+
|
| 469 |
+
Reads section-11's OWN artifact. `scripts/sft.py` records its gates
|
| 470 |
+
through `stage_gates.GateLedger`, which writes `stage_gates.json` as
|
| 471 |
+
`{"eval_source_hash": ..., "gates": {"quality_gate": {"passed": ...}}}`.
|
| 472 |
+
An earlier draft of this function read a standalone
|
| 473 |
+
`{"passed": true}` receipt that nothing writes -- the gate would have
|
| 474 |
+
raised FileNotFoundError with four GPUs already claimed. `path` may name
|
| 475 |
+
the ledger file or the run directory containing it.
|
| 476 |
+
|
| 477 |
+
The comparison itself is NOT restated here. Section-11's
|
| 478 |
+
`stage_gates.quality_gate` delegates to section-10's `assert_meets_golden`,
|
| 479 |
+
which compares against the WILSON LOWER BOUND -- the golden anchors are
|
| 480 |
+
themselves measurements with sampling noise, so demanding the point
|
| 481 |
+
estimate meet them would fail correct runs about half the time. This stage
|
| 482 |
+
consumes that verdict; a second copy of the CI reasoning here would drift
|
| 483 |
+
from it, and the drift would be invisible.
|
| 484 |
+
|
| 485 |
+
Three states, and the third is the one that gets skipped. Missing: section
|
| 486 |
+
11 never ran, or ran and never recorded. Present and failed: obvious.
|
| 487 |
+
Present and SILENT on this gate: also a refusal, because an artifact that
|
| 488 |
+
does not answer the question is indistinguishable from one that answers
|
| 489 |
+
'no', and defaulting to 'yes' would let a partial ledger authorise eight
|
| 490 |
+
GPU-hours.
|
| 491 |
+
"""
|
| 492 |
+
from .stage_gates import LEDGER_FILENAME, PENDING
|
| 493 |
+
|
| 494 |
+
resolved = path
|
| 495 |
+
if path and os.path.isdir(path):
|
| 496 |
+
resolved = os.path.join(path, LEDGER_FILENAME)
|
| 497 |
+
if not resolved or not os.path.isfile(resolved):
|
| 498 |
+
raise FileNotFoundError(
|
| 499 |
+
f"SFT quality gate ledger {resolved!r} is missing. DPO fine-tunes "
|
| 500 |
+
"the SFT checkpoint, so an SFT run that never passed its own gate "
|
| 501 |
+
"makes both arms measurements of a broken starting point -- at "
|
| 502 |
+
"full GPU cost, with nothing downstream able to tell. Point "
|
| 503 |
+
"sft_quality_gate_path at section-11's run dir or its "
|
| 504 |
+
f"{LEDGER_FILENAME}."
|
| 505 |
+
)
|
| 506 |
+
with open(resolved) as f:
|
| 507 |
+
blob = json.load(f)
|
| 508 |
+
|
| 509 |
+
# Accept the ledger shape, and a bare {"passed": ...} object too: the
|
| 510 |
+
# latter is what a hand-written override looks like, and refusing it would
|
| 511 |
+
# push someone toward editing the ledger instead.
|
| 512 |
+
gates = blob.get("gates", blob if "passed" in blob else {})
|
| 513 |
+
entry = gates.get(SFT_GATE_NAME, blob if "passed" in blob else PENDING)
|
| 514 |
+
if not isinstance(entry, dict) or "passed" not in entry:
|
| 515 |
+
raise RuntimeError(
|
| 516 |
+
f"{resolved!r} does not record whether the SFT {SFT_GATE_NAME!r} "
|
| 517 |
+
f"passed (it reads {entry!r}). An artifact that cannot answer the "
|
| 518 |
+
"question is not a pass."
|
| 519 |
+
)
|
| 520 |
+
if not entry["passed"]:
|
| 521 |
+
raise RuntimeError(
|
| 522 |
+
f"the SFT quality gate did not pass: "
|
| 523 |
+
f"{entry.get('detail', '(no detail recorded)')}. Fix section-11 "
|
| 524 |
+
"before spending this stage's GPU budget."
|
| 525 |
+
)
|
| 526 |
+
return {
|
| 527 |
+
"passed": True,
|
| 528 |
+
"detail": entry.get("detail", ""),
|
| 529 |
+
"recorded_at": entry.get("recorded_at"),
|
| 530 |
+
"eval_source_hash": blob.get("eval_source_hash"),
|
| 531 |
+
"checkpoint": blob.get("checkpoint", entry.get("checkpoint")),
|
| 532 |
+
"ledger": resolved,
|
| 533 |
+
}
|
| 534 |
+
|
| 535 |
+
|
| 536 |
+
def check_arm_descoped(arm: str, manifest: Mapping) -> dict:
|
| 537 |
+
"""Honour section-03's Arm A DESCOPED marker (filter yield < 1,500).
|
| 538 |
+
|
| 539 |
+
The failure this prevents is quiet and expensive: training Arm A on
|
| 540 |
+
whatever survived the filter and reporting it under the pre-registered
|
| 541 |
+
arm's name. A descoped arm is a RESULT -- it gets a manifest saying so --
|
| 542 |
+
not an absence.
|
| 543 |
+
"""
|
| 544 |
+
descoped = bool(manifest.get("arm_a_descoped", False)) and arm == "arm_a"
|
| 545 |
+
return {
|
| 546 |
+
"arm": arm,
|
| 547 |
+
"descoped": descoped,
|
| 548 |
+
"train": not descoped,
|
| 549 |
+
"detail": (
|
| 550 |
+
"section-03 marked Arm A descoped (filter yield below the "
|
| 551 |
+
"1,500 floor); refusing to train it and recording the "
|
| 552 |
+
"descope as this arm's result"
|
| 553 |
+
if descoped
|
| 554 |
+
else "arm is in scope"
|
| 555 |
+
),
|
| 556 |
+
}
|
| 557 |
+
|
| 558 |
+
|
| 559 |
+
def build_manifest_gate(cfg: DpoStageConfig, manifest: Mapping) -> dict:
|
| 560 |
+
"""Counts match the pinned values and the build verified its own pins."""
|
| 561 |
+
if not manifest.get("pins_verified", False):
|
| 562 |
+
raise RuntimeError(
|
| 563 |
+
"the build manifest reports pins_verified=false: the corpus was "
|
| 564 |
+
"built in re-measurement mode and its counts were never checked "
|
| 565 |
+
"against the pinned values. Sections 11 and 12 refuse to launch "
|
| 566 |
+
f"against it. Violations: {manifest.get('pin_violations')}"
|
| 567 |
+
)
|
| 568 |
+
counts = manifest.get("counts", {})
|
| 569 |
+
key = "arm_a_pairs" if cfg.arm == "arm_a" else "arm_b_curated"
|
| 570 |
+
measured = counts.get(key)
|
| 571 |
+
if measured is None:
|
| 572 |
+
raise KeyError(
|
| 573 |
+
f"build manifest carries no {key!r} count, so the optimizer-step "
|
| 574 |
+
"horizon cannot be derived from a measured pair count."
|
| 575 |
+
)
|
| 576 |
+
if int(measured) != int(cfg.expected_pairs):
|
| 577 |
+
raise RuntimeError(
|
| 578 |
+
f"{cfg.arm}: manifest reports {measured} pairs but the stage "
|
| 579 |
+
f"config expects {cfg.expected_pairs}. The horizon, the LR "
|
| 580 |
+
"schedule and the ref cache were all built for the config number; "
|
| 581 |
+
"one of the two is stale."
|
| 582 |
+
)
|
| 583 |
+
return {"pairs": int(measured), **check_arm_descoped(cfg.arm, manifest)}
|
| 584 |
+
|
| 585 |
+
|
| 586 |
+
# ---------------------------------------------------------------------------
|
| 587 |
+
# Banner
|
| 588 |
+
# ---------------------------------------------------------------------------
|
| 589 |
+
|
| 590 |
+
|
| 591 |
+
def measure_chosen_longer_fraction(rows: Sequence[Any]) -> float:
|
| 592 |
+
"""Fraction of pairs whose CHOSEN completion is longer, from the rows.
|
| 593 |
+
|
| 594 |
+
Needed because section-03's build manifest records Arm A's baseline
|
| 595 |
+
(0.5489) and not Arm B's, while the length-drift tripwire needs one per
|
| 596 |
+
arm: `LengthDriftTripwire` refuses a NaN baseline outright, and borrowing
|
| 597 |
+
Arm A's would arm Arm B against a distribution it was never drawn from --
|
| 598 |
+
a tripwire that then fires forever or never, both of which are worse than
|
| 599 |
+
no tripwire because they look like measurements.
|
| 600 |
+
|
| 601 |
+
Completion length, not sequence length: Arm B's two sides share their
|
| 602 |
+
entire prefix, so total length is dominated by a term identical on both
|
| 603 |
+
sides and would compress the fraction toward 0.5 regardless of the data.
|
| 604 |
+
"""
|
| 605 |
+
import numpy as np
|
| 606 |
+
|
| 607 |
+
longer = 0
|
| 608 |
+
counted = 0
|
| 609 |
+
for r in rows:
|
| 610 |
+
lc = len(np.asarray(r["chosen_input_ids"])) - int(r["chosen_prompt_len"])
|
| 611 |
+
lr = len(np.asarray(r["rejected_input_ids"])) - int(r["rejected_prompt_len"])
|
| 612 |
+
if lc <= 0 and lr <= 0:
|
| 613 |
+
continue # no completion on either side
|
| 614 |
+
counted += 1
|
| 615 |
+
longer += int(lc > lr)
|
| 616 |
+
return longer / counted if counted else NAN
|
| 617 |
+
|
| 618 |
+
|
| 619 |
+
def dpo_banner(
|
| 620 |
+
*,
|
| 621 |
+
cfg: DpoStageConfig,
|
| 622 |
+
manifest: Mapping,
|
| 623 |
+
ref_cache_key,
|
| 624 |
+
path_bias: Mapping,
|
| 625 |
+
probe: Mapping,
|
| 626 |
+
rss: Mapping,
|
| 627 |
+
backend: str,
|
| 628 |
+
sft_gate: Mapping,
|
| 629 |
+
length_baseline: Optional[tuple[float, str]] = None,
|
| 630 |
+
pre_registration: str = "docs/RESULTS.md",
|
| 631 |
+
) -> dict:
|
| 632 |
+
"""Everything this run depends on, or a raise.
|
| 633 |
+
|
| 634 |
+
Two things are enforced here rather than merely reported. The length-drift
|
| 635 |
+
baseline is READ FROM THE MANIFEST (Arm A measured 0.5489; Arm B's is
|
| 636 |
+
different, so a constant would mis-arm the tripwire on one arm forever),
|
| 637 |
+
and a path-bias decision of 'rerun through the sharded forward' must
|
| 638 |
+
actually have been honoured -- a measurement taken, recorded, and ignored
|
| 639 |
+
is worse than one never taken, because it leaves evidence that the
|
| 640 |
+
question was considered.
|
| 641 |
+
"""
|
| 642 |
+
baseline_key = f"{cfg.arm}_chosen_longer_fraction"
|
| 643 |
+
baselines = manifest.get("length_bias_baseline", {})
|
| 644 |
+
if length_baseline is not None:
|
| 645 |
+
baseline, baseline_source = length_baseline
|
| 646 |
+
if math.isnan(baseline):
|
| 647 |
+
raise ValueError(
|
| 648 |
+
f"{baseline_key}: the supplied baseline is NaN, which means "
|
| 649 |
+
"nobody measured it. The length-drift tripwire refuses a NaN "
|
| 650 |
+
"baseline, so this would disarm it silently."
|
| 651 |
+
)
|
| 652 |
+
elif baseline_key in baselines:
|
| 653 |
+
baseline, baseline_source = baselines[baseline_key], "build_manifest"
|
| 654 |
+
else:
|
| 655 |
+
raise KeyError(
|
| 656 |
+
f"build manifest has no {baseline_key!r} and no measured baseline "
|
| 657 |
+
"was supplied. The length-drift tripwire needs THIS arm's "
|
| 658 |
+
"chosen-longer fraction; defaulting to the other arm's would "
|
| 659 |
+
"mis-arm it permanently. scripts/dpo.py measures it from the "
|
| 660 |
+
"arm's own shards when the manifest lacks it "
|
| 661 |
+
"(measure_chosen_longer_fraction)."
|
| 662 |
+
)
|
| 663 |
+
|
| 664 |
+
if path_bias.get("fired") and getattr(ref_cache_key, "forward_impl", None) != "fsdp2_sharded":
|
| 665 |
+
raise RuntimeError(
|
| 666 |
+
"the path-bias measurement fired (action="
|
| 667 |
+
f"{path_bias.get('action')!r}, ratio={path_bias.get('ratio')}) but "
|
| 668 |
+
f"the reference cache is tagged "
|
| 669 |
+
f"{getattr(ref_cache_key, 'forward_impl', None)!r}, not "
|
| 670 |
+
"'fsdp2_sharded'. Re-run the precompute through the sharded "
|
| 671 |
+
"forward. A decision that was measured, recorded and then ignored "
|
| 672 |
+
"is worse than one never taken."
|
| 673 |
+
)
|
| 674 |
+
|
| 675 |
+
horizon = optimizer_steps(cfg.expected_pairs, cfg.global_batch, cfg.epochs)
|
| 676 |
+
return {
|
| 677 |
+
"arm": cfg.arm,
|
| 678 |
+
"loss_type": f"dpo_{cfg.variant}",
|
| 679 |
+
"variant": cfg.variant,
|
| 680 |
+
"beta": cfg.beta,
|
| 681 |
+
"lr": cfg.lr,
|
| 682 |
+
"epochs": cfg.epochs,
|
| 683 |
+
"global_batch": cfg.global_batch,
|
| 684 |
+
"grad_accum": cfg.grad_accum,
|
| 685 |
+
"expected_pairs": cfg.expected_pairs,
|
| 686 |
+
"optimizer_step_formula": f"{cfg.epochs} x ceil({cfg.expected_pairs}/{cfg.global_batch}) "
|
| 687 |
+
f"= {horizon}",
|
| 688 |
+
"scheduler_horizon_optimizer_steps": horizon,
|
| 689 |
+
"dataset_hashes": dict(manifest.get("hashes", {})),
|
| 690 |
+
"ref_cache_key": json.loads(ref_cache_key.to_json()),
|
| 691 |
+
"path_bias": dict(path_bias),
|
| 692 |
+
"probe_step_time_s": probe.get("step_time_s_median", NAN),
|
| 693 |
+
"probe_derived_wall_clock_h": probe.get("projected_wall_clock_h", NAN),
|
| 694 |
+
"rss_budget": dict(rss),
|
| 695 |
+
"attention_backend": backend,
|
| 696 |
+
"length_drift_baseline": baseline,
|
| 697 |
+
"length_drift_baseline_source": baseline_source,
|
| 698 |
+
"sft_checkpoint": sft_gate.get("checkpoint", cfg.sft_checkpoint),
|
| 699 |
+
"pre_registration": pre_registration,
|
| 700 |
+
}
|
| 701 |
+
|
| 702 |
+
|
| 703 |
+
def render_dpo_banner(resolved: Mapping, title: str = "align dpo") -> str:
|
| 704 |
+
"""Render, or raise. Never a partial banner.
|
| 705 |
+
|
| 706 |
+
Checks this stage's keys AND section-01's global mandatory set, without
|
| 707 |
+
mutating the global registry -- see DPO_BANNER_KEYS for why.
|
| 708 |
+
"""
|
| 709 |
+
from ..registry import MANDATORY_BANNER_KEYS
|
| 710 |
+
|
| 711 |
+
required = list(DPO_BANNER_KEYS) + [
|
| 712 |
+
k for k in MANDATORY_BANNER_KEYS if k not in DPO_BANNER_KEYS
|
| 713 |
+
]
|
| 714 |
+
missing = [k for k in DPO_BANNER_KEYS if k not in resolved]
|
| 715 |
+
unresolved = [k for k in DPO_BANNER_KEYS if k in resolved and resolved[k] is None]
|
| 716 |
+
if missing or unresolved:
|
| 717 |
+
raise RuntimeError(
|
| 718 |
+
"DPO launch banner incomplete -- refusing to start. A run that "
|
| 719 |
+
f"cannot answer its question does not begin. missing={missing} "
|
| 720 |
+
f"unresolved(None)={unresolved}. Undefined values must be "
|
| 721 |
+
"EXPLICIT: float('nan') or 'PENDING', never absent."
|
| 722 |
+
)
|
| 723 |
+
width = 78
|
| 724 |
+
lines = ["=" * width, f"| {title} [{resolved['arm']}]", "=" * width]
|
| 725 |
+
for key in required:
|
| 726 |
+
if key in resolved:
|
| 727 |
+
lines.append(f"| {key:<36} {resolved[key]!r}")
|
| 728 |
+
for key in sorted(k for k in resolved if k not in required):
|
| 729 |
+
lines.append(f"| {key:<36} {resolved[key]!r} (extra)")
|
| 730 |
+
lines.append("=" * width)
|
| 731 |
+
return "\n".join(lines)
|
| 732 |
+
|
| 733 |
+
|
| 734 |
+
# ---------------------------------------------------------------------------
|
| 735 |
+
# Tripwire response
|
| 736 |
+
# ---------------------------------------------------------------------------
|
| 737 |
+
|
| 738 |
+
|
| 739 |
+
def apply_tripwire_response(
|
| 740 |
+
cfg: DpoStageConfig, alarms: Sequence[Mapping]
|
| 741 |
+
) -> tuple[DpoStageConfig, dict]:
|
| 742 |
+
"""Map a fired chosen-logprob tripwire to the PRE-REGISTERED response.
|
| 743 |
+
|
| 744 |
+
Returns `(config to rerun the arm with, record)`. The record goes into the
|
| 745 |
+
arm manifest, so the deviation is part of the result rather than a note
|
| 746 |
+
somebody remembers.
|
| 747 |
+
|
| 748 |
+
The alarm being responded to: `logps/chosen` declining while the margin
|
| 749 |
+
improves. DPO's objective is satisfied by separating the two logprobs, and
|
| 750 |
+
crushing the rejected one separates them just as well as raising the
|
| 751 |
+
chosen one -- while degrading the policy. The mode is MAXIMAL FOR ARM B by
|
| 752 |
+
design, because pi_SFT already assigns those chosen continuations high
|
| 753 |
+
likelihood, so there is little headroom above and a great deal below.
|
| 754 |
+
|
| 755 |
+
Every permitted response puts a floor under the chosen logprob: `dpop`
|
| 756 |
+
adds an explicit penalty when it falls below the reference, `nll_aux` adds
|
| 757 |
+
a likelihood term on the chosen completion, and `dpop_nll_aux` -- the
|
| 758 |
+
frozen pre-registration's wording -- does both. Beta is NOT changed, and
|
| 759 |
+
the arm re-runs ONCE: a second rerun would be a third configuration.
|
| 760 |
+
"""
|
| 761 |
+
# "logps_chosen" is the WIRE NAME the trainer records (trainer.py appends
|
| 762 |
+
# {"alarm": name} for the armed wire key). This filter matched a name
|
| 763 |
+
# nobody emits ("logps_chosen_decline") until 2026-08-17 -- a fired
|
| 764 |
+
# collapse tripwire could never trigger the pre-registered rerun, and the
|
| 765 |
+
# unit test asserted the filter against its own literal instead of the
|
| 766 |
+
# trainer's. Silent-failure trap #7's shape: armed-looking, disconnected.
|
| 767 |
+
fired = [a for a in alarms if a.get("alarm") == "logps_chosen"]
|
| 768 |
+
if not fired:
|
| 769 |
+
return cfg, {
|
| 770 |
+
"fired": False,
|
| 771 |
+
"rerun_required": False,
|
| 772 |
+
"pre_registered": True,
|
| 773 |
+
"configuration": f"{cfg.variant} beta={cfg.beta}",
|
| 774 |
+
"detail": "no chosen-logprob alarm; primary configuration stands as the arm's result",
|
| 775 |
+
}
|
| 776 |
+
|
| 777 |
+
if cfg.tripwire_response not in PRE_REGISTERED_RESPONSES:
|
| 778 |
+
raise ValueError(
|
| 779 |
+
f"tripwire_response={cfg.tripwire_response!r} is outside the "
|
| 780 |
+
f"pre-registration ({list(PRE_REGISTERED_RESPONSES)}). A third "
|
| 781 |
+
"configuration requires a NEW pre-registration written before the "
|
| 782 |
+
"rerun -- choosing one after seeing the data is how a stage picks "
|
| 783 |
+
"its own success criterion."
|
| 784 |
+
)
|
| 785 |
+
if cfg.tripwire_response == "dpop_nll_aux":
|
| 786 |
+
new = replace(cfg, variant="dpop", nll_coeff=NLL_AUX_COEFF)
|
| 787 |
+
elif cfg.tripwire_response == "dpop":
|
| 788 |
+
new = replace(cfg, variant="dpop")
|
| 789 |
+
else:
|
| 790 |
+
new = replace(cfg, variant="sigmoid", nll_coeff=NLL_AUX_COEFF)
|
| 791 |
+
record = {
|
| 792 |
+
"fired": True,
|
| 793 |
+
"rerun_required": True,
|
| 794 |
+
"pre_registered": True,
|
| 795 |
+
"alarms": [dict(a) for a in fired],
|
| 796 |
+
"response": cfg.tripwire_response,
|
| 797 |
+
"configuration": (
|
| 798 |
+
f"{new.variant} beta={new.beta} nll_coeff={new.nll_coeff} dpop_lambda={new.dpop_lambda}"
|
| 799 |
+
),
|
| 800 |
+
"detail": (
|
| 801 |
+
"chosen-logprob collapse detected; the arm re-runs with the "
|
| 802 |
+
"pre-registered response and THAT configuration is recorded "
|
| 803 |
+
"as the arm's result"
|
| 804 |
+
),
|
| 805 |
+
}
|
| 806 |
+
logger.error("tripwire response applied: %s", record["configuration"])
|
| 807 |
+
return new, record
|
| 808 |
+
|
| 809 |
+
|
| 810 |
+
# ---------------------------------------------------------------------------
|
| 811 |
+
# Arm result manifest
|
| 812 |
+
# ---------------------------------------------------------------------------
|
| 813 |
+
|
| 814 |
+
|
| 815 |
+
def arm_result_manifest(
|
| 816 |
+
*,
|
| 817 |
+
cfg: DpoStageConfig,
|
| 818 |
+
descoped: bool,
|
| 819 |
+
summary: Mapping,
|
| 820 |
+
heldout: Mapping,
|
| 821 |
+
banner: Mapping,
|
| 822 |
+
checkpoint: Optional[str],
|
| 823 |
+
tripwire: Optional[Mapping] = None,
|
| 824 |
+
) -> dict:
|
| 825 |
+
"""What section-13 reads to select an arm and run the paired evaluation.
|
| 826 |
+
|
| 827 |
+
A descoped arm still produces a manifest. Its accuracy is NaN, not 0.0:
|
| 828 |
+
zero is a plausible catastrophic result and would read as one, while NaN
|
| 829 |
+
says nobody measured -- the distinction section-13's arm-selection rule
|
| 830 |
+
depends on.
|
| 831 |
+
"""
|
| 832 |
+
return {
|
| 833 |
+
"arm": cfg.arm,
|
| 834 |
+
"status": "descoped" if descoped else "trained",
|
| 835 |
+
"checkpoint": None if descoped else checkpoint,
|
| 836 |
+
"loss_variant_actually_used": None if descoped else cfg.variant,
|
| 837 |
+
"beta": cfg.beta,
|
| 838 |
+
"nll_coeff": cfg.nll_coeff,
|
| 839 |
+
"lr": cfg.lr,
|
| 840 |
+
"epochs": cfg.epochs,
|
| 841 |
+
"global_batch": cfg.global_batch,
|
| 842 |
+
"optimizer_steps_planned": optimizer_steps(
|
| 843 |
+
cfg.expected_pairs, cfg.global_batch, cfg.epochs
|
| 844 |
+
),
|
| 845 |
+
"optimizer_steps_run": summary.get("optimizer_step", 0),
|
| 846 |
+
"heldout_preference_accuracy": heldout.get("accuracy", NAN),
|
| 847 |
+
"heldout_ci": [heldout.get("ci_low", NAN), heldout.get("ci_high", NAN)],
|
| 848 |
+
"heldout_n": heldout.get("n", 0),
|
| 849 |
+
"final_mean_margin": heldout.get("mean_margin", NAN),
|
| 850 |
+
"tripwire": dict(tripwire) if tripwire else {"fired": False},
|
| 851 |
+
"tripwire_alarms": list(summary.get("tripwire_alarms", [])),
|
| 852 |
+
"trunk_report": dict(summary.get("trunk_report", {})),
|
| 853 |
+
"checkpoints_written": list(summary.get("checkpoints_written", [])),
|
| 854 |
+
"banner": dict(banner),
|
| 855 |
+
}
|
| 856 |
+
|
| 857 |
+
|
| 858 |
+
def write_arm_result(path: str, manifest: Mapping) -> str:
|
| 859 |
+
"""Write the arm manifest: rank 0 only, atomically.
|
| 860 |
+
|
| 861 |
+
COLLECTIVE (every rank calls it; the closing barrier keeps ranks together).
|
| 862 |
+
Run on all four torchrun ranks with a bare `open(path, "w")`, the ranks
|
| 863 |
+
truncated and wrote one path within a millisecond, with rank-local content
|
| 864 |
+
(`checkpoint` is "" off rank 0): the recorded Arm B manifest is
|
| 865 |
+
unparseable. Section-13 reads this file to select an arm, so it is written
|
| 866 |
+
once, via tmp + os.replace, and rank 0's `checkpoint` is the real one.
|
| 867 |
+
"""
|
| 868 |
+
from ..utils.dist import barrier, is_rank0
|
| 869 |
+
from ..utils.guard import assert_paths_off_root
|
| 870 |
+
from .ref_cache import _atomic_write
|
| 871 |
+
|
| 872 |
+
assert_paths_off_root([os.path.dirname(os.path.abspath(path))])
|
| 873 |
+
if is_rank0():
|
| 874 |
+
|
| 875 |
+
def _dump(tmp: str) -> None:
|
| 876 |
+
with open(tmp, "w") as f:
|
| 877 |
+
# default=str: the manifest is assembled by the caller from eval
|
| 878 |
+
# output and this writes an ARM'S RESULT after its full DPO run --
|
| 879 |
+
# the exact shape (harness-measured values serialized at the end)
|
| 880 |
+
# that killed SFT attempt 6 with a Tensor in a gate receipt.
|
| 881 |
+
json.dump(manifest, f, indent=2, default=str)
|
| 882 |
+
f.flush()
|
| 883 |
+
os.fsync(f.fileno())
|
| 884 |
+
|
| 885 |
+
_atomic_write(path, _dump)
|
| 886 |
+
logger.info("arm result manifest -> %s", path)
|
| 887 |
+
barrier()
|
| 888 |
+
return path
|
| 889 |
+
|
| 890 |
+
|
| 891 |
+
# ---------------------------------------------------------------------------
|
| 892 |
+
# Trainer-facing batch + loss adapter
|
| 893 |
+
# ---------------------------------------------------------------------------
|
| 894 |
+
|
| 895 |
+
|
| 896 |
+
class DpoBatch:
|
| 897 |
+
"""A `Preference` batch, iterable over its pair ids.
|
| 898 |
+
|
| 899 |
+
Section-07's `Trainer` iterates the object it is handed and logs
|
| 900 |
+
`list(batch)` as that step's identity -- its own tests pass samplers that
|
| 901 |
+
yield row-index lists, for which `list(batch)` is exactly right. A
|
| 902 |
+
`Preference` dataclass is not iterable, so a bare one would raise
|
| 903 |
+
TypeError at the first log step.
|
| 904 |
+
|
| 905 |
+
Wrapping supplies the identity the log wants, and supplies a better one
|
| 906 |
+
than row indices: which PAIRS a step consumed. Row indices are meaningless
|
| 907 |
+
across a reshuffle or a rebuild; pair ids survive both, so a suspicious
|
| 908 |
+
step stays reproducible afterwards.
|
| 909 |
+
"""
|
| 910 |
+
|
| 911 |
+
__slots__ = ("preference", "pair_ids")
|
| 912 |
+
|
| 913 |
+
def __init__(self, preference):
|
| 914 |
+
self.preference = preference
|
| 915 |
+
self.pair_ids = [(m or {}).get("pair_id") for m in preference.metadata]
|
| 916 |
+
|
| 917 |
+
def __iter__(self):
|
| 918 |
+
return iter(self.pair_ids)
|
| 919 |
+
|
| 920 |
+
def __len__(self) -> int:
|
| 921 |
+
return len(self.pair_ids)
|
| 922 |
+
|
| 923 |
+
def to(self, device) -> "DpoBatch":
|
| 924 |
+
return DpoBatch(self.preference.to(device))
|
| 925 |
+
|
| 926 |
+
|
| 927 |
+
class PreferenceBatchSource:
|
| 928 |
+
"""What section-07's `Trainer` iterates: `set_epoch` / `__iter__` / `__len__`.
|
| 929 |
+
|
| 930 |
+
The Trainer walks a batch source directly rather than a `DataLoader`, and
|
| 931 |
+
that is a deliberate section-07 choice: batch order becomes a pure
|
| 932 |
+
function of (seed, epoch), which is what makes the seeded-resume contract
|
| 933 |
+
checkable. It also means there are no dataloader worker processes in this
|
| 934 |
+
loop at all -- the `<=2 workers per rank` rule that the /dev/shm SIGBUS
|
| 935 |
+
incident produced has nothing to bind to here, because nothing is forked.
|
| 936 |
+
Collation happens inline, in the training process.
|
| 937 |
+
|
| 938 |
+
That is affordable for DPO specifically: pair shards are memory-mapped and
|
| 939 |
+
a micro-batch is two short sequences, so collation is microseconds against
|
| 940 |
+
a 7B forward. It would not be affordable for a tokenization-heavy loop.
|
| 941 |
+
"""
|
| 942 |
+
|
| 943 |
+
def __init__(self, rows: Sequence[Any], sampler, pad_token_id: int):
|
| 944 |
+
self.rows = list(rows)
|
| 945 |
+
self.sampler = sampler
|
| 946 |
+
self.pad_token_id = int(pad_token_id)
|
| 947 |
+
|
| 948 |
+
def set_epoch(self, epoch: int) -> None:
|
| 949 |
+
self.sampler.set_epoch(epoch)
|
| 950 |
+
|
| 951 |
+
def __len__(self) -> int:
|
| 952 |
+
return len(self.sampler)
|
| 953 |
+
|
| 954 |
+
def __iter__(self):
|
| 955 |
+
from ..data.loader import collate_preference
|
| 956 |
+
|
| 957 |
+
for indices in self.sampler:
|
| 958 |
+
yield DpoBatch(
|
| 959 |
+
collate_preference([self.rows[i] for i in indices], pad_token_id=self.pad_token_id)
|
| 960 |
+
)
|
| 961 |
+
|
| 962 |
+
|
| 963 |
+
def make_preference_sampler(
|
| 964 |
+
rows, *, max_tokens: int, max_rows: int, seed: int = 0, rank=None, world_size=None
|
| 965 |
+
):
|
| 966 |
+
"""Section-03's length-grouped sampler, RANK-SHARDED.
|
| 967 |
+
|
| 968 |
+
THE UNSHARDED-SAMPLER INCIDENT (~24 GPU-hours on Arm A). This used to
|
| 969 |
+
return the bare `LengthGroupedSampler`, so every rank walked the same
|
| 970 |
+
batch list in the same order and all four trained the SAME pair every
|
| 971 |
+
micro-step. The effective global batch was micro x accum (8), not
|
| 972 |
+
micro x accum x world (32) as pre-registered; one "epoch" therefore
|
| 973 |
+
consumed a quarter of the corpus; the horizon covered half an epoch; and
|
| 974 |
+
past it the cosine schedule clamps to lr=0, so the run kept stepping on
|
| 975 |
+
frozen weights for 287 more steps. Nothing failed -- the loss curve was
|
| 976 |
+
plausible and the manifest reported the intended batch size throughout.
|
| 977 |
+
|
| 978 |
+
SHARDING THE BATCHES, NOT THE ROWS, and the choice matters twice over:
|
| 979 |
+
|
| 980 |
+
* `PreferenceBatchSource` indexes the FULL row list with whatever
|
| 981 |
+
indices the sampler yields, so slicing batches keeps every index
|
| 982 |
+
valid and needs no remapping -- one seam instead of two that can
|
| 983 |
+
disagree.
|
| 984 |
+
* `DistributedBatchSampler` truncates the batch list to a multiple of
|
| 985 |
+
world_size so EVERY RANK RUNS THE SAME NUMBER OF STEPS. A rank that
|
| 986 |
+
finished early would leave its peers blocked inside a collective,
|
| 987 |
+
which surfaces hours later as an NCCL timeout rather than as an error
|
| 988 |
+
where it happened. Row-slicing (`rows[rank::world]` before batching)
|
| 989 |
+
cannot promise this: the sampler is token-budgeted, so different row
|
| 990 |
+
slices yield different batch counts.
|
| 991 |
+
|
| 992 |
+
The cost is a bounded tail: up to (world_size - 1) batches are dropped per
|
| 993 |
+
epoch. Section-03 chose that over padding with repeats, which would
|
| 994 |
+
double-count rows and corrupt the epoch semantics the horizon is derived
|
| 995 |
+
from.
|
| 996 |
+
|
| 997 |
+
`rank`/`world_size` default to the live environment but are explicit
|
| 998 |
+
parameters so four ranks can be simulated in one CPU process -- which is
|
| 999 |
+
how this contract is tested, since the incident is a property of the
|
| 1000 |
+
relationship BETWEEN ranks and no single-process run can see it.
|
| 1001 |
+
"""
|
| 1002 |
+
from ..data.loader import (
|
| 1003 |
+
DistributedBatchSampler,
|
| 1004 |
+
LengthGroupedSampler,
|
| 1005 |
+
preference_length_key,
|
| 1006 |
+
)
|
| 1007 |
+
from ..utils.dist import rank as env_rank
|
| 1008 |
+
from ..utils.dist import world_size as env_world_size
|
| 1009 |
+
|
| 1010 |
+
lengths = [preference_length_key(r) for r in rows]
|
| 1011 |
+
base = LengthGroupedSampler(lengths, max_tokens=max_tokens, max_rows=max_rows, seed=seed)
|
| 1012 |
+
|
| 1013 |
+
r = env_rank() if rank is None else int(rank)
|
| 1014 |
+
ws = env_world_size() if world_size is None else int(world_size)
|
| 1015 |
+
if ws <= 1:
|
| 1016 |
+
return base
|
| 1017 |
+
if not 0 <= r < ws:
|
| 1018 |
+
raise ValueError(f"rank {r} is outside world_size {ws}")
|
| 1019 |
+
return DistributedBatchSampler(base, rank=r, world_size=ws)
|
| 1020 |
+
|
| 1021 |
+
|
| 1022 |
+
def _check_global_batch(
|
| 1023 |
+
global_batch: int, world_size: int, micro_batch_size: int, grad_accum: int
|
| 1024 |
+
) -> int:
|
| 1025 |
+
"""The arithmetic behind both launch refusals; returns the live global batch."""
|
| 1026 |
+
live = world_size * micro_batch_size * grad_accum
|
| 1027 |
+
if global_batch != live:
|
| 1028 |
+
raise RuntimeError(
|
| 1029 |
+
f"global_batch={global_batch} (the pre-registered value the banner "
|
| 1030 |
+
f"and horizon are quoted in) != world_size {world_size} x micro "
|
| 1031 |
+
f"{micro_batch_size} x grad_accum {grad_accum} = {live}. The run "
|
| 1032 |
+
"would consume a different number of pairs per optimizer step than "
|
| 1033 |
+
"it reports. Launch with the pre-registered rank count or change "
|
| 1034 |
+
"the config deliberately."
|
| 1035 |
+
)
|
| 1036 |
+
return live
|
| 1037 |
+
|
| 1038 |
+
|
| 1039 |
+
def live_world_size() -> int:
|
| 1040 |
+
"""The data-parallel width this process is ACTUALLY part of.
|
| 1041 |
+
|
| 1042 |
+
The initialised process group wins over the environment: `WORLD_SIZE` is
|
| 1043 |
+
what torchrun exported, the group is what the collectives will average
|
| 1044 |
+
over, and a launch decision belongs to the latter. Falls back to the
|
| 1045 |
+
environment before `init_process_group` (and to 1 outside torchrun).
|
| 1046 |
+
"""
|
| 1047 |
+
import torch.distributed as dist
|
| 1048 |
+
|
| 1049 |
+
if dist.is_available() and dist.is_initialized():
|
| 1050 |
+
return int(dist.get_world_size())
|
| 1051 |
+
from ..utils.dist import world_size
|
| 1052 |
+
|
| 1053 |
+
return int(world_size())
|
| 1054 |
+
|
| 1055 |
+
|
| 1056 |
+
def assert_launch_topology(cfg: "DpoStageConfig", *, world_size: Optional[int] = None) -> dict:
|
| 1057 |
+
"""LAUNCH REFUSAL: world_size x micro x grad_accum must equal the
|
| 1058 |
+
pre-registered `global_batch` (2026-10-12 review).
|
| 1059 |
+
|
| 1060 |
+
Nothing used to check it. `global_batch` is a config number that the
|
| 1061 |
+
banner, `optimizer_steps()` and the arm manifest all quote, while what a
|
| 1062 |
+
run really consumes per optimizer step is `world_size * micro_batch_size *
|
| 1063 |
+
grad_accum`. A 2-rank launch of the 4-rank pre-registration therefore ran
|
| 1064 |
+
16 pairs per step under a banner that said 32 and a quoted horizon of 146
|
| 1065 |
+
(really 290): the experiment recorded as one thing and executed as
|
| 1066 |
+
another, with every number finite. `assert_dpo_horizon_consistent`
|
| 1067 |
+
carries the same check but sits AFTER the 14.5GB model load and was never
|
| 1068 |
+
passed the pre-registered value by the launcher; this one runs before any
|
| 1069 |
+
gate, in milliseconds, fed by the live topology.
|
| 1070 |
+
|
| 1071 |
+
`world_size=None` reads it from the live process group (see
|
| 1072 |
+
`live_world_size`); tests and tools pass it explicitly.
|
| 1073 |
+
"""
|
| 1074 |
+
ws = live_world_size() if world_size is None else int(world_size)
|
| 1075 |
+
if ws < 1:
|
| 1076 |
+
raise ValueError(f"world_size={ws} must be >= 1")
|
| 1077 |
+
live = _check_global_batch(cfg.global_batch, ws, cfg.micro_batch_size, cfg.grad_accum)
|
| 1078 |
+
return {
|
| 1079 |
+
"world_size": ws,
|
| 1080 |
+
"micro_batch_size": cfg.micro_batch_size,
|
| 1081 |
+
"grad_accum": cfg.grad_accum,
|
| 1082 |
+
"global_batch": live,
|
| 1083 |
+
}
|
| 1084 |
+
|
| 1085 |
+
|
| 1086 |
+
def expected_ref_cache_key(cfg: "DpoStageConfig", manifest: Mapping, sft_checkpoint: str):
|
| 1087 |
+
"""The key this run requires of its reference caches.
|
| 1088 |
+
|
| 1089 |
+
Five components are EXPECTATIONS built from this run's own facts (content
|
| 1090 |
+
hash of the SFT weights, tokenizer, shard manifest, max_length, truncation)
|
| 1091 |
+
and one is a RECORD: `forward_impl`, how the cache's own pass ran. The
|
| 1092 |
+
record is read from the cache itself, never from the path-bias report.
|
| 1093 |
+
|
| 1094 |
+
WHY NOT THE REPORT (2026-10-12 review). The launcher used to take
|
| 1095 |
+
forward_impl from the report's `cache_forward_impl`, which
|
| 1096 |
+
precompute_ref_logprobs wrote as "fsdp2_sharded" whenever `--path-bias`
|
| 1097 |
+
was passed -- but a `--path-bias` run does NOT recompute the caches, so the
|
| 1098 |
+
field described the measuring run, not the cache. The documented happy
|
| 1099 |
+
path (single-GPU precompute, then `--path-bias`, decision fired=False)
|
| 1100 |
+
was refused at the cache gate: "Mismatched: forward_impl
|
| 1101 |
+
(cache='single_gpu', run='fsdp2_sharded')". The rule that a FIRED decision
|
| 1102 |
+
must be honoured by a sharded cache is enforced where it belongs, by
|
| 1103 |
+
`dpo_banner`; here the held-out cache must agree with the train cache's
|
| 1104 |
+
tag, and everything else is asserted by `load_ref_cache(expect=...)`.
|
| 1105 |
+
"""
|
| 1106 |
+
from .ref_cache import build_ref_cache_key, peek_ref_cache_key
|
| 1107 |
+
|
| 1108 |
+
cache_key = peek_ref_cache_key(cfg.ref_cache_path)
|
| 1109 |
+
forward_impl = cache_key.forward_impl if cache_key is not None else "single_gpu"
|
| 1110 |
+
return build_ref_cache_key(sft_checkpoint, dict(manifest), forward_impl=forward_impl)
|
| 1111 |
+
|
| 1112 |
+
|
| 1113 |
+
def load_run_ref_caches(cfg: "DpoStageConfig", manifest: Mapping, sft_checkpoint: str):
|
| 1114 |
+
"""Load (train, held-out) reference caches under `expected_ref_cache_key`.
|
| 1115 |
+
|
| 1116 |
+
Both caches are loaded with `expect=`, so a missing file, a stale
|
| 1117 |
+
checkpoint, a rebuilt shard set, a different tokenizer/max_length, or a
|
| 1118 |
+
held-out cache computed through a different forward than the train cache
|
| 1119 |
+
all raise here, at the gate, naming the guilty component.
|
| 1120 |
+
"""
|
| 1121 |
+
from .ref_cache import load_ref_cache
|
| 1122 |
+
|
| 1123 |
+
key = expected_ref_cache_key(cfg, manifest, sft_checkpoint)
|
| 1124 |
+
train = load_ref_cache(cfg.ref_cache_path, expect=key)
|
| 1125 |
+
held = load_ref_cache(cfg.heldout_ref_cache_path, expect=key)
|
| 1126 |
+
return train, held
|
| 1127 |
+
|
| 1128 |
+
|
| 1129 |
+
def assert_dpo_horizon_consistent(
|
| 1130 |
+
scheduler_total_steps: int,
|
| 1131 |
+
*,
|
| 1132 |
+
n_pairs: int,
|
| 1133 |
+
world_size: int,
|
| 1134 |
+
micro_batch_size: int,
|
| 1135 |
+
grad_accum: int,
|
| 1136 |
+
epochs: int,
|
| 1137 |
+
slack: Optional[int] = None,
|
| 1138 |
+
global_batch: Optional[int] = None,
|
| 1139 |
+
) -> dict:
|
| 1140 |
+
"""Refuse to launch when the LR horizon disagrees with the corpus.
|
| 1141 |
+
|
| 1142 |
+
`global_batch`, when given, is the PRE-REGISTERED number the banner and the
|
| 1143 |
+
horizon formula (`optimizer_steps`) are quoted in; it must equal what the
|
| 1144 |
+
live topology actually consumes per optimizer step,
|
| 1145 |
+
`world_size * micro_batch_size * grad_accum`. Without this the banner can
|
| 1146 |
+
say global_batch 32 / horizon 146 while a 2-rank launch really runs 16 /
|
| 1147 |
+
290: the scheduler-vs-corpus check below passes (it uses the live world
|
| 1148 |
+
size) and the banner is simply wrong about what ran.
|
| 1149 |
+
|
| 1150 |
+
THE ZOMBIE-RUN REFUSAL. Arm A ran a 146-step horizon over a corpus needing
|
| 1151 |
+
~289 per-rank steps, so the cosine finished at half an epoch; past it
|
| 1152 |
+
`WarmupCosineSchedule` clamps to exactly 0 (min_lr_frac=0) and the loop
|
| 1153 |
+
kept stepping on frozen weights for 287 more steps. The horizon is a
|
| 1154 |
+
PRE-REGISTERED quantity: if it and the arithmetic disagree, one of them is
|
| 1155 |
+
wrong and no amount of training will reconcile them.
|
| 1156 |
+
|
| 1157 |
+
Computed from the SHARDED per-rank row count, because that is what a rank
|
| 1158 |
+
actually walks. `slack` absorbs the token-budgeted sampler landing a batch
|
| 1159 |
+
or two either side of the row-count arithmetic; it does not absorb a
|
| 1160 |
+
factor.
|
| 1161 |
+
"""
|
| 1162 |
+
import math as _math
|
| 1163 |
+
|
| 1164 |
+
if min(n_pairs, world_size, micro_batch_size, grad_accum, epochs) < 1:
|
| 1165 |
+
raise ValueError("every horizon input must be >= 1")
|
| 1166 |
+
|
| 1167 |
+
if global_batch is not None:
|
| 1168 |
+
_check_global_batch(global_batch, world_size, micro_batch_size, grad_accum)
|
| 1169 |
+
per_rank_rows = _math.ceil(n_pairs / world_size)
|
| 1170 |
+
rows_per_step = micro_batch_size * grad_accum
|
| 1171 |
+
expected = epochs * _math.ceil(per_rank_rows / rows_per_step)
|
| 1172 |
+
if slack is None:
|
| 1173 |
+
# PROPORTIONAL slack: the token-budgeted sampler legitimately cuts
|
| 1174 |
+
# batches short of max_rows, so the sampler-true horizon runs a few
|
| 1175 |
+
# PERCENT over the row arithmetic (Arm B: 728 vs 708, 2.8%, which a
|
| 1176 |
+
# fixed slack of 2 wrongly refused). 5% absorbs batch-shortfall while
|
| 1177 |
+
# still catching the zombie class (Arm A was 2.1x, far outside).
|
| 1178 |
+
slack = max(2, _math.ceil(0.05 * expected))
|
| 1179 |
+
report = {
|
| 1180 |
+
"scheduler_total_steps": int(scheduler_total_steps),
|
| 1181 |
+
"expected_steps": expected,
|
| 1182 |
+
"per_rank_rows": per_rank_rows,
|
| 1183 |
+
"rows_per_optimizer_step_per_rank": rows_per_step,
|
| 1184 |
+
"unique_rows_per_optimizer_step": rows_per_step * world_size,
|
| 1185 |
+
"epochs_covered": (scheduler_total_steps / expected * epochs if expected else float("nan")),
|
| 1186 |
+
}
|
| 1187 |
+
if abs(int(scheduler_total_steps) - expected) > slack:
|
| 1188 |
+
covered = report["epochs_covered"]
|
| 1189 |
+
raise RuntimeError(
|
| 1190 |
+
f"DPO horizon is inconsistent with the corpus: the scheduler will "
|
| 1191 |
+
f"run {scheduler_total_steps} optimizer steps but "
|
| 1192 |
+
f"{n_pairs} pairs / {world_size} ranks = {per_rank_rows} rows per "
|
| 1193 |
+
f"rank at {rows_per_step} rows per step needs {expected} "
|
| 1194 |
+
f"(epochs={epochs}, slack={slack}). That horizon covers "
|
| 1195 |
+
f"{covered:.2f} epoch(s).\n\nA horizon shorter than the data is "
|
| 1196 |
+
"not a shorter run -- past it the cosine schedule clamps the "
|
| 1197 |
+
"learning rate to exactly 0 and the loop keeps stepping on frozen "
|
| 1198 |
+
"weights, which is how Arm A spent ~24 GPU-hours training "
|
| 1199 |
+
"nothing. Fix the horizon or the corpus arithmetic before "
|
| 1200 |
+
"launching."
|
| 1201 |
+
)
|
| 1202 |
+
return report
|
| 1203 |
+
|
| 1204 |
+
|
| 1205 |
+
#: The canonical name for the implicit reward margin -- section-05's kernel
|
| 1206 |
+
#: emits it and section-07's Trainer reads it. Imported from the Trainer
|
| 1207 |
+
#: rather than spelled again here: a second copy of the string, asserting what
|
| 1208 |
+
#: the Trainer reads, is precisely the drift that disarmed the tripwire the
|
| 1209 |
+
#: first time.
|
| 1210 |
+
from .trainer import MARGIN_KEY as KERNEL_MARGIN_KEY # noqa: E402
|
| 1211 |
+
|
| 1212 |
+
|
| 1213 |
+
def _arm_tripwire_metrics(metrics: dict, preference, window=None) -> dict:
|
| 1214 |
+
"""Assert this adapter supplies what the armed tripwires consume.
|
| 1215 |
+
|
| 1216 |
+
THE DISARMED-TRIPWIRE INCIDENT, in its final form. Section-05's kernel
|
| 1217 |
+
emits the implicit margin as `rewards/margins`; section-07's Trainer used
|
| 1218 |
+
to seed a DIFFERENT key, `margin`, with NaN and feed the tripwire from
|
| 1219 |
+
that. Nothing produced it, `_slope` returns NaN for any window containing
|
| 1220 |
+
NaN, and the trip condition requires a non-NaN slope -- so the
|
| 1221 |
+
chosen-likelihood-collapse tripwire, the one with a pre-registered
|
| 1222 |
+
response and the one maximal for Arm B by design, could never fire while
|
| 1223 |
+
appearing fully wired.
|
| 1224 |
+
|
| 1225 |
+
The cure is section-07's, not this module's: the Trainer now reads
|
| 1226 |
+
`MARGIN_KEY` directly, tripwires declare `INPUT_KEYS`, and
|
| 1227 |
+
`assert_tripwires_armed` halts at the first log step if an armed guard's
|
| 1228 |
+
inputs are missing or NaN. This function no longer bridges names -- an
|
| 1229 |
+
alias here would write a key nothing reads.
|
| 1230 |
+
|
| 1231 |
+
What remains is a PRESENCE check at the seam, which is still worth its
|
| 1232 |
+
line: it catches a section-05 rename here, one function call after the
|
| 1233 |
+
kernel returns, rather than at the first log step with the metric already
|
| 1234 |
+
gone. `chosen_longer_fraction` is supplied for the same reason -- the
|
| 1235 |
+
length-drift tripwire's only input, and this adapter is the only thing
|
| 1236 |
+
that can compute it.
|
| 1237 |
+
"""
|
| 1238 |
+
if KERNEL_MARGIN_KEY not in metrics:
|
| 1239 |
+
raise KeyError(
|
| 1240 |
+
f"the DPO kernel did not emit {KERNEL_MARGIN_KEY!r} (present: "
|
| 1241 |
+
f"{sorted(metrics)}). That is the canonical margin key the "
|
| 1242 |
+
"chosen-logprob tripwire consumes; without it the armed tripwire "
|
| 1243 |
+
"self-test halts the run, and this raise names the cause one call "
|
| 1244 |
+
"after the kernel returned instead of at the first log step."
|
| 1245 |
+
)
|
| 1246 |
+
if window is not None:
|
| 1247 |
+
window.extend(_chosen_longer_flags(preference))
|
| 1248 |
+
metrics["chosen_longer_fraction"] = sum(window) / len(window) if window else NAN
|
| 1249 |
+
metrics["chosen_longer_pairs"] = float(len(window))
|
| 1250 |
+
metrics.setdefault("chosen_longer_fraction", preference.stats()["chosen_longer_fraction"])
|
| 1251 |
+
metrics.setdefault("chosen_longer_fraction_batch", preference.stats()["chosen_longer_fraction"])
|
| 1252 |
+
return metrics
|
| 1253 |
+
|
| 1254 |
+
|
| 1255 |
+
def _chosen_longer_flags(preference) -> list[int]:
|
| 1256 |
+
"""Per-pair `chosen completion is longer`, skipping pairs with no
|
| 1257 |
+
completion on either side.
|
| 1258 |
+
|
| 1259 |
+
Per PAIR, because the length-drift tripwire's input has to be a fraction
|
| 1260 |
+
over a sample whose size we control; the batch-level statistic is that
|
| 1261 |
+
same fraction over whatever happened to be in the micro-batch, which at
|
| 1262 |
+
micro_batch 2 is three possible values.
|
| 1263 |
+
|
| 1264 |
+
Completion tokens, not sequence tokens: Arm B's two sides share their
|
| 1265 |
+
entire prefix, so a sequence-length comparison is dominated by a term
|
| 1266 |
+
identical on both sides and would compress the fraction toward 0.5
|
| 1267 |
+
regardless of the data -- hiding exactly the drift being watched for.
|
| 1268 |
+
"""
|
| 1269 |
+
c = preference.chosen_completion_mask.sum(dim=1)
|
| 1270 |
+
r = preference.rejected_completion_mask.sum(dim=1)
|
| 1271 |
+
return [int(ci > ri) for ci, ri in zip(c.tolist(), r.tolist()) if (ci + ri) > 0]
|
| 1272 |
+
|
| 1273 |
+
|
| 1274 |
+
def make_dpo_stage_adapter(
|
| 1275 |
+
cache, cfg: Optional[DpoStageConfig] = None, require_fused: bool = False
|
| 1276 |
+
):
|
| 1277 |
+
"""Trainer loss adapter: attach cached refs by pair_id, then delegate.
|
| 1278 |
+
|
| 1279 |
+
Bridges three things section-05 and section-03 do not agree on, and does
|
| 1280 |
+
it in one place so the disagreement cannot be re-resolved differently
|
| 1281 |
+
somewhere else:
|
| 1282 |
+
|
| 1283 |
+
* NAMES. Section-03's `Preference` carries `chosen_input_ids` /
|
| 1284 |
+
`ref_chosen_logps`; section-05's adapter unpacks `input_ids_chosen` /
|
| 1285 |
+
`ref_logp_chosen`. Neither is wrong; they were written to different
|
| 1286 |
+
drafts.
|
| 1287 |
+
* LABELS. `Preference` carries a completion MASK, `fused_dpo` wants
|
| 1288 |
+
labels with IGNORE_INDEX outside the completion. The conversion is one
|
| 1289 |
+
`masked_fill`, and getting it backwards would score the prompt -- which
|
| 1290 |
+
cancels in the DPO difference while inflating both magnitudes.
|
| 1291 |
+
* REFERENCES. They come from the cache, joined by pair_id, never from
|
| 1292 |
+
batch order and never defaulted.
|
| 1293 |
+
|
| 1294 |
+
The reference tensors enter as plain constants; section-05's kernel
|
| 1295 |
+
detaches them and proves no gradient path survives. This adapter's only
|
| 1296 |
+
obligation is never to build one, which it discharges by reading them out
|
| 1297 |
+
of a dict of floats.
|
| 1298 |
+
"""
|
| 1299 |
+
from ..kernels.fused_dpo import IGNORE_INDEX
|
| 1300 |
+
from .dpo_loss import DpoLossConfig, make_dpo_loss_adapter
|
| 1301 |
+
from .ref_cache import attach_refs
|
| 1302 |
+
|
| 1303 |
+
stage = cfg or DpoStageConfig()
|
| 1304 |
+
#: Rolling per-PAIR history feeding the length-drift tripwire. Lives on
|
| 1305 |
+
#: the adapter, not on the batch: the statistic the tripwire needs spans
|
| 1306 |
+
#: batches, and computing it per batch is what makes it unusable.
|
| 1307 |
+
length_window = collections.deque(maxlen=stage.length_window_pairs)
|
| 1308 |
+
inner = make_dpo_loss_adapter(
|
| 1309 |
+
DpoLossConfig(
|
| 1310 |
+
beta=stage.beta,
|
| 1311 |
+
variant=stage.variant,
|
| 1312 |
+
nll_coeff=stage.nll_coeff,
|
| 1313 |
+
dpop_lambda=stage.dpop_lambda,
|
| 1314 |
+
),
|
| 1315 |
+
require_fused=require_fused,
|
| 1316 |
+
# One trunk graph at a time: both sides' checkpointed 7B graphs plus
|
| 1317 |
+
# fp32 shard grads measured 21.5GB and OOM'd (batch-size independent).
|
| 1318 |
+
decoupled_backward=require_fused,
|
| 1319 |
+
grad_scale=1.0 / max(1, stage.grad_accum),
|
| 1320 |
+
)
|
| 1321 |
+
|
| 1322 |
+
def adapter(model, batch):
|
| 1323 |
+
import torch
|
| 1324 |
+
|
| 1325 |
+
pref = batch.preference if isinstance(batch, DpoBatch) else batch
|
| 1326 |
+
pref = attach_refs(pref, cache)
|
| 1327 |
+
# The WHOLE batch to the compute device, refs included. The model
|
| 1328 |
+
# forward moves input_ids internally, which let CPU labels survive
|
| 1329 |
+
# to the loss's gather ("index is on cpu") on the stage's first
|
| 1330 |
+
# real step -- the SFT script moves its collated batch explicitly,
|
| 1331 |
+
# and this adapter is the DPO stage's equivalent place.
|
| 1332 |
+
#
|
| 1333 |
+
# Follow the MODEL's device, not `cuda.is_available()`: on a box with
|
| 1334 |
+
# visible GPUs a CPU-resident model (the unit-test tiny model) would
|
| 1335 |
+
# otherwise get CUDA batches and die inside the embedding. Under FSDP2
|
| 1336 |
+
# the parameters are DTensors on this rank's cuda device, so the real
|
| 1337 |
+
# path is unchanged.
|
| 1338 |
+
param = next(iter(model.parameters()), None)
|
| 1339 |
+
if param is not None and param.device.type == "cuda":
|
| 1340 |
+
pref = pref.to(torch.device("cuda", torch.cuda.current_device()))
|
| 1341 |
+
elif param is None and torch.cuda.is_available():
|
| 1342 |
+
pref = pref.to(torch.device("cuda", torch.cuda.current_device()))
|
| 1343 |
+
bound = {
|
| 1344 |
+
"input_ids_chosen": pref.chosen_input_ids,
|
| 1345 |
+
"labels_chosen": pref.chosen_input_ids.masked_fill(
|
| 1346 |
+
pref.chosen_completion_mask == 0, IGNORE_INDEX
|
| 1347 |
+
),
|
| 1348 |
+
"input_ids_rejected": pref.rejected_input_ids,
|
| 1349 |
+
"labels_rejected": pref.rejected_input_ids.masked_fill(
|
| 1350 |
+
pref.rejected_completion_mask == 0, IGNORE_INDEX
|
| 1351 |
+
),
|
| 1352 |
+
"ref_logp_chosen": pref.ref_chosen_logps,
|
| 1353 |
+
"ref_logp_rejected": pref.ref_rejected_logps,
|
| 1354 |
+
}
|
| 1355 |
+
out = inner(model, bound)
|
| 1356 |
+
_arm_tripwire_metrics(out.metrics, pref, window=length_window)
|
| 1357 |
+
return out
|
| 1358 |
+
|
| 1359 |
+
adapter.uses_fused = getattr(inner, "uses_fused", True)
|
| 1360 |
+
adapter.config = inner.config
|
| 1361 |
+
adapter.stage_config = stage
|
| 1362 |
+
return adapter
|
code/align/train/fsdp.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FSDP2 wrapping and activation checkpointing.
|
| 2 |
+
|
| 3 |
+
Called from the LAUNCH SCRIPTS, never from the Trainer: the Trainer receives an
|
| 4 |
+
already-wrapped model, which is what keeps it identical between the SFT and DPO
|
| 5 |
+
stages and testable on CPU without a process group.
|
| 6 |
+
|
| 7 |
+
Two policies here are not configurable, deliberately:
|
| 8 |
+
|
| 9 |
+
* `use_reentrant=False`, hard-asserted. Reentrant activation checkpointing
|
| 10 |
+
silently ZEROES trunk gradients under sharding. The run trains -- loss
|
| 11 |
+
falls, throughput is normal -- while only the embeddings and the LM head
|
| 12 |
+
move. There is no knob to turn it back on, because the only reason anyone
|
| 13 |
+
would set it is not knowing this. The trunk-movement probe in `probes.py`
|
| 14 |
+
is the runtime backstop; this is the compile-time one.
|
| 15 |
+
* No `CPUOffloadPolicy`. FSDP's own CPU offload pays per-layer transfers on
|
| 16 |
+
every forward AND backward; our offload is optimizer-state-only and
|
| 17 |
+
happens once per optimizer step (see `offload_adamw.py`). Turning both on
|
| 18 |
+
would move the same bytes twice.
|
| 19 |
+
|
| 20 |
+
Sharding is PER TRANSFORMER BLOCK, then the root. Per-block units are what
|
| 21 |
+
make the all-gather granularity match the compute granularity: one unit for
|
| 22 |
+
the whole model would gather all 7.25B parameters before the first matmul.
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
import functools
|
| 28 |
+
import logging
|
| 29 |
+
from typing import Any, Optional
|
| 30 |
+
|
| 31 |
+
import torch
|
| 32 |
+
|
| 33 |
+
logger = logging.getLogger(__name__)
|
| 34 |
+
|
| 35 |
+
#: bf16 compute, fp32 reduction. bf16 is mandatory (fp16 overflows the
|
| 36 |
+
#: 32768-vocab logits); the fp32 REDUCE matters independently -- gradient
|
| 37 |
+
#: all-reduce in bf16 loses small updates across 4 ranks precisely when they
|
| 38 |
+
#: are averaged, which is every step.
|
| 39 |
+
PARAM_DTYPE = torch.bfloat16
|
| 40 |
+
REDUCE_DTYPE = torch.float32
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def transformer_blocks(model) -> list:
|
| 44 |
+
"""The per-block FSDP units: every `DecoderBlock` in the stack.
|
| 45 |
+
|
| 46 |
+
Found structurally rather than by isinstance so this module does not
|
| 47 |
+
import the model (and so a wrapped/compiled model still works).
|
| 48 |
+
"""
|
| 49 |
+
inner = getattr(model, "model", model)
|
| 50 |
+
layers = getattr(inner, "layers", None)
|
| 51 |
+
if layers is None:
|
| 52 |
+
raise ValueError(
|
| 53 |
+
"no `.layers` found on the model: FSDP2 here shards per "
|
| 54 |
+
"transformer block, and a model with no block list would be "
|
| 55 |
+
"wrapped as ONE unit -- gathering all 7.25B parameters before the "
|
| 56 |
+
"first matmul, which does not fit on a 23GB card."
|
| 57 |
+
)
|
| 58 |
+
return list(layers)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def mixed_precision_policy():
|
| 62 |
+
"""`MixedPrecisionPolicy(param_dtype=bf16, reduce_dtype=fp32)`."""
|
| 63 |
+
from torch.distributed.fsdp import MixedPrecisionPolicy
|
| 64 |
+
|
| 65 |
+
return MixedPrecisionPolicy(param_dtype=PARAM_DTYPE, reduce_dtype=REDUCE_DTYPE)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def apply_fsdp(model, mesh: Optional[Any] = None, reshard_after_forward: bool = True):
|
| 69 |
+
"""Shard per transformer block, then the root.
|
| 70 |
+
|
| 71 |
+
Order is load-bearing: the root must be sharded LAST, after its children
|
| 72 |
+
are already units, or the root unit would own their parameters too.
|
| 73 |
+
"""
|
| 74 |
+
from torch.distributed.fsdp import fully_shard
|
| 75 |
+
|
| 76 |
+
policy = mixed_precision_policy()
|
| 77 |
+
blocks = transformer_blocks(model)
|
| 78 |
+
for block in blocks:
|
| 79 |
+
fully_shard(block, mesh=mesh, mp_policy=policy, reshard_after_forward=reshard_after_forward)
|
| 80 |
+
fully_shard(model, mesh=mesh, mp_policy=policy, reshard_after_forward=reshard_after_forward)
|
| 81 |
+
logger.info(
|
| 82 |
+
"FSDP2: %d per-block units + root, param=%s reduce=%s",
|
| 83 |
+
len(blocks),
|
| 84 |
+
PARAM_DTYPE,
|
| 85 |
+
REDUCE_DTYPE,
|
| 86 |
+
)
|
| 87 |
+
return model
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _checkpointed_forward(block, *args, **kwargs):
|
| 91 |
+
from torch.utils.checkpoint import checkpoint
|
| 92 |
+
|
| 93 |
+
return checkpoint(block._nmo_inner_forward, *args, use_reentrant=False, **kwargs)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def apply_activation_checkpointing(model, use_reentrant: bool = False):
|
| 97 |
+
"""Wrap every transformer block in non-reentrant activation checkpointing.
|
| 98 |
+
|
| 99 |
+
Patches each block's `forward` rather than wrapping the block in a new
|
| 100 |
+
module: a wrapper module would insert a level into the module tree and
|
| 101 |
+
change every parameter name, which would silently break the HF checkpoint
|
| 102 |
+
mapping and the interp pillar that loads through it.
|
| 103 |
+
"""
|
| 104 |
+
if use_reentrant:
|
| 105 |
+
raise ValueError(
|
| 106 |
+
"use_reentrant=True is refused. Reentrant activation checkpointing "
|
| 107 |
+
"silently ZEROES trunk gradients under sharding: the run trains "
|
| 108 |
+
"the embeddings and the LM head, the loss falls, throughput looks "
|
| 109 |
+
"normal, and the body of the model never moves. This is a "
|
| 110 |
+
"documented predecessor failure and there is no configuration in "
|
| 111 |
+
"which we want it."
|
| 112 |
+
)
|
| 113 |
+
blocks = transformer_blocks(model)
|
| 114 |
+
for block in blocks:
|
| 115 |
+
if hasattr(block, "_nmo_inner_forward"):
|
| 116 |
+
continue # idempotent: never double-wrap
|
| 117 |
+
block._nmo_inner_forward = block.forward
|
| 118 |
+
block.forward = functools.partial(_checkpointed_forward, block)
|
| 119 |
+
logger.info("activation checkpointing on %d blocks (use_reentrant=False)", len(blocks))
|
| 120 |
+
return model
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def is_checkpointed(block) -> bool:
|
| 124 |
+
return hasattr(block, "_nmo_inner_forward")
|
code/align/train/lora_scale.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LoRA scale sweep WITHOUT retraining (SOTA plan K4).
|
| 2 |
+
|
| 3 |
+
A LoRA layer computes `W x + s * B A x` with s = alpha / r. Evaluating the SAME trained adapter at a
|
| 4 |
+
multiple `k` of that delta (k in {0.5, 0.7, 1.0}: "alpha curve") needs no optimisation: set s -> k*s.
|
| 5 |
+
k = 1 is the trained model, k = 0 is the base, and the curve between them tells whether a regression
|
| 6 |
+
is "too much adapter" (a smaller k recovers it) or "the wrong direction" (it does not).
|
| 7 |
+
|
| 8 |
+
Two entry points, matching the two ways an adapter is evaluated:
|
| 9 |
+
|
| 10 |
+
* `scaled_adapter(model, adapter, k)` -- a context manager over a LIVE model with the adapter attached
|
| 11 |
+
(the un-merged serving option); `sweep(...)` runs an eval callable at each k.
|
| 12 |
+
* `merged_state_dict_at_scale(model, full_state, adapter, k)` -- a BASE-keyed merged state dict at
|
| 13 |
+
scale k, for merged exports, with exactly the bf16-cast fidelity report of
|
| 14 |
+
`align.ppo.lora.export_merged_state_dict` (the delta is k times smaller, so MORE of it can fall into
|
| 15 |
+
the bf16 dead zone: read `report["cosine"]` before trusting a small-k merged eval).
|
| 16 |
+
|
| 17 |
+
Refusals: a layer that has the adapter MERGED into its weight cannot be rescaled (the delta is already
|
| 18 |
+
inside W); the context manager raises instead of evaluating a polluted point. The model is never left
|
| 19 |
+
rescaled: the original scaling is restored in `finally`, on error and on KeyboardInterrupt alike.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
import contextlib
|
| 25 |
+
import math
|
| 26 |
+
from collections.abc import Callable, Iterable, Iterator
|
| 27 |
+
from typing import Any, Optional
|
| 28 |
+
|
| 29 |
+
import torch
|
| 30 |
+
import torch.nn as nn
|
| 31 |
+
|
| 32 |
+
from ..ppo.lora import export_merged_state_dict, lora_modules
|
| 33 |
+
|
| 34 |
+
#: The plan's K4 grid: fractions of the TRAINED adapter delta.
|
| 35 |
+
DEFAULT_SCALES: tuple[float, ...] = (0.5, 0.7, 1.0)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _check_scale(k: float) -> float:
|
| 39 |
+
k = float(k)
|
| 40 |
+
if not math.isfinite(k) or k < 0.0:
|
| 41 |
+
raise ValueError(f"LoRA delta scale must be a finite number >= 0, got {k!r}")
|
| 42 |
+
return k
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@contextlib.contextmanager
|
| 46 |
+
def scaled_adapter(model: nn.Module, adapter: str, k: float) -> Iterator[int]:
|
| 47 |
+
"""Within the block, every layer's `adapter` contributes `k` times its trained delta.
|
| 48 |
+
|
| 49 |
+
Yields the number of layers rescaled. Raises (before changing anything) if no layer carries the
|
| 50 |
+
adapter or if any layer has it merged.
|
| 51 |
+
"""
|
| 52 |
+
k = _check_scale(k)
|
| 53 |
+
layers = [(p, m) for p, m in lora_modules(model) if adapter in m.adapters]
|
| 54 |
+
if not layers:
|
| 55 |
+
raise KeyError(f"no layer carries adapter {adapter!r}; nothing to scale")
|
| 56 |
+
merged = [p for p, m in layers if m._merged is not None]
|
| 57 |
+
if merged:
|
| 58 |
+
raise RuntimeError(
|
| 59 |
+
f"adapter {adapter!r} is merged into {len(merged)} layer(s) (first: {merged[0]}); the delta is "
|
| 60 |
+
"already inside the weight and cannot be rescaled. Unmerge first, or use "
|
| 61 |
+
"merged_state_dict_at_scale on the unmerged model."
|
| 62 |
+
)
|
| 63 |
+
original = [(m.adapters[adapter], m.adapters[adapter].scaling) for _, m in layers]
|
| 64 |
+
try:
|
| 65 |
+
for ad, s in original:
|
| 66 |
+
ad.scaling = s * k
|
| 67 |
+
yield len(layers)
|
| 68 |
+
finally:
|
| 69 |
+
for ad, s in original:
|
| 70 |
+
ad.scaling = s
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def sweep(
|
| 74 |
+
model: nn.Module,
|
| 75 |
+
adapter: str,
|
| 76 |
+
eval_fn: Callable[[nn.Module], Any],
|
| 77 |
+
scales: Iterable[float] = DEFAULT_SCALES,
|
| 78 |
+
) -> dict[float, Any]:
|
| 79 |
+
"""`{k: eval_fn(model)}` with the adapter delta scaled to k each time. `eval_fn` sees the live model."""
|
| 80 |
+
out: dict[float, Any] = {}
|
| 81 |
+
for k in scales:
|
| 82 |
+
k = _check_scale(k)
|
| 83 |
+
if k in out:
|
| 84 |
+
raise ValueError(f"duplicate scale {k} in the sweep")
|
| 85 |
+
with scaled_adapter(model, adapter, k):
|
| 86 |
+
out[k] = eval_fn(model)
|
| 87 |
+
return out
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def merged_state_dict_at_scale(
|
| 91 |
+
model: nn.Module,
|
| 92 |
+
full_state: dict[str, torch.Tensor],
|
| 93 |
+
adapter: str,
|
| 94 |
+
k: float,
|
| 95 |
+
report: Optional[dict] = None,
|
| 96 |
+
min_cosine: Optional[float] = None,
|
| 97 |
+
) -> dict[str, torch.Tensor]:
|
| 98 |
+
"""`export_merged_state_dict` with the adapter delta scaled by `k` (the model is not mutated).
|
| 99 |
+
|
| 100 |
+
k = 1.0 is bit-identical to `export_merged_state_dict`. k = 0 returns the base weights (every nonzero
|
| 101 |
+
delta is zero, so `report` has cosine 1.0 and delta_norm 0). `report["scale"]` records k.
|
| 102 |
+
"""
|
| 103 |
+
k = _check_scale(k)
|
| 104 |
+
rep: dict = {}
|
| 105 |
+
with scaled_adapter(model, adapter, k):
|
| 106 |
+
out = export_merged_state_dict(model, full_state, adapter, report=rep, min_cosine=min_cosine)
|
| 107 |
+
rep["scale"] = k
|
| 108 |
+
if report is not None:
|
| 109 |
+
report.update(rep)
|
| 110 |
+
return out
|
code/align/train/moe_module.py
ADDED
|
@@ -0,0 +1,691 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""MoE auxiliary losses on the training seam: `MoETrainModule`.
|
| 2 |
+
|
| 3 |
+
The model (`align.model.olmoe`) adds NO aux loss: it RETURNS per-layer router
|
| 4 |
+
statistics (`RouterStats`) when asked. This module is where those become a
|
| 5 |
+
training objective, because only the TrainModule sees all three things the
|
| 6 |
+
objective depends on: the loss adapter's CE (an opaque scalar), the
|
| 7 |
+
gradient-accumulation window (`grad_accum`), and the data-parallel group.
|
| 8 |
+
|
| 9 |
+
THE MATH, and the one approximation in it
|
| 10 |
+
-----------------------------------------
|
| 11 |
+
Switch/OLMoE load balance, per layer: `E * sum_i f_i * P_i` with `f_i` the hard
|
| 12 |
+
top-k hit fraction (no gradient) and `P_i` the mean router probability
|
| 13 |
+
(gradient). Which batch `f_i` and `P_i` are measured over is the whole question
|
| 14 |
+
(Qiu et al., "Demons in the Detail"): per-micro-batch statistics over-constrain
|
| 15 |
+
the router when micro-batches are domain-homogeneous, because a sequence of
|
| 16 |
+
code must be allowed to use the code experts heavily. The loss should balance
|
| 17 |
+
over the GLOBAL batch -- every rank, every micro-batch of the optimizer step.
|
| 18 |
+
|
| 19 |
+
Exact global `f_i` is not available at micro-batch `m`: the window's later
|
| 20 |
+
micro-batches have not been run, and their backward cannot be deferred without
|
| 21 |
+
retaining every micro-batch's activations. Demons' Algorithm 1 resolves it with
|
| 22 |
+
a BUFFER: keep the window's running (all-reduced) counts, and take `f_i` from
|
| 23 |
+
the buffer as it stands when micro-batch `m` runs. The approximation is
|
| 24 |
+
therefore confined to `f_i` of micro-batches 1..accum-1; at the LAST micro-batch
|
| 25 |
+
`f_i` is exactly the global-batch fraction, and the logged `moe/lb_loss` (computed
|
| 26 |
+
from the full buffer at the optimizer-step boundary) is exact.
|
| 27 |
+
|
| 28 |
+
SCALING (why `world * m`). The Trainer divides each micro loss by `grad_accum`
|
| 29 |
+
and the gradient reduction averages over `world` ranks, so micro-batch `m` on
|
| 30 |
+
rank `r` reaches the weights with weight `1/(world*accum)`. For the window's
|
| 31 |
+
gradient to equal the gradient of ONE global loss
|
| 32 |
+
`E * sum_i f_i * (sum_{r,m} prob_sum_{r,m,i}) / N`, each term must be multiplied
|
| 33 |
+
by `world * accum / N_window`, and `N_window` (all valid tokens of the window) is
|
| 34 |
+
unknown until the end; estimated as `n_buf * accum / m` it gives
|
| 35 |
+
`world * m / n_buf`. At `m = accum` the estimate is exact. The same factor
|
| 36 |
+
scales the z-loss. In "micro" scope (the per-rank formulation, kept for
|
| 37 |
+
ablations) the factor is 1 and everything is local.
|
| 38 |
+
|
| 39 |
+
WHAT IS ALL-REDUCED, and when. One packed float64 tensor per micro-batch
|
| 40 |
+
[counts L*E | tokens L | prob_sum L*E | z_sum L | entropy_sum L]. DETACHED data
|
| 41 |
+
only: the gradient of the loss is each rank differentiating its own
|
| 42 |
+
`prob_sum`/`z_sum`, never a gradient crossing ranks here. Every rank calls the
|
| 43 |
+
collective the same number of times (the Trainer runs micro-steps in lockstep),
|
| 44 |
+
and the buffer is reset at `optim_step()`, which the Trainer calls on every
|
| 45 |
+
boundary whether or not the optimizer applied the step.
|
| 46 |
+
|
| 47 |
+
DEFAULTS. Load balance and z-loss are OFF for post-training stages and
|
| 48 |
+
0.01 / 0.001 for `pretrain` / `proxy` (see `MoEAuxConfig`); telemetry
|
| 49 |
+
(MaxVio, utilization, dead experts, router entropy) is logged either way.
|
| 50 |
+
|
| 51 |
+
LIMITS (stated, not hidden)
|
| 52 |
+
* Adapters that run their own backward and return a detached proxy (the DPO
|
| 53 |
+
decoupled-backward adapter) cannot carry an aux loss: the router graph is
|
| 54 |
+
consumed by the adapter's backward. With a nonzero weight this raises; with
|
| 55 |
+
zero weights telemetry still works (it reads detached data).
|
| 56 |
+
* Not validated under FSDP2 on GPU (no GPU in this lane): the stats ride out
|
| 57 |
+
of the wrapped forward as plain dataclass fields rather than pytree leaves.
|
| 58 |
+
* The returned `LossOutput.loss` is the CE alone (comparable with dense runs).
|
| 59 |
+
If an aux term is non-finite the returned loss is NaN, so the Trainer's
|
| 60 |
+
non-finite-loss accounting sees it.
|
| 61 |
+
|
| 62 |
+
EXPERT PARALLELISM (opt-in: `ep_groups`, default None = nothing below applies)
|
| 63 |
+
-----------------------------------------------------------------------------
|
| 64 |
+
`align.model.moe_ep` shards the experts across an EP group. The caller applies it
|
| 65 |
+
(`apply_expert_parallel(model, groups)`) BEFORE the optimizer is built -- the
|
| 66 |
+
optimizer must hold the shard parameters, and sharding after construction would
|
| 67 |
+
leave it stepping tensors that are no longer in the model. This module then does
|
| 68 |
+
the three things only the TrainModule can:
|
| 69 |
+
* the aux losses need NO change: the router is replicated, `RouterStats` come from
|
| 70 |
+
local tokens exactly as without EP, and `MoEAux` all-reduces them over the
|
| 71 |
+
DATA-parallel group (`group`), which contains the EP group.
|
| 72 |
+
* `optim_step` puts expert-shard gradients on the replicated-gradient scale
|
| 73 |
+
(`finalize_expert_grads`: they arrive SUMMED over the EP group) and clips by the
|
| 74 |
+
EP-GLOBAL norm (`clip_grads_`). The wrapped optimizer must therefore NOT clip
|
| 75 |
+
itself (`max_grad_norm` must be None): its norm would be shard-local, ranks would
|
| 76 |
+
clip by different coefficients and the replicated parameters would drift apart.
|
| 77 |
+
Pass the clip value as `ep_max_grad_norm`; `last_grad_norm` is the PRE-clip global.
|
| 78 |
+
* `full_model_state_dict` / `load_model_state_dict` gather / slice the expert shards
|
| 79 |
+
(a collective over the EP group), so a checkpoint is the same file a non-EP run writes.
|
| 80 |
+
"""
|
| 81 |
+
|
| 82 |
+
from __future__ import annotations
|
| 83 |
+
|
| 84 |
+
import logging
|
| 85 |
+
import math
|
| 86 |
+
from dataclasses import dataclass
|
| 87 |
+
from typing import Any, Optional
|
| 88 |
+
|
| 89 |
+
import torch
|
| 90 |
+
|
| 91 |
+
from ..config import MoEAuxConfig
|
| 92 |
+
from ..model import moe
|
| 93 |
+
from .train_module import LossAdapter, LossOutput, TrainModule
|
| 94 |
+
|
| 95 |
+
logger = logging.getLogger(__name__)
|
| 96 |
+
|
| 97 |
+
NAN = float("nan")
|
| 98 |
+
|
| 99 |
+
#: (lb_weight, z_weight) per stage when the config leaves a weight as None.
|
| 100 |
+
#: Stages that train a router FROM SCRATCH balance it; everything else leaves
|
| 101 |
+
#: the pretrained router alone. See MoEAuxConfig for the argument.
|
| 102 |
+
STAGE_AUX_DEFAULTS: dict[str, tuple[float, float]] = {
|
| 103 |
+
"pretrain": (0.01, 0.001),
|
| 104 |
+
"proxy": (0.01, 0.001),
|
| 105 |
+
}
|
| 106 |
+
POST_TRAINING_AUX_DEFAULT = (0.0, 0.0)
|
| 107 |
+
LB_SCOPES = ("global", "micro")
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def resolve_aux_weights(aux: MoEAuxConfig, stage: str) -> tuple[float, float]:
|
| 111 |
+
"""(lb_weight, z_weight) after applying the stage default to any `None`."""
|
| 112 |
+
d_lb, d_z = STAGE_AUX_DEFAULTS.get(stage, POST_TRAINING_AUX_DEFAULT)
|
| 113 |
+
lb = d_lb if aux.lb_weight is None else float(aux.lb_weight)
|
| 114 |
+
z = d_z if aux.z_weight is None else float(aux.z_weight)
|
| 115 |
+
for name, v in (("lb_weight", lb), ("z_weight", z)):
|
| 116 |
+
if not (math.isfinite(v) and v >= 0.0):
|
| 117 |
+
raise ValueError(f"moe_aux.{name}={v!r} must be a finite number >= 0")
|
| 118 |
+
if aux.lb_scope not in LB_SCOPES:
|
| 119 |
+
raise ValueError(f"moe_aux.lb_scope={aux.lb_scope!r}; known: {LB_SCOPES}")
|
| 120 |
+
return lb, z
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
# ---------------------------------------------------------------------------
|
| 124 |
+
# the window buffer (Demons Alg. 1)
|
| 125 |
+
# ---------------------------------------------------------------------------
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
@dataclass
|
| 129 |
+
class WindowTotals:
|
| 130 |
+
"""Detached sums over (ranks x micro-batches so far): everything the aux
|
| 131 |
+
losses and the telemetry need, nothing with a gradient."""
|
| 132 |
+
|
| 133 |
+
counts: torch.Tensor # [L, E] float64: top-k hits
|
| 134 |
+
n_tokens: torch.Tensor # [L] float64: valid tokens
|
| 135 |
+
prob_sum: torch.Tensor # [L, E] float64: sum of softmax probs
|
| 136 |
+
z_sum: torch.Tensor # [L] float64: sum of lse^2
|
| 137 |
+
entropy_sum: torch.Tensor # [L] float64
|
| 138 |
+
|
| 139 |
+
def __add__(self, o: "WindowTotals") -> "WindowTotals":
|
| 140 |
+
return WindowTotals(
|
| 141 |
+
self.counts + o.counts,
|
| 142 |
+
self.n_tokens + o.n_tokens,
|
| 143 |
+
self.prob_sum + o.prob_sum,
|
| 144 |
+
self.z_sum + o.z_sum,
|
| 145 |
+
self.entropy_sum + o.entropy_sum,
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
@classmethod
|
| 149 |
+
def of(cls, stats: list[moe.RouterStats]) -> "WindowTotals":
|
| 150 |
+
d = torch.float64
|
| 151 |
+
return cls(
|
| 152 |
+
counts=torch.stack([s.counts for s in stats]).to(d),
|
| 153 |
+
n_tokens=torch.stack([s.n_tokens for s in stats]).to(d),
|
| 154 |
+
prob_sum=torch.stack([s.prob_sum.detach() for s in stats]).to(d),
|
| 155 |
+
z_sum=torch.stack([s.z_sum.detach() for s in stats]).to(d),
|
| 156 |
+
entropy_sum=torch.stack([s.entropy_sum.detach() for s in stats]).to(d),
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
def all_reduce(self, group=None) -> "WindowTotals":
|
| 160 |
+
"""Sum over a process group in ONE collective (packed)."""
|
| 161 |
+
import torch.distributed as dist
|
| 162 |
+
|
| 163 |
+
n_layers, e = self.counts.shape
|
| 164 |
+
packed = torch.cat(
|
| 165 |
+
[
|
| 166 |
+
self.counts.reshape(-1),
|
| 167 |
+
self.n_tokens,
|
| 168 |
+
self.prob_sum.reshape(-1),
|
| 169 |
+
self.z_sum,
|
| 170 |
+
self.entropy_sum,
|
| 171 |
+
]
|
| 172 |
+
).contiguous()
|
| 173 |
+
dist.all_reduce(packed, group=group)
|
| 174 |
+
le = n_layers * e
|
| 175 |
+
o = 0
|
| 176 |
+
counts = packed[o : o + le].reshape(n_layers, e)
|
| 177 |
+
o += le
|
| 178 |
+
n = packed[o : o + n_layers]
|
| 179 |
+
o += n_layers
|
| 180 |
+
ps = packed[o : o + le].reshape(n_layers, e)
|
| 181 |
+
o += le
|
| 182 |
+
z = packed[o : o + n_layers]
|
| 183 |
+
o += n_layers
|
| 184 |
+
ent = packed[o : o + n_layers]
|
| 185 |
+
return WindowTotals(counts, n, ps, z, ent)
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def merge_call_stats(calls: list[list[moe.RouterStats]]) -> list[moe.RouterStats]:
|
| 189 |
+
"""Combine the stats of several grad-enabled forwards of ONE micro-batch.
|
| 190 |
+
|
| 191 |
+
One call (SFT, pretrain) is the identity. Several (a DPO adapter that runs
|
| 192 |
+
chosen and rejected separately) are summed per layer: they are different
|
| 193 |
+
token populations of the same optimizer step, so counts, probability sums,
|
| 194 |
+
z sums and token counts all ADD, and the losses are then over their union.
|
| 195 |
+
"""
|
| 196 |
+
if not calls:
|
| 197 |
+
raise ValueError("no router stats captured")
|
| 198 |
+
if len(calls) == 1:
|
| 199 |
+
return calls[0]
|
| 200 |
+
n_layers = len(calls[0])
|
| 201 |
+
if any(len(c) != n_layers for c in calls):
|
| 202 |
+
raise ValueError(f"forwards returned different layer counts: {[len(c) for c in calls]}")
|
| 203 |
+
out = []
|
| 204 |
+
for layer in range(n_layers):
|
| 205 |
+
parts = [c[layer] for c in calls]
|
| 206 |
+
out.append(
|
| 207 |
+
moe.RouterStats(
|
| 208 |
+
counts=sum(p.counts for p in parts),
|
| 209 |
+
prob_sum=sum(p.prob_sum for p in parts),
|
| 210 |
+
n_tokens=sum(p.n_tokens for p in parts),
|
| 211 |
+
z_sum=sum(p.z_sum for p in parts),
|
| 212 |
+
entropy_sum=sum(p.entropy_sum for p in parts),
|
| 213 |
+
)
|
| 214 |
+
)
|
| 215 |
+
return out
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
class MoEAux:
|
| 219 |
+
"""The aux-loss math and the window buffer, independent of any Trainer.
|
| 220 |
+
|
| 221 |
+
`micro(stats)` is called once per micro-batch, in lockstep on every rank,
|
| 222 |
+
and returns `(lb_term, z_term)` ALREADY scaled for the Trainer's
|
| 223 |
+
`/grad_accum` and the gradient reduction's mean over ranks (see the module
|
| 224 |
+
docstring). `window_end()` returns the exact global-window telemetry and
|
| 225 |
+
resets the buffer.
|
| 226 |
+
"""
|
| 227 |
+
|
| 228 |
+
def __init__(
|
| 229 |
+
self,
|
| 230 |
+
top_k: int,
|
| 231 |
+
grad_accum: int,
|
| 232 |
+
*,
|
| 233 |
+
scope: str = "global",
|
| 234 |
+
group=None,
|
| 235 |
+
world_size: Optional[int] = None,
|
| 236 |
+
):
|
| 237 |
+
if scope not in LB_SCOPES:
|
| 238 |
+
raise ValueError(f"scope {scope!r}; known: {LB_SCOPES}")
|
| 239 |
+
if grad_accum < 1:
|
| 240 |
+
raise ValueError(f"grad_accum must be >= 1, got {grad_accum}")
|
| 241 |
+
self.top_k = int(top_k)
|
| 242 |
+
self.grad_accum = int(grad_accum)
|
| 243 |
+
self.scope = scope
|
| 244 |
+
self.group = group
|
| 245 |
+
self._world = world_size
|
| 246 |
+
self.m = 0 # micro-batches seen in the current window
|
| 247 |
+
self.buf: Optional[WindowTotals] = None
|
| 248 |
+
|
| 249 |
+
# -- world ----------------------------------------------------------
|
| 250 |
+
def world(self) -> int:
|
| 251 |
+
if self._world is not None:
|
| 252 |
+
return self._world
|
| 253 |
+
import torch.distributed as dist
|
| 254 |
+
|
| 255 |
+
if dist.is_available() and dist.is_initialized():
|
| 256 |
+
return dist.get_world_size(self.group)
|
| 257 |
+
return 1
|
| 258 |
+
|
| 259 |
+
def _distributed(self) -> bool:
|
| 260 |
+
import torch.distributed as dist
|
| 261 |
+
|
| 262 |
+
return dist.is_available() and dist.is_initialized() and self.world() > 1
|
| 263 |
+
|
| 264 |
+
def reset(self) -> None:
|
| 265 |
+
self.m = 0
|
| 266 |
+
self.buf = None
|
| 267 |
+
|
| 268 |
+
# -- one micro-batch --------------------------------------------------
|
| 269 |
+
def micro(
|
| 270 |
+
self, stats: list[moe.RouterStats], want_lb: bool = True, want_z: bool = True
|
| 271 |
+
) -> tuple[Optional[torch.Tensor], Optional[torch.Tensor]]:
|
| 272 |
+
if self.m >= self.grad_accum:
|
| 273 |
+
raise RuntimeError(
|
| 274 |
+
f"{self.m + 1} micro-batches in one accumulation window (grad_accum="
|
| 275 |
+
f"{self.grad_accum}): optim_step()/reset() was not called at the boundary"
|
| 276 |
+
)
|
| 277 |
+
self.m += 1
|
| 278 |
+
local = WindowTotals.of(stats)
|
| 279 |
+
glob = local.all_reduce(self.group) if self._distributed() else local
|
| 280 |
+
self.buf = glob if self.buf is None else self.buf + glob
|
| 281 |
+
buf = self.buf
|
| 282 |
+
if not bool((buf.n_tokens > 0).all()):
|
| 283 |
+
raise ValueError(
|
| 284 |
+
"a MoE layer has seen zero valid tokens in this window: every position was masked. "
|
| 285 |
+
"The balance and z-losses are undefined over zero tokens (and returning 0.0 would "
|
| 286 |
+
"report a perfectly balanced router)."
|
| 287 |
+
)
|
| 288 |
+
lb = z = None
|
| 289 |
+
if self.scope == "global":
|
| 290 |
+
scale = float(self.world() * self.m)
|
| 291 |
+
if want_lb:
|
| 292 |
+
lb = scale * moe.load_balancing_loss(
|
| 293 |
+
stats,
|
| 294 |
+
self.top_k,
|
| 295 |
+
global_totals=moe.Totals(counts=buf.counts, n_tokens=buf.n_tokens),
|
| 296 |
+
)
|
| 297 |
+
if want_z:
|
| 298 |
+
# n_tokens is identical across layers (every layer routes the
|
| 299 |
+
# same valid tokens); the scalar is what router_z_loss takes.
|
| 300 |
+
z = scale * moe.router_z_loss(stats, loss_div_factor=buf.n_tokens[0])
|
| 301 |
+
else:
|
| 302 |
+
if want_lb:
|
| 303 |
+
lb = moe.load_balancing_loss(stats, self.top_k)
|
| 304 |
+
if want_z:
|
| 305 |
+
z = moe.router_z_loss(stats)
|
| 306 |
+
return lb, z
|
| 307 |
+
|
| 308 |
+
# -- the boundary -----------------------------------------------------
|
| 309 |
+
def window_values(self) -> dict[str, Any]:
|
| 310 |
+
"""Exact global-window quantities (float64 tensors) from the buffer."""
|
| 311 |
+
buf = self.buf
|
| 312 |
+
if buf is None:
|
| 313 |
+
raise RuntimeError("window_values() before any micro-batch")
|
| 314 |
+
n_layers, e = buf.counts.shape
|
| 315 |
+
n = buf.n_tokens.unsqueeze(1)
|
| 316 |
+
f = buf.counts / (self.top_k * n)
|
| 317 |
+
p = buf.prob_sum / n
|
| 318 |
+
lb = (e * (f * p).sum(dim=1)).mean()
|
| 319 |
+
z = (buf.z_sum / buf.n_tokens).mean()
|
| 320 |
+
ent = buf.entropy_sum / buf.n_tokens
|
| 321 |
+
per_layer = [moe.expert_load_metrics(buf.counts[i]) for i in range(n_layers)]
|
| 322 |
+
return {
|
| 323 |
+
"lb": lb,
|
| 324 |
+
"z": z,
|
| 325 |
+
"entropy": ent,
|
| 326 |
+
"f": f,
|
| 327 |
+
"max_vio": torch.stack([d["max_vio"] for d in per_layer]),
|
| 328 |
+
"utilization": torch.stack([d["utilization"] for d in per_layer]),
|
| 329 |
+
"frac_dead": torch.stack([d["frac_dead"] for d in per_layer]),
|
| 330 |
+
"load_entropy": torch.stack([d["load_entropy"] for d in per_layer]),
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
def window_metrics(values: dict[str, Any], per_layer: bool = False) -> dict[str, float]:
|
| 335 |
+
"""The `moe/*` history keys from `MoEAux.window_values()`. ONE host sync."""
|
| 336 |
+
n_layers = int(values["max_vio"].numel())
|
| 337 |
+
flat = torch.cat(
|
| 338 |
+
[
|
| 339 |
+
values["lb"].reshape(1),
|
| 340 |
+
values["z"].reshape(1),
|
| 341 |
+
values["entropy"],
|
| 342 |
+
values["max_vio"],
|
| 343 |
+
values["utilization"],
|
| 344 |
+
values["frac_dead"],
|
| 345 |
+
values["load_entropy"],
|
| 346 |
+
]
|
| 347 |
+
).tolist()
|
| 348 |
+
lb, z = flat[0], flat[1]
|
| 349 |
+
o = 2
|
| 350 |
+
ent = flat[o : o + n_layers]
|
| 351 |
+
o += n_layers
|
| 352 |
+
vio = flat[o : o + n_layers]
|
| 353 |
+
o += n_layers
|
| 354 |
+
util = flat[o : o + n_layers]
|
| 355 |
+
o += n_layers
|
| 356 |
+
dead = flat[o : o + n_layers]
|
| 357 |
+
o += n_layers
|
| 358 |
+
lent = flat[o : o + n_layers]
|
| 359 |
+
out = {
|
| 360 |
+
"moe/lb_loss": lb,
|
| 361 |
+
"moe/z_loss": z,
|
| 362 |
+
"moe/max_vio": sum(vio) / n_layers,
|
| 363 |
+
"moe/max_vio_worst": max(vio),
|
| 364 |
+
"moe/utilization": sum(util) / n_layers,
|
| 365 |
+
"moe/utilization_worst": min(util),
|
| 366 |
+
"moe/frac_dead_worst": max(dead),
|
| 367 |
+
"moe/load_entropy": sum(lent) / n_layers,
|
| 368 |
+
"moe/router_entropy": sum(ent) / n_layers,
|
| 369 |
+
}
|
| 370 |
+
if per_layer:
|
| 371 |
+
for i in range(n_layers):
|
| 372 |
+
out[f"moe/l{i}/max_vio"] = vio[i]
|
| 373 |
+
out[f"moe/l{i}/utilization"] = util[i]
|
| 374 |
+
return out
|
| 375 |
+
|
| 376 |
+
|
| 377 |
+
#: The keys a MoE run logs, NaN until the first window completes. Fixed set so
|
| 378 |
+
#: every history.jsonl row has the same columns.
|
| 379 |
+
def undefined_window_metrics(n_layers: Optional[int] = None, per_layer: bool = False) -> dict[str, float]:
|
| 380 |
+
keys = [
|
| 381 |
+
"moe/lb_loss",
|
| 382 |
+
"moe/z_loss",
|
| 383 |
+
"moe/max_vio",
|
| 384 |
+
"moe/max_vio_worst",
|
| 385 |
+
"moe/utilization",
|
| 386 |
+
"moe/utilization_worst",
|
| 387 |
+
"moe/frac_dead_worst",
|
| 388 |
+
"moe/load_entropy",
|
| 389 |
+
"moe/router_entropy",
|
| 390 |
+
]
|
| 391 |
+
out = {k: NAN for k in keys}
|
| 392 |
+
if per_layer and n_layers:
|
| 393 |
+
for i in range(n_layers):
|
| 394 |
+
out[f"moe/l{i}/max_vio"] = NAN
|
| 395 |
+
out[f"moe/l{i}/utilization"] = NAN
|
| 396 |
+
return out
|
| 397 |
+
|
| 398 |
+
|
| 399 |
+
# ---------------------------------------------------------------------------
|
| 400 |
+
# the model proxy
|
| 401 |
+
# ---------------------------------------------------------------------------
|
| 402 |
+
|
| 403 |
+
|
| 404 |
+
class _StatsModel:
|
| 405 |
+
"""What the loss adapter is handed instead of the model.
|
| 406 |
+
|
| 407 |
+
Adapters call `model(input_ids=..., hidden_consumer=...)` and expect the
|
| 408 |
+
loss (or logits) back. The proxy adds `return_router_stats=True` (only when
|
| 409 |
+
gradients are enabled -- a reference-model forward under `no_grad` has
|
| 410 |
+
nothing to balance), unwraps the `(out, stats)` tuple, and stashes the
|
| 411 |
+
stats for the module. Every other attribute is delegated, so adapters that
|
| 412 |
+
read `model.lm_head` or `model.attn_implementation` are unaffected.
|
| 413 |
+
"""
|
| 414 |
+
|
| 415 |
+
def __init__(self, owner: "MoETrainModule"):
|
| 416 |
+
object.__setattr__(self, "_owner", owner)
|
| 417 |
+
object.__setattr__(self, "calls", [])
|
| 418 |
+
object.__setattr__(self, "attention_mask", None)
|
| 419 |
+
|
| 420 |
+
def __call__(self, *args, **kwargs):
|
| 421 |
+
owner = self._owner
|
| 422 |
+
model = owner.model
|
| 423 |
+
want = torch.is_grad_enabled()
|
| 424 |
+
if want:
|
| 425 |
+
kwargs["return_router_stats"] = True
|
| 426 |
+
if kwargs.get("attention_mask") is None and self.attention_mask is not None:
|
| 427 |
+
# The batch's real-token mask: padding must not vote in the
|
| 428 |
+
# balance loss. The model's attention applies it too (a no-op
|
| 429 |
+
# for valid tokens under right padding, identical outputs).
|
| 430 |
+
kwargs["attention_mask"] = self.attention_mask
|
| 431 |
+
try:
|
| 432 |
+
out = model(*args, **kwargs)
|
| 433 |
+
except TypeError as e:
|
| 434 |
+
if want and "return_router_stats" in str(e):
|
| 435 |
+
raise RuntimeError(
|
| 436 |
+
"MoETrainModule needs a model whose forward accepts return_router_stats=True "
|
| 437 |
+
f"(got {type(model).__name__}); a dense model has no router to balance. "
|
| 438 |
+
"Set moe_aux weights to 0 and use the plain TrainModule."
|
| 439 |
+
) from e
|
| 440 |
+
raise
|
| 441 |
+
if not want:
|
| 442 |
+
return out
|
| 443 |
+
out, stats = out
|
| 444 |
+
self.calls.append(stats)
|
| 445 |
+
return out
|
| 446 |
+
|
| 447 |
+
def forward_hidden_states(self, **kwargs):
|
| 448 |
+
"""Route `forward_hidden_states` through THIS proxy's `__call__`.
|
| 449 |
+
|
| 450 |
+
The DPO adapter (coupled AND decoupled) never calls `model(...)`: it
|
| 451 |
+
calls `model.forward_hidden_states(...)`. Without this method the
|
| 452 |
+
attribute falls through `__getattr__` to the REAL model's bound method,
|
| 453 |
+
which runs without `return_router_stats`, so the adapter "ran no
|
| 454 |
+
grad-enabled forward" as far as the module can tell and the first
|
| 455 |
+
micro-step raises -- with default settings (telemetry on) on a stage
|
| 456 |
+
whose docstring promises telemetry still works. Mirrors
|
| 457 |
+
`OlmoeForCausalLM.forward_hidden_states`, which is `self(...,
|
| 458 |
+
return_hidden_states=True)`.
|
| 459 |
+
"""
|
| 460 |
+
return self(**kwargs, return_hidden_states=True)
|
| 461 |
+
|
| 462 |
+
def __getattr__(self, name):
|
| 463 |
+
return getattr(self._owner.model, name)
|
| 464 |
+
|
| 465 |
+
|
| 466 |
+
# ---------------------------------------------------------------------------
|
| 467 |
+
# the train module
|
| 468 |
+
# ---------------------------------------------------------------------------
|
| 469 |
+
|
| 470 |
+
|
| 471 |
+
class MoETrainModule(TrainModule):
|
| 472 |
+
"""`TrainModule` + router aux losses + MoE telemetry.
|
| 473 |
+
|
| 474 |
+
`train_batch` does what the base does -- forward through the loss adapter,
|
| 475 |
+
then `(loss / grad_accum).backward()` -- with `loss = CE + lb_weight * lb +
|
| 476 |
+
z_weight * z`. See the module docstring for the window buffer and scaling.
|
| 477 |
+
"""
|
| 478 |
+
|
| 479 |
+
def __init__(
|
| 480 |
+
self,
|
| 481 |
+
model,
|
| 482 |
+
loss_adapter: LossAdapter,
|
| 483 |
+
optimizer,
|
| 484 |
+
grad_accum: int,
|
| 485 |
+
*,
|
| 486 |
+
aux: Optional[MoEAuxConfig] = None,
|
| 487 |
+
stage: str = "sft",
|
| 488 |
+
top_k: Optional[int] = None,
|
| 489 |
+
weight_format=None,
|
| 490 |
+
group=None,
|
| 491 |
+
world_size: Optional[int] = None,
|
| 492 |
+
ep_groups=None,
|
| 493 |
+
ep_max_grad_norm: Optional[float] = None,
|
| 494 |
+
):
|
| 495 |
+
super().__init__(model, loss_adapter, optimizer, grad_accum)
|
| 496 |
+
self.ep_groups = ep_groups
|
| 497 |
+
self.ep_max_grad_norm = ep_max_grad_norm
|
| 498 |
+
self._ep_norm = float("nan")
|
| 499 |
+
if ep_groups is not None:
|
| 500 |
+
self._check_expert_parallel(model, optimizer, ep_groups)
|
| 501 |
+
self.aux_cfg = aux or MoEAuxConfig()
|
| 502 |
+
self.stage = stage
|
| 503 |
+
self.lb_weight, self.z_weight = resolve_aux_weights(self.aux_cfg, stage)
|
| 504 |
+
if top_k is None:
|
| 505 |
+
top_k = getattr(getattr(model, "cfg", None), "top_k", None)
|
| 506 |
+
if top_k is None:
|
| 507 |
+
raise ValueError("top_k not given and model.cfg.top_k not found")
|
| 508 |
+
self.top_k = int(top_k)
|
| 509 |
+
self.weight_format = weight_format
|
| 510 |
+
self.collect = bool(self.aux_cfg.telemetry or self.lb_weight > 0 or self.z_weight > 0)
|
| 511 |
+
self._aux = MoEAux(
|
| 512 |
+
self.top_k, grad_accum, scope=self.aux_cfg.lb_scope, group=group, world_size=world_size
|
| 513 |
+
)
|
| 514 |
+
self._proxy = _StatsModel(self)
|
| 515 |
+
self._last_metrics: dict[str, float] = {}
|
| 516 |
+
logger.info(
|
| 517 |
+
"MoE aux: stage=%s lb_weight=%g z_weight=%g scope=%s telemetry=%s top_k=%d",
|
| 518 |
+
stage,
|
| 519 |
+
self.lb_weight,
|
| 520 |
+
self.z_weight,
|
| 521 |
+
self.aux_cfg.lb_scope,
|
| 522 |
+
self.aux_cfg.telemetry,
|
| 523 |
+
self.top_k,
|
| 524 |
+
)
|
| 525 |
+
|
| 526 |
+
# -- expert parallelism (opt-in) ---------------------------------------
|
| 527 |
+
@staticmethod
|
| 528 |
+
def _check_expert_parallel(model, optimizer, groups) -> None:
|
| 529 |
+
"""Refuse the three silent EP misconfigurations instead of training through them."""
|
| 530 |
+
from ..model import moe_ep
|
| 531 |
+
|
| 532 |
+
blocks = moe_ep._moe_blocks(model)
|
| 533 |
+
if not blocks or any(b.ep is None for b in blocks):
|
| 534 |
+
raise ValueError(
|
| 535 |
+
"ep_groups was given but the model's MoE blocks are not expert-parallel: call "
|
| 536 |
+
"align.model.moe_ep.apply_expert_parallel(model, groups) BEFORE building the optimizer"
|
| 537 |
+
)
|
| 538 |
+
if any(b.ep.groups is not groups for b in blocks):
|
| 539 |
+
raise ValueError("ep_groups is not the group set the model was sharded with")
|
| 540 |
+
if getattr(optimizer, "max_grad_norm", None) is not None:
|
| 541 |
+
raise ValueError(
|
| 542 |
+
"the optimizer clips by its own (shard-local) gradient norm; under expert parallelism "
|
| 543 |
+
"that clips every rank by a different coefficient. Build it with max_grad_norm=None and "
|
| 544 |
+
"pass ep_max_grad_norm to MoETrainModule"
|
| 545 |
+
)
|
| 546 |
+
|
| 547 |
+
def optim_step(self) -> bool:
|
| 548 |
+
if self.ep_groups is not None:
|
| 549 |
+
from ..model import moe_ep
|
| 550 |
+
|
| 551 |
+
moe_ep.finalize_expert_grads(self.model, self.ep_groups)
|
| 552 |
+
if self.ep_max_grad_norm is not None:
|
| 553 |
+
norm = moe_ep.clip_grads_(self.model, self.ep_groups, self.ep_max_grad_norm)
|
| 554 |
+
else:
|
| 555 |
+
norm = moe_ep.global_grad_norm(self.model, self.ep_groups)
|
| 556 |
+
self._ep_norm = float(norm)
|
| 557 |
+
try:
|
| 558 |
+
return super().optim_step()
|
| 559 |
+
finally:
|
| 560 |
+
self._aux.reset()
|
| 561 |
+
|
| 562 |
+
@property
|
| 563 |
+
def last_grad_norm(self) -> float:
|
| 564 |
+
return self._ep_norm if self.ep_groups is not None else super().last_grad_norm
|
| 565 |
+
|
| 566 |
+
def full_model_state_dict(self):
|
| 567 |
+
sd = super().full_model_state_dict()
|
| 568 |
+
if self.ep_groups is not None:
|
| 569 |
+
from ..model import moe_ep
|
| 570 |
+
|
| 571 |
+
# the gather is a collective (every rank calls it); like the base state dict (rank 0 only
|
| 572 |
+
# when nothing is a DTensor) the result is merged where the base returned something
|
| 573 |
+
gathered = moe_ep.gather_full_expert_state(self.model, self.ep_groups, offload_to_cpu=True)
|
| 574 |
+
if sd:
|
| 575 |
+
for k, v in gathered.items():
|
| 576 |
+
sd[k] = v.cpu()
|
| 577 |
+
return sd
|
| 578 |
+
|
| 579 |
+
def load_model_state_dict(self, nmo_state: dict) -> None:
|
| 580 |
+
if self.ep_groups is not None:
|
| 581 |
+
from ..model import moe_ep
|
| 582 |
+
|
| 583 |
+
nmo_state = {**nmo_state, **moe_ep.local_expert_slices(self.model, self.ep_groups, nmo_state)}
|
| 584 |
+
super().load_model_state_dict(nmo_state)
|
| 585 |
+
|
| 586 |
+
# -- the loop-facing call ---------------------------------------------
|
| 587 |
+
def train_batch(self, batch) -> LossOutput:
|
| 588 |
+
if not self.collect:
|
| 589 |
+
return super().train_batch(batch)
|
| 590 |
+
|
| 591 |
+
proxy = self._proxy
|
| 592 |
+
proxy.calls.clear()
|
| 593 |
+
mask = getattr(batch, "attention_mask", None)
|
| 594 |
+
proxy.attention_mask = mask if isinstance(mask, torch.Tensor) else None
|
| 595 |
+
out = self.loss_adapter(proxy, batch)
|
| 596 |
+
calls = list(proxy.calls)
|
| 597 |
+
proxy.calls.clear()
|
| 598 |
+
if not calls:
|
| 599 |
+
raise RuntimeError(
|
| 600 |
+
"the loss adapter ran no grad-enabled forward through the model, so no router "
|
| 601 |
+
"statistics exist; MoE aux losses/telemetry cannot be computed"
|
| 602 |
+
)
|
| 603 |
+
stats = merge_call_stats(calls)
|
| 604 |
+
want_lb, want_z = self.lb_weight > 0, self.z_weight > 0
|
| 605 |
+
lb, z = self._aux.micro(stats, want_lb=want_lb, want_z=want_z)
|
| 606 |
+
|
| 607 |
+
ce = out.loss
|
| 608 |
+
total = ce
|
| 609 |
+
decoupled = ce.grad_fn is None
|
| 610 |
+
if want_lb or want_z:
|
| 611 |
+
if decoupled:
|
| 612 |
+
raise NotImplementedError(
|
| 613 |
+
"the loss adapter returned a detached proxy (decoupled backward, e.g. DPO): "
|
| 614 |
+
"its backward already consumed the model graph, so a router aux loss cannot "
|
| 615 |
+
"be added. Set moe_aux.lb_weight = z_weight = 0 for this stage "
|
| 616 |
+
"(telemetry still works)."
|
| 617 |
+
)
|
| 618 |
+
if want_lb:
|
| 619 |
+
total = total + self.lb_weight * lb
|
| 620 |
+
if want_z:
|
| 621 |
+
total = total + self.z_weight * z
|
| 622 |
+
(total / self.grad_accum).backward()
|
| 623 |
+
|
| 624 |
+
metrics = dict(out.metrics)
|
| 625 |
+
if self._aux.m == self.grad_accum:
|
| 626 |
+
vals = self._aux.window_values()
|
| 627 |
+
self._last_metrics = window_metrics(vals, per_layer=self.aux_cfg.per_layer_metrics)
|
| 628 |
+
elif not self._last_metrics:
|
| 629 |
+
self._last_metrics = undefined_window_metrics(
|
| 630 |
+
len(stats), per_layer=self.aux_cfg.per_layer_metrics
|
| 631 |
+
)
|
| 632 |
+
metrics.update(self._last_metrics)
|
| 633 |
+
|
| 634 |
+
reported = ce.detach()
|
| 635 |
+
if want_lb or want_z:
|
| 636 |
+
# 0 * finite = 0 (loss stays exactly CE); 0 * NaN/inf = NaN, so a
|
| 637 |
+
# poisoned aux term cannot hide behind a healthy CE.
|
| 638 |
+
reported = reported + 0.0 * (total.detach() - ce.detach())
|
| 639 |
+
return LossOutput(loss=reported, n_tokens=out.n_tokens, metrics=metrics)
|
| 640 |
+
|
| 641 |
+
def zero_grads(self) -> None:
|
| 642 |
+
super().zero_grads()
|
| 643 |
+
self._aux.reset()
|
| 644 |
+
|
| 645 |
+
|
| 646 |
+
def build_moe_train_module(
|
| 647 |
+
cfg,
|
| 648 |
+
model,
|
| 649 |
+
loss_adapter: LossAdapter,
|
| 650 |
+
optimizer,
|
| 651 |
+
*,
|
| 652 |
+
stage: str,
|
| 653 |
+
olmoe_cfg,
|
| 654 |
+
group=None,
|
| 655 |
+
ep_groups=None,
|
| 656 |
+
ep_max_grad_norm: Optional[float] = None,
|
| 657 |
+
) -> MoETrainModule:
|
| 658 |
+
"""The MoE module for an OLMoE-architecture model, wired from a RunConfig.
|
| 659 |
+
|
| 660 |
+
Carries the OLMoE weight format (the Trainer picks it up from the module),
|
| 661 |
+
`cfg.moe_aux` and `cfg.optimizer.grad_accum`.
|
| 662 |
+
"""
|
| 663 |
+
from .ckpt_format import OlmoeFormat
|
| 664 |
+
|
| 665 |
+
return MoETrainModule(
|
| 666 |
+
model,
|
| 667 |
+
loss_adapter,
|
| 668 |
+
optimizer,
|
| 669 |
+
cfg.optimizer.grad_accum,
|
| 670 |
+
aux=cfg.moe_aux,
|
| 671 |
+
stage=stage,
|
| 672 |
+
top_k=olmoe_cfg.top_k,
|
| 673 |
+
weight_format=OlmoeFormat(olmoe_cfg),
|
| 674 |
+
group=group,
|
| 675 |
+
ep_groups=ep_groups,
|
| 676 |
+
ep_max_grad_norm=ep_max_grad_norm,
|
| 677 |
+
)
|
| 678 |
+
|
| 679 |
+
|
| 680 |
+
__all__ = [
|
| 681 |
+
"STAGE_AUX_DEFAULTS",
|
| 682 |
+
"POST_TRAINING_AUX_DEFAULT",
|
| 683 |
+
"resolve_aux_weights",
|
| 684 |
+
"WindowTotals",
|
| 685 |
+
"merge_call_stats",
|
| 686 |
+
"MoEAux",
|
| 687 |
+
"window_metrics",
|
| 688 |
+
"undefined_window_metrics",
|
| 689 |
+
"MoETrainModule",
|
| 690 |
+
"build_moe_train_module",
|
| 691 |
+
]
|
code/align/train/offload_adamw.py
ADDED
|
@@ -0,0 +1,680 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Optimizer-state-only CPU offload for AdamW.
|
| 2 |
+
|
| 3 |
+
The arithmetic that forces this design (measured on this box, not re-derived):
|
| 4 |
+
full fine-tuning 7.25B params with AdamW needs ~116GB of optimizer state --
|
| 5 |
+
~29GB per GPU even fully sharded across 4 ranks, against a 23GB A10G budget.
|
| 6 |
+
Moving the state to host RAM fits it. Moving anything ELSE to host RAM does
|
| 7 |
+
not pay: FSDP's own `CPUOffloadPolicy` transfers parameters every forward AND
|
| 8 |
+
backward, while this transfers once per optimizer step, which at grad-accum 4
|
| 9 |
+
is a quarter as often and overlappable with the next accumulation window.
|
| 10 |
+
|
| 11 |
+
Measured cost (tmp/offload_bench.json): torch CPU AdamW (foreach) 2.56s per
|
| 12 |
+
1.81B-param rank shard; PCIe 13.2GB/s solo scaling to 52.7/53.9GB/s aggregate
|
| 13 |
+
across 4 ranks, giving ~0.82s of transfer per rank per optimizer step. At
|
| 14 |
+
grad-accum >= 4 that is an estimated 20-30% step-time overhead; at accum 1 it
|
| 15 |
+
would be 50-100%. This is WHY accum >= 4 is mandatory for full-FT, and the
|
| 16 |
+
Trainer refuses less.
|
| 17 |
+
|
| 18 |
+
Design note on the update rule: the CPU step is torch's own `AdamW` driven
|
| 19 |
+
over the offloaded fp32 master copies, not a reimplementation. Bitwise
|
| 20 |
+
equivalence with `torch.optim.AdamW` is then a property of the construction
|
| 21 |
+
rather than something a test has to keep rediscovering after every torch
|
| 22 |
+
release -- and the test still catches the failures that actually happen here,
|
| 23 |
+
which are plumbing failures: a dropped weight_decay, a mis-forwarded beta, a
|
| 24 |
+
master copy that round-trips through a narrower dtype.
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
from __future__ import annotations
|
| 28 |
+
|
| 29 |
+
import logging
|
| 30 |
+
from typing import Iterable, Optional, Sequence
|
| 31 |
+
|
| 32 |
+
import torch
|
| 33 |
+
|
| 34 |
+
logger = logging.getLogger(__name__)
|
| 35 |
+
|
| 36 |
+
#: Optimizer identities this repo KNOWS. Adafactor is deliberately absent
|
| 37 |
+
#: and refused BY NAME -- see `make_optimizer`.
|
| 38 |
+
#:
|
| 39 |
+
#: `adamw8bit` is BUILDABLE again: `align/train/adamw8bit.py` is a torch-only
|
| 40 |
+
#: block-wise 8-bit AdamW (the bitsandbytes dependency stays banned; see
|
| 41 |
+
#: docs/DEPENDENCY_POLICY.md). The distinction between "a typo'd optimizer
|
| 42 |
+
#: name" and "a known optimizer" must still survive in the error text: the
|
| 43 |
+
#: former is `ValueError("unknown optimizer")`.
|
| 44 |
+
KNOWN_OPTIMIZERS = ("offload_adamw", "adamw8bit")
|
| 45 |
+
|
| 46 |
+
#: The default optimizer in `RunConfig` and the one the pinned-staging budget
|
| 47 |
+
#: and the RSS projection model. Every SHIPPED full-FT config overrides it to
|
| 48 |
+
#: `adamw8bit`, because at 7.25B params / 4 ranks the host cannot hold this
|
| 49 |
+
#: optimizer's state (see adamw8bit.py for the arithmetic).
|
| 50 |
+
OPTIMIZER_OF_RECORD = "offload_adamw"
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def pinned_staging_bytes(cfg) -> float:
|
| 54 |
+
"""Total pinned staging budget across all ranks, DERIVED from the config.
|
| 55 |
+
|
| 56 |
+
One fp32 layer-shard is `n_params / n_layers / world_size` elements at 4
|
| 57 |
+
bytes; each rank is allowed `pinned_staging_shards_per_rank` of them.
|
| 58 |
+
|
| 59 |
+
Pinned memory is unswappable and unreclaimable, and this box has no swap,
|
| 60 |
+
so this is a hard budget rather than an estimate. It is the same formula
|
| 61 |
+
`guard.projected_full_ft_rss_gb` uses for the RSS projection -- deliberately
|
| 62 |
+
the same, so the launch banner and the preflight cannot disagree about how
|
| 63 |
+
much host RAM the job intends to lock down.
|
| 64 |
+
"""
|
| 65 |
+
if cfg.model.n_params <= 0:
|
| 66 |
+
raise ValueError(
|
| 67 |
+
f"model.n_params={cfg.model.n_params} is not a parameter count, so "
|
| 68 |
+
"there is no staging budget to derive. The tiny CI config sets it "
|
| 69 |
+
"to -1 deliberately (the 7B projection does not apply there); a "
|
| 70 |
+
"negative budget must raise rather than silently become a negative "
|
| 71 |
+
"cap that every allocation trivially satisfies."
|
| 72 |
+
)
|
| 73 |
+
world = cfg.train.world_size
|
| 74 |
+
layer_shard_bytes = cfg.model.n_params / cfg.model.n_layers / world * 4.0
|
| 75 |
+
return layer_shard_bytes * cfg.train.pinned_staging_shards_per_rank * world
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
class OffloadAdamW:
|
| 79 |
+
"""AdamW whose state lives on the host; params and grads never move.
|
| 80 |
+
|
| 81 |
+
`step()` returns False when the grad-skip gate refused the step.
|
| 82 |
+
"""
|
| 83 |
+
|
| 84 |
+
def __init__(
|
| 85 |
+
self,
|
| 86 |
+
params: Iterable[torch.Tensor],
|
| 87 |
+
lr: float,
|
| 88 |
+
betas: tuple[float, float] = (0.9, 0.95),
|
| 89 |
+
eps: float = 1e-8,
|
| 90 |
+
weight_decay: float = 0.0,
|
| 91 |
+
pinned_staging_cap_bytes: Optional[float] = None,
|
| 92 |
+
overlap: bool = True,
|
| 93 |
+
state_device: str = "cpu",
|
| 94 |
+
max_grad_norm: Optional[float] = None,
|
| 95 |
+
) -> None:
|
| 96 |
+
self.params: list[torch.Tensor] = list(params)
|
| 97 |
+
if not self.params:
|
| 98 |
+
raise ValueError("OffloadAdamW got an empty parameter list")
|
| 99 |
+
if max_grad_norm is not None and not (float(max_grad_norm) > 0.0):
|
| 100 |
+
raise ValueError(
|
| 101 |
+
f"max_grad_norm must be a positive float or None, got {max_grad_norm!r}"
|
| 102 |
+
)
|
| 103 |
+
#: GLOBAL-norm clip (`clip_grad_norm_`'s rule, `min(1, c / (norm + 1e-6))`),
|
| 104 |
+
#: None = no clipping. THE SILENT-NO-CLIP DEFECT (H2 review, 2026-10-13):
|
| 105 |
+
#: `OptimizerConfig.max_grad_norm` defaults to 1.0 and every shipped TOML
|
| 106 |
+
#: states it, but this class had no such parameter and `make_optimizer`
|
| 107 |
+
#: never forwarded the field -- the one optimizer of record for full-FT
|
| 108 |
+
#: ran UNCLIPPED while `BlockwiseAdamW8bit` honoured the same config.
|
| 109 |
+
#: `last_grad_norm` stays the PRE-clip norm (the tripwires must see an
|
| 110 |
+
#: explosion, not its clipped image).
|
| 111 |
+
self.max_grad_norm = None if max_grad_norm is None else float(max_grad_norm)
|
| 112 |
+
self.state_device = torch.device(state_device)
|
| 113 |
+
self.overlap = bool(overlap)
|
| 114 |
+
self.banner_tag = "offload_adamw"
|
| 115 |
+
|
| 116 |
+
# THIS RANK'S bytes, so the staging cap is a per-rank quantity. Under
|
| 117 |
+
# FSDP2 every parameter is a DTensor whose `.numel()` is the GLOBAL
|
| 118 |
+
# element count: a world-size-times overcount that would have refused
|
| 119 |
+
# (or, worse, mis-banner) any sharded job.
|
| 120 |
+
self.staging_bytes_required = float(
|
| 121 |
+
sum(self._localize(p).numel() * 4 for p in self.params)
|
| 122 |
+
)
|
| 123 |
+
self.pinned_staging_cap_bytes = pinned_staging_cap_bytes
|
| 124 |
+
if (
|
| 125 |
+
pinned_staging_cap_bytes is not None
|
| 126 |
+
and self.staging_bytes_required > pinned_staging_cap_bytes
|
| 127 |
+
):
|
| 128 |
+
raise ValueError(
|
| 129 |
+
f"pinned staging would need "
|
| 130 |
+
f"{self.staging_bytes_required / 2**30:.2f}GiB but the cap is "
|
| 131 |
+
f"{pinned_staging_cap_bytes / 2**30:.2f}GiB. Pinned memory is "
|
| 132 |
+
"unswappable and this box has no swap: exceeding the budget "
|
| 133 |
+
"does not degrade, it wedges the host. Lower "
|
| 134 |
+
"train.pinned_staging_shards_per_rank or shard further."
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
# fp32 master copies on the host. These are the AUTHORITATIVE weights:
|
| 138 |
+
# the device params are a bf16 view of them, refreshed every step.
|
| 139 |
+
self._pin = torch.cuda.is_available() and self.state_device.type == "cpu"
|
| 140 |
+
# THE FSDP2 DTENSOR DEFECT (found in the align port review, 2026-10-12;
|
| 141 |
+
# the predecessor never hit it because the shard-local 8-bit optimizer
|
| 142 |
+
# was the one that actually ran on FSDP2 parameters). Under `fully_shard`
|
| 143 |
+
# `model.parameters()` yields DTensors, and OffloadAdamW died on them
|
| 144 |
+
# twice: `is_pinned()` has no DTensor sharding rule at construction,
|
| 145 |
+
# and the grad-norm accumulator mixed a DTensor into a plain tensor at
|
| 146 |
+
# the first step. Masters, grads and the H2D copy-back therefore all go
|
| 147 |
+
# through `_localize` -- this rank's shard, a storage-sharing view -- the
|
| 148 |
+
# same contract `ShardLocalAdamW8bit` documents (AdamW is elementwise,
|
| 149 |
+
# so stepping the local shard is exact).
|
| 150 |
+
self._master = [
|
| 151 |
+
self._host_tensor(self._localize(p).detach().to(torch.float32)) for p in self.params
|
| 152 |
+
]
|
| 153 |
+
self._inner = torch.optim.AdamW(
|
| 154 |
+
self._master,
|
| 155 |
+
lr=lr,
|
| 156 |
+
betas=betas,
|
| 157 |
+
eps=eps,
|
| 158 |
+
weight_decay=weight_decay,
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
self.skipped_steps = 0
|
| 162 |
+
self.last_grad_norm = float("nan")
|
| 163 |
+
logger.info(
|
| 164 |
+
"OffloadAdamW: %d tensors, %.2fGiB fp32 master on %s (pinned=%s), "
|
| 165 |
+
"staging %.2fGiB, overlap=%s",
|
| 166 |
+
len(self.params),
|
| 167 |
+
sum(m.numel() * 4 for m in self._master) / 2**30,
|
| 168 |
+
self.state_device,
|
| 169 |
+
self._pin,
|
| 170 |
+
self.staging_bytes_required / 2**30,
|
| 171 |
+
self.overlap,
|
| 172 |
+
)
|
| 173 |
+
|
| 174 |
+
# -- host staging -------------------------------------------------------
|
| 175 |
+
|
| 176 |
+
@staticmethod
|
| 177 |
+
def _localize(t):
|
| 178 |
+
"""This rank's local shard of an FSDP2 DTensor (a view sharing the
|
| 179 |
+
parameter's storage), or `t` itself for an ordinary tensor."""
|
| 180 |
+
return t.to_local() if hasattr(t, "to_local") else t
|
| 181 |
+
|
| 182 |
+
def _host_tensor(self, t: torch.Tensor) -> torch.Tensor:
|
| 183 |
+
out = t.to(self.state_device, copy=True)
|
| 184 |
+
if self._pin and not out.is_pinned():
|
| 185 |
+
out = out.pin_memory()
|
| 186 |
+
return out
|
| 187 |
+
|
| 188 |
+
# -- the step -----------------------------------------------------------
|
| 189 |
+
|
| 190 |
+
def step(self) -> bool:
|
| 191 |
+
"""One optimizer step, or a refusal.
|
| 192 |
+
|
| 193 |
+
Order matters and is load-bearing: finiteness is checked BEFORE any
|
| 194 |
+
state is touched. An implementation that discovers the NaN after
|
| 195 |
+
updating the moments has already poisoned the second-moment estimate,
|
| 196 |
+
and every subsequent step is scaled by it.
|
| 197 |
+
"""
|
| 198 |
+
grads = [None if p.grad is None else self._localize(p.grad) for p in self.params]
|
| 199 |
+
if all(g is None for g in grads):
|
| 200 |
+
raise RuntimeError(
|
| 201 |
+
"OffloadAdamW.step() called with no gradients on any parameter. "
|
| 202 |
+
"A step that silently does nothing is indistinguishable from a "
|
| 203 |
+
"run that trained."
|
| 204 |
+
)
|
| 205 |
+
|
| 206 |
+
if not self._all_finite(grads):
|
| 207 |
+
self.skipped_steps += 1
|
| 208 |
+
# NaN, not 0.0: the norm of a non-finite gradient is undefined, and
|
| 209 |
+
# a 0.0 in the log reads as 'perfectly converged'.
|
| 210 |
+
self.last_grad_norm = float("nan")
|
| 211 |
+
logger.warning(
|
| 212 |
+
"grad-skip gate: non-finite gradient, skipping optimizer step "
|
| 213 |
+
"(%d skipped so far). Moments and step counts left untouched.",
|
| 214 |
+
self.skipped_steps,
|
| 215 |
+
)
|
| 216 |
+
return False
|
| 217 |
+
|
| 218 |
+
self.last_grad_norm = self._grad_norm(grads) # PRE-clip, on purpose
|
| 219 |
+
|
| 220 |
+
# `_grad_norm` all-reduces the sum of squares, so `coef` is the same on
|
| 221 |
+
# every rank: each rank scales its own disjoint shard by the GLOBAL factor.
|
| 222 |
+
coef = 1.0
|
| 223 |
+
if self.max_grad_norm is not None:
|
| 224 |
+
coef = min(1.0, self.max_grad_norm / (self.last_grad_norm + 1e-6))
|
| 225 |
+
|
| 226 |
+
# D2H: fp32 grads into the host staging that backs the master copies.
|
| 227 |
+
for master, g in zip(self._master, grads):
|
| 228 |
+
if g is None:
|
| 229 |
+
master.grad = None
|
| 230 |
+
continue
|
| 231 |
+
master.grad = self._host_tensor(g.detach().to(torch.float32))
|
| 232 |
+
if coef != 1.0:
|
| 233 |
+
master.grad.mul_(coef)
|
| 234 |
+
|
| 235 |
+
self._inner.step()
|
| 236 |
+
|
| 237 |
+
# H2D: updated master back into the device params, in their own dtype.
|
| 238 |
+
# copy_ keeps the storage identity FSDP's shard views depend on.
|
| 239 |
+
with torch.no_grad():
|
| 240 |
+
for p, master in zip(self.params, self._master):
|
| 241 |
+
local = self._localize(p).detach()
|
| 242 |
+
local.copy_(master.to(local.dtype))
|
| 243 |
+
return True
|
| 244 |
+
|
| 245 |
+
def zero_grad(self, set_to_none: bool = True) -> None:
|
| 246 |
+
for p in self.params:
|
| 247 |
+
if set_to_none:
|
| 248 |
+
p.grad = None
|
| 249 |
+
elif p.grad is not None:
|
| 250 |
+
p.grad.zero_()
|
| 251 |
+
for m in self._master:
|
| 252 |
+
m.grad = None
|
| 253 |
+
|
| 254 |
+
def _all_finite(self, grads: Sequence[Optional[torch.Tensor]]) -> bool:
|
| 255 |
+
"""Global finiteness. Under FSDP a rank whose shard is finite must not
|
| 256 |
+
step while a peer's is not: the peers would diverge silently and only
|
| 257 |
+
surface as an NCCL mismatch much later, if at all."""
|
| 258 |
+
local = True
|
| 259 |
+
for g in grads:
|
| 260 |
+
if g is not None and not bool(torch.isfinite(g).all()):
|
| 261 |
+
local = False
|
| 262 |
+
break
|
| 263 |
+
return bool(self._all_reduce_flag(local))
|
| 264 |
+
|
| 265 |
+
@staticmethod
|
| 266 |
+
def _collective_device() -> torch.device:
|
| 267 |
+
"""The device a tensor must live on to join a collective on the default
|
| 268 |
+
process group: this rank's OWN cuda device under NCCL (a bare .cuda()
|
| 269 |
+
would put every rank's tensor on device 0 and reduce the wrong ones),
|
| 270 |
+
CPU under gloo. Keyed on the group's backend, not on
|
| 271 |
+
`torch.cuda.is_available()`: a gloo group on a box that happens to have
|
| 272 |
+
GPUs would otherwise be handed a cuda tensor it cannot reduce."""
|
| 273 |
+
import torch.distributed as dist
|
| 274 |
+
|
| 275 |
+
if "nccl" in str(dist.get_backend()).lower() and torch.cuda.is_available():
|
| 276 |
+
from ..utils.dist import local_rank
|
| 277 |
+
|
| 278 |
+
return torch.device("cuda", local_rank())
|
| 279 |
+
return torch.device("cpu")
|
| 280 |
+
|
| 281 |
+
@staticmethod
|
| 282 |
+
def _all_reduce_flag(local: bool) -> bool:
|
| 283 |
+
import torch.distributed as dist
|
| 284 |
+
|
| 285 |
+
if not dist.is_available() or not dist.is_initialized():
|
| 286 |
+
return local
|
| 287 |
+
flag = torch.tensor([1.0 if local else 0.0], device=OffloadAdamW._collective_device())
|
| 288 |
+
dist.all_reduce(flag, op=dist.ReduceOp.MIN)
|
| 289 |
+
return bool(flag.item() > 0.0)
|
| 290 |
+
|
| 291 |
+
@staticmethod
|
| 292 |
+
def _grad_norm(grads: Sequence[Optional[torch.Tensor]]) -> float:
|
| 293 |
+
"""The GLOBAL L2 norm of the gradient, identical on every rank.
|
| 294 |
+
|
| 295 |
+
Accumulate on the GRADS' device, not on CPU: the accumulator used to be
|
| 296 |
+
a bare `torch.zeros((), dtype=float64)`, a CPU tensor, which broke the
|
| 297 |
+
moment the shard-local 8-bit optimizer called it with CUDA grads:
|
| 298 |
+
"Expected all tensors to be on the same device, but found cuda:0 and cpu".
|
| 299 |
+
|
| 300 |
+
THE SHARD-LOCAL NORM DEFECT (align port review, 2026-10-12). Under FSDP2
|
| 301 |
+
each rank holds a DISJOINT 1/world slice of every gradient, so the sum of
|
| 302 |
+
squares over the local slice is a norm of a quarter of the gradient --
|
| 303 |
+
and a different number on every rank. `GradNormTripwire` is a HALT wire
|
| 304 |
+
reading this value: a rank-local norm lets ONE rank cross the ceiling and
|
| 305 |
+
raise while its peers continue into the next collective (a hang until the
|
| 306 |
+
NCCL watchdog), and it under-reads a true explosion by ~sqrt(world). The
|
| 307 |
+
sum of squares is therefore all-reduced (SUM) before the root. Every
|
| 308 |
+
rank reaches this call together or not at all, because the finiteness
|
| 309 |
+
gate that precedes it is itself a collective.
|
| 310 |
+
"""
|
| 311 |
+
import torch.distributed as dist
|
| 312 |
+
|
| 313 |
+
present = [g for g in grads if g is not None]
|
| 314 |
+
distributed = dist.is_available() and dist.is_initialized()
|
| 315 |
+
if not present and not distributed:
|
| 316 |
+
return float("nan")
|
| 317 |
+
device = present[0].device if present else torch.device("cpu")
|
| 318 |
+
total = torch.zeros((), dtype=torch.float64, device=device)
|
| 319 |
+
for g in present:
|
| 320 |
+
total += g.detach().to(torch.float64).pow(2).sum()
|
| 321 |
+
if distributed:
|
| 322 |
+
total = total.to(OffloadAdamW._collective_device())
|
| 323 |
+
dist.all_reduce(total, op=dist.ReduceOp.SUM)
|
| 324 |
+
return float(total.sqrt())
|
| 325 |
+
|
| 326 |
+
# -- introspection ------------------------------------------------------
|
| 327 |
+
|
| 328 |
+
def describe_placement(self) -> dict:
|
| 329 |
+
"""Where every tensor actually lives. Consumed by the launch banner and
|
| 330 |
+
by the device-partition test; a placement claim nobody checks is how
|
| 331 |
+
'the state is offloaded' becomes untrue after a refactor."""
|
| 332 |
+
|
| 333 |
+
def entry(t: torch.Tensor) -> dict:
|
| 334 |
+
return {
|
| 335 |
+
"device": t.device.type,
|
| 336 |
+
"dtype": str(t.dtype),
|
| 337 |
+
"pinned": bool(t.is_pinned()) if t.device.type == "cpu" else False,
|
| 338 |
+
"bytes": t.numel() * t.element_size(),
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
state = []
|
| 342 |
+
for group in self._inner.state.values():
|
| 343 |
+
for key, v in group.items():
|
| 344 |
+
if isinstance(v, torch.Tensor):
|
| 345 |
+
state.append({"key": key, **entry(v)})
|
| 346 |
+
return {
|
| 347 |
+
"state": state,
|
| 348 |
+
"master": [entry(m) for m in self._master],
|
| 349 |
+
"params": [entry(self._localize(p)) for p in self.params],
|
| 350 |
+
"staging_bytes_required": self.staging_bytes_required,
|
| 351 |
+
"pinned_staging_cap_bytes": self.pinned_staging_cap_bytes,
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
def state_snapshot_for_tests(self) -> dict:
|
| 355 |
+
"""Flat clone of every optimizer-state tensor, including the step
|
| 356 |
+
counts. Tests assert these are byte-identical across a skipped step."""
|
| 357 |
+
out = {}
|
| 358 |
+
for i, p in enumerate(self._master):
|
| 359 |
+
for key, v in self._inner.state.get(p, {}).items():
|
| 360 |
+
out[f"{i}.{key}"] = (
|
| 361 |
+
v.detach().clone() if isinstance(v, torch.Tensor) else torch.tensor(float(v))
|
| 362 |
+
)
|
| 363 |
+
return out
|
| 364 |
+
|
| 365 |
+
# -- resume -------------------------------------------------------------
|
| 366 |
+
|
| 367 |
+
def state_dict(self) -> dict:
|
| 368 |
+
"""A DETACHED CLONE of every state tensor, on CPU.
|
| 369 |
+
|
| 370 |
+
THE SHARED-STATE ALIASING INCIDENT. Two traps stack here, and both are
|
| 371 |
+
silent:
|
| 372 |
+
|
| 373 |
+
1. `torch.optim.Optimizer.state_dict()` returns per-param dicts that
|
| 374 |
+
SHARE the live state tensors, and `.to("cpu")` on a tensor already
|
| 375 |
+
on CPU returns the same object -- so a state dict built without
|
| 376 |
+
clones is a live view of the optimizer, not a snapshot of it.
|
| 377 |
+
2. Mutating that returned dict in place (to clone the values) rebinds
|
| 378 |
+
the optimizer's OWN state entries, so the "snapshot" aliases the
|
| 379 |
+
live state no matter which order you do it in.
|
| 380 |
+
|
| 381 |
+
The result of getting this wrong is not a crash: `load_state_dict`
|
| 382 |
+
hands the loaded optimizer the same `step`/`exp_avg`/`exp_avg_sq`
|
| 383 |
+
objects the source still holds, both mutate them every step, Adam's
|
| 384 |
+
step counter advances twice per step, and the two runs diverge while
|
| 385 |
+
every state comparison between them says they are identical.
|
| 386 |
+
|
| 387 |
+
So this builds a NEW nested structure and never touches what the inner
|
| 388 |
+
optimizer returned.
|
| 389 |
+
"""
|
| 390 |
+
inner = self._inner.state_dict()
|
| 391 |
+
return {
|
| 392 |
+
"param_groups": inner["param_groups"],
|
| 393 |
+
"state": {
|
| 394 |
+
idx: {
|
| 395 |
+
k: (v.detach().to("cpu").clone() if isinstance(v, torch.Tensor) else v)
|
| 396 |
+
for k, v in group.items()
|
| 397 |
+
}
|
| 398 |
+
for idx, group in inner["state"].items()
|
| 399 |
+
},
|
| 400 |
+
"master": [m.detach().to("cpu").clone() for m in self._master],
|
| 401 |
+
"skipped_steps": self.skipped_steps,
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
def load_state_dict(self, sd: dict) -> None:
|
| 405 |
+
"""Restore moments AND master weights, then refresh the device params.
|
| 406 |
+
|
| 407 |
+
The master is the authoritative weight copy, so a resume that restored
|
| 408 |
+
only the moments would carry the pre-checkpoint parameters forward --
|
| 409 |
+
a run that looks like it resumed and is training a different model.
|
| 410 |
+
"""
|
| 411 |
+
masters = sd.get("master")
|
| 412 |
+
if masters is None:
|
| 413 |
+
raise KeyError(
|
| 414 |
+
"optimizer state_dict has no 'master' entry; refusing to "
|
| 415 |
+
"resume from a checkpoint that carries moments but not the "
|
| 416 |
+
"fp32 master weights."
|
| 417 |
+
)
|
| 418 |
+
if len(masters) != len(self._master):
|
| 419 |
+
raise ValueError(
|
| 420 |
+
f"checkpoint has {len(masters)} master tensors but this "
|
| 421 |
+
f"optimizer holds {len(self._master)}"
|
| 422 |
+
)
|
| 423 |
+
with torch.no_grad():
|
| 424 |
+
for dst, src in zip(self._master, masters):
|
| 425 |
+
dst.copy_(src.to(dst.dtype))
|
| 426 |
+
for p, master in zip(self.params, self._master):
|
| 427 |
+
local = self._localize(p).detach()
|
| 428 |
+
local.copy_(master.to(local.dtype))
|
| 429 |
+
# Clone on the way IN as well: torch stores these objects directly when
|
| 430 |
+
# no dtype/device cast is needed, so loading one checkpoint dict into
|
| 431 |
+
# two optimizers would otherwise make them share moments. Symmetric
|
| 432 |
+
# with the cloning in state_dict(), and for the same reason.
|
| 433 |
+
inner_sd = {
|
| 434 |
+
"param_groups": sd["param_groups"],
|
| 435 |
+
"state": {
|
| 436 |
+
idx: {
|
| 437 |
+
k: (v.detach().clone() if isinstance(v, torch.Tensor) else v)
|
| 438 |
+
for k, v in group.items()
|
| 439 |
+
}
|
| 440 |
+
for idx, group in sd["state"].items()
|
| 441 |
+
},
|
| 442 |
+
}
|
| 443 |
+
self._inner.load_state_dict(inner_sd)
|
| 444 |
+
self.skipped_steps = int(sd.get("skipped_steps", 0))
|
| 445 |
+
|
| 446 |
+
def reseed_masters_from_params(self) -> None:
|
| 447 |
+
"""Overwrite the fp32 masters with the CURRENT device params (audit U01-2).
|
| 448 |
+
|
| 449 |
+
The master is authoritative: every step rewrites the params from it. A resume that loads
|
| 450 |
+
weights but no optimizer state (a legacy checkpoint, `reshard(drop_optimizer=True)`) would
|
| 451 |
+
otherwise leave the masters at the LAUNCH weights and the first step would silently revert
|
| 452 |
+
the restored model. Moments stay fresh; only the master copy is re-seeded.
|
| 453 |
+
"""
|
| 454 |
+
with torch.no_grad():
|
| 455 |
+
for p, master in zip(self.params, self._master):
|
| 456 |
+
master.copy_(self._localize(p).detach().to(torch.float32))
|
| 457 |
+
|
| 458 |
+
@property
|
| 459 |
+
def param_groups(self):
|
| 460 |
+
"""Exposed so a torch LR scheduler can drive this optimizer directly."""
|
| 461 |
+
return self._inner.param_groups
|
| 462 |
+
|
| 463 |
+
|
| 464 |
+
class ShardLocalAdamW8bit:
|
| 465 |
+
"""Compatibility shim: the legacy name for the shard-local 8-bit AdamW.
|
| 466 |
+
|
| 467 |
+
The optimizer itself is `align.train.adamw8bit.BlockwiseAdamW8bit` (pure
|
| 468 |
+
torch; `make_optimizer` returns THAT directly). This class keeps the
|
| 469 |
+
legacy wrapper shape -- `_inner`, `_pairs`, and a `state_dict` that wraps
|
| 470 |
+
the inner state with `world_size`/`n_shards` -- because two invariants are
|
| 471 |
+
regression-tested against that shape WITHOUT constructing the class
|
| 472 |
+
(tests build a stub via `__new__` and hand it a fake `_inner`):
|
| 473 |
+
|
| 474 |
+
* the recursive CPU clone: a one-level clone once passed ~3.2GB of live
|
| 475 |
+
CUDA moments straight into the checkpoint pickle, and `gather_object`
|
| 476 |
+
restored them onto the SENDER'S device on rank 0 and OOM'd the DPO
|
| 477 |
+
run at its first checkpoint;
|
| 478 |
+
* the world-size refusal: the saved state is SHARD-shaped, and reloading
|
| 479 |
+
it under a different world size would silently align rank r's moments
|
| 480 |
+
to a different slice of the parameter.
|
| 481 |
+
|
| 482 |
+
WHY SHARD-LOCAL STEPPING IS EXACT, not an approximation: AdamW is
|
| 483 |
+
ELEMENTWISE. Every update depends only on that element's own gradient and
|
| 484 |
+
its own two moment scalars, so there is nothing for a shard boundary to
|
| 485 |
+
cut. (This is exactly what Adafactor cannot claim, and why Adafactor is
|
| 486 |
+
banned here: its factored second moment is a row/column statistic that
|
| 487 |
+
DOES cross shard boundaries.) `to_local()` is a VIEW sharing storage with
|
| 488 |
+
the DTensor, so stepping it updates the parameter -- measured on 4 real
|
| 489 |
+
ranks: storage pointers stable across calls and steps, and the DTensor
|
| 490 |
+
observes the update.
|
| 491 |
+
|
| 492 |
+
HISTORY: the first version wrapped bitsandbytes, whose fused kernel
|
| 493 |
+
cannot consume DTensors ("optimizer_update_8bit_blockwise got mixed
|
| 494 |
+
torch.Tensor and DTensor"), and was then retired with the dependency. It
|
| 495 |
+
is now a thin delegate to the in-repo implementation, which does the
|
| 496 |
+
gating, the GLOBAL grad norm and the state handling itself; the shim only
|
| 497 |
+
mirrors `skipped_steps` / `last_grad_norm` so legacy readers keep working.
|
| 498 |
+
"""
|
| 499 |
+
|
| 500 |
+
def __init__(
|
| 501 |
+
self,
|
| 502 |
+
params: Iterable[torch.Tensor],
|
| 503 |
+
lr: float,
|
| 504 |
+
betas: tuple[float, float] = (0.9, 0.95),
|
| 505 |
+
eps: float = 1e-8,
|
| 506 |
+
weight_decay: float = 0.0,
|
| 507 |
+
):
|
| 508 |
+
# Consume the parameter iterable first so a generator-backed caller
|
| 509 |
+
# (`model.parameters()`) behaves the same however it is used, and
|
| 510 |
+
# validate the empty case before anything is built.
|
| 511 |
+
params = list(params)
|
| 512 |
+
if not params:
|
| 513 |
+
raise ValueError("ShardLocalAdamW8bit got an empty parameter list")
|
| 514 |
+
# Lazy: adamw8bit imports this module at top level (it reuses the
|
| 515 |
+
# collective helpers), so the reverse import cannot be top-level.
|
| 516 |
+
from .adamw8bit import BlockwiseAdamW8bit
|
| 517 |
+
|
| 518 |
+
self._inner = BlockwiseAdamW8bit(
|
| 519 |
+
params, lr=lr, betas=betas, eps=eps, weight_decay=weight_decay
|
| 520 |
+
)
|
| 521 |
+
self._pairs = [(p, self._localize(p).detach()) for p in params]
|
| 522 |
+
self.skipped_steps = 0
|
| 523 |
+
self.last_grad_norm = float("nan")
|
| 524 |
+
self.banner_tag = self._inner.banner_tag
|
| 525 |
+
|
| 526 |
+
@staticmethod
|
| 527 |
+
def _localize(t):
|
| 528 |
+
return t.to_local() if hasattr(t, "to_local") else t
|
| 529 |
+
|
| 530 |
+
def step(self) -> bool:
|
| 531 |
+
"""One optimizer step, or a refusal. Same contract as OffloadAdamW.
|
| 532 |
+
|
| 533 |
+
Gating, the global grad norm and the update all live in the inner
|
| 534 |
+
optimizer; doing them here too would pay the collectives twice."""
|
| 535 |
+
stepped = self._inner.step()
|
| 536 |
+
self.skipped_steps = self._inner.skipped_steps
|
| 537 |
+
self.last_grad_norm = self._inner.last_grad_norm
|
| 538 |
+
return stepped
|
| 539 |
+
|
| 540 |
+
def zero_grad(self, set_to_none: bool = True) -> None:
|
| 541 |
+
for p, local in self._pairs:
|
| 542 |
+
p.grad = None if set_to_none else p.grad
|
| 543 |
+
local.grad = None
|
| 544 |
+
|
| 545 |
+
def state_dict(self) -> dict:
|
| 546 |
+
from ..utils.dist import world_size
|
| 547 |
+
|
| 548 |
+
inner = self._inner.state_dict()
|
| 549 |
+
|
| 550 |
+
# DETACHED CPU CLONES, RECURSIVELY -- same two aliasing traps
|
| 551 |
+
# OffloadAdamW.state_dict documents, plus the one that survived the
|
| 552 |
+
# first fix: bnb nests its real tensors under
|
| 553 |
+
# state[idx]["__bnb_optimizer_quant_state__"], a dict, so a one-level
|
| 554 |
+
# clone passed ~3.2GB of live CUDA moments straight into the pickle.
|
| 555 |
+
# gather_object then restored them onto the SENDER'S device on rank 0
|
| 556 |
+
# and OOM'd the DPO run at its first checkpoint (SFT's end-save
|
| 557 |
+
# survived the same bug on VRAM headroom alone).
|
| 558 |
+
def _to_cpu(o):
|
| 559 |
+
if isinstance(o, torch.Tensor):
|
| 560 |
+
return o.detach().to("cpu").clone()
|
| 561 |
+
if isinstance(o, dict):
|
| 562 |
+
return {k: _to_cpu(v) for k, v in o.items()}
|
| 563 |
+
if isinstance(o, (list, tuple)):
|
| 564 |
+
return type(o)(_to_cpu(v) for v in o)
|
| 565 |
+
return o
|
| 566 |
+
|
| 567 |
+
return {
|
| 568 |
+
"inner": _to_cpu(inner),
|
| 569 |
+
"world_size": world_size(),
|
| 570 |
+
"n_shards": len(self._pairs),
|
| 571 |
+
"skipped_steps": self.skipped_steps,
|
| 572 |
+
}
|
| 573 |
+
|
| 574 |
+
def load_state_dict(self, sd: dict) -> None:
|
| 575 |
+
from ..utils.dist import world_size
|
| 576 |
+
|
| 577 |
+
saved_ws, now_ws = sd.get("world_size"), world_size()
|
| 578 |
+
if saved_ws is not None and saved_ws != now_ws:
|
| 579 |
+
raise ValueError(
|
| 580 |
+
f"this optimizer state was saved at world_size={saved_ws} but "
|
| 581 |
+
f"the current run has world_size={now_ws}. The 8-bit moments "
|
| 582 |
+
"are SHARD-shaped: loading them under a different world size "
|
| 583 |
+
"would align each rank's state to a different slice of the "
|
| 584 |
+
"parameter, and training would continue plausibly on "
|
| 585 |
+
"corrupted state. Resume at the original world size, or start "
|
| 586 |
+
"fresh."
|
| 587 |
+
)
|
| 588 |
+
saved_n = sd.get("n_shards")
|
| 589 |
+
if saved_n is not None and saved_n != len(self._pairs):
|
| 590 |
+
raise ValueError(
|
| 591 |
+
f"checkpoint holds {saved_n} shards, this optimizer has {len(self._pairs)}"
|
| 592 |
+
)
|
| 593 |
+
self._inner.load_state_dict(sd["inner"])
|
| 594 |
+
self.skipped_steps = int(sd.get("skipped_steps", 0))
|
| 595 |
+
|
| 596 |
+
@property
|
| 597 |
+
def param_groups(self):
|
| 598 |
+
return self._inner.param_groups
|
| 599 |
+
|
| 600 |
+
|
| 601 |
+
def make_optimizer(cfg, params: Iterable[torch.Tensor]):
|
| 602 |
+
"""Build the optimizer named by the config, and tag it for the banner.
|
| 603 |
+
|
| 604 |
+
Which optimizer actually ran is recorded in the banner: a run that used a
|
| 605 |
+
different optimizer than its config names is not reproducible from that
|
| 606 |
+
config.
|
| 607 |
+
|
| 608 |
+
`adamw8bit` is the pre-registered on-device fallback for the case where
|
| 609 |
+
host RAM cannot hold the offloaded state (every shipped full-FT config
|
| 610 |
+
names it). It is the in-repo torch-only `BlockwiseAdamW8bit`; the banner
|
| 611 |
+
states its state size from the RANK-LOCAL element count
|
| 612 |
+
(`2*n + 8*n/block` bytes), so the launch log cannot claim a footprint the
|
| 613 |
+
optimizer does not have. It is never silently swapped for `offload_adamw`:
|
| 614 |
+
a run whose banner says one optimizer while the config names another is
|
| 615 |
+
exactly the irreproducibility this function exists to prevent.
|
| 616 |
+
"""
|
| 617 |
+
name = cfg.optimizer.name
|
| 618 |
+
lowered = str(name).lower()
|
| 619 |
+
|
| 620 |
+
if lowered == "adafactor":
|
| 621 |
+
raise ValueError(
|
| 622 |
+
"Adafactor is BANNED in this repo. Its factored second moment "
|
| 623 |
+
"crosses DTensor shard boundaries: under ZeRO-3 on this box it "
|
| 624 |
+
"produced a perfect-looking loss curve while moving ~1% of "
|
| 625 |
+
"tensors -- a run that trained nothing and reported success for "
|
| 626 |
+
"hours. AdamW only (offload_adamw is the optimizer of record)."
|
| 627 |
+
)
|
| 628 |
+
if lowered not in KNOWN_OPTIMIZERS:
|
| 629 |
+
raise ValueError(
|
| 630 |
+
f"unknown optimizer {name!r}; known: {list(KNOWN_OPTIMIZERS)}. "
|
| 631 |
+
"A typo must not fall through to a default -- the whole point of "
|
| 632 |
+
"naming the optimizer in the config is that the banner can state "
|
| 633 |
+
"which one ran."
|
| 634 |
+
)
|
| 635 |
+
|
| 636 |
+
o = cfg.optimizer
|
| 637 |
+
if lowered == "offload_adamw":
|
| 638 |
+
# The staging cap is a full-FT host-RAM budget. The tiny CPU harness
|
| 639 |
+
# has no layer shards to size it from (n_params = -1), so it runs
|
| 640 |
+
# uncapped rather than against a fabricated number.
|
| 641 |
+
cap = pinned_staging_bytes(cfg) if cfg.train.full_ft else None
|
| 642 |
+
opt = OffloadAdamW(
|
| 643 |
+
params,
|
| 644 |
+
lr=o.lr,
|
| 645 |
+
betas=tuple(o.betas),
|
| 646 |
+
eps=o.eps,
|
| 647 |
+
weight_decay=o.weight_decay,
|
| 648 |
+
pinned_staging_cap_bytes=cap,
|
| 649 |
+
max_grad_norm=getattr(o, "max_grad_norm", None),
|
| 650 |
+
)
|
| 651 |
+
opt.banner_tag = (
|
| 652 |
+
f"offload_adamw (fp32 master + moments on host, "
|
| 653 |
+
f"{opt.staging_bytes_required / 2**30:.2f}GiB staging)"
|
| 654 |
+
)
|
| 655 |
+
return opt
|
| 656 |
+
|
| 657 |
+
# Only 'adamw8bit' can reach here (unknown names raised above).
|
| 658 |
+
# Lazy import: adamw8bit imports this module for the collective helpers.
|
| 659 |
+
from .adamw8bit import BlockwiseAdamW8bit
|
| 660 |
+
|
| 661 |
+
opt = BlockwiseAdamW8bit(
|
| 662 |
+
params,
|
| 663 |
+
lr=o.lr,
|
| 664 |
+
betas=tuple(o.betas),
|
| 665 |
+
eps=o.eps,
|
| 666 |
+
weight_decay=o.weight_decay,
|
| 667 |
+
# OptimizerConfig.max_grad_norm (default 1.0), same rule and same pre-clip
|
| 668 |
+
# `last_grad_norm` record as the offload_adamw branch above.
|
| 669 |
+
max_grad_norm=getattr(o, "max_grad_norm", None),
|
| 670 |
+
)
|
| 671 |
+
n_local = sum(s["numel"] for s in opt._state)
|
| 672 |
+
state_bytes = BlockwiseAdamW8bit.projected_state_bytes(
|
| 673 |
+
[s["numel"] for s in opt._state], opt.block_size, opt.min_8bit_size
|
| 674 |
+
)
|
| 675 |
+
opt.banner_tag = (
|
| 676 |
+
f"adamw8bit (block-wise 8-bit moments ON DEVICE, block {opt.block_size}, "
|
| 677 |
+
f"{state_bytes / 2**30:.2f}GiB state for {n_local / 1e6:.1f}M local params "
|
| 678 |
+
f"= 2B + 8B/block per param; no master copy; zero host RSS)"
|
| 679 |
+
)
|
| 680 |
+
return opt
|
code/align/train/probes.py
ADDED
|
@@ -0,0 +1,627 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Probes and tripwires: the checks a loss curve cannot make.
|
| 2 |
+
|
| 3 |
+
Every mechanism here exists because the predecessor shipped through the
|
| 4 |
+
failure it detects, with healthy-looking metrics the whole way:
|
| 5 |
+
|
| 6 |
+
* TRUNK MOVEMENT. Reentrant gradient checkpointing silently zeroes trunk
|
| 7 |
+
gradients under sharding. The run trains -- loss falls, throughput is
|
| 8 |
+
normal -- while only the embeddings and the LM head move. Nothing in the
|
| 9 |
+
loss, the grad norm, or the throughput distinguishes it from a healthy
|
| 10 |
+
run, so the probe compares actual parameter values and RAISES at the first
|
| 11 |
+
log step rather than warning at the hundredth.
|
| 12 |
+
* logps/chosen DECLINE. DPO's objective only asks for a MARGIN, which is
|
| 13 |
+
equally well served by pushing the rejected logprob down as by pulling the
|
| 14 |
+
chosen one up. When it takes the second route the model gets better at the
|
| 15 |
+
loss and worse at the task. Maximal for Arm B by design, since pi_SFT
|
| 16 |
+
already assigns the chosen sequence high likelihood.
|
| 17 |
+
* LENGTH DRIFT. Preference optimization has a documented length bias. The
|
| 18 |
+
alarm is baseline-RELATIVE (Arm A measured 54.9% chosen-longer), because
|
| 19 |
+
an absolute threshold says nothing about a dataset whose baseline is not
|
| 20 |
+
50%.
|
| 21 |
+
|
| 22 |
+
Tripwires are pure functions over metric windows, so they are tested without a
|
| 23 |
+
model. NaN in means NaN out and never a trip: a NaN coerced to 0.0 reads as
|
| 24 |
+
'flat', and flat-chosen-with-rising-margin is itself the alarm condition.
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
from __future__ import annotations
|
| 28 |
+
|
| 29 |
+
import logging
|
| 30 |
+
import math
|
| 31 |
+
from dataclasses import dataclass, field
|
| 32 |
+
from typing import Any, Optional, Sequence
|
| 33 |
+
|
| 34 |
+
import torch
|
| 35 |
+
|
| 36 |
+
logger = logging.getLogger(__name__)
|
| 37 |
+
|
| 38 |
+
NAN = float("nan")
|
| 39 |
+
|
| 40 |
+
#: Parameter-name fragments that are NOT trunk. The probe must exclude them:
|
| 41 |
+
#: the incident's signature is "head and embeddings moved, trunk did not", so
|
| 42 |
+
#: a probe that tracked them would call that run healthy.
|
| 43 |
+
NON_TRUNK_FRAGMENTS = ("embed_tokens", "lm_head", "embed_out", "wte", "wpe")
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class TrunkMovementProbe:
|
| 47 |
+
"""Snapshot trunk parameters, then assert they actually moved."""
|
| 48 |
+
|
| 49 |
+
def __init__(self, model, max_tracked: int = 16, seed: int = 0):
|
| 50 |
+
self._model = model
|
| 51 |
+
self.tracked_names: list[str] = []
|
| 52 |
+
self._snapshots: list[torch.Tensor] = []
|
| 53 |
+
|
| 54 |
+
# Only TRAINABLE parameters can move. On a LoRA/partially-frozen model a
|
| 55 |
+
# stride that landed on frozen base weights would report "trunk frozen"
|
| 56 |
+
# on a perfectly healthy run (found in the PPO port: the probe was
|
| 57 |
+
# rebuilt on the real model at resume and tracked frozen tensors).
|
| 58 |
+
candidates = [
|
| 59 |
+
(n, p)
|
| 60 |
+
for n, p in model.named_parameters()
|
| 61 |
+
if p.requires_grad and not any(f in n for f in NON_TRUNK_FRAGMENTS)
|
| 62 |
+
]
|
| 63 |
+
if not candidates:
|
| 64 |
+
raise ValueError(
|
| 65 |
+
"TrunkMovementProbe found no trunk parameters to track "
|
| 66 |
+
f"(every TRAINABLE parameter was absent or matched {NON_TRUNK_FRAGMENTS}). It cannot report "
|
| 67 |
+
"'no movement' on an empty set -- that is the alarm condition, "
|
| 68 |
+
"and reporting it from nothing would be a false alarm forever."
|
| 69 |
+
)
|
| 70 |
+
# Deterministic even sample across depth, so a probe on a 32-layer
|
| 71 |
+
# model does not track 16 tensors from layer 0.
|
| 72 |
+
stride = max(1, len(candidates) // max_tracked)
|
| 73 |
+
for name, p in candidates[::stride][:max_tracked]:
|
| 74 |
+
self.tracked_names.append(name)
|
| 75 |
+
self._snapshots.append(self.snapshot_tensor(p))
|
| 76 |
+
self.n_tracked = len(self._snapshots)
|
| 77 |
+
|
| 78 |
+
def release(self) -> None:
|
| 79 |
+
"""Drop the model reference and the snapshots (U01-11): the probe is dead after the
|
| 80 |
+
post-train handoff and must not keep the training replica alive."""
|
| 81 |
+
self._model = None
|
| 82 |
+
self._snapshots = []
|
| 83 |
+
|
| 84 |
+
@staticmethod
|
| 85 |
+
def snapshot_tensor(t: Any) -> torch.Tensor:
|
| 86 |
+
"""Detached CPU copy of a parameter, reaching through DTensor.
|
| 87 |
+
|
| 88 |
+
Under FSDP2 every sharded parameter is a DTensor whose global view is
|
| 89 |
+
an unmaterialised placeholder; `.to_local()` is the rank's actual
|
| 90 |
+
shard. Comparing global views would compare something that never
|
| 91 |
+
changes on this rank.
|
| 92 |
+
"""
|
| 93 |
+
if hasattr(t, "to_local"):
|
| 94 |
+
t = t.to_local()
|
| 95 |
+
if hasattr(t, "detach"):
|
| 96 |
+
t = t.detach()
|
| 97 |
+
return t.to("cpu", copy=True).to(torch.float32)
|
| 98 |
+
|
| 99 |
+
def check(self, step: int, model=None, raise_on_frozen: bool = True) -> dict:
|
| 100 |
+
"""Compare against the snapshot. Zero movement RAISES."""
|
| 101 |
+
target = model or self._model
|
| 102 |
+
if target is None:
|
| 103 |
+
raise RuntimeError("TrunkMovementProbe.check after release(): the model reference was dropped")
|
| 104 |
+
current = dict(target.named_parameters())
|
| 105 |
+
tensors = [current[n] for n in self.tracked_names]
|
| 106 |
+
deltas = []
|
| 107 |
+
for snap, t in zip(self._snapshots, tensors):
|
| 108 |
+
now = self.snapshot_tensor(t)
|
| 109 |
+
deltas.append((now - snap).abs().max().item())
|
| 110 |
+
|
| 111 |
+
moved = sum(1 for d in deltas if d > 0.0)
|
| 112 |
+
report = {
|
| 113 |
+
"step": step,
|
| 114 |
+
"n_tracked": self.n_tracked,
|
| 115 |
+
"moved_fraction": moved / self.n_tracked if self.n_tracked else NAN,
|
| 116 |
+
"max_abs_delta": max(deltas) if deltas else NAN,
|
| 117 |
+
"tracked_names": list(self.tracked_names),
|
| 118 |
+
}
|
| 119 |
+
if moved == 0 and raise_on_frozen:
|
| 120 |
+
raise RuntimeError(
|
| 121 |
+
f"TRUNK MOVEMENT PROBE FAILED at step {step}: not one of "
|
| 122 |
+
f"{self.n_tracked} tracked trunk parameters changed by any "
|
| 123 |
+
"amount since step 0.\n\nThis is the reentrant-checkpointing "
|
| 124 |
+
"incident: `torch.utils.checkpoint` with use_reentrant=True "
|
| 125 |
+
"silently zeroes trunk gradients under sharding, and the run "
|
| 126 |
+
"keeps training the embeddings and the LM head while the body "
|
| 127 |
+
"stays frozen. Loss still falls. Throughput is normal. Nothing "
|
| 128 |
+
"else detects it.\n\nCheck that activation checkpointing was "
|
| 129 |
+
"wrapped with use_reentrant=False, that the trunk parameters "
|
| 130 |
+
"are in the optimizer's param groups, and that requires_grad "
|
| 131 |
+
f"is set on them. Tracked: {self.tracked_names[:4]}..."
|
| 132 |
+
)
|
| 133 |
+
logger.info(
|
| 134 |
+
"trunk probe ok at step %d: %d/%d moved, max|d|=%.3e",
|
| 135 |
+
step,
|
| 136 |
+
moved,
|
| 137 |
+
self.n_tracked,
|
| 138 |
+
report["max_abs_delta"],
|
| 139 |
+
)
|
| 140 |
+
return report
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
# ---------------------------------------------------------------------------
|
| 144 |
+
# tripwires
|
| 145 |
+
# ---------------------------------------------------------------------------
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
@dataclass
|
| 149 |
+
class TripwireResult:
|
| 150 |
+
tripped: bool
|
| 151 |
+
response: str
|
| 152 |
+
value: float = NAN
|
| 153 |
+
logps_slope: float = NAN
|
| 154 |
+
margin_slope: float = NAN
|
| 155 |
+
detail: str = ""
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def _slope(values: Sequence[float]) -> float:
|
| 159 |
+
"""Least-squares slope over an evenly spaced window. NaN anywhere in the
|
| 160 |
+
window makes the slope NaN -- a window with missing data has no trend, and
|
| 161 |
+
pretending otherwise is how a tripwire fires on absent measurements."""
|
| 162 |
+
n = len(values)
|
| 163 |
+
if n < 2 or any(math.isnan(v) for v in values):
|
| 164 |
+
return NAN
|
| 165 |
+
xs = list(range(n))
|
| 166 |
+
mx = sum(xs) / n
|
| 167 |
+
my = sum(values) / n
|
| 168 |
+
denom = sum((x - mx) ** 2 for x in xs)
|
| 169 |
+
if denom == 0:
|
| 170 |
+
return NAN
|
| 171 |
+
return sum((x - mx) * (y - my) for x, y in zip(xs, values)) / denom
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
class LogpsChosenTripwire:
|
| 175 |
+
"""Fires when PER-TOKEN chosen NLL rises materially while the margin improves.
|
| 176 |
+
|
| 177 |
+
R2 RECALIBRATION (2026-08-23 audit; docs/audits/phase1_postmortem.md).
|
| 178 |
+
The v1 instrument read raw SUMMED logps/chosen -- corr -0.89 with batch
|
| 179 |
+
completion length -- and applied a zero-threshold slope test over a
|
| 180 |
+
20-raw-row window (~1.25 optimizer steps). On the primary Arm B run it
|
| 181 |
+
fired 3,049 times; on a SHUFFLED copy of the same series, 2,987 times:
|
| 182 |
+
zero discriminating power. Its false alarm at micro-step 43 selected the
|
| 183 |
+
objective of the recorded (destroyed) rerun. By the correct metric --
|
| 184 |
+
per-token NLL, DPOP's own published diagnostic -- that primary run showed
|
| 185 |
+
no collapse at all (0.487 -> 0.548 nats/token across 727 steps).
|
| 186 |
+
|
| 187 |
+
v2 semantics: per-token chosen NLL (= -logps/chosen / lengths/chosen),
|
| 188 |
+
baseline median over the first `window` rows vs trailing median over the
|
| 189 |
+
last `window` rows; fires only when the trailing median exceeds baseline
|
| 190 |
+
by `nll_rise_nats` (default 0.15 -- healthy phase-1 drift measured 0.06,
|
| 191 |
+
Smaug's documented collapse is +1.45) AND the margin median has risen.
|
| 192 |
+
LATCHED: once fired it stays fired and stops logging. The calibration
|
| 193 |
+
contract is pinned by tests: it must NOT fire on a shuffled copy of a
|
| 194 |
+
healthy series, and MUST fire on an injected +0.3 nats/token shift.
|
| 195 |
+
"""
|
| 196 |
+
|
| 197 |
+
#: The metric keys this tripwire READS, declared so the wiring can be
|
| 198 |
+
#: checked instead of assumed (THE DISARMED-TRIPWIRE INCIDENT, below).
|
| 199 |
+
INPUT_KEYS: tuple[str, ...] = ("logps/chosen", "lengths/chosen", "rewards/margins")
|
| 200 |
+
|
| 201 |
+
#: The response is PRE-REGISTERED: decided before the run, so a tripped
|
| 202 |
+
#: alarm produces a recorded configuration change rather than a debate
|
| 203 |
+
#: about whether the number is real. Recorded as the arm's configuration.
|
| 204 |
+
PRE_REGISTERED_RESPONSE = (
|
| 205 |
+
"rerun this arm with DPOP (chosen-logprob floor) or an NLL auxiliary "
|
| 206 |
+
"term, and record the switch as the arm's configuration"
|
| 207 |
+
)
|
| 208 |
+
|
| 209 |
+
def __init__(self, window: int = 400, nll_rise_nats: float = 0.15):
|
| 210 |
+
self.window = int(window)
|
| 211 |
+
self.nll_rise_nats = float(nll_rise_nats)
|
| 212 |
+
self._baseline_nll: list[float] = []
|
| 213 |
+
self._baseline_margin: list[float] = []
|
| 214 |
+
self._nll: list[float] = []
|
| 215 |
+
self._margin: list[float] = []
|
| 216 |
+
self.fired = False
|
| 217 |
+
self._fired_at_row: Optional[int] = None
|
| 218 |
+
self._rows = 0
|
| 219 |
+
|
| 220 |
+
def state_dict(self) -> dict:
|
| 221 |
+
"""Baselines, window and latch (U01-6): primitives only, so resume.pt stays
|
| 222 |
+
`weights_only`-loadable. Without it every supervised relaunch re-baselined."""
|
| 223 |
+
return {
|
| 224 |
+
"baseline_nll": [float(v) for v in self._baseline_nll],
|
| 225 |
+
"baseline_margin": [float(v) for v in self._baseline_margin],
|
| 226 |
+
"nll": [float(v) for v in self._nll],
|
| 227 |
+
"margin": [float(v) for v in self._margin],
|
| 228 |
+
"fired": bool(self.fired),
|
| 229 |
+
"fired_at_row": self._fired_at_row,
|
| 230 |
+
"rows": int(self._rows),
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
def load_state_dict(self, sd: dict) -> None:
|
| 234 |
+
self._baseline_nll = [float(v) for v in sd["baseline_nll"]]
|
| 235 |
+
self._baseline_margin = [float(v) for v in sd["baseline_margin"]]
|
| 236 |
+
self._nll = [float(v) for v in sd["nll"]]
|
| 237 |
+
self._margin = [float(v) for v in sd["margin"]]
|
| 238 |
+
self.fired = bool(sd["fired"])
|
| 239 |
+
fr = sd.get("fired_at_row")
|
| 240 |
+
self._fired_at_row = None if fr is None else int(fr)
|
| 241 |
+
self._rows = int(sd["rows"])
|
| 242 |
+
|
| 243 |
+
@staticmethod
|
| 244 |
+
def _median(values: Sequence[float]) -> float:
|
| 245 |
+
# NaN-poisoning median: any NaN in the window makes the statistic NaN,
|
| 246 |
+
# so missing data can never fire (blind != healthy != alarm).
|
| 247 |
+
if any(math.isnan(v) for v in values):
|
| 248 |
+
return NAN
|
| 249 |
+
s = sorted(values)
|
| 250 |
+
n = len(s)
|
| 251 |
+
return s[n // 2] if n % 2 else 0.5 * (s[n // 2 - 1] + s[n // 2])
|
| 252 |
+
|
| 253 |
+
def update(self, logps_chosen: float, lengths_chosen: float, margin: float) -> TripwireResult:
|
| 254 |
+
self._rows += 1
|
| 255 |
+
lp, ln = float(logps_chosen), float(lengths_chosen)
|
| 256 |
+
nll = -lp / ln if (not math.isnan(lp) and not math.isnan(ln) and ln > 0) else NAN
|
| 257 |
+
m = float(margin)
|
| 258 |
+
|
| 259 |
+
if self.fired:
|
| 260 |
+
return TripwireResult(
|
| 261 |
+
True,
|
| 262 |
+
self.PRE_REGISTERED_RESPONSE,
|
| 263 |
+
detail=f"latched since row {self._fired_at_row}",
|
| 264 |
+
)
|
| 265 |
+
if len(self._baseline_nll) < self.window:
|
| 266 |
+
self._baseline_nll.append(nll)
|
| 267 |
+
self._baseline_margin.append(m)
|
| 268 |
+
return TripwireResult(
|
| 269 |
+
False, "", detail=f"baselining {len(self._baseline_nll)}/{self.window}"
|
| 270 |
+
)
|
| 271 |
+
self._nll.append(nll)
|
| 272 |
+
self._margin.append(m)
|
| 273 |
+
self._nll = self._nll[-self.window :]
|
| 274 |
+
self._margin = self._margin[-self.window :]
|
| 275 |
+
if len(self._nll) < self.window:
|
| 276 |
+
return TripwireResult(False, "", detail=f"windowing {len(self._nll)}/{self.window}")
|
| 277 |
+
|
| 278 |
+
base_nll = self._median(self._baseline_nll)
|
| 279 |
+
cur_nll = self._median(self._nll)
|
| 280 |
+
base_m = self._median(self._baseline_margin)
|
| 281 |
+
cur_m = self._median(self._margin)
|
| 282 |
+
rise = cur_nll - base_nll
|
| 283 |
+
tripped = (
|
| 284 |
+
not math.isnan(rise)
|
| 285 |
+
and not math.isnan(cur_m)
|
| 286 |
+
and not math.isnan(base_m)
|
| 287 |
+
and rise > self.nll_rise_nats
|
| 288 |
+
and cur_m > base_m
|
| 289 |
+
)
|
| 290 |
+
if tripped:
|
| 291 |
+
self.fired = True
|
| 292 |
+
self._fired_at_row = self._rows
|
| 293 |
+
logger.error(
|
| 294 |
+
"TRIPWIRE: per-token chosen NLL rose %.3f nats/token over baseline "
|
| 295 |
+
"(%.3f -> %.3f) while the DPO margin rises (%.4f -> %.4f). The "
|
| 296 |
+
"objective is degrading chosen likelihood in real terms. "
|
| 297 |
+
"Pre-registered response: %s",
|
| 298 |
+
rise,
|
| 299 |
+
base_nll,
|
| 300 |
+
cur_nll,
|
| 301 |
+
base_m,
|
| 302 |
+
cur_m,
|
| 303 |
+
self.PRE_REGISTERED_RESPONSE,
|
| 304 |
+
)
|
| 305 |
+
return TripwireResult(
|
| 306 |
+
tripped,
|
| 307 |
+
self.PRE_REGISTERED_RESPONSE if tripped else "",
|
| 308 |
+
value=rise,
|
| 309 |
+
logps_slope=rise, # field kept for record-schema compat: nll shift
|
| 310 |
+
margin_slope=(cur_m - base_m) if not math.isnan(cur_m) else NAN,
|
| 311 |
+
)
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
class GradNormTripwire:
|
| 315 |
+
"""HALTS the run on a gradient-norm explosion. R3 (2026-08-23 audit).
|
| 316 |
+
|
| 317 |
+
grad_norm was logged every optimizer step of both destroyed-run attempts
|
| 318 |
+
and read by NOTHING: the recorded rerun's first step showed 2,851 vs the
|
| 319 |
+
primary's 22 (~130x), and the terminal blow-up reached 355,830 with the
|
| 320 |
+
run continuing 221 steps past the break. A ceiling here would have fired
|
| 321 |
+
at step 1. Unlike the diagnostic tripwires this one HALTS (HALT=True):
|
| 322 |
+
training past a gradient explosion spends GPU-hours destroying weights.
|
| 323 |
+
"""
|
| 324 |
+
|
| 325 |
+
INPUT_KEYS: tuple[str, ...] = ("grad_norm",)
|
| 326 |
+
PRE_REGISTERED_RESPONSE = (
|
| 327 |
+
"halt the run; a gradient explosion is a launch/config defect, not a "
|
| 328 |
+
"tunable -- diagnose before any rerun"
|
| 329 |
+
)
|
| 330 |
+
HALT = True
|
| 331 |
+
#: `grad_norm` is a TRAINER-produced metric that is legitimately NaN on
|
| 332 |
+
#: every micro-step without an applied optimizer step (the NaN-reporting
|
| 333 |
+
#: contract). That is a periodic honest state, not a disarmed feed --
|
| 334 |
+
#: this wire handles NaN as "blind, cannot fire" itself. The arm-time
|
| 335 |
+
#: self-test therefore still requires the KEY (name drift stays a launch
|
| 336 |
+
#: failure) but tolerates NaN for wires that declare this.
|
| 337 |
+
TOLERATES_NAN_INPUT = True
|
| 338 |
+
|
| 339 |
+
def __init__(
|
| 340 |
+
self,
|
| 341 |
+
absolute_ceiling: float = 500.0,
|
| 342 |
+
relative_factor: float = 10.0,
|
| 343 |
+
window: int = 50,
|
| 344 |
+
warmup: int = 10,
|
| 345 |
+
):
|
| 346 |
+
self.absolute_ceiling = float(absolute_ceiling)
|
| 347 |
+
self.relative_factor = float(relative_factor)
|
| 348 |
+
self.window = int(window)
|
| 349 |
+
self.warmup = int(warmup)
|
| 350 |
+
self._history: list[float] = []
|
| 351 |
+
self.fired = False
|
| 352 |
+
|
| 353 |
+
def state_dict(self) -> dict:
|
| 354 |
+
"""Trailing history and latch (U01-6); primitives only."""
|
| 355 |
+
return {"history": [float(v) for v in self._history], "fired": bool(self.fired)}
|
| 356 |
+
|
| 357 |
+
def load_state_dict(self, sd: dict) -> None:
|
| 358 |
+
self._history = [float(v) for v in sd["history"]]
|
| 359 |
+
self.fired = bool(sd["fired"])
|
| 360 |
+
|
| 361 |
+
def update(self, grad_norm: float) -> TripwireResult:
|
| 362 |
+
g = float(grad_norm)
|
| 363 |
+
if self.fired:
|
| 364 |
+
return TripwireResult(True, self.PRE_REGISTERED_RESPONSE, value=g, detail="latched")
|
| 365 |
+
if math.isnan(g):
|
| 366 |
+
return TripwireResult(False, "", value=NAN, detail="NaN grad_norm: blind, cannot fire")
|
| 367 |
+
history = self._history
|
| 368 |
+
trailing = sorted(history[-self.window :])
|
| 369 |
+
med = trailing[len(trailing) // 2] if trailing else NAN
|
| 370 |
+
over_abs = g > self.absolute_ceiling
|
| 371 |
+
over_rel = (
|
| 372 |
+
len(history) >= self.warmup
|
| 373 |
+
and not math.isnan(med)
|
| 374 |
+
and med > 0
|
| 375 |
+
and g > self.relative_factor * med
|
| 376 |
+
)
|
| 377 |
+
history.append(g)
|
| 378 |
+
if over_abs or over_rel:
|
| 379 |
+
self.fired = True
|
| 380 |
+
logger.error(
|
| 381 |
+
"TRIPWIRE (HALT): grad_norm %.1f exceeds %s (trailing median %.2f). %s",
|
| 382 |
+
g,
|
| 383 |
+
f"absolute ceiling {self.absolute_ceiling}"
|
| 384 |
+
if over_abs
|
| 385 |
+
else f"{self.relative_factor}x trailing median",
|
| 386 |
+
med,
|
| 387 |
+
self.PRE_REGISTERED_RESPONSE,
|
| 388 |
+
)
|
| 389 |
+
return TripwireResult(True, self.PRE_REGISTERED_RESPONSE, value=g)
|
| 390 |
+
return TripwireResult(False, "", value=g)
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
class LengthDriftTripwire:
|
| 394 |
+
"""Fires when the chosen-longer fraction leaves a band around the measured
|
| 395 |
+
baseline for the arm -- but only once it has seen enough pairs to mean it.
|
| 396 |
+
|
| 397 |
+
THREE DECLARED STATES, and keeping them distinct is the whole design:
|
| 398 |
+
|
| 399 |
+
* BLIND -- input absent or NaN. The guard cannot see AND cannot say
|
| 400 |
+
so. Forbidden; `assert_tripwires_armed` halts the run.
|
| 401 |
+
* WARMING UP -- inputs present and finite, cumulative pairs below
|
| 402 |
+
`min_pairs`. The guard is watching and honestly reports
|
| 403 |
+
insufficient sample. Valid; the self-test accepts it.
|
| 404 |
+
* WATCHING -- armed and evaluating.
|
| 405 |
+
|
| 406 |
+
Why `min_pairs` exists (sec09's measurement): at micro_batch 2 the
|
| 407 |
+
per-batch chosen-longer fraction can only take the values {0, 0.5, 1}, two
|
| 408 |
+
of which sit outside a +-0.10 band around 0.549 -- so the alarm fired on
|
| 409 |
+
78% of batches while measuring nothing but the batch size. Their rolling
|
| 410 |
+
window fixes the estimator; this threshold fixes when it may speak. From
|
| 411 |
+
their standard-error table: 512 pairs gives 5.4e-06 false-fire per check,
|
| 412 |
+
256 gives roughly 4 spurious alarms per Arm-B run. An alarm that fires
|
| 413 |
+
four times a run is an alarm nobody reads the fifth time.
|
| 414 |
+
|
| 415 |
+
The baseline is a config field, never a constant here: Arm A measured
|
| 416 |
+
0.549 and Arm B's is different, so a hardcoded threshold would mis-fire on
|
| 417 |
+
one of them permanently.
|
| 418 |
+
"""
|
| 419 |
+
|
| 420 |
+
INPUT_KEYS: tuple[str, ...] = ("chosen_longer_fraction", "pairs")
|
| 421 |
+
|
| 422 |
+
RESPONSE = (
|
| 423 |
+
"length bias suspected: report length-controlled win-rate "
|
| 424 |
+
"alongside the raw metric and record the drift band that fired"
|
| 425 |
+
)
|
| 426 |
+
|
| 427 |
+
def __init__(self, baseline: float, band: float = 0.10, min_pairs: int = 512):
|
| 428 |
+
if math.isnan(baseline):
|
| 429 |
+
raise ValueError(
|
| 430 |
+
"LengthDriftTripwire needs a measured baseline; NaN means "
|
| 431 |
+
"nobody measured the arm and the check would be meaningless"
|
| 432 |
+
)
|
| 433 |
+
self.baseline = float(baseline)
|
| 434 |
+
self.band = float(band)
|
| 435 |
+
self.min_pairs = int(min_pairs)
|
| 436 |
+
self._pairs_seen = 0
|
| 437 |
+
|
| 438 |
+
def state_dict(self) -> dict:
|
| 439 |
+
return {"pairs_seen": int(self._pairs_seen)}
|
| 440 |
+
|
| 441 |
+
def load_state_dict(self, sd: dict) -> None:
|
| 442 |
+
self._pairs_seen = int(sd["pairs_seen"])
|
| 443 |
+
|
| 444 |
+
def state(self) -> str:
|
| 445 |
+
"""`warming_up` until the cumulative sample reaches min_pairs, then
|
| 446 |
+
`watching`. Reported in the manifest so 'armed' is never ambiguous."""
|
| 447 |
+
return "watching" if self._pairs_seen >= self.min_pairs else "warming_up"
|
| 448 |
+
|
| 449 |
+
def update(self, chosen_longer_fraction: float, n_pairs: int = 1) -> TripwireResult:
|
| 450 |
+
self._pairs_seen += int(n_pairs)
|
| 451 |
+
v = float(chosen_longer_fraction)
|
| 452 |
+
if math.isnan(v):
|
| 453 |
+
# No measurement is not a drift and not an all-clear.
|
| 454 |
+
return TripwireResult(False, "", value=NAN, detail="chosen_longer_fraction undefined")
|
| 455 |
+
if self._pairs_seen < self.min_pairs:
|
| 456 |
+
return TripwireResult(
|
| 457 |
+
False, "", value=v, detail=f"warming up: {self._pairs_seen}/{self.min_pairs} pairs"
|
| 458 |
+
)
|
| 459 |
+
|
| 460 |
+
tripped = abs(v - self.baseline) > self.band
|
| 461 |
+
if tripped:
|
| 462 |
+
logger.error(
|
| 463 |
+
"TRIPWIRE: chosen-longer fraction %.3f is outside %.3f +- %.3f over %d pairs",
|
| 464 |
+
v,
|
| 465 |
+
self.baseline,
|
| 466 |
+
self.band,
|
| 467 |
+
self._pairs_seen,
|
| 468 |
+
)
|
| 469 |
+
return TripwireResult(
|
| 470 |
+
tripped,
|
| 471 |
+
self.RESPONSE if tripped else "",
|
| 472 |
+
value=v,
|
| 473 |
+
detail=f"watching ({self._pairs_seen} pairs)",
|
| 474 |
+
)
|
| 475 |
+
|
| 476 |
+
|
| 477 |
+
def assert_tripwires_armed(metrics: dict, tripwires: dict) -> None:
|
| 478 |
+
"""Every ARMED tripwire's inputs must be present and finite, or halt.
|
| 479 |
+
|
| 480 |
+
THE DISARMED-TRIPWIRE INCIDENT. section-05's kernel emits the implicit
|
| 481 |
+
margin as `rewards/margins`; section-07's Trainer seeded `margin` with NaN
|
| 482 |
+
and the tripwire read that. Nothing produced the key, `_slope` returns NaN
|
| 483 |
+
for any window containing one, and the trip condition requires a non-NaN
|
| 484 |
+
slope -- so the chosen-likelihood-collapse tripwire could never fire while
|
| 485 |
+
appearing fully wired: constructed, updated every log step, and listed as
|
| 486 |
+
armed in every manifest. A guard that cannot fire is worse than no guard,
|
| 487 |
+
because it is also a claim that the failure mode is being watched.
|
| 488 |
+
|
| 489 |
+
TWO DIFFERENT CONTRACTS, and conflating them is what caused this:
|
| 490 |
+
|
| 491 |
+
* A REPORTED statistic that is undefined must be NaN. `0.0` would read
|
| 492 |
+
as a real measurement ("no preference learned"), which is a finding
|
| 493 |
+
rather than a missing number.
|
| 494 |
+
* A tripwire INPUT must never be NaN-seeded. An armed guard reading NaN
|
| 495 |
+
is running blind, and blind is indistinguishable from healthy in every
|
| 496 |
+
log it writes.
|
| 497 |
+
|
| 498 |
+
Run at the first log step of any stage with armed tripwires, so metric-name
|
| 499 |
+
drift becomes a launch failure instead of a silent disarming.
|
| 500 |
+
|
| 501 |
+
A tripwire reporting `warming_up` PASSES this check. Its inputs are
|
| 502 |
+
present and finite; it simply has not accumulated enough sample to speak
|
| 503 |
+
yet, and it says so. Treating that as a failure would halt every DPO run
|
| 504 |
+
on its first batch, and the fix for THAT would be to delete the self-test
|
| 505 |
+
-- which is exactly how the original disarmed tripwire came about.
|
| 506 |
+
"""
|
| 507 |
+
problems = []
|
| 508 |
+
for name, wire in tripwires.items():
|
| 509 |
+
for key in getattr(wire, "INPUT_KEYS", ()):
|
| 510 |
+
if key not in metrics:
|
| 511 |
+
problems.append(
|
| 512 |
+
f"{name}: input {key!r} is ABSENT from the logged metrics "
|
| 513 |
+
f"(present: {sorted(metrics)[:8]})"
|
| 514 |
+
)
|
| 515 |
+
elif (
|
| 516 |
+
isinstance(metrics[key], float)
|
| 517 |
+
and math.isnan(metrics[key])
|
| 518 |
+
and not getattr(wire, "TOLERATES_NAN_INPUT", False)
|
| 519 |
+
):
|
| 520 |
+
problems.append(f"{name}: input {key!r} is NaN -- the guard would run blind")
|
| 521 |
+
if problems:
|
| 522 |
+
raise RuntimeError(
|
| 523 |
+
"ARMED TRIPWIRE SELF-TEST FAILED -- halting before training "
|
| 524 |
+
"proceeds:\n - "
|
| 525 |
+
+ "\n - ".join(problems)
|
| 526 |
+
+ "\n\nAn armed tripwire whose input is missing or NaN cannot "
|
| 527 |
+
"fire, and will report itself armed in every manifest anyway. "
|
| 528 |
+
"Either supply the metric (the loss adapter owns this) or do not "
|
| 529 |
+
"arm the tripwire for this stage. Canonical margin key: "
|
| 530 |
+
"'rewards/margins' -- the name section-05's kernel emits."
|
| 531 |
+
)
|
| 532 |
+
|
| 533 |
+
|
| 534 |
+
# ---------------------------------------------------------------------------
|
| 535 |
+
# throughput probe + RSS projection
|
| 536 |
+
# ---------------------------------------------------------------------------
|
| 537 |
+
|
| 538 |
+
|
| 539 |
+
def _percentile(sorted_vals: Sequence[float], q: float) -> float:
|
| 540 |
+
if not sorted_vals:
|
| 541 |
+
return NAN
|
| 542 |
+
idx = min(len(sorted_vals) - 1, max(0, int(round(q * (len(sorted_vals) - 1)))))
|
| 543 |
+
return sorted_vals[idx]
|
| 544 |
+
|
| 545 |
+
|
| 546 |
+
def aggregate_micro_step_times(micro_times_s: Sequence[float], grad_accum: int) -> list[float]:
|
| 547 |
+
"""Sum consecutive micro-step durations into OPTIMIZER-step durations.
|
| 548 |
+
|
| 549 |
+
The unit bug this exists to kill (task #25): both probes measure
|
| 550 |
+
per-MICRO durations, while `throughput_report` takes per-OPTIMIZER token
|
| 551 |
+
counts and horizon. Feeding micro times straight in under-projects
|
| 552 |
+
wall-clock and over-reports tokens/s by ~grad_accum x (8x on the DPO
|
| 553 |
+
config) -- a banner that costs GPU-hours wrong by an order of magnitude.
|
| 554 |
+
|
| 555 |
+
Only COMPLETE groups are kept: a trailing partial group would read as one
|
| 556 |
+
impossibly fast optimizer step. Raises when no complete group exists --
|
| 557 |
+
same contract as throughput_report itself (resolved or refused, never
|
| 558 |
+
quietly wrong); size probe_steps >= grad_accum at the config level.
|
| 559 |
+
"""
|
| 560 |
+
if grad_accum <= 0:
|
| 561 |
+
raise ValueError(f"grad_accum={grad_accum} is not a schedule")
|
| 562 |
+
times = [float(t) for t in micro_times_s]
|
| 563 |
+
groups = [
|
| 564 |
+
sum(times[i : i + grad_accum]) for i in range(0, len(times) - grad_accum + 1, grad_accum)
|
| 565 |
+
]
|
| 566 |
+
if not groups:
|
| 567 |
+
raise ValueError(
|
| 568 |
+
f"{len(times)} micro-step times cannot fill one optimizer step at "
|
| 569 |
+
f"grad_accum={grad_accum}; raise probe_steps to at least grad_accum"
|
| 570 |
+
)
|
| 571 |
+
return groups
|
| 572 |
+
|
| 573 |
+
|
| 574 |
+
def throughput_report(
|
| 575 |
+
step_times_s: Sequence[float],
|
| 576 |
+
tokens_per_step: int,
|
| 577 |
+
optimizer_steps_planned: int,
|
| 578 |
+
offload_transfer_s: Optional[float] = None,
|
| 579 |
+
offload_cpu_step_s: Optional[float] = None,
|
| 580 |
+
) -> dict:
|
| 581 |
+
"""Turn measured step times into the banner's throughput entry.
|
| 582 |
+
|
| 583 |
+
Refuses to produce a partial entry: a stage that starts without knowing its
|
| 584 |
+
step time is a stage committing GPU-hours to a schedule nobody costed. When
|
| 585 |
+
offload is inactive the offload timings are NaN -- explicitly, because a
|
| 586 |
+
0.0 there would read as 'offload is free'.
|
| 587 |
+
"""
|
| 588 |
+
times = [float(t) for t in step_times_s]
|
| 589 |
+
if not times:
|
| 590 |
+
raise ValueError(
|
| 591 |
+
"throughput_report got no step times. A probe that cannot fill "
|
| 592 |
+
"its banner keys must raise: the banner's contract is that every "
|
| 593 |
+
"value is resolved or explicitly NaN, never quietly absent."
|
| 594 |
+
)
|
| 595 |
+
if optimizer_steps_planned <= 0:
|
| 596 |
+
raise ValueError(
|
| 597 |
+
f"optimizer_steps_planned={optimizer_steps_planned} is not a "
|
| 598 |
+
"schedule; the projected wall-clock would be meaningless"
|
| 599 |
+
)
|
| 600 |
+
|
| 601 |
+
mean = sum(times) / len(times)
|
| 602 |
+
ordered = sorted(times)
|
| 603 |
+
return {
|
| 604 |
+
"n_steps": len(times),
|
| 605 |
+
"mean_step_time_s": mean,
|
| 606 |
+
"p50_step_time_s": _percentile(ordered, 0.50),
|
| 607 |
+
"p90_step_time_s": _percentile(ordered, 0.90),
|
| 608 |
+
"tokens_per_s": tokens_per_step / mean if mean > 0 else NAN,
|
| 609 |
+
"projected_epoch_s": optimizer_steps_planned * mean,
|
| 610 |
+
"offload_transfer_s": (NAN if offload_transfer_s is None else float(offload_transfer_s)),
|
| 611 |
+
"offload_cpu_step_s": (NAN if offload_cpu_step_s is None else float(offload_cpu_step_s)),
|
| 612 |
+
}
|
| 613 |
+
|
| 614 |
+
|
| 615 |
+
def projected_job_rss(cfg) -> dict:
|
| 616 |
+
"""Projected host RSS for this job, for the guard and the launch banner.
|
| 617 |
+
|
| 618 |
+
Delegates to `guard.projected_full_ft_rss_gb` rather than restating the
|
| 619 |
+
arithmetic. Two formulas for the same budget WILL drift, and then the
|
| 620 |
+
banner advertises a number the preflight is not enforcing -- while the
|
| 621 |
+
preflight is the only thing standing between this job and the 2026-08-12
|
| 622 |
+
host crash (2GB free, load 619, no swap, a healthy run killed by an NCCL
|
| 623 |
+
timeout five hours into a stalled checkpoint).
|
| 624 |
+
"""
|
| 625 |
+
from ..utils.guard import projected_full_ft_rss_gb
|
| 626 |
+
|
| 627 |
+
return projected_full_ft_rss_gb(cfg)
|
code/align/train/quant8.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Block-wise 8-bit dynamic quantization, in plain torch.
|
| 2 |
+
|
| 3 |
+
This is the numeric core of `adamw8bit.py` and carries NO optimizer logic, so
|
| 4 |
+
the codebook and the quantize/dequantize round trip can be tested (and
|
| 5 |
+
mis-implemented) on their own. The scheme is Dettmers et al. 2022, "8-bit
|
| 6 |
+
Optimizers via Block-wise Quantization" (arXiv:2110.02861), rebuilt from the
|
| 7 |
+
paper's description with torch only (docs/DEPENDENCY_POLICY.md: bitsandbytes
|
| 8 |
+
is not on the allowed list).
|
| 9 |
+
|
| 10 |
+
WHY BLOCK-WISE: one absmax for a whole 134M-element embedding shard lets a
|
| 11 |
+
single outlier set the scale for every element, and the other 134M-1 values
|
| 12 |
+
land in the bottom few codes. Normalising each block of `block_size` (default
|
| 13 |
+
2048) elements by its OWN absmax confines an outlier's damage to its block and
|
| 14 |
+
makes the quantization independent of how the tensor happens to be sharded
|
| 15 |
+
across ranks -- which matters, because under FSDP2 the blocks are cut from the
|
| 16 |
+
rank-LOCAL shard.
|
| 17 |
+
|
| 18 |
+
WHY A DYNAMIC (NON-LINEAR) CODEBOOK: Adam's moments span many decades inside
|
| 19 |
+
one block (the second moment is a square of a heavy-tailed gradient). A
|
| 20 |
+
linear 8-bit grid has uniform ABSOLUTE resolution, so everything below
|
| 21 |
+
~absmax/256 collapses to zero -- and a zero second moment makes the update
|
| 22 |
+
`m / (sqrt(0) + eps)`, i.e. m / 1e-8. The dynamic map spends its 256 codes as
|
| 23 |
+
an exponent (a power of ten, 7 of them) plus a linear fraction inside each
|
| 24 |
+
decade: relative resolution of a few percent from absmax down to ~1e-6*absmax.
|
| 25 |
+
|
| 26 |
+
THE ONE THING THIS MODULE DOES NOT DO is decide when quantization is safe:
|
| 27 |
+
the caller keeps tensors below `min_8bit_size` in fp32 (small tensors such as
|
| 28 |
+
norm scales are both cheap to keep and the most sensitive to rounding).
|
| 29 |
+
"""
|
| 30 |
+
|
| 31 |
+
from __future__ import annotations
|
| 32 |
+
|
| 33 |
+
import functools
|
| 34 |
+
|
| 35 |
+
import torch
|
| 36 |
+
|
| 37 |
+
#: 8-bit codes. Not configurable: the code type is `uint8` and the lookup
|
| 38 |
+
#: arithmetic assumes exactly 256 entries.
|
| 39 |
+
N_CODES = 256
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def create_dynamic_map(signed: bool = True, max_exponent_bits: int = 7, total_bits: int = 8):
|
| 43 |
+
"""The sorted 256-entry dynamic codebook, float32, built like bnb's
|
| 44 |
+
`create_dynamic_map` (the construction the paper describes).
|
| 45 |
+
|
| 46 |
+
Layout: for each exponent `i` in `0..max_exponent_bits-1` the decade
|
| 47 |
+
`10**(i - (max_exponent_bits-1)) * [0.1, 1]` is cut into linear fraction
|
| 48 |
+
bins and each bin contributes its MIDPOINT (so no code sits on a decade
|
| 49 |
+
edge and codes are strictly increasing). Decade `i` gets `2**i` fraction
|
| 50 |
+
values when signed and `2**(i+1)` when unsigned -- the unsigned map spends
|
| 51 |
+
the sign bit on one more bit of fraction. Exact `0.0` and `1.0` are then
|
| 52 |
+
appended. Counting, signed: 2 * (1+2+...+64) + 2 = 256; unsigned:
|
| 53 |
+
(2+4+...+128) + 2 = 256. Both assertions below fire if someone changes a
|
| 54 |
+
parameter without redoing the arithmetic.
|
| 55 |
+
|
| 56 |
+
Signed map range: [-0.99297, 1.0] (there is no exact -1.0; the most
|
| 57 |
+
negative code is the midpoint of the top bin, 0.7% from -1). That is the
|
| 58 |
+
same asymmetry bnb has, and it is bounded: the block's largest-magnitude
|
| 59 |
+
element, if negative, is stored within 0.7%.
|
| 60 |
+
Unsigned map range: [0.0, 1.0].
|
| 61 |
+
|
| 62 |
+
Built in float64 and cast once, so the table is bit-identical across
|
| 63 |
+
machines and torch versions (linspace/mean in fp32 is not guaranteed to be).
|
| 64 |
+
"""
|
| 65 |
+
if total_bits != 8:
|
| 66 |
+
raise ValueError("create_dynamic_map builds 256-entry (8-bit) maps only")
|
| 67 |
+
non_sign_bits = total_bits - 1
|
| 68 |
+
if not 1 <= max_exponent_bits <= non_sign_bits:
|
| 69 |
+
raise ValueError(f"max_exponent_bits must be in [1, {non_sign_bits}]")
|
| 70 |
+
additional_items = 2 ** (non_sign_bits - max_exponent_bits) - 1
|
| 71 |
+
|
| 72 |
+
data: list[float] = []
|
| 73 |
+
for i in range(max_exponent_bits):
|
| 74 |
+
if signed:
|
| 75 |
+
fraction_items = 2 ** (i + non_sign_bits - max_exponent_bits) + 1
|
| 76 |
+
else:
|
| 77 |
+
fraction_items = 2 ** (i + non_sign_bits - max_exponent_bits + 1) + 1
|
| 78 |
+
boundaries = torch.linspace(0.1, 1.0, fraction_items, dtype=torch.float64)
|
| 79 |
+
means = (boundaries[:-1] + boundaries[1:]) / 2.0
|
| 80 |
+
scale = 10.0 ** (-(max_exponent_bits - 1) + i)
|
| 81 |
+
data += (scale * means).tolist()
|
| 82 |
+
if signed:
|
| 83 |
+
data += (-scale * means).tolist()
|
| 84 |
+
if additional_items > 0:
|
| 85 |
+
boundaries = torch.linspace(0.1, 1.0, additional_items + 1, dtype=torch.float64)
|
| 86 |
+
means = (boundaries[:-1] + boundaries[1:]) / 2.0
|
| 87 |
+
data += means.tolist()
|
| 88 |
+
if signed:
|
| 89 |
+
data += (-means).tolist()
|
| 90 |
+
data.append(0.0)
|
| 91 |
+
data.append(1.0)
|
| 92 |
+
data.sort()
|
| 93 |
+
if len(data) != N_CODES:
|
| 94 |
+
raise AssertionError(f"dynamic map has {len(data)} entries, expected {N_CODES}")
|
| 95 |
+
out = torch.tensor(data, dtype=torch.float64).to(torch.float32)
|
| 96 |
+
if not bool((out[1:] > out[:-1]).all()):
|
| 97 |
+
raise AssertionError("dynamic map is not strictly increasing in float32")
|
| 98 |
+
return out
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
@functools.lru_cache(maxsize=None)
|
| 102 |
+
def _cached_map(signed: bool) -> torch.Tensor:
|
| 103 |
+
return create_dynamic_map(signed=signed)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
_DEVICE_CACHE: dict = {}
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def codebook(signed: bool, device) -> torch.Tensor:
|
| 110 |
+
"""The shared codebook on `device` (cached; never mutate the result)."""
|
| 111 |
+
device = torch.device(device)
|
| 112 |
+
key = (bool(signed), device.type, device.index)
|
| 113 |
+
cb = _DEVICE_CACHE.get(key)
|
| 114 |
+
if cb is None:
|
| 115 |
+
cb = _cached_map(bool(signed)).to(device)
|
| 116 |
+
_DEVICE_CACHE[key] = cb
|
| 117 |
+
return cb
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def zero_code(signed: bool) -> int:
|
| 121 |
+
"""Index of the exact-0.0 code (the all-zero state initialises to it)."""
|
| 122 |
+
return int((_cached_map(bool(signed)) == 0.0).nonzero()[0])
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def nearest_code(x: torch.Tensor, cb: torch.Tensor) -> torch.Tensor:
|
| 126 |
+
"""Nearest-code index of every element of `x` (any shape), as uint8.
|
| 127 |
+
|
| 128 |
+
`searchsorted` gives, for each x, the first index `hi` with cb[hi] >= x; the
|
| 129 |
+
nearest code is then either `hi` or `hi-1`, decided by comparing x against
|
| 130 |
+
their MIDPOINT (ties go to the lower code). Clamping `hi` to [1, 255]
|
| 131 |
+
first makes both candidates always exist, and values outside the table
|
| 132 |
+
(x <= cb[0] or x >= cb[-1]) resolve to the end codes by the same
|
| 133 |
+
comparison -- no special-casing, so no off-by-one at the edges.
|
| 134 |
+
|
| 135 |
+
`out_int32=True`: the index tensor is the largest transient in a chunked
|
| 136 |
+
step, and int32 halves it. (The final uint8 cast is what is stored.)
|
| 137 |
+
"""
|
| 138 |
+
flat = x.reshape(-1).contiguous()
|
| 139 |
+
hi = torch.searchsorted(cb, flat, out_int32=True).clamp_(1, N_CODES - 1)
|
| 140 |
+
lo = hi - 1
|
| 141 |
+
mid = (cb[lo] + cb[hi]) * 0.5
|
| 142 |
+
pick_hi = flat > mid
|
| 143 |
+
return torch.where(pick_hi, hi, lo).to(torch.uint8).reshape(x.shape)
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def quantize_rows(x: torch.Tensor, cb: torch.Tensor):
|
| 147 |
+
"""Quantize a `[n_blocks, block_size]` fp32 matrix, one block per row.
|
| 148 |
+
|
| 149 |
+
Returns `(codes uint8 [n_blocks, block_size], absmax fp32 [n_blocks])`.
|
| 150 |
+
An all-zero block has absmax 0; dividing by it would put NaN through the
|
| 151 |
+
lookup, so the divisor is replaced by 1 there (the block's values are all
|
| 152 |
+
0, which then quantize to the zero code and dequantize to 0 * absmax = 0).
|
| 153 |
+
"""
|
| 154 |
+
absmax = x.abs().amax(dim=1)
|
| 155 |
+
safe = torch.where(absmax > 0, absmax, torch.ones_like(absmax))
|
| 156 |
+
normalized = x / safe.unsqueeze(1)
|
| 157 |
+
return nearest_code(normalized, cb), absmax
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def dequantize_rows(codes: torch.Tensor, absmax: torch.Tensor, cb: torch.Tensor) -> torch.Tensor:
|
| 161 |
+
"""Inverse of `quantize_rows`: fp32 `[n_blocks, block_size]`.
|
| 162 |
+
|
| 163 |
+
The index MUST be a signed integer tensor: `cb[codes]` with a uint8 `codes`
|
| 164 |
+
is interpreted by torch as a (deprecated) boolean-mask index, not a gather.
|
| 165 |
+
`index_select` with an int32 index has no such reading.
|
| 166 |
+
"""
|
| 167 |
+
n_blocks, block_size = codes.shape
|
| 168 |
+
values = cb.index_select(0, codes.reshape(-1).to(torch.int32)).reshape(n_blocks, block_size)
|
| 169 |
+
return values * absmax.unsqueeze(1)
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def quantize_blockwise(x: torch.Tensor, block_size: int = 2048, signed: bool = True):
|
| 173 |
+
"""Quantize any tensor (flattened) -> `(codes uint8 [n_blocks*block_size], absmax)`.
|
| 174 |
+
|
| 175 |
+
The final partial block is zero-padded; the pad quantizes to the zero code
|
| 176 |
+
and is dropped by `dequantize_blockwise(..., numel=)`.
|
| 177 |
+
"""
|
| 178 |
+
flat = x.detach().reshape(-1).to(torch.float32)
|
| 179 |
+
n = flat.numel()
|
| 180 |
+
n_blocks = max(1, -(-n // block_size))
|
| 181 |
+
padded = torch.zeros(n_blocks * block_size, dtype=torch.float32, device=flat.device)
|
| 182 |
+
padded[:n] = flat
|
| 183 |
+
codes, absmax = quantize_rows(padded.view(n_blocks, block_size), codebook(signed, flat.device))
|
| 184 |
+
return codes.reshape(-1), absmax
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def dequantize_blockwise(
|
| 188 |
+
codes: torch.Tensor, absmax: torch.Tensor, numel: int, block_size: int = 2048, signed: bool = True
|
| 189 |
+
) -> torch.Tensor:
|
| 190 |
+
"""Inverse of `quantize_blockwise`, trimmed to `numel` elements (fp32, flat)."""
|
| 191 |
+
rows = codes.reshape(-1, block_size)
|
| 192 |
+
out = dequantize_rows(rows, absmax, codebook(signed, codes.device))
|
| 193 |
+
return out.reshape(-1)[:numel]
|
code/align/train/qwen3_stage.py
ADDED
|
@@ -0,0 +1,466 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Qwen3 (Goedel-Prover-V2-8B) SFT stage pieces: model build, LoRA train module, weight format.
|
| 2 |
+
|
| 3 |
+
`scripts/sft.py` dispatches here when `model.arch == "qwen3"`; the dense-Mistral path never
|
| 4 |
+
imports this module. Everything the Trainer needs that is family-specific lives behind the
|
| 5 |
+
two seams the Trainer already has: `weight_format` (what a checkpoint's weight file is) and
|
| 6 |
+
`train_module` (what touches the model).
|
| 7 |
+
|
| 8 |
+
THE LoRA CHECKPOINT CONTRACT (same as `PPOTrainModule`, for the same reasons):
|
| 9 |
+
|
| 10 |
+
* The weight file of every checkpoint is the MERGED bf16 model (base + scaling * B @ A,
|
| 11 |
+
accumulated in fp32, cast once). Checkpoint saves are how vLLM / transformers / the
|
| 12 |
+
rollout engine receive the policy, and they load HF base weights, not adapters.
|
| 13 |
+
* The adapter itself (fp32, exact) rides in the optimizer payload, so a resume is lossless:
|
| 14 |
+
base is reloaded from the pinned snapshot at launch, the adapter and the 8-bit moments
|
| 15 |
+
come back from `optimizer_rank{r}.pt`.
|
| 16 |
+
* `load_weights` on a LoRA resume reads only the safetensors HEADERS (key set + shapes, on
|
| 17 |
+
the meta device). Materialising the merged 16 GB on each of four ranks would cost 64 GB of
|
| 18 |
+
host RAM to verify a key set and then be thrown away -- the 2026-08-12 shape.
|
| 19 |
+
|
| 20 |
+
FSDP2 + frozen base. Per-block `fully_shard` shards the frozen bf16 base (16.4 GB / world) and
|
| 21 |
+
the fp32 adapters together; only adapter gradients are reduce-scattered. Activation
|
| 22 |
+
checkpointing wraps each block. The root unit also owns embed_tokens / lm_head / final norm.
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
import gc
|
| 28 |
+
import logging
|
| 29 |
+
import os
|
| 30 |
+
from typing import Any, Optional
|
| 31 |
+
|
| 32 |
+
import torch
|
| 33 |
+
|
| 34 |
+
from .ckpt_format import WeightFormat # noqa: F401 (the Protocol this implements)
|
| 35 |
+
from .train_module import TrainModule
|
| 36 |
+
|
| 37 |
+
logger = logging.getLogger(__name__)
|
| 38 |
+
|
| 39 |
+
SUPPORTED_ARCHS = ("mistral", "qwen3")
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class Qwen3StageError(RuntimeError):
|
| 43 |
+
"""A qwen3 stage config that cannot be launched as written."""
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
# ---------------------------------------------------------------------------
|
| 47 |
+
# Config validation
|
| 48 |
+
# ---------------------------------------------------------------------------
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def validate_qwen3_cfg(cfg, qcfg) -> None:
|
| 52 |
+
"""Refuse launches whose declared numbers disagree with the snapshot they will load.
|
| 53 |
+
|
| 54 |
+
Derived-vs-hardcoded: `model.n_params` feeds the RSS projection and the banner; the
|
| 55 |
+
architecture truth is the snapshot's config.json. A TOML that still carries the Mistral
|
| 56 |
+
defaults (7.25B / 32 layers) would project the wrong host footprint and print a banner
|
| 57 |
+
describing a different model.
|
| 58 |
+
"""
|
| 59 |
+
from ..model.qwen3 import count_params
|
| 60 |
+
|
| 61 |
+
n = count_params(qcfg)
|
| 62 |
+
problems = []
|
| 63 |
+
if cfg.model.n_params != n:
|
| 64 |
+
problems.append(f"model.n_params={cfg.model.n_params} but the snapshot implies {n}")
|
| 65 |
+
if cfg.model.n_layers != qcfg.n_layers:
|
| 66 |
+
problems.append(f"model.n_layers={cfg.model.n_layers} but the snapshot has {qcfg.n_layers}")
|
| 67 |
+
if cfg.model.vocab_size != qcfg.vocab_size:
|
| 68 |
+
problems.append(f"model.vocab_size={cfg.model.vocab_size} but the snapshot has {qcfg.vocab_size}")
|
| 69 |
+
if cfg.model.hidden_size != qcfg.hidden_size:
|
| 70 |
+
problems.append(f"model.hidden_size={cfg.model.hidden_size} but the snapshot has {qcfg.hidden_size}")
|
| 71 |
+
if cfg.model.precision != "bf16":
|
| 72 |
+
problems.append(f"model.precision={cfg.model.precision!r}: bf16 only")
|
| 73 |
+
if problems:
|
| 74 |
+
raise Qwen3StageError("qwen3 stage config disagrees with its snapshot: " + "; ".join(problems))
|
| 75 |
+
lo = cfg.lora
|
| 76 |
+
if lo.enabled:
|
| 77 |
+
if lo.dropout != 0.0:
|
| 78 |
+
raise Qwen3StageError(
|
| 79 |
+
f"lora.dropout={lo.dropout}: align.ppo.lora has no dropout path (train-time and "
|
| 80 |
+
"score-time logprobs would differ); set 0.0"
|
| 81 |
+
)
|
| 82 |
+
if cfg.train.full_ft:
|
| 83 |
+
raise Qwen3StageError("lora.enabled with train.full_ft=true: LoRA freezes the base; set train.full_ft=false")
|
| 84 |
+
elif not cfg.train.full_ft:
|
| 85 |
+
raise Qwen3StageError("train.full_ft=false requires lora.enabled=true (otherwise nothing trains)")
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def resolve_qwen3(cfg):
|
| 89 |
+
"""(snapshot_dir, Qwen3Config) from `paths.model_snapshot`, with the attention request applied."""
|
| 90 |
+
from ..model import checkpoint as ckpt
|
| 91 |
+
from ..model.qwen3_checkpoint import read_qwen3_config
|
| 92 |
+
|
| 93 |
+
snapshot = ckpt.resolve_snapshot_dir(cfg.paths.model_snapshot)
|
| 94 |
+
qcfg = read_qwen3_config(snapshot).replace(attn_implementation=cfg.model.attn_implementation)
|
| 95 |
+
validate_qwen3_cfg(cfg, qcfg)
|
| 96 |
+
return snapshot, qcfg
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
# ---------------------------------------------------------------------------
|
| 100 |
+
# Weight format
|
| 101 |
+
# ---------------------------------------------------------------------------
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
class Qwen3Format:
|
| 105 |
+
"""`WeightFormat` for Qwen3: sharded HF safetensors + config.json + tokenizer files."""
|
| 106 |
+
|
| 107 |
+
def __init__(self, qcfg, tokenizer_dir: str, lora: bool):
|
| 108 |
+
self.qcfg = qcfg
|
| 109 |
+
self.tokenizer_dir = tokenizer_dir
|
| 110 |
+
self.lora = bool(lora)
|
| 111 |
+
|
| 112 |
+
def save_weights(self, state, out_dir, dtype):
|
| 113 |
+
from ..model.qwen3_checkpoint import save_hf_qwen3
|
| 114 |
+
|
| 115 |
+
save_hf_qwen3(dict(state), out_dir, cfg=self.qcfg, dtype=dtype)
|
| 116 |
+
|
| 117 |
+
def load_weights(self, path):
|
| 118 |
+
from ..model.checkpoint import load_hf_safetensors
|
| 119 |
+
from ..model.qwen3_checkpoint import hf_to_align
|
| 120 |
+
|
| 121 |
+
if self.lora:
|
| 122 |
+
return hf_to_align(_hf_headers_on_meta(path), self.qcfg)
|
| 123 |
+
return hf_to_align(load_hf_safetensors(path), self.qcfg)
|
| 124 |
+
|
| 125 |
+
def copy_tokenizer(self, snapshot, dst_dir): # noqa: ARG002 -- the configured dir is authoritative
|
| 126 |
+
from ..model.qwen3_checkpoint import copy_tokenizer_files
|
| 127 |
+
|
| 128 |
+
return copy_tokenizer_files(self.tokenizer_dir, dst_dir)
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def _hf_headers_on_meta(path: str) -> dict:
|
| 132 |
+
"""{hf key: empty meta tensor of the stored shape/dtype} for a checkpoint dir. Reads the
|
| 133 |
+
safetensors headers only -- no tensor data is touched."""
|
| 134 |
+
import json
|
| 135 |
+
|
| 136 |
+
from safetensors import safe_open
|
| 137 |
+
|
| 138 |
+
from ..model.checkpoint import INDEX_NAME, WEIGHTS_NAME
|
| 139 |
+
|
| 140 |
+
idx = os.path.join(path, INDEX_NAME)
|
| 141 |
+
if os.path.isfile(idx):
|
| 142 |
+
with open(idx) as f:
|
| 143 |
+
files = sorted(set(json.load(f)["weight_map"].values()))
|
| 144 |
+
elif os.path.isfile(os.path.join(path, WEIGHTS_NAME)):
|
| 145 |
+
files = [WEIGHTS_NAME]
|
| 146 |
+
else:
|
| 147 |
+
raise FileNotFoundError(f"no {WEIGHTS_NAME} or {INDEX_NAME} at {path!r}")
|
| 148 |
+
out: dict[str, torch.Tensor] = {}
|
| 149 |
+
for fname in files:
|
| 150 |
+
with safe_open(os.path.join(path, fname), framework="pt") as f:
|
| 151 |
+
for k in f.keys():
|
| 152 |
+
sl = f.get_slice(k)
|
| 153 |
+
dt = _ST_DTYPES[sl.get_dtype()]
|
| 154 |
+
out[k] = torch.empty(sl.get_shape(), dtype=dt, device="meta")
|
| 155 |
+
return out
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
_ST_DTYPES = {"BF16": torch.bfloat16, "F32": torch.float32, "F16": torch.float16}
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
# ---------------------------------------------------------------------------
|
| 162 |
+
# Base identity (a LoRA resume must reattach the adapter to the base it was trained on)
|
| 163 |
+
# ---------------------------------------------------------------------------
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def base_fingerprint(state) -> str:
|
| 167 |
+
"""sha256 over the frozen base's identity: every non-adapter tensor's name/shape/dtype, every
|
| 168 |
+
1-D tensor in full (norm weights differ between fine-tunes of one architecture), and the first
|
| 169 |
+
and last 8 rows of every matrix (embeddings, lm_head and each projection). Cheap (~tens of MB
|
| 170 |
+
hashed for 8B) yet distinguishes two bases with the SAME key set and shapes -- the only
|
| 171 |
+
difference a LoRA resume, which never loads the checkpoint's weights, could otherwise miss.
|
| 172 |
+
Needs plain (unsharded, non-meta) tensors: a shard-local hash would differ per rank."""
|
| 173 |
+
import hashlib
|
| 174 |
+
|
| 175 |
+
h = hashlib.sha256()
|
| 176 |
+
for k in sorted(k for k in state if ".adapters." not in k):
|
| 177 |
+
t = state[k]
|
| 178 |
+
if hasattr(t, "to_local") or t.device.type == "meta":
|
| 179 |
+
raise ValueError(f"cannot fingerprint {k!r}: sharded or meta tensor")
|
| 180 |
+
t = t.detach()
|
| 181 |
+
h.update(f"{k}|{tuple(t.shape)}|{t.dtype}".encode())
|
| 182 |
+
parts = [t] if t.dim() <= 1 else [t[:8], t[-8:]]
|
| 183 |
+
for part in parts:
|
| 184 |
+
h.update(part.to("cpu").contiguous().reshape(-1).view(torch.uint8).numpy().tobytes())
|
| 185 |
+
return h.hexdigest()
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
# ---------------------------------------------------------------------------
|
| 189 |
+
# LoRA train module
|
| 190 |
+
# ---------------------------------------------------------------------------
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
class LoRASFTModule(TrainModule):
|
| 194 |
+
"""`TrainModule` whose checkpoints publish merged weights and persist the adapter."""
|
| 195 |
+
|
| 196 |
+
def __init__(self, model, loss_adapter, optimizer, grad_accum: int, adapter: str, weight_format=None):
|
| 197 |
+
super().__init__(model, loss_adapter, optimizer, grad_accum)
|
| 198 |
+
from ..ppo.lora import has_lora
|
| 199 |
+
|
| 200 |
+
if not has_lora(model):
|
| 201 |
+
raise Qwen3StageError("LoRASFTModule needs a model with injected LoRA layers")
|
| 202 |
+
self.adapter = adapter
|
| 203 |
+
if weight_format is not None:
|
| 204 |
+
self.weight_format = weight_format
|
| 205 |
+
# Identity of the frozen base this adapter trains on (recorded in the optimizer payload,
|
| 206 |
+
# checked on resume). `build_qwen3_stage_model` stamps it BEFORE the FSDP wrap, when the
|
| 207 |
+
# tensors are still whole; a directly built model is fingerprinted here if it can be.
|
| 208 |
+
fp = getattr(model, "_base_fingerprint", None)
|
| 209 |
+
if fp is None:
|
| 210 |
+
try:
|
| 211 |
+
fp = base_fingerprint(model.state_dict())
|
| 212 |
+
except ValueError as e:
|
| 213 |
+
logger.warning("LoRA base fingerprint unavailable (%s); a resume cannot verify its base", e)
|
| 214 |
+
self.base_fingerprint: Optional[str] = fp
|
| 215 |
+
|
| 216 |
+
def full_model_state_dict(self):
|
| 217 |
+
"""BASE-keyed weights with the adapter FOLDED IN (see the module docstring).
|
| 218 |
+
|
| 219 |
+
`get_model_state_dict(full_state_dict=True, cpu_offload=True)` returns the full dict on
|
| 220 |
+
RANK 0 ONLY; every other rank gets `{}`. The gather is the collective every rank
|
| 221 |
+
joins; merging an empty dict is a KeyError on ranks 1..N-1 at the FIRST save, which
|
| 222 |
+
strands rank 0 in the next collective until the watchdog kills the run.
|
| 223 |
+
"""
|
| 224 |
+
import torch.distributed as dist
|
| 225 |
+
|
| 226 |
+
from ..ppo.lora import export_merged_state_dict
|
| 227 |
+
|
| 228 |
+
sd = super().full_model_state_dict()
|
| 229 |
+
if not sd and dist.is_available() and dist.is_initialized() and dist.get_rank() != 0:
|
| 230 |
+
return sd
|
| 231 |
+
self.last_export_report: dict = {} # retained-delta fidelity of the bf16 cast (warned on when poor)
|
| 232 |
+
return export_merged_state_dict(self.model, sd, self.adapter, report=self.last_export_report)
|
| 233 |
+
|
| 234 |
+
def load_model_state_dict(self, nmo_state: dict) -> None:
|
| 235 |
+
"""LoRA resume: the checkpoint's weights are MERGED and are deliberately NOT loaded --
|
| 236 |
+
the frozen base was reloaded from the snapshot at launch, and loading merged weights
|
| 237 |
+
into it would double-count the delta once the adapter is restored from the optimizer
|
| 238 |
+
payload. The key set is still verified so a checkpoint of another architecture cannot
|
| 239 |
+
resume silently."""
|
| 240 |
+
base_keys = {k for k in self.model.state_dict() if ".adapters." not in k}
|
| 241 |
+
missing, unexpected = sorted(base_keys - set(nmo_state)), sorted(set(nmo_state) - base_keys)
|
| 242 |
+
if missing or unexpected:
|
| 243 |
+
raise KeyError(f"resume state mismatch: missing={missing[:5]} unexpected={unexpected[:5]}")
|
| 244 |
+
logger.info("LoRA resume: merged weights verified (%d tensors) and NOT loaded; adapter comes from the optimizer payload", len(base_keys))
|
| 245 |
+
|
| 246 |
+
def optim_state_dict(self) -> dict:
|
| 247 |
+
from ..ppo.lora import lora_state_dict
|
| 248 |
+
|
| 249 |
+
lora = lora_state_dict(self.model, self.adapter)
|
| 250 |
+
# The ZeRO-3 save defect, structurally: a sharded placeholder serialises as an EMPTY
|
| 251 |
+
# tensor and the file loads without error while being untrained.
|
| 252 |
+
empty = [k for k, v in lora.items() if v.numel() == 0]
|
| 253 |
+
if empty:
|
| 254 |
+
raise RuntimeError(
|
| 255 |
+
f"{len(empty)} adapter tensors are empty (e.g. {empty[:2]}): a sharded placeholder was "
|
| 256 |
+
"serialised instead of the gathered parameter. Refusing to write a checkpoint that "
|
| 257 |
+
"loads without error and is untrained."
|
| 258 |
+
)
|
| 259 |
+
return {"optimizer": self.optimizer.state_dict(), "lora": lora, "base_fingerprint": self.base_fingerprint}
|
| 260 |
+
|
| 261 |
+
def load_optim_state_dict(self, sd: dict) -> None:
|
| 262 |
+
from ..ppo.lora import load_lora_state_dict
|
| 263 |
+
|
| 264 |
+
if "lora" not in sd or "optimizer" not in sd:
|
| 265 |
+
raise KeyError(
|
| 266 |
+
"checkpoint optimizer payload lacks 'lora'/'optimizer'; resuming would restart the "
|
| 267 |
+
"policy from the bare base while claiming a resume"
|
| 268 |
+
)
|
| 269 |
+
# THE UNBOUND-ADAPTER DEFECT. The checkpoint's merged weights are not loaded, so nothing
|
| 270 |
+
# else ties this adapter to the base reloaded at launch: a repointed model_snapshot or a
|
| 271 |
+
# second HF-cache snapshot (same keys, same shapes) would resume "fine" on the wrong base.
|
| 272 |
+
saved = sd.get("base_fingerprint")
|
| 273 |
+
if saved is None or self.base_fingerprint is None:
|
| 274 |
+
logger.warning(
|
| 275 |
+
"LoRA resume cannot verify the base: base fingerprint missing (checkpoint=%s, this launch=%s)",
|
| 276 |
+
saved is not None, self.base_fingerprint is not None,
|
| 277 |
+
)
|
| 278 |
+
elif saved != self.base_fingerprint:
|
| 279 |
+
raise RuntimeError(
|
| 280 |
+
"LoRA resume: the base model loaded at launch is not the base this adapter was trained on "
|
| 281 |
+
f"(fingerprint {self.base_fingerprint[:12]} != checkpoint {saved[:12]}). Check "
|
| 282 |
+
"paths.model_snapshot and the HF cache for a second snapshot."
|
| 283 |
+
)
|
| 284 |
+
self.optimizer.load_state_dict(sd["optimizer"])
|
| 285 |
+
load_lora_state_dict(self.model, self.adapter, sd["lora"], strict=True)
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
# ---------------------------------------------------------------------------
|
| 289 |
+
# Model build
|
| 290 |
+
# ---------------------------------------------------------------------------
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
def _vm_rss_gb(field: str = "VmRSS") -> float:
|
| 294 |
+
with open("/proc/self/status") as f:
|
| 295 |
+
for line in f:
|
| 296 |
+
if line.startswith(field + ":"):
|
| 297 |
+
return float(line.split()[1]) / 1024**2
|
| 298 |
+
return float("nan")
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
def build_qwen3_stage_model(cfg, attn_implementation: Optional[str] = None):
|
| 302 |
+
"""Rank-serial load -> GPU -> (LoRA inject, freeze base) -> activation checkpointing -> FSDP2.
|
| 303 |
+
|
| 304 |
+
RANK-SERIAL because each rank materialises the 16.4 GB bf16 state dict on the host before
|
| 305 |
+
moving it to its card; four at once is 66 GB on a no-swap box. Ranks load one at a time
|
| 306 |
+
(barrier-ordered) and return freed heap to the OS (`malloc_trim`) before the next starts.
|
| 307 |
+
"""
|
| 308 |
+
from ..model import qwen3 as q3
|
| 309 |
+
from ..model.qwen3_checkpoint import hf_to_align
|
| 310 |
+
from ..model.checkpoint import load_hf_safetensors
|
| 311 |
+
from ..ppo.lora import LoRAConfig, inject_lora, mark_only_adapter_trainable
|
| 312 |
+
from ..utils.dist import barrier, local_rank, rank, world_size
|
| 313 |
+
from . import fsdp
|
| 314 |
+
|
| 315 |
+
snapshot, qcfg = resolve_qwen3(cfg)
|
| 316 |
+
if attn_implementation:
|
| 317 |
+
qcfg = qcfg.replace(attn_implementation=attn_implementation)
|
| 318 |
+
device = torch.device("cuda", local_rank())
|
| 319 |
+
torch.cuda.set_device(device)
|
| 320 |
+
|
| 321 |
+
model = None
|
| 322 |
+
peaks: dict[str, Any] = {}
|
| 323 |
+
for r in range(world_size()):
|
| 324 |
+
if rank() == r:
|
| 325 |
+
sd = hf_to_align(load_hf_safetensors(snapshot, dtype=torch.bfloat16), qcfg)
|
| 326 |
+
model = q3.Qwen3ForCausalLM.from_state_dict(qcfg, sd, device="cuda")
|
| 327 |
+
del sd
|
| 328 |
+
model = model.to(device)
|
| 329 |
+
peaks["rss_hwm_gb"] = round(_vm_rss_gb("VmHWM"), 2) # high-water mark of the load, not the residual
|
| 330 |
+
gc.collect()
|
| 331 |
+
try:
|
| 332 |
+
import ctypes
|
| 333 |
+
|
| 334 |
+
ctypes.CDLL("libc.so.6").malloc_trim(0)
|
| 335 |
+
except (OSError, AttributeError):
|
| 336 |
+
pass
|
| 337 |
+
peaks["rss_after_trim_gb"] = round(_vm_rss_gb(), 2)
|
| 338 |
+
barrier()
|
| 339 |
+
|
| 340 |
+
if cfg.lora.enabled:
|
| 341 |
+
model._base_fingerprint = base_fingerprint(model.state_dict()) # whole tensors, before inject/FSDP
|
| 342 |
+
lc = LoRAConfig(r=cfg.lora.r, alpha=cfg.lora.alpha, target_modules=tuple(cfg.lora.target_modules), rslora=cfg.lora.rslora)
|
| 343 |
+
# Identical adapter init on every rank: FSDP2 shards what each rank already holds and
|
| 344 |
+
# does NOT broadcast, so a per-rank random A would be a different adapter per shard.
|
| 345 |
+
gen = torch.Generator().manual_seed(cfg.train.seed)
|
| 346 |
+
hit = inject_lora(model, lc, adapter=cfg.lora.adapter, generator=gen)
|
| 347 |
+
n_trainable = mark_only_adapter_trainable(model, cfg.lora.adapter)
|
| 348 |
+
expected = len(cfg.lora.target_modules) * qcfg.n_layers
|
| 349 |
+
if len(hit) != expected:
|
| 350 |
+
raise Qwen3StageError(f"LoRA hit {len(hit)} layers, expected {expected} ({len(cfg.lora.target_modules)} targets x {qcfg.n_layers} layers)")
|
| 351 |
+
peaks["lora_trainable_params"] = n_trainable
|
| 352 |
+
fsdp.apply_activation_checkpointing(model)
|
| 353 |
+
if world_size() > 1:
|
| 354 |
+
fsdp.apply_fsdp(model)
|
| 355 |
+
model._stage_load_stats = peaks # for the banner / probe records
|
| 356 |
+
return model
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
def qwen3_trainer_extras(cfg, model, loss_adapter, run_dir: str) -> dict:
|
| 360 |
+
"""The Trainer kwargs a qwen3 stage adds: weight_format, per-rank tracker, and for LoRA the
|
| 361 |
+
train_module and the probe view.
|
| 362 |
+
|
| 363 |
+
PER-RANK TRACKER. The Trainer's default constructs a `Tracker` on EVERY rank, all appending
|
| 364 |
+
to the same `<run_dir>/history.jsonl`: four interleaved streams with no rank field, so
|
| 365 |
+
"did each rank see different rows?" cannot be answered from the record (and torn
|
| 366 |
+
interleaving is one fsync away). Rank 0 keeps `<run_dir>`; rank r writes `<run_dir>/rank{r}`.
|
| 367 |
+
"""
|
| 368 |
+
from ..utils.dist import rank as _rank
|
| 369 |
+
from ..utils.tracking import Tracker
|
| 370 |
+
from .offload_adamw import make_optimizer
|
| 371 |
+
from .trainer import MANDATORY_METRICS
|
| 372 |
+
|
| 373 |
+
snapshot, qcfg = resolve_qwen3(cfg)
|
| 374 |
+
r = _rank()
|
| 375 |
+
extras: dict = {
|
| 376 |
+
"weight_format": Qwen3Format(qcfg, tokenizer_dir=snapshot, lora=cfg.lora.enabled),
|
| 377 |
+
"tracker": Tracker(run_dir if r == 0 else os.path.join(run_dir, f"rank{r}"), "sft",
|
| 378 |
+
mandatory_keys=MANDATORY_METRICS, run_name=cfg.run_name),
|
| 379 |
+
}
|
| 380 |
+
if not cfg.lora.enabled:
|
| 381 |
+
return extras
|
| 382 |
+
optimizer = make_optimizer(cfg, [p for p in model.parameters() if p.requires_grad])
|
| 383 |
+
extras["train_module"] = LoRASFTModule(
|
| 384 |
+
model, loss_adapter, optimizer, cfg.optimizer.grad_accum, cfg.lora.adapter,
|
| 385 |
+
weight_format=extras["weight_format"],
|
| 386 |
+
)
|
| 387 |
+
from ..ppo.trainer import AdapterProbeView
|
| 388 |
+
|
| 389 |
+
extras["probe_model"] = AdapterProbeView(model, cfg.lora.adapter)
|
| 390 |
+
return extras
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
def qwen3_entry_banner(cfg, ledger) -> dict:
|
| 394 |
+
"""The qwen3 counterpart of `stage_gates.run_entry_gates`: what can be verified WITHOUT the
|
| 395 |
+
Mistral-specific machinery, and an explicit PENDING for what cannot.
|
| 396 |
+
|
| 397 |
+
NOT APPLICABLE here, and why (each is a Mistral/PRM800K artifact, not a skipped check):
|
| 398 |
+
* golden regression + MATH-500/GSM8K quality gate: they certify the Mistral eval stack
|
| 399 |
+
against a Mistral checkpoint's recorded score. The qwen3 experiment's evaluation is the
|
| 400 |
+
Lean verifier loop (P2-B/C).
|
| 401 |
+
* the 10-step 7B smoke / 20-step probe: `build_stage_model` is Mistral-only. The qwen3
|
| 402 |
+
memory and throughput are measured by scripts/qwen3_sft_probe.py and recorded in
|
| 403 |
+
docs; the banner says PENDING rather than quoting a number this launch did not measure.
|
| 404 |
+
KEPT: batch arithmetic, shard receipts (hashes into the banner), the RSS projection, the
|
| 405 |
+
sampler-derived scheduler horizon, the snapshot-vs-config agreement.
|
| 406 |
+
"""
|
| 407 |
+
from ..utils.guard import projected_full_ft_rss_gb
|
| 408 |
+
from .stage_gates import PENDING, assemble_sft_banner, assert_batch_arithmetic_consistent
|
| 409 |
+
from .stage_smoke import build_training_sampler, resolve_split_dir, sampler_derived_horizon
|
| 410 |
+
|
| 411 |
+
snapshot, qcfg = resolve_qwen3(cfg)
|
| 412 |
+
assert_batch_arithmetic_consistent(cfg)
|
| 413 |
+
sampler, dataset = build_training_sampler(cfg)
|
| 414 |
+
horizon = sampler_derived_horizon(cfg, sampler)
|
| 415 |
+
# THE OVERLONG-ROW DEFECT. The length-grouped sampler budgets tokens, but a row longer than the
|
| 416 |
+
# budget is not rejected: it becomes a batch of one and trains. seq_len is the length the
|
| 417 |
+
# memory envelope was MEASURED at (scripts/qwen3_sft_probe.py); a longer row would be the
|
| 418 |
+
# first thing to run at an untested length, on the last GPU-hour of an epoch.
|
| 419 |
+
lengths = dataset.lengths()
|
| 420 |
+
over = int((lengths > cfg.train.seq_len).sum())
|
| 421 |
+
if over:
|
| 422 |
+
raise Qwen3StageError(
|
| 423 |
+
f"{over} of {len(lengths)} rows are longer than train.seq_len={cfg.train.seq_len} "
|
| 424 |
+
f"(longest {int(lengths.max())}). Filter them at data-build time or raise seq_len to a "
|
| 425 |
+
"length that has been measured; the sampler would otherwise train each as a lone batch."
|
| 426 |
+
)
|
| 427 |
+
split_dir = resolve_split_dir(cfg.data.shard_dir, cfg.data.sft_split)
|
| 428 |
+
import json
|
| 429 |
+
|
| 430 |
+
hashes = {}
|
| 431 |
+
for rp in sorted(split_dir.glob("*.receipt.json")):
|
| 432 |
+
with open(rp) as f:
|
| 433 |
+
r = json.load(f)
|
| 434 |
+
hashes[r["name"]] = r["content_hash"]
|
| 435 |
+
if not hashes:
|
| 436 |
+
raise Qwen3StageError(f"no shard receipts under {split_dir}")
|
| 437 |
+
banner = assemble_sft_banner(
|
| 438 |
+
cfg,
|
| 439 |
+
attention_backend=qcfg.attn_implementation or "auto(sdpa)",
|
| 440 |
+
optimizer_impl=f"{cfg.optimizer.name} lora={'on r=%d alpha=%g' % (cfg.lora.r, cfg.lora.alpha) if cfg.lora.enabled else 'off'}",
|
| 441 |
+
dataset_hashes=hashes,
|
| 442 |
+
ceiling_results={"lean_verifier": PENDING, "note": "qwen3 stage: evaluated by the Lean loop (P2), not MATH/GSM8K"},
|
| 443 |
+
probe_report=None,
|
| 444 |
+
rss_projection=projected_full_ft_rss_gb(cfg),
|
| 445 |
+
mde={"note": PENDING},
|
| 446 |
+
scheduler_horizon_optimizer_steps=horizon,
|
| 447 |
+
scheduler_horizon_rows_derived=None,
|
| 448 |
+
)
|
| 449 |
+
banner.update({"arch": cfg.model.arch, "snapshot": snapshot, "n_rows": len(dataset), "seq_len": cfg.train.seq_len})
|
| 450 |
+
# The Mistral gate ledger enforces the golden->smoke->probe order, which does not apply here
|
| 451 |
+
# (recording a pass for a gate that did not run would be a lie), so the entry is persisted as
|
| 452 |
+
# its own receipt next to the ledger instead.
|
| 453 |
+
from ..utils.dist import is_rank0
|
| 454 |
+
|
| 455 |
+
if is_rank0():
|
| 456 |
+
tmp = os.path.join(ledger.run_dir, "qwen3_entry.json.partial")
|
| 457 |
+
with open(tmp, "w") as f:
|
| 458 |
+
json.dump(banner, f, indent=1, sort_keys=True, default=str)
|
| 459 |
+
os.replace(tmp, os.path.join(ledger.run_dir, "qwen3_entry.json"))
|
| 460 |
+
return banner
|
| 461 |
+
|
| 462 |
+
|
| 463 |
+
__all__ = [
|
| 464 |
+
"SUPPORTED_ARCHS", "Qwen3StageError", "base_fingerprint", "Qwen3Format", "LoRASFTModule", "validate_qwen3_cfg",
|
| 465 |
+
"resolve_qwen3", "build_qwen3_stage_model", "qwen3_trainer_extras", "qwen3_entry_banner",
|
| 466 |
+
]
|
code/align/train/ref_cache.py
ADDED
|
@@ -0,0 +1,834 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Reference log-probabilities: precompute, cache, join, and the path-bias rule.
|
| 2 |
+
|
| 3 |
+
DPO needs, for every pair, what the FROZEN reference policy thought of the
|
| 4 |
+
chosen and rejected completions. The textbook implementation keeps a second
|
| 5 |
+
7B model resident and forwards it alongside the policy. That does not fit:
|
| 6 |
+
14.5 GB of policy weights plus optimizer shards plus activations already fills
|
| 7 |
+
a 23 GB A10G, and a second copy fills a second one for no gradient.
|
| 8 |
+
|
| 9 |
+
So the reference is computed once, offline, and consumed as constants. The
|
| 10 |
+
whole arrangement rests on one property -- with dropout disabled, a no-grad
|
| 11 |
+
pass over a fixed dataset yields the same numbers every time -- and on one
|
| 12 |
+
discipline: the cache must be keyed on EVERYTHING that could change those
|
| 13 |
+
numbers, because every failure mode of a stale cache is silent. A cache from
|
| 14 |
+
the wrong checkpoint, the wrong tokenizer, or the pre-rebuild shards still
|
| 15 |
+
produces a finite log-sigmoid of a difference of plausible numbers. The loss
|
| 16 |
+
decreases. The margin it reports is a margin against something that does not
|
| 17 |
+
exist.
|
| 18 |
+
|
| 19 |
+
The key therefore carries six components, and each one is a way somebody has
|
| 20 |
+
or could break this:
|
| 21 |
+
|
| 22 |
+
* `checkpoint_hash` -- the obvious one; a cache from the previous SFT run.
|
| 23 |
+
Must be a CONTENT hash (`checkpoint_content_hash`): the tensor-inventory
|
| 24 |
+
hash cannot tell two SFT checkpoints of one architecture apart.
|
| 25 |
+
(A seventh, DIAGNOSTIC field, `checkpoint_inventory_hash`, carries the
|
| 26 |
+
inventory hash beside it: it is compared like the rest, but its only job
|
| 27 |
+
is to make a `checkpoint_hash` mismatch legible -- inventory equal means
|
| 28 |
+
"same architecture, different weights", the stale-SFT-attempt case.)
|
| 29 |
+
* `tokenizer_hash` -- retokenized pairs are different pairs.
|
| 30 |
+
* `manifest_hash` -- the one the plan did not list and this project needs
|
| 31 |
+
most: section-03's shards are being REBUILT (the duplicated `# Answer`
|
| 32 |
+
marker fix). A rebuild changes every pair's tokens while leaving the
|
| 33 |
+
checkpoint, the tokenizer and max_length identical, so nothing else in the
|
| 34 |
+
key moves.
|
| 35 |
+
* `max_length` and `truncation_mode` -- a completion truncated differently
|
| 36 |
+
has a different summed logprob, and summed logprobs are what DPO compares.
|
| 37 |
+
* `forward_impl` -- a single-GPU forward and an FSDP2-sharded forward are
|
| 38 |
+
not bitwise identical. That is the PATH-BIAS risk, and it is measured
|
| 39 |
+
(`path_bias_decision`) rather than assumed away, because early in DPO the
|
| 40 |
+
policy IS the reference and the loss argument sits at zero, which is
|
| 41 |
+
exactly where a systematic reference bias does the most damage.
|
| 42 |
+
|
| 43 |
+
EXISTING CACHES MUST BE RE-PRECOMPUTED (2026-10-12). Before this date the
|
| 44 |
+
launchers filled `checkpoint_hash` from the tensor-inventory hash
|
| 45 |
+
(`checkpoint_fingerprint()["manifest_sha256"]`). Every cache written that way
|
| 46 |
+
carries a key that can no longer be reproduced from a checkpoint's weights, so
|
| 47 |
+
it is refused by `load_ref_cache(expect=...)` -- correctly, because it could not
|
| 48 |
+
have told its own checkpoint from any sibling's. The refusal names
|
| 49 |
+
`checkpoint_hash`; the fix is to re-run scripts/precompute_ref_logprobs.py, not
|
| 50 |
+
to loosen the comparison. Both launchers now build the key through
|
| 51 |
+
`build_ref_cache_key`, the single place the six facts are assembled.
|
| 52 |
+
"""
|
| 53 |
+
|
| 54 |
+
from __future__ import annotations
|
| 55 |
+
|
| 56 |
+
import hashlib
|
| 57 |
+
import json
|
| 58 |
+
import logging
|
| 59 |
+
import math
|
| 60 |
+
import os
|
| 61 |
+
from dataclasses import asdict, dataclass, field, fields
|
| 62 |
+
from typing import Any, Iterable, Optional, Sequence
|
| 63 |
+
|
| 64 |
+
import torch
|
| 65 |
+
|
| 66 |
+
logger = logging.getLogger(__name__)
|
| 67 |
+
|
| 68 |
+
NAN = float("nan")
|
| 69 |
+
|
| 70 |
+
#: One entry per pair. Lengths are carried because `sigmoid_norm` divides by
|
| 71 |
+
#: them; a length of 0 (a prompt-only row) must make the normalised quantity
|
| 72 |
+
#: NaN rather than raise a ZeroDivisionError three weeks later.
|
| 73 |
+
ENTRY_FIELDS = ("ref_logp_chosen", "ref_logp_rejected", "len_chosen", "len_rejected")
|
| 74 |
+
|
| 75 |
+
#: How the pass actually ran. Not a preference -- a record.
|
| 76 |
+
FORWARD_IMPLS = ("single_gpu", "fsdp2_sharded")
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
@dataclass(frozen=True)
|
| 80 |
+
class RefCacheKey:
|
| 81 |
+
"""Everything that could silently change a cached reference logprob.
|
| 82 |
+
|
| 83 |
+
Frozen and compared component-by-component so a mismatch names the guilty
|
| 84 |
+
field. `RuntimeError: ref cache mismatch` with no further detail sends
|
| 85 |
+
somebody hunting through three sections; `mismatched: manifest_hash
|
| 86 |
+
(cache=man-abc, run=man-xyz)` sends them to the shard rebuild.
|
| 87 |
+
"""
|
| 88 |
+
|
| 89 |
+
checkpoint_hash: str
|
| 90 |
+
tokenizer_hash: str
|
| 91 |
+
manifest_hash: str
|
| 92 |
+
max_length: int
|
| 93 |
+
truncation_mode: str
|
| 94 |
+
forward_impl: str
|
| 95 |
+
#: DIAGNOSTIC companion to `checkpoint_hash` (see the module docstring).
|
| 96 |
+
#: Empty only on caches written before 2026-10-12, which are refused on
|
| 97 |
+
#: `checkpoint_hash` anyway.
|
| 98 |
+
checkpoint_inventory_hash: str = ""
|
| 99 |
+
|
| 100 |
+
def __post_init__(self) -> None:
|
| 101 |
+
if self.forward_impl not in FORWARD_IMPLS:
|
| 102 |
+
raise ValueError(
|
| 103 |
+
f"forward_impl={self.forward_impl!r} must be one of "
|
| 104 |
+
f"{FORWARD_IMPLS}: it records how the pass ACTUALLY ran, and "
|
| 105 |
+
"an unrecognised tag cannot be compared against the run's."
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
def to_json(self) -> str:
|
| 109 |
+
return json.dumps(asdict(self), sort_keys=True)
|
| 110 |
+
|
| 111 |
+
@classmethod
|
| 112 |
+
def from_json(cls, blob: str) -> "RefCacheKey":
|
| 113 |
+
return cls(**json.loads(blob))
|
| 114 |
+
|
| 115 |
+
def mismatches(self, other: "RefCacheKey") -> list[str]:
|
| 116 |
+
return [f.name for f in fields(self) if getattr(self, f.name) != getattr(other, f.name)]
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
@dataclass
|
| 120 |
+
class RefCache:
|
| 121 |
+
key: RefCacheKey
|
| 122 |
+
#: pair_id -> (ref_logp_chosen, ref_logp_rejected, len_chosen, len_rejected)
|
| 123 |
+
entries: dict[str, tuple[float, float, float, float]] = field(default_factory=dict)
|
| 124 |
+
|
| 125 |
+
def __len__(self) -> int:
|
| 126 |
+
return len(self.entries)
|
| 127 |
+
|
| 128 |
+
def require(self, pair_id: str) -> tuple[float, float, float, float]:
|
| 129 |
+
"""Look up a pair, or raise. There is no default: a zero reference
|
| 130 |
+
turns DPO into likelihood maximisation on the chosen side, which
|
| 131 |
+
trains and converges and is not DPO."""
|
| 132 |
+
try:
|
| 133 |
+
return self.entries[pair_id]
|
| 134 |
+
except KeyError:
|
| 135 |
+
raise KeyError(
|
| 136 |
+
f"pair_id {pair_id!r} is not in the reference cache "
|
| 137 |
+
f"({len(self.entries)} entries, key {self.key.to_json()}). "
|
| 138 |
+
"Refusing to substitute a default reference logprob. Either "
|
| 139 |
+
"the precompute did not cover this split, or these shards are "
|
| 140 |
+
"not the ones it ran over."
|
| 141 |
+
) from None
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def build_ref_cache_key(
|
| 145 |
+
sft_checkpoint: str, manifest: dict, *, forward_impl: str
|
| 146 |
+
) -> RefCacheKey:
|
| 147 |
+
"""The ONE place a launcher turns facts into a `RefCacheKey`.
|
| 148 |
+
|
| 149 |
+
Both scripts/precompute_ref_logprobs.py (writer) and scripts/dpo.py
|
| 150 |
+
(reader) call this, so the key they compare cannot drift apart again. The
|
| 151 |
+
drift it replaces: each script assembled the key inline and BOTH filled
|
| 152 |
+
`checkpoint_hash` from the tensor-inventory hash (2026-10-12 review), so
|
| 153 |
+
two different-weight SFT checkpoints produced the same key. Here
|
| 154 |
+
`checkpoint_hash` is the WEIGHT-BYTES hash (`checkpoint_content_hash`; one
|
| 155 |
+
streamed read of the checkpoint, ~14.5GB at 7B) and the inventory hash
|
| 156 |
+
rides along as `checkpoint_inventory_hash`.
|
| 157 |
+
|
| 158 |
+
`manifest_hash` covers the shard rebuild nothing else in the key notices;
|
| 159 |
+
`forward_impl` is a record of how the pass ran and is the caller's to state.
|
| 160 |
+
"""
|
| 161 |
+
from ..data.receipts import content_hash
|
| 162 |
+
|
| 163 |
+
return RefCacheKey(
|
| 164 |
+
checkpoint_hash=checkpoint_content_hash(sft_checkpoint),
|
| 165 |
+
tokenizer_hash=manifest["tokenizer_hash"],
|
| 166 |
+
manifest_hash=content_hash(
|
| 167 |
+
[manifest["hashes"][k] for k in sorted(manifest.get("hashes", {}))]
|
| 168 |
+
),
|
| 169 |
+
max_length=int(manifest.get("pinned", {}).get("max_len", 2048)),
|
| 170 |
+
truncation_mode="right",
|
| 171 |
+
forward_impl=forward_impl,
|
| 172 |
+
checkpoint_inventory_hash=checkpoint_inventory_hash(sft_checkpoint),
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def peek_ref_cache_key(path: str) -> Optional[RefCacheKey]:
|
| 177 |
+
"""The key stored in a cache file's header, or None when there is no file.
|
| 178 |
+
|
| 179 |
+
INSPECTION ONLY -- it validates nothing about the cache. It exists so a
|
| 180 |
+
launcher can read the one component that is a RECORD rather than an
|
| 181 |
+
expectation (`forward_impl`: how the cache's own pass ran) from the cache
|
| 182 |
+
itself, build the expected key around it, and then `load_ref_cache(path,
|
| 183 |
+
expect=...)`, which asserts the other components. Never a substitute for
|
| 184 |
+
that load.
|
| 185 |
+
"""
|
| 186 |
+
from safetensors import safe_open
|
| 187 |
+
|
| 188 |
+
if not os.path.isfile(path):
|
| 189 |
+
return None
|
| 190 |
+
with safe_open(path, framework="pt") as f:
|
| 191 |
+
meta = f.metadata() or {}
|
| 192 |
+
if "key" not in meta:
|
| 193 |
+
return None
|
| 194 |
+
return RefCacheKey.from_json(meta["key"])
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
#: What `cache_forward_impl` reads when the report is written before any cache
|
| 198 |
+
#: exists. Not a member of FORWARD_IMPLS on purpose: it can never equal a real
|
| 199 |
+
#: cache's tag, so nothing can mistake "no cache yet" for a measured pass.
|
| 200 |
+
NO_CACHE_YET = "no_cache_at_report_time"
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def path_bias_report_cache_forward_impl(cache_path: str) -> str:
|
| 204 |
+
"""The `forward_impl` a path-bias report should record as `cache_forward_impl`.
|
| 205 |
+
|
| 206 |
+
The report's field is about the CACHE the training run will consume; the
|
| 207 |
+
`--path-bias` run measures it but does not recompute it. So the value is
|
| 208 |
+
the on-disk train cache's own tag, or `NO_CACHE_YET` when the measurement
|
| 209 |
+
was taken first. The launcher does not read this field back for its
|
| 210 |
+
expectation (it reads the cache), so this is a record, not a lever.
|
| 211 |
+
"""
|
| 212 |
+
key = peek_ref_cache_key(cache_path)
|
| 213 |
+
return key.forward_impl if key is not None else NO_CACHE_YET
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def _checkpoint_files(path: str) -> list[str]:
|
| 217 |
+
"""The weight files of a checkpoint, in the order they are hashed.
|
| 218 |
+
|
| 219 |
+
A `.safetensors` file, a directory holding `model.safetensors`, or a
|
| 220 |
+
sharded directory (index + shards, sorted by name).
|
| 221 |
+
"""
|
| 222 |
+
from ..model.checkpoint import INDEX_NAME, WEIGHTS_NAME
|
| 223 |
+
|
| 224 |
+
if os.path.isfile(path):
|
| 225 |
+
files = [path]
|
| 226 |
+
else:
|
| 227 |
+
single = os.path.join(path, WEIGHTS_NAME)
|
| 228 |
+
index = os.path.join(path, INDEX_NAME)
|
| 229 |
+
if os.path.isfile(single):
|
| 230 |
+
files = [single]
|
| 231 |
+
elif os.path.isfile(index):
|
| 232 |
+
with open(index) as f:
|
| 233 |
+
shards = sorted(set(json.load(f)["weight_map"].values()))
|
| 234 |
+
files = [os.path.join(path, s) for s in shards]
|
| 235 |
+
missing = [s for s in files if not os.path.isfile(s)]
|
| 236 |
+
if missing:
|
| 237 |
+
raise FileNotFoundError(f"{INDEX_NAME} names shard(s) absent from {path!r}: {missing}")
|
| 238 |
+
else:
|
| 239 |
+
raise FileNotFoundError(f"no {WEIGHTS_NAME} or {INDEX_NAME} at {path!r}")
|
| 240 |
+
return files
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def checkpoint_inventory_hash(path: str) -> str:
|
| 244 |
+
"""Hash of the tensor INVENTORY (name, shape, dtype) read from the safetensors
|
| 245 |
+
HEADERS only -- no tensor data is materialised.
|
| 246 |
+
|
| 247 |
+
This is the DIAGNOSTIC companion to `checkpoint_content_hash`, not a
|
| 248 |
+
substitute: it is exactly the kind of hash that cannot distinguish two
|
| 249 |
+
checkpoints of one architecture, which is its use -- when `checkpoint_hash`
|
| 250 |
+
mismatches and this one does not, the cache is from the same architecture
|
| 251 |
+
with different weights (the stale-SFT-attempt case). Deliberately NOT
|
| 252 |
+
`checkpoint_fingerprint()["manifest_sha256"]`: that routes through
|
| 253 |
+
`load_hf_safetensors`, which reads the whole 14.5GB into RAM on every rank
|
| 254 |
+
just to print shapes -- four ranks of it at gate 3 on a no-swap box.
|
| 255 |
+
"""
|
| 256 |
+
from safetensors import safe_open
|
| 257 |
+
|
| 258 |
+
inventory = {}
|
| 259 |
+
for fp in _checkpoint_files(path):
|
| 260 |
+
with safe_open(fp, framework="pt") as f:
|
| 261 |
+
for k in f.keys():
|
| 262 |
+
sl = f.get_slice(k)
|
| 263 |
+
inventory[k] = [list(sl.get_shape()), str(sl.get_dtype())]
|
| 264 |
+
return hashlib.sha256(json.dumps(inventory, sort_keys=True).encode()).hexdigest()[:16]
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
def checkpoint_content_hash(path: str) -> str:
|
| 268 |
+
"""Hash of the checkpoint's WEIGHT BYTES: the value `RefCacheKey.checkpoint_hash` must carry.
|
| 269 |
+
|
| 270 |
+
WHY THIS EXISTS (review of 2026-10-12). The launchers used to fill
|
| 271 |
+
`checkpoint_hash` from `checkpoint_fingerprint(path)["manifest_sha256"]`,
|
| 272 |
+
which hashes only the tensor inventory (name, shape, dtype). Every full-FT
|
| 273 |
+
SFT checkpoint of one architecture has the SAME inventory, so a cache from
|
| 274 |
+
a previous SFT attempt, or from micro_00000100 when the gate passed
|
| 275 |
+
micro_00000200, produced an identical key and loaded without complaint --
|
| 276 |
+
the exact stale-cache failure this key is documented to defend against
|
| 277 |
+
("a cache from the previous SFT run"). The receipt module says so itself
|
| 278 |
+
(`weights_sha256` is the content hash; `manifest_sha256` is a structure
|
| 279 |
+
hash), and `test_fingerprint_does_not_call_a_structure_hash_a_content_hash`
|
| 280 |
+
pins it. Reading ~14.5GB once per launch is the price of the key meaning
|
| 281 |
+
what it says; it is streamed in 64MB blocks and never held in memory.
|
| 282 |
+
|
| 283 |
+
Accepts a `.safetensors` file, a directory holding `model.safetensors`, or
|
| 284 |
+
a sharded directory (index + shards). Shard names are hashed together with
|
| 285 |
+
their bytes, in sorted order, so a renamed or reordered shard set changes
|
| 286 |
+
the hash too.
|
| 287 |
+
"""
|
| 288 |
+
files = _checkpoint_files(path)
|
| 289 |
+
h = hashlib.sha256()
|
| 290 |
+
for fp in files:
|
| 291 |
+
h.update(os.path.basename(fp).encode() + b"\0")
|
| 292 |
+
with open(fp, "rb") as fh:
|
| 293 |
+
for block in iter(lambda: fh.read(64 << 20), b""):
|
| 294 |
+
h.update(block)
|
| 295 |
+
return h.hexdigest()[:16]
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
def _atomic_write(path: str, write) -> None:
|
| 299 |
+
"""Run `write(tmp_path)` then `os.replace` onto `path`.
|
| 300 |
+
|
| 301 |
+
A cache or report written straight to its final name and killed mid-write
|
| 302 |
+
(the host has seized mid-checkpoint before) leaves a truncated file at the
|
| 303 |
+
name the next launch trusts, and REPLACES a previously good one. The temp
|
| 304 |
+
file sits beside the target (same filesystem, on /apps) so the rename is
|
| 305 |
+
atomic, and is removed if the write raises.
|
| 306 |
+
"""
|
| 307 |
+
tmp = f"{path}.tmp.{os.getpid()}"
|
| 308 |
+
try:
|
| 309 |
+
write(tmp)
|
| 310 |
+
os.replace(tmp, path)
|
| 311 |
+
except BaseException:
|
| 312 |
+
if os.path.exists(tmp):
|
| 313 |
+
os.remove(tmp)
|
| 314 |
+
raise
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
# ---------------------------------------------------------------------------
|
| 318 |
+
# I/O
|
| 319 |
+
# ---------------------------------------------------------------------------
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
def write_ref_cache(path: str, cache: RefCache) -> str:
|
| 323 |
+
"""One safetensors file per (arm, split): tensors for the values, the key
|
| 324 |
+
and the pair-id order in the metadata.
|
| 325 |
+
|
| 326 |
+
One file, not a tensor file plus a JSON sidecar, because a cache split
|
| 327 |
+
across two files can half-exist -- and a half-existing cache is the
|
| 328 |
+
resumable-looking partial checkpoint problem in another costume.
|
| 329 |
+
"""
|
| 330 |
+
from safetensors.torch import save_file
|
| 331 |
+
|
| 332 |
+
from ..utils.guard import assert_paths_off_root
|
| 333 |
+
|
| 334 |
+
assert_paths_off_root([os.path.dirname(os.path.abspath(path))])
|
| 335 |
+
pair_ids = list(cache.entries)
|
| 336 |
+
cols = list(zip(*(cache.entries[p] for p in pair_ids))) if pair_ids else [(), (), (), ()]
|
| 337 |
+
# float64 throughout: the values arrive as fp32 (the log-softmax is upcast
|
| 338 |
+
# even in a bf16 forward) and float32 -> float64 is exact, so the stored
|
| 339 |
+
# number IS the computed number. Storing them as fp32 would be equally
|
| 340 |
+
# exact today and would silently truncate if the compute dtype ever rose.
|
| 341 |
+
tensors = {
|
| 342 |
+
name: torch.tensor(col, dtype=torch.float64) for name, col in zip(ENTRY_FIELDS, cols)
|
| 343 |
+
}
|
| 344 |
+
from ..data.loader import PREFERENCE_MASK_VERSION
|
| 345 |
+
|
| 346 |
+
_atomic_write(
|
| 347 |
+
path,
|
| 348 |
+
lambda tmp: save_file(
|
| 349 |
+
tensors,
|
| 350 |
+
tmp,
|
| 351 |
+
metadata={
|
| 352 |
+
"key": cache.key.to_json(),
|
| 353 |
+
"pair_ids": json.dumps(pair_ids),
|
| 354 |
+
"mask_version": PREFERENCE_MASK_VERSION,
|
| 355 |
+
},
|
| 356 |
+
),
|
| 357 |
+
)
|
| 358 |
+
logger.info("ref cache written: %s (%d pairs)", path, len(pair_ids))
|
| 359 |
+
return path
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
def load_ref_cache(path: str, expect: Optional[RefCacheKey] = None) -> RefCache:
|
| 363 |
+
"""Load a cache and, when `expect` is given, ASSERT the key.
|
| 364 |
+
|
| 365 |
+
Loading without `expect` is for inspection tools only. Every training path
|
| 366 |
+
passes it; that assertion is the entire defence against a stale cache.
|
| 367 |
+
"""
|
| 368 |
+
from safetensors import safe_open
|
| 369 |
+
|
| 370 |
+
if not os.path.isfile(path):
|
| 371 |
+
raise FileNotFoundError(
|
| 372 |
+
f"reference cache {path!r} does not exist. Run "
|
| 373 |
+
"scripts/precompute_ref_logprobs.py for this arm first; DPO "
|
| 374 |
+
"cannot proceed without it and will not invent one."
|
| 375 |
+
)
|
| 376 |
+
with safe_open(path, framework="pt") as f:
|
| 377 |
+
meta = f.metadata() or {}
|
| 378 |
+
cols = {name: f.get_tensor(name) for name in ENTRY_FIELDS}
|
| 379 |
+
if "key" not in meta or "pair_ids" not in meta:
|
| 380 |
+
raise RuntimeError(
|
| 381 |
+
f"{path!r} carries no cache key. An unkeyed cache cannot be shown "
|
| 382 |
+
"to belong to this checkpoint, tokenizer and shard build, so it is "
|
| 383 |
+
"refused rather than trusted."
|
| 384 |
+
)
|
| 385 |
+
from ..data.loader import PREFERENCE_MASK_VERSION
|
| 386 |
+
|
| 387 |
+
cache_mask = meta.get("mask_version", "prompt_len_v1")
|
| 388 |
+
if cache_mask != PREFERENCE_MASK_VERSION:
|
| 389 |
+
raise RuntimeError(
|
| 390 |
+
f"{path!r} was precomputed under completion-mask semantics "
|
| 391 |
+
f"{cache_mask!r} but this build uses {PREFERENCE_MASK_VERSION!r} "
|
| 392 |
+
"(audit R1, 2026-08-23: the boundary moved from prompt_len to "
|
| 393 |
+
"shared_prefix_len). Reference logprobs are mask-dependent; "
|
| 394 |
+
"recompute the cache rather than mixing semantics."
|
| 395 |
+
)
|
| 396 |
+
key = RefCacheKey.from_json(meta["key"])
|
| 397 |
+
if expect is not None:
|
| 398 |
+
bad = key.mismatches(expect)
|
| 399 |
+
if bad:
|
| 400 |
+
detail = "; ".join(
|
| 401 |
+
f"{n} (cache={getattr(key, n)!r}, run={getattr(expect, n)!r})" for n in bad
|
| 402 |
+
)
|
| 403 |
+
legacy = ""
|
| 404 |
+
if "checkpoint_hash" in bad and not key.checkpoint_inventory_hash:
|
| 405 |
+
legacy = (
|
| 406 |
+
" This cache carries no checkpoint_inventory_hash: it predates "
|
| 407 |
+
"the 2026-10-12 content-hash key, whose checkpoint_hash was a "
|
| 408 |
+
"tensor-inventory hash that cannot identify a checkpoint. It "
|
| 409 |
+
"must be re-precomputed."
|
| 410 |
+
)
|
| 411 |
+
raise RuntimeError(
|
| 412 |
+
f"reference cache {path!r} does not match this run. "
|
| 413 |
+
f"Mismatched: {detail}.{legacy} A stale reference cache does not fail "
|
| 414 |
+
"loudly at train time -- the loss stays finite, the curve "
|
| 415 |
+
"stays healthy, and the margin is measured against a policy "
|
| 416 |
+
"that never existed. Re-run the precompute."
|
| 417 |
+
)
|
| 418 |
+
pair_ids = json.loads(meta["pair_ids"])
|
| 419 |
+
entries = {p: tuple(float(cols[n][i]) for n in ENTRY_FIELDS) for i, p in enumerate(pair_ids)}
|
| 420 |
+
return RefCache(key=key, entries=entries)
|
| 421 |
+
|
| 422 |
+
|
| 423 |
+
# ---------------------------------------------------------------------------
|
| 424 |
+
# Precompute
|
| 425 |
+
# ---------------------------------------------------------------------------
|
| 426 |
+
|
| 427 |
+
|
| 428 |
+
def _labels(
|
| 429 |
+
input_ids: torch.Tensor, completion_mask: torch.Tensor, ignore_index: int
|
| 430 |
+
) -> torch.Tensor:
|
| 431 |
+
"""Completion-only labels: prompt AND pad positions ignored.
|
| 432 |
+
|
| 433 |
+
Excluding the prompt is not cosmetic. For Arm B the entire shared prefix
|
| 434 |
+
is identical across the two sides, so including it adds the same term to
|
| 435 |
+
both logprobs: it cancels in the DPO difference while inflating both
|
| 436 |
+
magnitudes, and any length-normalised variant computed from it is wrong.
|
| 437 |
+
"""
|
| 438 |
+
return input_ids.masked_fill(completion_mask == 0, ignore_index)
|
| 439 |
+
|
| 440 |
+
|
| 441 |
+
@torch.no_grad()
|
| 442 |
+
def _side_logprobs(
|
| 443 |
+
model, input_ids: torch.Tensor, completion_mask: torch.Tensor, ignore_index: int
|
| 444 |
+
) -> tuple[torch.Tensor, torch.Tensor]:
|
| 445 |
+
"""Summed completion logprobs via the SAME routine `fused_dpo` uses.
|
| 446 |
+
|
| 447 |
+
Not a reimplementation: `completion_logprobs_eager` is imported from the
|
| 448 |
+
kernel module, and the caller-side shift here (`logits[:, :-1]` against
|
| 449 |
+
`labels[:, 1:]`) is the shift the DPO adapter applies at train time. If
|
| 450 |
+
precompute and consumption disagreed by one position, DPO would start from
|
| 451 |
+
a nonzero margin against itself and spend the run optimising a constant
|
| 452 |
+
offset it can never remove.
|
| 453 |
+
"""
|
| 454 |
+
from ..kernels.fused_dpo import completion_logprobs_eager
|
| 455 |
+
|
| 456 |
+
labels = _labels(input_ids, completion_mask, ignore_index)
|
| 457 |
+
logits = model(input_ids=input_ids)
|
| 458 |
+
return completion_logprobs_eager(logits[:, :-1], labels[:, 1:], ignore_index)
|
| 459 |
+
|
| 460 |
+
|
| 461 |
+
def precompute_ref_logprobs(
|
| 462 |
+
model,
|
| 463 |
+
rows: Sequence[Any],
|
| 464 |
+
*,
|
| 465 |
+
key: RefCacheKey,
|
| 466 |
+
pad_token_id: int,
|
| 467 |
+
batch_size: int = 8,
|
| 468 |
+
device: Optional[torch.device] = None,
|
| 469 |
+
) -> RefCache:
|
| 470 |
+
"""One no-grad pass over `rows`, returning a keyed cache.
|
| 471 |
+
|
| 472 |
+
`rows` are in the shape `collate_preference` consumes (section-03's
|
| 473 |
+
`preference_row_from_store` produces them); every row must carry a
|
| 474 |
+
`pair_id`, because pair_id is how refs find their way back to the right
|
| 475 |
+
training example after any reshuffle.
|
| 476 |
+
|
| 477 |
+
NOTE on batch size -- why it is NOT a key component, MEASURED not assumed.
|
| 478 |
+
Right-padding plus causal masking makes padded columns unreachable, so the
|
| 479 |
+
value is mathematically independent of how rows are grouped; the fp32
|
| 480 |
+
reduction ORDER is not. Measured on the tiny CPU harness (40 pairs, batch
|
| 481 |
+
size 1 vs 2/4/8/20/40): max |delta logp| 3.8e-06 = 9.7e-08 relative, max
|
| 482 |
+
|delta reference MARGIN| 5.7e-06, mean delta ~1e-07. Symmetric rounding
|
| 483 |
+
noise, not a systematic offset -- which is the distinction that decides
|
| 484 |
+
it, since only a systematic offset biases the loss argument. Run through
|
| 485 |
+
this module's own `path_bias_decision` the ratio is ~1e-05 against a 0.05
|
| 486 |
+
threshold, so the same rule that governs single-GPU-vs-sharded says
|
| 487 |
+
negligible here. Bitwise determinism holds at a FIXED batch size, which is
|
| 488 |
+
the property the caching design actually needs, and
|
| 489 |
+
`test_batch_size_variation_is_below_the_path_bias_threshold` keeps this
|
| 490 |
+
paragraph honest.
|
| 491 |
+
"""
|
| 492 |
+
from ..data.loader import collate_preference
|
| 493 |
+
from ..kernels.fused_dpo import IGNORE_INDEX
|
| 494 |
+
|
| 495 |
+
if getattr(model, "training", False):
|
| 496 |
+
raise RuntimeError(
|
| 497 |
+
"the reference model is in train mode. Mistral-v0.3 sets "
|
| 498 |
+
"attention_dropout 0.0 so this is PROBABLY harmless -- which is "
|
| 499 |
+
"exactly why nobody would notice, and why 'probably' is not a "
|
| 500 |
+
"basis for numbers that get cached and consumed as constants for "
|
| 501 |
+
"the rest of the stage. Call model.eval()."
|
| 502 |
+
)
|
| 503 |
+
if batch_size < 1:
|
| 504 |
+
raise ValueError(f"batch_size={batch_size} must be >= 1")
|
| 505 |
+
|
| 506 |
+
rows = list(rows)
|
| 507 |
+
entries: dict[str, tuple[float, float, float, float]] = {}
|
| 508 |
+
for start in range(0, len(rows), batch_size):
|
| 509 |
+
chunk = rows[start : start + batch_size]
|
| 510 |
+
batch = collate_preference(chunk, pad_token_id=pad_token_id)
|
| 511 |
+
if device is not None:
|
| 512 |
+
batch = batch.to(device)
|
| 513 |
+
lp_c, len_c = _side_logprobs(
|
| 514 |
+
model, batch.chosen_input_ids, batch.chosen_completion_mask, IGNORE_INDEX
|
| 515 |
+
)
|
| 516 |
+
lp_r, len_r = _side_logprobs(
|
| 517 |
+
model, batch.rejected_input_ids, batch.rejected_completion_mask, IGNORE_INDEX
|
| 518 |
+
)
|
| 519 |
+
for i, meta in enumerate(batch.metadata):
|
| 520 |
+
pid = meta.get("pair_id")
|
| 521 |
+
if pid is None:
|
| 522 |
+
raise ValueError(
|
| 523 |
+
"a preference row carries no pair_id. Refs are joined to "
|
| 524 |
+
"training rows BY pair_id; a row without one could only be "
|
| 525 |
+
"joined by position, which mis-pairs under any reshuffle."
|
| 526 |
+
)
|
| 527 |
+
if pid in entries:
|
| 528 |
+
raise ValueError(
|
| 529 |
+
f"duplicate pair_id {pid!r}: the cache is a mapping, so a "
|
| 530 |
+
"duplicate would silently overwrite one pair's reference "
|
| 531 |
+
"with another's."
|
| 532 |
+
)
|
| 533 |
+
entries[pid] = (float(lp_c[i]), float(lp_r[i]), float(len_c[i]), float(len_r[i]))
|
| 534 |
+
logger.info("precomputed %d reference pairs (%s)", len(entries), key.forward_impl)
|
| 535 |
+
return RefCache(key=key, entries=entries)
|
| 536 |
+
|
| 537 |
+
|
| 538 |
+
# ---------------------------------------------------------------------------
|
| 539 |
+
# Joining refs to batches
|
| 540 |
+
# ---------------------------------------------------------------------------
|
| 541 |
+
|
| 542 |
+
|
| 543 |
+
def attach_refs(batch, cache: RefCache):
|
| 544 |
+
"""Fill a `Preference` batch's ref tensors FROM THE CACHE, by pair_id.
|
| 545 |
+
|
| 546 |
+
By pair_id and never by position. The section-03 loader is length-grouped
|
| 547 |
+
and shuffled per epoch, so batch order is not cache order and never will
|
| 548 |
+
be; a positional join would attach a perfectly plausible reference to the
|
| 549 |
+
wrong row, produce a finite loss, and point the gradient somewhere
|
| 550 |
+
arbitrary. Nothing downstream can see it.
|
| 551 |
+
"""
|
| 552 |
+
pair_ids = []
|
| 553 |
+
for i, meta in enumerate(batch.metadata):
|
| 554 |
+
pid = (meta or {}).get("pair_id")
|
| 555 |
+
if pid is None:
|
| 556 |
+
raise ValueError(
|
| 557 |
+
f"row {i} of this batch carries no pair_id, so its reference "
|
| 558 |
+
"logprobs cannot be looked up. Falling back to batch order is "
|
| 559 |
+
"the silent-substitution incident this join exists to prevent."
|
| 560 |
+
)
|
| 561 |
+
pair_ids.append(pid)
|
| 562 |
+
|
| 563 |
+
vals = [cache.require(p) for p in pair_ids]
|
| 564 |
+
dtype = torch.float32
|
| 565 |
+
batch.ref_chosen_logps = torch.tensor([v[0] for v in vals], dtype=dtype)
|
| 566 |
+
batch.ref_rejected_logps = torch.tensor([v[1] for v in vals], dtype=dtype)
|
| 567 |
+
return batch
|
| 568 |
+
|
| 569 |
+
|
| 570 |
+
# ---------------------------------------------------------------------------
|
| 571 |
+
# Held-out preference accuracy
|
| 572 |
+
# ---------------------------------------------------------------------------
|
| 573 |
+
|
| 574 |
+
|
| 575 |
+
@torch.no_grad()
|
| 576 |
+
def heldout_preference_accuracy(
|
| 577 |
+
model, batches: Iterable[Any], cache: RefCache, beta: float, z: float = 1.96
|
| 578 |
+
) -> dict:
|
| 579 |
+
"""Fraction of held-out pairs the policy's IMPLICIT reward prefers.
|
| 580 |
+
|
| 581 |
+
The implicit reward difference is beta * [(logp_c - logp_r) - (ref_c -
|
| 582 |
+
ref_r)]; the pair counts as correct when it is strictly positive. This is
|
| 583 |
+
the stage's primary health metric together with the margin, and it is
|
| 584 |
+
deliberately NOT benchmark accuracy: the pre-registered expectations for
|
| 585 |
+
both arms are about preference fit, and section-13 owns the paired
|
| 586 |
+
benchmark evaluation.
|
| 587 |
+
|
| 588 |
+
Empty or degenerate held-out sets report NaN, never 0. Note the
|
| 589 |
+
distinction the test pins: at initialisation the policy IS the reference,
|
| 590 |
+
every implicit margin is exactly 0.0, and nothing is STRICTLY preferred --
|
| 591 |
+
accuracy 0.0 over a real denominator. That is an honest measurement, and
|
| 592 |
+
it is a different fact from NaN over a denominator of zero.
|
| 593 |
+
"""
|
| 594 |
+
from ..eval.metrics import wilson_interval
|
| 595 |
+
from ..kernels.fused_dpo import IGNORE_INDEX
|
| 596 |
+
|
| 597 |
+
was_training = getattr(model, "training", False)
|
| 598 |
+
model.eval()
|
| 599 |
+
n = 0
|
| 600 |
+
correct = 0
|
| 601 |
+
margin_sum = 0.0
|
| 602 |
+
try:
|
| 603 |
+
for batch in batches:
|
| 604 |
+
if int(batch.chosen_input_ids.shape[0]) == 0:
|
| 605 |
+
continue
|
| 606 |
+
lp_c, _ = _side_logprobs(
|
| 607 |
+
model, batch.chosen_input_ids, batch.chosen_completion_mask, IGNORE_INDEX
|
| 608 |
+
)
|
| 609 |
+
lp_r, _ = _side_logprobs(
|
| 610 |
+
model, batch.rejected_input_ids, batch.rejected_completion_mask, IGNORE_INDEX
|
| 611 |
+
)
|
| 612 |
+
for i, meta in enumerate(batch.metadata):
|
| 613 |
+
pid = (meta or {}).get("pair_id")
|
| 614 |
+
if pid is None:
|
| 615 |
+
raise ValueError("held-out row carries no pair_id")
|
| 616 |
+
ref_c, ref_r, _, _ = cache.require(pid)
|
| 617 |
+
m = beta * ((float(lp_c[i]) - float(lp_r[i])) - (ref_c - ref_r))
|
| 618 |
+
margin_sum += m
|
| 619 |
+
correct += int(m > 0.0)
|
| 620 |
+
n += 1
|
| 621 |
+
finally:
|
| 622 |
+
if was_training:
|
| 623 |
+
model.train()
|
| 624 |
+
|
| 625 |
+
if n == 0:
|
| 626 |
+
return {
|
| 627 |
+
"accuracy": NAN,
|
| 628 |
+
"ci_low": NAN,
|
| 629 |
+
"ci_high": NAN,
|
| 630 |
+
"mean_margin": NAN,
|
| 631 |
+
"n": 0,
|
| 632 |
+
"correct": 0,
|
| 633 |
+
"beta": beta,
|
| 634 |
+
}
|
| 635 |
+
lo, hi = wilson_interval(correct, n, z)
|
| 636 |
+
return {
|
| 637 |
+
"accuracy": correct / n,
|
| 638 |
+
"ci_low": lo,
|
| 639 |
+
"ci_high": hi,
|
| 640 |
+
"mean_margin": margin_sum / n,
|
| 641 |
+
"n": n,
|
| 642 |
+
"correct": correct,
|
| 643 |
+
"beta": beta,
|
| 644 |
+
}
|
| 645 |
+
|
| 646 |
+
|
| 647 |
+
# ---------------------------------------------------------------------------
|
| 648 |
+
# Path bias
|
| 649 |
+
# ---------------------------------------------------------------------------
|
| 650 |
+
|
| 651 |
+
|
| 652 |
+
def path_bias_decision(
|
| 653 |
+
deltas: Sequence[float], *, beta: float, early_margin_scale: float, threshold: float
|
| 654 |
+
) -> dict:
|
| 655 |
+
"""Decide whether a single-GPU reference cache is usable under FSDP2.
|
| 656 |
+
|
| 657 |
+
`deltas` are per-pair differences of the REFERENCE MARGIN (ref_c - ref_r)
|
| 658 |
+
between the single-GPU forward and the sharded forward. The two are not
|
| 659 |
+
bitwise identical -- different reduction trees, different collective
|
| 660 |
+
orders -- and the question is whether the difference matters.
|
| 661 |
+
|
| 662 |
+
It matters more here than anywhere else in the project. DPO's loss
|
| 663 |
+
argument is beta * (pi_margin - ref_margin). Early in the run the policy
|
| 664 |
+
IS the reference, so that argument sits at zero, which is where the
|
| 665 |
+
log-sigmoid's gradient is largest and where a systematic offset in
|
| 666 |
+
ref_margin is least attenuated. A bias that would be invisible against a
|
| 667 |
+
large margin is fully visible against a zero one.
|
| 668 |
+
|
| 669 |
+
The rule compares max|delta| to the early-margin scale. Beta multiplies
|
| 670 |
+
the perturbation and the reference scale alike, so it CANCELS out of the
|
| 671 |
+
ratio; it stays in the signature because the receipt must record the beta
|
| 672 |
+
the decision was made under, and because a reader who does not see the
|
| 673 |
+
cancellation stated will assume it was forgotten.
|
| 674 |
+
|
| 675 |
+
NaN is not small. A NaN delta means a pair could not be compared, and an
|
| 676 |
+
uncomparable pair forces the firing path rather than comparing False and
|
| 677 |
+
vanishing. So does an empty measurement: not measuring is not passing.
|
| 678 |
+
"""
|
| 679 |
+
if not (early_margin_scale > 0) or math.isnan(early_margin_scale):
|
| 680 |
+
raise ValueError(
|
| 681 |
+
f"early_margin_scale={early_margin_scale!r} must be a positive "
|
| 682 |
+
"measured scale; it is the denominator of the decision ratio and "
|
| 683 |
+
"a zero or NaN one makes the decision undefined rather than lenient"
|
| 684 |
+
)
|
| 685 |
+
if not (threshold > 0):
|
| 686 |
+
raise ValueError(f"threshold={threshold!r} must be positive")
|
| 687 |
+
|
| 688 |
+
vals = [float(d) for d in deltas]
|
| 689 |
+
n_nan = sum(1 for d in vals if math.isnan(d))
|
| 690 |
+
if not vals or n_nan:
|
| 691 |
+
detail = (
|
| 692 |
+
"no deltas were measured; not measuring is not passing"
|
| 693 |
+
if not vals
|
| 694 |
+
else f"{n_nan} of {len(vals)} deltas are NaN and a NaN delta is not a small delta"
|
| 695 |
+
)
|
| 696 |
+
return {
|
| 697 |
+
"fired": True,
|
| 698 |
+
"action": "rerun_precompute_sharded",
|
| 699 |
+
"ratio": NAN,
|
| 700 |
+
"max_abs_delta": NAN,
|
| 701 |
+
"mean_delta": NAN,
|
| 702 |
+
"median_abs_delta": NAN,
|
| 703 |
+
"p95_abs_delta": NAN,
|
| 704 |
+
"n": len(vals),
|
| 705 |
+
"n_nan": n_nan,
|
| 706 |
+
"beta": beta,
|
| 707 |
+
"early_margin_scale": early_margin_scale,
|
| 708 |
+
"threshold": threshold,
|
| 709 |
+
"detail": detail,
|
| 710 |
+
}
|
| 711 |
+
|
| 712 |
+
absd = sorted(abs(d) for d in vals)
|
| 713 |
+
max_abs = absd[-1]
|
| 714 |
+
ratio = max_abs / early_margin_scale
|
| 715 |
+
fired = ratio > threshold
|
| 716 |
+
return {
|
| 717 |
+
"fired": fired,
|
| 718 |
+
"action": ("rerun_precompute_sharded" if fired else "keep_single_gpu_cache"),
|
| 719 |
+
"ratio": ratio,
|
| 720 |
+
"max_abs_delta": max_abs,
|
| 721 |
+
"mean_delta": sum(vals) / len(vals),
|
| 722 |
+
"median_abs_delta": _quantile(absd, 0.5),
|
| 723 |
+
"p95_abs_delta": _quantile(absd, 0.95),
|
| 724 |
+
"n": len(vals),
|
| 725 |
+
"n_nan": 0,
|
| 726 |
+
"beta": beta,
|
| 727 |
+
"early_margin_scale": early_margin_scale,
|
| 728 |
+
"threshold": threshold,
|
| 729 |
+
"detail": (
|
| 730 |
+
f"max|delta| {max_abs:.3e} is "
|
| 731 |
+
f"{'above' if fired else 'within'} {threshold:g} x the "
|
| 732 |
+
f"early-margin scale {early_margin_scale:g}"
|
| 733 |
+
),
|
| 734 |
+
}
|
| 735 |
+
|
| 736 |
+
|
| 737 |
+
def _quantile(sorted_vals: Sequence[float], q: float) -> float:
|
| 738 |
+
if not sorted_vals:
|
| 739 |
+
return NAN
|
| 740 |
+
idx = min(len(sorted_vals) - 1, max(0, int(round(q * (len(sorted_vals) - 1)))))
|
| 741 |
+
return float(sorted_vals[idx])
|
| 742 |
+
|
| 743 |
+
|
| 744 |
+
def batch_invariance_report(
|
| 745 |
+
model,
|
| 746 |
+
rows: Sequence[Any],
|
| 747 |
+
*,
|
| 748 |
+
key: RefCacheKey,
|
| 749 |
+
pad_token_id: int,
|
| 750 |
+
beta: float,
|
| 751 |
+
early_margin_scale: float,
|
| 752 |
+
threshold: float,
|
| 753 |
+
batch_sizes: Sequence[int] = (2, 4, 8, 16),
|
| 754 |
+
) -> dict:
|
| 755 |
+
"""Batched-vs-single-row precompute deltas, as a receipt.
|
| 756 |
+
|
| 757 |
+
A SECOND path-bias question, distinct from the single-GPU-vs-sharded one
|
| 758 |
+
and cheaper to answer: does it matter how many pairs share a forward?
|
| 759 |
+
Right-padding plus causal masking makes the value grouping-independent in
|
| 760 |
+
exact arithmetic; the fp32 reduction order is not, so the honest answer is
|
| 761 |
+
a measurement.
|
| 762 |
+
|
| 763 |
+
It decides one thing: whether `batch_size` belongs in the cache key. It is
|
| 764 |
+
judged by the SAME rule as the sharded comparison (`path_bias_decision`),
|
| 765 |
+
because it is the same kind of error entering the same place -- a
|
| 766 |
+
perturbation of the reference margin, read at the point where the loss
|
| 767 |
+
argument sits near zero.
|
| 768 |
+
|
| 769 |
+
Reported per batch size, plus the worst case across all of them. A
|
| 770 |
+
systematic MEAN delta matters more than a large max: symmetric rounding
|
| 771 |
+
averages out of the gradient, a consistent offset does not.
|
| 772 |
+
"""
|
| 773 |
+
single = precompute_ref_logprobs(model, rows, key=key, pad_token_id=pad_token_id, batch_size=1)
|
| 774 |
+
per_size = {}
|
| 775 |
+
worst = None
|
| 776 |
+
for bs in batch_sizes:
|
| 777 |
+
got = precompute_ref_logprobs(
|
| 778 |
+
model, rows, key=key, pad_token_id=pad_token_id, batch_size=bs
|
| 779 |
+
)
|
| 780 |
+
deltas = [
|
| 781 |
+
(got.entries[p][0] - got.entries[p][1]) - (single.entries[p][0] - single.entries[p][1])
|
| 782 |
+
for p in single.entries
|
| 783 |
+
]
|
| 784 |
+
d = path_bias_decision(
|
| 785 |
+
deltas, beta=beta, early_margin_scale=early_margin_scale, threshold=threshold
|
| 786 |
+
)
|
| 787 |
+
per_size[str(bs)] = d
|
| 788 |
+
if worst is None or (not math.isnan(d["ratio"]) and d["ratio"] > worst["ratio"]):
|
| 789 |
+
worst = d
|
| 790 |
+
return {
|
| 791 |
+
"comparison": "batched_vs_single_row",
|
| 792 |
+
"n_examples": len(list(rows)),
|
| 793 |
+
"batch_sizes": list(batch_sizes),
|
| 794 |
+
"per_batch_size": per_size,
|
| 795 |
+
"worst": worst,
|
| 796 |
+
"belongs_in_cache_key": bool(worst and worst["fired"]),
|
| 797 |
+
"detail": (
|
| 798 |
+
"if belongs_in_cache_key is true, batch_size must join the "
|
| 799 |
+
"six key components; measured false means regrouping is "
|
| 800 |
+
"below the threshold that governs the sharded comparison"
|
| 801 |
+
),
|
| 802 |
+
}
|
| 803 |
+
|
| 804 |
+
|
| 805 |
+
def write_path_bias_report(path: str, decision: dict, *, n_examples: int, forward_impl: str) -> str:
|
| 806 |
+
"""The decision is written, not remembered: precompute and training are
|
| 807 |
+
separate heavy jobs (serialized by the flock, never concurrent) in
|
| 808 |
+
different processes, and the banner reads this file."""
|
| 809 |
+
from ..utils.guard import assert_paths_off_root
|
| 810 |
+
|
| 811 |
+
assert_paths_off_root([os.path.dirname(os.path.abspath(path))])
|
| 812 |
+
payload = dict(decision)
|
| 813 |
+
payload["n_examples"] = int(n_examples)
|
| 814 |
+
payload["cache_forward_impl"] = forward_impl
|
| 815 |
+
def _dump(tmp: str) -> None:
|
| 816 |
+
with open(tmp, "w") as f:
|
| 817 |
+
json.dump(payload, f, indent=2)
|
| 818 |
+
|
| 819 |
+
_atomic_write(path, _dump)
|
| 820 |
+
logger.info("path-bias report -> %s (fired=%s)", path, decision["fired"])
|
| 821 |
+
return path
|
| 822 |
+
|
| 823 |
+
|
| 824 |
+
def read_path_bias_report(path: str) -> dict:
|
| 825 |
+
if not os.path.isfile(path):
|
| 826 |
+
raise FileNotFoundError(
|
| 827 |
+
f"path-bias report {path!r} is missing. The measurement is "
|
| 828 |
+
"MANDATORY before a DPO launch: a sharded forward that disagrees "
|
| 829 |
+
"with the cached single-GPU reference biases the loss argument "
|
| 830 |
+
"exactly where it is most sensitive. Run "
|
| 831 |
+
"scripts/precompute_ref_logprobs.py --path-bias."
|
| 832 |
+
)
|
| 833 |
+
with open(path) as f:
|
| 834 |
+
return json.load(f)
|
code/align/train/sft_loss.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SFT loss adapter: collated batch -> section-05's chunked fused CE.
|
| 2 |
+
|
| 3 |
+
LOSS NORMALISATION (G3 audit U03-2). DEFAULT UNCHANGED; the global token mean is OPT-IN via
|
| 4 |
+
`train.loss_normalization = "global_token_mean"` (Trainer window look-ahead +
|
| 5 |
+
`TrainModule.train_batch(window_tokens=...)`; this adapter supplies `count_tokens`).
|
| 6 |
+
The paragraph below describes the default, "micro_mean".
|
| 7 |
+
The returned loss is the TOKEN MEAN OF ONE MICRO-BATCH, and `n_tokens` is only
|
| 8 |
+
reported. Under grad-accum A and world W the window gradient is therefore the
|
| 9 |
+
mean of A*W micro-batch means -- not the gradient of the global per-token mean
|
| 10 |
+
(sum CE / sum n_tokens): a target token carries weight 1/(A*W*n_micro), so with
|
| 11 |
+
token-budget batches whose target counts differ (280 vs 6) the short batch's
|
| 12 |
+
tokens weigh ~47x more. The claim "matches one large batch" holds only for
|
| 13 |
+
equal-n micro-batches. Making it a global token mean needs the window's token
|
| 14 |
+
total (a look-ahead over the window's batches plus a cross-rank reduce) and the
|
| 15 |
+
adapter returning CE_sum; that changes the objective every recorded SFT run
|
| 16 |
+
was trained under, so it is a protocol decision, not a bug fix made here.
|
| 17 |
+
|
| 18 |
+
The adapter owns plumbing only -- label shifting, masking, token accounting --
|
| 19 |
+
and section-05 owns the kernel. Keeping the shift on THIS side (caller-shifted
|
| 20 |
+
views) is the arbitrated contract: `fused_linear_cross_entropy` takes hidden
|
| 21 |
+
states and labels that already line up, so there is exactly one place in the
|
| 22 |
+
repo that decides what predicts what.
|
| 23 |
+
|
| 24 |
+
Why the fused path matters, so the reason survives the handoff: at seq 1024 and
|
| 25 |
+
micro-batch 8 the `[B*T, 32768]` logit tensor is 1.0GB in bf16 and its gradient
|
| 26 |
+
is another 1.0GB. Materialising both spends 2GB of a 23GB card on a tensor
|
| 27 |
+
nobody reads. The fused loss consumes HIDDEN STATES and applies the LM head per
|
| 28 |
+
chunk, which is why `NmoMistralForCausalLM.forward_hidden_states` exists as a
|
| 29 |
+
separate entry point.
|
| 30 |
+
"""
|
| 31 |
+
|
| 32 |
+
from __future__ import annotations
|
| 33 |
+
|
| 34 |
+
import logging
|
| 35 |
+
from typing import Any, Optional
|
| 36 |
+
|
| 37 |
+
import torch
|
| 38 |
+
import torch.nn.functional as F
|
| 39 |
+
|
| 40 |
+
from .trainer import LossOutput
|
| 41 |
+
|
| 42 |
+
logger = logging.getLogger(__name__)
|
| 43 |
+
|
| 44 |
+
#: Section-05's kernel module. Named here so the both-directions test can block
|
| 45 |
+
#: the import and exercise the refusal branch.
|
| 46 |
+
FUSED_CE_MODULE = "align.kernels.fused_ce"
|
| 47 |
+
|
| 48 |
+
#: Fallback only. The authority is section-05's constant (imported below when
|
| 49 |
+
#: available) and section-03's collator, which both use -100; this literal
|
| 50 |
+
#: exists so the module still loads if the kernel is missing.
|
| 51 |
+
IGNORE_INDEX = -100
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _load_fused():
|
| 55 |
+
"""Return (fused_fn, ignore_index) or (None, IGNORE_INDEX)."""
|
| 56 |
+
try:
|
| 57 |
+
import importlib
|
| 58 |
+
|
| 59 |
+
mod = importlib.import_module(FUSED_CE_MODULE)
|
| 60 |
+
return mod.fused_linear_cross_entropy, mod.IGNORE_INDEX
|
| 61 |
+
except Exception: # noqa: BLE001
|
| 62 |
+
return None, IGNORE_INDEX
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def fused_ce_available() -> bool:
|
| 66 |
+
return _load_fused()[0] is not None
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def make_sft_loss_adapter(chunk_size: Optional[int] = None, require_fused: bool = False):
|
| 70 |
+
"""Build the SFT loss adapter the Trainer calls once per micro-step.
|
| 71 |
+
|
| 72 |
+
`require_fused=True` refuses to run on the reference path -- which is what
|
| 73 |
+
the 7B stage scripts pass, so a GPU run cannot quietly spend 2GB of card on
|
| 74 |
+
a materialised logit tensor because section-05's import broke.
|
| 75 |
+
"""
|
| 76 |
+
fused_fn, ignore_index = _load_fused()
|
| 77 |
+
if require_fused and fused_fn is None:
|
| 78 |
+
raise RuntimeError(
|
| 79 |
+
f"the fused chunked cross-entropy ({FUSED_CE_MODULE}."
|
| 80 |
+
"fused_linear_cross_entropy) is not importable. Refusing to fall "
|
| 81 |
+
"back to the reference path for a full-scale run: it materialises "
|
| 82 |
+
"the [B*T, 32768] logit tensor AND its gradient, ~2GB of a 23GB "
|
| 83 |
+
"card at seq 1024."
|
| 84 |
+
)
|
| 85 |
+
if fused_fn is None:
|
| 86 |
+
logger.warning(
|
| 87 |
+
"%s unavailable; using the reference path. Correct, and "
|
| 88 |
+
"memory-hungry -- small-model use only.",
|
| 89 |
+
FUSED_CE_MODULE,
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
kwargs = {} if chunk_size is None else {"chunk_size": chunk_size}
|
| 93 |
+
|
| 94 |
+
def adapter(model, batch) -> LossOutput:
|
| 95 |
+
input_ids, labels = _unpack(batch)
|
| 96 |
+
# Caller-shifted: position t predicts token t+1. Done once, here.
|
| 97 |
+
target = labels[:, 1:]
|
| 98 |
+
n_tokens = int((target != ignore_index).sum())
|
| 99 |
+
if n_tokens == 0:
|
| 100 |
+
raise ValueError(
|
| 101 |
+
"SFT batch has no unmasked target tokens: every position is "
|
| 102 |
+
f"ignore_index ({ignore_index}). The mean cross-entropy over "
|
| 103 |
+
"zero positions is undefined, and returning 0.0 would report a "
|
| 104 |
+
"perfectly fit batch. Check prompt_len against sequence length "
|
| 105 |
+
"in the collator."
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
if fused_fn is not None:
|
| 109 |
+
# The loss runs INSIDE the model's forward via `hidden_consumer`,
|
| 110 |
+
# so section-05 still owns the lm_head matmul while FSDP2 has both
|
| 111 |
+
# the hidden states and lm_head.weight gathered. Computing it
|
| 112 |
+
# after the forward returned would see `lm_head.weight` re-sharded
|
| 113 |
+
# -- the mixed-DTensor failure that killed the first 4-rank smoke.
|
| 114 |
+
def _consume(hidden, lm_head_weight):
|
| 115 |
+
return fused_fn(
|
| 116 |
+
hidden[:, :-1], lm_head_weight, target, ignore_index=ignore_index, **kwargs
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
loss = model(input_ids=input_ids, hidden_consumer=_consume)
|
| 120 |
+
else:
|
| 121 |
+
logits = model(input_ids=input_ids)
|
| 122 |
+
loss = F.cross_entropy(
|
| 123 |
+
logits[:, :-1].reshape(-1, logits.shape[-1]).float(),
|
| 124 |
+
target.reshape(-1),
|
| 125 |
+
ignore_index=ignore_index,
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
return LossOutput(loss=loss, n_tokens=n_tokens, metrics={"tokens_in_loss": float(n_tokens)})
|
| 129 |
+
|
| 130 |
+
def count_tokens(batch) -> int:
|
| 131 |
+
"""Unmasked target tokens of `batch` (what `n_tokens` will be), without a forward pass.
|
| 132 |
+
The Trainer sums this over a look-ahead window for the global token mean."""
|
| 133 |
+
_, labels = _unpack(batch)
|
| 134 |
+
return int((labels[:, 1:] != ignore_index).sum())
|
| 135 |
+
|
| 136 |
+
adapter.uses_fused = fused_fn is not None
|
| 137 |
+
adapter.count_tokens = count_tokens
|
| 138 |
+
return adapter
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def _unpack(batch: Any) -> tuple[torch.Tensor, torch.Tensor]:
|
| 142 |
+
"""Accept either a `Collated` from section-03 or a plain (ids, labels)."""
|
| 143 |
+
if hasattr(batch, "input_ids") and hasattr(batch, "labels"):
|
| 144 |
+
return batch.input_ids, batch.labels
|
| 145 |
+
if isinstance(batch, (tuple, list)) and len(batch) == 2:
|
| 146 |
+
return batch[0], batch[1]
|
| 147 |
+
raise TypeError(
|
| 148 |
+
f"SFT loss adapter got {type(batch).__name__}; expected a Collated "
|
| 149 |
+
"batch (input_ids, labels) from align.data.loader.collate_sft"
|
| 150 |
+
)
|
code/align/train/sharded_ckpt.py
ADDED
|
@@ -0,0 +1,1285 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sharded streaming checkpoints: every rank writes ONLY the weights it owns.
|
| 2 |
+
|
| 3 |
+
OPT-IN (`train.checkpoint_format = "sharded"`; the default `"gathered"` is
|
| 4 |
+
untouched). Runtime deps: torch + stdlib. The converter half (`export_hf`,
|
| 5 |
+
`reshard`) never touches a GPU and never imports transformers/safetensors.
|
| 6 |
+
|
| 7 |
+
THE PROBLEM, MEASURED (docs/MEASUREMENTS.md, Track M, OLMoE-1B-7B, 4 ranks)
|
| 8 |
+
* save 228-241 s, rank-0 RSS peak +28 GB. `get_model_state_dict(
|
| 9 |
+
full_state_dict=True, cpu_offload=True)` is a full all-gather into rank 0's
|
| 10 |
+
host RAM (1x weights), and the HF converter then builds a second dict of
|
| 11 |
+
cloned expert slices plus `.contiguous()` copies (the other 1x): 2x the
|
| 12 |
+
weights on ONE rank of a no-swap, shared box -- the 2026-08-12 OOM shape.
|
| 13 |
+
Ranks 1..N-1 sit in a barrier for the whole four minutes.
|
| 14 |
+
* resume peaks at 90 GiB SUMMED RSS across ranks: `resume_from` reads the
|
| 15 |
+
whole `model.safetensors` on EVERY rank and then slices out the 1/N it
|
| 16 |
+
owns.
|
| 17 |
+
OLMo-core avoids both with distributed checkpointing (per-rank files, no full
|
| 18 |
+
gather). This is the same idea at the size this repo needs.
|
| 19 |
+
|
| 20 |
+
LAYOUT (a checkpoint directory in sharded mode)
|
| 21 |
+
model_rank00000.safetensors ... one per rank; that rank's LOCAL shards,
|
| 22 |
+
bf16, standard safetensors (any reader
|
| 23 |
+
opens them), written tensor by tensor
|
| 24 |
+
optimizer_rank{r}.pt unchanged: the existing per-rank optimizer
|
| 25 |
+
files (sharded mode implies them)
|
| 26 |
+
shard_manifest.json global shapes, placements, per-rank row
|
| 27 |
+
ranges, world size, per-file bytes and
|
| 28 |
+
sha256, the HF export recipe
|
| 29 |
+
resume.pt unchanged loop state (no weights)
|
| 30 |
+
tokenizer files as in the gathered layout
|
| 31 |
+
The manifest is written last, inside the `.partial` staging directory, and the
|
| 32 |
+
directory is published by the existing fsync-then-rename protocol. A directory
|
| 33 |
+
without a complete manifest and every rank file the manifest names is not a
|
| 34 |
+
checkpoint (`sharded_problem`, called from `trainer.checkpoint_problem`).
|
| 35 |
+
|
| 36 |
+
WHAT EACH PIECE PREVENTS (incident-first, repo style)
|
| 37 |
+
|
| 38 |
+
THE WRONG-SHARD DEFECT. A rank's file holds rows [off, off+n) of each tensor.
|
| 39 |
+
Loaded under a different world size, rank r would receive rows cut for another
|
| 40 |
+
partitioning and training would continue plausibly on scrambled weights --
|
| 41 |
+
nothing crashes, the loss is merely worse. Resume therefore reads the manifest
|
| 42 |
+
FIRST and REFUSES a different world size with a message naming the offline
|
| 43 |
+
reshard (`scripts/ckpt_convert.py reshard`); it never "tries" a mismatch. The
|
| 44 |
+
per-row ranges recorded in the manifest are also compared against the LIVE
|
| 45 |
+
model's own sharding tensor by tensor, so a model rebuilt with a different mesh
|
| 46 |
+
or an uneven-sharding change is caught even at the same world size.
|
| 47 |
+
|
| 48 |
+
THE SILENT-CORRUPTION DEFECT. Per-file sha256 is recorded at save and checked
|
| 49 |
+
by each rank, on its OWN file, before any weight is copied. The verdict is then
|
| 50 |
+
reduced across ranks (`any_rank`): a bit-rotted file on rank 2 alone would
|
| 51 |
+
otherwise make rank 2 raise while ranks 0/1/3 entered the next collective and
|
| 52 |
+
hung until the watchdog -- the lone-rank shape every other decision in the
|
| 53 |
+
trainer avoids. The save side uses the same reduction: a rank whose disk fills
|
| 54 |
+
mid-write must stop its peers at once, not leave them in a gather for the
|
| 55 |
+
watchdog's 30 minutes.
|
| 56 |
+
|
| 57 |
+
THE TWO-COPIES-ON-RANK-0 DEFECT (the measured 28 GB). There is no gather. Each
|
| 58 |
+
rank's peak is ONE tensor (`emit` copies one local shard to host, writes it,
|
| 59 |
+
drops it). The export side is byte-copy `pread` -> `write` in 8 MiB pieces: for
|
| 60 |
+
1:1 families it never builds a tensor at all.
|
| 61 |
+
|
| 62 |
+
THE PARTIAL-WRITE DEFECT (same as the gathered format). Rank files are written
|
| 63 |
+
to `*.tmp`, fsynced, renamed into the `.partial` staging dir; the staging dir
|
| 64 |
+
is fsync_tree'd and renamed to `micro_N` only after rank 0 has the manifest of
|
| 65 |
+
every rank. A kill anywhere leaves a `.partial` that `_discard_partials`
|
| 66 |
+
removes at the next start, never a `micro_N` that looks resumable.
|
| 67 |
+
|
| 68 |
+
WHAT THIS DOES NOT DO (read before relying on it)
|
| 69 |
+
* It is not loadable by `eval_checkpoint.py` / the rollout weight sync /
|
| 70 |
+
`ref_cache` until exported: those read `model.safetensors`. Export is
|
| 71 |
+
`ckpt_convert.py export` (streaming, bounded memory).
|
| 72 |
+
* Placements: `Shard(0)` and `Replicate()` on a 1-D mesh (what
|
| 73 |
+
`fully_shard` produces). Anything else (HSDP, TP, Shard(d>0)) is refused
|
| 74 |
+
at SAVE time, not discovered at resume.
|
| 75 |
+
* A train module that changes what "the weights" mean (LoRA folds an
|
| 76 |
+
adapter into merged weights; PPO has its own gather) must provide
|
| 77 |
+
`sharded_model_state()` / `load_sharded_model_state()` or the Trainer
|
| 78 |
+
refuses `"sharded"` at construction. Silent fallback to the base-class
|
| 79 |
+
behaviour would checkpoint the UNMERGED base under a merged name.
|
| 80 |
+
"""
|
| 81 |
+
|
| 82 |
+
from __future__ import annotations
|
| 83 |
+
|
| 84 |
+
import hashlib
|
| 85 |
+
import json
|
| 86 |
+
import logging
|
| 87 |
+
import os
|
| 88 |
+
import re
|
| 89 |
+
import shutil
|
| 90 |
+
import struct
|
| 91 |
+
from dataclasses import dataclass
|
| 92 |
+
from typing import Any, Callable, Iterable, Mapping, Optional
|
| 93 |
+
|
| 94 |
+
import torch
|
| 95 |
+
|
| 96 |
+
logger = logging.getLogger(__name__)
|
| 97 |
+
|
| 98 |
+
SHARD_MANIFEST_NAME = "shard_manifest.json"
|
| 99 |
+
FORMAT_NAME = "align-sharded-ckpt"
|
| 100 |
+
FORMAT_VERSION = 1
|
| 101 |
+
CHECKPOINT_FORMATS = ("gathered", "sharded")
|
| 102 |
+
|
| 103 |
+
#: Bytes moved per pread/write while exporting or resharding. Peak RSS of the
|
| 104 |
+
#: converter is a small multiple of this plus one optimizer tensor, whatever the
|
| 105 |
+
#: model size.
|
| 106 |
+
COPY_CHUNK = 8 * 1024 * 1024
|
| 107 |
+
|
| 108 |
+
#: Files in a sharded checkpoint directory that are NOT tokenizer/config
|
| 109 |
+
#: payload (the exporter copies everything else next to the weights).
|
| 110 |
+
_OWN_FILE_RE = re.compile(
|
| 111 |
+
r"^(model_rank\d+\.safetensors(\.tmp)?|optimizer_rank\d+\.pt(\.tmp)?|resume\.pt|"
|
| 112 |
+
+ r"integrity\.json|"
|
| 113 |
+
+ re.escape(SHARD_MANIFEST_NAME)
|
| 114 |
+
+ r")$"
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
class ShardedCheckpointError(RuntimeError):
|
| 119 |
+
"""A sharded checkpoint that is malformed, mismatched, or corrupt."""
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
class CheckpointIntegrityError(ShardedCheckpointError):
|
| 123 |
+
"""A payload file's sha256 differs from the one recorded at save time."""
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
class ShardedWorldSizeError(ShardedCheckpointError):
|
| 127 |
+
"""Resume under a world size other than the one that saved the shards."""
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
# ---------------------------------------------------------------------------
|
| 131 |
+
# dtypes
|
| 132 |
+
# ---------------------------------------------------------------------------
|
| 133 |
+
|
| 134 |
+
_ST_TO_TORCH = {
|
| 135 |
+
"BOOL": torch.bool,
|
| 136 |
+
"U8": torch.uint8,
|
| 137 |
+
"I8": torch.int8,
|
| 138 |
+
"I16": torch.int16,
|
| 139 |
+
"F16": torch.float16,
|
| 140 |
+
"BF16": torch.bfloat16,
|
| 141 |
+
"I32": torch.int32,
|
| 142 |
+
"F32": torch.float32,
|
| 143 |
+
"F64": torch.float64,
|
| 144 |
+
"I64": torch.int64,
|
| 145 |
+
}
|
| 146 |
+
_TORCH_TO_ST = {v: k for k, v in _ST_TO_TORCH.items()}
|
| 147 |
+
#: safetensors serialises tensors sorted by (dtype DESC, name); this is the
|
| 148 |
+
#: dtype order of its enum. Byte-identical output to `save_file` is a tested
|
| 149 |
+
#: property (it is what lets "export equals gathered export" be a file hash).
|
| 150 |
+
_ST_ORDER = ["BOOL", "U8", "I8", "I16", "F16", "BF16", "I32", "F32", "F64", "I64"]
|
| 151 |
+
_ST_ITEMSIZE = {"BOOL": 1, "U8": 1, "I8": 1, "I16": 2, "F16": 2, "BF16": 2, "I32": 4, "F32": 4, "F64": 8, "I64": 8}
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def _prod(shape: Iterable[int]) -> int:
|
| 155 |
+
n = 1
|
| 156 |
+
for s in shape:
|
| 157 |
+
n *= int(s)
|
| 158 |
+
return n
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def chunk_range(n: int, world_size: int, rank: int) -> tuple[int, int]:
|
| 162 |
+
"""(row offset, row count) of `rank`'s `Shard(0)` slice of `n` rows.
|
| 163 |
+
|
| 164 |
+
`torch.chunk` semantics, which is what `DTensor` `Shard(0)` uses: ceil(n/ws)
|
| 165 |
+
rows per rank, the tail ranks shorter or EMPTY (5 rows over 4 ranks is
|
| 166 |
+
2,2,1,0). Evenly dividing would put row 4 nowhere. One function for save,
|
| 167 |
+
load, reshard and export so they cannot disagree about who owns a row.
|
| 168 |
+
"""
|
| 169 |
+
c = -(-int(n) // int(world_size))
|
| 170 |
+
off = min(int(rank) * c, int(n))
|
| 171 |
+
return off, min(c, int(n) - off)
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
# ---------------------------------------------------------------------------
|
| 175 |
+
# safetensors, streaming (writer) and random-access (reader)
|
| 176 |
+
# ---------------------------------------------------------------------------
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
@dataclass
|
| 180 |
+
class StEntry:
|
| 181 |
+
"""One tensor to be written: `emit(f)` must write exactly `nbytes` bytes."""
|
| 182 |
+
|
| 183 |
+
name: str
|
| 184 |
+
st_dtype: str
|
| 185 |
+
shape: tuple
|
| 186 |
+
emit: Callable[[Any], None]
|
| 187 |
+
|
| 188 |
+
@property
|
| 189 |
+
def nbytes(self) -> int:
|
| 190 |
+
return _prod(self.shape) * _ST_ITEMSIZE[self.st_dtype]
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def _st_sort_key(e: StEntry):
|
| 194 |
+
return (-_ST_ORDER.index(e.st_dtype), e.name)
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def safetensors_header(entries: list[StEntry], metadata: Optional[Mapping[str, str]] = None) -> tuple[bytes, list[StEntry]]:
|
| 198 |
+
"""(8-byte length prefix + JSON header padded to 8 bytes, entries in file order)."""
|
| 199 |
+
ordered = sorted(entries, key=_st_sort_key)
|
| 200 |
+
header: dict[str, Any] = {}
|
| 201 |
+
if metadata:
|
| 202 |
+
header["__metadata__"] = dict(metadata)
|
| 203 |
+
off = 0
|
| 204 |
+
for e in ordered:
|
| 205 |
+
header[e.name] = {"dtype": e.st_dtype, "shape": [int(s) for s in e.shape], "data_offsets": [off, off + e.nbytes]}
|
| 206 |
+
off += e.nbytes
|
| 207 |
+
raw = json.dumps(header, separators=(",", ":")).encode("utf-8")
|
| 208 |
+
raw += b" " * ((-len(raw)) % 8)
|
| 209 |
+
return struct.pack("<Q", len(raw)) + raw, ordered
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
class _HashingWriter:
|
| 213 |
+
def __init__(self, f):
|
| 214 |
+
self.f = f
|
| 215 |
+
self.sha = hashlib.sha256()
|
| 216 |
+
self.n = 0
|
| 217 |
+
|
| 218 |
+
def write(self, b) -> int:
|
| 219 |
+
self.sha.update(b)
|
| 220 |
+
self.n += len(b)
|
| 221 |
+
return self.f.write(b)
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def write_safetensors_streaming(
|
| 225 |
+
path: str, entries: list[StEntry], metadata: Optional[Mapping[str, str]] = None
|
| 226 |
+
) -> dict:
|
| 227 |
+
"""Write `path` tensor by tensor, fsync it, return {"bytes", "sha256"}.
|
| 228 |
+
|
| 229 |
+
Peak memory is whatever ONE `emit` holds. `safetensors.save_file` needs the
|
| 230 |
+
whole dict resident, which is the 2x-weights transient this module exists
|
| 231 |
+
to remove.
|
| 232 |
+
"""
|
| 233 |
+
head, ordered = safetensors_header(entries, metadata)
|
| 234 |
+
with open(path, "wb") as raw:
|
| 235 |
+
w = _HashingWriter(raw)
|
| 236 |
+
w.write(head)
|
| 237 |
+
for e in ordered:
|
| 238 |
+
before = w.n
|
| 239 |
+
e.emit(w)
|
| 240 |
+
if w.n - before != e.nbytes:
|
| 241 |
+
raise ShardedCheckpointError(
|
| 242 |
+
f"{e.name}: emitted {w.n - before} bytes, header promised {e.nbytes}"
|
| 243 |
+
)
|
| 244 |
+
raw.flush()
|
| 245 |
+
os.fsync(raw.fileno())
|
| 246 |
+
return {"bytes": w.n, "sha256": w.sha.hexdigest()}
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
def tensor_bytes_emitter(t: torch.Tensor) -> Callable[[Any], None]:
|
| 250 |
+
def emit(f):
|
| 251 |
+
c = t.detach().contiguous().reshape(-1)
|
| 252 |
+
if c.numel():
|
| 253 |
+
f.write(memoryview(c.view(torch.uint8).numpy()))
|
| 254 |
+
|
| 255 |
+
return emit
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
class ShardFile:
|
| 259 |
+
"""Random-access reader over one safetensors file (header parsed once)."""
|
| 260 |
+
|
| 261 |
+
def __init__(self, path: str):
|
| 262 |
+
self.path = path
|
| 263 |
+
self.fd = os.open(path, os.O_RDONLY)
|
| 264 |
+
try:
|
| 265 |
+
size = os.fstat(self.fd).st_size
|
| 266 |
+
if size < 8:
|
| 267 |
+
raise ShardedCheckpointError(f"{os.path.basename(path)} is {size} bytes (no header)")
|
| 268 |
+
(n,) = struct.unpack("<Q", os.pread(self.fd, 8, 0))
|
| 269 |
+
if n > size - 8 or n > 256 * 2**20:
|
| 270 |
+
raise ShardedCheckpointError(f"{os.path.basename(path)} header claims {n} bytes of {size}")
|
| 271 |
+
header = json.loads(os.pread(self.fd, n, 8))
|
| 272 |
+
self.data_start = 8 + n
|
| 273 |
+
self.tensors: dict[str, tuple[str, tuple, int, int]] = {}
|
| 274 |
+
end = 0
|
| 275 |
+
for k, v in header.items():
|
| 276 |
+
if k == "__metadata__":
|
| 277 |
+
continue
|
| 278 |
+
a, b = v["data_offsets"]
|
| 279 |
+
self.tensors[k] = (v["dtype"], tuple(v["shape"]), int(a), int(b))
|
| 280 |
+
end = max(end, int(b))
|
| 281 |
+
if size < self.data_start + end:
|
| 282 |
+
raise ShardedCheckpointError(
|
| 283 |
+
f"{os.path.basename(path)} truncated: {size} bytes, header needs {self.data_start + end}"
|
| 284 |
+
)
|
| 285 |
+
except BaseException:
|
| 286 |
+
os.close(self.fd)
|
| 287 |
+
raise
|
| 288 |
+
|
| 289 |
+
def close(self) -> None:
|
| 290 |
+
if self.fd >= 0:
|
| 291 |
+
os.close(self.fd)
|
| 292 |
+
self.fd = -1
|
| 293 |
+
|
| 294 |
+
def __enter__(self):
|
| 295 |
+
return self
|
| 296 |
+
|
| 297 |
+
def __exit__(self, *a):
|
| 298 |
+
self.close()
|
| 299 |
+
|
| 300 |
+
def copy_range(self, name: str, lo: int, hi: int, sink) -> None:
|
| 301 |
+
"""Stream bytes [lo, hi) of tensor `name` into `sink.write`."""
|
| 302 |
+
_dt, _shape, a, b = self.tensors[name]
|
| 303 |
+
if not (0 <= lo <= hi <= b - a):
|
| 304 |
+
raise ShardedCheckpointError(f"{name}: byte range [{lo},{hi}) outside the {b - a}-byte tensor")
|
| 305 |
+
pos = self.data_start + a + lo
|
| 306 |
+
left = hi - lo
|
| 307 |
+
while left:
|
| 308 |
+
buf = os.pread(self.fd, min(COPY_CHUNK, left), pos)
|
| 309 |
+
if not buf:
|
| 310 |
+
raise ShardedCheckpointError(f"{self.path}: short read at {pos}")
|
| 311 |
+
sink.write(buf)
|
| 312 |
+
pos += len(buf)
|
| 313 |
+
left -= len(buf)
|
| 314 |
+
|
| 315 |
+
def tensor(self, name: str) -> torch.Tensor:
|
| 316 |
+
dt, shape, a, b = self.tensors[name]
|
| 317 |
+
torch_dt = _ST_TO_TORCH[dt]
|
| 318 |
+
if b == a:
|
| 319 |
+
return torch.empty(shape, dtype=torch_dt)
|
| 320 |
+
buf = bytearray(b - a)
|
| 321 |
+
mv = memoryview(buf)
|
| 322 |
+
pos, got = self.data_start + a, 0
|
| 323 |
+
while got < len(buf):
|
| 324 |
+
n = os.preadv(self.fd, [mv[got : got + COPY_CHUNK]], pos + got)
|
| 325 |
+
if n <= 0:
|
| 326 |
+
raise ShardedCheckpointError(f"{self.path}: short read of {name}")
|
| 327 |
+
got += n
|
| 328 |
+
return torch.frombuffer(buf, dtype=torch_dt).reshape(shape)
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
def file_sha256(path: str) -> str:
|
| 332 |
+
h = hashlib.sha256()
|
| 333 |
+
with open(path, "rb") as f:
|
| 334 |
+
while True:
|
| 335 |
+
b = f.read(COPY_CHUNK)
|
| 336 |
+
if not b:
|
| 337 |
+
break
|
| 338 |
+
h.update(b)
|
| 339 |
+
return h.hexdigest()
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
# ---------------------------------------------------------------------------
|
| 343 |
+
# payload integrity sidecar (audit U01-3)
|
| 344 |
+
# ---------------------------------------------------------------------------
|
| 345 |
+
|
| 346 |
+
#: `<ckpt>/integrity.json`: {"version": 1, "sha256": {file name: hex}} for the payloads the
|
| 347 |
+
#: weights manifest does not already cover (resume.pt, optimizer_rank*.pt, and the gathered
|
| 348 |
+
#: layout's model.safetensors). A torch.save zip passes the structural check after a same-size
|
| 349 |
+
#: bit flip in a stored tensor, so structure alone cannot catch silent media corruption.
|
| 350 |
+
INTEGRITY_NAME = "integrity.json"
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
def write_integrity(out_dir: str, sha256: Mapping[str, str]) -> None:
|
| 354 |
+
path = os.path.join(out_dir, INTEGRITY_NAME)
|
| 355 |
+
with open(path, "w", encoding="utf-8") as f:
|
| 356 |
+
f.write(json.dumps({"version": 1, "sha256": dict(sha256)}, indent=1, sort_keys=True) + "\n")
|
| 357 |
+
f.flush()
|
| 358 |
+
os.fsync(f.fileno())
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
def read_integrity(path: str) -> Optional[dict]:
|
| 362 |
+
"""{file name: sha256} or None for a checkpoint that predates the sidecar. A sidecar that
|
| 363 |
+
exists but cannot be parsed is an error, never 'legacy' (fail closed)."""
|
| 364 |
+
fp = os.path.join(path, INTEGRITY_NAME)
|
| 365 |
+
if not os.path.isfile(fp):
|
| 366 |
+
return None
|
| 367 |
+
try:
|
| 368 |
+
with open(fp, encoding="utf-8") as f:
|
| 369 |
+
blob = json.load(f)
|
| 370 |
+
out = {str(k): str(v) for k, v in blob["sha256"].items()}
|
| 371 |
+
except (OSError, ValueError, KeyError, AttributeError, TypeError) as e:
|
| 372 |
+
raise CheckpointIntegrityError(f"{fp} is unreadable ({type(e).__name__}: {e})") from e
|
| 373 |
+
return out
|
| 374 |
+
|
| 375 |
+
|
| 376 |
+
def verify_integrity(path: str, names: Iterable[str], recorded: Optional[dict] = None) -> Optional[str]:
|
| 377 |
+
"""None iff every name in `names` that the sidecar records matches its sha256.
|
| 378 |
+
|
| 379 |
+
A name the sidecar does not list is skipped (a legacy or partial sidecar); a listed file that
|
| 380 |
+
is missing or differs is a problem.
|
| 381 |
+
"""
|
| 382 |
+
recorded = read_integrity(path) if recorded is None else recorded
|
| 383 |
+
if recorded is None:
|
| 384 |
+
return None
|
| 385 |
+
for n in names:
|
| 386 |
+
want = recorded.get(n)
|
| 387 |
+
if want is None:
|
| 388 |
+
continue
|
| 389 |
+
fp = os.path.join(path, n)
|
| 390 |
+
if not os.path.isfile(fp):
|
| 391 |
+
return f"{n} is missing (integrity.json records it)"
|
| 392 |
+
got = file_sha256(fp)
|
| 393 |
+
if got != want:
|
| 394 |
+
return f"{n} sha256 {got[:12]}.. != recorded {want[:12]}.. (bit rot or a partial copy)"
|
| 395 |
+
return None
|
| 396 |
+
|
| 397 |
+
|
| 398 |
+
# ---------------------------------------------------------------------------
|
| 399 |
+
# describing a live (possibly DTensor) tensor
|
| 400 |
+
# ---------------------------------------------------------------------------
|
| 401 |
+
|
| 402 |
+
|
| 403 |
+
def _local_and_desc(t: torch.Tensor, rank: int, world_size: int) -> tuple[torch.Tensor, dict]:
|
| 404 |
+
"""(this rank's local tensor, descriptor) for a plain tensor or a DTensor.
|
| 405 |
+
|
| 406 |
+
Descriptor: global `shape`, `placement` ("shard0" | "replicate"), and for
|
| 407 |
+
shard0 this rank's `row` = [offset, count]. Everything else is refused HERE
|
| 408 |
+
(save time): a layout the loader cannot place must not produce a file.
|
| 409 |
+
"""
|
| 410 |
+
if hasattr(t, "to_local") and hasattr(t, "placements"):
|
| 411 |
+
pl = tuple(t.placements)
|
| 412 |
+
mesh = t.device_mesh
|
| 413 |
+
if mesh.ndim != 1 or mesh.size() != world_size:
|
| 414 |
+
raise ShardedCheckpointError(
|
| 415 |
+
f"unsupported mesh {tuple(mesh.shape)} for world_size={world_size}: sharded checkpoints "
|
| 416 |
+
"support a 1-D mesh over all ranks (what fully_shard builds). HSDP/TP meshes are not "
|
| 417 |
+
"implemented; use checkpoint_format='gathered'."
|
| 418 |
+
)
|
| 419 |
+
if mesh.get_local_rank() != rank:
|
| 420 |
+
raise ShardedCheckpointError(f"mesh local rank {mesh.get_local_rank()} != process rank {rank}")
|
| 421 |
+
local = t.to_local()
|
| 422 |
+
shape = tuple(int(s) for s in t.shape)
|
| 423 |
+
from torch.distributed.tensor import Replicate, Shard
|
| 424 |
+
|
| 425 |
+
if len(pl) == 1 and isinstance(pl[0], Shard) and pl[0].dim == 0:
|
| 426 |
+
off, n = chunk_range(shape[0], world_size, rank)
|
| 427 |
+
if tuple(local.shape) != (n,) + shape[1:]:
|
| 428 |
+
raise ShardedCheckpointError(
|
| 429 |
+
f"local shard {tuple(local.shape)} != chunk_range-derived {(n,) + shape[1:]} "
|
| 430 |
+
f"(global {shape}, rank {rank}/{world_size}): uneven/padded layout not understood"
|
| 431 |
+
)
|
| 432 |
+
return local, {"shape": list(shape), "placement": "shard0", "row": [off, n]}
|
| 433 |
+
if len(pl) == 1 and isinstance(pl[0], Replicate):
|
| 434 |
+
return local, {"shape": list(shape), "placement": "replicate", "row": None}
|
| 435 |
+
raise ShardedCheckpointError(f"unsupported placements {pl}: only Shard(0) and Replicate() are implemented")
|
| 436 |
+
return t, {"shape": [int(s) for s in t.shape], "placement": "replicate", "row": None}
|
| 437 |
+
|
| 438 |
+
|
| 439 |
+
def _store_dtype(t: torch.Tensor, store_dtype: torch.dtype) -> str:
|
| 440 |
+
dt = store_dtype if t.is_floating_point() else t.dtype
|
| 441 |
+
try:
|
| 442 |
+
return _TORCH_TO_ST[dt]
|
| 443 |
+
except KeyError as e:
|
| 444 |
+
raise ShardedCheckpointError(f"unsupported tensor dtype {dt}") from e
|
| 445 |
+
|
| 446 |
+
|
| 447 |
+
def rank_file_name(rank: int) -> str:
|
| 448 |
+
return f"model_rank{rank:05d}.safetensors"
|
| 449 |
+
|
| 450 |
+
|
| 451 |
+
# ---------------------------------------------------------------------------
|
| 452 |
+
# save (one rank)
|
| 453 |
+
# ---------------------------------------------------------------------------
|
| 454 |
+
|
| 455 |
+
|
| 456 |
+
def save_rank_shard(
|
| 457 |
+
state: Mapping[str, torch.Tensor],
|
| 458 |
+
out_dir: str,
|
| 459 |
+
*,
|
| 460 |
+
rank: int,
|
| 461 |
+
world_size: int,
|
| 462 |
+
store_dtype: torch.dtype = torch.bfloat16,
|
| 463 |
+
) -> dict:
|
| 464 |
+
"""Write THIS rank's local shards to `out_dir/model_rank{r}.safetensors`.
|
| 465 |
+
|
| 466 |
+
Atomic per file (tmp + fsync + rename). Returns this rank's info for the
|
| 467 |
+
manifest: {"file": {name, bytes, sha256}, "tensors": {name: descriptor}}.
|
| 468 |
+
Peak host memory is one tensor.
|
| 469 |
+
"""
|
| 470 |
+
entries: list[StEntry] = []
|
| 471 |
+
tensors: dict[str, dict] = {}
|
| 472 |
+
for name in sorted(state):
|
| 473 |
+
local, desc = _local_and_desc(state[name], rank, world_size)
|
| 474 |
+
st_dt = _store_dtype(local, store_dtype)
|
| 475 |
+
desc["dtype"] = st_dt
|
| 476 |
+
tensors[name] = desc
|
| 477 |
+
|
| 478 |
+
def emit(f, local=local, st_dt=st_dt):
|
| 479 |
+
host = local.detach().to(device="cpu", dtype=_ST_TO_TORCH[st_dt])
|
| 480 |
+
tensor_bytes_emitter(host)(f)
|
| 481 |
+
|
| 482 |
+
entries.append(StEntry(name, st_dt, tuple(local.shape), emit))
|
| 483 |
+
fname = rank_file_name(rank)
|
| 484 |
+
final = os.path.join(out_dir, fname)
|
| 485 |
+
tmp = final + ".tmp"
|
| 486 |
+
try:
|
| 487 |
+
meta = write_safetensors_streaming(tmp, entries, {"format": "pt"})
|
| 488 |
+
os.replace(tmp, final)
|
| 489 |
+
except BaseException:
|
| 490 |
+
for p in (tmp, final):
|
| 491 |
+
try:
|
| 492 |
+
os.remove(p)
|
| 493 |
+
except OSError:
|
| 494 |
+
pass
|
| 495 |
+
raise
|
| 496 |
+
return {"file": {"name": fname, **meta}, "tensors": tensors, "order": list(state)}
|
| 497 |
+
|
| 498 |
+
|
| 499 |
+
def build_manifest(
|
| 500 |
+
infos: list[dict],
|
| 501 |
+
*,
|
| 502 |
+
param_order: Optional[list[str]] = None,
|
| 503 |
+
export: Optional[dict] = None,
|
| 504 |
+
extra: Optional[dict] = None,
|
| 505 |
+
) -> dict:
|
| 506 |
+
"""Merge every rank's info (rank-ordered) into the manifest. Cross-checks
|
| 507 |
+
that all ranks saw the same tensors and that shard0 rows tile [0, n)."""
|
| 508 |
+
ws = len(infos)
|
| 509 |
+
names = sorted(infos[0]["tensors"])
|
| 510 |
+
tensors: dict[str, dict] = {}
|
| 511 |
+
for n in names:
|
| 512 |
+
d0 = infos[0]["tensors"][n]
|
| 513 |
+
rows = []
|
| 514 |
+
for r, info in enumerate(infos):
|
| 515 |
+
if sorted(info["tensors"]) != names:
|
| 516 |
+
raise ShardedCheckpointError(f"rank {r} wrote a different tensor set than rank 0")
|
| 517 |
+
d = info["tensors"][n]
|
| 518 |
+
for k in ("shape", "placement", "dtype"):
|
| 519 |
+
if d[k] != d0[k]:
|
| 520 |
+
raise ShardedCheckpointError(f"{n}: rank {r} disagrees with rank 0 on {k}: {d[k]} vs {d0[k]}")
|
| 521 |
+
rows.append(d["row"])
|
| 522 |
+
entry = {"shape": d0["shape"], "dtype": d0["dtype"], "placement": d0["placement"]}
|
| 523 |
+
if d0["placement"] == "shard0":
|
| 524 |
+
pos = 0
|
| 525 |
+
for off, cnt in rows:
|
| 526 |
+
if off != min(pos, d0["shape"][0]) and cnt:
|
| 527 |
+
raise ShardedCheckpointError(f"{n}: shard rows do not tile ({rows})")
|
| 528 |
+
pos += cnt
|
| 529 |
+
if pos != d0["shape"][0]:
|
| 530 |
+
raise ShardedCheckpointError(f"{n}: shard rows sum to {pos}, global rows {d0['shape'][0]}")
|
| 531 |
+
entry["rows"] = rows
|
| 532 |
+
tensors[n] = entry
|
| 533 |
+
m: dict[str, Any] = {
|
| 534 |
+
"format": FORMAT_NAME,
|
| 535 |
+
"version": FORMAT_VERSION,
|
| 536 |
+
"world_size": ws,
|
| 537 |
+
"files": {str(r): infos[r]["file"] for r in range(ws)},
|
| 538 |
+
"tensors": tensors,
|
| 539 |
+
}
|
| 540 |
+
if infos[0].get("order") is not None:
|
| 541 |
+
# The model's own state-dict order: shard planning (qwen3's 5 GB files)
|
| 542 |
+
# is greedy over insertion order, and the manifest JSON is key-sorted,
|
| 543 |
+
# so without this the exported file split would differ from the
|
| 544 |
+
# gathered writer's.
|
| 545 |
+
m["key_order"] = list(infos[0]["order"])
|
| 546 |
+
if param_order is not None:
|
| 547 |
+
m["param_order"] = list(param_order)
|
| 548 |
+
if export is not None:
|
| 549 |
+
m["export"] = export
|
| 550 |
+
if extra:
|
| 551 |
+
m.update(extra)
|
| 552 |
+
return m
|
| 553 |
+
|
| 554 |
+
|
| 555 |
+
def write_manifest(out_dir: str, manifest: dict) -> None:
|
| 556 |
+
path = os.path.join(out_dir, SHARD_MANIFEST_NAME)
|
| 557 |
+
with open(path, "w", encoding="utf-8") as f:
|
| 558 |
+
f.write(json.dumps(manifest, indent=1, sort_keys=True) + "\n")
|
| 559 |
+
f.flush()
|
| 560 |
+
os.fsync(f.fileno())
|
| 561 |
+
|
| 562 |
+
|
| 563 |
+
# ---------------------------------------------------------------------------
|
| 564 |
+
# read side: manifest, completeness, load
|
| 565 |
+
# ---------------------------------------------------------------------------
|
| 566 |
+
|
| 567 |
+
|
| 568 |
+
def is_sharded(path: str) -> bool:
|
| 569 |
+
return os.path.isfile(os.path.join(path, SHARD_MANIFEST_NAME))
|
| 570 |
+
|
| 571 |
+
|
| 572 |
+
def read_manifest(path: str) -> dict:
|
| 573 |
+
"""Parse + structurally validate; raises `ShardedCheckpointError`."""
|
| 574 |
+
mp = os.path.join(path, SHARD_MANIFEST_NAME)
|
| 575 |
+
try:
|
| 576 |
+
with open(mp, encoding="utf-8") as f:
|
| 577 |
+
m = json.load(f)
|
| 578 |
+
except (OSError, ValueError) as e:
|
| 579 |
+
raise ShardedCheckpointError(f"unreadable {SHARD_MANIFEST_NAME} ({e})") from e
|
| 580 |
+
if not isinstance(m, dict) or m.get("format") != FORMAT_NAME:
|
| 581 |
+
raise ShardedCheckpointError(f"{SHARD_MANIFEST_NAME} is not an {FORMAT_NAME} manifest")
|
| 582 |
+
if m.get("version") != FORMAT_VERSION:
|
| 583 |
+
raise ShardedCheckpointError(f"manifest version {m.get('version')} != supported {FORMAT_VERSION}")
|
| 584 |
+
ws = m.get("world_size")
|
| 585 |
+
if not isinstance(ws, int) or ws < 1:
|
| 586 |
+
raise ShardedCheckpointError(f"manifest world_size {ws!r} is not a positive int")
|
| 587 |
+
files, tensors = m.get("files"), m.get("tensors")
|
| 588 |
+
if not isinstance(files, dict) or sorted(files, key=int) != [str(r) for r in range(ws)]:
|
| 589 |
+
raise ShardedCheckpointError("manifest does not name exactly one file per rank")
|
| 590 |
+
for r in range(ws):
|
| 591 |
+
fe = files[str(r)]
|
| 592 |
+
if not (isinstance(fe, dict) and {"name", "bytes", "sha256"} <= fe.keys()):
|
| 593 |
+
raise ShardedCheckpointError(f"manifest file entry for rank {r} is malformed")
|
| 594 |
+
# An EMPTY tensor set is legal: a module whose weights are reconstructible
|
| 595 |
+
# elsewhere (a frozen base + adapter) may declare `sharded_model_state() == {}`
|
| 596 |
+
# and checkpoint only its optimizer/adapter state.
|
| 597 |
+
if not isinstance(tensors, dict):
|
| 598 |
+
raise ShardedCheckpointError("manifest tensors entry is not an object")
|
| 599 |
+
for n, e in tensors.items():
|
| 600 |
+
if e.get("placement") not in ("shard0", "replicate") or e.get("dtype") not in _ST_ITEMSIZE:
|
| 601 |
+
raise ShardedCheckpointError(f"{n}: malformed tensor entry")
|
| 602 |
+
if e["placement"] == "shard0" and len(e.get("rows", [])) != ws:
|
| 603 |
+
raise ShardedCheckpointError(f"{n}: shard0 entry has {len(e.get('rows', []))} row ranges, world {ws}")
|
| 604 |
+
return m
|
| 605 |
+
|
| 606 |
+
|
| 607 |
+
def sharded_problem(path: str, st_header_problem: Callable[[str], Optional[str]]) -> Optional[str]:
|
| 608 |
+
"""None iff every rank file the manifest names is present, the size it
|
| 609 |
+
recorded, and a structurally complete safetensors file. CHEAP (no payload
|
| 610 |
+
reads): sha256 is verified on the rank that loads the file."""
|
| 611 |
+
try:
|
| 612 |
+
m = read_manifest(path)
|
| 613 |
+
except ShardedCheckpointError as e:
|
| 614 |
+
return str(e)
|
| 615 |
+
for r in range(m["world_size"]):
|
| 616 |
+
fe = m["files"][str(r)]
|
| 617 |
+
fp = os.path.join(path, fe["name"])
|
| 618 |
+
if not os.path.isfile(fp):
|
| 619 |
+
return f"missing shard file {fe['name']}"
|
| 620 |
+
if os.path.getsize(fp) != fe["bytes"]:
|
| 621 |
+
return f"{fe['name']} is {os.path.getsize(fp)} bytes, manifest recorded {fe['bytes']}"
|
| 622 |
+
problem = st_header_problem(fp)
|
| 623 |
+
if problem:
|
| 624 |
+
return problem
|
| 625 |
+
return None
|
| 626 |
+
|
| 627 |
+
|
| 628 |
+
def verify_rank_file(path: str, rank: int) -> Optional[str]:
|
| 629 |
+
"""sha256 of THIS rank's file against the manifest; None if it matches."""
|
| 630 |
+
m = read_manifest(path)
|
| 631 |
+
fe = m["files"][str(rank)]
|
| 632 |
+
got = file_sha256(os.path.join(path, fe["name"]))
|
| 633 |
+
if got != fe["sha256"]:
|
| 634 |
+
return f"{fe['name']} sha256 {got[:12]}.. != manifest {fe['sha256'][:12]}.. (bit rot or a partial copy)"
|
| 635 |
+
return None
|
| 636 |
+
|
| 637 |
+
|
| 638 |
+
def check_world_size(path: str, manifest: dict, world_size: int) -> None:
|
| 639 |
+
if manifest["world_size"] != world_size:
|
| 640 |
+
raise ShardedWorldSizeError(
|
| 641 |
+
f"checkpoint {path} was saved with world_size={manifest['world_size']} but this run has "
|
| 642 |
+
f"world_size={world_size}. Sharded weights are rank-local row slices and cannot be loaded "
|
| 643 |
+
"under another partitioning. Resume with the same number of ranks, or reshard offline: "
|
| 644 |
+
f"python scripts/ckpt_convert.py reshard {path} <dst> --world-size {world_size}"
|
| 645 |
+
)
|
| 646 |
+
|
| 647 |
+
|
| 648 |
+
def load_rank_shard(
|
| 649 |
+
state: Mapping[str, torch.Tensor], path: str, *, rank: int, world_size: int, verify_hash: bool = False
|
| 650 |
+
) -> int:
|
| 651 |
+
"""Copy this rank's file into the LIVE tensors of `state` (a model state
|
| 652 |
+
dict whose DTensors alias the parameters). Reads only this rank's file, one
|
| 653 |
+
tensor at a time. Returns the number of tensors loaded."""
|
| 654 |
+
m = read_manifest(path)
|
| 655 |
+
check_world_size(path, m, world_size)
|
| 656 |
+
mt = m["tensors"]
|
| 657 |
+
missing, unexpected = sorted(set(mt) - set(state)), sorted(set(state) - set(mt))
|
| 658 |
+
if missing or unexpected:
|
| 659 |
+
raise KeyError(f"resume state mismatch: missing={missing[:5]} unexpected={unexpected[:5]}")
|
| 660 |
+
if verify_hash:
|
| 661 |
+
bad = verify_rank_file(path, rank)
|
| 662 |
+
if bad:
|
| 663 |
+
raise ShardedCheckpointError(bad)
|
| 664 |
+
# validate EVERYTHING before the first copy_: a mismatch on tensor 300 must
|
| 665 |
+
# not leave a model half-overwritten.
|
| 666 |
+
plan = []
|
| 667 |
+
for name in sorted(mt):
|
| 668 |
+
local, desc = _local_and_desc(state[name], rank, world_size)
|
| 669 |
+
e = mt[name]
|
| 670 |
+
if desc["shape"] != e["shape"] or desc["placement"] != e["placement"]:
|
| 671 |
+
raise ShardedCheckpointError(
|
| 672 |
+
f"{name}: live {desc['placement']} {desc['shape']} != checkpoint {e['placement']} {e['shape']}"
|
| 673 |
+
)
|
| 674 |
+
if e["placement"] == "shard0" and list(e["rows"][rank]) != list(desc["row"]):
|
| 675 |
+
raise ShardedCheckpointError(
|
| 676 |
+
f"{name}: live rows {desc['row']} != checkpoint rows {e['rows'][rank]} for rank {rank}"
|
| 677 |
+
)
|
| 678 |
+
if local.is_floating_point() != _ST_TO_TORCH[e["dtype"]].is_floating_point:
|
| 679 |
+
raise ShardedCheckpointError(f"{name}: dtype class differs (live {local.dtype}, ckpt {e['dtype']})")
|
| 680 |
+
plan.append((name, local))
|
| 681 |
+
with ShardFile(os.path.join(path, m["files"][str(rank)]["name"])) as sf:
|
| 682 |
+
missing_in_file = [n for n, _ in plan if n not in sf.tensors]
|
| 683 |
+
if missing_in_file:
|
| 684 |
+
raise ShardedCheckpointError(f"{sf.path} lacks tensors {missing_in_file[:5]}")
|
| 685 |
+
with torch.no_grad():
|
| 686 |
+
for name, local in plan:
|
| 687 |
+
t = sf.tensor(name)
|
| 688 |
+
if tuple(t.shape) != tuple(local.shape):
|
| 689 |
+
raise ShardedCheckpointError(f"{name}: file shape {tuple(t.shape)} != live {tuple(local.shape)}")
|
| 690 |
+
local.copy_(t.to(device=local.device))
|
| 691 |
+
return len(plan)
|
| 692 |
+
|
| 693 |
+
|
| 694 |
+
class _ListSink:
|
| 695 |
+
def __init__(self):
|
| 696 |
+
self.parts: list[bytes] = []
|
| 697 |
+
|
| 698 |
+
def write(self, b):
|
| 699 |
+
self.parts.append(bytes(b))
|
| 700 |
+
return len(b)
|
| 701 |
+
|
| 702 |
+
|
| 703 |
+
class GlobalReader:
|
| 704 |
+
"""Byte-range reads over the GLOBAL (row-major) tensors of a sharded checkpoint."""
|
| 705 |
+
|
| 706 |
+
def __init__(self, path: str, manifest: Optional[dict] = None):
|
| 707 |
+
self.path = path
|
| 708 |
+
self.m = manifest or read_manifest(path)
|
| 709 |
+
self.files = {
|
| 710 |
+
r: ShardFile(os.path.join(path, self.m["files"][str(r)]["name"])) for r in range(self.m["world_size"])
|
| 711 |
+
}
|
| 712 |
+
|
| 713 |
+
def close(self):
|
| 714 |
+
for f in self.files.values():
|
| 715 |
+
f.close()
|
| 716 |
+
|
| 717 |
+
def __enter__(self):
|
| 718 |
+
return self
|
| 719 |
+
|
| 720 |
+
def __exit__(self, *a):
|
| 721 |
+
self.close()
|
| 722 |
+
|
| 723 |
+
def info(self, name: str) -> tuple[str, tuple]:
|
| 724 |
+
e = self.m["tensors"][name]
|
| 725 |
+
return e["dtype"], tuple(e["shape"])
|
| 726 |
+
|
| 727 |
+
def nbytes(self, name: str) -> int:
|
| 728 |
+
dt, shape = self.info(name)
|
| 729 |
+
return _prod(shape) * _ST_ITEMSIZE[dt]
|
| 730 |
+
|
| 731 |
+
def copy_range(self, name: str, lo: int, hi: int, sink) -> None:
|
| 732 |
+
"""Stream global bytes [lo, hi) of tensor `name` into `sink`."""
|
| 733 |
+
e = self.m["tensors"][name]
|
| 734 |
+
if not (0 <= lo <= hi <= self.nbytes(name)):
|
| 735 |
+
raise ShardedCheckpointError(f"{name}: byte range [{lo},{hi}) outside tensor")
|
| 736 |
+
if e["placement"] == "replicate":
|
| 737 |
+
self.files[0].copy_range(name, lo, hi, sink)
|
| 738 |
+
return
|
| 739 |
+
dt, shape = self.info(name)
|
| 740 |
+
rb = _prod(shape[1:]) * _ST_ITEMSIZE[dt]
|
| 741 |
+
for r, (off, cnt) in enumerate(e["rows"]):
|
| 742 |
+
a, b = max(lo, off * rb), min(hi, (off + cnt) * rb)
|
| 743 |
+
if a < b:
|
| 744 |
+
self.files[r].copy_range(name, a - off * rb, b - off * rb, sink)
|
| 745 |
+
|
| 746 |
+
def tensor(self, name: str) -> torch.Tensor:
|
| 747 |
+
dt, shape = self.info(name)
|
| 748 |
+
sink = _ListSink()
|
| 749 |
+
self.copy_range(name, 0, self.nbytes(name), sink)
|
| 750 |
+
buf = bytearray(b"".join(sink.parts))
|
| 751 |
+
if not buf:
|
| 752 |
+
return torch.empty(shape, dtype=_ST_TO_TORCH[dt])
|
| 753 |
+
return torch.frombuffer(buf, dtype=_ST_TO_TORCH[dt]).reshape(shape)
|
| 754 |
+
|
| 755 |
+
|
| 756 |
+
def load_full_state_dict(path: str) -> dict[str, torch.Tensor]:
|
| 757 |
+
"""The WHOLE state dict, assembled in host memory. For tests and small
|
| 758 |
+
models only -- the production path is `export_hf`, which never does this."""
|
| 759 |
+
with GlobalReader(path) as g:
|
| 760 |
+
return {n: g.tensor(n) for n in g.m["tensors"]}
|
| 761 |
+
|
| 762 |
+
|
| 763 |
+
# ---------------------------------------------------------------------------
|
| 764 |
+
# describing the weight format (so the checkpoint is self-exporting)
|
| 765 |
+
# ---------------------------------------------------------------------------
|
| 766 |
+
|
| 767 |
+
|
| 768 |
+
def describe_weight_format(weight_format: Any, dtype: torch.dtype = torch.bfloat16) -> Optional[dict]:
|
| 769 |
+
"""{"family", "hf_config"} for the formats we can export; None otherwise
|
| 770 |
+
(the checkpoint still saves and resumes, `export` then refuses with a clear
|
| 771 |
+
message instead of guessing a config)."""
|
| 772 |
+
# Subclasses count: scripts wrap OlmoeFormat to serialise its loads
|
| 773 |
+
# (olmoe_sft_smoke.SerialWeightFormat) and must stay exportable.
|
| 774 |
+
names = {c.__name__ for c in type(weight_format).__mro__}
|
| 775 |
+
name = next((n for n in ("DenseFormat", "OlmoeFormat", "Qwen3Format") if n in names), type(weight_format).__name__)
|
| 776 |
+
try:
|
| 777 |
+
if name == "DenseFormat":
|
| 778 |
+
from ..model import hf_config as hfc
|
| 779 |
+
|
| 780 |
+
return {"family": "dense", "hf_config": hfc.to_hf_config_dict(weight_format.model_cfg, dtype=dtype)}
|
| 781 |
+
if name == "OlmoeFormat":
|
| 782 |
+
from ..model.olmoe_checkpoint import config_to_hf_dict
|
| 783 |
+
|
| 784 |
+
return {"family": "olmoe", "hf_config": config_to_hf_dict(weight_format.olmoe_cfg, dtype=dtype)}
|
| 785 |
+
if name == "Qwen3Format":
|
| 786 |
+
return {"family": "qwen3", "hf_config": weight_format.qcfg.to_hf_config(dtype=dtype)}
|
| 787 |
+
except Exception as e: # noqa: BLE001 -- a config we cannot describe must not block saving weights
|
| 788 |
+
logger.warning("cannot describe weight format %s for export (%s: %s)", name, type(e).__name__, e)
|
| 789 |
+
return None
|
| 790 |
+
|
| 791 |
+
|
| 792 |
+
# ---------------------------------------------------------------------------
|
| 793 |
+
# HF export plan + streaming export
|
| 794 |
+
# ---------------------------------------------------------------------------
|
| 795 |
+
|
| 796 |
+
|
| 797 |
+
@dataclass
|
| 798 |
+
class HFEntry:
|
| 799 |
+
hf_name: str
|
| 800 |
+
st_dtype: str
|
| 801 |
+
shape: tuple
|
| 802 |
+
src: str # align key
|
| 803 |
+
lo: int # global byte range in the source tensor
|
| 804 |
+
hi: int
|
| 805 |
+
|
| 806 |
+
|
| 807 |
+
def _meta(manifest: dict) -> dict[str, torch.Tensor]:
|
| 808 |
+
order = manifest.get("key_order") or list(manifest["tensors"])
|
| 809 |
+
return {
|
| 810 |
+
n: torch.empty(tuple(manifest["tensors"][n]["shape"]), dtype=_ST_TO_TORCH[manifest["tensors"][n]["dtype"]], device="meta")
|
| 811 |
+
for n in order
|
| 812 |
+
}
|
| 813 |
+
|
| 814 |
+
|
| 815 |
+
def build_hf_plan(manifest: dict) -> list[HFEntry]:
|
| 816 |
+
"""Plan the HF tensor list from the manifest ALONE (no data). 1:1 families
|
| 817 |
+
run the family's own converter on META tensors and map outputs back by
|
| 818 |
+
object identity, so the name table is the converter's, not a copy of it.
|
| 819 |
+
OLMoE's stacked experts become per-expert byte ranges of the fused tensor."""
|
| 820 |
+
if not manifest["tensors"]:
|
| 821 |
+
raise ShardedCheckpointError("this checkpoint holds no weight tensors (adapter/optimizer-only); nothing to export")
|
| 822 |
+
ex = manifest.get("export")
|
| 823 |
+
if not ex:
|
| 824 |
+
raise ShardedCheckpointError(
|
| 825 |
+
"this checkpoint carries no export recipe (its weight format is not Dense/Olmoe/Qwen3); "
|
| 826 |
+
"export cannot guess a config"
|
| 827 |
+
)
|
| 828 |
+
fam, hfc_d = ex["family"], ex["hf_config"]
|
| 829 |
+
meta = _meta(manifest)
|
| 830 |
+
ident = {id(v): k for k, v in meta.items()}
|
| 831 |
+
entries: list[HFEntry] = []
|
| 832 |
+
|
| 833 |
+
def one_to_one(out: Mapping[str, torch.Tensor]) -> None:
|
| 834 |
+
for hf_name, v in out.items():
|
| 835 |
+
src = ident.get(id(v))
|
| 836 |
+
if src is None:
|
| 837 |
+
raise ShardedCheckpointError(f"{hf_name}: not a 1:1 image of an align tensor")
|
| 838 |
+
e = manifest["tensors"][src]
|
| 839 |
+
entries.append(HFEntry(hf_name, e["dtype"], tuple(e["shape"]), src, 0, _prod(e["shape"]) * _ST_ITEMSIZE[e["dtype"]]))
|
| 840 |
+
|
| 841 |
+
if fam == "dense":
|
| 842 |
+
from ..model import checkpoint as ckpt
|
| 843 |
+
from ..model import hf_config as hfc
|
| 844 |
+
|
| 845 |
+
cfg = hfc.from_hf_config_dict(hfc_d)
|
| 846 |
+
one_to_one(ckpt.nmo_to_hf(meta, expected_n_layers=cfg.n_layers))
|
| 847 |
+
elif fam == "qwen3":
|
| 848 |
+
from ..model.qwen3 import Qwen3Config
|
| 849 |
+
from ..model.qwen3_checkpoint import align_to_hf
|
| 850 |
+
|
| 851 |
+
one_to_one(align_to_hf(meta, Qwen3Config.from_hf_config(hfc_d)))
|
| 852 |
+
elif fam == "olmoe":
|
| 853 |
+
from ..model import olmoe_checkpoint as oc
|
| 854 |
+
|
| 855 |
+
cfg = oc.config_from_hf_dict(hfc_d)
|
| 856 |
+
real = oc.align_to_hf(meta, cfg) # the converter's own key set and shapes: the oracle
|
| 857 |
+
inter = cfg.intermediate_size
|
| 858 |
+
fused = set()
|
| 859 |
+
for i in range(cfg.n_layers):
|
| 860 |
+
gu_k, dn_k = (k.format(i=i) for k in oc._AL_FUSED)
|
| 861 |
+
fused.update((gu_k, dn_k))
|
| 862 |
+
gu, dn = manifest["tensors"][gu_k], manifest["tensors"][dn_k]
|
| 863 |
+
es = _ST_ITEMSIZE[gu["dtype"]]
|
| 864 |
+
e_n, two_i, d = gu["shape"]
|
| 865 |
+
for e in range(e_n):
|
| 866 |
+
base = e * two_i * d * es
|
| 867 |
+
half = inter * d * es
|
| 868 |
+
entries.append(HFEntry(oc._HF_EXPERT.format(i=i, e=e, p="gate"), gu["dtype"], (inter, d), gu_k, base, base + half))
|
| 869 |
+
entries.append(HFEntry(oc._HF_EXPERT.format(i=i, e=e, p="up"), gu["dtype"], (inter, d), gu_k, base + half, base + 2 * half))
|
| 870 |
+
dbase = e * dn["shape"][1] * dn["shape"][2] * es
|
| 871 |
+
entries.append(HFEntry(oc._HF_EXPERT.format(i=i, e=e, p="down"), dn["dtype"], tuple(dn["shape"][1:]), dn_k, dbase, dbase + _prod(dn["shape"][1:]) * es))
|
| 872 |
+
rest = {k: v for k, v in real.items() if id(v) in ident and ident[id(v)] not in fused}
|
| 873 |
+
one_to_one(rest)
|
| 874 |
+
got = {e.hf_name: e.shape for e in entries}
|
| 875 |
+
want = {k: tuple(v.shape) for k, v in real.items()}
|
| 876 |
+
if got != want:
|
| 877 |
+
raise ShardedCheckpointError(
|
| 878 |
+
f"olmoe export plan disagrees with align_to_hf: only-plan={sorted(set(got) - set(want))[:3]} "
|
| 879 |
+
f"only-converter={sorted(set(want) - set(got))[:3]} "
|
| 880 |
+
f"shape-diff={[k for k in got if k in want and got[k] != want[k]][:3]}"
|
| 881 |
+
)
|
| 882 |
+
else:
|
| 883 |
+
raise ShardedCheckpointError(f"unknown export family {fam!r}")
|
| 884 |
+
return entries
|
| 885 |
+
|
| 886 |
+
|
| 887 |
+
def export_hf(src: str, dst: str, *, verify: bool = True) -> dict:
|
| 888 |
+
"""Sharded checkpoint -> HF-layout directory, tensor by tensor.
|
| 889 |
+
|
| 890 |
+
Byte-identical to what the gathered path writes (`save_nmo_as_hf` /
|
| 891 |
+
`save_olmoe_as_hf` / `save_hf_qwen3`) -- tested by file hash -- with peak RSS
|
| 892 |
+
of one `COPY_CHUNK` instead of 2x the weights. `dst` is staged as
|
| 893 |
+
`dst.partial`, fsynced and renamed: an interrupted export leaves nothing at
|
| 894 |
+
`dst`.
|
| 895 |
+
"""
|
| 896 |
+
if os.path.exists(dst):
|
| 897 |
+
raise FileExistsError(f"{dst} exists; export never overwrites")
|
| 898 |
+
m = read_manifest(src)
|
| 899 |
+
if verify:
|
| 900 |
+
for r in range(m["world_size"]):
|
| 901 |
+
bad = verify_rank_file(src, r)
|
| 902 |
+
if bad:
|
| 903 |
+
raise ShardedCheckpointError(bad)
|
| 904 |
+
plan = build_hf_plan(m)
|
| 905 |
+
fam = m["export"]["family"]
|
| 906 |
+
staging = dst.rstrip("/") + ".partial"
|
| 907 |
+
if os.path.isdir(staging):
|
| 908 |
+
shutil.rmtree(staging)
|
| 909 |
+
os.makedirs(staging)
|
| 910 |
+
try:
|
| 911 |
+
with GlobalReader(src, m) as g:
|
| 912 |
+
def make_entry(h: HFEntry) -> StEntry:
|
| 913 |
+
def emit(f, h=h):
|
| 914 |
+
g.copy_range(h.src, h.lo, h.hi, f)
|
| 915 |
+
|
| 916 |
+
return StEntry(h.hf_name, h.st_dtype, h.shape, emit)
|
| 917 |
+
|
| 918 |
+
by_name = {h.hf_name: h for h in plan}
|
| 919 |
+
if fam == "qwen3":
|
| 920 |
+
from ..model.qwen3_checkpoint import MAX_SHARD_BYTES, _plan_shards
|
| 921 |
+
|
| 922 |
+
sizes = {h.hf_name: _prod(h.shape) * _ST_ITEMSIZE[h.st_dtype] for h in plan}
|
| 923 |
+
groups = _plan_shards(sizes, MAX_SHARD_BYTES)
|
| 924 |
+
weight_map: dict[str, str] = {}
|
| 925 |
+
for n, keys in enumerate(groups, start=1):
|
| 926 |
+
fname = f"model-{n:05d}-of-{len(groups):05d}.safetensors"
|
| 927 |
+
write_safetensors_streaming(
|
| 928 |
+
os.path.join(staging, fname), [make_entry(by_name[k]) for k in keys], {"format": "pt"}
|
| 929 |
+
)
|
| 930 |
+
weight_map.update({k: fname for k in keys})
|
| 931 |
+
with open(os.path.join(staging, "model.safetensors.index.json"), "w") as fh:
|
| 932 |
+
json.dump({"metadata": {"total_size": sum(sizes.values())}, "weight_map": weight_map}, fh, indent=2, sort_keys=True)
|
| 933 |
+
fh.flush()
|
| 934 |
+
os.fsync(fh.fileno())
|
| 935 |
+
else:
|
| 936 |
+
write_safetensors_streaming(
|
| 937 |
+
os.path.join(staging, "model.safetensors"), [make_entry(h) for h in plan], {"format": "pt"}
|
| 938 |
+
)
|
| 939 |
+
with open(os.path.join(staging, "config.json"), "w", encoding="utf-8") as fh:
|
| 940 |
+
fh.write(json.dumps(m["export"]["hf_config"], indent=2, sort_keys=True) + "\n")
|
| 941 |
+
fh.flush()
|
| 942 |
+
os.fsync(fh.fileno())
|
| 943 |
+
for fn in sorted(os.listdir(src)):
|
| 944 |
+
if not _OWN_FILE_RE.match(fn) and os.path.isfile(os.path.join(src, fn)) and fn != "config.json":
|
| 945 |
+
shutil.copyfile(os.path.join(src, fn), os.path.join(staging, fn))
|
| 946 |
+
_fsync_tree(staging)
|
| 947 |
+
os.rename(staging, dst)
|
| 948 |
+
_fsync_dir(os.path.dirname(os.path.abspath(dst)))
|
| 949 |
+
except BaseException:
|
| 950 |
+
shutil.rmtree(staging, ignore_errors=True)
|
| 951 |
+
raise
|
| 952 |
+
return {"tensors": len(plan), "family": fam, "dst": dst}
|
| 953 |
+
|
| 954 |
+
|
| 955 |
+
def _fsync_dir(p: str) -> None:
|
| 956 |
+
fd = os.open(p, os.O_RDONLY)
|
| 957 |
+
try:
|
| 958 |
+
os.fsync(fd)
|
| 959 |
+
finally:
|
| 960 |
+
os.close(fd)
|
| 961 |
+
|
| 962 |
+
|
| 963 |
+
def _fsync_tree(root: str) -> None:
|
| 964 |
+
dirs = []
|
| 965 |
+
for dp, _dn, fns in os.walk(root):
|
| 966 |
+
dirs.append(dp)
|
| 967 |
+
for fn in fns:
|
| 968 |
+
fd = os.open(os.path.join(dp, fn), os.O_RDONLY)
|
| 969 |
+
try:
|
| 970 |
+
os.fsync(fd)
|
| 971 |
+
finally:
|
| 972 |
+
os.close(fd)
|
| 973 |
+
for d in reversed(dirs):
|
| 974 |
+
_fsync_dir(d)
|
| 975 |
+
|
| 976 |
+
|
| 977 |
+
# ---------------------------------------------------------------------------
|
| 978 |
+
# offline reshard
|
| 979 |
+
# ---------------------------------------------------------------------------
|
| 980 |
+
|
| 981 |
+
|
| 982 |
+
class ReshardUnsupported(ShardedCheckpointError):
|
| 983 |
+
"""Optimizer state whose layout cannot be re-cut without knowing the optimizer."""
|
| 984 |
+
|
| 985 |
+
|
| 986 |
+
def _check_opt_leaf(vals: list, shapes_old: list[tuple], name: str, where: str) -> None:
|
| 987 |
+
"""Every old rank's piece must have exactly the LOCAL shard shape of the parameter it is
|
| 988 |
+
filed under (shape metadata only: reads nothing from an mmap'd file)."""
|
| 989 |
+
for r, (v, want) in enumerate(zip(vals, shapes_old)):
|
| 990 |
+
if tuple(v.shape) != tuple(want):
|
| 991 |
+
raise ReshardUnsupported(
|
| 992 |
+
f"{where}: tensor shape {tuple(v.shape)} on old rank {r} != the local shard {tuple(want)} of "
|
| 993 |
+
f"parameter {name!r}. This optimizer state is not elementwise-per-shard (quantised/blocked "
|
| 994 |
+
"moments?) or its parameter order differs from the model's; it cannot be re-cut safely."
|
| 995 |
+
)
|
| 996 |
+
|
| 997 |
+
|
| 998 |
+
def _cut_opt_leaf(vals: list, old_rows: Optional[list], new_ws: int, new_rank: int) -> torch.Tensor:
|
| 999 |
+
"""NEW rank `new_rank`'s piece of one per-param optimizer tensor, built from only the
|
| 1000 |
+
old pieces that overlap its rows (no whole-tensor concatenation, no other new rank)."""
|
| 1001 |
+
if old_rows is None: # replicated parameter
|
| 1002 |
+
return vals[0].clone()
|
| 1003 |
+
total = sum(c for _o, c in old_rows)
|
| 1004 |
+
off, n = chunk_range(total, new_ws, new_rank)
|
| 1005 |
+
parts = []
|
| 1006 |
+
for v, (qo, qc) in zip(vals, old_rows):
|
| 1007 |
+
a, b = max(off, qo), min(off + n, qo + qc)
|
| 1008 |
+
if a < b:
|
| 1009 |
+
parts.append(v[a - qo : b - qo])
|
| 1010 |
+
if not parts:
|
| 1011 |
+
return torch.empty((0,) + tuple(vals[0].shape[1:]), dtype=vals[0].dtype)
|
| 1012 |
+
return torch.cat(parts, dim=0) if len(parts) > 1 else parts[0].clone()
|
| 1013 |
+
|
| 1014 |
+
|
| 1015 |
+
def iter_reshard_optimizer(old: list[dict], manifest: dict, new_ws: int):
|
| 1016 |
+
"""Yield the re-cut optimizer state dict of NEW rank 0, 1, ... one at a time.
|
| 1017 |
+
|
| 1018 |
+
THE ALL-RANKS-AT-ONCE DEFECT this shape prevents. The first version returned a
|
| 1019 |
+
list holding every new rank's state, so its peak was the WHOLE optimizer
|
| 1020 |
+
(12 B/param for fp32-master AdamW: 83 GB for a 7B model) while the CLI
|
| 1021 |
+
estimated `optimizer_bytes / new_ws` and the 45 GB floor check was sized on
|
| 1022 |
+
that. Measured on 4 old -> 4 new ranks, 50 M params: anonymous-RSS growth 576
|
| 1023 |
+
MiB against an estimate of 144 MiB, i.e. `new_ws` times the estimate. On this
|
| 1024 |
+
no-swap shared box a reshard that passes the preflight at 21 GB and takes 83
|
| 1025 |
+
GB is the 2026-08-12 OOM. Here only the old pieces that overlap the rank being
|
| 1026 |
+
built are read (mmap'd), and the caller writes and drops each yielded dict
|
| 1027 |
+
before asking for the next, so the peak is one new rank's state.
|
| 1028 |
+
|
| 1029 |
+
Every shape/consistency refusal is raised BEFORE the first yield, so a
|
| 1030 |
+
resharder that cannot prove it understands the state writes no file at all.
|
| 1031 |
+
"""
|
| 1032 |
+
order = manifest.get("param_order")
|
| 1033 |
+
if not order:
|
| 1034 |
+
raise ReshardUnsupported("manifest has no param_order: cannot map optimizer slots to parameters")
|
| 1035 |
+
ws = manifest["world_size"]
|
| 1036 |
+
if len(old) != ws:
|
| 1037 |
+
raise ReshardUnsupported(f"{len(old)} optimizer file(s) for a {ws}-rank checkpoint")
|
| 1038 |
+
if "world_size" in old[0]:
|
| 1039 |
+
raise ReshardUnsupported("optimizer state is stamped with its world_size (quantised shard-local state)")
|
| 1040 |
+
|
| 1041 |
+
def entry(pname: str) -> dict:
|
| 1042 |
+
e = manifest["tensors"].get(pname)
|
| 1043 |
+
if e is None:
|
| 1044 |
+
raise ReshardUnsupported(f"param_order names {pname!r}, which is not a tensor of this checkpoint")
|
| 1045 |
+
return e
|
| 1046 |
+
|
| 1047 |
+
def local_shape(pname: str, r: int) -> tuple:
|
| 1048 |
+
e = entry(pname)
|
| 1049 |
+
if e["placement"] == "replicate":
|
| 1050 |
+
return tuple(e["shape"])
|
| 1051 |
+
return (e["rows"][r][1],) + tuple(e["shape"][1:])
|
| 1052 |
+
|
| 1053 |
+
def rows_of(pname: str) -> Optional[list]:
|
| 1054 |
+
e = entry(pname)
|
| 1055 |
+
return None if e["placement"] == "replicate" else [tuple(x) for x in e["rows"]]
|
| 1056 |
+
|
| 1057 |
+
def agree(vals: list, where: str):
|
| 1058 |
+
v0 = vals[0]
|
| 1059 |
+
for v in vals[1:]:
|
| 1060 |
+
same = torch.equal(v, v0) if isinstance(v0, torch.Tensor) else v == v0
|
| 1061 |
+
if not same:
|
| 1062 |
+
raise ReshardUnsupported(f"{where}: differs across old ranks ({v0!r} vs {v!r})")
|
| 1063 |
+
return v0
|
| 1064 |
+
|
| 1065 |
+
# -- pass 1: validate everything, build a per-key plan (no tensor payload read) -----------
|
| 1066 |
+
plan: list[tuple] = []
|
| 1067 |
+
for key in old[0]:
|
| 1068 |
+
vals = [o[key] for o in old]
|
| 1069 |
+
if key == "state":
|
| 1070 |
+
leaves = [] # (idx, k, kind, payload)
|
| 1071 |
+
for idx in vals[0]:
|
| 1072 |
+
if not 0 <= int(idx) < len(order):
|
| 1073 |
+
raise ReshardUnsupported(f"state slot {idx} is outside param_order ({len(order)} entries)")
|
| 1074 |
+
pname = order[int(idx)]
|
| 1075 |
+
for k in vals[0][idx]:
|
| 1076 |
+
leaf = [v[idx][k] for v in vals]
|
| 1077 |
+
where = f"state[{idx}][{k}]"
|
| 1078 |
+
if isinstance(leaf[0], torch.Tensor) and leaf[0].ndim >= 1:
|
| 1079 |
+
_check_opt_leaf(leaf, [local_shape(pname, r) for r in range(ws)], pname, where)
|
| 1080 |
+
leaves.append((idx, k, "cut", (leaf, rows_of(pname))))
|
| 1081 |
+
else:
|
| 1082 |
+
leaves.append((idx, k, "same", agree(leaf, where)))
|
| 1083 |
+
plan.append((key, "state", leaves))
|
| 1084 |
+
elif key == "master":
|
| 1085 |
+
if len(vals[0]) != len(order) or any(len(v) != len(order) for v in vals):
|
| 1086 |
+
raise ReshardUnsupported(
|
| 1087 |
+
f"master has {[len(v) for v in vals]} entries per old rank, param_order {len(order)}: the "
|
| 1088 |
+
"optimizer was not built over the model's trainable parameters in order"
|
| 1089 |
+
)
|
| 1090 |
+
masters = []
|
| 1091 |
+
for i, pname in enumerate(order):
|
| 1092 |
+
leaf = [v[i] for v in vals]
|
| 1093 |
+
_check_opt_leaf(leaf, [local_shape(pname, r) for r in range(ws)], pname, f"master[{i}]")
|
| 1094 |
+
masters.append((leaf, rows_of(pname)))
|
| 1095 |
+
plan.append((key, "master", masters))
|
| 1096 |
+
else:
|
| 1097 |
+
if _has_tensor(vals[0], min_ndim=1):
|
| 1098 |
+
raise ReshardUnsupported(f"optimizer key {key!r} holds sharded tensors this resharder does not understand")
|
| 1099 |
+
plan.append((key, "plain", _agree_tree(vals, key)))
|
| 1100 |
+
|
| 1101 |
+
# -- pass 2: build ONE new rank at a time ---------------------------------------------------
|
| 1102 |
+
for r in range(new_ws):
|
| 1103 |
+
out: dict = {}
|
| 1104 |
+
for key, kind, payload in plan:
|
| 1105 |
+
if kind == "state":
|
| 1106 |
+
st: dict = {}
|
| 1107 |
+
for idx, k, how, p in payload:
|
| 1108 |
+
st.setdefault(idx, {})[k] = _cut_opt_leaf(p[0], p[1], new_ws, r) if how == "cut" else p
|
| 1109 |
+
out[key] = st
|
| 1110 |
+
elif kind == "master":
|
| 1111 |
+
out[key] = [_cut_opt_leaf(leaf, rows, new_ws, r) for leaf, rows in payload]
|
| 1112 |
+
else:
|
| 1113 |
+
out[key] = payload
|
| 1114 |
+
yield out
|
| 1115 |
+
|
| 1116 |
+
|
| 1117 |
+
def reshard_optimizer(old: list[dict], manifest: dict, new_ws: int) -> list[dict]:
|
| 1118 |
+
"""Re-cut per-rank optimizer state dicts for `new_ws`, all at once (tests, small models).
|
| 1119 |
+
|
| 1120 |
+
Production resharding uses `iter_reshard_optimizer` and writes each rank
|
| 1121 |
+
before building the next; this wrapper holds every new rank in memory.
|
| 1122 |
+
|
| 1123 |
+
Handles the layout `torch.optim.AdamW` / `OffloadAdamW` produce:
|
| 1124 |
+
`state[idx][k]` tensors and a `master` list, each shaped like the LOCAL
|
| 1125 |
+
shard of parameter `param_order[idx]`. Scalars (`step`) and non-tensor
|
| 1126 |
+
leaves must agree across old ranks. ANYTHING else that holds a tensor with
|
| 1127 |
+
ndim >= 1 (adamw8bit's quantised blocks, a `world_size`-stamped state)
|
| 1128 |
+
raises `ReshardUnsupported`: re-cutting what we do not understand is how a
|
| 1129 |
+
resume loads plausible, wrong moments.
|
| 1130 |
+
"""
|
| 1131 |
+
return list(iter_reshard_optimizer(old, manifest, new_ws))
|
| 1132 |
+
|
| 1133 |
+
|
| 1134 |
+
def _has_tensor(o: Any, min_ndim: int) -> bool:
|
| 1135 |
+
if isinstance(o, torch.Tensor):
|
| 1136 |
+
return o.ndim >= min_ndim
|
| 1137 |
+
if isinstance(o, dict):
|
| 1138 |
+
return any(_has_tensor(v, min_ndim) for v in o.values())
|
| 1139 |
+
if isinstance(o, (list, tuple)):
|
| 1140 |
+
return any(_has_tensor(v, min_ndim) for v in o)
|
| 1141 |
+
return False
|
| 1142 |
+
|
| 1143 |
+
|
| 1144 |
+
def _agree_tree(vals: list, where: str):
|
| 1145 |
+
v0 = vals[0]
|
| 1146 |
+
for v in vals[1:]:
|
| 1147 |
+
if repr(v) != repr(v0):
|
| 1148 |
+
raise ReshardUnsupported(f"{where}: differs across old ranks")
|
| 1149 |
+
return v0
|
| 1150 |
+
|
| 1151 |
+
|
| 1152 |
+
def reshard(src: str, dst: str, new_ws: int, *, drop_optimizer: bool = False) -> dict:
|
| 1153 |
+
"""Offline reshard of a sharded checkpoint to `new_ws` ranks.
|
| 1154 |
+
|
| 1155 |
+
Model shards are re-cut by byte range (bounded memory). Optimizer files are
|
| 1156 |
+
re-cut by `reshard_optimizer` (peak RSS = one NEW rank's optimizer state --
|
| 1157 |
+
at 7B fp32-master AdamW that is 12 B/param/new_ws; run it on an idle box).
|
| 1158 |
+
With `drop_optimizer=True` the optimizer files are omitted and the loop
|
| 1159 |
+
state is marked, so resume starts FRESH moments: a recorded deviation, and
|
| 1160 |
+
NOT lossless when the optimizer holds the fp32 master.
|
| 1161 |
+
`resume.pt` is carried over with its world-size stamp rewritten and the
|
| 1162 |
+
per-rank RNG list dropped (the trainer restarts RNG from the seed with a
|
| 1163 |
+
warning; carrying N_old streams into N_new ranks would be wrong).
|
| 1164 |
+
"""
|
| 1165 |
+
if new_ws < 1:
|
| 1166 |
+
raise ValueError("new_ws must be >= 1")
|
| 1167 |
+
m = read_manifest(src)
|
| 1168 |
+
for r in range(m["world_size"]):
|
| 1169 |
+
bad = verify_rank_file(src, r)
|
| 1170 |
+
if bad:
|
| 1171 |
+
raise ShardedCheckpointError(bad)
|
| 1172 |
+
if os.path.exists(dst):
|
| 1173 |
+
raise FileExistsError(f"{dst} exists; reshard never overwrites")
|
| 1174 |
+
staging = dst.rstrip("/") + ".partial"
|
| 1175 |
+
if os.path.isdir(staging):
|
| 1176 |
+
shutil.rmtree(staging)
|
| 1177 |
+
os.makedirs(staging)
|
| 1178 |
+
try:
|
| 1179 |
+
infos = []
|
| 1180 |
+
with GlobalReader(src, m) as g:
|
| 1181 |
+
for r in range(new_ws):
|
| 1182 |
+
entries, tdesc = [], {}
|
| 1183 |
+
for name in sorted(m["tensors"]):
|
| 1184 |
+
e = m["tensors"][name]
|
| 1185 |
+
dt, shape = e["dtype"], tuple(e["shape"])
|
| 1186 |
+
if e["placement"] == "shard0":
|
| 1187 |
+
off, cnt = chunk_range(shape[0], new_ws, r)
|
| 1188 |
+
lshape = (cnt,) + shape[1:]
|
| 1189 |
+
rb = _prod(shape[1:]) * _ST_ITEMSIZE[dt]
|
| 1190 |
+
lo, hi = off * rb, (off + cnt) * rb
|
| 1191 |
+
tdesc[name] = {"shape": list(shape), "placement": "shard0", "row": [off, cnt], "dtype": dt}
|
| 1192 |
+
else:
|
| 1193 |
+
lshape, lo, hi = shape, 0, g.nbytes(name)
|
| 1194 |
+
tdesc[name] = {"shape": list(shape), "placement": "replicate", "row": None, "dtype": dt}
|
| 1195 |
+
|
| 1196 |
+
def emit(f, name=name, lo=lo, hi=hi):
|
| 1197 |
+
g.copy_range(name, lo, hi, f)
|
| 1198 |
+
|
| 1199 |
+
entries.append(StEntry(name, dt, lshape, emit))
|
| 1200 |
+
fname = rank_file_name(r)
|
| 1201 |
+
meta = write_safetensors_streaming(os.path.join(staging, fname), entries, {"format": "pt"})
|
| 1202 |
+
infos.append({"file": {"name": fname, **meta}, "tensors": tdesc, "order": m.get("key_order")})
|
| 1203 |
+
new_manifest = build_manifest(
|
| 1204 |
+
infos,
|
| 1205 |
+
param_order=m.get("param_order"),
|
| 1206 |
+
export=m.get("export"),
|
| 1207 |
+
extra={"resharded_from": {"world_size": m["world_size"], "source": os.path.basename(src.rstrip("/"))}},
|
| 1208 |
+
)
|
| 1209 |
+
write_manifest(staging, new_manifest)
|
| 1210 |
+
|
| 1211 |
+
state = torch.load(os.path.join(src, "resume.pt"), weights_only=True, map_location="cpu")
|
| 1212 |
+
state.pop("rng_per_rank", None)
|
| 1213 |
+
if drop_optimizer:
|
| 1214 |
+
state["optimizer_state"] = {"layout": "none", "world_size": new_ws}
|
| 1215 |
+
else:
|
| 1216 |
+
state["optimizer_state"] = {"layout": "per_rank_files", "world_size": new_ws}
|
| 1217 |
+
olds = [
|
| 1218 |
+
torch.load(os.path.join(src, f"optimizer_rank{r}.pt"), weights_only=True, map_location="cpu", mmap=True)
|
| 1219 |
+
for r in range(m["world_size"])
|
| 1220 |
+
]
|
| 1221 |
+
for r, sd in enumerate(iter_reshard_optimizer(olds, m, new_ws)):
|
| 1222 |
+
p = os.path.join(staging, f"optimizer_rank{r}.pt")
|
| 1223 |
+
with open(p, "wb") as f:
|
| 1224 |
+
torch.save(sd, f)
|
| 1225 |
+
f.flush()
|
| 1226 |
+
os.fsync(f.fileno())
|
| 1227 |
+
del sd # one new rank's state alive at a time
|
| 1228 |
+
del olds
|
| 1229 |
+
with open(os.path.join(staging, "resume.pt"), "wb") as f:
|
| 1230 |
+
torch.save(state, f)
|
| 1231 |
+
f.flush()
|
| 1232 |
+
os.fsync(f.fileno())
|
| 1233 |
+
write_integrity(
|
| 1234 |
+
staging,
|
| 1235 |
+
{
|
| 1236 |
+
fn: file_sha256(os.path.join(staging, fn))
|
| 1237 |
+
for fn in sorted(os.listdir(staging))
|
| 1238 |
+
if fn == "resume.pt" or re.fullmatch(r"optimizer_rank\d+\.pt", fn)
|
| 1239 |
+
},
|
| 1240 |
+
)
|
| 1241 |
+
for fn in sorted(os.listdir(src)):
|
| 1242 |
+
if not _OWN_FILE_RE.match(fn) and os.path.isfile(os.path.join(src, fn)):
|
| 1243 |
+
shutil.copyfile(os.path.join(src, fn), os.path.join(staging, fn))
|
| 1244 |
+
_fsync_tree(staging)
|
| 1245 |
+
os.rename(staging, dst)
|
| 1246 |
+
_fsync_dir(os.path.dirname(os.path.abspath(dst)))
|
| 1247 |
+
except BaseException:
|
| 1248 |
+
shutil.rmtree(staging, ignore_errors=True)
|
| 1249 |
+
raise
|
| 1250 |
+
return {"dst": dst, "world_size": new_ws, "from": m["world_size"], "optimizer": "dropped" if drop_optimizer else "resharded"}
|
| 1251 |
+
|
| 1252 |
+
|
| 1253 |
+
def peak_rss_kb() -> int:
|
| 1254 |
+
"""VmHWM of this process in kB (psutil is not a dependency)."""
|
| 1255 |
+
with open("/proc/self/status") as f:
|
| 1256 |
+
for line in f:
|
| 1257 |
+
if line.startswith("VmHWM:"):
|
| 1258 |
+
return int(line.split()[1])
|
| 1259 |
+
return -1
|
| 1260 |
+
|
| 1261 |
+
|
| 1262 |
+
__all__ = [
|
| 1263 |
+
"CHECKPOINT_FORMATS",
|
| 1264 |
+
"SHARD_MANIFEST_NAME",
|
| 1265 |
+
"ShardedCheckpointError",
|
| 1266 |
+
"ShardedWorldSizeError",
|
| 1267 |
+
"ReshardUnsupported",
|
| 1268 |
+
"chunk_range",
|
| 1269 |
+
"save_rank_shard",
|
| 1270 |
+
"build_manifest",
|
| 1271 |
+
"write_manifest",
|
| 1272 |
+
"read_manifest",
|
| 1273 |
+
"is_sharded",
|
| 1274 |
+
"sharded_problem",
|
| 1275 |
+
"verify_rank_file",
|
| 1276 |
+
"load_rank_shard",
|
| 1277 |
+
"load_full_state_dict",
|
| 1278 |
+
"describe_weight_format",
|
| 1279 |
+
"build_hf_plan",
|
| 1280 |
+
"export_hf",
|
| 1281 |
+
"reshard",
|
| 1282 |
+
"reshard_optimizer",
|
| 1283 |
+
"iter_reshard_optimizer",
|
| 1284 |
+
"peak_rss_kb",
|
| 1285 |
+
]
|
code/align/train/stage_gates.py
ADDED
|
@@ -0,0 +1,1002 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Stage gate sequencing and decisions (section-11).
|
| 2 |
+
|
| 3 |
+
This is the first stage that spends real GPU-hours, so the job of this module
|
| 4 |
+
is to make wasting them structurally impossible rather than merely unlikely.
|
| 5 |
+
Everything here is a pure decision over recorded facts; the stage script drives
|
| 6 |
+
it and owns the side effects.
|
| 7 |
+
|
| 8 |
+
WHY THE ORDER IS FIXED, and why it is enforced instead of documented:
|
| 9 |
+
|
| 10 |
+
golden_regression -> smoke_7b -> probe_20step -> rss_preflight
|
| 11 |
+
-> train -> eval -> quality_gate
|
| 12 |
+
|
| 13 |
+
* The golden regression runs before ANY new training because it validates
|
| 14 |
+
the eval stack itself against numbers that are already known. A defective
|
| 15 |
+
harness silently corrupts every downstream measurement, and a full-epoch
|
| 16 |
+
run that under-performs a 0.35-epoch run then looks like a modeling
|
| 17 |
+
result instead of the stack bug it is.
|
| 18 |
+
* The 7B smoke runs before the probe because the chunked-loss peak-memory
|
| 19 |
+
assertion is only meaningful at 7B on the real 4-GPU topology. A
|
| 20 |
+
unit-scale memory check cannot catch what a 32768-vocab logit tensor does
|
| 21 |
+
to a 23GB card.
|
| 22 |
+
* The probe runs before the preflight and the launch because a measured step
|
| 23 |
+
time that invalidates the schedule must stop the run BEFORE it starts, not
|
| 24 |
+
three hours in.
|
| 25 |
+
|
| 26 |
+
MODULE-LEVEL IMPORTS ARE DELIBERATELY TORCH-FREE. `scripts/sft.py` imports this
|
| 27 |
+
module above `apply_resource_guards()`, and torch reads
|
| 28 |
+
PYTORCH_CUDA_ALLOC_CONF, the cache paths and the thread caps AT IMPORT -- so
|
| 29 |
+
anything that pulls torch in transitively (probes, trainer, model) is imported
|
| 30 |
+
lazily inside the function that needs it. A test asserts this.
|
| 31 |
+
"""
|
| 32 |
+
|
| 33 |
+
from __future__ import annotations
|
| 34 |
+
|
| 35 |
+
import hashlib
|
| 36 |
+
import json
|
| 37 |
+
import logging
|
| 38 |
+
import math
|
| 39 |
+
import os
|
| 40 |
+
from dataclasses import dataclass
|
| 41 |
+
from datetime import datetime, timezone
|
| 42 |
+
from typing import Any, Optional
|
| 43 |
+
|
| 44 |
+
from ..registry import MANDATORY_BANNER_KEYS, assert_banner_complete
|
| 45 |
+
|
| 46 |
+
logger = logging.getLogger(__name__)
|
| 47 |
+
|
| 48 |
+
NAN = float("nan")
|
| 49 |
+
|
| 50 |
+
#: Explicit "not measured". Never absent, never a plausible substitute.
|
| 51 |
+
PENDING = "PENDING"
|
| 52 |
+
|
| 53 |
+
#: The fixed stage sequence. Data, not prose: the ledger iterates it.
|
| 54 |
+
GATE_ORDER: tuple[str, ...] = (
|
| 55 |
+
"golden_regression",
|
| 56 |
+
"smoke_7b",
|
| 57 |
+
"probe_20step",
|
| 58 |
+
"rss_preflight",
|
| 59 |
+
"train",
|
| 60 |
+
"eval",
|
| 61 |
+
"quality_gate",
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
#: Gates whose result depends on the eval stack. A change under `align/eval/`
|
| 65 |
+
#: invalidates exactly these -- re-running the 7B smoke or the probe because
|
| 66 |
+
#: someone edited a verifier would burn GPU-hours for nothing.
|
| 67 |
+
EVAL_DEPENDENT_GATES: frozenset[str] = frozenset({"golden_regression", "eval", "quality_gate"})
|
| 68 |
+
|
| 69 |
+
LEDGER_FILENAME = "stage_gates.json"
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _is_rank0() -> bool:
|
| 73 |
+
"""Rank from the environment, so this works before (and without) any
|
| 74 |
+
process group -- same convention as the heavy-job flock."""
|
| 75 |
+
return int(os.environ.get("RANK", "0")) == 0
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
class GateOrderError(RuntimeError):
|
| 79 |
+
"""Raised when a gate runs before its predecessors have passed."""
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
@dataclass
|
| 83 |
+
class GateDecision:
|
| 84 |
+
passed: bool
|
| 85 |
+
halt_message: str = ""
|
| 86 |
+
detail: dict = None # type: ignore[assignment]
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
# ---------------------------------------------------------------------------
|
| 90 |
+
# ledger
|
| 91 |
+
# ---------------------------------------------------------------------------
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def eval_source_hash(repo_root: Optional[str] = None) -> str:
|
| 95 |
+
"""Hash the `align/eval/` source tree.
|
| 96 |
+
|
| 97 |
+
The golden regression certifies the harness, so its pass is only valid for
|
| 98 |
+
the harness that produced it. Keying the ledger entry on this hash is what
|
| 99 |
+
turns "we ran the golden regression once" into "we ran it against THIS
|
| 100 |
+
code".
|
| 101 |
+
"""
|
| 102 |
+
root = repo_root or os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 103 |
+
eval_dir = os.path.join(root, "align", "eval")
|
| 104 |
+
h = hashlib.sha256()
|
| 105 |
+
for dirpath, _dirs, files in sorted(os.walk(eval_dir)):
|
| 106 |
+
for fn in sorted(files):
|
| 107 |
+
if not fn.endswith(".py"):
|
| 108 |
+
continue
|
| 109 |
+
path = os.path.join(dirpath, fn)
|
| 110 |
+
h.update(os.path.relpath(path, eval_dir).encode())
|
| 111 |
+
with open(path, "rb") as f:
|
| 112 |
+
h.update(f.read())
|
| 113 |
+
return h.hexdigest()[:16]
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
class GateLedger:
|
| 117 |
+
"""Records gate results, enforces the order, survives a restart."""
|
| 118 |
+
|
| 119 |
+
def __init__(self, run_dir: str, eval_source_hash: Optional[str] = None):
|
| 120 |
+
self.run_dir = run_dir
|
| 121 |
+
os.makedirs(run_dir, exist_ok=True)
|
| 122 |
+
self.path = os.path.join(run_dir, LEDGER_FILENAME)
|
| 123 |
+
self.eval_source_hash = eval_source_hash
|
| 124 |
+
self._entries: dict[str, dict] = {}
|
| 125 |
+
self._load()
|
| 126 |
+
|
| 127 |
+
# -- persistence --------------------------------------------------------
|
| 128 |
+
|
| 129 |
+
def _load(self) -> None:
|
| 130 |
+
if not os.path.isfile(self.path):
|
| 131 |
+
return
|
| 132 |
+
try:
|
| 133 |
+
with open(self.path) as f:
|
| 134 |
+
blob = json.load(f)
|
| 135 |
+
except (OSError, json.JSONDecodeError):
|
| 136 |
+
logger.warning("unreadable gate ledger at %s; starting fresh", self.path)
|
| 137 |
+
return
|
| 138 |
+
|
| 139 |
+
recorded_hash = blob.get("eval_source_hash")
|
| 140 |
+
self._entries = dict(blob.get("gates", {}))
|
| 141 |
+
if (
|
| 142 |
+
self.eval_source_hash is not None
|
| 143 |
+
and recorded_hash is not None
|
| 144 |
+
and recorded_hash != self.eval_source_hash
|
| 145 |
+
):
|
| 146 |
+
dropped = [g for g in EVAL_DEPENDENT_GATES if g in self._entries]
|
| 147 |
+
for g in dropped:
|
| 148 |
+
del self._entries[g]
|
| 149 |
+
if dropped:
|
| 150 |
+
logger.warning(
|
| 151 |
+
"align/eval/ changed since this ledger was written (%s -> %s); "
|
| 152 |
+
"invalidated %s. The golden regression certifies the eval "
|
| 153 |
+
"stack, so its pass does not carry across a change to it.",
|
| 154 |
+
recorded_hash,
|
| 155 |
+
self.eval_source_hash,
|
| 156 |
+
sorted(dropped),
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
def _save(self) -> None:
|
| 160 |
+
"""Persist. RANK 0 ONLY -- see `record`."""
|
| 161 |
+
if not _is_rank0():
|
| 162 |
+
return
|
| 163 |
+
tmp = self.path + ".partial"
|
| 164 |
+
with open(tmp, "w") as f:
|
| 165 |
+
json.dump(
|
| 166 |
+
{"eval_source_hash": self.eval_source_hash, "gates": self._entries},
|
| 167 |
+
f,
|
| 168 |
+
indent=1,
|
| 169 |
+
sort_keys=True,
|
| 170 |
+
# Runs on EVERY record: the site with the most chances to meet
|
| 171 |
+
# a non-serializable detail (attempt-6 class).
|
| 172 |
+
default=str,
|
| 173 |
+
)
|
| 174 |
+
os.replace(tmp, self.path)
|
| 175 |
+
|
| 176 |
+
# -- recording ----------------------------------------------------------
|
| 177 |
+
|
| 178 |
+
def record(self, gate: str, passed: bool, detail: str = "") -> dict:
|
| 179 |
+
if gate not in GATE_ORDER:
|
| 180 |
+
raise GateOrderError(f"unknown gate {gate!r}; the sequence is {list(GATE_ORDER)}")
|
| 181 |
+
idx = GATE_ORDER.index(gate)
|
| 182 |
+
for earlier in GATE_ORDER[:idx]:
|
| 183 |
+
if not self.is_passed(earlier):
|
| 184 |
+
raise GateOrderError(
|
| 185 |
+
f"refusing to record {gate!r}: {earlier!r} has not passed "
|
| 186 |
+
f"(it reads {self.status()[earlier]!r}). The stage sequence "
|
| 187 |
+
f"is fixed at {list(GATE_ORDER)} and each gate exists to "
|
| 188 |
+
"stop the next one from wasting GPU-hours. Run the earlier "
|
| 189 |
+
"gate, or fix why it failed."
|
| 190 |
+
)
|
| 191 |
+
entry = {
|
| 192 |
+
"passed": bool(passed),
|
| 193 |
+
"detail": detail,
|
| 194 |
+
"recorded_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
| 195 |
+
}
|
| 196 |
+
# EVERY rank updates its own in-memory view; only RANK 0 persists.
|
| 197 |
+
#
|
| 198 |
+
# THE LEDGER RENAME RACE (killed attempt 3, past the smoke): all four
|
| 199 |
+
# ranks must run the gates -- the 7B smoke needs the full FSDP
|
| 200 |
+
# topology -- so all four called this, all four wrote the SAME
|
| 201 |
+
# `.partial`, the first rename consumed it and the other three died on
|
| 202 |
+
# FileNotFoundError. A gate is a property of the JOB, not of a rank.
|
| 203 |
+
#
|
| 204 |
+
# NO BARRIER, deliberately, and this is the safer half of the choice.
|
| 205 |
+
# Every rank keeps an identical in-memory ledger (the order checks and
|
| 206 |
+
# the passed flag are deterministic from local state), so no rank ever
|
| 207 |
+
# needs to READ the file mid-run -- which means no collective here,
|
| 208 |
+
# which means no way for a rank-0 raise to strand its peers waiting on
|
| 209 |
+
# one. A barrier would have re-introduced the NCCL-timeout shape to
|
| 210 |
+
# fix a filesystem race. Gate failures are themselves deterministic
|
| 211 |
+
# across ranks (same config, same files, host-wide MemAvailable), so
|
| 212 |
+
# all ranks raise together and torchrun propagates it.
|
| 213 |
+
self._entries[gate] = entry
|
| 214 |
+
self._save()
|
| 215 |
+
logger.info("gate %s: %s (%s)", gate, "PASS" if passed else "FAIL", detail or "-")
|
| 216 |
+
return entry
|
| 217 |
+
|
| 218 |
+
# -- reading ------------------------------------------------------------
|
| 219 |
+
|
| 220 |
+
def is_passed(self, gate: str) -> bool:
|
| 221 |
+
return self._entries.get(gate, {}).get("passed") is True
|
| 222 |
+
|
| 223 |
+
def status(self) -> dict:
|
| 224 |
+
"""Every gate present; unrun gates read PENDING, never absent."""
|
| 225 |
+
return {g: (self._entries[g] if g in self._entries else PENDING) for g in GATE_ORDER}
|
| 226 |
+
|
| 227 |
+
def next_gate(self) -> Optional[str]:
|
| 228 |
+
for gate in GATE_ORDER:
|
| 229 |
+
if not self.is_passed(gate):
|
| 230 |
+
return gate
|
| 231 |
+
return None
|
| 232 |
+
|
| 233 |
+
def require(self, gate: str) -> None:
|
| 234 |
+
if not self.is_passed(gate):
|
| 235 |
+
raise GateOrderError(
|
| 236 |
+
f"gate {gate!r} has not passed (reads {self.status()[gate]!r}); "
|
| 237 |
+
"refusing to continue"
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
# ---------------------------------------------------------------------------
|
| 242 |
+
# individual gates
|
| 243 |
+
# ---------------------------------------------------------------------------
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def _mem_available_gb() -> float:
|
| 247 |
+
with open("/proc/meminfo") as f:
|
| 248 |
+
for line in f:
|
| 249 |
+
if line.startswith("MemAvailable:"):
|
| 250 |
+
return float(line.split()[1]) / 1024**2
|
| 251 |
+
raise RuntimeError("/proc/meminfo has no MemAvailable line")
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def rss_preflight(cfg, mem_available_gb: Optional[float] = None) -> dict:
|
| 255 |
+
"""`MemAvailable >= projected_job_RSS + headroom`, or refuse to start.
|
| 256 |
+
|
| 257 |
+
The generic 45GB launch floor is insufficient for this job class and that
|
| 258 |
+
insufficiency IS the 2026-08-12 incident: the offloaded optimizer state
|
| 259 |
+
(~87GB) arrives over the first optimizer steps, AFTER any launch-time free
|
| 260 |
+
check has passed. On a box with no swap there is no overflow -- exhausting
|
| 261 |
+
RAM wedges the host in D-state and takes unrelated runs down with it.
|
| 262 |
+
"""
|
| 263 |
+
from .probes import projected_job_rss # imports torch; kept lazy
|
| 264 |
+
|
| 265 |
+
projection = projected_job_rss(cfg)
|
| 266 |
+
projected = projection["total_gb"]
|
| 267 |
+
headroom = cfg.gates.rss_headroom_gb
|
| 268 |
+
required = projected + headroom
|
| 269 |
+
available = _mem_available_gb() if mem_available_gb is None else float(mem_available_gb)
|
| 270 |
+
|
| 271 |
+
result = {
|
| 272 |
+
"passed": available >= required,
|
| 273 |
+
"mem_available_gb": available,
|
| 274 |
+
"projected_job_rss_gb": projected,
|
| 275 |
+
"headroom_gb": headroom,
|
| 276 |
+
"required_gb": required,
|
| 277 |
+
"breakdown": projection,
|
| 278 |
+
}
|
| 279 |
+
if not result["passed"]:
|
| 280 |
+
raise RuntimeError(
|
| 281 |
+
f"RSS PREFLIGHT FAILED: MemAvailable {available:.1f}GB < projected "
|
| 282 |
+
f"job RSS {projected:.1f}GB + {headroom:.0f}GB headroom = "
|
| 283 |
+
f"{required:.1f}GB.\nBreakdown: {projection}\nThis box has NO SWAP, "
|
| 284 |
+
"so there is no overflow: the offloaded optimizer state lands after "
|
| 285 |
+
"launch and would wedge the host mid-run (2026-08-12: 2GB free, "
|
| 286 |
+
"load 619, a healthy run killed by a 5-hour NCCL stall). Wait for "
|
| 287 |
+
"the host to quiesce, or switch optimizer.name to adamw8bit, which "
|
| 288 |
+
"drops the host-state term entirely."
|
| 289 |
+
)
|
| 290 |
+
logger.info("RSS preflight ok: %.1fGB available >= %.1fGB required", available, required)
|
| 291 |
+
return result
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def schedule_check(probe_report: dict, cfg, optimizer_steps_planned: int) -> dict:
|
| 295 |
+
"""Project epoch wall-clock from the measured step time, or refuse.
|
| 296 |
+
|
| 297 |
+
A measured step time that invalidates the schedule must stop the run before
|
| 298 |
+
it starts. Three hours into an epoch is too late to discover that the
|
| 299 |
+
epoch was never going to fit the window.
|
| 300 |
+
"""
|
| 301 |
+
step_time = float(probe_report.get("mean_step_time_s", NAN))
|
| 302 |
+
if math.isnan(step_time):
|
| 303 |
+
raise RuntimeError(
|
| 304 |
+
"schedule check refused: the probe did not measure a step time "
|
| 305 |
+
"(mean_step_time_s is NaN). Every comparison against NaN is False, "
|
| 306 |
+
"so a naive budget check would read 'within budget' and launch. "
|
| 307 |
+
"Re-run the 20-step probe."
|
| 308 |
+
)
|
| 309 |
+
if optimizer_steps_planned <= 0:
|
| 310 |
+
raise ValueError(f"optimizer_steps_planned={optimizer_steps_planned} is not a schedule")
|
| 311 |
+
|
| 312 |
+
projected = step_time * optimizer_steps_planned
|
| 313 |
+
allowed = cfg.gates.epoch_budget_s * cfg.gates.schedule_budget_factor
|
| 314 |
+
result = {
|
| 315 |
+
"passed": projected <= allowed,
|
| 316 |
+
"mean_step_time_s": step_time,
|
| 317 |
+
"optimizer_steps_planned": optimizer_steps_planned,
|
| 318 |
+
"projected_epoch_s": projected,
|
| 319 |
+
"budget_s": cfg.gates.epoch_budget_s,
|
| 320 |
+
"allowed_s": allowed,
|
| 321 |
+
}
|
| 322 |
+
if not result["passed"]:
|
| 323 |
+
raise RuntimeError(
|
| 324 |
+
f"SCHEDULE CHECK FAILED: measured step time {step_time:.2f}s x "
|
| 325 |
+
f"{optimizer_steps_planned} optimizer steps projects an epoch "
|
| 326 |
+
f"wall-clock of {projected:.0f}s, beyond the allowed "
|
| 327 |
+
f"{allowed:.0f}s (budget {cfg.gates.epoch_budget_s:.0f}s x factor "
|
| 328 |
+
f"{cfg.gates.schedule_budget_factor}). Refusing to launch: the "
|
| 329 |
+
"GPU-hours are spent whether or not the epoch finishes. Either "
|
| 330 |
+
"raise gates.epoch_budget_s deliberately, or find the step-time "
|
| 331 |
+
"regression first."
|
| 332 |
+
)
|
| 333 |
+
logger.info("schedule ok: %.0fs projected <= %.0fs allowed", projected, allowed)
|
| 334 |
+
return result
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
def quality_gate(report: dict, cfg) -> GateDecision:
|
| 338 |
+
"""Does the new SFT checkpoint reach the golden anchors?
|
| 339 |
+
|
| 340 |
+
Delegates the accuracy-vs-CI comparison to section-10's
|
| 341 |
+
`assert_meets_golden` rather than restating it: that function compares
|
| 342 |
+
against the WILSON LOWER BOUND (the golden numbers are themselves
|
| 343 |
+
measurements with sampling noise, so demanding the point estimate meet
|
| 344 |
+
them would fail correct runs about half the time), and two copies of that
|
| 345 |
+
reasoning would drift.
|
| 346 |
+
|
| 347 |
+
NaN halts. That is the extraction-ceiling incident in gate form: a PERFECT
|
| 348 |
+
policy scored 0/100 for two full PPO runs because extraction was broken,
|
| 349 |
+
and an undefined accuracy that compared False would read as 'above golden'.
|
| 350 |
+
"""
|
| 351 |
+
from ..eval.harness import assert_meets_golden
|
| 352 |
+
|
| 353 |
+
golden = {"math500": cfg.gates.golden_math500, "gsm8k": cfg.gates.golden_gsm8k}
|
| 354 |
+
try:
|
| 355 |
+
assert_meets_golden(report, golden)
|
| 356 |
+
except RuntimeError as exc:
|
| 357 |
+
return GateDecision(
|
| 358 |
+
passed=False,
|
| 359 |
+
halt_message=(
|
| 360 |
+
f"{exc}\n\nSTAGE HALTED. Section-12 (DPO) must not start on a "
|
| 361 |
+
"failed SFT gate. Bisect in this order before touching any "
|
| 362 |
+
"hyperparameter -- DATA (is the corpus the rebuilt one, and "
|
| 363 |
+
"does a decoded sample match the training format?), then LOSS "
|
| 364 |
+
"(does fused_ce still match the eager reference?), then "
|
| 365 |
+
"TRAINER (did the trunk-movement probe pass, and did the "
|
| 366 |
+
"schedule run in optimizer steps?), then EVAL (does the golden "
|
| 367 |
+
"regression still reproduce 0.09/0.258?). A full-epoch run "
|
| 368 |
+
"below a 0.35-epoch anchor is near-certain evidence of a "
|
| 369 |
+
"new-stack defect, not a modeling result."
|
| 370 |
+
),
|
| 371 |
+
detail={"golden": golden, "report": report.get("per_set", {})},
|
| 372 |
+
)
|
| 373 |
+
return GateDecision(passed=True, detail={"golden": golden})
|
| 374 |
+
|
| 375 |
+
|
| 376 |
+
def write_quality_gate_receipt(cfg, decision: "GateDecision", checkpoint: str = "") -> str:
|
| 377 |
+
"""Write the receipt section-12 reads before it will start.
|
| 378 |
+
|
| 379 |
+
The contract is narrow on purpose: a JSON object with a BOOLEAN `passed`.
|
| 380 |
+
Section-12's `read_sft_quality_gate` refuses anything else -- absent,
|
| 381 |
+
PENDING, or a truthy string all read as "not passed", because the whole
|
| 382 |
+
point is that a DPO run cannot begin on an SFT stage that did not clear
|
| 383 |
+
its own gate.
|
| 384 |
+
"""
|
| 385 |
+
path = cfg.paths.sft_quality_gate_path
|
| 386 |
+
if not _is_rank0():
|
| 387 |
+
# Same rename race as the ledger: one receipt, written by the job.
|
| 388 |
+
return path
|
| 389 |
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
| 390 |
+
payload = {
|
| 391 |
+
"passed": bool(decision.passed),
|
| 392 |
+
# `detail` is the key SECTION-12's reader quotes in its refusal
|
| 393 |
+
# message. Emitting only `halt_message` would leave their error
|
| 394 |
+
# reading "(no detail recorded)" -- technically a correct refusal,
|
| 395 |
+
# and useless to whoever has to act on it.
|
| 396 |
+
"detail": (
|
| 397 |
+
decision.halt_message.splitlines()[0]
|
| 398 |
+
if decision.halt_message
|
| 399 |
+
else "quality gate passed"
|
| 400 |
+
),
|
| 401 |
+
"halt_message": decision.halt_message,
|
| 402 |
+
"checkpoint": checkpoint,
|
| 403 |
+
"golden": (decision.detail or {}).get("golden", {}),
|
| 404 |
+
"recorded_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
| 405 |
+
}
|
| 406 |
+
tmp = path + ".partial"
|
| 407 |
+
with open(tmp, "w") as f:
|
| 408 |
+
# default=str: this receipt is written AFTER the full epoch and both
|
| 409 |
+
# evals -- a non-serializable value here would discard the run's
|
| 410 |
+
# verdict at maximum cost (the smoke-receipt Tensor crash, attempt 6).
|
| 411 |
+
json.dump(payload, f, indent=1, sort_keys=True, default=str)
|
| 412 |
+
os.replace(tmp, path)
|
| 413 |
+
logger.info("SFT quality-gate receipt written: %s (passed=%s)", path, payload["passed"])
|
| 414 |
+
return path
|
| 415 |
+
|
| 416 |
+
|
| 417 |
+
def assert_build_is_current(manifest_path: str, cfg) -> dict:
|
| 418 |
+
"""Refuse to train on a data build that predates the last semantic fix.
|
| 419 |
+
|
| 420 |
+
SECTION-03 REOPENED: every SFT target rendered `# Answer` TWICE -- the raw
|
| 421 |
+
PRM800K final step carries the marker inline and the renderer prepended it
|
| 422 |
+
again. Extraction survived by taking the last match, so nothing failed
|
| 423 |
+
loudly; the cost is training on a format the extraction ceiling never
|
| 424 |
+
exercised.
|
| 425 |
+
|
| 426 |
+
Read honestly from the manifest, with no hardcoded dates: `pins_verified`
|
| 427 |
+
must be True, `pin_violations` must be empty, and `build_version` must
|
| 428 |
+
reach the configured minimum. A MISSING `build_version` raises -- an
|
| 429 |
+
unstamped build is not evidence of a current one.
|
| 430 |
+
"""
|
| 431 |
+
if not os.path.isfile(manifest_path):
|
| 432 |
+
raise RuntimeError(
|
| 433 |
+
f"no data build manifest at {manifest_path}. The stage consumes "
|
| 434 |
+
"shard hashes from the build artifact and never rebuilds data; "
|
| 435 |
+
"run scripts/build_data.py first."
|
| 436 |
+
)
|
| 437 |
+
|
| 438 |
+
# Section-03 drops a DEFECTIVE marker beside a build it has condemned.
|
| 439 |
+
# Checked FIRST and independently of build_version: a human-written "do
|
| 440 |
+
# not train on this" is the most direct signal there is, and honouring it
|
| 441 |
+
# costs one stat() while ignoring it costs a 4-hour epoch.
|
| 442 |
+
defective = os.path.join(os.path.dirname(os.path.abspath(manifest_path)), "DEFECTIVE")
|
| 443 |
+
if os.path.isfile(defective):
|
| 444 |
+
with open(defective) as f:
|
| 445 |
+
reason = f.read().strip()
|
| 446 |
+
raise RuntimeError(
|
| 447 |
+
f"the data build at {os.path.dirname(defective)} is marked "
|
| 448 |
+
f"DEFECTIVE by section-03:\n\n{reason}\n\nRefusing to train on "
|
| 449 |
+
"condemned shards."
|
| 450 |
+
)
|
| 451 |
+
|
| 452 |
+
with open(manifest_path) as f:
|
| 453 |
+
manifest = json.load(f)
|
| 454 |
+
|
| 455 |
+
if manifest.get("pins_verified") is not True:
|
| 456 |
+
raise RuntimeError(
|
| 457 |
+
f"{manifest_path}: pins_verified is "
|
| 458 |
+
f"{manifest.get('pins_verified')!r}, not True. The corpus counts "
|
| 459 |
+
"were not corroborated against their pinned values, so the stage "
|
| 460 |
+
"cannot say what it would be training on."
|
| 461 |
+
)
|
| 462 |
+
violations = manifest.get("pin_violations") or []
|
| 463 |
+
if violations:
|
| 464 |
+
raise RuntimeError(
|
| 465 |
+
f"{manifest_path}: pins_verified is True but pin_violations is "
|
| 466 |
+
f"non-empty: {violations}. Trust the violations, not the flag."
|
| 467 |
+
)
|
| 468 |
+
|
| 469 |
+
version = manifest.get("build_version")
|
| 470 |
+
minimum = cfg.gates.min_build_version
|
| 471 |
+
if version is None:
|
| 472 |
+
raise RuntimeError(
|
| 473 |
+
f"{manifest_path} has no `build_version` field, so the stage "
|
| 474 |
+
"cannot tell whether this corpus predates the doubled-`# Answer`-"
|
| 475 |
+
"marker fix. An unstamped build is NOT assumed current: section-03 "
|
| 476 |
+
"stamps build_version (>= 2 after the fix; 1 is everything before "
|
| 477 |
+
"it) and this stage requires at least "
|
| 478 |
+
f"{minimum}. Rebuild the corpus, or add the field if the rebuild "
|
| 479 |
+
"already happened."
|
| 480 |
+
)
|
| 481 |
+
if int(version) < minimum:
|
| 482 |
+
raise RuntimeError(
|
| 483 |
+
f"{manifest_path}: build_version {version} < required {minimum}. "
|
| 484 |
+
"This corpus predates the doubled-`# Answer`-marker fix -- every "
|
| 485 |
+
"SFT target renders the marker twice, which trains a format the "
|
| 486 |
+
"extraction ceiling never exercised. Rebuild before spending "
|
| 487 |
+
"GPU-hours on it."
|
| 488 |
+
)
|
| 489 |
+
logger.info("data build ok: build_version=%s, pins verified", version)
|
| 490 |
+
return manifest
|
| 491 |
+
|
| 492 |
+
|
| 493 |
+
def assert_distinct_devices() -> dict:
|
| 494 |
+
"""Every rank must own a DIFFERENT physical GPU. Microseconds, at startup.
|
| 495 |
+
|
| 496 |
+
THE DUPLICATE-GPU DEFECT. torchrun starts four processes and assigns no
|
| 497 |
+
devices; nothing bound a rank to its LOCAL_RANK, so every `.cuda()` landed
|
| 498 |
+
on device 0 and NCCL died with "Duplicate GPU detected : rank 2 and rank 0
|
| 499 |
+
both on CUDA device 1b0" -> ncclInvalidUsage. The symptom appeared deep in
|
| 500 |
+
a 7B forward+backward and read like a sharding or dtype problem; the cause
|
| 501 |
+
was four processes sharing one card.
|
| 502 |
+
|
| 503 |
+
Two checks, and the second is the one that matters. `current_device() ==
|
| 504 |
+
local_rank()` catches the missing bind. But the REAL invariant is that the
|
| 505 |
+
four devices are physically distinct -- a correct-looking local index can
|
| 506 |
+
still collide under an unexpected CUDA_VISIBLE_DEVICES -- so the ranks
|
| 507 |
+
all-gather their device UUIDs and assert uniqueness. UUID rather than
|
| 508 |
+
pci_bus_id: `get_device_properties(i).pci_bus_id` returns the local index
|
| 509 |
+
(0 here), which is exactly the number that would agree while the hardware
|
| 510 |
+
collides.
|
| 511 |
+
|
| 512 |
+
Raises with the full rank -> device map, so the failure names itself
|
| 513 |
+
instead of surfacing 400 lines later as an NCCL error code.
|
| 514 |
+
"""
|
| 515 |
+
import torch
|
| 516 |
+
|
| 517 |
+
from ..utils.dist import local_rank, world_size
|
| 518 |
+
|
| 519 |
+
if not torch.cuda.is_available():
|
| 520 |
+
return {"checked": False, "reason": "no CUDA device visible"}
|
| 521 |
+
|
| 522 |
+
current, lr = torch.cuda.current_device(), local_rank()
|
| 523 |
+
uuid = str(getattr(torch.cuda.get_device_properties(current), "uuid", current))
|
| 524 |
+
ws = world_size()
|
| 525 |
+
|
| 526 |
+
import torch.distributed as dist
|
| 527 |
+
|
| 528 |
+
distributed = ws > 1 and dist.is_available() and dist.is_initialized()
|
| 529 |
+
|
| 530 |
+
# THE COLLECTIVE COMES FIRST, and my own both-directions test is why. My
|
| 531 |
+
# first version raised the local mismatch BEFORE gathering: under a real
|
| 532 |
+
# collision ranks 1-3 raised immediately while rank 0 (whose
|
| 533 |
+
# current_device DID equal its local_rank) entered the all-gather alone
|
| 534 |
+
# and waited forever for peers that had already died. A guard against a
|
| 535 |
+
# hang must not be able to hang. So every rank enters the collective
|
| 536 |
+
# unconditionally, then all of them evaluate the SAME gathered map and
|
| 537 |
+
# raise identically.
|
| 538 |
+
gathered: list = [(0, lr, current, uuid)]
|
| 539 |
+
if distributed:
|
| 540 |
+
# GLOO, not the default NCCL group. NCCL cannot complete a collective
|
| 541 |
+
# on ranks that share a device -- it raises ncclInvalidUsage -- so a
|
| 542 |
+
# guard that gathers over NCCL reports the very failure it exists to
|
| 543 |
+
# name, in NCCL's words instead of ours. gloo is CPU-side and does not
|
| 544 |
+
# care about GPU binding, so the duplicate map survives to the error
|
| 545 |
+
# message. Verified: with all four ranks forced onto device 0 this
|
| 546 |
+
# prints the rank->device map; over NCCL it printed "invalid usage".
|
| 547 |
+
group = getattr(assert_distinct_devices, "_guard_group", None)
|
| 548 |
+
if group is None:
|
| 549 |
+
group = dist.new_group(backend="gloo")
|
| 550 |
+
assert_distinct_devices._guard_group = group
|
| 551 |
+
gathered = [None] * ws
|
| 552 |
+
dist.all_gather_object(gathered, (dist.get_rank(), lr, current, uuid), group=group)
|
| 553 |
+
|
| 554 |
+
device_map = {r: {"local_rank": l, "current_device": c, "uuid": u} for r, l, c, u in gathered}
|
| 555 |
+
|
| 556 |
+
mismatched = {r: v for r, v in device_map.items() if v["current_device"] != v["local_rank"]}
|
| 557 |
+
if mismatched:
|
| 558 |
+
raise RuntimeError(
|
| 559 |
+
f"rank binding is wrong on {sorted(mismatched)}: "
|
| 560 |
+
f"{mismatched}. Call align.utils.dist.init_distributed() BEFORE any "
|
| 561 |
+
"CUDA op -- it does init_process_group + set_device(local_rank) -- "
|
| 562 |
+
"and place tensors with torch.device('cuda', local_rank()), never "
|
| 563 |
+
"a bare .cuda()."
|
| 564 |
+
)
|
| 565 |
+
|
| 566 |
+
uuids = [v["uuid"] for v in device_map.values()]
|
| 567 |
+
if len(set(uuids)) != len(uuids):
|
| 568 |
+
collisions: dict = {}
|
| 569 |
+
for r, v in device_map.items():
|
| 570 |
+
collisions.setdefault(v["uuid"], []).append(r)
|
| 571 |
+
dupes = {u: rs for u, rs in collisions.items() if len(rs) > 1}
|
| 572 |
+
raise RuntimeError(
|
| 573 |
+
f"DUPLICATE GPU: {len(uuids)} ranks are sharing "
|
| 574 |
+
f"{len(set(uuids))} physical device(s). Ranks per device: {dupes}. "
|
| 575 |
+
f"Full map: {device_map}. Every rank must bind its own card before "
|
| 576 |
+
"any CUDA allocation; this is the ncclInvalidUsage that surfaces "
|
| 577 |
+
"later as a mysterious failure inside the model's forward."
|
| 578 |
+
)
|
| 579 |
+
logger.info("device binding ok: %d rank(s) on %d distinct GPU(s)", len(uuids), len(set(uuids)))
|
| 580 |
+
return {"checked": True, "world_size": len(uuids), "device_map": device_map}
|
| 581 |
+
|
| 582 |
+
|
| 583 |
+
def derived_global_batch_rows(cfg) -> int:
|
| 584 |
+
"""Rows per OPTIMIZER step, derived from the numbers that actually
|
| 585 |
+
produce them: grad_accum x micro_batch_size x world_size."""
|
| 586 |
+
return cfg.optimizer.grad_accum * cfg.train.micro_batch_size * cfg.train.world_size
|
| 587 |
+
|
| 588 |
+
|
| 589 |
+
def assert_batch_arithmetic_consistent(cfg) -> dict:
|
| 590 |
+
"""`global_batch_rows` must equal accum x micro x world, or refuse.
|
| 591 |
+
|
| 592 |
+
THE TWO-HORIZONS DEFECT (found pre-launch). configs/sft.toml carried
|
| 593 |
+
grad_accum 4 x micro_batch 3 x world 4 = 48 rows per optimizer step while
|
| 594 |
+
declaring global_batch_rows = 24. Nothing reconciled them, and the two
|
| 595 |
+
numbers feed DIFFERENT consumers: the banner's reported horizon is
|
| 596 |
+
ceil(rows / global_batch_rows), while the Trainer's LR schedule is
|
| 597 |
+
ceil(len(sampler) x epochs / accum) and never reads global_batch_rows at
|
| 598 |
+
all. An LR schedule that ends at half or a third of its reported horizon
|
| 599 |
+
is the iterations-vs-steps incident wearing a new coat -- the run finishes
|
| 600 |
+
at a learning rate nobody chose, and the banner records the intended one.
|
| 601 |
+
|
| 602 |
+
This makes the config internally consistent. It does NOT by itself make
|
| 603 |
+
the banner agree with the Trainer -- see `assert_horizon_agrees`, because
|
| 604 |
+
the length-grouped sampler batches by TOKEN budget, so its batch count is
|
| 605 |
+
not rows/global_batch under any value.
|
| 606 |
+
"""
|
| 607 |
+
derived = derived_global_batch_rows(cfg)
|
| 608 |
+
declared = cfg.train.global_batch_rows
|
| 609 |
+
if declared != derived:
|
| 610 |
+
raise RuntimeError(
|
| 611 |
+
f"batch arithmetic is inconsistent: train.global_batch_rows="
|
| 612 |
+
f"{declared} but grad_accum({cfg.optimizer.grad_accum}) x "
|
| 613 |
+
f"micro_batch_size({cfg.train.micro_batch_size}) x world_size("
|
| 614 |
+
f"{cfg.train.world_size}) = {derived}. One of these is wrong and "
|
| 615 |
+
"the stage cannot tell which: they drive DIFFERENT consumers "
|
| 616 |
+
"(the banner's reported horizon vs the optimizer step the trainer "
|
| 617 |
+
"actually takes), so a mismatch means the LR schedule ends "
|
| 618 |
+
f"{max(declared, derived) / min(declared, derived):.1f}x away from "
|
| 619 |
+
"the horizon the banner reports. Set global_batch_rows to "
|
| 620 |
+
f"{derived}, or change accum/micro/world so the product is "
|
| 621 |
+
f"{declared}."
|
| 622 |
+
)
|
| 623 |
+
return {"global_batch_rows": declared, "derived": derived}
|
| 624 |
+
|
| 625 |
+
|
| 626 |
+
def assert_horizon_agrees(sampler_horizon: int, cfg, tolerance: float = 0.0) -> dict:
|
| 627 |
+
"""The banner's horizon and the Trainer's must be the SAME number.
|
| 628 |
+
|
| 629 |
+
The Trainer schedules on `ceil(len(sampler) x epochs / accum)`; the banner
|
| 630 |
+
reports `ceil(rows / global_batch_rows) x epochs`. Because the sampler
|
| 631 |
+
batches by token budget rather than row count, these agree only by
|
| 632 |
+
coincidence. Rather than pick one silently, this refuses on disagreement
|
| 633 |
+
and prints both -- an LR horizon is a pre-registered quantity and choosing
|
| 634 |
+
it is a human decision, not a rounding convention.
|
| 635 |
+
"""
|
| 636 |
+
from .stage_smoke import _sft_rows
|
| 637 |
+
|
| 638 |
+
config_horizon = scheduler_horizon_optimizer_steps(
|
| 639 |
+
rows=_sft_rows(cfg), global_batch=cfg.train.global_batch_rows, epochs=cfg.train.epochs
|
| 640 |
+
)
|
| 641 |
+
if sampler_horizon != config_horizon:
|
| 642 |
+
spread = max(sampler_horizon, config_horizon) / max(1, min(sampler_horizon, config_horizon))
|
| 643 |
+
raise RuntimeError(
|
| 644 |
+
f"HORIZON MISMATCH: the trainer will schedule over "
|
| 645 |
+
f"{sampler_horizon} optimizer steps (len(sampler) x epochs / "
|
| 646 |
+
f"accum) but the config projects {config_horizon} "
|
| 647 |
+
f"(rows / global_batch_rows x epochs) -- {spread:.2f}x apart. The "
|
| 648 |
+
"LR schedule is a pre-registered quantity; the stage refuses to "
|
| 649 |
+
"guess which horizon was intended. Reconcile global_batch_rows "
|
| 650 |
+
"with the sampler's token budget, or set the horizon explicitly."
|
| 651 |
+
)
|
| 652 |
+
return {"horizon_optimizer_steps": sampler_horizon}
|
| 653 |
+
|
| 654 |
+
|
| 655 |
+
def stamp_golden_report(path: str, repo_root: Optional[str] = None) -> str:
|
| 656 |
+
"""Stamp the golden report with the hash of the eval source that PRODUCED it (audit U01-9).
|
| 657 |
+
|
| 658 |
+
Called by `scripts/eval.py --mode=golden` right after the run (also when the gate raised: the
|
| 659 |
+
failing report is evidence too). `record_golden_from_report` refuses a report whose stamp is
|
| 660 |
+
not the current eval source, so editing align/eval/ and re-recording from the old report
|
| 661 |
+
cannot re-certify the new code. Atomic rewrite; returns the stamped hash.
|
| 662 |
+
"""
|
| 663 |
+
h = eval_source_hash(repo_root)
|
| 664 |
+
with open(path, "rb") as f:
|
| 665 |
+
report = json.loads(f.read())
|
| 666 |
+
report["eval_source_hash"] = h
|
| 667 |
+
tmp = path + ".partial"
|
| 668 |
+
with open(tmp, "w") as f:
|
| 669 |
+
json.dump(report, f, indent=1, default=str)
|
| 670 |
+
f.flush()
|
| 671 |
+
os.fsync(f.fileno())
|
| 672 |
+
os.replace(tmp, path)
|
| 673 |
+
return h
|
| 674 |
+
|
| 675 |
+
|
| 676 |
+
def record_golden_from_report(
|
| 677 |
+
ledger: GateLedger, report_path: Optional[str] = None, cfg=None
|
| 678 |
+
) -> dict:
|
| 679 |
+
"""Bridge the golden-regression RUN to the gate LEDGER, by re-deciding.
|
| 680 |
+
|
| 681 |
+
THE MISSING-BRIDGE DEFECT (pre-launch): `scripts/eval.py --mode=golden`
|
| 682 |
+
writes its report and passes or raises IN PROCESS; nothing ever wrote the
|
| 683 |
+
verdict into the ledger. `require_golden_receipt` reads
|
| 684 |
+
`ledger.is_passed("golden_regression")`, so after a PASSING golden run the
|
| 685 |
+
SFT stage would still have refused on PENDING.
|
| 686 |
+
|
| 687 |
+
VERIFY FROM EVIDENCE, NOT FROM A FLAG. This does not take "the run
|
| 688 |
+
exited 0" as the receipt -- it loads the persisted report and re-runs the
|
| 689 |
+
verdict through section-10's own `assert_meets_golden`, so there is ONE
|
| 690 |
+
authority for what meeting the golden numbers means. A report that no
|
| 691 |
+
longer passes cannot leave a stale PASS in the ledger.
|
| 692 |
+
|
| 693 |
+
Three outcomes, and the middle one is the one that gets skipped:
|
| 694 |
+
* report passes -> record PASSED, with the path and sha256 in the
|
| 695 |
+
detail so the receipt names the exact bytes it certified;
|
| 696 |
+
* report FAILS -> record FAILED with the failure text. Failing is not
|
| 697 |
+
not-yet-run: a recorded failure BLOCKS every later gate, whereas
|
| 698 |
+
leaving it unrecorded would let a rerun quietly try again;
|
| 699 |
+
* report absent -> record NOTHING, leaving the gate PENDING. The run
|
| 700 |
+
has not happened; inventing a verdict from a missing file is the
|
| 701 |
+
failure this whole gate exists to prevent.
|
| 702 |
+
|
| 703 |
+
sha256 is computed inline rather than via `receipts.sha256_file` to keep
|
| 704 |
+
this module importable above the resource guards -- see the module header.
|
| 705 |
+
"""
|
| 706 |
+
from ..eval.golden import GOLDEN, GOLDEN_N, assert_meets_golden
|
| 707 |
+
|
| 708 |
+
path = report_path or (cfg.paths.golden_report_path if cfg is not None else None)
|
| 709 |
+
if not path:
|
| 710 |
+
raise ValueError("record_golden_from_report needs a report path or a cfg")
|
| 711 |
+
|
| 712 |
+
if not os.path.isfile(path):
|
| 713 |
+
logger.warning(
|
| 714 |
+
"golden report %s is absent; leaving golden_regression PENDING. "
|
| 715 |
+
"Run `scripts/eval.py --mode=golden` before the stage.",
|
| 716 |
+
path,
|
| 717 |
+
)
|
| 718 |
+
return {"recorded": False, "passed": None, "path": path, "reason": "report absent"}
|
| 719 |
+
|
| 720 |
+
with open(path, "rb") as f:
|
| 721 |
+
raw = f.read()
|
| 722 |
+
digest = hashlib.sha256(raw).hexdigest()
|
| 723 |
+
report = json.loads(raw)
|
| 724 |
+
|
| 725 |
+
# U01-9: the ledger drops the golden pass when align/eval/ changes (`GateLedger._load`), but a
|
| 726 |
+
# report produced by the OLD source would otherwise be re-recorded as a fresh PASS here. The
|
| 727 |
+
# report must name the source that produced it, and it must be the source we are running.
|
| 728 |
+
stamped = report.get("eval_source_hash") if isinstance(report, dict) else None
|
| 729 |
+
current = ledger.eval_source_hash or eval_source_hash()
|
| 730 |
+
if stamped is None:
|
| 731 |
+
logger.warning(
|
| 732 |
+
"golden report %s carries no eval_source_hash (it predates producer stamping): "
|
| 733 |
+
"cannot verify it was produced by the current align/eval/ source",
|
| 734 |
+
path,
|
| 735 |
+
)
|
| 736 |
+
elif stamped != current:
|
| 737 |
+
logger.error(
|
| 738 |
+
"golden report %s was produced by eval source %s but align/eval/ is now %s; refusing to "
|
| 739 |
+
"re-certify it. Re-run `scripts/eval.py --mode=golden`.",
|
| 740 |
+
path,
|
| 741 |
+
stamped,
|
| 742 |
+
current,
|
| 743 |
+
)
|
| 744 |
+
return {
|
| 745 |
+
"recorded": False,
|
| 746 |
+
"passed": None,
|
| 747 |
+
"path": path,
|
| 748 |
+
"reason": f"golden report produced by a different eval source ({stamped} != {current})",
|
| 749 |
+
}
|
| 750 |
+
|
| 751 |
+
try:
|
| 752 |
+
assert_meets_golden(report, GOLDEN, expected_n=GOLDEN_N)
|
| 753 |
+
except (RuntimeError, AssertionError) as exc:
|
| 754 |
+
detail = json.dumps({"report": path, "sha256": digest, "failure": str(exc)[:600]})
|
| 755 |
+
ledger.record("golden_regression", passed=False, detail=detail)
|
| 756 |
+
logger.error("golden regression FAILED per %s: %s", path, exc)
|
| 757 |
+
return {
|
| 758 |
+
"recorded": True,
|
| 759 |
+
"passed": False,
|
| 760 |
+
"path": path,
|
| 761 |
+
"sha256": digest,
|
| 762 |
+
"failure": str(exc),
|
| 763 |
+
}
|
| 764 |
+
|
| 765 |
+
detail = json.dumps(
|
| 766 |
+
{
|
| 767 |
+
"report": path,
|
| 768 |
+
"sha256": digest,
|
| 769 |
+
"golden": GOLDEN,
|
| 770 |
+
"expected_n": GOLDEN_N,
|
| 771 |
+
"per_set": report.get("per_set", {}),
|
| 772 |
+
},
|
| 773 |
+
default=str,
|
| 774 |
+
)
|
| 775 |
+
ledger.record("golden_regression", passed=True, detail=detail)
|
| 776 |
+
logger.info("golden regression PASSED per %s (sha256 %s)", path, digest[:12])
|
| 777 |
+
return {"recorded": True, "passed": True, "path": path, "sha256": digest}
|
| 778 |
+
|
| 779 |
+
|
| 780 |
+
def require_golden_receipt(ledger: GateLedger, cfg) -> None:
|
| 781 |
+
"""The golden regression must have PASSED, not merely been attempted.
|
| 782 |
+
|
| 783 |
+
PENDING is never passed. This is the precondition that keeps a defective
|
| 784 |
+
eval stack from certifying everything downstream of it.
|
| 785 |
+
"""
|
| 786 |
+
if not cfg.gates.require_golden_receipt:
|
| 787 |
+
logger.warning(
|
| 788 |
+
"gates.require_golden_receipt is False -- training will "
|
| 789 |
+
"start without a validated eval stack"
|
| 790 |
+
)
|
| 791 |
+
return
|
| 792 |
+
if not ledger.is_passed("golden_regression"):
|
| 793 |
+
raise GateOrderError(
|
| 794 |
+
"the golden-regression gate has not passed (it reads "
|
| 795 |
+
f"{ledger.status()['golden_regression']!r}). Refusing to train: "
|
| 796 |
+
"that run is what validates the eval stack -- verifier, prompt "
|
| 797 |
+
"template, extraction, inference path -- against numbers that are "
|
| 798 |
+
"already known. Without it, every accuracy this stage produces is "
|
| 799 |
+
"unfalsifiable. Run `scripts/eval.py --mode=golden` first."
|
| 800 |
+
)
|
| 801 |
+
|
| 802 |
+
|
| 803 |
+
# ---------------------------------------------------------------------------
|
| 804 |
+
# banner
|
| 805 |
+
# ---------------------------------------------------------------------------
|
| 806 |
+
|
| 807 |
+
|
| 808 |
+
def assemble_sft_banner(
|
| 809 |
+
cfg,
|
| 810 |
+
attention_backend: str,
|
| 811 |
+
optimizer_impl: str,
|
| 812 |
+
dataset_hashes: Optional[dict],
|
| 813 |
+
ceiling_results: Optional[dict],
|
| 814 |
+
probe_report: Optional[dict],
|
| 815 |
+
rss_projection: Optional[dict],
|
| 816 |
+
mde: Optional[dict],
|
| 817 |
+
scheduler_horizon_optimizer_steps: Optional[int],
|
| 818 |
+
scheduler_horizon_rows_derived: Optional[int] = None,
|
| 819 |
+
) -> dict:
|
| 820 |
+
"""Every mandatory banner value, resolved or explicitly PENDING/NaN.
|
| 821 |
+
|
| 822 |
+
`beta` is NaN for SFT rather than 0.0: 0.0 is a legitimate DPO beta, so a
|
| 823 |
+
zero here would be indistinguishable from a real setting.
|
| 824 |
+
|
| 825 |
+
THE HORIZON IS THE TRAINER'S, NOT THE CONFIG'S. `scheduler_horizon_
|
| 826 |
+
optimizer_steps` must be the sampler-derived number the scheduler will
|
| 827 |
+
actually run; the rows/global_batch projection rides beside it under a
|
| 828 |
+
LABELED secondary key. A banner that records a horizon the scheduler does
|
| 829 |
+
not run is the config-translation value-drift class in its purest form --
|
| 830 |
+
the record looks right and the run is something else. A mismatch is LOGGED
|
| 831 |
+
loudly here rather than raised; the hard refusal
|
| 832 |
+
(`assert_horizon_agrees`) is wired after the first SFT run so it cannot
|
| 833 |
+
become a fresh launch blocker.
|
| 834 |
+
"""
|
| 835 |
+
banner = {
|
| 836 |
+
"attention_backend": attention_backend,
|
| 837 |
+
"loss_type": "sft_ce",
|
| 838 |
+
"beta": NAN,
|
| 839 |
+
"lr": cfg.optimizer.lr,
|
| 840 |
+
"scheduler_horizon_optimizer_steps": (
|
| 841 |
+
scheduler_horizon_optimizer_steps
|
| 842 |
+
if scheduler_horizon_optimizer_steps is not None
|
| 843 |
+
else PENDING
|
| 844 |
+
),
|
| 845 |
+
"dataset_hashes": dataset_hashes,
|
| 846 |
+
"ceiling_results": ceiling_results,
|
| 847 |
+
"rss_budget": rss_projection,
|
| 848 |
+
"pinned_staging_gb": (rss_projection or {}).get("pinned_staging_gb", PENDING),
|
| 849 |
+
"probe_step_time_s": (probe_report or {}).get("mean_step_time_s", PENDING),
|
| 850 |
+
"mde": mde,
|
| 851 |
+
"optimizer_impl": optimizer_impl,
|
| 852 |
+
"grad_accum": cfg.optimizer.grad_accum,
|
| 853 |
+
"probe_p90_step_time_s": (probe_report or {}).get("p90_step_time_s", PENDING),
|
| 854 |
+
"scheduler_horizon_rows_derived": (
|
| 855 |
+
scheduler_horizon_rows_derived
|
| 856 |
+
if scheduler_horizon_rows_derived is not None
|
| 857 |
+
else PENDING
|
| 858 |
+
),
|
| 859 |
+
}
|
| 860 |
+
if (
|
| 861 |
+
scheduler_horizon_rows_derived is not None
|
| 862 |
+
and scheduler_horizon_optimizer_steps is not None
|
| 863 |
+
and scheduler_horizon_rows_derived != scheduler_horizon_optimizer_steps
|
| 864 |
+
):
|
| 865 |
+
logger.warning(
|
| 866 |
+
"HORIZON DIVERGENCE: the scheduler will run %s optimizer steps "
|
| 867 |
+
"(sampler-derived, AUTHORITATIVE) but rows/global_batch_rows "
|
| 868 |
+
"projects %s -- %.2fx apart. The banner records the former "
|
| 869 |
+
"because that is what runs. Reconcile global_batch_rows with the "
|
| 870 |
+
"sampler's token budget; the hard refusal lands after this run.",
|
| 871 |
+
scheduler_horizon_optimizer_steps,
|
| 872 |
+
scheduler_horizon_rows_derived,
|
| 873 |
+
max(scheduler_horizon_optimizer_steps, scheduler_horizon_rows_derived)
|
| 874 |
+
/ max(1, min(scheduler_horizon_optimizer_steps, scheduler_horizon_rows_derived)),
|
| 875 |
+
)
|
| 876 |
+
# Raises rather than printing a partial banner: a run that cannot answer
|
| 877 |
+
# its own question does not begin.
|
| 878 |
+
assert_banner_complete(banner)
|
| 879 |
+
return banner
|
| 880 |
+
|
| 881 |
+
|
| 882 |
+
def assert_probe_resolved(banner: dict) -> None:
|
| 883 |
+
"""A stage that REQUIRES the probe refuses to launch on PENDING.
|
| 884 |
+
|
| 885 |
+
Banner completeness and banner resolution are different properties: every
|
| 886 |
+
key being present is not the same as every value having been measured.
|
| 887 |
+
"""
|
| 888 |
+
pending = [
|
| 889 |
+
k
|
| 890 |
+
for k in ("probe_step_time_s", "rss_budget", "scheduler_horizon_optimizer_steps")
|
| 891 |
+
if banner.get(k) == PENDING or banner.get(k) is None
|
| 892 |
+
]
|
| 893 |
+
if pending:
|
| 894 |
+
raise RuntimeError(
|
| 895 |
+
f"refusing to launch: {pending} still read PENDING. The 20-step "
|
| 896 |
+
"probe and the RSS projection must be MEASURED before the epoch "
|
| 897 |
+
"starts -- their whole purpose is to stop a run that cannot fit "
|
| 898 |
+
"its schedule or its host RAM."
|
| 899 |
+
)
|
| 900 |
+
step_time = banner["probe_step_time_s"]
|
| 901 |
+
if isinstance(step_time, float) and math.isnan(step_time):
|
| 902 |
+
raise RuntimeError(
|
| 903 |
+
"probe_step_time_s is NaN: the probe ran but measured nothing. "
|
| 904 |
+
"Refusing to launch on an unmeasured schedule."
|
| 905 |
+
)
|
| 906 |
+
|
| 907 |
+
|
| 908 |
+
# ---------------------------------------------------------------------------
|
| 909 |
+
# CLI contract + stage drivers (thin; scripts/sft.py owns the side effects)
|
| 910 |
+
# ---------------------------------------------------------------------------
|
| 911 |
+
|
| 912 |
+
|
| 913 |
+
def parse_args(argv: Optional[list[str]] = None):
|
| 914 |
+
"""`torchrun --nproc_per_node=4 scripts/sft.py configs/sft.toml
|
| 915 |
+
--optimizer.lr=1e-5` -- first positional is the TOML, the rest are dotted
|
| 916 |
+
overrides handed to section-01's loader (struct mode on, so a typo'd key
|
| 917 |
+
raises at launch instead of training with the default it failed to
|
| 918 |
+
override).
|
| 919 |
+
|
| 920 |
+
Parsed by hand rather than with argparse: `--optimizer.lr=1e-5` is not an
|
| 921 |
+
argparse option, it is a dotted-override entry (`section.field=value`), and argparse rejects
|
| 922 |
+
every one of them as unrecognized.
|
| 923 |
+
"""
|
| 924 |
+
import sys as _sys
|
| 925 |
+
|
| 926 |
+
argv = list(_sys.argv[1:] if argv is None else argv)
|
| 927 |
+
if "-h" in argv or "--help" in argv:
|
| 928 |
+
print(__doc__)
|
| 929 |
+
raise SystemExit(0)
|
| 930 |
+
|
| 931 |
+
positional = [a for a in argv if not a.startswith("-")]
|
| 932 |
+
overrides = [a.lstrip("-") for a in argv if a.startswith("-")]
|
| 933 |
+
if not positional:
|
| 934 |
+
raise SystemExit(
|
| 935 |
+
"usage: sft.py <config.toml> [--section.field=value ...]\n"
|
| 936 |
+
"The stage TOML is required: a stage that picks its own defaults "
|
| 937 |
+
"is a stage nobody can reproduce."
|
| 938 |
+
)
|
| 939 |
+
if len(positional) > 1:
|
| 940 |
+
raise SystemExit(
|
| 941 |
+
f"expected exactly one config TOML, got {positional}. Extra "
|
| 942 |
+
"positional arguments are silently ignored by most launchers, "
|
| 943 |
+
"which is how a run ends up using the wrong config."
|
| 944 |
+
)
|
| 945 |
+
bad = [o for o in overrides if "=" not in o]
|
| 946 |
+
if bad:
|
| 947 |
+
raise SystemExit(f"overrides must be dotted key=value pairs; got {bad}")
|
| 948 |
+
return type("Args", (), {"config": positional[0], "overrides": overrides})()
|
| 949 |
+
|
| 950 |
+
|
| 951 |
+
def run_entry_gates(
|
| 952 |
+
cfg,
|
| 953 |
+
ledger: GateLedger,
|
| 954 |
+
smoke_steps: int = 10,
|
| 955 |
+
probe_steps: int = 20,
|
| 956 |
+
mem_available_gb: Optional[float] = None,
|
| 957 |
+
optimizer_steps_planned: Optional[int] = None,
|
| 958 |
+
) -> dict:
|
| 959 |
+
"""The M5 entry sequence, in order: 7B smoke -> 20-step probe -> RSS
|
| 960 |
+
preflight. GPU-only; imported lazily so this module stays torch-free."""
|
| 961 |
+
from .stage_smoke import run_smoke_and_probe # GPU-only helpers
|
| 962 |
+
|
| 963 |
+
require_golden_receipt(ledger, cfg)
|
| 964 |
+
assert_batch_arithmetic_consistent(cfg)
|
| 965 |
+
|
| 966 |
+
smoke = run_smoke_and_probe(cfg, smoke_steps=smoke_steps, probe_steps=probe_steps)
|
| 967 |
+
ledger.record("smoke_7b", passed=True, detail=json.dumps(smoke["smoke"], default=str))
|
| 968 |
+
ledger.record("probe_20step", passed=True, detail=json.dumps(smoke["probe"], default=str))
|
| 969 |
+
|
| 970 |
+
steps = optimizer_steps_planned or smoke["optimizer_steps_planned"]
|
| 971 |
+
schedule_check(smoke["probe"], cfg, optimizer_steps_planned=steps)
|
| 972 |
+
rss = rss_preflight(cfg, mem_available_gb=mem_available_gb)
|
| 973 |
+
ledger.record(
|
| 974 |
+
"rss_preflight",
|
| 975 |
+
passed=True,
|
| 976 |
+
detail=json.dumps({k: v for k, v in rss.items() if k != "breakdown"}),
|
| 977 |
+
)
|
| 978 |
+
|
| 979 |
+
return assemble_sft_banner(
|
| 980 |
+
cfg,
|
| 981 |
+
attention_backend=smoke["attention_backend"],
|
| 982 |
+
optimizer_impl=smoke["optimizer_impl"],
|
| 983 |
+
dataset_hashes=smoke["dataset_hashes"],
|
| 984 |
+
ceiling_results=smoke["ceiling_results"],
|
| 985 |
+
probe_report=smoke["probe"],
|
| 986 |
+
rss_projection=rss["breakdown"],
|
| 987 |
+
mde=smoke["mde"],
|
| 988 |
+
scheduler_horizon_optimizer_steps=steps,
|
| 989 |
+
scheduler_horizon_rows_derived=smoke.get("optimizer_steps_rows_derived"),
|
| 990 |
+
)
|
| 991 |
+
|
| 992 |
+
|
| 993 |
+
def evaluate_and_gate(cfg, ledger: GateLedger, checkpoint: str) -> GateDecision:
|
| 994 |
+
"""Run the post-training eval and apply the quality gate, recording both."""
|
| 995 |
+
from .stage_smoke import run_stage_eval # GPU-only helper
|
| 996 |
+
|
| 997 |
+
report = run_stage_eval(cfg, checkpoint)
|
| 998 |
+
ledger.record("eval", passed=True, detail=json.dumps(report.get("per_set", {}), default=str))
|
| 999 |
+
decision = quality_gate(report, cfg)
|
| 1000 |
+
ledger.record("quality_gate", passed=decision.passed, detail=decision.halt_message[:400])
|
| 1001 |
+
write_quality_gate_receipt(cfg, decision, checkpoint=checkpoint)
|
| 1002 |
+
return decision
|
code/align/train/stage_smoke.py
ADDED
|
@@ -0,0 +1,530 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""GPU-only stage helpers: the 7B smoke, the throughput probe, the stage eval.
|
| 2 |
+
|
| 3 |
+
Separated from `stage_gates.py` so that module stays importable above the
|
| 4 |
+
resource guards. Everything here touches torch, the 7B weights, or the eval
|
| 5 |
+
harness, so nothing here runs in the CPU suite -- the gate DECISIONS these
|
| 6 |
+
feed are what the CPU tests cover.
|
| 7 |
+
|
| 8 |
+
The peak-memory assertion lives here rather than in the unit tests because it
|
| 9 |
+
is only meaningful at 7B on the real topology: the tensor it proves we avoid
|
| 10 |
+
is `[B*T, 32768]`, which at seq 1024 x micro-batch 8 is 1.0GB in bf16 plus
|
| 11 |
+
another 1.0GB of gradient. A unit-scale check cannot see that.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import logging
|
| 17 |
+
import time
|
| 18 |
+
from typing import Optional
|
| 19 |
+
|
| 20 |
+
import torch
|
| 21 |
+
|
| 22 |
+
logger = logging.getLogger(__name__)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _load_snapshot_state(snapshot: str) -> dict:
|
| 26 |
+
"""HF-layout state dict from a snapshot dir, single-file OR sharded, cast
|
| 27 |
+
to bf16 shard-by-shard.
|
| 28 |
+
|
| 29 |
+
The pinned Mistral-7B-v0.3 snapshot is THREE shards plus
|
| 30 |
+
`model.safetensors.index.json`; `transformers.from_pretrained` used to
|
| 31 |
+
resolve that. The layout logic (and the index-vs-shards completeness
|
| 32 |
+
check) now lives in `ckpt.load_hf_safetensors`, so every caller pointed at
|
| 33 |
+
a base snapshot gets the same behaviour; this wrapper only pins the dtype.
|
| 34 |
+
"""
|
| 35 |
+
from ..model import checkpoint as ckpt
|
| 36 |
+
|
| 37 |
+
return ckpt.load_hf_safetensors(snapshot, dtype=torch.bfloat16)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def build_stage_model(
|
| 41 |
+
cfg, attn_implementation: Optional[str] = None, weights_dir: Optional[str] = None
|
| 42 |
+
):
|
| 43 |
+
"""The real 7B model, FSDP2-sharded per block with activation
|
| 44 |
+
checkpointing. Wrapping happens HERE (the stage), never inside the
|
| 45 |
+
Trainer -- the Trainer receives an already-wrapped model, which is what
|
| 46 |
+
keeps it identical between the SFT and DPO stages.
|
| 47 |
+
|
| 48 |
+
`weights_dir` names an HF-layout checkpoint whose weights replace the
|
| 49 |
+
pinned snapshot's (the architecture still comes from the snapshot's
|
| 50 |
+
config.json, which a full-FT checkpoint shares). Without it the model is
|
| 51 |
+
the PRETRAINED BASE -- right for training, wrong for evaluating a trained
|
| 52 |
+
checkpoint (G3 audit U01-4: the stage-11 gate scored the base)."""
|
| 53 |
+
from ..model import checkpoint as ckpt
|
| 54 |
+
from ..model.hf_config import read_config
|
| 55 |
+
from ..model.mistral import NmoMistralForCausalLM
|
| 56 |
+
from . import fsdp
|
| 57 |
+
|
| 58 |
+
# Local only -- never let a stage reach the hub and start a 15GB download
|
| 59 |
+
# onto a shared box mid-launch. `resolve_snapshot_dir` accepts the
|
| 60 |
+
# configured `snapshots/` parent and resolves the one hash dir under it.
|
| 61 |
+
snapshot = ckpt.resolve_snapshot_dir(cfg.paths.model_snapshot)
|
| 62 |
+
# The pre-port path went through transformers' `from_pretrained` (and its
|
| 63 |
+
# MistralConfig defaults); `read_config` states every architecture field
|
| 64 |
+
# explicitly and refuses a config.json that omits one.
|
| 65 |
+
model_cfg = read_config(snapshot)
|
| 66 |
+
model_cfg.attn_implementation = attn_implementation or cfg.model.attn_implementation
|
| 67 |
+
# Shards are read straight from safetensors (the snapshot is bf16 on disk;
|
| 68 |
+
# the cast is a no-op there and a safety net for an fp32 checkpoint).
|
| 69 |
+
hf_state = _load_snapshot_state(weights_dir if weights_dir is not None else snapshot)
|
| 70 |
+
model = NmoMistralForCausalLM.from_state_dict(
|
| 71 |
+
model_cfg,
|
| 72 |
+
ckpt.hf_to_nmo(hf_state),
|
| 73 |
+
attn_implementation=model_cfg.attn_implementation,
|
| 74 |
+
)
|
| 75 |
+
del hf_state
|
| 76 |
+
|
| 77 |
+
# Onto THIS rank's device before sharding. The weights arrive on CPU from
|
| 78 |
+
# safetensors, and `fully_shard` on CPU parameters shards the wrong
|
| 79 |
+
# thing; the batches are already `.cuda()`. Uses the local rank explicitly
|
| 80 |
+
# rather than a bare `.cuda()`, which is device 0 for every rank.
|
| 81 |
+
from ..utils.dist import local_rank
|
| 82 |
+
|
| 83 |
+
device = torch.device("cuda", local_rank()) if torch.cuda.is_available() else None
|
| 84 |
+
if device is not None:
|
| 85 |
+
torch.cuda.set_device(device)
|
| 86 |
+
model = model.to(device)
|
| 87 |
+
|
| 88 |
+
fsdp.apply_activation_checkpointing(model)
|
| 89 |
+
fsdp.apply_fsdp(model)
|
| 90 |
+
return model
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def assert_chunked_loss_memory(cfg, model, batch) -> dict:
|
| 94 |
+
"""The chunked-loss peak-memory assertion, at 7B scale.
|
| 95 |
+
|
| 96 |
+
MEASURES THE LOSS, NOT THE STEP -- and the first 7B run is why. I had
|
| 97 |
+
wrapped the whole adapter forward+backward, so the measured peak was
|
| 98 |
+
6103 MiB against a [1002, 32768] logit tensor of 125 MiB: a 40x "failure"
|
| 99 |
+
that was really the 7B model's own activations and FSDP all-gathers being
|
| 100 |
+
attributed to the loss. The gate asserts that CHUNKING controls the loss's
|
| 101 |
+
memory; feeding it the model's memory makes it unsatisfiable by any
|
| 102 |
+
implementation, which is the same unsatisfiable-gate trap section-05
|
| 103 |
+
already hit once with the lm_head gradient.
|
| 104 |
+
|
| 105 |
+
So the hidden states are produced OUTSIDE the measured region and
|
| 106 |
+
detached, and the lm_head weight is materialised once via `full_tensor()`
|
| 107 |
+
(it is a DTensor under FSDP2 and the fused loss needs the whole thing).
|
| 108 |
+
What remains inside the measurement is exactly the chunked loss and its
|
| 109 |
+
backward.
|
| 110 |
+
|
| 111 |
+
The full step's peak is still REPORTED, as `full_step_peak_bytes` -- it is
|
| 112 |
+
the number that tells us what a rank actually costs -- it just is not what
|
| 113 |
+
this gate asserts on.
|
| 114 |
+
"""
|
| 115 |
+
from ..kernels.fused_ce import (
|
| 116 |
+
IGNORE_INDEX,
|
| 117 |
+
assert_chunking_saves,
|
| 118 |
+
assert_peak_memory_below_full_logits,
|
| 119 |
+
fused_linear_cross_entropy,
|
| 120 |
+
lm_head_grad_allowance,
|
| 121 |
+
)
|
| 122 |
+
from .sft_loss import make_sft_loss_adapter
|
| 123 |
+
|
| 124 |
+
input_ids, labels = batch
|
| 125 |
+
target = labels[:, 1:]
|
| 126 |
+
|
| 127 |
+
# 1. The real step, executed and measured for the record (this is also the
|
| 128 |
+
# end-to-end wiring check: FSDP2 hooks, checkpointing, fused path).
|
| 129 |
+
torch.cuda.reset_peak_memory_stats()
|
| 130 |
+
out = make_sft_loss_adapter(require_fused=True)(model, (input_ids, labels))
|
| 131 |
+
out.loss.backward()
|
| 132 |
+
torch.cuda.synchronize()
|
| 133 |
+
full_step_peak = int(torch.cuda.max_memory_allocated())
|
| 134 |
+
model.zero_grad(set_to_none=True)
|
| 135 |
+
loss_value = float(out.loss.detach())
|
| 136 |
+
del out
|
| 137 |
+
|
| 138 |
+
# 2. The gate: the chunked loss in isolation, over detached hidden states.
|
| 139 |
+
with torch.no_grad():
|
| 140 |
+
hidden = model(input_ids=input_ids, return_hidden_states=True)
|
| 141 |
+
h = hidden[:, :-1].detach().clone().requires_grad_(True)
|
| 142 |
+
del hidden
|
| 143 |
+
# n_rows DERIVED FROM THE TENSOR, never from the config. The measured
|
| 144 |
+
# batch is 1002 rows where seq_len x micro_batch would predict 3072 --
|
| 145 |
+
# the sampler is token-budgeted -- and a budget computed from the config
|
| 146 |
+
# would silently be 3x the shape actually being measured.
|
| 147 |
+
n_rows = int(h.reshape(-1, h.shape[-1]).shape[0])
|
| 148 |
+
w = model.lm_head.weight
|
| 149 |
+
w_full = w.full_tensor() if hasattr(w, "full_tensor") else w
|
| 150 |
+
w_full = w_full.detach().clone().requires_grad_(True)
|
| 151 |
+
|
| 152 |
+
def _loss_only():
|
| 153 |
+
loss = fused_linear_cross_entropy(h, w_full, target, ignore_index=IGNORE_INDEX)
|
| 154 |
+
loss.backward()
|
| 155 |
+
return loss
|
| 156 |
+
|
| 157 |
+
report = assert_peak_memory_below_full_logits(
|
| 158 |
+
_loss_only,
|
| 159 |
+
n_rows,
|
| 160 |
+
cfg.model.vocab_size,
|
| 161 |
+
device=h.device,
|
| 162 |
+
# DTYPE-AWARE, and section-05's gate now refuses the alternative. The
|
| 163 |
+
# fp32 default (8 bytes/element) came to 1024 MiB, which EXCEEDED the
|
| 164 |
+
# entire 775.8 MiB measured peak -- so `attributable` clamped to zero
|
| 165 |
+
# and the gate passed no matter what the loss did. Under FSDP2
|
| 166 |
+
# mixed-precision the lm_head is bf16 with fp32 gradient accumulation:
|
| 167 |
+
# 6 bytes/element, 768 MiB. That leaves a real 7.8 MiB attributable
|
| 168 |
+
# against a 125.2 MiB budget -- a measurement instead of a rubber stamp.
|
| 169 |
+
# `param_dtype` is the lm_head's own dtype under FSDP2
|
| 170 |
+
# mixed-precision (bf16); `accum_dtype` is the fp32 the fused kernel
|
| 171 |
+
# accumulates d_weight in. Their sum -- 6 bytes/element, 768 MiB -- is
|
| 172 |
+
# what the module provably holds at the instant d_weight is cast to
|
| 173 |
+
# the parameter dtype, which is the peak.
|
| 174 |
+
unavoidable_bytes=lm_head_grad_allowance(
|
| 175 |
+
cfg.model.vocab_size,
|
| 176 |
+
cfg.model.hidden_size,
|
| 177 |
+
param_dtype=torch.bfloat16,
|
| 178 |
+
accum_dtype=torch.float32,
|
| 179 |
+
),
|
| 180 |
+
label="section-11 M5 7B smoke",
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
# DIFFERENTIAL gate: chunked vs unchunked at the SAME shape, so every
|
| 184 |
+
# confounder cancels and no allowance is needed. An implementation that
|
| 185 |
+
# materialised the full logit tensor would show ~0 saving. This is the
|
| 186 |
+
# check that cannot be made vacuous by a mis-specified allowance, which is
|
| 187 |
+
# the failure the previous version of this gate actually had.
|
| 188 |
+
def _make_fn(chunk_size: int):
|
| 189 |
+
def _run():
|
| 190 |
+
h.grad = None
|
| 191 |
+
w_full.grad = None
|
| 192 |
+
fused_linear_cross_entropy(
|
| 193 |
+
h, w_full, target, chunk_size=chunk_size, ignore_index=IGNORE_INDEX
|
| 194 |
+
).backward()
|
| 195 |
+
|
| 196 |
+
return _run
|
| 197 |
+
|
| 198 |
+
report["chunking_saves"] = assert_chunking_saves(
|
| 199 |
+
_make_fn,
|
| 200 |
+
n_rows=n_rows,
|
| 201 |
+
vocab=cfg.model.vocab_size,
|
| 202 |
+
chunk_rows=128,
|
| 203 |
+
device=h.device,
|
| 204 |
+
label="section-11 M5 7B smoke (differential)",
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
report["full_step_peak_bytes"] = full_step_peak
|
| 208 |
+
report["step_loss"] = loss_value
|
| 209 |
+
logger.info(
|
| 210 |
+
"7B smoke: loss=%.4f full-step peak=%.0fMiB loss-only peak=%.0fMiB",
|
| 211 |
+
loss_value,
|
| 212 |
+
full_step_peak / 2**20,
|
| 213 |
+
report.get("peak_bytes", 0) / 2**20,
|
| 214 |
+
)
|
| 215 |
+
return report
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def resolve_split_dir(shard_dir, split: str):
|
| 219 |
+
"""Find the directory holding `<split>-NNNN.receipt.json`, by DISCOVERY.
|
| 220 |
+
|
| 221 |
+
THE HAND-COMPOSED-PATH DEFECT (caught at launch). I wrote
|
| 222 |
+
`ShardDataset(cfg.data.shard_dir, names=[cfg.data.sft_split])`, expecting a
|
| 223 |
+
flat `data/shards/sft-train.receipt.json`. Section-03's real layout is
|
| 224 |
+
`data/shards/sft/train/sft-train-0000.receipt.json`. Resolved by searching
|
| 225 |
+
for the receipts the store itself wrote rather than rebuilding
|
| 226 |
+
`<root>/<group>/<split>` from a naming convention, so a layout change moves
|
| 227 |
+
this by itself and a missing split reports the splits that ARE present.
|
| 228 |
+
"""
|
| 229 |
+
from pathlib import Path
|
| 230 |
+
|
| 231 |
+
root = Path(shard_dir)
|
| 232 |
+
if not root.is_dir():
|
| 233 |
+
raise FileNotFoundError(
|
| 234 |
+
f"shard root {root} does not exist; the stage consumes shards "
|
| 235 |
+
"built by scripts/build_data.py and never builds them itself"
|
| 236 |
+
)
|
| 237 |
+
|
| 238 |
+
found: dict[str, "Path"] = {}
|
| 239 |
+
for receipt in root.rglob("*.receipt.json"):
|
| 240 |
+
prefix = receipt.name.split(".receipt.json")[0].rsplit("-", 1)[0]
|
| 241 |
+
found.setdefault(prefix, receipt.parent)
|
| 242 |
+
if split not in found:
|
| 243 |
+
raise FileNotFoundError(
|
| 244 |
+
f"no shards for split {split!r} under {root}. Splits present: "
|
| 245 |
+
f"{sorted(found)}. (The stage never composes this path from a "
|
| 246 |
+
"naming convention -- it looks for the receipts the store wrote.)"
|
| 247 |
+
)
|
| 248 |
+
return found[split]
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def open_split(cfg, split: Optional[str] = None):
|
| 252 |
+
"""The ONE way this stage opens shards. Both the smoke batches and the
|
| 253 |
+
training sampler go through here, so they cannot disagree about layout."""
|
| 254 |
+
from ..data.store import ShardDataset
|
| 255 |
+
|
| 256 |
+
name = split or cfg.data.sft_split
|
| 257 |
+
return ShardDataset(resolve_split_dir(cfg.data.shard_dir, name))
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def build_training_sampler(cfg, rank: int | None = None, world_size: int | None = None):
|
| 261 |
+
"""The sampler the Trainer will actually iterate -- THIS RANK's share.
|
| 262 |
+
|
| 263 |
+
Built HERE and reused by scripts/sft.py and scripts/pretrain.py so the
|
| 264 |
+
stage cannot time, project, and then train on three different batch
|
| 265 |
+
streams. The LR horizon is derived from THIS object's length, which is
|
| 266 |
+
therefore the per-rank micro-step count the Trainer really runs.
|
| 267 |
+
|
| 268 |
+
Under world_size > 1 the token-budgeted batch list is sharded by
|
| 269 |
+
`DistributedBatchSampler` (the same seam dpo_stage uses). Before this,
|
| 270 |
+
every rank iterated the full unsharded list: a 4-rank run averaged four
|
| 271 |
+
IDENTICAL gradients -- the throughput of one GPU at the cost of four --
|
| 272 |
+
and scheduled on a horizon 4x the per-rank work. Nothing failed; the loss
|
| 273 |
+
curve was that of a smaller-batch run.
|
| 274 |
+
|
| 275 |
+
`rank`/`world_size` default to the live environment and are explicit so
|
| 276 |
+
the inter-rank contract can be tested in one CPU process.
|
| 277 |
+
"""
|
| 278 |
+
from ..data.loader import DistributedBatchSampler, LengthGroupedSampler
|
| 279 |
+
from ..utils.dist import rank as env_rank
|
| 280 |
+
from ..utils.dist import world_size as env_world_size
|
| 281 |
+
|
| 282 |
+
dataset = open_split(cfg)
|
| 283 |
+
lengths = [len(dataset[i].input_ids) for i in range(len(dataset))]
|
| 284 |
+
base = LengthGroupedSampler(
|
| 285 |
+
lengths, max_tokens=cfg.train.seq_len * cfg.train.micro_batch_size, seed=cfg.train.seed
|
| 286 |
+
)
|
| 287 |
+
r = env_rank() if rank is None else int(rank)
|
| 288 |
+
ws = env_world_size() if world_size is None else int(world_size)
|
| 289 |
+
if ws <= 1:
|
| 290 |
+
return base, dataset
|
| 291 |
+
if not 0 <= r < ws:
|
| 292 |
+
raise ValueError(f"rank {r} is outside world_size {ws}")
|
| 293 |
+
return DistributedBatchSampler(base, rank=r, world_size=ws), dataset
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
def sampler_derived_horizon(cfg, sampler) -> int:
|
| 297 |
+
"""The horizon the TRAINER will schedule on: the same arithmetic
|
| 298 |
+
`Trainer.__init__` uses, `ceil(len(sampler) x epochs / accum)`.
|
| 299 |
+
|
| 300 |
+
This is the authoritative number. The rows/global_batch figure is a
|
| 301 |
+
projection over a ROW count, but the sampler batches by TOKEN budget, so
|
| 302 |
+
the two agree only by coincidence -- and the banner must record the
|
| 303 |
+
schedule that runs, not the one that was estimated.
|
| 304 |
+
"""
|
| 305 |
+
import math as _math
|
| 306 |
+
|
| 307 |
+
total_micro = len(sampler) * cfg.train.epochs
|
| 308 |
+
return max(1, _math.ceil(total_micro / cfg.optimizer.grad_accum))
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
def run_smoke_and_probe(cfg, smoke_steps: int = 10, probe_steps: int = 20) -> dict:
|
| 312 |
+
"""10-step 7B smoke (with the peak-memory assertion) then the 20-step
|
| 313 |
+
throughput probe. Returns everything the launch banner needs."""
|
| 314 |
+
from ..data.loader import assert_worker_count
|
| 315 |
+
from .offload_adamw import make_optimizer
|
| 316 |
+
from .probes import aggregate_micro_step_times, throughput_report
|
| 317 |
+
from .sft_loss import make_sft_loss_adapter
|
| 318 |
+
from .trainer import scheduler_horizon_optimizer_steps
|
| 319 |
+
|
| 320 |
+
assert_worker_count(cfg.data.num_workers)
|
| 321 |
+
model = build_stage_model(cfg)
|
| 322 |
+
optimizer = make_optimizer(cfg, [p for p in model.parameters() if p.requires_grad])
|
| 323 |
+
adapter = make_sft_loss_adapter(require_fused=True)
|
| 324 |
+
|
| 325 |
+
batches = _stage_batches(cfg, n=smoke_steps + probe_steps)
|
| 326 |
+
memory_report = assert_chunked_loss_memory(cfg, model, batches[0])
|
| 327 |
+
|
| 328 |
+
step_times: list[float] = []
|
| 329 |
+
for i, batch in enumerate(batches):
|
| 330 |
+
t0 = time.perf_counter()
|
| 331 |
+
out = adapter(model, batch)
|
| 332 |
+
(out.loss / cfg.optimizer.grad_accum).backward()
|
| 333 |
+
if (i + 1) % cfg.optimizer.grad_accum == 0:
|
| 334 |
+
optimizer.step()
|
| 335 |
+
optimizer.zero_grad()
|
| 336 |
+
torch.cuda.synchronize()
|
| 337 |
+
elapsed = time.perf_counter() - t0
|
| 338 |
+
if i >= smoke_steps: # the smoke steps are warmup
|
| 339 |
+
step_times.append(elapsed)
|
| 340 |
+
|
| 341 |
+
# AUTHORITATIVE: what the Trainer will actually schedule on.
|
| 342 |
+
sampler, _dataset = build_training_sampler(cfg)
|
| 343 |
+
planned = sampler_derived_horizon(cfg, sampler)
|
| 344 |
+
# SECONDARY, labeled: the rows/global_batch projection, kept beside it so
|
| 345 |
+
# a divergence is visible in the record rather than silently resolved.
|
| 346 |
+
rows_derived = scheduler_horizon_optimizer_steps(
|
| 347 |
+
rows=_sft_rows(cfg), global_batch=cfg.train.global_batch_rows, epochs=cfg.train.epochs
|
| 348 |
+
)
|
| 349 |
+
probe = throughput_report(
|
| 350 |
+
# micro -> optimizer-step durations (task #25): tokens_per_step and
|
| 351 |
+
# `planned` below are per-optimizer-step units.
|
| 352 |
+
step_times_s=aggregate_micro_step_times(step_times, cfg.optimizer.grad_accum),
|
| 353 |
+
tokens_per_step=cfg.train.seq_len
|
| 354 |
+
* cfg.train.micro_batch_size
|
| 355 |
+
* cfg.optimizer.grad_accum
|
| 356 |
+
* cfg.train.world_size,
|
| 357 |
+
optimizer_steps_planned=planned,
|
| 358 |
+
offload_transfer_s=None,
|
| 359 |
+
offload_cpu_step_s=None,
|
| 360 |
+
)
|
| 361 |
+
|
| 362 |
+
return {
|
| 363 |
+
"smoke": {"steps": smoke_steps, **memory_report},
|
| 364 |
+
"probe": probe,
|
| 365 |
+
"optimizer_steps_planned": planned,
|
| 366 |
+
"optimizer_steps_rows_derived": rows_derived,
|
| 367 |
+
"attention_backend": model.attn_implementation,
|
| 368 |
+
"optimizer_impl": getattr(optimizer, "banner_tag", cfg.optimizer.name),
|
| 369 |
+
"dataset_hashes": _manifest_hashes(cfg),
|
| 370 |
+
"ceiling_results": _ceiling_results(cfg),
|
| 371 |
+
"mde": _mde(cfg),
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
|
| 375 |
+
def run_stage_eval(cfg, checkpoint: str) -> dict:
|
| 376 |
+
"""Evaluate a checkpoint on MATH-500 + GSM8K under section-10's harness."""
|
| 377 |
+
from ..eval.harness import EvalConfig, load_eval_set, run_eval
|
| 378 |
+
from ..eval.verifier import OutcomeVerifier
|
| 379 |
+
|
| 380 |
+
verifier = OutcomeVerifier()
|
| 381 |
+
reports = {}
|
| 382 |
+
for name, path, limit in (
|
| 383 |
+
("math500", cfg.paths.math500_path, cfg.eval.math500_n),
|
| 384 |
+
("gsm8k", cfg.paths.gsm8k_path, cfg.eval.gsm8k_n),
|
| 385 |
+
):
|
| 386 |
+
problems = load_eval_set(path, limit=limit)
|
| 387 |
+
report = run_eval(
|
| 388 |
+
problems, _stage_generate_fn(cfg, checkpoint), verifier, EvalConfig(), model_name=name
|
| 389 |
+
)
|
| 390 |
+
# EvalReport has no `.results`; per-set scores live in `per_set`,
|
| 391 |
+
# keyed by each problem's source (one key for a single-set call).
|
| 392 |
+
# The old attribute error was latent -- this path never ran to
|
| 393 |
+
# completion before 2026-08-18.
|
| 394 |
+
reports[name] = report.per_set[name]
|
| 395 |
+
return {
|
| 396 |
+
"per_set": reports,
|
| 397 |
+
"config": {
|
| 398 |
+
"mde": _mde(cfg),
|
| 399 |
+
"n_problems": sum(r.get("n", 0) for r in reports.values()),
|
| 400 |
+
"eval_set_hash": _manifest_hashes(cfg),
|
| 401 |
+
},
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
|
| 405 |
+
# -- small helpers the stage script fills in from the build artifact ---------
|
| 406 |
+
|
| 407 |
+
|
| 408 |
+
def _stage_batches(cfg, n: int):
|
| 409 |
+
from ..data.loader import collate_sft
|
| 410 |
+
from ..utils.dist import local_rank
|
| 411 |
+
|
| 412 |
+
dataset = open_split(cfg)
|
| 413 |
+
mb = cfg.train.micro_batch_size
|
| 414 |
+
out = []
|
| 415 |
+
for i in range(n):
|
| 416 |
+
rows = [dataset[(i * mb + j) % len(dataset)] for j in range(mb)]
|
| 417 |
+
batch = collate_sft(rows, pad_token_id=cfg.data.pad_token_id)
|
| 418 |
+
# Explicit device: a bare .cuda() is device 0 for every rank unless
|
| 419 |
+
# set_device already ran, and relying on that ordering is what the
|
| 420 |
+
# duplicate-GPU guard exists to stop us doing.
|
| 421 |
+
dev = torch.device("cuda", local_rank())
|
| 422 |
+
out.append((batch.input_ids.to(dev), batch.labels.to(dev)))
|
| 423 |
+
return out
|
| 424 |
+
|
| 425 |
+
|
| 426 |
+
def _sft_rows(cfg) -> int:
|
| 427 |
+
"""Corpus size from the BUILD ARTIFACT, never a literal.
|
| 428 |
+
|
| 429 |
+
The plan says "~11.8K"; the build measured 12,410 (walk- and max_len-
|
| 430 |
+
dependent). The schedule horizon must follow the corpus that actually
|
| 431 |
+
exists, or the LR decays against a row count nobody trained on.
|
| 432 |
+
"""
|
| 433 |
+
import json
|
| 434 |
+
|
| 435 |
+
with open(cfg.data.manifest_path) as f:
|
| 436 |
+
counts = json.load(f).get("counts", {})
|
| 437 |
+
rows = counts.get("sft_rows")
|
| 438 |
+
if not rows:
|
| 439 |
+
raise RuntimeError(
|
| 440 |
+
f"{cfg.data.manifest_path} has no counts.sft_rows; the scheduler "
|
| 441 |
+
"horizon cannot be derived and must not be guessed."
|
| 442 |
+
)
|
| 443 |
+
return int(rows)
|
| 444 |
+
|
| 445 |
+
|
| 446 |
+
def _manifest_hashes(cfg) -> dict:
|
| 447 |
+
import json
|
| 448 |
+
|
| 449 |
+
with open(cfg.data.manifest_path) as f:
|
| 450 |
+
return json.load(f).get("hashes", {})
|
| 451 |
+
|
| 452 |
+
|
| 453 |
+
def _ceiling_results(cfg) -> dict:
|
| 454 |
+
"""The extraction ceiling, re-run against THIS stage's eval sets.
|
| 455 |
+
|
| 456 |
+
`preflight_extraction` returns NaN with a loud warning when no source
|
| 457 |
+
exists, and a NaN here is what the banner shows and the gate refuses --
|
| 458 |
+
which is the point: an outcome signal nobody ceiling-tested is how a
|
| 459 |
+
PERFECT policy scored 0/100 for two full PPO runs.
|
| 460 |
+
"""
|
| 461 |
+
from ..eval.roundtrip import preflight_extraction
|
| 462 |
+
|
| 463 |
+
return {
|
| 464 |
+
"extraction_roundtrip": preflight_extraction(
|
| 465 |
+
[cfg.paths.math500_path, cfg.paths.gsm8k_path], eos="</s>", label="section-11 SFT stage"
|
| 466 |
+
)
|
| 467 |
+
}
|
| 468 |
+
|
| 469 |
+
|
| 470 |
+
def _mde(cfg) -> dict:
|
| 471 |
+
from ..eval.metrics import min_detectable_effect
|
| 472 |
+
|
| 473 |
+
return {
|
| 474 |
+
"math500": min_detectable_effect(cfg.eval.math500_n),
|
| 475 |
+
"gsm8k": min_detectable_effect(cfg.eval.gsm8k_n),
|
| 476 |
+
}
|
| 477 |
+
|
| 478 |
+
|
| 479 |
+
def _stage_generate_fn(cfg, checkpoint: str):
|
| 480 |
+
"""Bind section-09's Engine to the harness's `generate_fn(prompts, cfg,
|
| 481 |
+
seeds)` contract, over the weights IN `checkpoint`.
|
| 482 |
+
|
| 483 |
+
THE BASE-MODEL-GATE DEFECT (G3 audit U01-4). This used to build the model
|
| 484 |
+
from `cfg.paths.model_snapshot` and use `checkpoint` only to find
|
| 485 |
+
tokenizer.json, so the post-epoch quality gate (and the receipt section-12
|
| 486 |
+
reads) scored the untouched base model; and its signature
|
| 487 |
+
`(prompts, seeds=None, **kw)` could not even accept the harness's three
|
| 488 |
+
positional arguments. Generation is the golden gate's greedy path
|
| 489 |
+
(`golden_generate_fn`), so the numbers are comparable to the anchors the
|
| 490 |
+
gate compares against.
|
| 491 |
+
|
| 492 |
+
PAIRED SEEDS. The Engine owns ONE sampling generator per instance, so
|
| 493 |
+
per-problem seeds cannot be threaded through it. Under greedy decoding
|
| 494 |
+
(the eval default) the seeds are irrelevant and dropping them is exact; a
|
| 495 |
+
SAMPLED eval would silently decorrelate the arms and invalidate every
|
| 496 |
+
McNemar comparison, so it is refused instead.
|
| 497 |
+
"""
|
| 498 |
+
import os
|
| 499 |
+
|
| 500 |
+
from ..eval.golden import golden_generate_fn
|
| 501 |
+
from ..model.checkpoint import resolve_snapshot_dir
|
| 502 |
+
from ..tokenizer import load_tokenizer
|
| 503 |
+
|
| 504 |
+
# Weights-only checkpoints (pre tokenizer-copy fix) have no tokenizer.json
|
| 505 |
+
# -- that crash cost stage-11's first post-epoch eval. Vocab never changes
|
| 506 |
+
# during full-FT, so the pinned snapshot's tokenizer IS the checkpoint's.
|
| 507 |
+
# (`load_tokenizer` reads tokenizer.json directly; there is no slow
|
| 508 |
+
# sentencepiece-conversion path left to fall into.)
|
| 509 |
+
tok_src = checkpoint
|
| 510 |
+
if not os.path.isfile(os.path.join(checkpoint, "tokenizer.json")):
|
| 511 |
+
tok_src = resolve_snapshot_dir(cfg.paths.model_snapshot)
|
| 512 |
+
logger.warning(
|
| 513 |
+
"checkpoint %s has no tokenizer.json; loading tokenizer from the pinned snapshot %s",
|
| 514 |
+
checkpoint,
|
| 515 |
+
tok_src,
|
| 516 |
+
)
|
| 517 |
+
tokenizer = load_tokenizer(tok_src)
|
| 518 |
+
model = build_stage_model(cfg, weights_dir=checkpoint)
|
| 519 |
+
greedy_fn = golden_generate_fn(model, tokenizer)
|
| 520 |
+
|
| 521 |
+
def generate_fn(prompts, eval_cfg, seeds):
|
| 522 |
+
if eval_cfg.temperature != 0.0:
|
| 523 |
+
raise ValueError(
|
| 524 |
+
f"stage eval is greedy-only: temperature={eval_cfg.temperature} needs the "
|
| 525 |
+
"paired per-problem seeds, which the Engine cannot thread (one generator "
|
| 526 |
+
"per engine); dropping them would invalidate every paired comparison"
|
| 527 |
+
)
|
| 528 |
+
return greedy_fn(prompts, eval_cfg, seeds)
|
| 529 |
+
|
| 530 |
+
return generate_fn
|
code/align/train/train_module.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The TrainModule: model + loss adapter + optimizer behind one seam.
|
| 2 |
+
|
| 3 |
+
This is the OLMo-core separation (olmo_core/train/train_module/train_module.py)
|
| 4 |
+
ported as a PATTERN, not as code: the Trainer owns the loop, the schedule, the
|
| 5 |
+
checkpoint orchestration, the instruments and history.jsonl; the TrainModule
|
| 6 |
+
owns everything that touches the model -- forward, backward, the gated
|
| 7 |
+
optimizer step, and the model/optimizer state movement that FSDP2 makes
|
| 8 |
+
non-trivial. The Trainer never calls `.backward()` or `.step()` itself and
|
| 9 |
+
never inspects a DTensor; the TrainModule never opens a file, never logs a
|
| 10 |
+
metric, and never decides WHEN anything happens.
|
| 11 |
+
|
| 12 |
+
Why the seam matters here and not just aesthetically: stage-b GRPO (M-C/M-D)
|
| 13 |
+
needs a second module -- same Trainer loop, same checkpoint orchestration,
|
| 14 |
+
same tripwires-and-history machinery, different train_batch (rollout batch,
|
| 15 |
+
vendored GRPO loss, rho weights). Under the old single-class Trainer that
|
| 16 |
+
would have meant a second trainer or a stage flag forest; under this seam it
|
| 17 |
+
is one new TrainModule subclass.
|
| 18 |
+
|
| 19 |
+
Two contracts are load-bearing and inherited verbatim from the old Trainer:
|
| 20 |
+
|
| 21 |
+
* `train_batch` performs EXACTLY `(out.loss / grad_accum).backward()`. The
|
| 22 |
+
DPO decoupled-backward adapter returns a detached leaf proxy sized so
|
| 23 |
+
that this call applies the trainer's accumulation contract to gradients
|
| 24 |
+
the adapter already planted -- any cleverness here (scaling elsewhere,
|
| 25 |
+
calling backward twice, retaining graphs) silently double-scales DPO
|
| 26 |
+
gradients.
|
| 27 |
+
* The FSDP2 state movement (full gather on save, CPU-slice per-tensor load
|
| 28 |
+
on resume) keeps its exact production-proven form; both carry incident
|
| 29 |
+
history in their docstrings.
|
| 30 |
+
"""
|
| 31 |
+
|
| 32 |
+
from __future__ import annotations
|
| 33 |
+
|
| 34 |
+
import logging
|
| 35 |
+
from dataclasses import dataclass, field
|
| 36 |
+
from typing import Any, Optional, Protocol
|
| 37 |
+
|
| 38 |
+
import torch
|
| 39 |
+
|
| 40 |
+
logger = logging.getLogger(__name__)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
@dataclass
|
| 44 |
+
class LossOutput:
|
| 45 |
+
"""What every loss adapter returns.
|
| 46 |
+
|
| 47 |
+
`metrics` carries stage-specific values for the log (DPO's margin,
|
| 48 |
+
logps/chosen, logps/rejected). Undefined entries must be NaN, never 0.0 --
|
| 49 |
+
a 0.0 margin reads as 'no preference learned', which is a finding, not a
|
| 50 |
+
missing measurement.
|
| 51 |
+
"""
|
| 52 |
+
|
| 53 |
+
loss: torch.Tensor
|
| 54 |
+
n_tokens: int
|
| 55 |
+
metrics: dict[str, float] = field(default_factory=dict)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class LossAdapter(Protocol):
|
| 59 |
+
"""The interface the TrainModule consumes. Section-05 owns the two real
|
| 60 |
+
implementations; neither the Trainer nor the TrainModule imports either.
|
| 61 |
+
|
| 62 |
+
SFT: batch -> chunked fused CE over hidden states, so the
|
| 63 |
+
[B*T, 32768] logit tensor never materialises.
|
| 64 |
+
DPO: batch (with precomputed reference logprobs as constants) ->
|
| 65 |
+
fused DPO loss, reporting margin and both logps in `metrics`.
|
| 66 |
+
"""
|
| 67 |
+
|
| 68 |
+
def __call__(self, model: Any, batch: Any) -> LossOutput: ...
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
LOSS_NORMALIZATIONS = ("micro_mean", "global_token_mean")
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def reduce_window_tokens(local_tokens: int) -> int:
|
| 75 |
+
"""SUM of `local_tokens` over every rank (gloo group; identity on one process).
|
| 76 |
+
|
| 77 |
+
The denominator of the global token mean: the number of target tokens in the whole accumulation
|
| 78 |
+
window of the WHOLE world. A collective -- every rank must call it at the same program point.
|
| 79 |
+
"""
|
| 80 |
+
import torch.distributed as dist
|
| 81 |
+
|
| 82 |
+
if not (dist.is_available() and dist.is_initialized()):
|
| 83 |
+
return int(local_tokens)
|
| 84 |
+
from ..utils.dist import gloo_group
|
| 85 |
+
|
| 86 |
+
g = gloo_group()
|
| 87 |
+
if g is None:
|
| 88 |
+
return int(local_tokens)
|
| 89 |
+
t = torch.tensor([int(local_tokens)], dtype=torch.int64)
|
| 90 |
+
dist.all_reduce(t, op=dist.ReduceOp.SUM, group=g)
|
| 91 |
+
return int(t.item())
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _dp_world_size() -> int:
|
| 95 |
+
import torch.distributed as dist
|
| 96 |
+
|
| 97 |
+
return dist.get_world_size() if (dist.is_available() and dist.is_initialized()) else 1
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
class TrainModule:
|
| 101 |
+
"""Owns the model, the loss adapter, and the optimizer.
|
| 102 |
+
|
| 103 |
+
The default module wraps the existing SFT/DPO path unchanged; stage-b
|
| 104 |
+
GRPO subclasses `train_batch`.
|
| 105 |
+
"""
|
| 106 |
+
|
| 107 |
+
def __init__(self, model, loss_adapter: LossAdapter, optimizer, grad_accum: int):
|
| 108 |
+
if grad_accum < 1:
|
| 109 |
+
raise ValueError(f"grad_accum must be >= 1, got {grad_accum}")
|
| 110 |
+
self.model = model
|
| 111 |
+
self.loss_adapter = loss_adapter
|
| 112 |
+
self.optimizer = optimizer
|
| 113 |
+
self.grad_accum = int(grad_accum)
|
| 114 |
+
|
| 115 |
+
# -- the two loop-facing calls -------------------------------------------
|
| 116 |
+
|
| 117 |
+
def train_batch(self, batch, window_tokens: Optional[int] = None) -> LossOutput:
|
| 118 |
+
"""Forward + backward for one micro-batch.
|
| 119 |
+
|
| 120 |
+
Scales by accumulation so the gradient matches one large batch rather
|
| 121 |
+
than the sum of `grad_accum` batches. This exact call is the contract
|
| 122 |
+
the DPO decoupled-backward proxy honors -- see the module docstring.
|
| 123 |
+
|
| 124 |
+
`window_tokens` (None = `loss_normalization="micro_mean"`, the original
|
| 125 |
+
behaviour, byte-for-byte the line above) switches to the GLOBAL TOKEN
|
| 126 |
+
MEAN (audit U03-2): the adapter's micro-batch mean is turned back into
|
| 127 |
+
the micro-batch's summed CE (`loss * n_tokens`) and divided by the
|
| 128 |
+
target-token count of the whole window across all ranks. FSDP2/DDP then
|
| 129 |
+
AVERAGE the per-rank gradients over the world, so the local term is
|
| 130 |
+
multiplied by the world size to make that average a SUM:
|
| 131 |
+
grad = (1/N) * sum over every rank and micro-batch of grad(sum CE).
|
| 132 |
+
"""
|
| 133 |
+
out = self.loss_adapter(self.model, batch)
|
| 134 |
+
if window_tokens is None:
|
| 135 |
+
(out.loss / self.grad_accum).backward()
|
| 136 |
+
else:
|
| 137 |
+
if window_tokens < 1:
|
| 138 |
+
raise ValueError(f"window_tokens must be >= 1, got {window_tokens}")
|
| 139 |
+
scale = _dp_world_size() * float(out.n_tokens) / float(window_tokens)
|
| 140 |
+
(out.loss * scale).backward()
|
| 141 |
+
return out
|
| 142 |
+
|
| 143 |
+
def optim_step(self) -> bool:
|
| 144 |
+
"""Gated optimizer step + zero_grad; True iff the step was applied.
|
| 145 |
+
|
| 146 |
+
The gate (non-finite grads skip the step) lives in the optimizer
|
| 147 |
+
wrapper; this module only reports what happened so the Trainer can
|
| 148 |
+
keep its rule that the schedule counts steps that ACTUALLY happened.
|
| 149 |
+
"""
|
| 150 |
+
stepped = self.optimizer.step()
|
| 151 |
+
self.optimizer.zero_grad()
|
| 152 |
+
return stepped
|
| 153 |
+
|
| 154 |
+
def zero_grads(self) -> None:
|
| 155 |
+
self.optimizer.zero_grad()
|
| 156 |
+
|
| 157 |
+
def set_train(self) -> None:
|
| 158 |
+
self.model.train()
|
| 159 |
+
|
| 160 |
+
# -- optimizer telemetry the Trainer logs --------------------------------
|
| 161 |
+
|
| 162 |
+
@property
|
| 163 |
+
def last_grad_norm(self) -> float:
|
| 164 |
+
return self.optimizer.last_grad_norm
|
| 165 |
+
|
| 166 |
+
@property
|
| 167 |
+
def skipped_steps(self) -> int:
|
| 168 |
+
return getattr(self.optimizer, "skipped_steps", 0)
|
| 169 |
+
|
| 170 |
+
# -- state movement -------------------------------------------------------
|
| 171 |
+
|
| 172 |
+
def full_model_state_dict(self):
|
| 173 |
+
"""An UNSHARDED, CPU state dict that safetensors can actually write.
|
| 174 |
+
|
| 175 |
+
THE DTENSOR CHECKPOINT DEFECT (found before it could fire, one gate
|
| 176 |
+
after the ledger race). Under FSDP2 `model.state_dict()` returns
|
| 177 |
+
DTensors -- global shape, local shard -- and safetensors dies on them
|
| 178 |
+
with "Attempted to access the data pointer on an invalid python
|
| 179 |
+
storage". Measured on the tiny model at 4 ranks: a [64,64] weight
|
| 180 |
+
comes back as a DTensor holding a [16,64] shard. Nothing in the CPU
|
| 181 |
+
suite can see this; a single process has no sharding.
|
| 182 |
+
|
| 183 |
+
`get_model_state_dict(full_state_dict=True, cpu_offload=True)` is the
|
| 184 |
+
canonical gather. It costs a transient full copy in host RAM per rank,
|
| 185 |
+
which is why the write is rank-0-only rather than the gather being
|
| 186 |
+
skipped.
|
| 187 |
+
"""
|
| 188 |
+
import torch.distributed as dist
|
| 189 |
+
|
| 190 |
+
if not (dist.is_available() and dist.is_initialized()):
|
| 191 |
+
return self.model.state_dict()
|
| 192 |
+
from torch.distributed.checkpoint.state_dict import StateDictOptions, get_model_state_dict
|
| 193 |
+
|
| 194 |
+
return get_model_state_dict(
|
| 195 |
+
self.model, options=StateDictOptions(full_state_dict=True, cpu_offload=True)
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
def load_model_state_dict(self, nmo_state: dict) -> None:
|
| 199 |
+
"""Load full CPU tensors into a possibly-FSDP2-sharded model.
|
| 200 |
+
|
| 201 |
+
FSDP2 params are DTensors: a plain load_state_dict copies full
|
| 202 |
+
tensors into shards and throws "mixed torch.Tensor and DTensor"
|
| 203 |
+
-- first exercised IN PRODUCTION at the stage-11 tail resume,
|
| 204 |
+
since every earlier launch resumed from nothing. And
|
| 205 |
+
set_model_state_dict(full_state_dict=True) is the wrong mirror
|
| 206 |
+
here, and so is per-tensor distribute_tensor over full tensors:
|
| 207 |
+
both put each WHOLE tensor on GPU before sharding, and the
|
| 208 |
+
varying-shape transients churn reserved memory toward
|
| 209 |
+
full-model size per rank (measured: rank 0 at 14GB against a
|
| 210 |
+
3.4GB shard; the 4-rank resume OOM'd on a card also carrying
|
| 211 |
+
peers' NCCL IPC mappings). Every rank already read the FULL
|
| 212 |
+
state from disk, so: slice on CPU, ship ONLY the local shard.
|
| 213 |
+
"""
|
| 214 |
+
if any(hasattr(p, "to_local") for p in self.model.parameters()):
|
| 215 |
+
from torch.distributed.tensor import Shard, distribute_tensor
|
| 216 |
+
|
| 217 |
+
params = dict(self.model.named_parameters())
|
| 218 |
+
missing = sorted(set(params) - set(nmo_state))
|
| 219 |
+
unexpected = sorted(set(nmo_state) - set(params))
|
| 220 |
+
if missing or unexpected:
|
| 221 |
+
raise KeyError(
|
| 222 |
+
f"resume state mismatch: missing={missing[:5]} unexpected={unexpected[:5]}"
|
| 223 |
+
)
|
| 224 |
+
with torch.no_grad():
|
| 225 |
+
for pname, p in params.items():
|
| 226 |
+
full = nmo_state[pname].to(p.dtype)
|
| 227 |
+
local = p.to_local()
|
| 228 |
+
if tuple(p.placements) == (Shard(0),):
|
| 229 |
+
ws = p.device_mesh.size()
|
| 230 |
+
rk = p.device_mesh.get_local_rank()
|
| 231 |
+
chunk = full.chunk(ws, dim=0)[rk]
|
| 232 |
+
if chunk.shape != local.shape:
|
| 233 |
+
raise RuntimeError(
|
| 234 |
+
f"{pname}: local shard {tuple(local.shape)} != "
|
| 235 |
+
f"chunk {tuple(chunk.shape)} (uneven sharding?)"
|
| 236 |
+
)
|
| 237 |
+
local.copy_(chunk.to(local.device))
|
| 238 |
+
else:
|
| 239 |
+
p.copy_(
|
| 240 |
+
distribute_tensor(full, p.device_mesh, p.placements, src_data_rank=None)
|
| 241 |
+
)
|
| 242 |
+
else:
|
| 243 |
+
self.model.load_state_dict(nmo_state, strict=True)
|
| 244 |
+
|
| 245 |
+
def optim_state_dict(self) -> dict:
|
| 246 |
+
return self.optimizer.state_dict()
|
| 247 |
+
|
| 248 |
+
def load_optim_state_dict(self, sd: dict) -> None:
|
| 249 |
+
self.optimizer.load_state_dict(sd)
|
| 250 |
+
|
| 251 |
+
def reseed_optimizer_from_params(self) -> bool:
|
| 252 |
+
"""After a resume that restored weights but NO optimizer state: let an optimizer that owns an
|
| 253 |
+
fp32 master copy (OffloadAdamW) re-seed it from the restored params. True iff it did."""
|
| 254 |
+
fn = getattr(self.optimizer, "reseed_masters_from_params", None)
|
| 255 |
+
if fn is None:
|
| 256 |
+
return False
|
| 257 |
+
fn()
|
| 258 |
+
return True
|
| 259 |
+
|
| 260 |
+
# -- module-owned state that must survive a kill --------------------------
|
| 261 |
+
|
| 262 |
+
def extra_state(self) -> dict:
|
| 263 |
+
"""Module-private resume state, saved under `resume.pt["extra_state"]`.
|
| 264 |
+
|
| 265 |
+
The Trainer owns weights, optimizer, schedule and stream position; it
|
| 266 |
+
cannot know about state a SUBCLASS accumulates across steps (the GRPO
|
| 267 |
+
collapse tripwire's baseline window and latch). That state used to die
|
| 268 |
+
with the process: a resume re-measured the baseline on the post-resume
|
| 269 |
+
policy, so a collapse that began before the kill was invisible for
|
| 270 |
+
~min_steps_before_fire steps. Subclasses override this pair; the default
|
| 271 |
+
is an empty dict so the key always exists and `load_extra_state({})`
|
| 272 |
+
is a no-op, never a KeyError on an older checkpoint's neighbour.
|
| 273 |
+
|
| 274 |
+
Contract: only tensors, dicts, lists and primitives. `resume.pt` is
|
| 275 |
+
read back with `weights_only=True`, so anything else would make the
|
| 276 |
+
checkpoint unloadable -- fail at SAVE time, not at the resume you
|
| 277 |
+
needed it for.
|
| 278 |
+
"""
|
| 279 |
+
return {}
|
| 280 |
+
|
| 281 |
+
def load_extra_state(self, sd: dict) -> None:
|
| 282 |
+
"""Inverse of `extra_state`. Called BEFORE the scheduler/stream restore."""
|
| 283 |
+
return None
|
code/align/train/trainer.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
code/align/utils/__init__.py
ADDED
|
File without changes
|
code/align/utils/dist.py
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Thin distributed helpers -- env-var based so they work before
|
| 2 |
+
init_process_group, plus the seeded-determinism policy and the
|
| 3 |
+
wait-condition helper.
|
| 4 |
+
|
| 5 |
+
No framework: torchrun sets RANK/WORLD_SIZE/LOCAL_RANK; we read them.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import os
|
| 11 |
+
import random
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def rank() -> int:
|
| 15 |
+
return int(os.environ.get("RANK", "0"))
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def local_rank() -> int:
|
| 19 |
+
return int(os.environ.get("LOCAL_RANK", "0"))
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def world_size() -> int:
|
| 23 |
+
return int(os.environ.get("WORLD_SIZE", "1"))
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def is_rank0() -> bool:
|
| 27 |
+
return rank() == 0
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
#: Default collective timeout. torch's own default is 10 min for NCCL (30 min
|
| 31 |
+
#: for gloo), which is shorter than a 14.5GB checkpoint write on this box
|
| 32 |
+
#: while peers wait in the post-save barrier; 30 min is long enough for that
|
| 33 |
+
#: and short enough that a genuinely wedged rank is reported in the same
|
| 34 |
+
#: sitting rather than after the 5-hour stall of the 2026-08-12 incident.
|
| 35 |
+
DEFAULT_PG_TIMEOUT_S = 1800.0
|
| 36 |
+
|
| 37 |
+
PG_TIMEOUT_ENV = "ALIGN_PG_TIMEOUT_S"
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def pg_timeout_s(environ=None) -> float:
|
| 41 |
+
"""The process-group timeout in seconds: `ALIGN_PG_TIMEOUT_S` or 30 min.
|
| 42 |
+
|
| 43 |
+
Parsed strictly. A typo ("30m") that silently fell back to the default
|
| 44 |
+
would leave an operator believing a 60-second watchdog was armed; a
|
| 45 |
+
non-positive value would make every collective time out instantly (or,
|
| 46 |
+
in torch, mean "no timeout"). Both raise instead.
|
| 47 |
+
"""
|
| 48 |
+
environ = os.environ if environ is None else environ
|
| 49 |
+
raw = environ.get(PG_TIMEOUT_ENV)
|
| 50 |
+
if raw is None or raw.strip() == "":
|
| 51 |
+
return DEFAULT_PG_TIMEOUT_S
|
| 52 |
+
try:
|
| 53 |
+
val = float(raw)
|
| 54 |
+
except ValueError:
|
| 55 |
+
raise ValueError(f"{PG_TIMEOUT_ENV}={raw!r} is not a number of seconds") from None
|
| 56 |
+
if not (val > 0.0) or val == float("inf"):
|
| 57 |
+
raise ValueError(f"{PG_TIMEOUT_ENV}={raw!r} must be a finite number of seconds > 0")
|
| 58 |
+
return val
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
#: The timeout `init_distributed` armed the default process group with. The
|
| 62 |
+
#: gloo CONTROL group (`gloo_group`) must use the same number -- see there.
|
| 63 |
+
_armed_timeout_s: float | None = None
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
#: The flight-recorder buffer-size variable was renamed TORCH_NCCL_TRACE_BUFFER_SIZE ->
|
| 67 |
+
#: TORCH_FR_BUFFER_SIZE when the recorder moved out of ProcessGroupNCCL (torch 2.11:
|
| 68 |
+
#: FlightRecorder.hpp:111 looks up {"TORCH_FR_BUFFER_SIZE", "TORCH_NCCL_TRACE_BUFFER_SIZE"}
|
| 69 |
+
#: in that order). Both are read by 2.11; only the new one is the supported name.
|
| 70 |
+
_FR_BUFFER_NEW = "TORCH_FR_BUFFER_SIZE"
|
| 71 |
+
_FR_BUFFER_LEGACY = "TORCH_NCCL_TRACE_BUFFER_SIZE"
|
| 72 |
+
_FR_RENAME_VERSION = (2, 11)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _torch_version_tuple(version: str | None = None) -> tuple[int, int]:
|
| 76 |
+
"""(major, minor) of the installed torch, or of an explicit version string."""
|
| 77 |
+
import re
|
| 78 |
+
|
| 79 |
+
if version is None:
|
| 80 |
+
import torch
|
| 81 |
+
|
| 82 |
+
version = torch.__version__
|
| 83 |
+
m = re.match(r"^(\d+)\.(\d+)", str(version))
|
| 84 |
+
if not m:
|
| 85 |
+
raise ValueError(f"cannot parse a torch version from {version!r}")
|
| 86 |
+
return int(m.group(1)), int(m.group(2))
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def configure_nccl_diagnostics(
|
| 90 |
+
run_dir: str | None = None, environ=None, torch_version: str | None = None
|
| 91 |
+
) -> dict:
|
| 92 |
+
"""Turn the PyTorch NCCL flight recorder on, with its dump under the run dir.
|
| 93 |
+
|
| 94 |
+
Must run BEFORE `init_process_group`: the recorder and the watchdog read
|
| 95 |
+
these variables when the NCCL process group is constructed. Defaults only
|
| 96 |
+
(`setdefault`): an operator's explicit export always wins. Returns what it
|
| 97 |
+
set, for the launch banner.
|
| 98 |
+
|
| 99 |
+
Variable names verified against the installed torch 2.11.0 (headers under
|
| 100 |
+
torch/include/torch/csrc/distributed/c10d/ and the strings in
|
| 101 |
+
libtorch_cpu.so / libtorch_cuda.so):
|
| 102 |
+
|
| 103 |
+
* TORCH_FR_BUFFER_SIZE (torch >= 2.11, FlightRecorder.hpp:111) or, below 2.11,
|
| 104 |
+
TORCH_NCCL_TRACE_BUFFER_SIZE (ProcessGroupNCCL.hpp:114; still read as the
|
| 105 |
+
fallback alias on 2.11). Ring-buffer entries;
|
| 106 |
+
the 2.11 default is 2000, which one FSDP2 step with 32 layers can wrap, so we
|
| 107 |
+
ask for 20000. >0 enables the recorder.
|
| 108 |
+
* TORCH_NCCL_DUMP_ON_TIMEOUT (ProcessGroupNCCL.hpp:73). Dump the buffer when
|
| 109 |
+
the watchdog times out. Per the header it must be set together with
|
| 110 |
+
TORCH_NCCL_ENABLE_MONITORING=1 and a buffer size > 0; we do NOT set the
|
| 111 |
+
monitoring variable (its abort-on-stuck-heartbeat side effect is a separate
|
| 112 |
+
decision) and have not re-verified its 2.11 default, so on a build where it
|
| 113 |
+
is off the dump is simply not triggered.
|
| 114 |
+
* TORCH_FR_DUMP_TEMP_FILE (new name) / TORCH_NCCL_DEBUG_INFO_TEMP_FILE (legacy
|
| 115 |
+
name; both strings present in libtorch_cpu.so, neither in a header because the
|
| 116 |
+
dump writer is compiled). The value is a FILE PREFIX; torch appends the
|
| 117 |
+
global rank. Without it the dump goes to /tmp/nccl_trace_rank_<r>, which this
|
| 118 |
+
box forbids (/tmp is noexec and not durable). We set both names so either
|
| 119 |
+
generation of the reader finds it.
|
| 120 |
+
|
| 121 |
+
`run_dir=None` falls back to `<cwd>/nccl_trace` rather than the /tmp default.
|
| 122 |
+
|
| 123 |
+
THE DEPRECATED-NAME DEFECT (H2 review, 2026-10-13): on torch >= 2.11 the buffer
|
| 124 |
+
size is `TORCH_FR_BUFFER_SIZE`; the old `TORCH_NCCL_TRACE_BUFFER_SIZE` is only a
|
| 125 |
+
fallback alias, so exporting it alone arms the recorder through a name the
|
| 126 |
+
next release is free to drop -- and the recorder going dark is invisible until
|
| 127 |
+
the hang it was meant to explain. The legacy name is set ONLY below 2.11, where
|
| 128 |
+
it is the only one the build knows. An operator's explicit export of EITHER name
|
| 129 |
+
wins: the new name outranks the old in torch's lookup, so setting it over an
|
| 130 |
+
operator's legacy export would silently override their value.
|
| 131 |
+
"""
|
| 132 |
+
environ = os.environ if environ is None else environ
|
| 133 |
+
new_torch = _torch_version_tuple(torch_version) >= _FR_RENAME_VERSION
|
| 134 |
+
buffer_name = _FR_BUFFER_NEW if new_torch else _FR_BUFFER_LEGACY
|
| 135 |
+
operator_set_buffer = _FR_BUFFER_NEW in environ or _FR_BUFFER_LEGACY in environ
|
| 136 |
+
trace_dir = os.path.join(run_dir or os.getcwd(), "nccl_trace")
|
| 137 |
+
os.makedirs(trace_dir, exist_ok=True)
|
| 138 |
+
prefix = os.path.join(trace_dir, "rank_")
|
| 139 |
+
wanted = {
|
| 140 |
+
buffer_name: "20000",
|
| 141 |
+
"TORCH_NCCL_DUMP_ON_TIMEOUT": "1",
|
| 142 |
+
"TORCH_FR_DUMP_TEMP_FILE": prefix,
|
| 143 |
+
"TORCH_NCCL_DEBUG_INFO_TEMP_FILE": prefix,
|
| 144 |
+
}
|
| 145 |
+
applied = {}
|
| 146 |
+
for k, v in wanted.items():
|
| 147 |
+
if k == buffer_name and operator_set_buffer:
|
| 148 |
+
continue
|
| 149 |
+
if k not in environ:
|
| 150 |
+
environ[k] = v
|
| 151 |
+
applied[k] = v
|
| 152 |
+
return applied
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def init_distributed(
|
| 156 |
+
backend: str = "nccl", run_dir: str | None = None, timeout_s: float | None = None
|
| 157 |
+
) -> None:
|
| 158 |
+
"""Idempotent process-group init for torchrun launches; no-op on a
|
| 159 |
+
single process.
|
| 160 |
+
|
| 161 |
+
Always passes an EXPLICIT timeout (`ALIGN_PG_TIMEOUT_S`, default 30 min)
|
| 162 |
+
instead of inheriting torch's backend-dependent default, and, for NCCL,
|
| 163 |
+
arms the flight recorder first (see `configure_nccl_diagnostics`) so a hang
|
| 164 |
+
leaves a per-rank trace of the last collectives instead of only a
|
| 165 |
+
watchdog SIGABRT.
|
| 166 |
+
"""
|
| 167 |
+
if world_size() == 1:
|
| 168 |
+
return
|
| 169 |
+
import datetime
|
| 170 |
+
|
| 171 |
+
import torch.distributed as dist
|
| 172 |
+
|
| 173 |
+
if not dist.is_initialized():
|
| 174 |
+
if backend == "nccl":
|
| 175 |
+
configure_nccl_diagnostics(run_dir)
|
| 176 |
+
global _armed_timeout_s
|
| 177 |
+
t = pg_timeout_s() if timeout_s is None else float(timeout_s)
|
| 178 |
+
_armed_timeout_s = t
|
| 179 |
+
dist.init_process_group(backend=backend, timeout=datetime.timedelta(seconds=t))
|
| 180 |
+
import torch
|
| 181 |
+
|
| 182 |
+
# Only NCCL needs a device bound per rank; a gloo group on a box whose
|
| 183 |
+
# GPUs are hidden (the CPU test children) must not touch CUDA.
|
| 184 |
+
if backend == "nccl" and torch.cuda.is_available():
|
| 185 |
+
torch.cuda.set_device(local_rank())
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def barrier() -> None:
|
| 189 |
+
"""No-op-safe barrier: single process or uninitialized group passes."""
|
| 190 |
+
if world_size() == 1:
|
| 191 |
+
return
|
| 192 |
+
import torch.distributed as dist
|
| 193 |
+
|
| 194 |
+
if dist.is_initialized():
|
| 195 |
+
dist.barrier()
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def seed_everything(seed: int, rank_offset: bool = True) -> int:
|
| 199 |
+
"""The seeded-determinism policy.
|
| 200 |
+
|
| 201 |
+
`rank_offset=True` gives each rank a distinct-but-derived stream
|
| 202 |
+
(seed + rank) -- required for dataloader shuffling; checkpoint-resume
|
| 203 |
+
tests (section-07) and crosscoder streaming (section-08) rely on this
|
| 204 |
+
exact derivation to reproduce batch order after a restart.
|
| 205 |
+
"""
|
| 206 |
+
s = seed + (rank() if rank_offset else 0)
|
| 207 |
+
random.seed(s)
|
| 208 |
+
try:
|
| 209 |
+
import numpy as np
|
| 210 |
+
|
| 211 |
+
np.random.seed(s % 2**32)
|
| 212 |
+
except ImportError:
|
| 213 |
+
pass
|
| 214 |
+
import sys
|
| 215 |
+
|
| 216 |
+
if "torch" in sys.modules: # never import torch just to seed it
|
| 217 |
+
import torch
|
| 218 |
+
|
| 219 |
+
torch.manual_seed(s)
|
| 220 |
+
return s
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def find_pids(interpreter_substr: str, script_path: str, exclude_self: bool = True) -> list[int]:
|
| 224 |
+
"""Find processes by INTERPRETER + FULL SCRIPT PATH, never bare name.
|
| 225 |
+
|
| 226 |
+
Incident: a stage-4 launcher waited on `pgrep -f <bare name>`, matched
|
| 227 |
+
its own command line, and idled 4 GPUs for 3 hours. Matching both the
|
| 228 |
+
interpreter and the absolute script path (and excluding ourselves and
|
| 229 |
+
our parent shell) cannot self-match.
|
| 230 |
+
"""
|
| 231 |
+
me = os.getpid()
|
| 232 |
+
parent = os.getppid()
|
| 233 |
+
hits = []
|
| 234 |
+
for pid_dir in os.listdir("/proc"):
|
| 235 |
+
if not pid_dir.isdigit():
|
| 236 |
+
continue
|
| 237 |
+
pid = int(pid_dir)
|
| 238 |
+
if exclude_self and pid in (me, parent):
|
| 239 |
+
continue
|
| 240 |
+
try:
|
| 241 |
+
with open(f"/proc/{pid}/cmdline", "rb") as f:
|
| 242 |
+
argv = f.read().decode(errors="replace").split("\0")
|
| 243 |
+
except OSError:
|
| 244 |
+
continue
|
| 245 |
+
if len(argv) < 2:
|
| 246 |
+
continue
|
| 247 |
+
if interpreter_substr in os.path.basename(argv[0]) and any(a == script_path for a in argv):
|
| 248 |
+
hits.append(pid)
|
| 249 |
+
return hits
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
def gloo_group():
|
| 253 |
+
"""Cached CPU-side subgroup for object collectives and control-plane
|
| 254 |
+
gathers. None on a single process. gloo, never NCCL: object collectives
|
| 255 |
+
stage through pickled CPU tensors, and NCCL binds them to GPU state that
|
| 256 |
+
the control plane must not depend on.
|
| 257 |
+
|
| 258 |
+
THE UNARMED-WATCHDOG DEFECT. `dist.new_group(backend="gloo")` with no
|
| 259 |
+
`timeout` does NOT inherit the default group's timeout: torch fills in its
|
| 260 |
+
own per-backend default (`_get_default_timeout`, 30 min for gloo) whatever
|
| 261 |
+
`init_process_group` was given. Every stop / HALT / skip-cap decision and
|
| 262 |
+
every checkpoint gather runs on THIS group, so an operator who exported
|
| 263 |
+
`ALIGN_PG_TIMEOUT_S=120` to be told about a wedged rank within two minutes
|
| 264 |
+
still waited 30 on the optimizer-step boundary all-reduce -- the exact
|
| 265 |
+
shape of the 2026-08-12 stall, behind a knob that read as armed. The group
|
| 266 |
+
is therefore built with the timeout `init_distributed` armed the default
|
| 267 |
+
group with (or, when a caller initialised the group itself, the env value).
|
| 268 |
+
"""
|
| 269 |
+
import datetime
|
| 270 |
+
|
| 271 |
+
import torch.distributed as dist
|
| 272 |
+
|
| 273 |
+
if world_size() == 1 or not dist.is_initialized():
|
| 274 |
+
return None
|
| 275 |
+
g = getattr(gloo_group, "_group", None)
|
| 276 |
+
if g is None:
|
| 277 |
+
t = _armed_timeout_s if _armed_timeout_s is not None else pg_timeout_s()
|
| 278 |
+
g = dist.new_group(backend="gloo", timeout=datetime.timedelta(seconds=t))
|
| 279 |
+
gloo_group._group = g
|
| 280 |
+
return g
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
def all_reduce_max(values: list[int]) -> list[int]:
|
| 284 |
+
"""Element-wise MAX of a short int vector across ranks, on the gloo group.
|
| 285 |
+
|
| 286 |
+
The control-plane primitive: "did ANY rank ...?" decisions (stop signal,
|
| 287 |
+
wall-clock, HALT, bad step) are made by reducing a 0/1 flag with MAX so that
|
| 288 |
+
every rank acts on the same answer at the same program point. gloo, not
|
| 289 |
+
NCCL, for the same reason as `gloo_group` -- and so a decision never
|
| 290 |
+
queues behind (or deadlocks with) in-flight CUDA collectives. A single
|
| 291 |
+
process returns the input unchanged.
|
| 292 |
+
"""
|
| 293 |
+
import torch
|
| 294 |
+
import torch.distributed as dist
|
| 295 |
+
|
| 296 |
+
g = gloo_group()
|
| 297 |
+
if g is None:
|
| 298 |
+
return [int(v) for v in values]
|
| 299 |
+
t = torch.tensor([int(v) for v in values], dtype=torch.int64)
|
| 300 |
+
dist.all_reduce(t, op=dist.ReduceOp.MAX, group=g)
|
| 301 |
+
return [int(v) for v in t.tolist()]
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
def any_rank(flag: bool) -> bool:
|
| 305 |
+
"""True iff `flag` is True on at least one rank (identical on all ranks)."""
|
| 306 |
+
return bool(all_reduce_max([1 if flag else 0])[0])
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
def all_gather_objects(obj) -> list:
|
| 310 |
+
"""`obj` from every rank, rank-ordered, on the gloo group ([obj] on one process)."""
|
| 311 |
+
import torch.distributed as dist
|
| 312 |
+
|
| 313 |
+
g = gloo_group()
|
| 314 |
+
if g is None:
|
| 315 |
+
return [obj]
|
| 316 |
+
out: list = [None] * dist.get_world_size()
|
| 317 |
+
dist.all_gather_object(out, obj, group=g)
|
| 318 |
+
return out
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
def capture_rng_state() -> dict:
|
| 322 |
+
"""This process's RNG state: torch CPU, torch CUDA (current device only),
|
| 323 |
+
python `random`, numpy. Every leaf is a tensor, list, int, float, str or
|
| 324 |
+
None, because `resume.pt` is read back with `weights_only=True`.
|
| 325 |
+
|
| 326 |
+
WHY. `seed_everything` seeds the streams at launch, but a resumed process
|
| 327 |
+
re-seeds from scratch and so replays the first draws of the original run:
|
| 328 |
+
dropout masks, sampling noise and any shuffle that is not a pure function
|
| 329 |
+
of (seed, epoch) restart instead of continuing. The run is then not the run
|
| 330 |
+
its uninterrupted twin would have been. State is per rank (the streams are
|
| 331 |
+
seeded seed + rank).
|
| 332 |
+
|
| 333 |
+
CUDA: only the CURRENT device, and only if CUDA is already initialised.
|
| 334 |
+
`torch.cuda.get_rng_state_all()` would create a context on every visible
|
| 335 |
+
GPU from every rank -- under torchrun that is world_size x n_gpus contexts
|
| 336 |
+
on a box with 23GB cards.
|
| 337 |
+
"""
|
| 338 |
+
import torch
|
| 339 |
+
|
| 340 |
+
py = random.getstate()
|
| 341 |
+
out: dict = {
|
| 342 |
+
"torch": torch.get_rng_state(),
|
| 343 |
+
"python": [py[0], list(py[1]), py[2]],
|
| 344 |
+
}
|
| 345 |
+
try:
|
| 346 |
+
import numpy as np
|
| 347 |
+
|
| 348 |
+
ns = np.random.get_state()
|
| 349 |
+
out["numpy"] = {
|
| 350 |
+
"name": str(ns[0]),
|
| 351 |
+
"keys": [int(k) for k in ns[1]],
|
| 352 |
+
"pos": int(ns[2]),
|
| 353 |
+
"has_gauss": int(ns[3]),
|
| 354 |
+
"cached_gaussian": float(ns[4]),
|
| 355 |
+
}
|
| 356 |
+
except ImportError: # pragma: no cover -- numpy is a hard dependency
|
| 357 |
+
pass
|
| 358 |
+
if torch.cuda.is_available() and torch.cuda.is_initialized():
|
| 359 |
+
dev = torch.cuda.current_device()
|
| 360 |
+
out["cuda"] = {str(dev): torch.cuda.get_rng_state(dev)}
|
| 361 |
+
return out
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
def restore_rng_state(sd: dict) -> None:
|
| 365 |
+
"""Inverse of `capture_rng_state`. Missing sections are skipped (an older
|
| 366 |
+
checkpoint or a CPU-only save resumed on GPU)."""
|
| 367 |
+
import torch
|
| 368 |
+
|
| 369 |
+
if "python" in sd:
|
| 370 |
+
ver, keys, gauss = sd["python"]
|
| 371 |
+
random.setstate((int(ver), tuple(int(k) for k in keys), gauss))
|
| 372 |
+
if "numpy" in sd:
|
| 373 |
+
import numpy as np
|
| 374 |
+
|
| 375 |
+
n = sd["numpy"]
|
| 376 |
+
np.random.set_state(
|
| 377 |
+
(
|
| 378 |
+
n["name"],
|
| 379 |
+
np.array(n["keys"], dtype=np.uint32),
|
| 380 |
+
int(n["pos"]),
|
| 381 |
+
int(n["has_gauss"]),
|
| 382 |
+
float(n["cached_gaussian"]),
|
| 383 |
+
)
|
| 384 |
+
)
|
| 385 |
+
if "torch" in sd:
|
| 386 |
+
torch.set_rng_state(sd["torch"].cpu())
|
| 387 |
+
if "cuda" in sd and torch.cuda.is_available():
|
| 388 |
+
for idx, state in sd["cuda"].items():
|
| 389 |
+
if int(idx) < torch.cuda.device_count():
|
| 390 |
+
# lazily applied if CUDA is not initialised yet
|
| 391 |
+
torch.cuda.set_rng_state(state.cpu(), device=int(idx))
|