Skip to content

65. QLoRA Selection Project | QLoRA 选型项目 ​

难度: Hard | 环境: CPU + 单卡 GPU | 标签: 量化压缩, QLoRA, 选型 | 目标人群: 项目决策练习者

🚀 云端运行环境

本章节的实战代码可以点击以下链接在免费 GPU 算力平台上直接运行:

Open In ColabOpen In Studio (国内推荐:魔搭社区免费实例)


本节导读 ​

当显存有限、又不能明显牺牲验证质量时,NF4 QLoRA 能否替代 BF16 LoRA?本节固定数据准入结果、模型提交、adapter 与有效 batch,只改变冻结底座表示;再用显存、训练速度、独立验证与 adapter 产物判断低比特路径是否值得采用。

CPU 题目区先验证身份、预算与证据是否足以比较;GPU 实验再生成可复测的 QLoRA 选型记录。输出包括固定 workload、两条训练路径的测量、低比特生效状态、adapter 哈希与 accept / tune / reject 决策。

关键词: QLoRA、NF4、冻结底座、有效 batch、训练产物


前置阅读 ​

导语: 进入本项目前,先能说明 LoRA、有效 batch 和低比特权重如何组合,再在预算约束下判断是否采用 QLoRA。


Step 1:固定任务与预算,明确要比较什么 ​

输入是 64 节的数据准入 manifest、固定模型提交和同一 LoRA adapter。问题不是“QLoRA 是否更省显存”这一孤立事实,而是它能否在给定显存上限内保留可接受的验证质量,并生成可定位的 adapter。

输入或约束固定内容产生的判断
数据准入样本 ID、训练/验证切分与 template两条路径是否学习同一任务
更新语义rank、alpha、target modules、学习率与有效 batch差异能否归因于冻结底座表示
资源预算峰值显存上限、最小可行 micro-batch候选能否进入真实训练
交付要求独立验证、低比特生效、adapter 与哈希结果能否被后续实验复测

QLoRA 的预算选型

Step 2:保持更新语义,只改变冻结底座表示 ​

BF16 LoRA 与 NF4 QLoRA 使用同一模型提交、相同数据切分、相同随机种子组、相同 LoRA 配置和有效 batch。只有加载底座的表示与量化元数据不同;否则显存或质量差异无法解释。

比较对象必须固定唯一变化观察字段
模型与数据revision、tokenizer、数据 manifest、种子组无独立验证损失与样本身份
训练路径rank、alpha、target modules、学习率、有效 batch无可训练参数与更新次数
冻结底座相同模型权重BF16 / NF4 + double quant峰值、保留显存与低比特生效状态
容量测量seq_len、步数、OOM 处理micro-batch 阶梯最大可行 micro-batch

Step 3:从资源账本走到可交付训练证据 ​

账本只能估计容量;真实训练还必须确认 NF4 是否加载、生效的训练路径、峰值显存、独立验证、失败运行和 adapter 哈希。只有这些证据处于同一 workload,才允许给出低资源选型结论。

证据最少记录可以作出的判断
CPU 账本冻结底座、adapter、梯度、优化器、激活与量化元数据候选是否值得进入 GPU 对照
GPU 配对重复峰值、步时、吞吐、独立验证、失败运行固定 workload 下的资源与质量差异
容量阶梯每档 micro-batch 的成功或 OOM低比特路径是否扩大可行规模
artifact模型提交、LoRA 配置、adapter 哈希、结果 JSON候选能否被定位与复测

训练账本与 QLoRA 证据链

Step 4(CPU 代码练习):核对比较身份、证据与交付动作 ​

题目区不模拟训练速度,也不为候选编造总分。它依次检查两条路径是否属于同一 workload、QLoRA 证据是否达到可比较条件,以及结果应进入交付、调参还是拒绝。

类型函数学习者需要完成的内容测试关注点
辅助函数validate_qlora_candidate / validate_budget_and_quality检查字段、数值与预算缺失、非法和非有限值
TODO 1same_workload_identity比较模型、数据、adapter 与有效 batch 身份单个字段漂移必须阻断比较
TODO 2candidate_passes_gates将真实证据、低比特生效、质量和容量合成硬门槛估算或单次 smoke 不能进入交付
TODO 3choose_qlora_action从两条可比较路径的证据决定交付动作accept / tune / reject 与失败原因
python
from typing import Dict, List
python
# 题目区只挖空三项选型机制:门槛判断、候选排序与策略决策。
# 输入校验、失败原因和报告字段由骨架提供;每个 TODO 都能由对应测试独立验证。

import math

def build_memory_ledger(base_weight_mb: float, trainable_param_mb: float, gradient_mb: float, optimizer_state_mb: float, activation_mb: float, quant_metadata_mb: float = 0.0, peak_memory_mb: float = None, peak_reserved_mb: float = None, evidence: str = 'estimated') -> Dict[str, object]:
    """汇总训练显存对象,并区分账本估算与 CUDA 峰值。"""
    values = {
        'base_weight_mb': base_weight_mb, 'trainable_param_mb': trainable_param_mb,
        'gradient_mb': gradient_mb, 'optimizer_state_mb': optimizer_state_mb,
        'activation_mb': activation_mb, 'quant_metadata_mb': quant_metadata_mb,
    }
    if any(float(value) < 0 for value in values.values()):
        raise ValueError('显存账本中的各项不能为负数。')
    estimated_total_mb = sum(float(value) for value in values.values())
    report = {**values, 'estimated_total_mb': round(estimated_total_mb, 3), 'evidence': evidence}
    if peak_memory_mb is not None:
        report['peak_memory_mb'] = float(peak_memory_mb)
        report['reconciliation_gap_mb'] = round(float(peak_memory_mb) - estimated_total_mb, 3)
    if peak_reserved_mb is not None:
        report['peak_reserved_mb'] = float(peak_reserved_mb)
    return report

def validate_qlora_candidate(candidate: Dict[str, object]) -> List[str]:
    """检查候选是否具备进入预算筛选的完整测量与证据字段。"""
    errors = []
    required = ('name', 'strategy', 'quantization', 'evidence', 'memory_mb', 'max_micro_batch', 'tokens_per_s', 'val_loss')
    for key in required:
        if key not in candidate:
            errors.append(f'missing:{key}')
    if errors:
        return errors
    for key in ('memory_mb', 'max_micro_batch', 'tokens_per_s', 'val_loss'):
        try:
            value = float(candidate[key])
        except (TypeError, ValueError):
            errors.append(f'non_numeric:{key}')
            continue
        if not math.isfinite(value):
            errors.append(f'non_finite:{key}')
        if key in ('memory_mb', 'max_micro_batch', 'tokens_per_s') and value < 0:
            errors.append(f'negative:{key}')
    for key in ('strategy', 'quantization', 'evidence'):
        if not str(candidate[key]).strip():
            errors.append(f'empty:{key}')
    return errors

def validate_budget_and_quality(budget: Dict[str, float], quality_floor: Dict[str, float]) -> Dict[str, object]:
    """检查显存上限、吞吐下限和验证损失上限是否完整。"""
    required_budget_keys = ['memory_cap_mb', 'min_micro_batch', 'min_tokens_per_s']
    required_quality_keys = ['max_val_loss']
    missing_keys = [key for key in required_budget_keys if key not in budget]
    missing_keys += [key for key in required_quality_keys if key not in quality_floor]
    for key in required_budget_keys:
        if key in budget:
            try:
                value = float(budget[key])
            except (TypeError, ValueError):
                missing_keys.append(f'non_numeric:{key}')
                continue
            if not math.isfinite(value) or value <= 0:
                missing_keys.append(f'invalid:{key}')
    if 'max_val_loss' in quality_floor:
        try:
            value = float(quality_floor['max_val_loss'])
        except (TypeError, ValueError):
            missing_keys.append('non_numeric:max_val_loss')
        else:
            if not math.isfinite(value) or value < 0:
                missing_keys.append('invalid:max_val_loss')
    return {'is_valid': len(missing_keys) == 0, 'missing_keys': missing_keys}

def _candidate_failure_reasons(candidate: Dict[str, object], budget: Dict[str, float], quality_floor: Dict[str, float]) -> List[str]:
    """给出候选未通过的门槛;报告字段由骨架统一生成。"""
    reasons = []
    if candidate['memory_mb'] > budget['memory_cap_mb']:
        reasons.append('memory')
    if candidate['max_micro_batch'] < budget['min_micro_batch']:
        reasons.append('scale')
    if candidate['tokens_per_s'] < budget['min_tokens_per_s']:
        reasons.append('throughput')
    if candidate['val_loss'] > quality_floor['max_val_loss']:
        reasons.append('quality')
    return reasons


