Skip to content

Commit 6d5aa7a

Browse files
committed
separate processes
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
1 parent 7f144c9 commit 6d5aa7a

9 files changed

Lines changed: 191 additions & 154 deletions

File tree

backend/python/trl/backend.py

Lines changed: 122 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
import argparse
99
import json
1010
import os
11-
import queue
1211
import signal
1312
import sys
1413
import threading
@@ -21,7 +20,7 @@
2120
import backend_pb2_grpc
2221

2322
_ONE_DAY_IN_SECONDS = 60 * 60 * 24
24-
MAX_WORKERS = int(os.environ.get('PYTHON_GRPC_MAX_WORKERS', '1'))
23+
MAX_WORKERS = int(os.environ.get('PYTHON_GRPC_MAX_WORKERS', '2'))
2524

2625

2726
class ProgressCallback:
@@ -38,14 +37,61 @@ def get_callback(self):
3837
parent = self
3938

4039
class _Callback(TrainerCallback):
40+
def __init__(self):
41+
super().__init__()
42+
self._train_start_time = None
43+
self._last_step_event_time = 0
44+
45+
def on_train_begin(self, args, state, control, **kwargs):
46+
self._train_start_time = time.time()
47+
# Emit the total step count as soon as the Trainer has computed it.
48+
# Pre-training events (loading_model, etc.) don't know this value.
49+
total_steps = state.max_steps if state.max_steps > 0 else 0
50+
update = backend_pb2.FineTuneProgressUpdate(
51+
job_id=parent.job_id,
52+
current_step=0,
53+
total_steps=total_steps,
54+
total_epochs=float(parent.total_epochs),
55+
status="training",
56+
message=f"Training started — {total_steps} steps",
57+
)
58+
parent.progress_queue.push(update)
59+
60+
def on_step_end(self, args, state, control, **kwargs):
61+
# Lightweight step-count update so the UI's progress bar stays
62+
# in sync with the tqdm bar even between on_log events.
63+
# Throttle to at most once per second to avoid flooding the queue.
64+
now = time.time()
65+
if now - self._last_step_event_time < 1.0:
66+
return
67+
self._last_step_event_time = now
68+
total_steps = state.max_steps if state.max_steps > 0 else 0
69+
progress = (state.global_step / total_steps * 100) if total_steps > 0 else 0
70+
eta = 0.0
71+
if state.global_step > 0 and total_steps > 0 and self._train_start_time is not None:
72+
elapsed = time.time() - self._train_start_time
73+
remaining_steps = total_steps - state.global_step
74+
eta = remaining_steps * (elapsed / state.global_step)
75+
76+
update = backend_pb2.FineTuneProgressUpdate(
77+
job_id=parent.job_id,
78+
current_step=state.global_step,
79+
total_steps=total_steps,
80+
total_epochs=float(parent.total_epochs),
81+
progress_percent=float(progress),
82+
eta_seconds=float(eta),
83+
status="training",
84+
)
85+
parent.progress_queue.push(update)
86+
4187
def on_log(self, args, state, control, logs=None, **kwargs):
4288
if logs is None:
4389
return
4490
total_steps = state.max_steps if state.max_steps > 0 else 0
4591
progress = (state.global_step / total_steps * 100) if total_steps > 0 else 0
4692
eta = 0.0
47-
if state.global_step > 0 and total_steps > 0:
48-
elapsed = time.time() - state.logging_steps # approximate
93+
if state.global_step > 0 and total_steps > 0 and self._train_start_time is not None:
94+
elapsed = time.time() - self._train_start_time
4995
remaining_steps = total_steps - state.global_step
5096
if state.global_step > 1:
5197
eta = remaining_steps * (elapsed / state.global_step)
@@ -70,39 +116,91 @@ def on_log(self, args, state, control, logs=None, **kwargs):
70116
status="training",
71117
extra_metrics=extra_metrics,
72118
)
73-
parent.progress_queue.put(update)
119+
parent.progress_queue.push(update)
74120

75121
def on_save(self, args, state, control, **kwargs):
122+
total_steps = state.max_steps if state.max_steps > 0 else 0
76123
checkpoint_path = os.path.join(args.output_dir, f"checkpoint-{state.global_step}")
77124
update = backend_pb2.FineTuneProgressUpdate(
78125
job_id=parent.job_id,
79126
current_step=state.global_step,
127+
total_steps=total_steps,
80128
status="saving",
81129
message=f"Checkpoint saved at step {state.global_step}",
82130
checkpoint_path=checkpoint_path,
83131
)
84-
parent.progress_queue.put(update)
132+
parent.progress_queue.push(update)
85133

