迭代 122 - 性能优化实施方案(第三轮)

背景

迭代 119/120/121 的性能分析显示,当前整体耗时的主要热点仍然集中在:

  • backtrader/feeds/pandafeed.py:PandasData._load 逐行取数

  • Pandas 索引层:pandas/core/indexing.py:__getitem__(累积耗时非常高)

  • Line 系统基础操作:linebuffer.__getitem__/__setitem__/forwardlineseries.__setattr__

其中 PandasData._load 当前使用 DataFrame.iloc[row, col],会触发 pandas indexer 的大量 Python 层逻辑(类型、边界、切片、索引规范化),属于“每根 bar 必经”的高频路径,优化收益上限高且风险相对可控。

对比基准(最新 2026-01-17)

本轮迭代的对比基准来自 logs/

  • 基准日志(master)logs/performance_profile_master_20260117_093200.log563.25s

  • 当前日志(development)logs/performance_profile_development_20260117_101745.log537.65s

整体结论(从日志中读取的 Total Execution Time):

  • development 相对基准 -25.60s(约 -4.5%)

说明:虽然 development 在该次 profile 结果中已快于基准,但热点结构仍然以 PandasData 的 pandas 索引取数为主。本迭代目标是在不改变对外行为/不改测试的前提下,继续压榨这部分热点,进一步降低整体耗时,并为后续性能回归提供稳定的优化方向。

关键热点摘录(来自上述两份日志)

以下热点在两份日志中都排名靠前(按 cumulative/total time 观察):

  • backtrader/feeds/pandafeed.py:PandasData._load

  • pandas/core/indexing.py:__getitem__

  • backtrader/linebuffer.py:__getitem__/__setitem__/forward

  • backtrader/lineseries.py:__setattr__

本迭代聚焦 PandasData._load 的原因:

  • 它是“每根 bar 必经路径”,优化收益对全局最稳定

  • 当前实现会触发 pandas indexer(__getitem__)的重逻辑,属于可通过“预取 + 直接索引”显著降耗的典型场景

目标

  • 目标:将 PandasData._load 从逐字段 .iloc 访问切换为 Numpy 后端(预取 + 直接索引),显著降低 pandas.core.indexing.__getitem__ 的调用与耗时。

  • 约束

    • 不修改测试用例

    • 修改源代码后需通过 pip install -U .

    • 需运行 bash scripts/optimize_code.sh

    • 需通过现有测试

    • 使用 scripts/profile_performance.py 复测,并与现有日志对比

方案概述(核心思路)

