Files
natural-memory-nm21/stress_test_natural_memory.py
WpyQwq 643e22ecb9 Natural Memory NM2.1: 记忆路由器分叉、数据集缺陷修复与全轴评测证据
- 引入 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,读写关闭时与原生模型逐位相同
2026-09-19 11:11:31 +08:00

199 lines
8.1 KiB
Python

"""Run a bounded, restart-like Natural Memory v2 endurance test.
The test deliberately clears only the in-process working copy. It never
calls ``save_embedded_memory_weights`` and therefore does not modify the
shipped model package. Defaults are conservative for a 12 GB GPU.
"""
from __future__ import annotations
import argparse
import gc
import json
import statistics
import time
from pathlib import Path
import torch
from .qwen_integration import load_qwen_dynamic, load_tokenizer
PROJECT_ROOT = Path(__file__).resolve().parent
def _project_path(value: str | Path) -> Path:
path = Path(value)
if path.is_absolute() or path.exists():
return path
return PROJECT_ROOT / path
def _gpu_stats() -> dict[str, float | int | None]:
if not torch.cuda.is_available():
return {"allocated_mb": None, "reserved_mb": None, "free_mb": None, "total_mb": None}
allocated = torch.cuda.memory_allocated() / (1024 * 1024)
reserved = torch.cuda.memory_reserved() / (1024 * 1024)
free, total = torch.cuda.mem_get_info()
return {
"allocated_mb": round(allocated, 2),
"reserved_mb": round(reserved, 2),
"free_mb": round(free / (1024 * 1024), 2),
"total_mb": round(total / (1024 * 1024), 2),
}
def _long_input(tokenizer, target_tokens: int) -> tuple[torch.Tensor, torch.Tensor]:
seed_text = (
"这是 Natural Memory v2 的长上下文压力测试。它应当把旧上下文归档到有界的记忆记录,"
"保留最近工作窗口,并且不让当前 token 对全部页面做注意力。"
)
seed = tokenizer(seed_text, add_special_tokens=False, return_tensors="pt")["input_ids"][0]
repeats = max(1, (target_tokens + seed.numel() - 1) // seed.numel())
ids = seed.repeat(repeats)[:target_tokens].unsqueeze(0)
return ids, torch.ones_like(ids)
@torch.inference_mode()
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--model-path", default="qwen3_5_4b_natural_memory_v2")
parser.add_argument("--rounds", type=int, default=40)
parser.add_argument("--records-per-round", type=int, default=8)
parser.add_argument("--long-context-every", type=int, default=10)
parser.add_argument("--long-context-tokens", type=int, default=2048)
parser.add_argument("--kv-budget", type=int, default=1024)
parser.add_argument("--chunk-tokens", type=int, default=256)
parser.add_argument("--duration-minutes", type=float, default=0.0)
parser.add_argument("--output", default="natural_memory_v2_stress_report.json")
parser.add_argument("--no-4bit", action="store_true")
args = parser.parse_args()
if args.rounds < 1 or args.records_per_round < 1:
raise SystemExit("--rounds and --records-per-round must be positive")
if args.kv_budget < 1 or args.long_context_tokens < args.kv_budget:
raise SystemExit("--long-context-tokens must be >= --kv-budget >= 1")
model_path = _project_path(args.model_path)
tokenizer = load_tokenizer(model_path)
model = load_qwen_dynamic(model_path, load_in_4bit=not args.no_4bit)
model.eval()
device = model._find_layer_device()
if model.memory_os_v2 is None:
raise RuntimeError("the selected model does not contain hierarchical memory")
# Work on an empty ephemeral memory instance. The embedded package on
# disk is never changed by this script.
model.clear_hierarchical_memory()
model.memory_os_v2.read_threshold = 0.0
model.memory_os_v2.kv_budget.max_tokens = min(
int(args.kv_budget), int(model.memory_os_v2.kv_budget.hard_max_tokens)
)
model.reset_runtime_memory(batch_size=1, device=device)
latencies: list[float] = []
writes = 0
read_hits = 0
read_attempts = 0
compactions = 0
archived_records = 0
errors: list[str] = []
start = time.perf_counter()
next_record = 0
try:
for round_index in range(args.rounds):
if args.duration_minutes > 0 and (time.perf_counter() - start) >= args.duration_minutes * 60:
break
round_start = time.perf_counter()
try:
for _ in range(args.records_per_round):
record_index = next_record
next_record += 1
key = torch.randn(model.memory.hidden_size)
token_ids = torch.tensor([1000 + (record_index % 10000), 2000 + (record_index % 10000)])
text = f"stress record {record_index} belongs to Natural Memory endurance test"
record, _ = model.memory_os_v2.write(
text=text,
key=key,
summary=key,
token_ids=token_ids,
token_mask=torch.ones_like(token_ids, dtype=torch.bool),
memory_type="stress_test",
entity=f"stress-user-{record_index % 7}",
attribute=f"attribute-{record_index}",
value=str(record_index),
importance=0.9,
confidence=0.99,
source="stress_test",
trusted=True,
force=True,
)
writes += 1
read_attempts += 1
found, _ = model.memory_os_v2.read(
query_key=key,
query_text=text,
query_token_ids=token_ids,
top_k_pages=4,
top_k_records=8,
max_hops=3,
)
if any(item.record_id == record.record_id for item in found):
read_hits += 1
if args.long_context_every > 0 and (round_index + 1) % args.long_context_every == 0:
ids, mask = _long_input(tokenizer, args.long_context_tokens)
_, _, plan = model.compact_context_for_kv(
ids.to(device),
mask.to(device),
archive=True,
chunk_tokens=args.chunk_tokens,
)
if plan.get("compacted"):
compactions += 1
archived_records += int(plan.get("archived_records", 0))
except Exception as error: # keep the report useful after one bad round
errors.append(f"round {round_index}: {type(error).__name__}: {error}")
latencies.append(time.perf_counter() - round_start)
finally:
final_stats = model.memory_v2_stats()
audit = model.audit_memory()
gpu_peak = _gpu_stats()
model.close_memory_storage()
del model
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
latencies_sorted = sorted(latencies)
report = {
"format_version": 1,
"model_path": str(model_path),
"rounds_completed": len(latencies),
"records_per_round": args.records_per_round,
"writes": writes,
"read_attempts": read_attempts,
"read_hits": read_hits,
"read_hit_rate": read_hits / max(1, read_attempts),
"compactions": compactions,
"archived_records": archived_records,
"round_latency_seconds": {
"mean": statistics.fmean(latencies) if latencies else 0.0,
"p50": latencies_sorted[len(latencies_sorted) // 2] if latencies_sorted else 0.0,
"p95": latencies_sorted[min(len(latencies_sorted) - 1, int(len(latencies_sorted) * 0.95))] if latencies_sorted else 0.0,
"max": max(latencies, default=0.0),
},
"max_gpu": gpu_peak,
"final_memory_stats": final_stats,
"audit": audit,
"errors": errors,
"package_mutated": False,
"warning": "This is an endurance/safety smoke test, not a quality benchmark.",
}
output = _project_path(args.output)
output.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps(report, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()