File size: 23,098 Bytes
4c383ef
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
"""Single-GPU ROCm/CUDA LoRA/full fine-tuning on variable-sized decision groups."""
import argparse
import json
import os
import shutil
import time
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import numpy as np
import torch
from transformers import Trainer, TrainerCallback, TrainingArguments, ProgressCallback, set_seed
from transformers.trainer_pt_utils import LengthGroupedSampler
from mugi_decision.progress_callback import StableProgressCallback
from mugi_decision.optimizations import enable_fast_trainable_tokens
from mugi_decision.runtime import gpu_device, load_model, dtype_for, save_json, gpu_metadata
from mugi_decision.frozen import FrozenDecisions, FrozenCollator, model_encoding_signature
from mugi_decision.resume import validate_resume

LORA_TARGETS = ['q_proj','k_proj','v_proj','o_proj','in_proj_qkv','in_proj_z','in_proj_b','in_proj_a','out_proj','gate_proj','up_proj','down_proj']

def main():
    p = argparse.ArgumentParser()
    p.add_argument('--backend', choices=['auto','rocm','cuda'], default=None)
    p.add_argument('--progress', action=argparse.BooleanOptionalAction, default=True)
    p.add_argument('--eta-window', type=int, default=250, help='Sliding window of optimizer updates for stable speed/ETA')
    p.add_argument('--model', default=str(Path(__file__).resolve().parents[1]))
    p.add_argument('--data', default='data/ready', help='Completed offline dataset directory with manifest.json')
    p.add_argument('--no-eval', action='store_true')
    p.add_argument('--initial-eval', action='store_true')
    p.add_argument('--output', default='runs/mugi-lora')
    p.add_argument('--mode', choices=['lora','full'], default='lora')
    p.add_argument('--dtype', choices=['bf16','fp32'], default='bf16')
    p.add_argument('--max-length', type=int, help='Must equal prepared manifest; change lengths with build_training_set.py')
    p.add_argument('--batch-groups', type=int, default=1)
    p.add_argument('--group-by-length', action='store_true', help='Group the same frozen rows by precomputed length to reduce padding')
    p.add_argument('--gradient-accumulation', type=int, default=16)
    p.add_argument('--epoch-snapshots', action='store_true', help='Evaluate and preserve each completed epoch adapter separately')
    p.add_argument('--epochs', type=float, default=1)
    p.add_argument('--max-steps', type=int, default=-1)
    p.add_argument('--lr', type=float, default=1e-4)
    p.add_argument('--optimizer', choices=['adamw_torch','adamw_torch_fused'], default='adamw_torch_fused')
    p.add_argument('--native-token-lookup', action='store_true', help='Use the original PEFT full-vocabulary merge path')
    p.add_argument('--keep-padding-mask', action='store_true', help='Disable the equivalent causal right-padding fast path')
    p.add_argument('--rank', type=int, default=16)
    p.add_argument('--save-steps', type=int, default=500)
    p.add_argument('--logging-steps', type=int, default=10)
    p.add_argument('--seed', type=int, default=42)
    p.add_argument('--best-model-metric', choices=['accuracy_macro_language','accuracy_train_language_weighted'], default='accuracy_macro_language')
    p.add_argument('--init-adapter', help='Load trained LoRA/head/marker weights with fresh optimizer and schedule')
    p.add_argument('--resume', help='A checkpoint directory created by this trainer')
    p.add_argument('--no-gradient-checkpointing', action='store_true')
    p.add_argument('--checkpoint-linear-only', action='store_true', help='Checkpoint 18 DeltaNet decoder layers; leave 6 full-attention layers without recomputation')
    p.add_argument('--checkpoint-linear-stride', type=int, default=1, choices=[1, 2, 3],
                   help='Checkpoint every Nth DeltaNet layer; 2 selects nine layers, 3 selects six')
    p.add_argument('--adaptive-checkpoint-tokens', type=int, default=0,
                   help='Skip recomputation below this padded branch-token count; requires a validated GPU memory budget')
    p.add_argument('--autocast-lora', action='store_true', help='Avoid redundant FP32 activation copies inside BF16 LoRA autocast')
    p.add_argument('--runtime-cumsum', action='store_true', help='Avoid FLA scalar cumsum recompilation for each candidate count')
    p.add_argument('--fused-norms', action='store_true', help='Use CUDA fusion for frozen Qwen RMSNorms without per-length compilation')
    p.add_argument('--allow-performance-resume', action='store_true',
                   help='Allow explicit performance changes while keeping effective batch/data/LR/sampler unchanged')
    p.add_argument('--cpu-threads', type=int, help='Explicit CPU intra-op limit for this process')
    p.add_argument('--cpu-node', type=int, help='Linux CPU affinity to the GPU-local NUMA node')
    p.add_argument('--benchmark-steps', type=int,
                   help='Stop after N updates, only with a separate benchmark-* output directory')
    args = p.parse_args()
    if args.epoch_snapshots and (args.no_eval or args.mode != 'lora'):
        p.error('epoch-snapshots requires LoRA mode and evaluation')
    if args.init_adapter and (args.resume or args.mode != 'lora'):
        p.error('init-adapter requires LoRA mode and cannot be combined with resume')
    if args.batch_groups < 1 or args.gradient_accumulation < 1:
        p.error('batch-groups and gradient-accumulation must be positive')
    if args.checkpoint_linear_stride != 1 and not args.checkpoint_linear_only:
        p.error('checkpoint-linear-stride requires checkpoint-linear-only')
    if args.adaptive_checkpoint_tokens < 0 or (args.adaptive_checkpoint_tokens and
            (not args.checkpoint_linear_only or args.no_gradient_checkpointing)):
        p.error('adaptive checkpointing requires checkpoint-linear-only and a nonnegative threshold')
    if args.benchmark_steps is not None:
        if args.benchmark_steps < 1 or not Path(args.output).name.startswith('benchmark-'):
            p.error('benchmark-steps requires a positive count and a separate benchmark-* output directory')
    if args.cpu_threads is not None:
        if args.cpu_threads < 1:
            p.error('cpu-threads must be positive')
    if args.cpu_node is not None:
        if not hasattr(os, 'sched_setaffinity'):
            p.error('cpu-node requires Linux CPU affinity')
        spans = Path(f'/sys/devices/system/node/node{args.cpu_node}/cpulist').read_text().strip().split(',')
        cpus = set()
        for span in spans:
            bounds = list(map(int, span.split('-')))
            cpus.update(range(bounds[0], bounds[-1]+1))
        cpus &= os.sched_getaffinity(0)
        if not cpus:
            p.error('Requested NUMA node has no allowed CPUs')
        os.sched_setaffinity(0, cpus)
    if args.cpu_threads is not None:
        torch.set_num_threads(args.cpu_threads)
    if args.eta_window < 1:
        p.error('eta-window must be positive')
    if args.checkpoint_linear_only and args.no_gradient_checkpointing:
        p.error('checkpoint-linear-only requires gradient checkpointing')
    output = Path(args.output)
    if output.exists() and any(output.iterdir()) and not args.resume:
        raise SystemExit('Output directory is not empty. Use --resume or a new --output.')
    print('Checking frozen dataset hashes; no runtime tokenization or filtering...', flush=True)
    signature = model_encoding_signature(args.model)
    train_data = FrozenDecisions(args.data, 'train', signature)
    eval_data = None if args.no_eval else FrozenDecisions(args.data, 'validation', signature)
    prepared_length = train_data.manifest['max_length']
    if args.max_length is not None and args.max_length != prepared_length:
        raise ValueError('max-length differs from prepared data. Build a new dataset offline.')
    args.max_length = prepared_length
    performance_changes = {}
    initialization = None
    if args.resume:
        source_config = Path(args.resume).resolve().parent/'run_config.json'
        if not source_config.is_file():
            raise ValueError('Resume checkpoint needs its original run_config.json in the parent directory')
        previous = json.loads(source_config.read_text(encoding='utf-8'))
        initialization = previous.get('initialization')
        performance_changes = validate_resume(previous, vars(args), train_data.manifest_hash,
                                              args.allow_performance_resume)
        if performance_changes:
            print('Explicit performance resume changes: '+json.dumps(performance_changes), flush=True)
    device = gpu_device(backend=args.backend)
    if args.runtime_cumsum:
        if torch.version.hip:
            raise ValueError("Runtime cumsum optimization requires CUDA")
        from mugi_decision.runtime_cumsum import enable_runtime_cumsum
        enable_runtime_cumsum()
    set_seed(args.seed)
    model, tokenizer = load_model(args.model, device, dtype_for(args.dtype))
    if args.init_adapter:
        from peft import PeftModel
        from mugi_decision.initialization import adapter_initialization
        initialization = adapter_initialization(args.init_adapter, args.model, args.rank,
                                                model.config.decision_token_ids, LORA_TARGETS)
        model = PeftModel.from_pretrained(model, args.init_adapter, is_trainable=True,
                                         local_files_only=True)
        print('Weight-only initialization: '+json.dumps(initialization), flush=True)
    elif args.mode == 'lora':
        from peft import LoraConfig, get_peft_model
        model = get_peft_model(model, LoraConfig(r=args.rank, lora_alpha=2*args.rank,
            lora_dropout=0.05, target_modules=LORA_TARGETS, modules_to_save=['score'],
            trainable_token_indices={'backbone.embed_tokens': model.config.decision_token_ids},
            bias='none'))
    fast_token_layers = 0 if args.native_token_lookup else enable_fast_trainable_tokens(model)
    fused_norm_layers = 0
    if args.fused_norms:
        from mugi_decision.cuda_optimizations import enable_fused_norms
        fused_norm_layers = enable_fused_norms(model)
        if not fused_norm_layers:
            raise ValueError('Fused norms require frozen Qwen3.5 norm weights (LoRA mode)')
    autocast_lora_layers = 0
    if args.autocast_lora:
        from mugi_decision.cuda_optimizations import enable_autocast_lora
        autocast_lora_layers = enable_autocast_lora(model)
        if not autocast_lora_layers or args.dtype != 'bf16':
            raise ValueError('Autocast LoRA requires LoRA mode and BF16 training')
    checkpoint_policy = None
    if args.adaptive_checkpoint_tokens:
        from mugi_decision.checkpointing import AdaptiveLinearCheckpointing
        checkpoint_policy = AdaptiveLinearCheckpointing(model, args.checkpoint_linear_stride, args.adaptive_checkpoint_tokens)
    # Trainer must normalize loss per decision group, not per candidate branch/token.
    model.accepts_loss_kwargs = False
    trainable = sum(x.numel() for x in model.parameters() if x.requires_grad)
    print(json.dumps({'train':train_data.summary,'eval':eval_data.summary if eval_data else None,
                      'trainable_parameters':trainable, 'hip':torch.version.hip, 'fast_token_layers':fast_token_layers,
                      'optimizer':args.optimizer,'causal_padding_fast_path':not args.keep_padding_mask}, ensure_ascii=False), flush=True)
    settings = TrainingArguments(output_dir=str(output), per_device_train_batch_size=args.batch_groups,
        per_device_eval_batch_size=args.batch_groups, gradient_accumulation_steps=args.gradient_accumulation,
        learning_rate=args.lr, num_train_epochs=args.epochs, max_steps=args.max_steps,
        warmup_steps=0.03, weight_decay=0.01, max_grad_norm=1.0,
        bf16=args.dtype=='bf16', fp16=False, optim=args.optimizer,
        gradient_checkpointing=not args.no_gradient_checkpointing,
        gradient_checkpointing_kwargs={'use_reentrant':False},
        logging_steps=args.logging_steps, save_steps=args.save_steps, save_total_limit=2,
        eval_strategy='steps' if eval_data else 'no', eval_steps=args.save_steps,
        remove_unused_columns=False, label_names=['labels'],
        dataloader_num_workers=0, dataloader_pin_memory=False,
        report_to='none', seed=args.seed, data_seed=args.seed,
        disable_tqdm=not args.progress,
        eval_accumulation_steps=8, logging_nan_inf_filter=False,
        load_best_model_at_end=bool(eval_data), metric_for_best_model=args.best_model_metric,
        greater_is_better=True)
    # Keep evaluation order fixed; shuffle candidates only in the training collator.
    class DecisionTrainer(Trainer):
        def _get_train_sampler(self, train_dataset=None):
            dataset = self.train_dataset if train_dataset is None else train_dataset
            if args.group_by_length:
                class RecordedSampler(LengthGroupedSampler):
                    def __iter__(sampler_self):
                        indices = list(super().__iter__())
                        if args.benchmark_steps is not None:
                            save_json(output/'sampler_indices.json', indices)
                        return iter(indices)
                return RecordedSampler(
                    batch_size=self.args.train_batch_size * self.args.gradient_accumulation_steps,
                    lengths=dataset.lengths)
            return super()._get_train_sampler(train_dataset)

        def compute_loss(self, model, inputs, return_outputs=False, num_items_in_batch=None):
            if checkpoint_policy is not None and model.training:
                checkpoint_policy(inputs['input_ids'])
            loss, outputs = super().compute_loss(model, inputs, return_outputs=True,
                                                num_items_in_batch=num_items_in_batch)
            if not torch.isfinite(loss).all():
                raise FloatingPointError('Non-finite decision loss; stopping without skipping the sample')
            return (loss, outputs) if return_outputs else loss
        def get_eval_dataloader(self, eval_dataset=None):
            old = self.data_collator
            try:
                self.data_collator = FrozenCollator(tokenizer, augment=False, elide_padding_mask=not args.keep_padding_mask)
                return super().get_eval_dataloader(eval_dataset)
            finally:
                self.data_collator = old
    def preprocess(logits, labels):
        # ModelOutput also contains mask and flat_scores; only group logits matter.
        return (logits[0] if isinstance(logits, tuple) else logits).argmax(-1)
    def metrics(result):
        correct = np.asarray(result.predictions) == np.asarray(result.label_ids)
        if len(correct) != len(eval_data):
            raise ValueError('Evaluation predictions do not align with frozen records')
        report = {'accuracy':float(correct.mean())}
        group_names = ['language','source','options']
        language_scores=[]
        for dimension, name in enumerate(group_names):
            values = [group[dimension] for group in eval_data.groups]
            for value in sorted(set(values), key=str):
                selected = np.asarray([x == value for x in values])
                accuracy = float(correct[selected].mean())
                report[f'accuracy_{name}_{value}'] = accuracy
                if name == 'language': language_scores.append(accuracy)
        report['accuracy_macro_language'] = float(np.mean(language_scores))
        if args.best_model_metric == 'accuracy_train_language_weighted':
            from mugi_decision.language_metrics import train_weighted_language_accuracy
            report['accuracy_train_language_weighted'] = train_weighted_language_accuracy(
                {language: report['accuracy_language_' + language] for language in train_data.summary['languages'] if 'accuracy_language_' + language in report},
                train_data.summary['languages'])
        from mugi_decision.eval_cohorts import cohort_metrics, named_cohort_metrics
        report.update(cohort_metrics(correct, eval_data.groups,
                                     eval_data.manifest.get('legacy_validation_rows', 0)))
        report.update(named_cohort_metrics(correct, eval_data.groups,
                                          eval_data.manifest.get('validation_cohorts', [])))
        report['random_choice_baseline'] = float(np.mean([1/x[2] for x in eval_data.groups]))
        return report
    stable_progress = StableProgressCallback(window=args.eta_window, enabled=args.progress)
    class Progress(TrainerCallback):
        def __init__(self):
            self.started = time.monotonic()
            self.logs = {}
        def on_train_begin(self, training_args, state, control, **kwargs):
            self.start_step = state.global_step
            state.train_batch_size = training_args.train_batch_size
            if args.checkpoint_linear_only:
                # Trainer enables all layers before on_train_begin, including on resume.
                base = model.get_base_model() if args.mode == 'lora' else model
                layers = base.backbone.layers
                linear_index = 0
                for layer in layers:
                    linear = hasattr(layer, 'linear_attn')
                    layer.gradient_checkpointing = linear and linear_index % args.checkpoint_linear_stride == 0
                    linear_index += int(linear)
                enabled = sum(bool(layer.gradient_checkpointing) for layer in layers)
                print(f'Gradient checkpointing: {enabled}/{len(layers)} decoder layers (DeltaNet stride {args.checkpoint_linear_stride})', flush=True)
        def on_log(self, args, state, control, logs=None, **kwargs):
            self.logs.update(logs or {})
            for key in ('loss','grad_norm'):
                if key in self.logs and not np.isfinite(float(self.logs[key])):
                    raise FloatingPointError(f'Non-finite {key}; stopped')
        def on_step_end(self, training_args, state, control, **kwargs):
            if args.benchmark_steps is not None and state.global_step-self.start_step >= args.benchmark_steps:
                control.should_training_stop = True
                control.should_save = True
            save_json(output/'progress.json', {'status':'training','step':state.global_step,
                'max_steps':state.max_steps,'epoch':state.epoch,
                'elapsed_seconds':time.monotonic()-self.started,
                'peak_allocated_gib':torch.cuda.max_memory_allocated(device)/2**30,
                'peak_reserved_gib':torch.cuda.max_memory_reserved(device)/2**30,
                'latest_logs':self.logs, 'eta':stable_progress.snapshot})
    progress_callback = Progress()
    callbacks = [stable_progress, progress_callback]
    if args.epoch_snapshots:
        from mugi_decision.epoch_snapshots import EpochSnapshots
        callbacks.append(EpochSnapshots(output, args.model, train_data.manifest_hash))
    trainer = DecisionTrainer(model=model, args=settings, train_dataset=train_data, eval_dataset=eval_data,
        data_collator=FrozenCollator(tokenizer, augment=True, elide_padding_mask=not args.keep_padding_mask), processing_class=tokenizer,
        compute_metrics=metrics, preprocess_logits_for_metrics=preprocess, callbacks=callbacks)
    for callback in list(trainer.callback_handler.callbacks):
        if type(callback) is ProgressCallback:
            trainer.remove_callback(callback)
    if trainer.model_accepts_loss_kwargs:
        raise RuntimeError('Trainer must normalize the per-question mean loss during gradient accumulation')
    output.mkdir(parents=True, exist_ok=True)
    save_json(output/'run_config.json', dict(vars(args), base_model=str(Path(args.model).resolve()),
              dataset_manifest_sha256=train_data.manifest_hash, initialization=initialization, runtime=gpu_metadata(device),
              train_summary=train_data.summary, eval_summary=eval_data.summary if eval_data else None,
              trainable_parameters=trainable, fast_token_layers=fast_token_layers,
              fused_norm_layers=fused_norm_layers, autocast_lora_layers=autocast_lora_layers, performance_resume_changes=performance_changes,
              cpu_threads=torch.get_num_threads(), cpu_affinity=sorted(os.sched_getaffinity(0)) if hasattr(os, 'sched_getaffinity') else None,
              special_tokens_trainable=args.mode=='full' or bool(model.peft_config)))
    if args.initial_eval and eval_data and not args.resume:
        save_json(output/'progress.json', {'status':'initial_evaluation','step':0,'eval_rows':len(eval_data)})
        trainer.save_metrics('initial_eval', trainer.evaluate())
    torch.cuda.reset_peak_memory_stats(device)
    try:
        result = trainer.train(resume_from_checkpoint=args.resume)
    except BaseException as exc:
        save_json(output/'progress.json', {'status':'failed','step':trainer.state.global_step,'error':repr(exc)})
        raise
    if args.benchmark_steps is not None:
        completed = trainer.state.global_step-progress_callback.start_step
        runtime = result.metrics['train_runtime']
        save_json(output/'benchmark_results.json', {
            'start_step':progress_callback.start_step, 'end_step':trainer.state.global_step,
            'total_planned_steps':trainer.state.max_steps,
            'updates':completed, 'questions':completed*args.batch_groups*args.gradient_accumulation,
            'runtime_seconds':runtime,
            'runtime_questions_per_second':completed*args.batch_groups*args.gradient_accumulation/runtime,
            'steady_window':stable_progress.snapshot,
            'checkpoint_batches':checkpoint_policy.batches if checkpoint_policy else None,
            'peak_allocated_gib':torch.cuda.max_memory_allocated(device)/2**30,
            'peak_reserved_gib':torch.cuda.max_memory_reserved(device)/2**30})
    result.metrics['peak_allocated_gib'] = torch.cuda.max_memory_allocated(device) / 2**30
    result.metrics['peak_reserved_gib'] = torch.cuda.max_memory_reserved(device) / 2**30
    trainer.save_model(str(output/'final'))
    tokenizer.save_pretrained(output/'final')
    if (Path(args.model)/'LICENSE').exists():
        shutil.copyfile(Path(args.model)/'LICENSE', output/'final'/'LICENSE')
    save_json(output/'final'/'decision_training.json', {'base_model':str(Path(args.model).resolve()),'mode':args.mode,'steps':trainer.state.global_step,'state':'finetuned',
        'dataset_manifest_sha256':train_data.manifest_hash, 'best_checkpoint':trainer.state.best_model_checkpoint,
        'initialization':initialization})
    trainer.save_metrics('train', result.metrics)
    if eval_data:
        trainer.save_metrics('eval', trainer.evaluate())
    save_json(output/'progress.json', {'status':'completed','step':trainer.state.global_step,
        'final':str((output/'final').resolve()),'best_checkpoint':trainer.state.best_model_checkpoint})
    print('Saved:', output/'final')

if __name__ == '__main__': main()