当前位置: 首页 > news >正文

【笔记】asp.net 中,为什么第二次压测的单核性能是第一次压测的 3.2 倍

【笔记】asp.net 中,为什么第二次压测的单核性能是第一次压测的 3.2 倍

Posted on 2026-04-28 21:45  ahfuzhang  阅读(0)  评论(0)    收藏  举报

作者:张富春(ahfuzhang),转载时请注明作者和引用链接,谢谢!

  • cnblogs博客
  • zhihu
  • Github
  • 公众号:一本正经的瞎扯

在压测一个 asp.net 实现的服务器时,发现第一次压测和第二次压测性能差距较大。
以下是我的分析,以及询问 ChatGPT 得到的答案:

C# 中基于 asp.net 的框架做了一个 web 服务。编译时 -p:PublishAot=false。

压测发现,第一次压测单核 qps 为 15156.5; 第二次压测为 48923.7
猜测是 dotnet runtime 内部使用了类似 PGO 的技术,导致预热后的性能更好。

请帮我分析一下这个现象。

这个现象非常像 .NET 的“预热效应”,但不应该只归因于 PGO。
15156 qps → 48923 qps,提升约 3.23 倍,幅度比较大,通常是多个因素叠加:

第一次压测:冷启动 / 冷 JIT / Tier0 代码 / 连接池未热 / ThreadPool 未扩容 / 路由未缓存 / HTTP/2 或 TLS 状态未热第二次压测:JIT 完成 / Tier1 优化代码生效 / Dynamic PGO 生效 / 连接复用 / ThreadPool 稳定 / 缓存命中

1. PublishAot=false 意味着你在跑 JIT 模式

你编译时:

-p:PublishAot=false

说明不是 NativeAOT,而是普通 CoreCLR JIT 模式。

ASP.NET Core 服务启动后,很多方法不会在启动时全部编译,而是在第一次执行到某个方法时才 JIT 编译。

所以第一次压测期间,CPU 里有一部分时间花在:

IL → native code JIT 编译

而第二次压测时,大量热点路径已经编译好了,所以 QPS 会明显上升。

这点是最基础的冷启动差异。


2. Tiered Compilation 是核心原因之一

.NET 默认启用 Tiered Compilation

它大概是这样的:

第一次调用某个方法:先快速生成低优化代码,称为 Tier0方法被频繁调用后:后台重新编译成高度优化代码,称为 Tier1

Tier0 的目标是:

尽快启动,快速生成机器码

Tier1 的目标是:

运行更快,做更多优化

所以第一次压测期间,很多请求路径可能还在跑 Tier0 代码。
第二次压测时,关键路径已经变成 Tier1 代码,所以吞吐明显提高。

可以把它理解成:

第一次压测:边跑边优化
第二次压测:已经优化完再跑

3. Dynamic PGO 也可能参与了

你猜的 PGO 是有道理的。现代 .NET 运行时默认支持 Dynamic PGO

它会在运行时收集热点路径信息,例如:

某个虚方法实际最常调用哪个具体类型
某个 interface call 实际指向哪个实现
某个分支哪边更常走
某些泛型/委托/循环的实际行为

然后 JIT 可以据此重新生成更激进的 Tier1 代码,例如:

interface IFoo
{int Run();
}IFoo foo = new RealFoo();
foo.Run();

冷的时候可能是普通 interface dispatch。

热了之后,如果 runtime 发现这里 99% 都是 RealFoo,可能会做类似:

if (foo.GetType() == typeof(RealFoo))直接调用 RealFoo.Run()
elsefallback 到 interface dispatch

这样可以减少间接调用,提高 inline 机会。

在 ASP.NET Core 场景里,Dynamic PGO 可能优化:

中间件 pipeline
Endpoint routing
JSON 序列化路径
日志判断路径
DI 调用路径
泛型集合访问
Kestrel 请求处理路径

所以你的猜测方向是对的:PGO 可能是原因之一,但它不是唯一原因。


4. ASP.NET Core 本身也有大量应用级预热

除了 runtime,ASP.NET Core 框架自身也会在第一次请求时初始化很多东西。

例如:

Endpoint 路由匹配结构
Controller / Minimal API delegate
模型绑定缓存
JSON 序列化 metadata
反射结果缓存
表达式树编译结果
DI 解析路径缓存
日志 category/cache
TLS/HTTP 连接状态

如果你的接口返回 JSON,那么 System.Text.Json 第一次序列化某个类型时,也可能建立 metadata cache。

例如:

public record UserDto(long Id, string Name);

第一次序列化 UserDto 时,runtime 会准备类型元数据、属性访问器等。
第二次开始就大量复用缓存。


5. ThreadPool 也可能是关键因素

第一次压测时,ThreadPool 可能还没有达到稳定线程数量。

尤其如果你的接口中有:

await
Task.Run
同步阻塞
.Result
.Wait()
数据库/Redis/HTTP IO
日志 IO

那么第一次压测可能经历 ThreadPool hill-climbing 扩容过程。

大概是:

初始 worker threads 较少
压测突然打进来
请求排队
ThreadPool 逐步判断是否需要增加线程
吞吐逐渐上升

第二次压测时,线程池已经处于更合适的状态,所以 QPS 会更高。

不过你说的是“单核 QPS”,如果确实严格限制到 1 个 CPU core,那么 ThreadPool 扩容的影响可能小一些,但仍然会影响调度、队列和异步 continuation。


