Files
tick-stock-panel/backend/app/factors/materialize_cache.py
T
shy3130 6d51bb52de feat(factors): P2b 内容寻址因子列缓存 — +10 因子热路径增量 0.1ms, P2 两项验收达成
自定义 (DSL) 因子此前每次求值都全量重算 frame_transform (132 万行帧上
每因子 ~36ms, 随因子数线性增长 — 回测/守护复验/实验室批量的共同热路径)。

- app/factors/materialize_cache.py: 键 = sha256(定义 + 依赖列内容指纹 +
  行数) — 内容指纹即失效传播 (公式变/输入任何值变 → 键变), 无需数据版本
  号。两层: 进程内 LRU (字节上限) + 磁盘 parquet (跨进程, 文件数上限,
  目录可整删)。fail-open: 任何故障回退现算。列指纹按帧记忆化 (weakref
  finalize 回收), 同帧 K 个因子每列只哈希一次 (~8ms/百万行)。
- materialize_scoring_columns 两段式: 命中列一次批量附着 (成本与因子数
  无关); 未命中逐个现算 (依赖顺序)。DSL 分阶段临时列不再泄漏进下游帧
  (与命中路径列集合完全一致)。
- 顺带修复存量 bug: winsorize(x) 省略 k 时 DSL 编译器直接 IndexError
  (该写法此前完全不可用), 补回归测试。
- 基准 (132 万行, K3→K13): 热路径 0.0ms→0.1ms (+10 因子绝对增量 0.1ms,
  基线 +400ms 线性); 冷启动 480.7ms vs 无缓存 478ms (开销 ~0.5%)。
  端到端优化器复测 trials 4.23×/含 prepare 3.16×, stats 完全一致。
- 回归: 552 个相关测试全过 (含 6 个缓存契约测试: 命中等价/输入与定义
  变更失效/损坏回退/小帧旁路/临时列不泄漏); ruff 新增文件全清。

Signed-off-by: shy3130 <shy3130@qq.com>
2026-10-26 14:36:58 +08:00

189 lines
6.8 KiB
Python

