Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 37 additions & 5 deletions interface/screens/fine_tuning_screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,9 +150,36 @@ def apply_recommended_fine_tune_settings(self) -> None:
synced = self._sync_architecture_from_fine_tune_base()
self._set_combo_text(self.peft_method, "LoRA adapters")
self.lora_dropout.setValue(0.05)
self._set_combo_text(self.lora_targets, "Attention projections")
self._set_combo_text(self.lora_targets, "Attention + MLP")
self.max_grad_norm.setValue(0.5)
self.weight_decay.setValue(0.05)
# Dynamically determine recommended batch size and gradient accumulation from detected hardware
rec_batch = 16
if torch.cuda.is_available():
try:
_, total_bytes = torch.cuda.mem_get_info()
total_gb = total_bytes / (1024 ** 3)
if total_gb >= 22.0:
rec_batch = 32
elif total_gb >= 10.0:
rec_batch = 16
elif total_gb >= 6.0:
rec_batch = 8
else:
rec_batch = 4
except Exception:
rec_batch = 16
else:
rec_batch = 4
if hasattr(self, "batch_size"):
self.batch_size.setValue(min(self.batch_size.maximum(), rec_batch))
if hasattr(self, "gradient_accumulation"):
target_eff = 32
self.gradient_accumulation.setValue(max(1, (target_eff + rec_batch - 1) // rec_batch))
if hasattr(self, "precision"):
bf16_supported = torch.cuda.is_available() and getattr(torch.cuda, "is_bf16_supported", lambda: False)()
rec_precision = "BF16" if bf16_supported else ("FP16" if torch.cuda.is_available() else "FP32")
self._set_combo_text(self.precision, rec_precision)
self._set_combo_by_data(self.scheduler_name, "cosine", {
"warmup_linear": "Warmup linear",
"cosine": "Cosine decay",
Expand All @@ -161,30 +188,35 @@ def apply_recommended_fine_tune_settings(self) -> None:
"constant": "Constant",
})
if stage == "conversation":
self._set_combo_text(self.lora_targets, "Attention + MLP")
self.lora_rank.setValue(16)
self.lora_alpha.setValue(32.0)
self.learning_rate.setValue(0.00003)
self.epochs.setValue(max(1, min(self.epochs.value(), 2)))
elif stage == "tool_call":
self._set_combo_text(self.lora_targets, "Attention + MLP")
self.lora_rank.setValue(16)
self.lora_alpha.setValue(32.0)
self.learning_rate.setValue(0.00003)
self.epochs.setValue(max(1, min(self.epochs.value(), 3)))
elif stage == "code":
self._set_combo_text(self.lora_targets, "Attention projections")
self.lora_rank.setValue(8)
self.lora_alpha.setValue(16.0)
self.lora_dropout.setValue(0.05)
self.learning_rate.setValue(0.00005)
self.max_grad_norm.setValue(0.5)
self.epochs.setValue(max(1, min(self.epochs.value(), 3)))
elif stage == "instruction":
self.lora_rank.setValue(8)
self.lora_alpha.setValue(16.0)
self._set_combo_text(self.lora_targets, "Attention + MLP")
self.lora_rank.setValue(16)
self.lora_alpha.setValue(32.0)
self.learning_rate.setValue(0.00005)
self.epochs.setValue(max(1, min(self.epochs.value(), 3)))
else:
self.lora_rank.setValue(8)
self.lora_alpha.setValue(16.0)
self._set_combo_text(self.lora_targets, "Attention + MLP")
self.lora_rank.setValue(16)
self.lora_alpha.setValue(32.0)
self.learning_rate.setValue(0.00005)
self._update_training_mode_controls()
message = "Recommended LoRA settings applied."
Expand Down
22 changes: 18 additions & 4 deletions interface/screens/training_estimation.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,10 +122,24 @@ def _update_architecture_advisor(
notes.append("Grouped/multi-query attention reduces KV memory and is useful for longer contexts.")
if model_config.mlp_type == "swiglu" and model_config.norm_type == "rmsnorm":
notes.append("Llama-like blocks improve modern compatibility but must match checkpoints when resuming.")
if training_config is not None and training_config.device == "cuda" and vram_bytes > 3.5 * 1024**3:
notes.append("Estimated VRAM is high for 4 GB GPUs. Try lower batch, context, embedding, or layers.")
if label == "Advisor: balanced":
label = "Advisor: memory check"
if training_config is not None and training_config.device.startswith("cuda") and vram_bytes > 0:
if torch.cuda.is_available():
try:
free_bytes, total_bytes = torch.cuda.mem_get_info()
if vram_bytes > free_bytes * 0.85:
notes.append(
f"Estimated VRAM ({vram_bytes / 1024**3:.1f} GB) is close to free GPU memory "
f"({free_bytes / 1024**3:.1f} GB of {total_bytes / 1024**3:.1f} GB). "
"Consider reducing micro-batch size or enabling activation checkpointing."
)
if label == "Advisor: balanced":
label = "Advisor: memory check"
except Exception:
pass
elif vram_bytes > 3.5 * 1024**3:
notes.append("Estimated VRAM is high. Try lower batch, context, embedding, or layers.")
if label == "Advisor: balanced":
label = "Advisor: memory check"
self.architecture_advisor_metric.setText(label)
self._tip(self.architecture_advisor_metric, "\n".join(notes))

Expand Down
43 changes: 36 additions & 7 deletions interface/screens/training_screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,19 @@ def _configure_device_options(self) -> None:

self.device.clear()
if torch.cuda.is_available():
device_name = torch.cuda.get_device_name(0)
device_count = torch.cuda.device_count()
self.device.addItem("cuda")
if device_count > 1:
for idx in range(device_count):
self.device.addItem(f"cuda:{idx}")
self.device.addItem("cpu")
self.device_info.setText(f"CUDA ready: {device_name}")
device_name = torch.cuda.get_device_name(0)
try:
free_bytes, total_bytes = torch.cuda.mem_get_info(0)
vram_gb = total_bytes / (1024 ** 3)
self.device_info.setText(f"CUDA ready: {device_name} ({vram_gb:.1f} GB VRAM)")
except Exception:
self.device_info.setText(f"CUDA ready: {device_name}")
self.use_amp_default = True
else:
self.device.addItem("cpu")
Expand Down Expand Up @@ -220,7 +229,24 @@ def _apply_profile_runtime_defaults(self, profile: str) -> None:
batch_size = 32
else:
batch_size = 16
batch_size = min(self.batch_size.maximum(), batch_size)

# Scale micro-batch dynamically based on detected hardware capacity
if torch.cuda.is_available():
try:
_, total_bytes = torch.cuda.mem_get_info()
vram_total_gb = total_bytes / (1024 ** 3)
if vram_total_gb >= 22.0:
batch_size = min(batch_size * 2, 64)
elif vram_total_gb < 6.0:
batch_size = min(batch_size, 4)
elif vram_total_gb < 8.0:
batch_size = min(batch_size, 8)
except Exception:
pass
else:
batch_size = min(batch_size, 4)

batch_size = min(self.batch_size.maximum(), max(1, batch_size))
self.batch_size.setValue(batch_size)
accumulation = max(1, (target_effective_batch + batch_size - 1) // batch_size)
self.gradient_accumulation.setValue(min(self.gradient_accumulation.maximum(), accumulation))
Expand Down Expand Up @@ -262,6 +288,9 @@ def apply_training_profile(self) -> None:
"""

profile = self.training_profile.currentText()
bf16_supported = torch.cuda.is_available() and getattr(torch.cuda, "is_bf16_supported", lambda: False)()
rec_precision = "BF16" if bf16_supported else ("FP16" if torch.cuda.is_available() else "FP32")

if profile == "Low-memory":
self._set_combo_text(self.optimizer_name, "Adafactor")
self._set_combo_text(self.scheduler_name, "Cosine decay")
Expand All @@ -270,7 +299,7 @@ def apply_training_profile(self) -> None:
self.min_lr_ratio.setValue(0.05)
self.polynomial_power.setValue(1.0)
self.max_grad_norm.setValue(1.0)
self._set_combo_text(self.precision, "BF16" if torch.cuda.is_available() else "FP32")
self._set_combo_text(self.precision, rec_precision)
self.use_amp.setChecked(True)
self._set_combo_text(self.attention_type, "Grouped-query")
self.kv_head_count.setValue(max(1, self.n_head.value() // 2))
Expand All @@ -293,7 +322,7 @@ def apply_training_profile(self) -> None:
self.min_lr_ratio.setValue(0.1)
self.polynomial_power.setValue(1.0)
self.max_grad_norm.setValue(0.5)
self._set_combo_text(self.precision, "FP16")
self._set_combo_text(self.precision, rec_precision)
self.use_amp.setChecked(True)
self.activation_checkpointing.setChecked(False)
self.batch_size.setValue(16)
Expand All @@ -320,7 +349,7 @@ def apply_training_profile(self) -> None:
self.max_grad_norm.setValue(1.0)
# Lion is reported to be more sensitive to fp16 under/overflow
# than AdamW; prefer bf16 where available, fp32 otherwise.
self._set_combo_text(self.precision, "BF16" if torch.cuda.is_available() else "FP32")
self._set_combo_text(self.precision, rec_precision)
self.use_amp.setChecked(True)
self.activation_checkpointing.setChecked(False)
self.batch_size.setValue(16)
Expand All @@ -337,7 +366,7 @@ def apply_training_profile(self) -> None:
self.min_lr_ratio.setValue(0.1)
self.polynomial_power.setValue(1.0)
self.max_grad_norm.setValue(1.0)
self._set_combo_text(self.precision, "FP16")
self._set_combo_text(self.precision, rec_precision)
self.use_amp.setChecked(True)
self.activation_checkpointing.setChecked(False)
self.batch_size.setValue(16)
Expand Down
126 changes: 126 additions & 0 deletions tests/test_conversation_finetune.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
"""Unit tests for conversation fine-tuning and multi-turn chat handling."""

import tempfile
import unittest
from pathlib import Path

import numpy as np
import torch
from tokenizers import Tokenizer
from tokenizers.models import BPE
from tokenizers.trainers import BpeTrainer
from tokenizers.pre_tokenizers import ByteLevel

from engine.config import ModelConfig
from engine.data_core import _extract_structured_text
from engine.microgpt_chat import STOP_SEQUENCES, MicroGPTChatSession
from engine.target_masking import (
IGNORE_INDEX,
InstructionDataset,
find_completion_spans,
mask_prompt_targets,
)


class TestConversationFineTune(unittest.TestCase):
def setUp(self) -> None:
self.tokenizer = Tokenizer(BPE(unk_token="<unk>"))
self.tokenizer.pre_tokenizer = ByteLevel()
trainer = BpeTrainer(special_tokens=["<unk>", "<|endoftext|>", "<pad>", "<bos>", "<eos>"], vocab_size=500)
sample_corpus = [
"System: You are a conversational assistant.\n"
"User: Hi\nAssistant: Hello! How can I help?\n"
"User: Tell me more.\nAssistant: Certainly, here is more detail.\n<eos>\n",
]
self.tokenizer.train_from_iterator(sample_corpus, trainer)

def test_multi_turn_spans_detection(self) -> None:
record = {
"messages": [
{"role": "system", "content": "Be concise."},
{"role": "user", "content": "Question 1"},
{"role": "assistant", "content": "Answer 1"},
{"role": "user", "content": "Question 2"},
{"role": "assistant", "content": "Answer 2"},
]
}
text = _extract_structured_text(record, "conversation")
self.assertIn("System: Be concise.", text)
self.assertIn("User: Question 1", text)
self.assertIn("Assistant: Answer 1", text)

spans = find_completion_spans(text)
self.assertEqual(len(spans), 2)
span1 = text[spans[0][0]:spans[0][1]]
span2 = text[spans[1][0]:spans[1][1]]
self.assertEqual(span1.strip(), "Answer 1")
self.assertEqual(span2.strip(), "Answer 2")

def test_multi_turn_target_masking(self) -> None:
text = (
"System: Sys\n"
"User: Q1\n"
"Assistant: A1\n"
"User: Q2\n"
"Assistant: A2\n"
"<eos>"
)
enc = self.tokenizer.encode(text)
targets = mask_prompt_targets(text, self.tokenizer, enc.ids, enc.offsets)
self.assertEqual(len(targets), len(enc.ids))

# First tokens (System: Sys) must be -100
self.assertEqual(targets[0], IGNORE_INDEX)

# Unmasked tokens must exist for both A1 and A2
unmasked = [tid for tid in targets if tid != IGNORE_INDEX]
self.assertGreater(len(unmasked), 0)

# Trailing <eos> must be unmasked
self.assertEqual(targets[-1], enc.ids[-1])

def test_instruction_dataset_keeps_multi_turn_intact(self) -> None:
# Single 5-turn conversation ending in EOS=4
tokens = np.array([10, 11, 12, 13, 14, 15, 16, 17, 4], dtype=np.int64)
targets = np.array([-100, -100, 12, -100, -100, 15, 16, 17, 4], dtype=np.int64)
ds = InstructionDataset(tokens, targets=targets, context_length=32, eos_token_id=4)
# Should be exactly 1 discrete sample containing all turns
self.assertEqual(len(ds), 1)
x, y = ds[0]
self.assertEqual(len(x), len(tokens) - 1)
self.assertEqual(len(y), len(targets) - 1)

def test_microgpt_chat_stop_sequences_contain_user(self) -> None:
self.assertIn("\nUser:", STOP_SEQUENCES)
self.assertIn("\nSystem:", STOP_SEQUENCES)
self.assertIn("<eos>", STOP_SEQUENCES)

def test_microgpt_chat_render_prompt_pruning(self) -> None:
session = MicroGPTChatSession.__new__(MicroGPTChatSession)
session.config = ModelConfig(vocab_size=500, context_length=64)
session.tokenizer = self.tokenizer
session._messages = [
{"role": "user", "content": "Very old question that should be dropped"},
{"role": "assistant", "content": "Very old answer that should be dropped"},
{"role": "user", "content": "Recent question"},
{"role": "assistant", "content": "Recent answer"},
]
prompt = "Current question"
system_prompt = "Permanent System Instruction"

rendered = session._render_prompt(
prompt, system_prompt, reasoning_effort="None", thinking_enabled=False, max_tokens=16
)

# Permanent system instruction and latest turn MUST be preserved
self.assertIn("System: Permanent System Instruction", rendered)
self.assertIn("User: Current question", rendered)
self.assertIn("Assistant:", rendered)

# Token length must fit inside context_length - 16
enc = self.tokenizer.encode(rendered)
self.assertLessEqual(len(enc.ids), session.config.context_length - 16)


if __name__ == "__main__":
unittest.main()
Loading
Loading