@mlx-node/trl 0.0.13 → 0.0.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -4
- package/src/data/dataset.ts +193 -0
- package/src/data/sft-dataset.ts +562 -0
- package/src/index.ts +139 -0
- package/src/trainers/grpo-trainer.ts +2151 -0
- package/src/trainers/sft-config.ts +250 -0
- package/src/trainers/sft-trainer.ts +679 -0
- package/src/trainers/training-logger.ts +830 -0
- package/src/types.ts +70 -0
- package/src/utils/path-security.ts +88 -0
- package/src/utils/xml-parser.ts +209 -0
|
@@ -0,0 +1,679 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SFT (Supervised Fine-Tuning) Trainer
|
|
3
|
+
*
|
|
4
|
+
* This module provides a Rust-native SFT training engine for training
|
|
5
|
+
* models on fixed prompt-completion pairs using cross-entropy loss.
|
|
6
|
+
*
|
|
7
|
+
* ## Key Features
|
|
8
|
+
* - Training loop runs in Rust (eliminates FFI overhead)
|
|
9
|
+
* - Cross-entropy loss with completion masking (ignore_index=-100)
|
|
10
|
+
* - Label smoothing support
|
|
11
|
+
* - Gradient accumulation and clipping
|
|
12
|
+
* - High-level train() method for full training runs
|
|
13
|
+
* - Low-level trainStep() for custom training loops
|
|
14
|
+
*
|
|
15
|
+
* ## Usage
|
|
16
|
+
* ```typescript
|
|
17
|
+
* const trainer = await SFTTrainer.create({
|
|
18
|
+
* modelPath: './model',
|
|
19
|
+
* learningRate: 2e-5,
|
|
20
|
+
* numEpochs: 3,
|
|
21
|
+
* });
|
|
22
|
+
* await trainer.train(dataset);
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, copyFileSync, rmSync } from 'node:fs';
|
|
27
|
+
import { join, parse } from 'node:path';
|
|
28
|
+
import * as readline from 'node:readline';
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
SftTrainingEngine,
|
|
32
|
+
Qwen3Model,
|
|
33
|
+
Qwen35Model,
|
|
34
|
+
Qwen35MoeModel,
|
|
35
|
+
Qwen3Tokenizer,
|
|
36
|
+
MxArray,
|
|
37
|
+
type SftEngineConfig,
|
|
38
|
+
type SftStepMetrics,
|
|
39
|
+
type SftEpochMetrics,
|
|
40
|
+
} from '@mlx-node/core';
|
|
41
|
+
import { loadModel, type TrainableModel } from '@mlx-node/lm';
|
|
42
|
+
|
|
43
|
+
import { SFTDataset, loadSFTDataset, type SFTBatch } from '../data/sft-dataset.js';
|
|
44
|
+
import type { SFTTrainerConfig } from './sft-config.js';
|
|
45
|
+
import { getDefaultSFTConfig, mergeSFTConfig } from './sft-config.js';
|
|
46
|
+
import { createTrainingLogger, type TrainingLogger } from './training-logger.js';
|
|
47
|
+
|
|
48
|
+
// Re-export types
|
|
49
|
+
export { SftTrainingEngine } from '@mlx-node/core';
|
|
50
|
+
export type { SftEngineConfig, SftStepMetrics, SftEpochMetrics } from '@mlx-node/core';
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Training state saved with checkpoints for resumption
|
|
54
|
+
*/
|
|
55
|
+
export interface SFTTrainingState {
|
|
56
|
+
step: number;
|
|
57
|
+
epoch: number;
|
|
58
|
+
timestamp: string;
|
|
59
|
+
trainerType: 'sft';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Training step result
|
|
64
|
+
*/
|
|
65
|
+
export interface SFTTrainStepResult {
|
|
66
|
+
/** Step metrics */
|
|
67
|
+
metrics: SftStepMetrics;
|
|
68
|
+
/** Current epoch */
|
|
69
|
+
epoch: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* SFT Trainer - Rust-Native Training Engine
|
|
74
|
+
*
|
|
75
|
+
* Provides a TypeScript-friendly interface to the Rust SFT training engine.
|
|
76
|
+
*/
|
|
77
|
+
export class SFTTrainer {
|
|
78
|
+
private engine: SftTrainingEngine;
|
|
79
|
+
private model: TrainableModel;
|
|
80
|
+
private tokenizer: Qwen3Tokenizer;
|
|
81
|
+
private config: SFTTrainerConfig;
|
|
82
|
+
private currentEpoch: number = 0;
|
|
83
|
+
private currentStep: number = 0;
|
|
84
|
+
/** Original model path (for tokenizer files when saving checkpoints) */
|
|
85
|
+
private originalModelPath?: string;
|
|
86
|
+
|
|
87
|
+
// TUI state
|
|
88
|
+
private paused: boolean = false;
|
|
89
|
+
private stopRequested: boolean = false;
|
|
90
|
+
private stdinInterface?: readline.Interface;
|
|
91
|
+
private logger: TrainingLogger;
|
|
92
|
+
private sampleDisplayMode: 'all' | 'best_worst' | 'random' = 'all';
|
|
93
|
+
private signalHandlersInstalled: boolean = false;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Create a new SFT trainer from a model
|
|
97
|
+
*
|
|
98
|
+
* @param model - Pre-loaded Qwen3 model
|
|
99
|
+
* @param tokenizer - Pre-loaded tokenizer
|
|
100
|
+
* @param config - Training configuration
|
|
101
|
+
* @param logger - Optional custom logger
|
|
102
|
+
*/
|
|
103
|
+
constructor(
|
|
104
|
+
model: TrainableModel,
|
|
105
|
+
tokenizer: Qwen3Tokenizer,
|
|
106
|
+
config: Partial<SFTTrainerConfig> = {},
|
|
107
|
+
logger?: TrainingLogger,
|
|
108
|
+
) {
|
|
109
|
+
// Auto-detect TUI mode from environment variable
|
|
110
|
+
const tuiModeFromEnv = process.env.MLX_TUI_MODE === '1';
|
|
111
|
+
if (tuiModeFromEnv && config.tuiMode === undefined) {
|
|
112
|
+
config.tuiMode = true;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
this.config = mergeSFTConfig(getDefaultSFTConfig(), config);
|
|
116
|
+
this.model = model;
|
|
117
|
+
this.tokenizer = tokenizer;
|
|
118
|
+
|
|
119
|
+
// Create or use provided logger
|
|
120
|
+
this.logger =
|
|
121
|
+
logger ??
|
|
122
|
+
createTrainingLogger({
|
|
123
|
+
logConsole: !this.config.tuiMode,
|
|
124
|
+
logJsonl: this.config.logJsonl,
|
|
125
|
+
outputDir: this.config.outputDir,
|
|
126
|
+
runName: this.config.runName,
|
|
127
|
+
logInterval: this.config.loggingSteps,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// Convert to native config
|
|
131
|
+
const engineConfig: SftEngineConfig = {
|
|
132
|
+
learningRate: this.config.learningRate,
|
|
133
|
+
gradientAccumulationSteps: this.config.gradientAccumulationSteps,
|
|
134
|
+
gradientClipNorm: this.config.maxGradNorm,
|
|
135
|
+
weightDecay: this.config.weightDecay,
|
|
136
|
+
labelSmoothing: this.config.labelSmoothing,
|
|
137
|
+
gradientCheckpointing: this.config.gradientCheckpointing,
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
if (model instanceof Qwen35Model) {
|
|
141
|
+
this.engine = SftTrainingEngine.fromQwen35(model, engineConfig);
|
|
142
|
+
} else if (model instanceof Qwen35MoeModel) {
|
|
143
|
+
this.engine = SftTrainingEngine.fromQwen35Moe(model, engineConfig);
|
|
144
|
+
} else if (model instanceof Qwen3Model) {
|
|
145
|
+
this.engine = new SftTrainingEngine(model, engineConfig);
|
|
146
|
+
} else {
|
|
147
|
+
throw new Error(`Unsupported model type: ${(model as object).constructor?.name ?? typeof model}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Setup stdin handler if TUI mode
|
|
151
|
+
if (this.config.tuiMode) {
|
|
152
|
+
this.setupStdinHandler();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Setup OS signal handlers for graceful shutdown on interrupt.
|
|
158
|
+
* Saves an emergency checkpoint before exiting to prevent progress loss.
|
|
159
|
+
*/
|
|
160
|
+
private setupSignalHandlers(): void {
|
|
161
|
+
if (this.signalHandlersInstalled) return;
|
|
162
|
+
this.signalHandlersInstalled = true;
|
|
163
|
+
|
|
164
|
+
const gracefulShutdown = async (signal: string) => {
|
|
165
|
+
this.logger.warn(`Received ${signal}, initiating graceful shutdown...`);
|
|
166
|
+
this.stopRequested = true;
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
if (this.config.outputDir && this.currentStep > 0) {
|
|
170
|
+
this.logger.info(`Saving emergency checkpoint at step ${this.currentStep}...`);
|
|
171
|
+
await this.saveCheckpoint(`emergency-checkpoint-${this.currentStep}`);
|
|
172
|
+
this.logger.info('Emergency checkpoint saved.');
|
|
173
|
+
}
|
|
174
|
+
} catch (e) {
|
|
175
|
+
console.error('Failed to save emergency checkpoint:', e);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
process.exit(0);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
process.on('SIGTERM', () => {
|
|
182
|
+
gracefulShutdown('SIGTERM').catch(console.error);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
process.on('SIGINT', () => {
|
|
186
|
+
gracefulShutdown('SIGINT').catch(console.error);
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Setup stdin handler for TUI control commands
|
|
192
|
+
*/
|
|
193
|
+
private setupStdinHandler(): void {
|
|
194
|
+
if (!this.config.tuiMode) return;
|
|
195
|
+
|
|
196
|
+
this.stdinInterface = readline.createInterface({
|
|
197
|
+
input: process.stdin,
|
|
198
|
+
output: process.stdout,
|
|
199
|
+
terminal: false,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
this.stdinInterface.on('line', (line: string) => {
|
|
203
|
+
const cmd = line.trim();
|
|
204
|
+
this.handleStdinCommand(cmd);
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Handle a command received from stdin
|
|
210
|
+
*/
|
|
211
|
+
private handleStdinCommand(cmd: string): void {
|
|
212
|
+
switch (cmd) {
|
|
213
|
+
case 'PAUSE':
|
|
214
|
+
this.paused = true;
|
|
215
|
+
this.logger.paused(this.currentStep);
|
|
216
|
+
break;
|
|
217
|
+
case 'RESUME':
|
|
218
|
+
this.paused = false;
|
|
219
|
+
this.logger.resumed(this.currentStep);
|
|
220
|
+
break;
|
|
221
|
+
case 'SAVE_CHECKPOINT':
|
|
222
|
+
this.saveCheckpoint().catch(() => {});
|
|
223
|
+
break;
|
|
224
|
+
case 'STOP':
|
|
225
|
+
this.stopRequested = true;
|
|
226
|
+
break;
|
|
227
|
+
default:
|
|
228
|
+
// Handle SET commands (e.g., SET sample_display=best_worst)
|
|
229
|
+
if (cmd.startsWith('SET ')) {
|
|
230
|
+
const keyValue = cmd.slice(4); // Remove 'SET ' prefix
|
|
231
|
+
const eqIdx = keyValue.indexOf('=');
|
|
232
|
+
if (eqIdx > 0) {
|
|
233
|
+
const key = keyValue.slice(0, eqIdx);
|
|
234
|
+
const value = keyValue.slice(eqIdx + 1);
|
|
235
|
+
if (key === 'sample_display') {
|
|
236
|
+
if (value === 'all' || value === 'best_worst' || value === 'random') {
|
|
237
|
+
this.sampleDisplayMode = value;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Wait for resume if paused
|
|
248
|
+
*/
|
|
249
|
+
private async waitForResume(): Promise<void> {
|
|
250
|
+
while (this.paused && !this.stopRequested) {
|
|
251
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Create a trainer by loading a model from disk
|
|
257
|
+
*
|
|
258
|
+
* @param config - Configuration including modelPath
|
|
259
|
+
* @returns Promise<SFTTrainer>
|
|
260
|
+
*/
|
|
261
|
+
static async create(config: Partial<SFTTrainerConfig>): Promise<SFTTrainer> {
|
|
262
|
+
if (!config.modelName) {
|
|
263
|
+
throw new Error('modelName is required when using SFTTrainer.create()');
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Create logger early
|
|
267
|
+
const logger = createTrainingLogger({
|
|
268
|
+
logConsole: !config.tuiMode,
|
|
269
|
+
logJsonl: config.logJsonl ?? true,
|
|
270
|
+
outputDir: config.outputDir,
|
|
271
|
+
runName: config.runName,
|
|
272
|
+
logInterval: config.loggingSteps ?? 10,
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
let modelPath = config.modelName;
|
|
276
|
+
let resumedState: SFTTrainingState | null = null;
|
|
277
|
+
|
|
278
|
+
// Handle checkpoint resumption
|
|
279
|
+
if (config.resumeFromCheckpoint) {
|
|
280
|
+
const checkpointPath =
|
|
281
|
+
config.resumeFromCheckpoint === 'latest'
|
|
282
|
+
? SFTTrainer.findLatestCheckpoint(config.outputDir)
|
|
283
|
+
: config.resumeFromCheckpoint;
|
|
284
|
+
|
|
285
|
+
if (checkpointPath) {
|
|
286
|
+
const statePath = join(checkpointPath, 'training_state.json');
|
|
287
|
+
if (existsSync(statePath)) {
|
|
288
|
+
resumedState = JSON.parse(readFileSync(statePath, 'utf-8'));
|
|
289
|
+
logger.info(
|
|
290
|
+
`Resuming from checkpoint: ${checkpointPath} (step ${resumedState?.step}, epoch ${resumedState?.epoch})`,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
modelPath = checkpointPath;
|
|
294
|
+
} else if (config.resumeFromCheckpoint === 'latest') {
|
|
295
|
+
logger.info('No checkpoint found, starting fresh training');
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Get model name for display
|
|
300
|
+
const modelName = parse(modelPath).base || 'Unknown';
|
|
301
|
+
logger.status('loading', `Loading ${modelName}...`);
|
|
302
|
+
|
|
303
|
+
// Load model (auto-detects architecture from config.json)
|
|
304
|
+
const model = await loadModel(modelPath);
|
|
305
|
+
|
|
306
|
+
const tokenizer = await Qwen3Tokenizer.fromPretrained(join(modelPath, 'tokenizer.json'));
|
|
307
|
+
|
|
308
|
+
logger.status('loading', `${modelName} loaded (${model.constructor.name})`);
|
|
309
|
+
|
|
310
|
+
// Create trainer
|
|
311
|
+
// @ts-expect-error
|
|
312
|
+
const trainer = new SFTTrainer(model, tokenizer, config, logger);
|
|
313
|
+
trainer.originalModelPath = config.modelName;
|
|
314
|
+
|
|
315
|
+
// Restore training state if resuming
|
|
316
|
+
if (resumedState) {
|
|
317
|
+
trainer.currentStep = resumedState.step;
|
|
318
|
+
trainer.currentEpoch = resumedState.epoch;
|
|
319
|
+
// Also restore engine state to sync step/epoch accounting
|
|
320
|
+
trainer.engine.restoreState(resumedState.step, resumedState.epoch);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return trainer;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Find the latest checkpoint in the output directory
|
|
328
|
+
*/
|
|
329
|
+
static findLatestCheckpoint(outputDir?: string): string | null {
|
|
330
|
+
if (!outputDir || !existsSync(outputDir)) {
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const entries = readdirSync(outputDir, { withFileTypes: true });
|
|
335
|
+
const checkpoints = entries
|
|
336
|
+
.filter((e) => e.isDirectory() && e.name.startsWith('checkpoint-'))
|
|
337
|
+
.map((e) => ({
|
|
338
|
+
name: e.name,
|
|
339
|
+
step: parseInt(e.name.replace('checkpoint-', ''), 10),
|
|
340
|
+
path: join(outputDir, e.name),
|
|
341
|
+
}))
|
|
342
|
+
.filter((c) => !isNaN(c.step))
|
|
343
|
+
.sort((a, b) => b.step - a.step);
|
|
344
|
+
|
|
345
|
+
return checkpoints.length > 0 ? checkpoints[0].path : null;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Run a single training step
|
|
350
|
+
*
|
|
351
|
+
* @param batch - Tokenized batch with input_ids and labels
|
|
352
|
+
* @returns Training step metrics
|
|
353
|
+
*/
|
|
354
|
+
async trainStep(batch: SFTBatch): Promise<SFTTrainStepResult> {
|
|
355
|
+
// Convert Int32Array to MxArray
|
|
356
|
+
const inputIds = MxArray.fromInt32(batch.inputIds, BigInt64Array.from(batch.shape.map(BigInt)));
|
|
357
|
+
const labels = MxArray.fromInt32(batch.labels, BigInt64Array.from(batch.shape.map(BigInt)));
|
|
358
|
+
|
|
359
|
+
// Call native engine
|
|
360
|
+
const metrics = await this.engine.trainStep(inputIds, labels);
|
|
361
|
+
|
|
362
|
+
// Sync step with engine when gradients are applied (fixes gradient accumulation accounting)
|
|
363
|
+
// Note: metrics.step is i64 from Rust; JS number may lose precision beyond 2^53-1,
|
|
364
|
+
// but such step counts are unrealistic for any practical training run.
|
|
365
|
+
if (metrics.gradientsApplied) {
|
|
366
|
+
this.currentStep = Number(metrics.step);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return {
|
|
370
|
+
metrics,
|
|
371
|
+
epoch: this.currentEpoch,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Run a full training loop over a dataset
|
|
377
|
+
*
|
|
378
|
+
* @param dataset - SFT dataset or path to JSONL file
|
|
379
|
+
*/
|
|
380
|
+
async train(dataset: SFTDataset | string): Promise<void> {
|
|
381
|
+
// Load dataset if path provided
|
|
382
|
+
let sftDataset: SFTDataset;
|
|
383
|
+
if (typeof dataset === 'string') {
|
|
384
|
+
sftDataset = await loadSFTDataset(dataset, this.tokenizer, {
|
|
385
|
+
maxSeqLength: this.config.maxSeqLength,
|
|
386
|
+
completionOnly: this.config.completionOnly,
|
|
387
|
+
seed: this.config.seed,
|
|
388
|
+
limit: this.config.maxTrainSamples > 0 ? this.config.maxTrainSamples : undefined,
|
|
389
|
+
});
|
|
390
|
+
} else {
|
|
391
|
+
sftDataset = dataset;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (sftDataset.length === 0) {
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Setup signal handlers for crash recovery
|
|
399
|
+
this.setupSignalHandlers();
|
|
400
|
+
|
|
401
|
+
const numEpochs = this.config.numEpochs;
|
|
402
|
+
const batchSize = this.config.batchSize;
|
|
403
|
+
const saveInterval = this.config.saveSteps;
|
|
404
|
+
|
|
405
|
+
// Create output directory
|
|
406
|
+
if (this.config.outputDir && !existsSync(this.config.outputDir)) {
|
|
407
|
+
mkdirSync(this.config.outputDir, { recursive: true });
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Calculate steps per epoch (in batches)
|
|
411
|
+
const stepsPerEpoch = sftDataset.numBatches(batchSize);
|
|
412
|
+
|
|
413
|
+
// Compute resume position (all logic centralized in Rust)
|
|
414
|
+
const resumePos = this.engine.computeResumePosition(stepsPerEpoch);
|
|
415
|
+
const effectiveStartEpoch = resumePos.startEpoch;
|
|
416
|
+
const effectiveStartBatchIdx = resumePos.startBatchIdx;
|
|
417
|
+
|
|
418
|
+
// Get model name
|
|
419
|
+
const modelName =
|
|
420
|
+
(this.originalModelPath ? parse(this.originalModelPath).base : null) ??
|
|
421
|
+
(this.config.modelName ? parse(this.config.modelName).base : null) ??
|
|
422
|
+
'Unknown';
|
|
423
|
+
|
|
424
|
+
// Log training start
|
|
425
|
+
this.logger.init(
|
|
426
|
+
modelName,
|
|
427
|
+
{
|
|
428
|
+
trainingType: 'sft',
|
|
429
|
+
numEpochs,
|
|
430
|
+
batchSize,
|
|
431
|
+
groupSize: 1, // SFT doesn't use groups
|
|
432
|
+
learningRate: this.config.learningRate,
|
|
433
|
+
},
|
|
434
|
+
sftDataset.length,
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
if (this.currentStep > 0) {
|
|
438
|
+
if (resumePos.isEpochBoundary) {
|
|
439
|
+
this.logger.info(`Resuming at epoch boundary, advancing to epoch ${effectiveStartEpoch + 1}`);
|
|
440
|
+
} else {
|
|
441
|
+
this.logger.info(
|
|
442
|
+
`Resuming from step ${this.currentStep} (epoch ${effectiveStartEpoch + 1}, batch ${effectiveStartBatchIdx + 1}/${stepsPerEpoch})`,
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
for (let epoch = effectiveStartEpoch; epoch < numEpochs; epoch++) {
|
|
448
|
+
if (this.stopRequested) break;
|
|
449
|
+
|
|
450
|
+
this.currentEpoch = epoch;
|
|
451
|
+
this.engine.startEpoch(epoch);
|
|
452
|
+
const epochStartTime = Date.now();
|
|
453
|
+
|
|
454
|
+
// Use epoch-based shuffle (deterministic, reproducible via seed + epoch)
|
|
455
|
+
sftDataset.shuffleForEpoch(epoch);
|
|
456
|
+
|
|
457
|
+
// Log epoch start
|
|
458
|
+
this.logger.epochStart(epoch, numEpochs, stepsPerEpoch);
|
|
459
|
+
|
|
460
|
+
// Determine batch start position for this epoch
|
|
461
|
+
const batchStart = epoch === effectiveStartEpoch ? effectiveStartBatchIdx : 0;
|
|
462
|
+
|
|
463
|
+
// Iterate through batches
|
|
464
|
+
let batchIdx = 0;
|
|
465
|
+
for await (const batch of sftDataset.batches(batchSize)) {
|
|
466
|
+
if (this.stopRequested) break;
|
|
467
|
+
|
|
468
|
+
// Skip batches if resuming mid-epoch
|
|
469
|
+
if (batchIdx < batchStart) {
|
|
470
|
+
batchIdx++;
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Wait if paused
|
|
475
|
+
if (this.paused) {
|
|
476
|
+
await this.waitForResume();
|
|
477
|
+
if (this.stopRequested) break;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Run training step
|
|
481
|
+
const { metrics } = await this.trainStep(batch);
|
|
482
|
+
|
|
483
|
+
// Log step metrics (only when gradients are applied to avoid duplicate logs during accumulation)
|
|
484
|
+
if (metrics.gradientsApplied) {
|
|
485
|
+
this.logger.step(
|
|
486
|
+
{
|
|
487
|
+
step: this.currentStep,
|
|
488
|
+
loss: metrics.loss,
|
|
489
|
+
totalTokens: metrics.totalTokens,
|
|
490
|
+
// SFT-specific metrics (no reward/advantage!)
|
|
491
|
+
perplexity: Math.exp(metrics.loss),
|
|
492
|
+
// Token accuracy is not currently tracked in the SFT engine
|
|
493
|
+
// Could be added later if the Rust engine exposes it
|
|
494
|
+
trainingTimeMs: metrics.trainingTimeMs,
|
|
495
|
+
},
|
|
496
|
+
batchIdx,
|
|
497
|
+
stepsPerEpoch,
|
|
498
|
+
);
|
|
499
|
+
|
|
500
|
+
// Save checkpoint periodically
|
|
501
|
+
if (this.config.outputDir && this.currentStep > 0 && this.currentStep % saveInterval === 0) {
|
|
502
|
+
const path = await this.saveCheckpoint();
|
|
503
|
+
if (path) {
|
|
504
|
+
this.logger.checkpoint(path, this.currentStep);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Check for emergency checkpoint
|
|
510
|
+
if (this.config.outputDir && this.engine.needsEmergencySave()) {
|
|
511
|
+
this.logger.warn(`[EMERGENCY] Saving emergency checkpoint at step ${this.currentStep} due to NaN gradients`);
|
|
512
|
+
await this.saveCheckpoint(`emergency-checkpoint-${this.currentStep}`);
|
|
513
|
+
this.engine.clearEmergencySave();
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
batchIdx++;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Flush any remaining accumulated gradients (TRL parity)
|
|
520
|
+
const flushed = this.engine.flushGradients();
|
|
521
|
+
if (flushed) {
|
|
522
|
+
this.currentStep = this.engine.getStep();
|
|
523
|
+
|
|
524
|
+
// Check if flush step aligns with save interval
|
|
525
|
+
if (this.config.outputDir && this.currentStep > 0 && this.currentStep % saveInterval === 0) {
|
|
526
|
+
const path = await this.saveCheckpoint();
|
|
527
|
+
if (path) {
|
|
528
|
+
this.logger.checkpoint(path, this.currentStep);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const epochEndTime = Date.now();
|
|
534
|
+
const epochTimeSecs = (epochEndTime - epochStartTime) / 1000;
|
|
535
|
+
this.engine.endEpoch(epochTimeSecs);
|
|
536
|
+
|
|
537
|
+
this.logger.epochEnd(epoch, numEpochs, epochTimeSecs);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Save final checkpoint
|
|
541
|
+
if (this.config.outputDir && !this.stopRequested) {
|
|
542
|
+
const path = await this.saveCheckpoint('final');
|
|
543
|
+
if (path) {
|
|
544
|
+
this.logger.checkpoint(path, this.currentStep);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Log completion
|
|
549
|
+
this.logger.complete(this.currentStep);
|
|
550
|
+
|
|
551
|
+
// Cleanup
|
|
552
|
+
if (this.stdinInterface) {
|
|
553
|
+
this.stdinInterface.close();
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Save a checkpoint with model weights and training state
|
|
559
|
+
*
|
|
560
|
+
* @param name - Checkpoint name (default: "checkpoint-{step}")
|
|
561
|
+
* @returns Path to saved checkpoint
|
|
562
|
+
*/
|
|
563
|
+
async saveCheckpoint(name?: string): Promise<string> {
|
|
564
|
+
const checkpointName = name ?? `checkpoint-${this.currentStep}`;
|
|
565
|
+
const outputDir = this.config.outputDir ?? './outputs';
|
|
566
|
+
const checkpointPath = join(outputDir, checkpointName);
|
|
567
|
+
|
|
568
|
+
// Create checkpoint directory
|
|
569
|
+
if (!existsSync(checkpointPath)) {
|
|
570
|
+
mkdirSync(checkpointPath, { recursive: true });
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Save training state
|
|
574
|
+
const state: SFTTrainingState = {
|
|
575
|
+
step: this.currentStep,
|
|
576
|
+
epoch: this.currentEpoch,
|
|
577
|
+
timestamp: new Date().toISOString(),
|
|
578
|
+
trainerType: 'sft',
|
|
579
|
+
};
|
|
580
|
+
const statePath = join(checkpointPath, 'training_state.json');
|
|
581
|
+
writeFileSync(statePath, JSON.stringify(state, null, 2));
|
|
582
|
+
|
|
583
|
+
// Save model weights
|
|
584
|
+
await this.model.saveModel(checkpointPath);
|
|
585
|
+
|
|
586
|
+
// Copy tokenizer files
|
|
587
|
+
const tokenizerSource = this.originalModelPath ?? this.config.modelName;
|
|
588
|
+
if (tokenizerSource) {
|
|
589
|
+
const tokenizerFiles = ['tokenizer.json', 'tokenizer_config.json', 'vocab.json', 'merges.txt'];
|
|
590
|
+
for (const file of tokenizerFiles) {
|
|
591
|
+
const srcPath = join(tokenizerSource, file);
|
|
592
|
+
const destPath = join(checkpointPath, file);
|
|
593
|
+
if (existsSync(srcPath) && !existsSync(destPath)) {
|
|
594
|
+
copyFileSync(srcPath, destPath);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
this.logger.info(`Checkpoint saved: ${checkpointPath}`);
|
|
600
|
+
|
|
601
|
+
// Clean up old checkpoints
|
|
602
|
+
const maxCheckpoints = this.config.maxCheckpoints;
|
|
603
|
+
if (maxCheckpoints > 0) {
|
|
604
|
+
this.cleanupOldCheckpoints(outputDir, maxCheckpoints);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
return checkpointPath;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Remove old checkpoints, keeping only the most recent ones
|
|
612
|
+
*/
|
|
613
|
+
private cleanupOldCheckpoints(outputDir: string, maxToKeep: number): void {
|
|
614
|
+
try {
|
|
615
|
+
const entries = readdirSync(outputDir, { withFileTypes: true });
|
|
616
|
+
|
|
617
|
+
const checkpoints: { name: string; step: number }[] = [];
|
|
618
|
+
for (const entry of entries) {
|
|
619
|
+
if (!entry.isDirectory()) continue;
|
|
620
|
+
if (entry.name === 'final' || entry.name.startsWith('emergency-')) continue;
|
|
621
|
+
|
|
622
|
+
const match = entry.name.match(/^checkpoint-(\d+)$/);
|
|
623
|
+
if (match) {
|
|
624
|
+
checkpoints.push({
|
|
625
|
+
name: entry.name,
|
|
626
|
+
step: parseInt(match[1], 10),
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
checkpoints.sort((a, b) => b.step - a.step);
|
|
632
|
+
|
|
633
|
+
if (checkpoints.length > maxToKeep) {
|
|
634
|
+
const toRemove = checkpoints.slice(maxToKeep);
|
|
635
|
+
for (const checkpoint of toRemove) {
|
|
636
|
+
const checkpointPath = join(outputDir, checkpoint.name);
|
|
637
|
+
rmSync(checkpointPath, { recursive: true, force: true });
|
|
638
|
+
this.logger.debug(`Removed old checkpoint: ${checkpoint.name}`);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
} catch (error) {
|
|
642
|
+
this.logger.warn(`Failed to cleanup old checkpoints: ${error as Error}`);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Get current training step
|
|
648
|
+
*/
|
|
649
|
+
get step(): number {
|
|
650
|
+
return this.engine.getStep();
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Get current epoch
|
|
655
|
+
*/
|
|
656
|
+
get epoch(): number {
|
|
657
|
+
return this.engine.getEpoch();
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Get the underlying model for inference
|
|
662
|
+
*/
|
|
663
|
+
getModel(): TrainableModel {
|
|
664
|
+
if (this.model instanceof Qwen35MoeModel) {
|
|
665
|
+
return this.engine.getQwen35MoeModel();
|
|
666
|
+
}
|
|
667
|
+
if (this.model instanceof Qwen35Model) {
|
|
668
|
+
return this.engine.getQwen35Model();
|
|
669
|
+
}
|
|
670
|
+
return this.engine.getModel();
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Get the tokenizer
|
|
675
|
+
*/
|
|
676
|
+
getTokenizer(): Qwen3Tokenizer {
|
|
677
|
+
return this.tokenizer;
|
|
678
|
+
}
|
|
679
|
+
}
|