把 pandas 的“索引/取数”工作尽量提前到 start()(一次性做),把 _load() 变成 tight loop:

  • start()

    • DataFrame 需要的列转换为 Numpy 数组/视图(尽量 copy=False

    • 建立 [(line_obj, colidx), ...] 的写入列表,避免 _load() 内反复 getattr(self.lines, name)

    • 对 datetime 做预处理:生成 dtnum 数组(float)或可快速转换的数据结构

  • _load()

    • 仅做 _idx += 1、边界判断

    • 按缓存的 (line_obj, colidx) 列表,从 Numpy 数组直接取值并写入 line[0]

    • datetime 直接从预处理数组取值写入

详细实现方案

1. 改动文件与范围

  • 文件backtrader/feeds/pandafeed.py

  • PandasData

  • 修改方法start()_load()

  • 新增内部缓存字段(建议)

    • self._df_values: Numpy 2D 数组或可索引对象(行、列)

    • self._loaditems: List[Tuple[line, colidx]],用于快速写入

    • self._dt_values: datetime 的预处理结果(建议直接存 dtnum 数组)

    • self._df_len: DataFrame 行数缓存(避免每次 len(df)

说明:只引入“内部缓存字段”,不改变对外 API;出现异常时 fallback 到现有 .iloc 逻辑。

2. start() 阶段:构建 Numpy 后端

2.1 缓存 DataFrame 长度

  • 现状:_load() 每次 if self._idx >= len(self.p.dataname)

  • 改进:在 start() 缓存 self._df_len = len(self.p.dataname)

2.2 缓存各 line 对象与列号

  • 现状:每个 datafield 每根 bar:

    • for datafield in self.getlinealiases()

    • line = getattr(self.lines, datafield)

    • colindex = self._colmapping[datafield]

  • 改进:在 start() 预生成 self._loaditems

伪代码:

self._loaditems = []
for datafield in self.getlinealiases():
    if datafield == 'datetime':
        continue
    colindex = self._colmapping[datafield]
    if colindex is None:
        continue
    line = getattr(self.lines, datafield)
    self._loaditems.append((line, colindex))

```bash

#### 2.3 构建 `_df_values`(Numpy 访问路径)

优先级建议

- **优先路径 A最快**:`df.to_numpy(copy=False)` 得到二维数组
  - 优点:`values[row, col]` 最直接
  - 风险混合 dtype  `dtype=object`,仍比 `.iloc` 但收益会打折

- **备选路径 B稳健**逐列缓存 `Series.to_numpy(copy=False)`
  - 结构:`self._col_arrays[colidx] = ndarray`
  - `_load()` :`val = col_array[row]`
  - 优点避免二维 object array 的行列索引开销

建议实现策略

1. 先尝试 A
   - `self._df_values = df.to_numpy(copy=False)`
1. 如果发现 `self._df_values.dtype == object` 且列数不多典型 OHLCV),可切到 B

> 为减少行为变化与改动量本轮建议先落地 A并提供 B 作为后续增强项

#### 2.4 datetime 预处理(重点)

现状

- 每根 bar 
  - `tstamp = df.index[self._idx]`  `df.iloc[self._idx, coldtime]`
  - `dt = tstamp.to_pydatetime()`
  - `dtnum = date2num(dt)`

改进 `start()` 一次性构建 `self._dt_dtnum`:

-  `coldtime is None`:
  - `ts = df.index`
- 否则
  - `ts = df.iloc[:, coldtime]`(这里仍可能触发 pandas 逻辑但只做一次

推荐做法兼容优先):

1. 尝试快速拿到 python datetime 序列
   - `ts.to_pydatetime()`(DatetimeIndex/Series 通常支持
1. 然后用 `date2num` 逐个转换为 floatPython 循环仍在但只做一次

伪代码

```python

# ts: DatetimeIndex 或 Series

py_dts = ts.to_pydatetime()
self._dt_dtnum = [date2num(d) for d in py_dts]

```bash
> 注意这里生成 list 也可以,`_load()` 只需要 O(1) 索引后续若要继续压榨可改为 `array('d')`  `numpy.array(dtype=float)`。

#### 2.5 失败回退策略(必须)

为了避免因数据类型/索引类型差异导致行为变化

- `start()` 中构建 Numpy 后端时用 `try/except` 包住
- 如果失败
  - `self._df_values = None`
  - `self._loaditems = None`
  - `self._dt_dtnum = None`
- `_load()` 检测 `self._df_values is None` 走原 `.iloc` 逻辑

### 3. `_load()` 阶段:tight loop

目标不再调用 `.iloc`,不再遍历 `getlinealiases()`,不再动态 `getattr(self.lines, ...)`。

伪代码

```python
self._idx += 1
if self._idx >= self._df_len:
    return False

if self._df_values is not None:
    row = self._idx
    values = self._df_values
    for line, col in self._loaditems:
        line[0] = values[row, col]

    self.lines.datetime[0] = self._dt_dtnum[row]
    return True

# fallback: 原始实现

...

```bash

### 4. 兼容性与边界条件

- **列映射**保持现有 `_colmapping`、`nocase`、string/integer col 的兼容行为
- **缺失列**:`colindex is None` 时跳过与当前一致
- **datetime 来源**
  - index (`datetime=None`) 与列 (`datetime>=0 或列名`) 两种都支持
- **timezone**:`date2num` 会处理 tzinfo预处理阶段不改变该逻辑
- **DataFrame 在运行中被修改**
  - 若用户在回测过程中修改原 DataFrame不常见),Numpy view 可能反映修改或不反映取决于底层是否 copy
  - 为保证可预期本方案倾向 `copy=False`,但出现异常/不一致时可改成 `copy=True`(以确定性换性能

### 5. 预期收益

- 直接减少:`pandas.core.indexing.__getitem__` 的调用与 cumtime
- 间接减少:`builtins.getattr/len`(因为 `_load()` 内少了循环与动态属性访问

在日志里 pandas indexing cumtime 为百秒级若能回收其中 30%~60%整体有望带来 **5%~15%**的总耗时下降依赖策略组合数据长度与列数)。

## 实施步骤(建议按提交拆分)

为降低风险并便于定位回归本轮建议按如下顺序落地

### Step 1:只做“结构缓存”,不引入 Numpy 取值(低风险)

-  `PandasData.start()` 里新增并缓存
  - `self._df_len`
  - `self._loaditems`
-  `_load()` 中优先使用 `self._df_len`  `self._loaditems`,但取值仍保持 `.iloc`(减少循环内的 `len()`  `getattr(self.lines, ...)`)

验收点

- 行为不变测试全通过
- profile  `builtins.getattr`、`builtins.len` 有可观察的下降即使很小也可接受

### Step 2:引入 Numpy 后端取值(主要收益点)

-  `start()` 尝试构建
  - `self._df_values = self.p.dataname.to_numpy(copy=False)`
  - `self._dt_dtnum`(datetime 预处理数组
-  `_load()` 
  - `if self._df_values is not None`: tight loop直接 `values[row, col]`),并写入 `self.lines.datetime[0] = self._dt_dtnum[row]`
  - 否则 fallback 到原 `.iloc` 实现

验收点

- `pandas/core/indexing.py:__getitem__`  `ncalls/cumtime` 有显著下降
- `backtrader/feeds/pandafeed.py:_load`  `tottime/cumtime` 有显著下降

### Step 3:必要时启用“逐列 Numpy array”缓存(处理 dtype=object 场景)

仅当 Step 2 收益不明显且确认 `df.to_numpy(copy=False)` 得到了 `dtype=object` 时再做

-  `_loaditems` 里涉及的 col单独构建 `col_array = df.iloc[:, col].to_numpy(copy=False)`
- `_load()` 改为 `line[0] = col_array[row]`

## 回滚方案

若出现以下任一情况

- 单测失败 / 行为变化尤其是 datetime 对齐问题
- profile 无收益或收益不稳定

回滚策略

- 保留 Step 1 的结构缓存通常低风险且可能有小收益
- 禁用 Step 2/3 `self._df_values = None`,强制 `_load()` 走原 `.iloc`

## 验收标准(Definition of Done)

- **安装**:`pip install -U .` 成功
- **格式化/质量**:`bash scripts/optimize_code.sh` 通过
- **测试**现有测试全通过不允许改测试
- **性能**
  - 运行 `python scripts/profile_performance.py --processes 12` 生成新日志
  - 与基准日志对比:`Total Execution Time` 不得回退建议目标至少 -2%
  - `pandas/core/indexing.py:__getitem__`  `pandafeed.py:_load` 的指标需呈现下降趋势 `ncalls/cumtime` 为准

## 开发落地结果(2026-01-17)

本次已按本计划完成 Step 1/Step 2 的代码落地(`PandasData.start/_load` 缓存与 Numpy 后端),并完成测试与性能复测

### 测试/质量

- `bash scripts/optimize_code.sh`:✅ 通过(`478 passed`)

### 性能复测

生成日志

- 新日志development):`logs/performance_profile_development_20260117_105735.log`(`398.67s`)

对比注意事项

- `scripts/profile_performance.py`  `Total Execution Time` 各策略执行时间的聚合值”,会受并行调度/机器负载影响
- **为保证可比性**建议对比时固定相同的 `--processes`,并尽量在相近系统负载条件下运行
- 本次复测日志 `105735` 使用 `--processes 7`( development 基准 `101745` 的默认进程数口径一致)。

对比结果

- 对比 development 基准
  - 基准:`logs/performance_profile_development_20260117_101745.log`(`537.65s`)
  - 当前:`398.67s`
  - 变化:`-138.98s`(`-25.8%`)
  - 对比报告:`logs/performance_compare_101745_vs_105735.md`
- 对比 master 基准
  - 基准:`logs/performance_profile_master_20260117_093200.log`(`563.25s`)
  - 当前:`398.67s`
  - 变化:`-164.58s`(`-29.2%`)
  - 对比报告:`logs/performance_compare_master_093200_vs_dev_105735.md`

### 热点变化摘要

- `PandasData._load`:从以 `.iloc[row, col]` 为主切换为 `df.to_numpy(copy=False)` 的直接索引写入
- `pandas/core/indexing.py:__getitem__`:不再出现在 Top hotspots从日志 Top 50 中消失),说明 pandas indexer 的热路径调用被显著削减

性能结果仍会受文件系统缓存并行调度等影响建议在同一环境下至少重复跑 2 次取稳定结果

## 验证与回归步骤

### 1) 安装验证

```bash
pip install -U .

```bash

### 2) 格式化/质量

```bash
bash scripts/optimize_code.sh

```bash

### 3) 单元测试

```bash
pytest tests -n 12

```bash

### 4) 性能复测

```bash
python scripts/profile_performance.py --processes 12

```bash

### 5) 对比报告

```bash
python scripts/compare_performance_logs.py \
  logs/performance_profile_master_20260117_093200.log \
  logs/performance_profile_development_YYYYMMDD_HHMMSS.log

```bash
关注指标

- `Total Execution Time` 是否下降
- `pandas/core/indexing.py:__getitem__`  `ncalls/cumtime` 是否显著下降
- `backtrader/feeds/pandafeed.py:_load`  `tottime/cumtime` 是否显著下降

## 后续可选增强(不在本轮必须范围)

- **增强 1** `df.to_numpy(copy=False)` 返回 `dtype=object` 切换到逐列 Numpy array缓存进一步减少 object 二维索引开销
- **增强 2**datetime 转换用 numpy/pandas 向量化减少 Python for-loop),例如直接把 `datetime64[ns]` 转成 `date2num` 兼容的 float days需严格对齐 `date2num`  tz/微秒规则风险更高