"""L2 内容寻址因子列缓存 (P2b) — 自定义 (DSL) 因子物化结果的复用。
键 = sha256(因子定义指纹 + 全部依赖列内容指纹 + 行数)。内容指纹即失效
传播: 公式变 / 依赖列任何值变 → 键变 → 自然失效, 不需要数据版本号或
mtime 签名 — 键本身覆盖「同一因子作用在同一输入上」的全部语义。
fail-open: 缓存读写任何故障 (目录不可写 / parquet 损坏 / 超限) 都回退
现算。缓存是纯优化, 永不影响正确性; 目录可整删 (内容寻址, 删除安全)。
两层:
- 进程内 LRU (键 → 列, 字节上限): 同帧/同数据的重复求值近零成本
(守护复验、优化器、实验室批量回测);
- 磁盘 parquet (data/cache/factor_columns/<key>.parquet, 文件数上限):
跨进程复用 (回测 worker 每任务一个进程)。
列内容指纹按 (frame, 列名) 记忆 — 同一帧上 K 个因子共享依赖列, 每列只
哈希一次 (hash+sha256 ≈ 8ms/百万行, 远低于因子现算)。frame 指纹表用
weakref 弱持有, 帧被回收后自动失效, 不阻止内存释放。
"""
from __future__ import annotations
import contextlib
import hashlib
import logging
import threading
import weakref
from collections import OrderedDict
from dataclasses import dataclass
from pathlib import Path
import polars as pl
from app.config import settings
logger = logging.getLogger(__name__)
# 小帧不缓存: 哈希+IO 比现算还贵 (筛选页预览、单标的明细等)。
_MIN_ROWS = 50_000
_LRU_MAX_BYTES = 256 * 1024 * 1024
_DISK_MAX_FILES = 1000
_DISK_SUBDIR = Path("cache") / "factor_columns"
@dataclass
class _Entry:
series: pl.Series
nbytes: int
_lru: OrderedDict[str, _Entry] = OrderedDict()
_lru_bytes = 0
# (id(frame) → {列名: 指纹}); finalize 在帧被 GC 时清除条目 — 不强持帧引用,
# id 也不会复用到陈旧条目 (条目随帧消亡)。
_frame_digests: dict[int, dict[str, str]] = {}
_stats = {"lru_hit": 0, "disk_hit": 0, "miss": 0, "store": 0, "bypass_small": 0, "error": 0}
_lock = threading.Lock()
def stats() -> dict[str, int]:
"""缓存命中计数 (观测/测试用)。"""
return dict(_stats)
def invalidate(*, disk: bool = False) -> None:
"""清空进程内 LRU 并重置命中计数; disk=True 时连磁盘缓存目录一起清
(内容寻址, 整删安全)。"""
global _lru_bytes
with _lock:
_lru.clear()
_lru_bytes = 0
_frame_digests.clear()
_stats.update({"lru_hit": 0, "disk_hit": 0, "miss": 0, "store": 0, "bypass_small": 0, "error": 0})
if disk:
import shutil
root = Path(settings.data_dir) / _DISK_SUBDIR
if root.exists():
shutil.rmtree(root, ignore_errors=True)
def _column_digest(frame: pl.DataFrame, column: str) -> str:
"""单列内容指纹 (同帧记忆化): dtype + null 数 + 逐行 murmur 哈希的 sha256。
Series.hash 跨进程确定 (同 polars 版本, 固定种子), 行序敏感 — 顺序不同
视为不同输入 (物化按行位对齐, 顺序本来就是语义的一部分)。"""
with _lock:
frame_key = id(frame)
memo = _frame_digests.get(frame_key)
if memo is None:
memo = {}
_frame_digests[frame_key] = memo
weakref.finalize(frame, _frame_digests.pop, frame_key, None)
elif column in memo:
return memo[column]
series = frame.get_column(column)
digest = hashlib.sha256()
digest.update(column.encode())
digest.update(str(series.dtype).encode())
digest.update(f"nulls={series.null_count()};rows={series.len()};".encode())
digest.update(series.hash().to_numpy().tobytes())
value = digest.hexdigest()
with _lock:
memo[column] = value
return value
def cache_key(
factor_id: str,
formula_text: str,
version: int,
frame: pl.DataFrame,
dependency_columns,
) -> str | None:
"""定义 + 输入内容 → 缓存键; None = 不缓存 (小帧 / 依赖列缺一)。"""
if frame.height < _MIN_ROWS:
_stats["bypass_small"] += 1
return None
columns = sorted(set(dependency_columns))
digests: list[str] = []
for column in columns:
if column not in frame.columns:
return None # 缺列时 frame_transform 本就会 E013 拒绝, 无缓存意义
digests.append(_column_digest(frame, column))
digest = hashlib.sha256()
digest.update(f"{factor_id}|v{version}|{formula_text}|rows={frame.height};".encode())
digest.update(";".join(digests).encode())
return digest.hexdigest()
def _disk_path(key: str) -> Path:
return Path(settings.data_dir) / _DISK_SUBDIR / f"{key}.parquet"
def get_cached(key: str, expected_rows: int) -> pl.Series | None:
"""取缓存的因子列; LRU 未命中读磁盘并晋升; 任何异常按未命中处理。"""
global _lru_bytes
with _lock:
entry = _lru.get(key)
if entry is not None:
_lru.move_to_end(key)
if entry.series.len() == expected_rows:
_stats["lru_hit"] += 1
return entry.series
_lru.pop(key)
_lru_bytes -= entry.nbytes
path = _disk_path(key)
if not path.exists():
_stats["miss"] += 1
return None
try:
series = pl.read_parquet(path).to_series(0)
if series.len() != expected_rows:
raise ValueError(f"缓存行数不符: {series.len()} != {expected_rows}")
except Exception as e: # noqa: BLE001 — 损坏缓存删除并回退现算
_stats["error"] += 1
logger.debug("factor cache read failed %s: %s", key[:12], e)
with contextlib.suppress(OSError):
path.unlink(missing_ok=True)
_stats["miss"] += 1
return None
_stats["disk_hit"] += 1
put_cached(key, series)
return series
def put_cached(key: str, series: pl.Series) -> None:
"""写入两层缓存; 超限/故障静默跳过 (fail-open)。"""
global _lru_bytes
nbytes = series.len() * 8 # 物化因子列均为 float64
with _lock:
if key not in _lru:
_lru[key] = _Entry(series=series, nbytes=nbytes)
_lru_bytes += nbytes
while _lru_bytes > _LRU_MAX_BYTES and len(_lru) > 1:
_, evicted = _lru.popitem(last=False)
_lru_bytes -= evicted.nbytes
_stats["store"] += 1
try:
root = _disk_path(key).parent
if sum(1 for _ in root.glob("*.parquet")) >= _DISK_MAX_FILES:
return # 文件数上限: 跳写不淘汰 (目录可整删, 见模块 docstring)
root.mkdir(parents=True, exist_ok=True)
tmp = root / f"{key}.parquet.tmp"
series.to_frame().write_parquet(tmp)
tmp.replace(root / f"{key}.parquet")
except OSError as e:
_stats["error"] += 1
logger.debug("factor cache write failed %s: %s", key[:12], e)