86134
def on_train_end(self, args, state, control, **kwargs):
87135
update = backend_pb2.FineTuneProgressUpdate(
88136
job_id=parent.job_id,
89137
current_step=state.global_step,
90-
total_steps=state.max_steps,
138+
total_steps=state.max_steps if state.max_steps > 0 else 0,
91139
progress_percent=100.0,
92140
status="completed",
93141
message="Training completed",
94142
)
95-
parent.progress_queue.put(update)
143+
parent.progress_queue.push(update)
96144

97145
return _Callback()
98146

99147

148+
class EventLog:
149+
"""Append-only event log that supports multiple concurrent readers without losing events.
150+
151+
Unlike queue.Queue (where get() is destructive), readers iterate the log
152+
from any position. Reconnecting clients can resume from where they left off,
153+
or start from 0 to replay everything.
154+
"""
155+
156+
def __init__(self):
157+
self._events = []
158+
self._lock = threading.Lock()
159+
self._condition = threading.Condition(threading.Lock())
160+
self._done = False # Set when the sentinel (None) is pushed
161+
162+
def push(self, update):
163+
"""Append an event. If update is None, mark the log as done."""
164+
with self._lock:
165+
self._events.append(update)
166+
if update is None:
167+
self._done = True
168+
with self._condition:
169+
self._condition.notify_all()
170+
171+
def iter_from(self, start=0, timeout=1.0, is_alive=None):
172+
"""Yield events from *start* onward, blocking when caught up.
173+
174+
*is_alive* is an optional callable checked on each timeout.
175+
If it returns False the iterator ends (e.g. pass ``context.is_active``).
176+
Ends iteration when the sentinel (None) is reached.
177+
"""
178+
idx = start
179+
while True:
180+
with self._lock:
181+
if idx < len(self._events):
182+
event = self._events[idx]
183+
idx += 1
184+
if event is None:
185+
return # sentinel — stream complete
186+
yield event
187+
continue
188+
done = self._done
189+
if done:
190+
return
191+
if is_alive is not None and not is_alive():
192+
return
193+
# Wait for new events
194+
with self._condition:
195+
self._condition.wait(timeout=timeout)
196+
197+
100198
class ActiveJob:
101199
"""Represents an active fine-tuning job."""
102200

103201
def __init__(self, job_id):
104202
self.job_id = job_id
105-
self.progress_queue = queue.Queue()
203+
self.progress_queue = EventLog()
106204
self.trainer = None
107205
self.thread = None
108206
self.model = None
@@ -179,9 +277,9 @@ def _run_training(self, request, job):
179277
status="failed",
180278
message=msg,
181279
)
182-
job.progress_queue.put(update)
280+
job.progress_queue.push(update)
183281
# Send sentinel
184-
job.progress_queue.put(None)
282+
job.progress_queue.push(None)
185283

186284
def _do_training(self, request, job):
187285
import torch
@@ -193,7 +291,7 @@ def _do_training(self, request, job):
193291
training_type = request.training_type or "lora"
194292

