新增 benchmarks 模块覆盖图构建/执行/上下文注入/状态后端四个维度; Graph.layers() 结果缓存避免重复拓扑排序,cmd 任务跳过签名内省。
This commit is contained in:
@@ -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:通过
|
||||
- pytest:1185 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 空任务)
|
||||
- sequential:514 ops/s(最快,无并发开销)
|
||||
- thread:93 ops/s(线程池开销)
|
||||
- async:44 ops/s(事件循环开销)
|
||||
- dependency:42 ops/s(最大并行度但调度开销高)
|
||||
|
||||
### 状态后端对比
|
||||
- MemoryBackend.save/load:600万/447万 ops/s
|
||||
- JSONBackend.save(batch=10)/load:3913/11.9万 ops/s
|
||||
- SQLiteBackend.save(batch=10)/load:9657/1.5万 ops/s(save 更快,load 较慢)
|
||||
|
||||
## 遗留事项
|
||||
|
||||
- P4 任务取消与优雅停止(下一迭代)
|
||||
- 基准套件可扩展:添加更多真实场景(I/O 密集型、CPU 密集型)
|
||||
@@ -0,0 +1,10 @@
|
||||
"""PyFlowX 性能基准套件。
|
||||
|
||||
用法::
|
||||
|
||||
python -m benchmarks # 运行全部基准
|
||||
python -m benchmarks graph # 仅图构建基准
|
||||
python -m benchmarks execution # 仅执行基准
|
||||
python -m benchmarks context # 仅上下文注入基准
|
||||
python -m benchmarks storage # 仅状态后端基准
|
||||
"""
|
||||
@@ -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))
|
||||
|
||||
# JSONBackend(batch 模式)
|
||||
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())
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@@ -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 函数."""
|
||||
|
||||
@@ -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",))
|
||||
|
||||
Reference in New Issue
Block a user