test(cqrs-benchmarks): 补充stream lifetime双观测口径

- 新增 StreamLifetimeBenchmarks 的 FirstItem 与 DrainAll 观测模式,用于拆分建流瞬时成本与完整枚举成本

- 更新 cqrs-rewrite 恢复文档与 benchmark README,同步 RP-127 的验证结果、branch diff 与下一恢复点
This commit is contained in:
gewuyou 2026-05-09 16:19:14 +08:00
parent b7fa3eee29
commit 9ffe3ba237
4 changed files with 143 additions and 43 deletions

View File

@ -21,7 +21,7 @@ using Microsoft.Extensions.DependencyInjection;
namespace GFramework.Cqrs.Benchmarks.Messaging;
/// <summary>
/// 对比 stream 完整枚举在不同 handler 生命周期下的额外开销。
/// 对比 stream 在不同 handler 生命周期与观测方式下的额外开销。
/// </summary>
/// <remarks>
/// 当前矩阵只覆盖 `Singleton` 与 `Transient`。
@ -48,6 +48,12 @@ public class StreamLifetimeBenchmarks
[Params(HandlerLifetime.Singleton, HandlerLifetime.Transient)]
public HandlerLifetime Lifetime { get; set; }
/// <summary>
/// 控制当前 benchmark 观察“只推进首个元素”还是“完整枚举整个 stream”。
/// </summary>
[Params(StreamObservation.FirstItem, StreamObservation.DrainAll)]
public StreamObservation Observation { get; set; }
/// <summary>
/// 可公平比较的 benchmark handler 生命周期集合。
/// </summary>
@ -64,6 +70,22 @@ public class StreamLifetimeBenchmarks
Transient
}
/// <summary>
/// 用于拆分 stream dispatch 与后续枚举成本的观测模式。
/// </summary>
public enum StreamObservation
{
/// <summary>
/// 只推进到首个元素后立即释放枚举器。
/// </summary>
FirstItem,
/// <summary>
/// 完整枚举整个 stream保留原有 benchmark 语义。
/// </summary>
DrainAll
}
/// <summary>
/// 配置 stream 生命周期 benchmark 的公共输出格式。
/// </summary>
@ -141,59 +163,49 @@ public class StreamLifetimeBenchmarks
}
/// <summary>
/// 直接调用 handler 并完整枚举,作为不同生命周期矩阵下的 dispatch 额外开销 baseline。
/// 直接调用 handler,并按当前观测模式消费 stream,作为不同生命周期矩阵下的 dispatch 额外开销 baseline。
/// </summary>
[Benchmark(Baseline = true)]
public async ValueTask Stream_Baseline()
public ValueTask Stream_Baseline()
{
await foreach (var response in _baselineHandler.Handle(_reflectionRequest, CancellationToken.None).ConfigureAwait(false))
{
_ = response;
}
return ObserveAsync(_baselineHandler.Handle(_reflectionRequest, CancellationToken.None), Observation);
}
/// <summary>
/// 通过 GFramework.CQRS reflection stream binding 路径创建并完整枚举 stream。
/// 通过 GFramework.CQRS reflection stream binding 路径创建 stream,并按当前观测模式消费
/// </summary>
[Benchmark]
public async ValueTask Stream_GFrameworkReflection()
public ValueTask Stream_GFrameworkReflection()
{
await foreach (var response in _reflectionRuntime.CreateStream(
BenchmarkContext.Instance,
_reflectionRequest,
CancellationToken.None)
.ConfigureAwait(false))
{
_ = response;
}
return ObserveAsync(
_reflectionRuntime.CreateStream(
BenchmarkContext.Instance,
_reflectionRequest,
CancellationToken.None),
Observation);
}
/// <summary>
/// 通过 generated stream invoker provider 预热后的 GFramework.CQRS runtime 创建并完整枚举 stream。
/// 通过 generated stream invoker provider 预热后的 GFramework.CQRS runtime 创建 stream,并按当前观测模式消费
/// </summary>
[Benchmark]
public async ValueTask Stream_GFrameworkGenerated()
public ValueTask Stream_GFrameworkGenerated()
{
await foreach (var response in _generatedRuntime.CreateStream(
BenchmarkContext.Instance,
_generatedRequest,
CancellationToken.None)
.ConfigureAwait(false))
{
_ = response;
}
return ObserveAsync(
_generatedRuntime.CreateStream(
BenchmarkContext.Instance,
_generatedRequest,
CancellationToken.None),
Observation);
}
/// <summary>
/// 通过 MediatR 创建并完整枚举 stream作为外部对照。
/// 通过 MediatR 创建 stream,并按当前观测模式消费,作为外部对照。
/// </summary>
[Benchmark]
public async ValueTask Stream_MediatR()
public ValueTask Stream_MediatR()
{
await foreach (var response in _mediatr.CreateStream(_mediatrRequest, CancellationToken.None).ConfigureAwait(false))
{
_ = response;
}
return ObserveAsync(_mediatr.CreateStream(_mediatrRequest, CancellationToken.None), Observation);
}
/// <summary>
@ -271,6 +283,62 @@ public class StreamLifetimeBenchmarks
};
}
/// <summary>
/// 按观测模式消费 stream便于把“建流/首个元素”和“完整枚举”分开观察。
/// </summary>
/// <typeparam name="TResponse">当前 stream 的响应类型。</typeparam>
/// <param name="responses">待观察的异步响应序列。</param>
/// <param name="observation">当前 benchmark 选定的观测模式。</param>
/// <returns>异步消费完成后的等待句柄。</returns>
private static ValueTask ObserveAsync<TResponse>(
IAsyncEnumerable<TResponse> responses,
StreamObservation observation)
{
ArgumentNullException.ThrowIfNull(responses);
return observation switch
{
StreamObservation.FirstItem => ConsumeFirstItemAsync(responses),
StreamObservation.DrainAll => DrainAsync(responses),
_ => throw new ArgumentOutOfRangeException(
nameof(observation),
observation,
"Unsupported stream observation mode.")
};
}
/// <summary>
/// 只推进到首个元素后立即释放枚举器,用来近似隔离建流与首个 `MoveNextAsync` 的固定成本。
/// </summary>
/// <typeparam name="TResponse">当前 stream 的响应类型。</typeparam>
/// <param name="responses">待观察的异步响应序列。</param>
/// <returns>消费首个元素后的等待句柄。</returns>
private static async ValueTask ConsumeFirstItemAsync<TResponse>(IAsyncEnumerable<TResponse> responses)
{
var enumerator = responses.GetAsyncEnumerator();
await using (enumerator.ConfigureAwait(false))
{
if (await enumerator.MoveNextAsync().ConfigureAwait(false))
{
_ = enumerator.Current;
}
}
}
/// <summary>
/// 完整枚举整个 stream保留原 benchmark 的总成本观测口径。
/// </summary>
/// <typeparam name="TResponse">当前 stream 的响应类型。</typeparam>
/// <param name="responses">待完整枚举的异步响应序列。</param>
/// <returns>完整枚举结束后的等待句柄。</returns>
private static async ValueTask DrainAsync<TResponse>(IAsyncEnumerable<TResponse> responses)
{
await foreach (var response in responses.ConfigureAwait(false))
{
_ = response;
}
}
/// <summary>
/// Reflection runtime stream request。
/// </summary>