def same_workload_identity(baseline: Dict[str, object], candidate: Dict[str, object]) -> bool:
    """确认两条训练路径只在冻结底座表示上不同。"""
    baseline_identity = baseline.get('identity', {})
    candidate_identity = candidate.get('identity', {})
    required = ('model_commit', 'data_manifest_hash', 'adapter_signature', 'effective_batch', 'max_length', 'update_steps', 'seed_set')
    # TODO 1(比较身份):每个必需字段都存在且完全一致,才允许比较两条训练路径。
    # 变量提示:baseline_identity、candidate_identity、required。
    raise NotImplementedError('TODO 1:请验证两条路径的 workload identity')


def candidate_passes_gates(candidate: Dict[str, object], budget: Dict[str, float], quality_floor: Dict[str, float]) -> bool:
    """判断候选是否达到进入 GPU 对照的资源与质量门槛。"""
    memory_ok = candidate['memory_mb'] <= budget['memory_cap_mb']
    scale_ok = candidate['max_micro_batch'] >= budget['min_micro_batch']
    throughput_ok = candidate['tokens_per_s'] >= budget['min_tokens_per_s']
    quality_ok = candidate['val_loss'] <= quality_floor['max_val_loss']
    # TODO 2(候选准入):只有资源、规模、速度和质量同时通过,候选才可进入 GPU 对照。
    # 变量提示:memory_ok、scale_ok、throughput_ok、quality_ok。
    raise NotImplementedError('TODO 2:请判断候选是否通过四项门槛')


def candidate_rank_key(candidate: Dict[str, object]) -> tuple[float, float, float, float]:
    """定义可行候选的比较顺序:更省显存,再更大可行规模、更高吞吐和更低损失。"""
    return (candidate['memory_mb'], -candidate['max_micro_batch'], -candidate['tokens_per_s'], candidate['val_loss'])


def choose_qlora_action(summary: Dict[str, object]) -> str:
    """根据身份、实测证据和 QLoRA 候选状态选择交付动作。"""
    qlora_evaluation = summary.get('qlora_evaluation')
    # TODO 3(交付决策):没有可行候选 -> reject;身份不一致或不是配对 GPU 实测 -> tune;
    # QLoRA 可行、低比特已生效且资源最优 -> accept;其余 -> tune。
    # 变量提示:summary['feasible_count']、summary['identity_match']、summary['best_strategy']、qlora_evaluation。
    raise NotImplementedError('TODO 3:请完成 QLoRA 策略决策')


def evaluate_qlora_candidate(candidate: Dict[str, object], budget: Dict[str, float], quality_floor: Dict[str, float]) -> Dict[str, object]:
    """调用门槛机制,并由骨架生成候选评估记录。"""
    errors = validate_qlora_candidate(candidate)
    if errors:
        raise ValueError(f'非法 QLoRA 候选 {candidate.get("name", "unknown")}: {errors}')
    quality_ok = candidate['val_loss'] <= quality_floor['max_val_loss']
    return {
        'name': candidate['name'],
        'strategy': candidate['strategy'],
        'max_micro_batch': candidate['max_micro_batch'],
        'feasible': candidate_passes_gates(candidate, budget, quality_floor),
        'failure_reasons': _candidate_failure_reasons(candidate, budget, quality_floor),
        'quality_failed': not quality_ok,
    }


def summarize_low_resource_candidates(candidates: List[Dict[str, object]], budget: Dict[str, float], quality_floor: Dict[str, float]) -> Dict[str, object]:
    """汇总低比特训练候选并保留 QLoRA 对照结果;报告字段由骨架提供。"""
    evaluations = [evaluate_qlora_candidate(candidate, budget, quality_floor) for candidate in candidates]
    feasible = [candidate for candidate, evaluation in zip(candidates, evaluations) if evaluation['feasible']]
    feasible.sort(key=candidate_rank_key)
    rejected = [
        {'name': evaluation['name'], 'reasons': evaluation['failure_reasons']}
        for evaluation in evaluations if not evaluation['feasible']
    ]
    qlora_evaluation = next((item for item in evaluations if item['strategy'] == 'qlora'), None)
    baseline = next((item for item in candidates if item['strategy'] == 'lora'), None)
    qlora_candidate = next((item for item in candidates if item['strategy'] == 'qlora'), None)
    return {
        'candidate_count': len(candidates),
        'feasible_count': len(feasible),
        'best_candidate': feasible[0]['name'] if feasible else None,
        'best_strategy': feasible[0]['strategy'] if feasible else None,
        'quality_failed_count': sum(item['quality_failed'] for item in evaluations),
        'feasible_names': [item['name'] for item in feasible],
        'rejected': rejected,
        'evaluations': evaluations,
        'qlora_evaluation': qlora_evaluation,
        'identity_match': bool(baseline and qlora_candidate and same_workload_identity(baseline, qlora_candidate)),
        'qlora_evidence_level': qlora_candidate.get('evidence') if qlora_candidate else None,
        'qlora_low_bit_active': bool(qlora_candidate and qlora_candidate.get('low_bit_active')),
    }


def decide_qlora_project(summary: Dict[str, object]) -> Dict[str, object]:
    """将策略机制转换成项目报告;这部分不是题目挖空。"""
    decision = choose_qlora_action(summary)
    details = {
        'accept': ('qlora_is_best_feasible_option', 'promote_to_training_run'),
        'tune': ('qlora_needs_rank_or_quant_tuning', 'adjust_rank_or_quantization_bits'),
        'reject': ('no_candidate_meets_budget_and_quality', 'relax_budget_or_improve_quality'),
    }
    reason, next_action = details[decision]
    return {'decision': decision, 'reason': reason, 'next_action': next_action}
python
# 测试目标:分别验证门槛组合、候选排序和项目决策;最后再检查完整链路。
# 这些测试复用题目区的输入契约与结果接口,不依赖 GPU 或真实模型加载。

def _budget():
    return {'memory_cap_mb': 12000.0, 'min_micro_batch': 1, 'min_tokens_per_s': 18.0}


def _quality_floor():
    return {'max_val_loss': 1.20}


def _selection_fixtures():
    return [
        {'name': 'lora_bf16', 'strategy': 'lora', 'quantization': 'bf16', 'evidence': 'real_gpu_paired_repeat', 'memory_mb': 14500.0, 'max_micro_batch': 1, 'tokens_per_s': 20.0, 'val_loss': 1.10, 'low_bit_active': False, 'identity': {'model_commit': 'm1', 'data_manifest_hash': 'd1', 'adapter_signature': 'r8-qv', 'effective_batch': 2, 'max_length': 256, 'update_steps': 12, 'seed_set': '42,123,2024'}},
        {'name': 'qlora_nf4', 'strategy': 'qlora', 'quantization': 'nf4', 'evidence': 'real_gpu_paired_repeat', 'memory_mb': 9800.0, 'max_micro_batch': 2, 'tokens_per_s': 19.0, 'val_loss': 1.16, 'low_bit_active': True, 'identity': {'model_commit': 'm1', 'data_manifest_hash': 'd1', 'adapter_signature': 'r8-qv', 'effective_batch': 2, 'max_length': 256, 'update_steps': 12, 'seed_set': '42,123,2024'}},
    ]


def test_memory_ledger_contract():
    ledger = build_memory_ledger(100.0, 10.0, 10.0, 40.0, 200.0, quant_metadata_mb=5.0, peak_memory_mb=380.0, peak_reserved_mb=420.0)
    assert ledger['estimated_total_mb'] == 365.0
    assert ledger['reconciliation_gap_mb'] == 15.0
    try:
        build_memory_ledger(-1.0, 0.0, 0.0, 0.0, 0.0)
    except ValueError:
        return
    raise AssertionError('显存账本不应接受负数')


def test_candidate_validation_contract():
    errors = validate_qlora_candidate({'name': 'broken', 'memory_mb': -1})
    assert 'missing:strategy' in errors
    invalid = validate_qlora_candidate({'name': 'bad', 'strategy': 'qlora', 'quantization': 'nf4', 'evidence': 'estimated', 'memory_mb': float('nan'), 'max_micro_batch': 1, 'tokens_per_s': 1.0, 'val_loss': 1.0})
    assert 'non_finite:memory_mb' in invalid
    assert validate_budget_and_quality(_budget(), _quality_floor())['is_valid'] is True


