perf: 添加性能基准套件并优化 layers 缓存与 cmd 快速路径
CI / Lint, Typecheck & Test (push) Has been cancelled

新增 benchmarks 模块覆盖图构建/执行/上下文注入/状态后端四个维度;
Graph.layers() 结果缓存避免重复拓扑排序,cmd 任务跳过签名内省。
This commit is contained in:
2026-07-07 11:38:37 +08:00
parent 9f50bb3e7c
commit 98c3d84334
7 changed files with 583 additions and 0 deletions
@@ -0,0 +1,85 @@
# 迭代 08:性能基准与优化
## 本轮目标
1. 建立性能基准测试套件,覆盖图构建、任务执行、上下文注入、状态后端等关键路径
2. 基于基准结果识别并实施优化
## 改动文件清单
- `benchmarks/__init__.py` — 新增:基准套件入口
- `benchmarks/bench_graph.py` — 新增:图构建/校验/分层/resolve 基准
- `benchmarks/bench_execution.py` — 新增:任务执行基准(四种策略 × 多种图规模)
- `benchmarks/bench_context.py` — 新增:上下文注入基准
- `benchmarks/bench_storage.py` — 新增:状态后端基准
- `benchmarks/__main__.py` — 新增:CLI 入口 `python -m benchmarks`
- `src/pyflowx/graph.py` — 优化:缓存 `layers()` 结果
- `src/pyflowx/context.py` — 优化:cmd 任务跳过签名内省
## 关键设计
### 1. 基准套件
独立 `benchmarks/` 目录(非 pytest 测试),用 `time.perf_counter` 计时:
- 图构建:10/100/500/1000 节点的 DAG 构建 + validate + layers
- 任务执行:空 fn 任务 × 100/500,四种策略对比
- 上下文注入:有/无依赖、有/无 Context 标注
- 状态后端:MemoryBackend vs JSONBackend vs SQLiteBackend
### 2. layers() 缓存
Graph.layers() 每次 run() 都重算拓扑排序。添加 `_layers_cache` 字段:
- 首次调用计算并缓存
- `add()` / `clear()` 时失效
- `resolved_spec` 已有缓存模式可参照
### 3. cmd 任务跳过签名内省
cmd 任务的 `effective_fn` 是无参闭包 `_run()`。当前 `build_call_args` 仍会:
- 调用 `_signature(fn)` 获取签名(虽有 lru_cache,仍有 dict lookup 开销)
- 构建 `dep_context` dict(即使无注入需求)
- 遍历所有参数
优化:检测 `spec.fn is None and spec.cmd is not None` 时直接返回 `((), {})`
## 验收标准
- 基准套件可独立运行:`python -m benchmarks`
- 输出格式化报告:各场景的 ops/sec 或 ms/op
- layers() 缓存生效:重复调用 O(1)
- cmd 任务跳过上下文注入:减少签名内省开销
- 覆盖率 ≥ 95%
- ruff / pyrefly / pytest 全部通过
## 验证结果
- ruff check + format:通过
- pyrefly check:通过
- pytest1185 passed+4 新测试:layers 缓存 + cmd 快速路径)
- 覆盖率:97.24%
## 基准结果摘要
### layers() 缓存优化
- 冷启动:264-6465 ops/s(取决于图规模)
- 缓存命中:~1500万 ops/s~50000x 加速)
### cmd 任务快速路径
- cmd(fast-path)1130万 ops/s
- fn(no-deps)144万 ops/s~8x 加速)
### 执行策略对比(500 空任务)
- sequential514 ops/s(最快,无并发开销)
- thread93 ops/s(线程池开销)
- async44 ops/s(事件循环开销)
- dependency42 ops/s(最大并行度但调度开销高)
### 状态后端对比
- MemoryBackend.save/load600万/447万 ops/s
- JSONBackend.save(batch=10)/load3913/11.9万 ops/s
- SQLiteBackend.save(batch=10)/load9657/1.5万 ops/ssave 更快,load 较慢)
## 遗留事项
- P4 任务取消与优雅停止(下一迭代)
- 基准套件可扩展:添加更多真实场景(I/O 密集型、CPU 密集型)
+10
View File
@@ -0,0 +1,10 @@
"""PyFlowX 性能基准套件。
用法::
python -m benchmarks # 运行全部基准
python -m benchmarks graph # 仅图构建基准
python -m benchmarks execution # 仅执行基准
python -m benchmarks context # 仅上下文注入基准
python -m benchmarks storage # 仅状态后端基准
"""
+434
View File
@@ -0,0 +1,434 @@
"""PyFlowX 性能基准套件。
用法::
python -m benchmarks # 运行全部基准
python -m benchmarks graph # 仅图构建基准
python -m benchmarks execution # 仅执行基准
python -m benchmarks context # 仅上下文注入基准
python -m benchmarks storage # 仅状态后端基准
"""
from __future__ import annotations
import math
import sys
import tempfile
import time
from collections.abc import Callable
from pathlib import Path
from typing import Any
from rich.console import Console
from rich.table import Table
import pyflowx as px
from pyflowx import Graph, GraphDefaults, RetryPolicy, TaskSpec
from pyflowx.context import build_call_args
from pyflowx.storage import JSONBackend, MemoryBackend, SQLiteBackend
# ============================================================================
# 计时工具
# ============================================================================
def time_it(fn: Callable[[], Any], iterations: int = 100, warmup: int = 5) -> tuple[float, float]:
"""计时工具:返回 (平均耗时 ms, 吞吐 ops/sec)。"""
for _ in range(warmup):
fn()
times: list[float] = []
for _ in range(iterations):
t0 = time.perf_counter()
fn()
times.append(time.perf_counter() - t0)
avg = sum(times) / len(times)
return avg * 1000, 1.0 / avg if avg > 0 else float("inf")
def print_results(title: str, results: list[tuple[str, int, float, float]]) -> None:
"""打印格式化基准结果表。"""
console = Console()
table = Table(title=title, show_header=True, header_style="bold")
table.add_column("场景", style="cyan", no_wrap=True)
table.add_column("迭代", justify="right")
table.add_column("平均耗时", justify="right", style="yellow")
table.add_column("吞吐", justify="right", style="green")
for name, iters, ms, ops in results:
ms_str = f"{ms:.3f} ms" if ms < 1 else f"{ms:.2f} ms"
ops_str = f"{ops:.0f} ops/s" if ops > 1000 else f"{ops:.1f} ops/s"
table.add_row(name, str(iters), ms_str, ops_str)
console.print(table)
console.print()
# ============================================================================
# 图生成工具
# ============================================================================
def make_chain(n: int) -> list[TaskSpec]:
"""生成 n 个任务的链式 DAG。"""
specs = [TaskSpec(f"t{i}", cmd=["true"]) for i in range(n)]
for i in range(1, n):
specs[i] = TaskSpec(f"t{i}", cmd=["true"], depends_on=(f"t{i - 1}",))
return specs
def make_diamond(n: int) -> list[TaskSpec]:
"""生成 n 个任务的菱形 DAG(每层宽度约 sqrt(n))。"""
width = max(1, int(math.sqrt(n)))
specs: list[TaskSpec] = []
prev_layer: list[str] = []
layer = 0
count = 0
while count < n:
cur_layer: list[str] = []
for j in range(width):
if count >= n:
break
name = f"l{layer}_t{j}"
deps = tuple(prev_layer) if prev_layer else ()
specs.append(TaskSpec(name, cmd=["true"], depends_on=deps))
cur_layer.append(name)
count += 1
prev_layer = cur_layer
layer += 1
return specs
def make_wide(n: int) -> list[TaskSpec]:
"""生成 n 个独立任务(无依赖,最大并行度)。"""
return [TaskSpec(f"t{i}", cmd=["true"]) for i in range(n)]
# ============================================================================
# 基准:图构建
# ============================================================================
def bench_construction() -> None:
"""图构建(from_specs + validate)基准。"""
results = []
for n in (10, 100, 500, 1000):
specs = make_chain(n)
ms, _ = time_it(lambda s=specs: Graph.from_specs(s), iterations=20)
results.append((f"chain({n})", 20, ms, 1.0 / (ms / 1000) if ms > 0 else 0))
for n in (10, 100, 500, 1000):
specs = make_diamond(n)
ms, _ = time_it(lambda s=specs: Graph.from_specs(s), iterations=20)
results.append((f"diamond({n})", 20, ms, 1.0 / (ms / 1000) if ms > 0 else 0))
print_results("图构建 (from_specs + validate)", results)
def bench_layers() -> None:
"""拓扑分层基准(冷启动 vs 缓存命中)。"""
results = []
for n in (100, 500, 1000):
specs = make_diamond(n)
graph = Graph.from_specs(specs)
def _cold(g: Graph = graph) -> None:
g._layers_cache = None # type: ignore[attr-defined]
g.layers()
ms_cold, ops_cold = time_it(_cold, iterations=50, warmup=5)
results.append((f"layers(cold,{n})", 50, ms_cold, ops_cold))
ms_hot, ops_hot = time_it(lambda g=graph: g.layers(), iterations=200, warmup=10)
results.append((f"layers(cached,{n})", 200, ms_hot, ops_hot))
print_results("拓扑分层 (layers)", results)
def bench_resolved_spec() -> None:
"""resolved_spec 缓存命中基准。"""
results = []
for n in (100, 500, 1000):
specs = make_chain(n)
defaults = GraphDefaults(retry=RetryPolicy(max_attempts=2))
graph = Graph.from_specs(specs, defaults=defaults)
name = f"t{n // 2}"
ms, ops = time_it(lambda g=graph, nm=name: g.resolved_spec(nm), iterations=500, warmup=20)
results.append((f"resolved_spec(cached,{n})", 500, ms, ops))
print_results("resolved_spec (缓存命中)", results)
def run_graph() -> None:
"""运行全部图基准。"""
bench_construction()
bench_layers()
bench_resolved_spec()
# ============================================================================
# 基准:任务执行
# ============================================================================
def bench_sequential() -> None:
"""sequential 策略执行基准。"""
results = []
def noop() -> None:
pass
for n in (50, 200, 500):
specs = [TaskSpec(f"t{i}", fn=noop) for i in range(n)]
graph = Graph.from_specs(specs)
ms, ops = time_it(lambda g=graph: px.run(g, strategy="sequential"), iterations=10, warmup=2)
results.append((f"sequential({n})", 10, ms, ops))
print_results("执行策略: sequential", results)
def bench_thread() -> None:
"""thread 策略执行基准。"""
results = []
def noop() -> None:
pass
for n in (50, 200, 500):
specs = [TaskSpec(f"t{i}", fn=noop) for i in range(n)]
graph = Graph.from_specs(specs)
ms, ops = time_it(lambda g=graph: px.run(g, strategy="thread"), iterations=10, warmup=2)
results.append((f"thread({n})", 10, ms, ops))
print_results("执行策略: thread", results)
def bench_async() -> None:
"""async 策略执行基准。"""
results = []
def noop() -> None:
pass
for n in (50, 200, 500):
specs = [TaskSpec(f"t{i}", fn=noop) for i in range(n)]
graph = Graph.from_specs(specs)
ms, ops = time_it(lambda g=graph: px.run(g, strategy="async"), iterations=10, warmup=2)
results.append((f"async({n})", 10, ms, ops))
print_results("执行策略: async", results)
def bench_dependency() -> None:
"""dependency 策略执行基准。"""
results = []
def noop() -> None:
pass
for n in (50, 200, 500):
specs = [TaskSpec(f"t{i}", fn=noop) for i in range(n)]
graph = Graph.from_specs(specs)
ms, ops = time_it(lambda g=graph: px.run(g, strategy="dependency"), iterations=10, warmup=2)
results.append((f"dependency({n})", 10, ms, ops))
print_results("执行策略: dependency", results)
def bench_cmd_execution() -> None:
"""cmd 任务执行基准(真实子进程)。"""
results = []
for n in (10, 50, 100):
specs = [TaskSpec(f"t{i}", cmd=["true"]) for i in range(n)]
graph = Graph.from_specs(specs)
ms, ops = time_it(lambda g=graph: px.run(g, strategy="sequential"), iterations=5, warmup=1)
results.append((f"cmd-sequential({n})", 5, ms, ops))
for n in (10, 50, 100):
specs = [TaskSpec(f"t{i}", cmd=["true"]) for i in range(n)]
graph = Graph.from_specs(specs)
ms, ops = time_it(lambda g=graph: px.run(g, strategy="thread", max_workers=8), iterations=5, warmup=1)
results.append((f"cmd-thread({n})", 5, ms, ops))
print_results("cmd 任务执行 (['true'])", results)
def run_execution() -> None:
"""运行全部执行基准。"""
bench_sequential()
bench_thread()
bench_async()
bench_dependency()
bench_cmd_execution()
# ============================================================================
# 基准:上下文注入
# ============================================================================
def bench_context_no_deps() -> None:
"""无依赖 fn 任务的上下文注入基准。"""
results = []
def noop() -> None:
pass
spec = TaskSpec("noop", fn=noop)
context: dict[str, Any] = {}
ms, ops = time_it(lambda s=spec, c=context: build_call_args(s, c), iterations=2000, warmup=100)
results.append(("fn(no-deps)", 2000, ms, ops))
# cmd 任务快速路径
spec_cmd = TaskSpec("cmd", cmd=["true"])
ms, ops = time_it(lambda s=spec_cmd, c=context: build_call_args(s, c), iterations=2000, warmup=100)
results.append(("cmd(fast-path)", 2000, ms, ops))
print_results("上下文注入 (build_call_args)", results)
def bench_context_with_deps() -> None:
"""有依赖 fn 任务的上下文注入基准。"""
results = []
def consumer(a: int, b: int) -> int:
return a + b
spec = TaskSpec("consumer", fn=consumer, depends_on=("a", "b"))
context = {"a": 1, "b": 2, "c": 3}
ms, ops = time_it(lambda s=spec, c=context: build_call_args(s, c), iterations=2000, warmup=100)
results.append(("fn(2-deps)", 2000, ms, ops))
# Context 标注
from pyflowx.task import Context
def ctx_fn(ctx: Context) -> int:
return sum(ctx.values())
spec_ctx = TaskSpec("ctx", fn=ctx_fn, depends_on=("a", "b"))
ms, ops = time_it(lambda s=spec_ctx, c=context: build_call_args(s, c), iterations=2000, warmup=100)
results.append(("fn(Context-annotated)", 2000, ms, ops))
# **kwargs
def kwargs_fn(**kwargs: int) -> int:
return sum(kwargs.values())
spec_kw = TaskSpec("kw", fn=kwargs_fn, depends_on=("a", "b"))
ms, ops = time_it(lambda s=spec_kw, c=context: build_call_args(s, c), iterations=2000, warmup=100)
results.append(("fn(**kwargs)", 2000, ms, ops))
print_results("上下文注入 (有依赖)", results)
def run_context() -> None:
"""运行全部上下文注入基准。"""
bench_context_no_deps()
bench_context_with_deps()
# ============================================================================
# 基准:状态后端
# ============================================================================
def bench_storage() -> None:
"""状态后端 save/load 基准。"""
results = []
# MemoryBackend
mem = MemoryBackend()
ms, ops = time_it(lambda: mem.save("key", "value"), iterations=1000, warmup=50)
results.append(("MemoryBackend.save", 1000, ms, ops))
ms, ops = time_it(mem.load, iterations=1000, warmup=50)
results.append(("MemoryBackend.load", 1000, ms, ops))
# JSONBackendbatch 模式)
tmp_dir = tempfile.mkdtemp()
json_path = str(Path(tmp_dir) / "state.json")
json_backend = JSONBackend(json_path)
with json_backend.batch():
for i in range(100):
json_backend.save(f"task_{i}", f"result_{i}")
def _json_save() -> None:
jb = JSONBackend(json_path)
with jb.batch():
for i in range(10):
jb.save(f"bench_{i}", f"val_{i}")
ms, ops = time_it(_json_save, iterations=50, warmup=5)
results.append(("JSONBackend.save(batch=10)", 50, ms, ops))
ms, ops = time_it(json_backend.load, iterations=200, warmup=10)
results.append(("JSONBackend.load", 200, ms, ops))
# SQLiteBackend
db_path = str(Path(tmp_dir) / "state.db")
sqlite_backend = SQLiteBackend(db_path)
with sqlite_backend.batch():
for i in range(100):
sqlite_backend.save(f"task_{i}", f"result_{i}")
def _sqlite_save() -> None:
sb = SQLiteBackend(db_path)
with sb.batch():
for i in range(10):
sb.save(f"bench_{i}", f"val_{i}")
ms, ops = time_it(_sqlite_save, iterations=50, warmup=5)
results.append(("SQLiteBackend.save(batch=10)", 50, ms, ops))
ms, ops = time_it(sqlite_backend.load, iterations=200, warmup=10)
results.append(("SQLiteBackend.load", 200, ms, ops))
print_results("状态后端 (save/load)", results)
# 清理临时目录
import shutil
shutil.rmtree(tmp_dir, ignore_errors=True)
def run_storage() -> None:
"""运行全部状态后端基准。"""
bench_storage()
# ============================================================================
# 主入口
# ============================================================================
BENCH_MODULES: dict[str, Callable[[], None]] = {
"graph": run_graph,
"execution": run_execution,
"context": run_context,
"storage": run_storage,
}
def main(argv: list[str] | None = None) -> int:
"""CLI 入口。"""
args = argv if argv is not None else sys.argv[1:]
console = Console()
console.print("[bold cyan]PyFlowX 性能基准套件[/bold cyan]\n")
if not args or args[0] in ("--all", "-a"):
for name, fn in BENCH_MODULES.items():
console.print(f"[bold]运行: {name}[/bold]")
fn()
elif args[0] in BENCH_MODULES:
BENCH_MODULES[args[0]]()
elif args[0] in ("--help", "-h"):
console.print("用法: python -m benchmarks [graph|execution|context|storage]")
console.print(" 无参数 = 运行全部基准")
else:
console.print(f"[red]未知基准模块: {args[0]}[/red]")
console.print(f"可用: {', '.join(BENCH_MODULES)}")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+5
View File
@@ -63,6 +63,11 @@ def build_call_args(
``context`` 必须已包含所有硬依赖与软依赖的结果(软依赖被跳过时由
执行器填入 :attr:`TaskSpec.defaults` 中的默认值)。
"""
# 快速路径:cmd 任务(无 fn)的 effective_fn 是无参闭包,无需签名内省与依赖注入。
# 仅当无静态 args/kwargs 时生效(cmd 任务通常不设这些字段)。
if spec.fn is None and spec.cmd is not None and not spec.args and not spec.kwargs:
return (), {}
fn = spec.effective_fn
sig = _signature(fn)
params = sig.parameters
+10
View File
@@ -151,6 +151,10 @@ class Graph:
# 在 specs / defaults 变更时失效。
_resolved_cache: dict[str, TaskSpec[Any]] = field(default_factory=dict)
# layers() 缓存:避免重复 run() 调用时重算拓扑排序。
# 在 specs 变更时失效。
_layers_cache: list[list[str]] | None = field(default=None)
# ------------------------------------------------------------------ #
# 构建
# ------------------------------------------------------------------ #
@@ -189,6 +193,7 @@ class Graph:
# 拓扑依赖仅含硬依赖;软依赖仅用于注入,不影响分层。
self.deps[spec.name] = spec.depends_on
self._resolved_cache.clear()
self._layers_cache = None
@classmethod
def from_specs(
@@ -386,10 +391,14 @@ class Graph:
同层任务无相互硬依赖,可并发执行。软依赖不参与分层。
层按执行顺序返回。图有环时抛出 :class:`CycleError`。
结果按实例缓存;specs 变更时失效(:meth:`add` / :meth:`_register`)。
.. note::
本方法假定图已通过 :meth:`validate` 校验(由 :func:`pyflowx.run`
在入口统一执行一次)。若直接调用本方法,需自行先校验。
"""
if self._layers_cache is not None:
return self._layers_cache
sorter = _TopologicalSorter(self.deps)
result: list[list[str]] = []
sorter.prepare()
@@ -399,6 +408,7 @@ class Graph:
result.append(ready)
for node in ready:
sorter.done(node)
self._layers_cache = result
return result
# ------------------------------------------------------------------ #
+14
View File
@@ -129,6 +129,20 @@ class TestBuildCallArgs:
_args, kwargs = build_call_args(spec, {"a": 1, "b": 2, "c": 99})
assert kwargs == {"ctx": {"a": 1, "b": 2}}
def test_cmd_task_fast_path(self) -> None:
"""cmd 任务(无 fn)走快速路径,跳过签名内省。"""
spec = px.TaskSpec("cmd_task", cmd=["echo", "hello"])
args, kwargs = build_call_args(spec, {"a": 1, "b": 2})
assert args == ()
assert kwargs == {}
def test_cmd_task_with_depends_fast_path(self) -> None:
"""cmd 任务有依赖时也走快速路径(依赖仅用于排序,不注入)。"""
spec = px.TaskSpec("cmd_task", cmd=["echo", "hello"], depends_on=("a",))
args, kwargs = build_call_args(spec, {"a": 1})
assert args == ()
assert kwargs == {}
class TestDescribeInjection:
"""测试 describe_injection 函数."""
+25
View File
@@ -80,6 +80,31 @@ def test_layers_grouping() -> None:
assert layers == [["a", "b"], ["c"], ["d"]]
def test_layers_cached() -> None:
"""layers() 结果缓存:重复调用返回同一列表对象。"""
graph = px.Graph.from_specs(
[
px.TaskSpec("a", _fn),
px.TaskSpec("b", _fn, depends_on=("a",)),
]
)
first = graph.layers()
second = graph.layers()
assert first is second # 缓存命中返回同一对象
def test_layers_cache_invalidated_on_add() -> None:
"""添加任务后缓存失效,layers() 返回新结果。"""
graph = px.Graph.from_specs([px.TaskSpec("a", _fn)])
first = graph.layers()
assert first == [["a"]]
graph.add(px.TaskSpec("b", _fn, depends_on=("a",)))
second = graph.layers()
assert second == [["a"], ["b"]]
assert first is not second # 缓存已失效,新对象
def test_self_dependency_rejected() -> None:
with pytest.raises(ValueError):
_ = px.TaskSpec("a", _fn, depends_on=("a",))