View File

@ -60,7 +60,8 @@ dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.cspro
- `BenchmarkDotNet.Artifacts/` 属于本地生成输出,默认加入仓库忽略,不作为常规提交内容
- `RequestLifetimeBenchmarks` 现在复用与默认 generated-provider 路径一致的 benchmark 宿主接线;它比较的是生命周期切换后的 handler 解析与 dispatch 成本,不单独引入另一套 runtime 发现口径
- `StreamLifetimeBenchmarks` 现在按 direct handler、`GFramework.Cqrs` reflection、`GFramework.Cqrs` generated、`MediatR` 四层口径组织,用于把 stream 生命周期成本与 binding 路径成本拆开观察
- `StreamLifetimeBenchmarks` 现在按 direct handler、`GFramework.Cqrs` reflection、`GFramework.Cqrs` generated、`MediatR` 四层口径组织,并额外区分 `FirstItem``DrainAll` 两种观测方式,用于把 stream 建流/首个元素成本与完整枚举成本拆开观察
- 按 `RP-127` 当前短跑结果,`StreamLifetimeBenchmarks``Singleton` 下无论 `FirstItem` 还是 `DrainAll` 都表现为 generated 略优于 reflection`Transient` 下,`FirstItem` 仍是 reflection 略优于 generated`DrainAll` 已转为 generated 优于 reflection。这说明当前差值主要集中在建流到首个元素之间的瞬时成本而不是完整枚举阶段整体退化
- 只要变更影响 `GFramework.Cqrs` request dispatch、DI 解析热路径、invoker/provider、pipeline 或 benchmark 宿主就应至少复跑能覆盖该路径的过滤场景request 热路径通常先看:
- `RequestBenchmarks.SendRequest_*`
- `RequestLifetimeBenchmarks.SendRequest_*`
@ -71,6 +72,7 @@ dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.cspro
## 后续扩展方向
- 若继续沿 `RP-127` 收口 stream lifetime可优先复核 `Transient + FirstItem` 下 generated 与 reflection 的小幅差值是否稳定,再决定继续压 generated 宿主的建流瞬时成本,还是把后续对照切回 `StreamInvokerBenchmarks` / `Mediator` concrete runtime 批次
- request / stream 的真实 source-generator 产物与 handwritten generated provider 对照
- `Mediator` 的 transient / scoped compile-time lifetime 矩阵对照
- 带真实显式作用域边界的 scoped host 对照