def test_workload_identity_mechanism():
    baseline, qlora = _selection_fixtures()
    assert same_workload_identity(baseline, qlora) is True
    drifted = {**qlora, 'identity': {**qlora['identity'], 'max_length': 512}}
    assert same_workload_identity(baseline, drifted) is False


def test_gate_mechanism():
    qlora = _selection_fixtures()[-1]
    assert candidate_passes_gates(qlora, _budget(), _quality_floor()) is True
    too_small = {**_selection_fixtures()[0], 'memory_mb': 11000.0, 'max_micro_batch': 0}
    assert candidate_passes_gates(too_small, _budget(), _quality_floor()) is False


def test_candidate_ranking_mechanism():
    candidates = [
        {'name': 'qlora_rank_16', 'strategy': 'qlora', 'quantization': 'nf4', 'evidence': 'estimated', 'memory_mb': 9600.0, 'max_micro_batch': 1, 'tokens_per_s': 20.0, 'val_loss': 1.18},
        {'name': 'qlora_rank_32', 'strategy': 'qlora', 'quantization': 'nf4', 'evidence': 'estimated', 'memory_mb': 9800.0, 'max_micro_batch': 2, 'tokens_per_s': 23.0, 'val_loss': 1.12},
    ]
    summary = summarize_low_resource_candidates(candidates, _budget(), _quality_floor())
    assert summary['best_candidate'] == 'qlora_rank_16'
    assert candidate_rank_key(candidates[0]) < candidate_rank_key(candidates[1])


def test_project_decision_mechanism():
    accepted = summarize_low_resource_candidates(_selection_fixtures(), _budget(), _quality_floor())
    assert choose_qlora_action(accepted) == 'accept'
    tuning = summarize_low_resource_candidates([
        {'name': 'lora_bf16', 'strategy': 'lora', 'quantization': 'bf16', 'evidence': 'estimated', 'memory_mb': 11000.0, 'max_micro_batch': 1, 'tokens_per_s': 19.0, 'val_loss': 1.10},
        {'name': 'qlora_nf4', 'strategy': 'qlora', 'quantization': 'nf4', 'evidence': 'estimated', 'memory_mb': 10000.0, 'max_micro_batch': 2, 'tokens_per_s': 20.0, 'val_loss': 1.28},
    ], _budget(), _quality_floor())
    assert choose_qlora_action(tuning) == 'tune'
    rejected = summarize_low_resource_candidates([
        {'name': 'qlora_nf4', 'strategy': 'qlora', 'quantization': 'nf4', 'evidence': 'estimated', 'memory_mb': 13000.0, 'max_micro_batch': 0, 'tokens_per_s': 17.0, 'val_loss': 1.28},
    ], _budget(), _quality_floor())
    assert choose_qlora_action(rejected) == 'reject'


def test_qlora_selection_integration():
    summary = summarize_low_resource_candidates(_selection_fixtures(), _budget(), _quality_floor())
    result = decide_qlora_project(summary)
    assert summary['qlora_evaluation']['feasible'] is True
    assert result['decision'] == 'accept'
    assert result['next_action'] == 'promote_to_training_run'


def run_qlora_selection_tests():
    for test in (
        test_memory_ledger_contract,
        test_candidate_validation_contract,
        test_workload_identity_mechanism,
        test_gate_mechanism,
        test_candidate_ranking_mechanism,
        test_project_decision_mechanism,
        test_qlora_selection_integration,
    ):
        test()
    print('✅ QLoRA 选型机制测试通过:门槛、排序、策略决策与完整链路均已验证。')


run_qlora_selection_tests()

🛑 STOP HERE 🛑

参考代码与解析 ​

代码 ​

python
import math

def build_memory_ledger(base_weight_mb: float, trainable_param_mb: float, gradient_mb: float, optimizer_state_mb: float, activation_mb: float, quant_metadata_mb: float = 0.0, peak_memory_mb: float = None, peak_reserved_mb: float = None, evidence: str = 'estimated') -> Dict[str, object]:
    """汇总训练显存对象,并区分估算值与 CUDA 峰值。"""
    values = {
        'base_weight_mb': base_weight_mb, 'trainable_param_mb': trainable_param_mb,
        'gradient_mb': gradient_mb, 'optimizer_state_mb': optimizer_state_mb,
        'activation_mb': activation_mb, 'quant_metadata_mb': quant_metadata_mb,
    }
    if any(float(value) < 0 for value in values.values()):
        raise ValueError('显存账本中的各项不能为负数。')
    estimated_total_mb = sum(float(value) for value in values.values())
    report = {**values, 'estimated_total_mb': round(estimated_total_mb, 3), 'evidence': evidence}
    if peak_memory_mb is not None:
        report['peak_memory_mb'] = float(peak_memory_mb)
        report['reconciliation_gap_mb'] = round(float(peak_memory_mb) - estimated_total_mb, 3)
    if peak_reserved_mb is not None:
        report['peak_reserved_mb'] = float(peak_reserved_mb)
    return report

def validate_qlora_candidate(candidate: Dict[str, object]) -> List[str]:
    """检查候选是否具备进入预算筛选的完整测量与证据字段。"""
    errors = []
    required = ('name', 'strategy', 'quantization', 'evidence', 'memory_mb', 'max_micro_batch', 'tokens_per_s', 'val_loss')
    for key in required:
        if key not in candidate:
            errors.append(f'missing:{key}')
    if errors:
        return errors
    for key in ('memory_mb', 'max_micro_batch', 'tokens_per_s', 'val_loss'):
        try:
            value = float(candidate[key])
        except (TypeError, ValueError):
            errors.append(f'non_numeric:{key}')
            continue
        if not math.isfinite(value):
            errors.append(f'non_finite:{key}')
        if key in ('memory_mb', 'max_micro_batch', 'tokens_per_s') and value < 0:
            errors.append(f'negative:{key}')
    for key in ('strategy', 'quantization', 'evidence'):
        if not str(candidate[key]).strip():
            errors.append(f'empty:{key}')
    return errors

def validate_budget_and_quality(budget: Dict[str, float], quality_floor: Dict[str, float]) -> Dict[str, object]:
    """检查显存、可行规模、吞吐和验证损失门槛是否完整。"""
    required_budget_keys = ['memory_cap_mb', 'min_micro_batch', 'min_tokens_per_s']
    required_quality_keys = ['max_val_loss']
    missing_keys = [key for key in required_budget_keys if key not in budget]
    missing_keys += [key for key in required_quality_keys if key not in quality_floor]
    for key in required_budget_keys:
        if key in budget:
            try:
                value = float(budget[key])
            except (TypeError, ValueError):
                missing_keys.append(f'non_numeric:{key}')
                continue
            if not math.isfinite(value) or value <= 0:
                missing_keys.append(f'invalid:{key}')
    if 'max_val_loss' in quality_floor:
        try:
            value = float(quality_floor['max_val_loss'])
        except (TypeError, ValueError):
            missing_keys.append('non_numeric:max_val_loss')
        else:
            if not math.isfinite(value) or value < 0:
                missing_keys.append('invalid:max_val_loss')
    return {'is_valid': len(missing_keys) == 0, 'missing_keys': missing_keys}

def _candidate_failure_reasons(candidate: Dict[str, object], budget: Dict[str, float], quality_floor: Dict[str, float]) -> List[str]:
    """给出候选未通过的门槛;报告字段由骨架统一生成。"""
    reasons = []
    if candidate['memory_mb'] > budget['memory_cap_mb']:
        reasons.append('memory')
    if candidate['max_micro_batch'] < budget['min_micro_batch']:
        reasons.append('scale')
    if candidate['tokens_per_s'] < budget['min_tokens_per_s']:
        reasons.append('throughput')
    if candidate['val_loss'] > quality_floor['max_val_loss']:
        reasons.append('quality')
    return reasons


