@mlx-node/trl 0.0.2 → 0.0.4

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