- 引入 MemoryRouterXL 与 v5/v6 流式多线程训练/编码管线 - 修复 prepare_memory_router_dataset 候选池重建缺陷(mega 家族 3568x 加速,输出逐字节相同) - 修复 v5 被破坏的拒答与多跳标签(train 未知样本 319 -> 16319,multi_hop 平均正例 1.00 -> 2.00) - 同存储预算下 V2-128 v6 逐轴 22/22 通过:Top-1 41.12% -> 94.62%,未知拒答 0.00% -> 100.00% - 记录三条被实测推翻的显然优化(logits_to_keep=1 反而慢 55%、XL 容量未带来收益) - 记忆手术跨架构可移植性 14/14,读写关闭时与原生模型逐位相同
566 lines
21 KiB
Python
566 lines
21 KiB
Python
"""Run a same-protocol 4B comparison: base Qwen, Chunk RAG, and Natural Memory.
|
|
|
|
The benchmark uses the same general-memory and real-repository query sets for
|
|
all systems. It reports answer correctness, refusal correctness, prompt/read
|
|
overhead, generation speed, and peak VRAM. Memory is kept in process RAM and
|
|
the GPU placement cap is shared by every model load.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import gc
|
|
import json
|
|
import math
|
|
import re
|
|
import time
|
|
from pathlib import Path
|
|
from statistics import mean
|
|
from typing import Any
|
|
|
|
import torch
|
|
|
|
from .benchmark_real_scale_memory_4b import (
|
|
_build_general_records,
|
|
_build_project_records,
|
|
_chat_generate,
|
|
_contains_answer,
|
|
_is_refusal,
|
|
_max_memory,
|
|
_native_cases,
|
|
_path,
|
|
_prepare_records,
|
|
_set_cuda_process_cap,
|
|
_source_files,
|
|
_sync,
|
|
)
|
|
from .qwen_integration import load_qwen_base, load_qwen_dynamic, load_tokenizer
|
|
from .stream_chat_qwen_memory import _chat_tensor
|
|
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parent
|
|
TERM_PATTERN = re.compile(r"[\u4e00-\u9fff]|[A-Za-z0-9_\-]+")
|
|
|
|
|
|
def _terms(text: str) -> set[str]:
|
|
return set(TERM_PATTERN.findall(str(text).lower()))
|
|
|
|
|
|
def _vram_snapshot(device: torch.device) -> dict[str, float | None]:
|
|
if device.type != "cuda":
|
|
return {"allocated_gb": None, "reserved_gb": None}
|
|
return {
|
|
"allocated_gb": torch.cuda.memory_allocated(device) / 1024**3,
|
|
"reserved_gb": torch.cuda.memory_reserved(device) / 1024**3,
|
|
}
|
|
|
|
|
|
def _record_entry(record: dict[str, Any]) -> dict[str, Any]:
|
|
text = str(record.get("text", ""))
|
|
return {
|
|
"text": text,
|
|
"terms": _terms(text),
|
|
"entity": str(record.get("entity", "")),
|
|
"attribute": str(record.get("attribute", "")),
|
|
"value": str(record.get("value", "")),
|
|
}
|
|
|
|
|
|
def _build_rag_index(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
return [_record_entry(record) for record in records if str(record.get("text", "")).strip()]
|
|
|
|
|
|
def _rag_retrieve(
|
|
index: list[dict[str, Any]],
|
|
query: str,
|
|
*,
|
|
top_k: int,
|
|
) -> tuple[list[dict[str, Any]], float]:
|
|
query_terms = _terms(query)
|
|
query_lower = query.strip().lower()
|
|
started = time.perf_counter()
|
|
scored: list[tuple[float, int, dict[str, Any]]] = []
|
|
for position, item in enumerate(index):
|
|
shared = len(query_terms.intersection(item["terms"]))
|
|
score = shared / math.sqrt(max(1, len(query_terms) * len(item["terms"])))
|
|
entity = item["entity"].strip().lower()
|
|
attribute = item["attribute"].strip().lower()
|
|
if len(entity) >= 4 and entity in query_lower:
|
|
score += 5.0
|
|
if attribute and attribute in query_lower:
|
|
score += 1.0
|
|
scored.append((score, -position, item))
|
|
scored.sort(key=lambda value: (value[0], value[1]), reverse=True)
|
|
elapsed_ms = (time.perf_counter() - started) * 1000.0
|
|
return [item for _, _, item in scored[: max(1, int(top_k))]], elapsed_ms
|
|
|
|
|
|
def _rag_prompt(query: str, records: list[dict[str, Any]]) -> str:
|
|
evidence = "\n".join(
|
|
f"[证据 {index}] {record['text']}"
|
|
for index, record in enumerate(records, 1)
|
|
)
|
|
return (
|
|
"以下是检索器返回的记忆证据。只能使用证据中明确出现的事实,"
|
|
"先核对实体、属性和已确认值;多个候选并存时不要把候选拼成一个事实;"
|
|
"新旧冲突时优先最新且来源更可靠的证据;找不到目标时请明确说不知道,"
|
|
"不要用相似用户的信息代替。\n"
|
|
"---记忆证据开始---\n"
|
|
+ evidence
|
|
+ "\n---记忆证据结束---\n问题:"
|
|
+ query
|
|
)
|
|
|
|
|
|
@torch.inference_mode()
|
|
def _base_generate(
|
|
model: Any,
|
|
tokenizer: Any,
|
|
prompt: str,
|
|
device: torch.device,
|
|
*,
|
|
max_new_tokens: int,
|
|
) -> dict[str, Any]:
|
|
encoded = _chat_tensor(tokenizer, prompt)
|
|
encoded = {key: value.to(device) for key, value in encoded.items()}
|
|
if device.type == "cuda":
|
|
torch.cuda.reset_peak_memory_stats(device)
|
|
_sync(device)
|
|
started = time.perf_counter()
|
|
output = model.generate(
|
|
**encoded,
|
|
max_new_tokens=max_new_tokens,
|
|
do_sample=False,
|
|
use_cache=True,
|
|
pad_token_id=tokenizer.pad_token_id,
|
|
)
|
|
_sync(device)
|
|
elapsed = time.perf_counter() - started
|
|
response_ids = output[0, encoded["input_ids"].shape[1] :]
|
|
response = tokenizer.decode(
|
|
response_ids.detach().cpu().tolist(),
|
|
skip_special_tokens=True,
|
|
).strip()
|
|
generated_tokens = int(response_ids.numel())
|
|
row = {
|
|
"status": "ok",
|
|
"response": response,
|
|
"prompt_tokens": int(encoded["input_ids"].shape[1]),
|
|
"generated_tokens": generated_tokens,
|
|
"total_latency_s": elapsed,
|
|
"decode_tok_s": generated_tokens / max(elapsed, 1e-9),
|
|
}
|
|
row.update(_vram_snapshot(device))
|
|
del output
|
|
return row
|
|
|
|
|
|
def _quality_summary(rows: list[dict[str, Any]]) -> dict[str, Any]:
|
|
answerable = [row for row in rows if row["answerable"]]
|
|
unknown = [row for row in rows if not row["answerable"]]
|
|
return {
|
|
"cases": len(rows),
|
|
"answerable_cases": len(answerable),
|
|
"answerable_correct": sum(int(row["correct"]) for row in answerable),
|
|
"answerable_accuracy": sum(int(row["correct"]) for row in answerable) / max(1, len(answerable)),
|
|
"unknown_cases": len(unknown),
|
|
"unknown_correct": sum(int(row["correct"]) for row in unknown),
|
|
"unknown_refusal_accuracy": sum(int(row["correct"]) for row in unknown) / max(1, len(unknown)),
|
|
"mean_prompt_tokens": mean(row["prompt_tokens"] for row in rows) if rows else 0.0,
|
|
"mean_total_latency_ms": mean(row["total_latency_s"] for row in rows) * 1000.0 if rows else 0.0,
|
|
"mean_decode_tok_s": mean(row["decode_tok_s"] for row in rows) if rows else 0.0,
|
|
"peak_allocated_gb": max((row.get("allocated_gb") or 0.0 for row in rows), default=0.0),
|
|
"peak_reserved_gb": max((row.get("reserved_gb") or 0.0 for row in rows), default=0.0),
|
|
}
|
|
|
|
|
|
def _run_base_system(
|
|
model: Any,
|
|
tokenizer: Any,
|
|
rows: list[dict[str, Any]],
|
|
index: list[dict[str, Any]],
|
|
device: torch.device,
|
|
*,
|
|
mode: str,
|
|
rag_top_k: int,
|
|
max_new_tokens: int,
|
|
) -> dict[str, Any]:
|
|
output_rows: list[dict[str, Any]] = []
|
|
for row in rows:
|
|
query = str(row["query"])
|
|
retrieve_ms = 0.0
|
|
retrieved: list[dict[str, Any]] = []
|
|
if mode == "chunk_rag":
|
|
retrieved, retrieve_ms = _rag_retrieve(index, query, top_k=rag_top_k)
|
|
prompt = _rag_prompt(query, retrieved)
|
|
else:
|
|
prompt = query
|
|
generated = _base_generate(
|
|
model,
|
|
tokenizer,
|
|
prompt,
|
|
device,
|
|
max_new_tokens=max_new_tokens,
|
|
)
|
|
expected = str(row.get("expected", ""))
|
|
answerable = bool(row.get("answerable", True))
|
|
correct = (
|
|
_contains_answer(generated["response"], expected)
|
|
if answerable
|
|
else _is_refusal(generated["response"])
|
|
)
|
|
retrieved_target = answerable and any(
|
|
_contains_answer(item["value"], expected)
|
|
or _contains_answer(item["text"], expected)
|
|
for item in retrieved
|
|
)
|
|
output_rows.append(
|
|
{
|
|
"id": row.get("id", ""),
|
|
"query": query,
|
|
"expected": expected,
|
|
"answerable": answerable,
|
|
"correct": bool(correct),
|
|
"retrieved_target": bool(retrieved_target),
|
|
"retrieved_count": len(retrieved),
|
|
"retriever_ms": retrieve_ms,
|
|
"retrieved_values": [item["value"] for item in retrieved],
|
|
**generated,
|
|
}
|
|
)
|
|
summary = _quality_summary(output_rows)
|
|
summary["mean_retriever_ms"] = mean(row["retriever_ms"] for row in output_rows) if output_rows else 0.0
|
|
reader_values = [float(row["reader_ms"]) for row in output_rows if "reader_ms" in row]
|
|
if reader_values:
|
|
summary["mean_reader_ms"] = mean(reader_values)
|
|
summary["answerable_retrieval_recall"] = (
|
|
sum(int(row["retrieved_target"]) for row in output_rows if row["answerable"])
|
|
/ max(1, sum(int(row["answerable"]) for row in output_rows))
|
|
)
|
|
summary["rows"] = output_rows
|
|
return summary
|
|
|
|
|
|
def _run_natural_memory(
|
|
model: Any,
|
|
tokenizer: Any,
|
|
general_records: list[dict[str, Any]],
|
|
general_rows: list[dict[str, Any]],
|
|
project_records: list[dict[str, Any]],
|
|
project_rows: list[dict[str, Any]],
|
|
device: torch.device,
|
|
*,
|
|
generation_general: int,
|
|
generation_project: int,
|
|
max_new_tokens: int,
|
|
encode_batch_size: int,
|
|
) -> dict[str, Any]:
|
|
model.eval()
|
|
model.memory_config.memory_top_k_records = 2
|
|
model.memory_os_v2.bank.top_k_records = 2
|
|
model.reset_memory(batch_size=1, device=device)
|
|
|
|
output: dict[str, Any] = {}
|
|
prepared_general = _prepare_records(
|
|
model,
|
|
tokenizer,
|
|
general_records,
|
|
device,
|
|
batch_size=max(1, encode_batch_size),
|
|
)
|
|
model.memory_os_v2.write_batch(prepared_general)
|
|
general_rows = general_rows[: max(1, generation_general)]
|
|
general_output: list[dict[str, Any]] = []
|
|
for row in general_rows:
|
|
generated = _chat_generate(
|
|
model,
|
|
tokenizer,
|
|
str(row["query"]),
|
|
device,
|
|
max_new_tokens,
|
|
)
|
|
expected = str(row.get("expected", ""))
|
|
answerable = bool(row.get("answerable", True))
|
|
correct = (
|
|
_contains_answer(generated["response"], expected)
|
|
if answerable
|
|
else _is_refusal(generated["response"])
|
|
)
|
|
general_output.append(
|
|
{
|
|
"id": row.get("id", ""),
|
|
"query": row["query"],
|
|
"expected": expected,
|
|
"answerable": answerable,
|
|
"correct": bool(correct),
|
|
"prompt_tokens": int(generated["public_prompt_tokens"] + generated["prefix_tokens"]),
|
|
"generated_tokens": int(generated["generated_tokens"]),
|
|
"total_latency_s": float(generated["generation_seconds"]),
|
|
"decode_tok_s": int(generated["generated_tokens"]) / max(float(generated["generation_seconds"]), 1e-9),
|
|
"reader_ms": float(generated["memory_read_seconds"]) * 1000.0,
|
|
"prefix_used": bool(generated["prefix_used"]),
|
|
"prefix_tokens": int(generated["prefix_tokens"]),
|
|
"retrieved_values": list(generated["selected_values"]),
|
|
"retrieved_ids": list(generated["selected_record_ids"]),
|
|
"allocated_gb": _vram_snapshot(device)["allocated_gb"],
|
|
"reserved_gb": _vram_snapshot(device)["reserved_gb"],
|
|
"response": generated["response"],
|
|
}
|
|
)
|
|
output["general"] = _quality_summary(general_output)
|
|
output["general"]["rows"] = general_output
|
|
|
|
model.memory_os_v2 = model._new_memory_os_v2(model.memory.hidden_size)
|
|
model.reset_memory(batch_size=1, device=device)
|
|
prepared_project = _prepare_records(
|
|
model,
|
|
tokenizer,
|
|
project_records,
|
|
device,
|
|
batch_size=max(1, encode_batch_size),
|
|
)
|
|
model.memory_os_v2.write_batch(prepared_project)
|
|
project_rows = project_rows[: max(1, generation_project)]
|
|
project_output: list[dict[str, Any]] = []
|
|
for row in project_rows:
|
|
generated = _chat_generate(
|
|
model,
|
|
tokenizer,
|
|
str(row["query"]),
|
|
device,
|
|
max_new_tokens,
|
|
)
|
|
expected = str(row.get("expected", ""))
|
|
correct = _contains_answer(generated["response"], expected)
|
|
snapshot = _vram_snapshot(device)
|
|
project_output.append(
|
|
{
|
|
"id": row.get("id", ""),
|
|
"query": row["query"],
|
|
"expected": expected,
|
|
"answerable": True,
|
|
"correct": bool(correct),
|
|
"prompt_tokens": int(generated["public_prompt_tokens"] + generated["prefix_tokens"]),
|
|
"generated_tokens": int(generated["generated_tokens"]),
|
|
"total_latency_s": float(generated["generation_seconds"]),
|
|
"decode_tok_s": int(generated["generated_tokens"]) / max(float(generated["generation_seconds"]), 1e-9),
|
|
"reader_ms": float(generated["memory_read_seconds"]) * 1000.0,
|
|
"prefix_used": bool(generated["prefix_used"]),
|
|
"prefix_tokens": int(generated["prefix_tokens"]),
|
|
"retrieved_values": list(generated["selected_values"]),
|
|
"retrieved_ids": list(generated["selected_record_ids"]),
|
|
"allocated_gb": snapshot["allocated_gb"],
|
|
"reserved_gb": snapshot["reserved_gb"],
|
|
"response": generated["response"],
|
|
}
|
|
)
|
|
output["project"] = _quality_summary(project_output)
|
|
output["project"]["rows"] = project_output
|
|
return output
|
|
|
|
|
|
def _release(model: Any) -> None:
|
|
del model
|
|
gc.collect()
|
|
if torch.cuda.is_available():
|
|
torch.cuda.empty_cache()
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--base-model", default=r"H:\Memory")
|
|
parser.add_argument(
|
|
"--memory-model",
|
|
default=r"H:\Memory\V2_dpskw\qwen3_5_4b_natural_memory_v2",
|
|
)
|
|
parser.add_argument("--data-root", default=str(PROJECT_ROOT / "data"))
|
|
parser.add_argument("--output", default=str(PROJECT_ROOT / "baseline_compare_4b.json"))
|
|
parser.add_argument("--general-cases", type=int, default=640)
|
|
parser.add_argument("--project-records", type=int, default=8192)
|
|
parser.add_argument("--project-targets", type=int, default=256)
|
|
parser.add_argument("--generation-general", type=int, default=128)
|
|
parser.add_argument("--generation-project", type=int, default=64)
|
|
parser.add_argument("--rag-top-k", type=int, default=2)
|
|
parser.add_argument("--max-new-tokens", type=int, default=32)
|
|
parser.add_argument("--encode-batch-size", type=int, default=16)
|
|
parser.add_argument("--gpu-memory-gb", type=float, default=10.0)
|
|
parser.add_argument("--no-4bit", action="store_true")
|
|
parser.add_argument(
|
|
"--reuse-original-report",
|
|
default=None,
|
|
help="reuse original_qwen results from an earlier report and rerun only Natural Memory",
|
|
)
|
|
args = parser.parse_args()
|
|
_set_cuda_process_cap(args.gpu_memory_gb)
|
|
|
|
tokenizer = load_tokenizer(_path(args.base_model))
|
|
general_cases = _native_cases(_path(args.data_root), max(1, int(args.general_cases)))
|
|
general_raw_records, general_queries = _build_general_records(general_cases)
|
|
general_generation_rows = [
|
|
row for row in general_queries if row["split"] == "eval"
|
|
][: max(1, int(args.generation_general))]
|
|
project_files = _source_files()
|
|
project_raw_records, project_queries, project_meta = _build_project_records(
|
|
project_files,
|
|
record_count=max(128, int(args.project_records)),
|
|
target_count=max(1, int(args.project_targets)),
|
|
chunk_tokens=512,
|
|
)
|
|
project_generation_rows = [
|
|
{"id": f"project-{index}", "query": row["query"], "expected": row["expected"], "answerable": True}
|
|
for index, row in enumerate(project_queries[: max(1, int(args.generation_project))])
|
|
]
|
|
general_index = _build_rag_index(general_raw_records)
|
|
project_index = _build_rag_index(project_raw_records)
|
|
use_4bit = not args.no_4bit
|
|
max_memory = _max_memory(args.gpu_memory_gb)
|
|
|
|
reused_original_report = None
|
|
if args.reuse_original_report:
|
|
reused_original_report = json.loads(
|
|
_path(args.reuse_original_report).read_text(encoding="utf-8")
|
|
)
|
|
original_system = reused_original_report.get("systems", {}).get("original_qwen")
|
|
if not isinstance(original_system, dict):
|
|
raise ValueError("reuse report does not contain systems.original_qwen")
|
|
base_load_vram = original_system.get("load_vram")
|
|
base_general = original_system["general"]
|
|
base_project = original_system["project"]
|
|
print("reusing original Qwen results")
|
|
else:
|
|
print("loading original Qwen 4B")
|
|
base = load_qwen_base(
|
|
_path(args.base_model),
|
|
load_in_4bit=use_4bit,
|
|
max_memory=max_memory,
|
|
)
|
|
base.eval()
|
|
base_device = base.get_input_embeddings().weight.device
|
|
base_load_vram = _vram_snapshot(base_device)
|
|
base_general = {
|
|
"no_memory": _run_base_system(
|
|
base,
|
|
tokenizer,
|
|
general_generation_rows,
|
|
general_index,
|
|
base_device,
|
|
mode="no_memory",
|
|
rag_top_k=args.rag_top_k,
|
|
max_new_tokens=max(1, args.max_new_tokens),
|
|
),
|
|
"chunk_rag": _run_base_system(
|
|
base,
|
|
tokenizer,
|
|
general_generation_rows,
|
|
general_index,
|
|
base_device,
|
|
mode="chunk_rag",
|
|
rag_top_k=max(1, args.rag_top_k),
|
|
max_new_tokens=max(1, args.max_new_tokens),
|
|
),
|
|
}
|
|
base_project = {
|
|
"no_memory": _run_base_system(
|
|
base,
|
|
tokenizer,
|
|
project_generation_rows,
|
|
project_index,
|
|
base_device,
|
|
mode="no_memory",
|
|
rag_top_k=args.rag_top_k,
|
|
max_new_tokens=max(1, args.max_new_tokens),
|
|
),
|
|
"chunk_rag": _run_base_system(
|
|
base,
|
|
tokenizer,
|
|
project_generation_rows,
|
|
project_index,
|
|
base_device,
|
|
mode="chunk_rag",
|
|
rag_top_k=max(1, args.rag_top_k),
|
|
max_new_tokens=max(1, args.max_new_tokens),
|
|
),
|
|
}
|
|
_release(base)
|
|
# Drop the caller's reference as well. Otherwise the original Qwen
|
|
# remains resident while Natural Memory is loaded below, making the
|
|
# latter's VRAM measurement include two complete 4B models.
|
|
base = None
|
|
|
|
print("loading Natural Memory 4B")
|
|
memory = load_qwen_dynamic(
|
|
_path(args.memory_model),
|
|
load_in_4bit=use_4bit,
|
|
max_memory=max_memory,
|
|
)
|
|
memory_device = memory._find_layer_device()
|
|
natural = _run_natural_memory(
|
|
memory,
|
|
tokenizer,
|
|
general_raw_records,
|
|
general_generation_rows,
|
|
project_raw_records,
|
|
project_generation_rows,
|
|
memory_device,
|
|
generation_general=args.generation_general,
|
|
generation_project=args.generation_project,
|
|
max_new_tokens=max(1, args.max_new_tokens),
|
|
encode_batch_size=max(1, args.encode_batch_size),
|
|
)
|
|
natural_load_vram = _vram_snapshot(memory_device)
|
|
_release(memory)
|
|
|
|
report = {
|
|
"benchmark": "baseline_compare_4b",
|
|
"base_model": str(_path(args.base_model)),
|
|
"memory_model": str(_path(args.memory_model)),
|
|
"quantization": "4bit_nf4" if use_4bit else "none",
|
|
"gpu_memory_cap_gb": float(args.gpu_memory_gb),
|
|
"general_cases": len(general_cases),
|
|
"general_records": len(general_raw_records),
|
|
"project": project_meta,
|
|
"protocol": {
|
|
"generation_general": len(general_generation_rows),
|
|
"generation_project": len(project_generation_rows),
|
|
"rag_top_k": int(args.rag_top_k),
|
|
"max_new_tokens": int(args.max_new_tokens),
|
|
"same_tokenizer": True,
|
|
"same_sampling": "greedy",
|
|
"memory_storage": "process_ram; selected Natural Memory records promoted to bounded GPU cache",
|
|
"reused_original_report": str(_path(args.reuse_original_report)) if args.reuse_original_report else None,
|
|
},
|
|
"systems": {
|
|
"original_qwen": {
|
|
"load_vram": base_load_vram,
|
|
"general": base_general,
|
|
"project": base_project,
|
|
},
|
|
"natural_memory": {
|
|
"load_vram": natural_load_vram,
|
|
"general": natural["general"],
|
|
"project": natural["project"],
|
|
},
|
|
},
|
|
"limitations": [
|
|
"Chunk RAG uses a CPU lexical candidate index with exact entity/attribute bonuses; it is a transparent baseline, not a hosted embedding service.",
|
|
"The general corpus is the local native-memory benchmark; the project corpus is the current repository source and documentation.",
|
|
"Latency for Natural Memory is split into reader_ms and generation is measured by the existing model wrapper; this first comparison prioritizes correctness and VRAM.",
|
|
],
|
|
}
|
|
output = _path(args.output)
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
output.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
compact = {
|
|
name: {
|
|
"general": {key: value for key, value in system["general"].items() if key != "rows"},
|
|
"project": {key: value for key, value in system["project"].items() if key != "rows"},
|
|
}
|
|
for name, system in report["systems"].items()
|
|
}
|
|
print(json.dumps(compact, ensure_ascii=False, indent=2))
|
|
print(f"saved={output}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|