def same_workload_identity(baseline: Dict[str, object], candidate: Dict[str, object]) -> bool:
    """确认两条训练路径只在冻结底座表示上不同。"""
    baseline_identity = baseline.get('identity', {})
    candidate_identity = candidate.get('identity', {})
    required = ('model_commit', 'data_manifest_hash', 'adapter_signature', 'effective_batch', 'max_length', 'update_steps', 'seed_set')
    # TODO 1(比较身份):所有身份字段存在且完全一致才允许比较。
    return all(baseline_identity.get(key) is not None and baseline_identity.get(key) == candidate_identity.get(key) for key in required)


def candidate_passes_gates(candidate: Dict[str, object], budget: Dict[str, float], quality_floor: Dict[str, float]) -> bool:
    """判断候选是否达到进入 GPU 对照的资源与质量门槛。"""
    memory_ok = candidate['memory_mb'] <= budget['memory_cap_mb']
    scale_ok = candidate['max_micro_batch'] >= budget['min_micro_batch']
    throughput_ok = candidate['tokens_per_s'] >= budget['min_tokens_per_s']
    quality_ok = candidate['val_loss'] <= quality_floor['max_val_loss']
    # TODO 2(候选准入):资源、规模、速度和质量同时通过,候选才可进入 GPU 对照。
    return memory_ok and scale_ok and throughput_ok and quality_ok


def candidate_rank_key(candidate: Dict[str, object]) -> tuple[float, float, float, float]:
    """定义可行候选的比较顺序:更省显存,再更大可行规模、更高吞吐和更低损失。"""
    # 只对已通过硬门槛的候选使用该稳定排序。
    return (candidate['memory_mb'], -candidate['max_micro_batch'], -candidate['tokens_per_s'], candidate['val_loss'])


def choose_qlora_action(summary: Dict[str, object]) -> str:
    """根据身份、实测证据和 QLoRA 候选状态选择交付动作。"""
    qlora_evaluation = summary.get('qlora_evaluation')
    # TODO 3(交付决策):估算或单次 smoke 只能保留为 tune,不能写成选型结论。
    if summary['feasible_count'] == 0:
        return 'reject'
    if not summary.get('identity_match') or not qlora_evaluation or not qlora_evaluation['feasible']:
        return 'tune'
    if summary.get('qlora_evidence_level') != 'real_gpu_paired_repeat' or not summary.get('qlora_low_bit_active'):
        return 'tune'
    return 'accept' if summary['best_strategy'] == 'qlora' else 'tune'


def evaluate_qlora_candidate(candidate: Dict[str, object], budget: Dict[str, float], quality_floor: Dict[str, float]) -> Dict[str, object]:
    """调用门槛机制,并由骨架生成候选评估记录。"""
    errors = validate_qlora_candidate(candidate)
    if errors:
        raise ValueError(f'非法 QLoRA 候选 {candidate.get("name", "unknown")}: {errors}')
    quality_ok = candidate['val_loss'] <= quality_floor['max_val_loss']
    return {
        'name': candidate['name'],
        'strategy': candidate['strategy'],
        'max_micro_batch': candidate['max_micro_batch'],
        'feasible': candidate_passes_gates(candidate, budget, quality_floor),
        'failure_reasons': _candidate_failure_reasons(candidate, budget, quality_floor),
        'quality_failed': not quality_ok,
    }


def summarize_low_resource_candidates(candidates: List[Dict[str, object]], budget: Dict[str, float], quality_floor: Dict[str, float]) -> Dict[str, object]:
    """汇总低比特训练候选并保留 QLoRA 对照结果;报告字段由骨架提供。"""
    evaluations = [evaluate_qlora_candidate(candidate, budget, quality_floor) for candidate in candidates]
    feasible = [candidate for candidate, evaluation in zip(candidates, evaluations) if evaluation['feasible']]
    feasible.sort(key=candidate_rank_key)
    rejected = [
        {'name': evaluation['name'], 'reasons': evaluation['failure_reasons']}
        for evaluation in evaluations if not evaluation['feasible']
    ]
    qlora_evaluation = next((item for item in evaluations if item['strategy'] == 'qlora'), None)
    baseline = next((item for item in candidates if item['strategy'] == 'lora'), None)
    qlora_candidate = next((item for item in candidates if item['strategy'] == 'qlora'), None)
    return {
        'candidate_count': len(candidates),
        'feasible_count': len(feasible),
        'best_candidate': feasible[0]['name'] if feasible else None,
        'best_strategy': feasible[0]['strategy'] if feasible else None,
        'quality_failed_count': sum(item['quality_failed'] for item in evaluations),
        'feasible_names': [item['name'] for item in feasible],
        'rejected': rejected,
        'evaluations': evaluations,
        'qlora_evaluation': qlora_evaluation,
        'identity_match': bool(baseline and qlora_candidate and same_workload_identity(baseline, qlora_candidate)),
        'qlora_evidence_level': qlora_candidate.get('evidence') if qlora_candidate else None,
        'qlora_low_bit_active': bool(qlora_candidate and qlora_candidate.get('low_bit_active')),
    }


def decide_qlora_project(summary: Dict[str, object]) -> Dict[str, object]:
    """将策略机制转换成项目报告;这部分不是题目挖空。"""
    decision = choose_qlora_action(summary)
    details = {
        'accept': ('qlora_is_best_feasible_option', 'promote_to_training_run'),
        'tune': ('qlora_needs_rank_or_quant_tuning', 'adjust_rank_or_quantization_bits'),
        'reject': ('no_candidate_meets_budget_and_quality', 'relax_budget_or_improve_quality'),
    }
    reason, next_action = details[decision]
    return {'decision': decision, 'reason': reason, 'next_action': next_action}

解析 ​

题目区的三处 TODO 分别对应项目比较中的身份、准入和交付判断;字段校验、失败原因和报告结构由骨架提供。

TODO 1:比较身份一致性

  • 两条路径必须使用相同模型提交、数据 manifest、adapter、有效 batch、长度、更新步数与种子组。
  • 任一字段漂移都会改变训练语义或 workload,因此不能把测量差异归因于 NF4。

TODO 2:候选准入

  • 显存、可行 micro-batch、吞吐和验证质量必须同时通过,候选才值得进入 GPU 配对复测。
  • 这里的通过只代表“可以测”;最终交付仍要求低比特路径生效、配对重复和 artifact 证据。

TODO 3:QLoRA 交付决策

  • 没有可行候选时 reject;身份不一致、仅有估算或单次 smoke 时 tune;只有 QLoRA 通过配对 GPU 重复、低比特已生效且资源最优时才 accept。
  • tune 的下一步是补齐身份、容量、重复或量化路径证据,而不是直接扩大结论。

CPU 选型报告(可选导出) ​

完成 CPU 候选筛选后,可以保存 65_qlora_selection.json,其中记录预算、速度、质量和选型结论。真实 adapter 或 merged model 的 artifact manifest 在 Step 5.5 读取 GPU 结果时生成。

python
import json

try:
    from tools.fine_tuning_project_runtime import preflight_runtime, runtime_snapshot, save_project_report, validate_project_config
except ModuleNotFoundError:
    preflight_runtime = lambda torch_module, run_mode='cpu', **kwargs: {'run_mode': run_mode, 'ready': False, 'reasons': ['共享运行时工具不可用']}
    runtime_snapshot = lambda: {'device': 'unknown'}
    validate_project_config = lambda config: []
    save_project_report = None

RUN_MODE = 'dry_run'  # dry_run 只检查环境;改为 real_gpu 执行真实 QLoRA 对照。
PROJECT_ID = '65_qlora_selection'
PROJECT_RESULT_PATH = 'benchmarks/results/65_qlora_selection/qlora_selection_cpu_baseline.json'
PROJECT_CONFIG = {
    'project': PROJECT_ID, 'model': 'template', 'dtype': 'fp32',
    'batch_size': 1, 'seq_len': 128, 'steps': 1, 'seed': 42,
    'run_mode': RUN_MODE, 'result_json': PROJECT_RESULT_PATH,
}
RUN_PROJECT_EXPORT = False  # True 时保存已完成的 CPU 选型报告。

config_errors = validate_project_config(PROJECT_CONFIG)
if config_errors:
    raise ValueError('; '.join(config_errors))
print('runtime:', runtime_snapshot())

if RUN_MODE == 'dry_run':
    import importlib.util
    try:
        import torch
        preflight = preflight_runtime(torch, run_mode='dry_run')
    except ImportError as exc:
        preflight = {'run_mode': 'dry_run', 'ready': False, 'reasons': [f'缺少 torch:{exc}']}
    preflight['bitsandbytes_available'] = importlib.util.find_spec('bitsandbytes') is not None
    print('dry_run:', preflight)