View File

@ -7,18 +7,20 @@ CQRS 迁移与收敛。
## 当前恢复点
- 恢复点编号:`CQRS-REWRITE-RP-126`
- 恢复点编号:`CQRS-REWRITE-RP-127`
- 当前阶段:`Phase 8`
- 当前 PR 锚点:`PR #344``origin/main` 已合入)
- 当前结论:
- 当前 `RP-126` 延续 `$gframework-batch-boot 50`,在 `RP-124` 收口 stream behavior presence cache 后,先用 `f9c9561f``RequestLifetimeBenchmarks` 对齐到 generated-provider 宿主路径,再用 `9107e232``StreamLifetimeBenchmarks` 扩成 `baseline / GFramework reflection / GFramework generated / MediatR` 的完整生命周期对照口径
- 本轮写面只落在 `GFramework.Cqrs.Benchmarks/Messaging/GeneratedRequestLifetimeBenchmarkRegistry.cs``RequestLifetimeBenchmarks.cs``GeneratedStreamLifetimeBenchmarkRegistry.cs``StreamLifetimeBenchmarks.cs`,以及 `ai-plan/public/cqrs-rewrite` 恢复文档,不回改 runtime、测试或 README
- 当前 `RP-127` 延续 `$gframework-batch-boot 50`,在 `RP-126` 已补齐 stream lifetime 四方口径后,再用 `b7fa3eee``CqrsDispatcher.CreateStream(...)` 的 stream dispatch binding 改为按 `TResponse` 强类型缓存,同时为 `StreamLifetimeBenchmarks` 增加 `FirstItem / DrainAll` 观测维度,并把新的结果回填到公开可恢复文档
- 本轮写面落在 `GFramework.Cqrs/Internal/CqrsDispatcher.cs``GFramework.Cqrs.Tests/Cqrs/CqrsDispatcherCacheTests.cs``GFramework.Cqrs.Tests/Cqrs/CqrsGeneratedRequestInvokerProviderTests.cs``GFramework.Cqrs.Benchmarks/Messaging/StreamLifetimeBenchmarks.cs`,以及 `GFramework.Cqrs.Benchmarks/README.md` / `ai-plan/public/cqrs-rewrite/**` 恢复文档;没有扩散到 request runtime、notification runtime 或额外文档模块
- `f9c9561f` 为 request lifetime 补入 handwritten generated registry并在 setup/cleanup 清理 dispatcher cache这样 `Singleton / Transient` 生命周期矩阵继续只比较 handler 生命周期与 dispatch 常量路径,不再混入旧宿主差异
- `9107e232` 将 stream lifetime 的 reflection、generated 与 `MediatR` 请求/响应/handler 彻底拆开,并限制 generated registry 只绑定 generated lane避免静态 dispatcher cache 把不同 stream 对照口径污染到一起
- 当前 request lifetime benchmark 已用新宿主重新验证:`Singleton` 下 baseline / `GFramework.Cqrs` / `MediatR` 约为 `5.012 ns / 32 B``49.612 ns / 32 B``51.796 ns / 232 B``Transient` 下约为 `3.962 ns / 32 B``50.480 ns / 56 B``50.284 ns / 232 B`
- 当前 stream lifetime benchmark 已产出完整四方结果:`Singleton` 下 baseline / generated / reflection / `MediatR` 约为 `79.602 ns / 280 B``111.547 ns / 280 B``120.553 ns / 280 B``208.381 ns / 672 B``Transient` 下 baseline / reflection / generated / `MediatR` 约为 `76.351 ns / 280 B``119.632 ns / 304 B``129.166 ns / 304 B``213.420 ns / 672 B`
- 当前已提交分支相对 `origin/main``d85828c5`, `2026-05-09 12:25:41 +0800`)的累计 branch diff 已到 `10 files``556 insertions / 75 deletions`),仍远低于 `$gframework-batch-boot 50``50 files` stop condition
- 下一推荐步骤:若继续 benchmark 线,先以 `RP-126` 的四方 stream lifetime 口径判断是否要追 `Generated``Transient` 下仍慢于 `Reflection` 的差值,再决定继续压 stream transient 常量路径,还是单开 `Mediator` concrete runtime 的 stream lifetime 对照批次;若切回 runtime 线,则以 `RP-125` / `RP-126` 结果作为后续性能回归基线
- 当前 stream lifetime benchmark 已更新为 `Observation=FirstItem / DrainAll` 双口径:`Singleton + FirstItem` 下 baseline / generated / reflection / `MediatR` 约为 `48.704 ns / 216 B``94.629 ns / 216 B``95.417 ns / 216 B``152.886 ns / 608 B``Singleton + DrainAll` 下约为 `73.335 ns / 280 B``118.860 ns / 280 B``119.632 ns / 280 B``205.629 ns / 672 B`
- `Transient + FirstItem` 下 baseline / reflection / generated / `MediatR` 约为 `48.293 ns / 216 B``97.628 ns / 240 B``100.011 ns / 240 B``154.149 ns / 608 B``Transient + DrainAll` 下约为 `78.466 ns / 280 B``124.174 ns / 304 B``116.780 ns / 304 B``220.040 ns / 672 B`
- 现阶段可恢复结论收口为三点:一是 stream lifetime 已具备四方口径加 `FirstItem / DrainAll` 双观测维度;二是 `b7fa3eee` 已让 generated lane 在 `DrainAll` 口径下重新领先 reflection三是 `Transient + FirstItem` 仍保留约 `2.4 ns` 的小幅反向差值,更像建流到首个元素之间的瞬时成本,而不是完整枚举阶段退化
- 当前已提交分支相对 `origin/main``d85828c5`, `2026-05-09 12:25:41 +0800`)的累计 branch diff 已到 `21 files``1231 insertions / 181 deletions`),仍明显低于 `$gframework-batch-boot 50``50 files` stop condition
- 下一推荐步骤:若继续 benchmark 线,优先从 `StreamLifetimeBenchmarks``Transient + FirstItem` 小幅差值继续恢复,并用 `StreamInvokerBenchmarks` 复核 generated lane 的常量成本收益是否能在更窄口径下复现;若差值不再稳定,再决定是否转去 `Mediator` concrete runtime 的 stream lifetime 对照批次
- 更早的 `RP-123` 及之前阶段细节以下方 trace 与归档为准active 入口不再重复展开旧阶段流水。
- 当前分支相对 `origin/main` 的累计 branch diff 启动时为 `9 files`,仍明显低于 `$gframework-batch-boot 50` 的停止阈值;这一批继续保持单模块、低风险、可直接评审的 benchmark 边界
- 当前 `RP-113` 已继续沿用 `$gframework-batch-boot 50`,并把 notification 线从 benchmark 对照推进到实际 runtime 能力:新增公开内置 `TaskWhenAllNotificationPublisher`,让 `GFramework.Cqrs` 在保留默认顺序发布器的同时,提供与 `Mediator` `TaskWhenAllPublisher` 对齐的并行 notification publish 策略