6. 连接复用也可能造成巨大差异

压测工具的行为也很重要。

比如使用:

wrk
bombardier
h2load
ab
hey

第一次压测时可能包含:

TCP 连接建立
TLS 握手
HTTP/2 连接初始化
连接池建立
客户端侧连接预热
服务端 socket accept 路径预热

第二次压测时,如果客户端和服务端连接状态、内核缓存、端口状态、HTTP/2 stream 状态更稳定,吞吐可能明显上升。

不过如果你的两次压测是两个完全独立进程,连接复用影响会小一些;如果是同一个压测进程多轮跑,影响可能更明显。


7. 这个差距是否正常?

15k → 49k qps,差距偏大,但在下面条件下是合理的:

压测时间较短,例如 5s、10s
服务刚启动后立刻压测
接口路径涉及较多 framework pipeline
Dynamic PGO / Tiered JIT 尚未完成
第一次压测包含 JIT 和缓存初始化
单核限制导致 JIT 开销更明显

如果你第一次压测时间很短,例如:

wrk -t1 -c100 -d10s

那第一次结果非常容易被冷启动污染。

更合理的 benchmark 应该是:

启动服务
先 warmup 30s ~ 120s
丢弃 warmup 结果
再正式压测 30s ~ 120s
重复多轮取稳定值

8. 建议你做几个对照实验

实验 1:关闭 Tiered Compilation

DOTNET_TieredCompilation=0

再压测。

如果第一次和第二次差距明显缩小,说明 Tiered JIT 是主要因素之一。


实验 2:关闭 Dynamic PGO

DOTNET_TieredPGO=0

再压测。

如果第二次 QPS 明显下降,说明 Dynamic PGO 对你的服务路径有贡献。


实验 3:打开 QuickJitForLoops

通常现代 .NET 默认策略已经比较合理,但你可以显式观察:

DOTNET_TC_QuickJitForLoops=1

它影响循环方法是否先 quick JIT。


实验 4:延长第一次压测时间

例如:

wrk -t1 -c64 -d10s http://127.0.0.1:5000/
wrk -t1 -c64 -d60s http://127.0.0.1:5000/
wrk -t1 -c64 -d120s http://127.0.0.1:5000/

观察第一次压测内部是不是逐渐升高。

如果 120 秒的第一次压测平均 QPS 接近第二次,说明主要是预热问题。


实验 5:使用 warmup 脚本

例如:

# warmup
wrk -t1 -c64 -d60s http://127.0.0.1:5000/# real benchmark
wrk -t1 -c64 -d60s http://127.0.0.1:5000/

只记录第二段。


9. 可以用 EventPipe / dotnet-trace 直接验证

你可以抓 runtime 事件,看 JIT 和 Tiered Compilation 行为。

例如:

dotnet-trace collect \-p <pid> \--providers Microsoft-Windows-DotNETRuntime:0x10:4

0x10 是 JIT keyword。

也可以更粗暴一点收 runtime profile:

dotnet-trace collect \-p <pid> \--profile cpu-sampling \--duration 00:00:30 \-o trace.nettrace

然后转 speedscope:

dotnet-trace convert trace.nettrace --format speedscope

在第一次压测期间,如果看到很多类似:

JIT
Compiler
clrjit
System.Private.CoreLib
Microsoft.AspNetCore.*
System.Text.Json.*

第二次明显减少,就能验证 JIT/预热因素。


10. 也可以用 counters 看 ThreadPool / JIT

dotnet-counters monitor -p <pid> System.Runtime

重点看:

% Time in GC
ThreadPool Thread Count
ThreadPool Queue Length
JIT Method Count
Time spent in JIT
GC Heap Size
Allocation Rate

如果第一次压测时:

JIT Method Count 持续增长
Time spent in JIT 增长明显
ThreadPool Queue Length 偏高

那基本就坐实了。


11. 对生产环境的建议

如果你的目标是稳定低延迟/高 QPS,建议做显式 warmup。

例如服务启动后主动请求关键 endpoint:

app.Lifetime.ApplicationStarted.Register(() =>
{_ = Task.Run(async () =>{using var client = new HttpClient();for (int i = 0; i < 10000; i++){await client.GetAsync("http://127.0.0.1:5000/your-hot-path");}});
});

但更好的做法是在外部 readiness 流程中做:

启动服务
warmup 热路径
readiness probe 才返回 ready
开始接真实流量

不要让真实用户流量承担 JIT / PGO / cache 初始化成本。


结论

你的判断基本正确,但更完整的解释是:

第一次压测低:冷 JIT + Tier0 代码 + runtime/framework 缓存初始化 + ThreadPool/连接预热第二次压测高:Tier1 optimized code + Dynamic PGO 优化 + framework cache 命中 + 运行状态稳定

其中最值得重点验证的是:

Tiered Compilation
Dynamic PGO
ASP.NET Core 路由/JSON/DI 缓存
ThreadPool 状态
压测工具连接复用

建议你先做这两个环境变量对照:

DOTNET_TieredCompilation=0
DOTNET_TieredPGO=0

再配合:

dotnet-counters monitor -p <pid> System.Runtime

基本就能判断到底是 JIT/TieredPGO 主导,还是 ThreadPool/连接/框架缓存主导。