if RUN_PROJECT_EXPORT:
    if 'PROJECT_REPORT' not in globals():
        raise RuntimeError('请先组装完整的 PROJECT_REPORT')
    PROJECT_REPORT.setdefault('project', PROJECT_ID)
    PROJECT_REPORT.setdefault('config', PROJECT_CONFIG)
    PROJECT_REPORT.setdefault('environment', runtime_snapshot())
    if save_project_report is None:
        raise RuntimeError('需要从仓库根目录运行导出工具')
    save_project_report(PROJECT_RESULT_PATH, PROJECT_REPORT)
    print(f'已保存 CPU 选型报告:{PROJECT_RESULT_PATH}')

Step 5:QLoRA 配对 GPU 实验与低资源交付 ​

5.1 环境、模型与固定 workload ​

本实验读取 64 节已经准入的数据快照,按同一模型提交、数据切分、适配器、有效 batch、训练步数和随机种子,配对比较 BF16 LoRA 与 NF4 QLoRA。容量阶梯单独回答能否扩大 micro-batch,不能替代固定 workload 的质量比较。

实验要素LoRA baselineQLoRA candidate
模型、数据与适配器固定提交与 64 节切分;同一 LoRA 配置与 baseline 相同
固定训练条件effective batch、seq_len、steps、seeds 固定与 baseline 相同
唯一变化变量BF16 冻结底座NF4 + double quant 冻结底座
配对证据峰值显存、步时、监督词元/s、独立评测 loss与 baseline 对照

QLoRA GPU 复测流程

python
# 5.1:固定上游数据快照、配对 workload 与输出路径。
import json
import importlib.metadata as metadata
import platform
from pathlib import Path
from tools.fine_tuning_project_runtime import locate_repo_root
from tools.project_runtime import save_json_record_once

PROFILE_ID = 'Q-QLORA'  # bitsandbytes 低比特训练环境。
RUN_LABEL = 'rtx5070ti_qlora_15b_capacity_v1'  # 1.5B 容量交叉实验;保留 0.5B v3 的 tune 证据。
RUN_MODE = 'dry_run'  # 默认只检查配置;改为 'real_gpu' 执行新的真实对照。
RUN_GPU_EXPERIMENT = RUN_MODE == 'real_gpu'
SEMANTIC_ID = 'PT-QLORA-SELECTION'
SCHEMA_VERSION = 'qlora-benchmark/v1'

SAVE_ARTIFACTS = True
REPO_ROOT = locate_repo_root()
UPSTREAM_DATA_MANIFEST = REPO_ROOT / 'benchmarks/results/64_sft_data_quality/sft_data_manifest_alpaca_qwen25_15b_v1.json'
MODEL_ID = 'Qwen/Qwen2.5-1.5B'  # 必须与本轮 64 数据审计的 tokenizer 身份一致。
MODEL_REVISION = 'main'
LOCAL_FILES_ONLY = False
MAX_LENGTH = 256  # 与 64 节数据审计的长度上限一致。
TRAIN_EXAMPLE_COUNT = 96
EVALUATION_EXAMPLE_COUNT = 32
MAX_STEPS = 12
FIXED_MICRO_BATCH = 1
MICRO_BATCH_CANDIDATES = (1, 2, 4, 8)
RUN_CAPACITY_LADDER = True
CAPACITY_PROBE_STEPS = 2
SEEDS = (17, 29, 43)
QUALITY_TOLERANCE = 0.10
MAX_STEP_TIME_REGRESSION_RATIO = 0.15  # 固定 workload 上可接受的中位步时回退上限。
MIN_MEMORY_SAVING_RATIO = 0.10  # 未扩大容量时,至少节省 10% 峰值显存才视为低资源收益。
RESULTS_DIR = REPO_ROOT / 'benchmarks/results/65_qlora_selection'
RAW_RESULT_PATH = RESULTS_DIR / f'qlora_selection_raw_{RUN_LABEL}.json'
RESULT_PATH = RESULTS_DIR / f'qlora_selection_{RUN_LABEL}.json'
ARTIFACT_MANIFEST_PATH = RESULTS_DIR / f'qlora_artifact_manifest_{RUN_LABEL}.json'

print({'run_mode': RUN_MODE, 'profile': PROFILE_ID, 'run_label': RUN_LABEL, 'upstream_data_manifest': str(UPSTREAM_DATA_MANIFEST), 'model': MODEL_ID, 'max_length': MAX_LENGTH, 'train_examples': TRAIN_EXAMPLE_COUNT, 'evaluation_examples': EVALUATION_EXAMPLE_COUNT, 'steps': MAX_STEPS, 'seeds': SEEDS, 'capacity_ladder': RUN_CAPACITY_LADDER, 'raw_result_json': str(RAW_RESULT_PATH), 'result_json': str(RESULT_PATH), 'artifact_manifest': str(ARTIFACT_MANIFEST_PATH)})

5.2 环境启动检查 ​

确认 CUDA、GPU、PyTorch、Transformers、PEFT、bitsandbytes 和数据来源可用,并记录实际 dtype、软件版本与显存容量。

python
# 5.2:只检查环境和依赖;不会下载模型或启动训练。
def _package_version(name):
    try:
        return metadata.version(name)
    except metadata.PackageNotFoundError:
        return 'not-installed'


def _require_gpu_dependencies():
    import torch
    if not torch.cuda.is_available():
        raise RuntimeError("未检测到 CUDA GPU;请改回 RUN_MODE='dry_run',并检查 Q-QLORA 环境。")
    from datasets import load_dataset
    from transformers import AutoModelForCausalLM, AutoTokenizer, Trainer, TrainingArguments
    from peft import LoraConfig, get_peft_model
    return torch, AutoModelForCausalLM, AutoTokenizer, Trainer, TrainingArguments, LoraConfig, get_peft_model


def _environment_snapshot(torch):
    return {
        'gpu': torch.cuda.get_device_name(0), 'torch': torch.__version__,
        'cuda': torch.version.cuda, 'python': platform.python_version(),
        'transformers': _package_version('transformers'), 'peft': _package_version('peft'),
        'bitsandbytes': _package_version('bitsandbytes'), 'datasets': _package_version('datasets'),
    }


if RUN_GPU_EXPERIMENT:
    try:
        _preflight_deps = _require_gpu_dependencies()
        print('GPU preflight:', _environment_snapshot(_preflight_deps[0]))
    except Exception as error:
        print({'preflight': 'failed', 'error_type': type(error).__name__, 'error': str(error)})
        raise
else:
    print('GPU preflight 未启动;保持 RUN_MODE=dry_run。')

5.3 配置 LoRA/QLoRA 与数据 ​

先读取 64 节数据准入清单,再按其中的源索引重建训练与评测记录;重算样本 ID 不一致会直接停止。SFT labels 屏蔽提示词,只对 response Token 计算损失。

python
# 5.3:从 64 节准入清单重建数据,并定义一组可复跑的训练。
def _record_sample_id(record):
    from tools.post_training_project_runtime import normalized_text, stable_text_hash
    return stable_text_hash((normalized_text(record.get('instruction')), normalized_text(record.get('input')), normalized_text(record.get('output'))))


def load_admitted_workload():
    """按 64 节登记的源索引与样本 ID 重建本项目的固定子 workload。"""
    from datasets import load_dataset
    manifest = json.loads(UPSTREAM_DATA_MANIFEST.read_text(encoding='utf-8'))
    if manifest.get('decision') != 'accept':
        raise RuntimeError('64 节数据准入不是 accept;不能启动 QLoRA 对照。')
    if manifest['tokenizer']['id'] != MODEL_ID or manifest['workload']['max_length'] != MAX_LENGTH:
        raise RuntimeError('65 的模型或 max_length 与 64 的数据审计身份不一致。')
    snapshot = manifest['snapshot']
    train_indices = snapshot['train_record_indices'][:TRAIN_EXAMPLE_COUNT]
    evaluation_indices = snapshot['evaluation_record_indices'][:EVALUATION_EXAMPLE_COUNT]
    if len(train_indices) != TRAIN_EXAMPLE_COUNT or len(evaluation_indices) != EVALUATION_EXAMPLE_COUNT:
        raise RuntimeError('64 节准入快照不足以构成当前固定 workload。')
    dataset = load_dataset(
        manifest['dataset']['id'], split=f"train[:{manifest['workload']['sample_count']}]",
        revision=manifest['dataset']['resolved_commit'],
    )
    train_records = [dict(dataset[index]) for index in train_indices]
    evaluation_records = [dict(dataset[index]) for index in evaluation_indices]
    if [_record_sample_id(row) for row in train_records] != snapshot['train_sample_ids'][:TRAIN_EXAMPLE_COUNT]:
        raise RuntimeError('重建后的训练样本 ID 与 64 节准入快照不一致。')
    if [_record_sample_id(row) for row in evaluation_records] != snapshot['evaluation_sample_ids'][:EVALUATION_EXAMPLE_COUNT]:
        raise RuntimeError('重建后的评测样本 ID 与 64 节准入快照不一致。')
    return manifest, train_records, evaluation_records