195293
# Send loading status
196-
job.progress_queue.put(backend_pb2.FineTuneProgressUpdate(
294+
job.progress_queue.push(backend_pb2.FineTuneProgressUpdate(
197295
job_id=job.job_id, status="loading_model", message=f"Loading model {request.model}",
198296
))
199297

@@ -241,7 +339,7 @@ def _do_training(self, request, job):
241339
model = get_peft_model(model, peft_config)
242340

243341
# Load dataset
244-
job.progress_queue.put(backend_pb2.FineTuneProgressUpdate(
342+
job.progress_queue.push(backend_pb2.FineTuneProgressUpdate(
245343
job_id=job.job_id, status="loading_dataset", message="Loading dataset",
246344
))
247345

@@ -534,10 +632,8 @@ def _do_training(self, request, job):
534632

535633
job.trainer = trainer
536634

537-
# Start training
538-
job.progress_queue.put(backend_pb2.FineTuneProgressUpdate(
539-
job_id=job.job_id, status="training", message="Training started",
540-
))
635+
# Note: the "training started" event with total_steps is emitted by
636+
# on_train_begin in ProgressCallback, once the Trainer has computed max_steps.
541637

542638
resume_ckpt = request.resume_from_checkpoint if request.resume_from_checkpoint else None
543639
trainer.train(resume_from_checkpoint=resume_ckpt)
@@ -549,7 +645,7 @@ def _do_training(self, request, job):
549645

550646
job.completed = True
551647
# Sentinel to signal stream end
552-
job.progress_queue.put(None)
648+
job.progress_queue.push(None)
553649

554650
def FineTuneProgress(self, request, context):
555651
if self.active_job is None or self.active_job.job_id != request.job_id:
@@ -558,20 +654,14 @@ def FineTuneProgress(self, request, context):
558654
return
559655

560656
job = self.active_job
561-
while True:
562-
try:
563-
update = job.progress_queue.get(timeout=1.0)
564-
if update is None:
565-
break
566-
yield update
567-
if update.status in ("completed", "failed", "stopped"):
568-
break
569-
except queue.Empty:
570-
if job.completed or job.stopped:
571-
break
572-
if not context.is_active():
573-
break
574-
continue
657+
# Each stream gets its own iterator over the append-only event log.
658+
# Reconnecting clients replay from event 0, so no events are lost.
659+
for update in job.progress_queue.iter_from(
660+
start=0, timeout=1.0, is_alive=context.is_active,
661+
):
662+
yield update
663+
if update.status in ("completed", "failed", "stopped"):
664+
break
575665

576666
def StopFineTune(self, request, context):
577667
# No-op: stopping is handled by killing the backend process from Go.

core/http/react-ui/src/pages/FineTune.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -501,7 +501,7 @@ function TrainingMonitor({ job, onStop }) {
501501
</div>
502502
<div className="card" style={{ padding: 'var(--spacing-sm)', textAlign: 'center' }}>
503503
<div style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)' }}>Step</div>
504-
<div style={{ fontWeight: 'bold' }}>{latest.current_step} / {latest.total_steps}</div>
504+
<div style={{ fontWeight: 'bold' }}>{latest.total_steps > 0 ? `${latest.current_step} / ${latest.total_steps}` : '--'}</div>
505505
</div>
506506
<div className="card" style={{ padding: 'var(--spacing-sm)', textAlign: 'center' }}>
507507
<div style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)' }}>Loss</div>

core/services/backend_monitor.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,6 @@ func (bms BackendMonitorService) CheckAndSample(modelName string) (*proto.Status
110110
return status, nil
111111
}
112112

113-
func (bms BackendMonitorService) ShutdownModel(modelName string, opts ...model.ShutdownOption) error {
114-
return bms.modelLoader.ShutdownModel(modelName, opts...)
113+
func (bms BackendMonitorService) ShutdownModel(modelName string) error {
114+
return bms.modelLoader.ShutdownModel(modelName)
115115
}

core/services/finetune.go

Lines changed: 28 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,6 @@ import (
2222
"gopkg.in/yaml.v3"
2323
)
2424

25-
// fineTuneModelID returns the model loader ID for a fine-tuning job's backend process.
26-
// Each job gets its own backend process so that stopping one job doesn't affect others.
27-
func fineTuneModelID(backend, jobID string) string {
28-
return backend + "-finetune-" + jobID
29-
}
30-
3125
// FineTuneService manages fine-tuning jobs and their lifecycle.
3226
type FineTuneService struct {
3327
appConfig *config.ApplicationConfig
@@ -189,7 +183,7 @@ func (s *FineTuneService) StartJob(ctx context.Context, userID string, req schem
189183
backendModel, err := s.modelLoader.Load(
190184
model.WithBackendString(backendName),
191185
model.WithModel(backendName),
192-
model.WithModelID(fineTuneModelID(backendName, jobID)),
186+
model.WithModelID(backendName+"-finetune"),
193187
)
194188
if err != nil {
195189
return nil, fmt.Errorf("failed to load backend %s: %w", backendName, err)
@@ -271,11 +265,9 @@ func (s *FineTuneService) StopJob(ctx context.Context, userID, jobID string, sav
271265
}
272266
s.mu.Unlock()
273267

274-
// Kill the backend process directly — gRPC stop deadlocks on single-threaded Python backends.
275-
// Use force shutdown to skip the busy-wait loop since the progress stream keeps the
276-
// backend marked as busy for the entire training duration.
277-
modelID := fineTuneModelID(job.Backend, job.ID)
278-
err := s.modelLoader.ShutdownModel(modelID, model.WithForceShutdown())
268+
// Kill the backend process directly — gRPC stop deadlocks on single-threaded Python backends
269+
modelID := job.Backend + "-finetune"
270+
err := s.modelLoader.ShutdownModel(modelID)
279271
if err != nil {
280272
return fmt.Errorf("failed to stop backend: %w", err)
281273
}
@@ -363,16 +355,19 @@ func (s *FineTuneService) StreamProgress(ctx context.Context, userID, jobID stri
363355
}
364356
s.mu.Unlock()
365357

366-
backendModel, err := s.modelLoader.Load(
367-
model.WithBackendString(job.Backend),
368-
model.WithModel(job.Backend),
369-
model.WithModelID(fineTuneModelID(job.Backend, job.ID)),
370-
)
371-
if err != nil {
372-
return fmt.Errorf("failed to load backend: %w", err)
358+
// Use GetModel for a fast, non-blocking lookup instead of Load().
359+
// Load() calls checkIsLoaded which does a gRPC health check. That health check
360+
// would deadlock: it needs opMutex (held by any active FineTuneProgress stream)
361+
// and a free Python gRPC worker (occupied by the streaming call with MAX_WORKERS=2).
362+
// The backend is already running (started in StartJob), so a simple map lookup suffices.
363+
modelID := job.Backend + "-finetune"
364+
m := s.modelLoader.GetModel(modelID)
365+
if m == nil {
366+
return fmt.Errorf("backend %s not loaded (model ID: %s)", job.Backend, modelID)
373367
}
368+
backendClient := m.GRPC(false, nil)
374369

375-
return backendModel.FineTuneProgress(ctx, &pb.FineTuneProgressRequest{
370+
return backendClient.FineTuneProgress(ctx, &pb.FineTuneProgressRequest{
376371
JobId: jobID,
377372
}, func(update *pb.FineTuneProgressUpdate) {
378373
// Update job status and persist
@@ -432,16 +427,15 @@ func (s *FineTuneService) ListCheckpoints(ctx context.Context, userID, jobID str
432427
}
433428
s.mu.Unlock()
434429

435-
backendModel, err := s.modelLoader.Load(
436-
model.WithBackendString(job.Backend),
437-
model.WithModel(job.Backend),
438-
model.WithModelID(fineTuneModelID(job.Backend, job.ID)),
439-
)
440-
if err != nil {
441-
return nil, fmt.Errorf("failed to load backend: %w", err)
430+
// Use GetModel to avoid the health-check deadlock (see StreamProgress comment).
431+
modelID := job.Backend + "-finetune"
432+
m := s.modelLoader.GetModel(modelID)
433+
if m == nil {
434+
return nil, fmt.Errorf("backend %s not loaded (model ID: %s)", job.Backend, modelID)
442435
}
436+
backendClient := m.GRPC(false, nil)
443437

444-
resp, err := backendModel.ListCheckpoints(ctx, &pb.ListCheckpointsRequest{
438+
resp, err := backendClient.ListCheckpoints(ctx, &pb.ListCheckpointsRequest{
445439
OutputDir: job.OutputDir,
446440
})
447441
if err != nil {
@@ -522,15 +516,14 @@ func (s *FineTuneService) ExportModel(ctx context.Context, userID, jobID string,
522516
go func() {
523517
s.setExportMessage(job, "Loading export backend...")
524518

525-
backendModel, err := s.modelLoader.Load(
526-
model.WithBackendString(job.Backend),
527-
model.WithModel(job.Backend),
528-
model.WithModelID(fineTuneModelID(job.Backend, job.ID)),
529-
)
530-
if err != nil {
531-
s.setExportFailed(job, fmt.Sprintf("failed to load backend: %v", err))
519+
// Use GetModel to avoid the health-check deadlock (see StreamProgress comment).
520+
exportModelID := job.Backend + "-finetune"
521+
em := s.modelLoader.GetModel(exportModelID)
522+
if em == nil {
523+
s.setExportFailed(job, fmt.Sprintf("backend %s not loaded (model ID: %s)", job.Backend, exportModelID))
532524
return
533525
}
526+
backendModel := em.GRPC(false, nil)
534527

535528
// Merge job's extra_options (contains hf_token from training) with request's
536529
mergedOpts := make(map[string]string)

0 commit comments

Comments
 (0)