View File

@ -2,6 +2,34 @@
## 2026-05-09
### 阶段stream lifetime 观测维度补齐与 generated binding 强类型缓存CQRS-REWRITE-RP-127
- 延续 `$gframework-batch-boot 50`,在 `RP-126` 已建立四方 stream lifetime 口径后,本轮改用多 worker wave 同时推进三个互不冲突切片:
- `benchmark-only`:为 `StreamLifetimeBenchmarks` 增加 `FirstItem / DrainAll` 观测维度,并收口 `MA0004`
- `runtime-only`:把 `CqrsDispatcher.CreateStream(...)` 的 stream dispatch binding 改成按 `TResponse` 强类型缓存,避免 generated lane 在热路径上继续通过 `object -> IAsyncEnumerable<TResponse>` 桥接
- `docs / ai-plan`:把 `RP-126` 的旧恢复结论更新为本轮实测结果,并给出下一恢复入口
- 本轮主线程验收与修正:
- 首次并行验证时,`dotnet test``dotnet build` 同跑触发 `MSB3030` 输出争用;已按仓库规则改为串行重跑同一命令,并以串行结果为权威
- runtime worker 初版在 `CqrsDispatcher.cs` 留下一个 `StreamInvoker<TResponse>` 方法组绑定编译错误;主线程已局部修正为显式 lambda 适配,未改变预期语义
- 经修正后generated lane 不再出现 “incompatible invoker signature” 运行时异常,`StreamLifetimeBenchmarks` 16 个 case 全部通过
- 本轮验证:
- `dotnet build GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release`
- 结果:通过,`0 warning / 0 error`
- `dotnet build GFramework.Cqrs/GFramework.Cqrs.csproj -c Release`
- 结果:通过,`0 warning / 0 error`
- `dotnet test GFramework.Cqrs.Tests/GFramework.Cqrs.Tests.csproj -c Release --filter "FullyQualifiedName~CqrsDispatcherCacheTests|FullyQualifiedName~CqrsGeneratedRequestInvokerProviderTests"`
- 结果:通过,`31/31` passed
- `dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release --no-build -- --filter "*StreamLifetimeBenchmarks*" --job short --warmupCount 1 --iterationCount 1 --launchCount 1`
- 结果:通过
- 备注:`Transient + FirstItem` 下 generated 约 `100.011 ns / 240 B`、reflection 约 `97.628 ns / 240 B``Transient + DrainAll` 下 generated 约 `116.780 ns / 304 B`、reflection 约 `124.174 ns / 304 B`
- 本轮结论:
- `FirstItem / DrainAll` 双观测维度把“建流到首个元素的瞬时成本”和“完整枚举总成本”拆开后,`Transient` 场景下的 generated lane 已不再呈现统一的反向退化
- 当前仍保留的差值集中在 `Transient + FirstItem`,规模约 `2.4 ns`,明显小于 `RP-126` 的旧结论;而 `Transient + DrainAll` 已转为 generated 领先 reflection
- 当前分支相对 `origin/main` 的累计 branch diff 已到 `21 files``1231 insertions / 181 deletions`),仍低于 `$gframework-batch-boot 50` 阈值;但主线程已接近当前回合的安全上下文预算,因此在本轮自然边界停止,不继续开下一波 worker
- 下一恢复点:
- 若继续 benchmark 线,先从 `Transient + FirstItem` 的小幅差值恢复,并用 `StreamInvokerBenchmarks` 复核 generated lane 的常量成本收益是否仍成立;若差值不稳定,再考虑把下一批切到 `Mediator` concrete runtime 的 stream lifetime 对照
- 若切回 runtime 线,则以 `b7fa3eee` 与本轮 `StreamLifetimeBenchmarks` 双口径结果作为后续回归基线
### 阶段stream lifetime 对照口径补齐CQRS-REWRITE-RP-126
- 延续 `$gframework-batch-boot 50`,在 `RP-125` 先把 request lifetime benchmark 宿主对齐到 generated-provider 路径后,本轮继续补齐 stream 生命周期矩阵的当前对照口径,不回到 runtime 或测试代码
@ -16,11 +44,11 @@
- 结果:通过
- 备注:`Singleton` 下 baseline / generated / reflection / `MediatR` 约为 `79.602 ns / 280 B``111.547 ns / 280 B``120.553 ns / 280 B``208.381 ns / 672 B``Transient` 下 baseline / reflection / generated / `MediatR` 约为 `76.351 ns / 280 B``119.632 ns / 304 B``129.166 ns / 304 B``213.420 ns / 672 B`
- 本轮结论:
- 当前 stream 生命周期矩阵已经从“单看 `GFramework.Cqrs` 一条线是否还能跑通”升级为可直接比较 `baseline / reflection / generated / MediatR` 的四方口径
- 当前短跑下generated lane 在 `Singleton` 档优于 reflection但在 `Transient` 档仍慢于 reflection;这说明后续若继续压 stream 热点,应该围绕 generated 宿主的瞬时解析成本继续定位,而不是回头重做已经分离完成的对照口径
- 当前 stream 生命周期矩阵已经具备可直接比较 `baseline / reflection / generated / MediatR` 的四方口径,后续恢复时无需再回头拼接不同批次的 stream 生命周期数据
- 当前短跑下generated lane 在 `Singleton` 档优于 reflection但在 `Transient` 档仍慢于 reflection,差值约为 `9.5 ns``24 B`;这里先只把它记录为 benchmark 观察,不把它放大成更宽泛的 runtime 优劣结论
- 当前已提交分支相对 `origin/main``d85828c5`, `2026-05-09 12:25:41 +0800`)的累计 branch diff 已到 `10 files``556 insertions / 75 deletions`),仍远低于 `$gframework-batch-boot 50``50 files` stop condition
- 下一恢复点:
- 若继续 benchmark 线,优先判断是否要追 `Stream_GFrameworkGenerated``Transient` 下仍慢于 `Stream_GFrameworkReflection` 的差值,或单开 `Mediator` concrete runtime 的 stream lifetime 对照批次;若切回 runtime 线,则以 `RP-126` 的四方矩阵作为后续性能回归基线
- 若继续 benchmark 线,优先`Stream_GFrameworkGenerated``Stream_GFrameworkReflection``Transient` 差值恢复,先确认该差值是否稳定,再决定继续压 generated 宿主的瞬时解析成本,或单开 `Mediator` concrete runtime 的 stream lifetime 对照批次;若切回 runtime 线,则以 `RP-126` 的四方矩阵作为后续性能回归基线
### 阶段request lifetime generated-provider 宿主对齐CQRS-REWRITE-RP-125