def _tokenize_examples(tokenizer, records):
    rows = []
    for record in records:
        prompt = f"### Instruction:\n{record['instruction']}\n"
        if str(record.get('input', '')).strip():
            prompt += f"### Input:\n{record['input']}\n"
        prompt += '### Response:\n'
        prompt_ids = tokenizer(prompt, add_special_tokens=False)['input_ids']
        response_ids = tokenizer(str(record['output']), add_special_tokens=False)['input_ids'] + [tokenizer.eos_token_id]
        input_ids = (prompt_ids + response_ids)[:MAX_LENGTH]
        response_start = min(len(prompt_ids), len(input_ids))
        if response_start == len(input_ids):
            raise ValueError('当前 max_length 截断了全部 response Token。')
        labels = [-100] * response_start + input_ids[response_start:]
        padding = MAX_LENGTH - len(input_ids)
        rows.append({'input_ids': input_ids + [tokenizer.pad_token_id] * padding, 'attention_mask': [1] * len(input_ids) + [0] * padding, 'labels': labels + [-100] * padding})
    return rows


class _ListDataset:
    def __init__(self, rows): self.rows = rows
    def __len__(self): return len(self.rows)
    def __getitem__(self, index): return self.rows[index]


def _collate(tokenizer, rows):
    import torch
    return {key: torch.tensor([row[key] for row in rows], dtype=torch.long) for key in ('input_ids', 'attention_mask', 'labels')}


def run_qlora_variant(label, qlora, torch, AutoModelForCausalLM, AutoTokenizer, Trainer, TrainingArguments, LoraConfig, get_peft_model, *, model_commit, train_records, evaluation_records, seed, micro_batch_size=FIXED_MICRO_BATCH, update_steps=MAX_STEPS, save_artifact=SAVE_ARTIFACTS):
    import time
    from transformers import BitsAndBytesConfig
    from peft import prepare_model_for_kbit_training
    from tools.post_training_project_runtime import hash_directory
    torch.manual_seed(seed); torch.cuda.manual_seed_all(seed)
    tokenizer = AutoTokenizer.from_pretrained(MODEL_ID, revision=model_commit, local_files_only=LOCAL_FILES_ONLY)
    if tokenizer.pad_token is None: tokenizer.pad_token = tokenizer.eos_token
    compute_dtype = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16
    model_kwargs = {'torch_dtype': compute_dtype, 'device_map': 'auto'}
    if qlora:
        model_kwargs['quantization_config'] = BitsAndBytesConfig(
            load_in_4bit=True, bnb_4bit_quant_type='nf4',
            bnb_4bit_use_double_quant=True, bnb_4bit_compute_dtype=compute_dtype,
        )
    model = AutoModelForCausalLM.from_pretrained(MODEL_ID, revision=model_commit, local_files_only=LOCAL_FILES_ONLY, **model_kwargs)
    low_bit_active = bool(getattr(model, 'is_loaded_in_4bit', False))
    if qlora and not low_bit_active: raise RuntimeError('QLoRA candidate 未激活 4-bit 冻结底座。')
    if qlora: model = prepare_model_for_kbit_training(model)
    model = get_peft_model(model, LoraConfig(
        r=8, lora_alpha=16, lora_dropout=0.05, bias='none',
        target_modules=['q_proj', 'k_proj', 'v_proj', 'o_proj'], task_type='CAUSAL_LM',
    ))
    train_rows = _tokenize_examples(tokenizer, train_records)
    evaluation_rows = _tokenize_examples(tokenizer, evaluation_records)
    dataset, eval_dataset = _ListDataset(train_rows), _ListDataset(evaluation_rows)
    args = TrainingArguments(
        output_dir=f'/tmp/65_{label}', per_device_train_batch_size=micro_batch_size,
        gradient_accumulation_steps=1, learning_rate=2e-4, max_steps=update_steps,
        logging_steps=1, save_strategy='no', report_to='none', seed=seed,
        remove_unused_columns=False, fp16=compute_dtype == torch.float16,
        bf16=compute_dtype == torch.bfloat16,
    )
    trainer = Trainer(model=model, args=args, train_dataset=dataset, eval_dataset=eval_dataset,
                      data_collator=lambda batch_rows: _collate(tokenizer, batch_rows))
    torch.cuda.empty_cache(); torch.cuda.reset_peak_memory_stats()
    start = time.perf_counter(); trainer.train(); torch.cuda.synchronize()
    elapsed = time.perf_counter() - start
    eval_report = trainer.evaluate()
    logs = [item for item in trainer.state.log_history if 'loss' in item]
    artifact_path = RESULTS_DIR / f'{RUN_LABEL}_{label}_seed{seed}_adapter'
    if save_artifact:
        if artifact_path.exists(): raise FileExistsError(f'产物已存在,请修改 RUN_LABEL:{artifact_path}')
        model.save_pretrained(artifact_path)
    return {
        'label': label, 'model_id': MODEL_ID, 'quantization': 'nf4' if qlora else 'bf16',
        'fixed_micro_batch': micro_batch_size, 'train_examples': len(train_rows), 'evaluation_examples': len(evaluation_rows),
        'compute_dtype': str(compute_dtype), 'steps': update_steps, 'seed': seed,
        'step_time_s': round(elapsed / max(update_steps, 1), 4),
        'supervised_tokens_per_s': round(sum(sum(token != -100 for token in row['labels']) for row in train_rows[:update_steps]) / max(elapsed, 1e-9), 3),
        'peak_memory_mb': round(torch.cuda.max_memory_allocated() / 2**20, 3),
        'peak_memory_scope': 'train_and_eval',
        'peak_reserved_mb': round(torch.cuda.max_memory_reserved() / 2**20, 3),
        'last_train_loss': logs[-1].get('loss') if logs else None,
        'val_loss': eval_report.get('eval_loss'),
        'low_bit_active': low_bit_active,
        'artifact_path': str(artifact_path) if save_artifact else None,
        'artifact_hash': hash_directory(artifact_path) if save_artifact else None,
        'evidence_level': 'real_gpu_pair_member',
    }


def summarize_variant(reports):
    import statistics
    if len(reports) != len(SEEDS):
        raise RuntimeError('缺少配对重复,不能汇总为项目级证据。')
    return {
        'label': reports[0]['label'], 'seed_reports': reports, 'seeds': [report['seed'] for report in reports],
        'peak_memory_mb_median': statistics.median(report['peak_memory_mb'] for report in reports),
        'step_time_s_median': statistics.median(report['step_time_s'] for report in reports),
        'supervised_tokens_per_s_median': statistics.median(report['supervised_tokens_per_s'] for report in reports),
        'val_loss_median': statistics.median(report['val_loss'] for report in reports),
        'all_low_bit_active': all(report['low_bit_active'] for report in reports),
        'all_artifacts_hashed': all(report['artifact_hash'] for report in reports),
    }

5.4 执行训练并保存 JSON ​

每个 seed 依次运行 LoRA baseline 与 QLoRA candidate,保证数据、步数和随机性一一对应。完成三组配对后,再用一个固定 seed 跑容量阶梯;OOM 是容量证据,不会覆盖已完成的质量比较。

