迭代 122 - 性能优化实施方案(第三轮)¶
背景¶
迭代 119/120/121 的性能分析显示,当前整体耗时的主要热点仍然集中在:
backtrader/feeds/pandafeed.py:PandasData._load逐行取数Pandas 索引层:
pandas/core/indexing.py:__getitem__(累积耗时非常高)Line 系统基础操作:
linebuffer.__getitem__/__setitem__/forward、lineseries.__setattr__
其中 PandasData._load 当前使用 DataFrame.iloc[row, col],会触发 pandas indexer 的大量 Python 层逻辑(类型、边界、切片、索引规范化),属于“每根 bar 必经”的高频路径,优化收益上限高且风险相对可控。
对比基准(最新 2026-01-17)¶
本轮迭代的对比基准来自 logs/:
基准日志(master):
logs/performance_profile_master_20260117_093200.log(563.25s)当前日志(development):
logs/performance_profile_development_20260117_101745.log(537.65s)
整体结论(从日志中读取的 Total Execution Time):
development 相对基准 -25.60s(约 -4.5%)
说明:虽然 development 在该次 profile 结果中已快于基准,但热点结构仍然以 PandasData 的 pandas 索引取数为主。本迭代目标是在不改变对外行为/不改测试的前提下,继续压榨这部分热点,进一步降低整体耗时,并为后续性能回归提供稳定的优化方向。
关键热点摘录(来自上述两份日志)¶
以下热点在两份日志中都排名靠前(按 cumulative/total time 观察):
backtrader/feeds/pandafeed.py:PandasData._loadpandas/core/indexing.py:__getitem__backtrader/linebuffer.py:__getitem__/__setitem__/forwardbacktrader/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` 逐个转换为 float(Python 循环仍在,但只做一次)
伪代码:
```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/微秒规则,风险更高)