Files
wehub-resource-sync e64161ec32
Release / release_and_publish (push) Waiting to run
CI / ci (3.11) (push) Has been cancelled
CI / ci (3.10) (push) Has been cancelled
CI / dependabot (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:36:15 +08:00

305 lines
12 KiB
Python

"""
Simplified LLM Fine-tuning Configuration Validator
Two-step validation:
1. Parameter filtering - Remove unsupported parameters
2. Micro-batch testing - Runtime validation with small dataset
"""
import json
import re
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional, Set
import yaml
from rdagent.components.coder.finetune.conf import (
FT_DEBUG_YAML_FILE_NAME,
FT_TEST_PARAMS_FILE_NAME,
get_ft_env,
get_workspace_prefix,
)
from rdagent.core.experiment import FBWorkspace
from rdagent.log import rdagent_logger as logger
from rdagent.scenarios.finetune.scen.llama_factory_manager import LLaMAFactory_manager
DIRNAME = Path(__file__).absolute().resolve().parent
# System-managed parameters that are automatically injected during validation.
# These should NOT be checked for alignment in eval prompts.
# Single source of truth: modify here to change injected parameters.
SYSTEM_MANAGED_PARAMS = {
"overwrite_cache": True, # Avoid HF datasets cache lock contention
"save_only_model": True, # Save disk space
# "save_total_limit": 1, # Limit checkpoint count to save disk space
"output_dir": "./output", # Standardize model output location
"per_device_eval_batch_size": 1, # Prevent OOM during evaluation
}
@dataclass
class ValidationResult:
"""Configuration validation result"""
success: bool
filtered_config: str
execution_output: str = "" # Parsed/summarized output for LLM
raw_stdout: str = "" # Full raw stdout for UI display
errors: List[str] = field(default_factory=list)
execution_time: float = 0.0
class LLMConfigValidator:
"""LLM configuration validator with two-step validation:
1. Parameter filtering - Remove unsupported parameters
2. Micro-batch test - Runtime validation with small dataset
The micro-batch test inherently validates completeness, so no separate completeness check is needed.
"""
def __init__(self):
self._supported_params_cache: Optional[Set[str]] = None
def validate_and_test(self, config_yaml: str, workspace: FBWorkspace, env) -> ValidationResult:
"""Three-step validation: parameter filtering + injection + micro-batch testing"""
start_time = time.time()
# Step 1: Parameter filtering
filtered_config, removed_params = self._filter_parameters(config_yaml)
# Step 2: Inject required parameters for multi-task environments
injected_config = self._inject_required_parameters(filtered_config)
# Step 3: Micro-batch testing (validates everything at runtime)
result = self._run_micro_batch_test(injected_config, workspace, env)
result.execution_time = time.time() - start_time
# Add filtered params info to execution_output for agent learning
if removed_params:
filter_info = (
f"\n\n[Filtered Parameters] {len(removed_params)} unsupported params removed: {removed_params}"
)
result.execution_output += filter_info
return result
def _filter_parameters(self, config_yaml: str) -> tuple[str, List[str]]:
"""Filter configuration parameters to only include supported ones.
Returns:
tuple: (filtered_yaml, removed_params_list)
"""
config_dict = yaml.safe_load(config_yaml)
if not isinstance(config_dict, dict):
return config_yaml, []
supported_params = self._get_supported_parameters()
filtered_config = {}
removed_params = []
for k, v in config_dict.items():
if k in supported_params:
filtered_config[k] = v
else:
removed_params.append(k)
if removed_params:
logger.info(f"Filtered out {len(removed_params)} unsupported parameters: {removed_params}")
return yaml.dump(filtered_config, default_flow_style=False, sort_keys=False), removed_params
def _inject_required_parameters(self, config_yaml: str) -> str:
"""Inject required parameters for multi-task environments.
Uses SYSTEM_MANAGED_PARAMS as the single source of truth.
"""
config = yaml.safe_load(config_yaml)
if not isinstance(config, dict):
return config_yaml
config.update(SYSTEM_MANAGED_PARAMS)
logger.info(f"Injected required parameters: {SYSTEM_MANAGED_PARAMS}")
return yaml.dump(config, default_flow_style=False, sort_keys=False)
def _get_supported_parameters(self) -> Set[str]:
"""Get supported parameters from LlamaFactory Manager"""
if self._supported_params_cache is not None:
return self._supported_params_cache
all_params = LLaMAFactory_manager.get_parameters()
# Extract all parameter names from all parameter types (including nested structures)
supported_params = set()
for param_type, params_dict in all_params.items():
if isinstance(params_dict, dict):
# Recursively extract parameter names from nested dictionaries
for key, value in params_dict.items():
if isinstance(value, dict) and "name" in value:
# This is a parameter definition with metadata
supported_params.add(key)
elif isinstance(value, dict):
# This is a nested category (e.g., BaseModelArguments, LoraArguments)
# Extract parameter names from the nested structure
for nested_key, nested_value in value.items():
if isinstance(nested_value, dict) and "name" in nested_value:
supported_params.add(nested_key)
if not supported_params:
raise RuntimeError("No parameters found in LlamaFactory Manager")
logger.info(f"Loaded {len(supported_params)} parameters from LlamaFactory Manager")
self._supported_params_cache = supported_params
return supported_params
def _parse_execution_log(self, stdout: str, exit_code: int, failed_stage: str = None) -> str:
"""Parse execution log and extract key information for LLM evaluation.
Reduces log from ~36k tokens to ~500 tokens by extracting only:
- Status and exit code
- Error messages (if any)
- Training metrics (if successful)
- Warnings (limited)
- Timeout and stage information (if applicable)
Args:
stdout: The execution output
exit_code: The process exit code
failed_stage: Which stage failed - "data_processing" or "training"
"""
result = {
"status": "success" if exit_code == 0 else "failed",
"exit_code": exit_code,
}
# Handle timeout (exit_code 124)
if exit_code == 124:
result["timeout"] = True
if failed_stage:
result["failed_stage"] = failed_stage
# 1. Extract error information (highest priority)
# Strategy: extract rank0's error block (each line prefixed with [rank0]:)
error_text = None
# Method A: Extract [rank0]: prefixed lines and reconstruct traceback
rank0_lines = re.findall(r"\[rank0\]:[^\n]+", stdout)
if rank0_lines:
rank0_block = "\n".join(line.replace("[rank0]: ", "").replace("[rank0]:", "") for line in rank0_lines)
# Find traceback in rank0 block
tb_match = re.search(
r"Traceback \(most recent call last\):.*?(?:Error|Exception):[^\n]+", rank0_block, re.DOTALL
)
if tb_match:
error_text = tb_match.group(0)
# Method B: Fallback to generic traceback (no rank prefix)
# Use findall to get ALL tracebacks, then keep the first one (root cause)
if not error_text:
all_tracebacks = re.findall(
r"Traceback \(most recent call last\):.*?(?:Error|Exception):[^\n]+", stdout, re.DOTALL
)
if all_tracebacks:
# First traceback is usually the root cause
error_text = all_tracebacks[0]
if len(all_tracebacks) > 1:
error_text += f"\n\n[Note: {len(all_tracebacks)} total errors, showing root cause]"
if error_text:
# Limit length but keep from the END (actual error type/message is at the end of traceback)
result["error"] = error_text[-4000:] if len(error_text) > 4000 else error_text
# 2. Extract training information
if "Running training" in stdout:
result["training_started"] = True
# Extract training config
# NOTE: we may have log like "Num examples = 1,000,000" and "Num Epochs = 1,000"; So we need to handle ","
num_examples = re.search(r"Num examples\s*=\s*([\d,]+)", stdout)
num_epochs = re.search(r"Num Epochs\s*=\s*([\d,]+)", stdout)
if num_examples:
result["num_examples"] = int(num_examples.group(1).replace(",", ""))
if num_epochs:
result["num_epochs"] = int(num_epochs.group(1).replace(",", ""))
# Extract final metrics (JSON format from trainer output)
final_metrics = re.search(r"\{'train_runtime':[^}]+\}", stdout)
if final_metrics:
try:
metrics = eval(final_metrics.group(0)) # Safe: only numbers and strings
result["final_metrics"] = {
"train_loss": metrics.get("train_loss"),
"train_runtime": metrics.get("train_runtime"),
"train_samples_per_second": metrics.get("train_samples_per_second"),
}
except Exception:
pass
# Check completion
if "Training completed" in stdout:
result["completed"] = True
# 3. Extract warnings (limit to 20)
warnings = re.findall(r"\[WARNING[^\]]*\][^\n]+", stdout)
if warnings:
result["warnings"] = list(set(warnings))[:20]
# 4. Fallback: if parsing failed, include truncated raw log
if not result.get("error") and not result.get("training_started"):
result["raw_log_tail"] = stdout[-2000:] if len(stdout) > 2000 else stdout
return json.dumps(result, indent=2, ensure_ascii=False)
def _run_micro_batch_test(self, config_yaml: str, workspace: FBWorkspace, env) -> ValidationResult:
"""Run micro-batch training test for runtime validation"""
result = ValidationResult(success=True, filtered_config=config_yaml)
ws_prefix = get_workspace_prefix(env)
# Create micro-batch test configuration
config = yaml.safe_load(config_yaml)
if not isinstance(config, dict):
result.success = False
result.execution_output = "Invalid YAML configuration"
result.errors.append("Invalid configuration for micro-batch test")
return result
test_config = config.copy()
# Load extra test parameters from workspace (generated by coder in 2nd turn)
extra_test_params = yaml.safe_load(workspace.file_dict[FT_TEST_PARAMS_FILE_NAME])
# Merge extra test parameters (overrides previous settings)
if extra_test_params:
test_config.update(extra_test_params)
# Run micro-batch training
workspace.inject_files(**{FT_DEBUG_YAML_FILE_NAME: yaml.dump(test_config, default_flow_style=False)})
training_result = workspace.run(
env=env,
entry=f"llamafactory-cli train {FT_DEBUG_YAML_FILE_NAME}",
)
# Remove micro-batch test files
workspace.remove_files([FT_DEBUG_YAML_FILE_NAME, FT_TEST_PARAMS_FILE_NAME])
# Parse and store structured execution output (reduces ~36k tokens to ~500)
raw_stdout = training_result.stdout if training_result.stdout else ""
result.raw_stdout = raw_stdout # Keep full log for UI
result.execution_output = self._parse_execution_log(raw_stdout, training_result.exit_code)
# Check results
progress_indicators = ["train_loss", "Training:", "Epoch", "loss:", "step"]
has_progress = any(ind.lower() in training_result.stdout.lower() for ind in progress_indicators)
if training_result.exit_code == 0 and has_progress:
logger.info("Micro-batch test passed")
result.success = True
else:
result.success = False
result.errors.append(f"Micro-batch test failed (exit_code={training_result.exit_code})")
return result