python
# 5.4:按同一上游快照运行 3 个种子的 LoRA / QLoRA 配对重复。
if RUN_GPU_EXPERIMENT:
    collisions = [path for path in (RAW_RESULT_PATH, RESULT_PATH, ARTIFACT_MANIFEST_PATH) if path.exists()]
    if collisions:
        raise FileExistsError(f'当前 RUN_LABEL 已有结果,请更换标签:{collisions}')
    try:
        deps = _require_gpu_dependencies()
        torch = deps[0]
        environment = _environment_snapshot(torch)
        upstream_manifest, train_records, evaluation_records = load_admitted_workload()
        model_commit = upstream_manifest['tokenizer']['resolved_commit']
        paired_reports = {'lora_baseline': [], 'qlora_candidate': []}
        for seed in SEEDS:
            paired_reports['lora_baseline'].append(run_qlora_variant('lora_baseline', False, *deps, model_commit=model_commit, train_records=train_records, evaluation_records=evaluation_records, seed=seed))
            torch.cuda.empty_cache()
            paired_reports['qlora_candidate'].append(run_qlora_variant('qlora_candidate', True, *deps, model_commit=model_commit, train_records=train_records, evaluation_records=evaluation_records, seed=seed))
            torch.cuda.empty_cache()
        baseline, candidate = summarize_variant(paired_reports['lora_baseline']), summarize_variant(paired_reports['qlora_candidate'])
        config = {
            'model_id': MODEL_ID, 'requested_model_revision': MODEL_REVISION, 'model_commit': model_commit, 'max_length': MAX_LENGTH, 'max_steps': MAX_STEPS, 'seeds': list(SEEDS),
            'fixed_micro_batch': FIXED_MICRO_BATCH, 'gradient_accumulation_steps': 1, 'effective_batch_size': FIXED_MICRO_BATCH, 'learning_rate': 2e-4,
            'lora_r': 8, 'lora_alpha': 16,
            'target_modules': ['q_proj', 'k_proj', 'v_proj', 'o_proj'],
            'qlora_format': 'NF4 + double quant', 'data_manifest': str(UPSTREAM_DATA_MANIFEST),
        }
        scale_probe = {'status': 'not_run', 'candidates': list(MICRO_BATCH_CANDIDATES), 'reports': []}
        if RUN_CAPACITY_LADDER:
            scale_probe['status'] = 'completed'
            for label, use_qlora in [('lora_baseline', False), ('qlora_candidate', True)]:
                attempts = []
                for micro_batch in MICRO_BATCH_CANDIDATES:
                    try:
                        attempts.append(run_qlora_variant(f'{label}_mb{micro_batch}', use_qlora, *deps, model_commit=model_commit, train_records=train_records, evaluation_records=evaluation_records, seed=SEEDS[0], micro_batch_size=micro_batch, update_steps=CAPACITY_PROBE_STEPS, save_artifact=False))
                    except RuntimeError as error:
                        if 'out of memory' not in str(error).lower():
                            raise
                        torch.cuda.empty_cache()
                        attempts.append({'label': f'{label}_mb{micro_batch}', 'fixed_micro_batch': micro_batch, 'status': 'oom', 'error': str(error)})
                        break
                max_micro_batch = max((item['fixed_micro_batch'] for item in attempts if item.get('status') != 'oom'), default=0)
                scale_probe['reports'].append({'label': label, 'max_micro_batch': max_micro_batch, 'attempts': attempts})
        payload = {
            'schema_version': SCHEMA_VERSION, 'semantic_id': SEMANTIC_ID, 'project': '65',
            'run_label': RUN_LABEL, 'run_mode': RUN_MODE, 'environment_profile': PROFILE_ID,
            'json_path': str(RAW_RESULT_PATH),
            'workload': {'model_id': MODEL_ID, 'model_commit': model_commit, 'max_length': MAX_LENGTH, 'steps': MAX_STEPS, 'seeds': list(SEEDS),
                         'train_examples': len(train_records), 'evaluation_examples': len(evaluation_records), 'fixed_micro_batch': FIXED_MICRO_BATCH, 'gradient_accumulation_steps': 1,
                         'data_manifest_hash': upstream_manifest['sample_ids_hash'], 'train_sample_ids_hash': upstream_manifest['snapshot']['train_sample_ids_hash'], 'evaluation_sample_ids_hash': upstream_manifest['snapshot']['evaluation_sample_ids_hash']},
            'baseline': baseline, 'candidate': candidate, 'environment': environment,
            'config': config, 'paired_reports': paired_reports, 'scale_probe': scale_probe, 'failure': None,
            'evidence_level': 'real_gpu_paired_repeat', 'decision': 'pending_analysis',
        }
        save_json_record_once(RAW_RESULT_PATH, payload)
        print({'raw_result_json': str(RAW_RESULT_PATH), 'groups': list(paired_reports), 'seeds': list(SEEDS)})
    except Exception as exc:
        failure = {
            'schema_version': SCHEMA_VERSION, 'semantic_id': SEMANTIC_ID, 'project': '65',
            'run_label': RUN_LABEL, 'run_mode': RUN_MODE, 'environment_profile': PROFILE_ID,
            'json_path': str(RAW_RESULT_PATH),
            'workload': {'model_id': MODEL_ID, 'max_length': MAX_LENGTH, 'steps': MAX_STEPS, 'seeds': list(SEEDS)},
            'status': 'failed', 'baseline': None, 'candidate': None,
            'error_type': type(exc).__name__, 'error': str(exc), 'evidence_level': 'gpu_attempt_failed',
        }
        if not RAW_RESULT_PATH.exists():
            save_json_record_once(RAW_RESULT_PATH, failure)
        raise
else:
    print('训练未启动;将 RUN_MODE 改为 real_gpu 后再运行。')

5.5 读取结果、artifact manifest 与复测记录 ​

读取训练 JSON 后,先核对 64 节数据身份、模型提交和三个配对种子,再查看两组的中位显存、步时、监督词元吞吐、独立评测损失与容量阶梯。三个 QLoRA adapter 都保存并哈希后,才写入来源清单。

实验组配对重复表示与 adapter峰值显存中位数监督词元/s 中位数独立评测 loss 中位数最大可行 micro-batchartifact 哈希evidencedecision
LoRA baseline三个相同 seedBF16 + LoRA待填写待填写待填写待探测待填写待填写待判断
QLoRA candidate与 baseline 一一配对NF4 + double quant + LoRA待填写待填写待填写待探测待填写待填写待判断
python
# 5.5:读取配对结果;组装全部 QLoRA adapter 的可追溯来源清单。
def _build_artifact_manifest(gpu_result):
    baseline, candidate = gpu_result['baseline'], gpu_result['candidate']
    return {
        'schema_version': 'qlora-artifact/v2', 'semantic_id': SEMANTIC_ID, 'project': '65',
        'run_label': RUN_LABEL, 'run_mode': RUN_MODE, 'environment_profile': PROFILE_ID,
        'manifest_role': 'training_provenance_only', 'artifact_type': 'adapter_or_merged_model',
        'base_model_id': MODEL_ID, 'model_revision': gpu_result['workload']['model_commit'], 'tokenizer_path': MODEL_ID,
        'upstream_data_manifest': str(UPSTREAM_DATA_MANIFEST), 'data_identity': gpu_result['workload'],
        'baseline_summary': baseline, 'candidate_summary': candidate,
        'artifacts': [{'seed': report['seed'], 'path': report['artifact_path'], 'sha256': report['artifact_hash']} for report in candidate['seed_reports']], 'artifact_format': 'peft_adapter',
        'training_config': gpu_result['config'],
        'quantization_config': {'base_model': 'nf4 + double quant', 'compute_dtype': candidate['seed_reports'][0]['compute_dtype'], 'low_bit_active': candidate['all_low_bit_active']},
        'adapter_config': {'r': 8, 'lora_alpha': 16, 'target_modules': ['q_proj', 'k_proj', 'v_proj', 'o_proj']},
        'quality_report': {'baseline_val_loss_median': baseline['val_loss_median'], 'candidate_val_loss_median': candidate['val_loss_median'], 'quality_tolerance': QUALITY_TOLERANCE},
        'scale_probe': gpu_result.get('scale_probe'),
        'environment': gpu_result['environment'], 'result_json': str(RESULT_PATH),
        'status': 'ready' if candidate['all_artifacts_hashed'] and candidate['all_low_bit_active'] else 'pending_artifact_export',
        'evidence_level': gpu_result['evidence_level'],
        'next_project': '62_sft_delivery_or_quantization_deployment',
        'quantization_artifact_note': '此 adapter 不能替代 GPTQ/AWQ/GGUF 量化 artifact。',
    }


saved_gpu_result = None
result_file = RAW_RESULT_PATH if RAW_RESULT_PATH.exists() else RESULT_PATH
if result_file.exists():
    saved_gpu_result = json.loads(result_file.read_text(encoding='utf-8'))
    recorded_label = saved_gpu_result.get('run_label')
    if recorded_label not in (None, RUN_LABEL):
        raise ValueError(f'结果标签不匹配:配置={RUN_LABEL}, 文件={recorded_label}')
    if saved_gpu_result.get('failure') or saved_gpu_result.get('status') == 'failed':
        print({'result_json': str(result_file), 'status': 'failed', 'failure': saved_gpu_result.get('error')})
    else:
        manifest_preview = _build_artifact_manifest(saved_gpu_result)
        print({'source_result': str(result_file), 'final_result': str(RESULT_PATH), 'artifact_manifest': str(ARTIFACT_MANIFEST_PATH), 'artifact_status': manifest_preview['status']})
else:
    print(f'等待 5.4 生成训练结果:{RAW_RESULT_PATH}')

5.6 解释结果与形成决策 ​

先检查三个种子是否一一配对、64 节数据身份是否一致、NF4 是否真正激活,再比较中位显存、步时和独立评测损失。速度优先时,QLoRA 必须控制步时回退;容量受限时,只要它扩大最大 micro-batch 且质量通过,就可以作为低资源训练路径采用。

决策项rtx5070ti_qlora_15b_capacity_v1 实测证据结果使用条件
配对与身份三个共享随机种子;64 的 1.5B 数据清单、模型提交和 LoRA 配置一致通过不与 0.5B LoRA 选型混合比较
显存与容量QLoRA 中位峰值分配显存 4,572.63 MiB,对照为 5,968.06 MiB(低 23.4%);最大 micro-batch 4 → 8通过目标是扩大可行 batch 时可采用
质量与速度验证损失差 +0.0386,处于 0.1 容忍带内;中位步时 +65.2%质量通过,速度未通过速度敏感负载继续使用 BF16 LoRA
项目决策NF4 状态、adapter 哈希、容量阶梯和三种子证据完整accept仅作为 1.5B、12 GB、容量受限训练路径

当前登记结果

字段登记内容
项目与标签65 节 QLoRA 容量选型;rtx5070ti_qlora_15b_capacity_v1
运行条件RTX 5070 Ti Laptop GPU;Qwen2.5-1.5B;BF16 LoRA 与 NF4 QLoRA 三种子配对
原始证据选型结果
正式证据选型结果内保存质量、步时、显存、容量阶梯和 NF4 状态
清单与交接产物清单;本地 adapters 由清单记录
结论accept:仅用于 1.5B、12 GB、容量受限路径;速度敏感负载保留 BF16 LoRA
python
# 5.6:根据配对重复、容量收益、低比特状态和质量门槛生成不可变项目决策。
def _compare_reports(baseline, candidate, scale_probe):
    baseline_loss, candidate_loss = baseline.get('val_loss_median'), candidate.get('val_loss_median')
    quality_ok = baseline_loss is not None and candidate_loss is not None and candidate_loss <= baseline_loss + QUALITY_TOLERANCE
    memory_delta_mb = candidate['peak_memory_mb_median'] - baseline['peak_memory_mb_median']
    memory_saving_ratio = -memory_delta_mb / baseline['peak_memory_mb_median']
    memory_saved = memory_delta_mb < 0
    step_time_regression_ratio = candidate['step_time_s_median'] / baseline['step_time_s_median'] - 1
    speed_ok = step_time_regression_ratio <= MAX_STEP_TIME_REGRESSION_RATIO
    capacity = {item['label']: item['max_micro_batch'] for item in scale_probe.get('reports', [])}
    capacity_benefit = None if scale_probe.get('status') != 'completed' else capacity.get('qlora_candidate', 0) > capacity.get('lora_baseline', 0)
    resource_benefit = capacity_benefit is True or memory_saving_ratio >= MIN_MEMORY_SAVING_RATIO
    paired = baseline.get('seeds') == candidate.get('seeds') == list(SEEDS)
    low_bit_verified = candidate.get('all_low_bit_active') and not baseline.get('all_low_bit_active')
    artifact_complete = candidate.get('all_artifacts_hashed')
    evidence_complete = paired and low_bit_verified and artifact_complete
    fixed_workload_accept = quality_ok and memory_saved and resource_benefit and speed_ok and evidence_complete
    capacity_limited_accept = quality_ok and memory_saved and capacity_benefit is True and evidence_complete
    decision = 'accept' if fixed_workload_accept or capacity_limited_accept else 'tune'
    return {
        'peak_memory_delta_mb': round(memory_delta_mb, 3), 'memory_saving_ratio': round(memory_saving_ratio, 4),
        'step_time_delta_s': round(candidate['step_time_s_median'] - baseline['step_time_s_median'], 4),
        'supervised_tokens_per_s_delta': round(candidate['supervised_tokens_per_s_median'] - baseline['supervised_tokens_per_s_median'], 3),
        'val_loss_delta': round(candidate_loss - baseline_loss, 6) if quality_ok else None,
        'quality_tolerance': QUALITY_TOLERANCE, 'quality_ok': quality_ok,
        'memory_saved': memory_saved, 'capacity_benefit': capacity_benefit, 'resource_benefit': resource_benefit,
        'step_time_regression_ratio': round(step_time_regression_ratio, 4), 'speed_ok': speed_ok,
        'paired': paired, 'low_bit_verified': low_bit_verified, 'artifact_complete': artifact_complete,
        'fixed_workload_accept': fixed_workload_accept, 'capacity_limited_accept': capacity_limited_accept,
        'selection_scope': 'both' if fixed_workload_accept and capacity_limited_accept else 'capacity_limited' if capacity_limited_accept else 'none',
        'decision': decision,
        'next_action': 'use_qlora_for_capacity_limited_training_and_keep_bf16_lora_for_speed_sensitive_runs' if capacity_limited_accept and not fixed_workload_accept else 'use_qlora_branch_for_low_resource_delivery' if decision == 'accept' else 'increase_model_or_sequence_before_retesting_qlora',
    }


if saved_gpu_result is None:
    print('尚无训练 JSON,等待完成 5.4。')
elif saved_gpu_result.get('failure') or saved_gpu_result.get('status') == 'failed':
    print({'decision': 'tune', 'reason': saved_gpu_result.get('error', '训练失败记录')})
else:
    comparison = _compare_reports(saved_gpu_result['baseline'], saved_gpu_result['candidate'], saved_gpu_result.get('scale_probe', {}))
    final_result = dict(saved_gpu_result)
    final_result.update({
        'schema_version': SCHEMA_VERSION, 'semantic_id': SEMANTIC_ID,
        'run_label': RUN_LABEL, 'run_mode': RUN_MODE, 'environment_profile': PROFILE_ID,
        'json_path': str(RESULT_PATH), 'comparison': comparison, 'decision': comparison['decision'],
    })
    if RESULT_PATH.exists():
        recorded_result = json.loads(RESULT_PATH.read_text(encoding='utf-8'))
        recorded_label = recorded_result.get('run_label')
        if recorded_label not in (None, RUN_LABEL):
            raise ValueError(f'最终结果标签不匹配:配置={RUN_LABEL}, 文件={recorded_label}')
    else:
        save_json_record_once(RESULT_PATH, final_result)

    artifact_manifest = _build_artifact_manifest(final_result)
    artifact_manifest.update({'comparison': comparison, 'decision': comparison['decision']})
    if ARTIFACT_MANIFEST_PATH.exists():
        recorded_manifest = json.loads(ARTIFACT_MANIFEST_PATH.read_text(encoding='utf-8'))
        recorded_label = recorded_manifest.get('run_label')
        if recorded_label not in (None, RUN_LABEL):
            raise ValueError(f'产物清单标签不匹配:配置={RUN_LABEL}, 文件={recorded_label}')
    else:
        save_json_record_once(ARTIFACT_MANIFEST_PATH, artifact_manifest)
    print(json.dumps({'decision': comparison['decision'], 'comparison': comparison, 'result_json': str(RESULT_PATH), 'artifact_manifest': str(ARTIFACT_MANIFEST_PATH)}, ensure_ascii=False, indent=2))

相关阅读 ​

以下资料按“低比特微调论文 → 开源实现 → 量化部署项目”排列,用于把预算、质量和低比特 kernel 约束连接到真实部署。

Released under the MIT License.