依木食品商场 發表於 2026-4-28 21:45:00

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

<p><strong><font size="1" color="gray">作者:张富春(ahfuzhang),转载时请注明作者和引用链接,谢谢!</font></strong></p>
<ul>
<li><font size="1" color="gray">cnblogs博客</font></li>
<li><font size="1" color="gray">zhihu</font></li>
<li><font size="1" color="gray">Github</font></li>
<li><font size="1" color="gray">公众号:一本正经的瞎扯</font><br>
<img src="https://img2022.cnblogs.com/blog/1457949/202202/1457949-20220216153819145-1193738712.png"></li>
</ul>
<hr>
<p>在压测一个 asp.net 实现的服务器时,发现第一次压测和第二次压测性能差距较大。<br>
以下是我的分析,以及询问 ChatGPT 得到的答案:</p>
<blockquote>
<p>C# 中基于 asp.net 的框架做了一个 web 服务。编译时 -p:PublishAot=false。</p>
<p>压测发现,第一次压测单核 qps 为 15156.5; 第二次压测为 48923.7<br>
猜测是 dotnet runtime 内部使用了类似 PGO 的技术,导致预热后的性能更好。</p>
<p>请帮我分析一下这个现象。</p>
</blockquote>
<p>这个现象<strong>非常像 .NET 的“预热效应”</strong>,但不应该只归因于 PGO。<br>
从 <code>15156 qps → 48923 qps</code>,提升约 <strong>3.23 倍</strong>,幅度比较大,通常是多个因素叠加:</p>
<pre><code class="language-text">第一次压测:
冷启动 / 冷 JIT / Tier0 代码 / 连接池未热 / ThreadPool 未扩容 / 路由未缓存 / HTTP/2 或 TLS 状态未热

第二次压测:
JIT 完成 / Tier1 优化代码生效 / Dynamic PGO 生效 / 连接复用 / ThreadPool 稳定 / 缓存命中
</code></pre>
<h2 id="1-publishaotfalse-意味着你在跑-jit-模式"><strong>1. PublishAot=false 意味着你在跑 JIT 模式</strong></h2>
<p>你编译时:</p>
<pre><code class="language-bash">-p:PublishAot=false
</code></pre>
<p>说明不是 NativeAOT,而是普通 CoreCLR JIT 模式。</p>
<p>ASP.NET Core 服务启动后,很多方法不会在启动时全部编译,而是在第一次执行到某个方法时才 JIT 编译。</p>
<p>所以第一次压测期间,CPU 里有一部分时间花在:</p>
<pre><code class="language-text">IL → native code JIT 编译
</code></pre>
<p>而第二次压测时,大量热点路径已经编译好了,所以 QPS 会明显上升。</p>
<p>这点是最基础的冷启动差异。</p>
<hr>
<h2 id="2-tiered-compilation-是核心原因之一"><strong>2. Tiered Compilation 是核心原因之一</strong></h2>
<p>.NET 默认启用 <strong>Tiered Compilation</strong>。</p>
<p>它大概是这样的:</p>
<pre><code class="language-text">第一次调用某个方法:
    先快速生成低优化代码,称为 Tier0

方法被频繁调用后:
    后台重新编译成高度优化代码,称为 Tier1
</code></pre>
<p>Tier0 的目标是:</p>
<pre><code class="language-text">尽快启动,快速生成机器码
</code></pre>
<p>Tier1 的目标是:</p>
<pre><code class="language-text">运行更快,做更多优化
</code></pre>
<p>所以第一次压测期间,很多请求路径可能还在跑 Tier0 代码。<br>
第二次压测时,关键路径已经变成 Tier1 代码,所以吞吐明显提高。</p>
<p>可以把它理解成:</p>
<pre><code class="language-text">第一次压测:边跑边优化
第二次压测:已经优化完再跑
</code></pre>
<hr>
<h2 id="3-dynamic-pgo-也可能参与了"><strong>3. Dynamic PGO 也可能参与了</strong></h2>
<p>你猜的 PGO 是有道理的。现代 .NET 运行时默认支持 <strong>Dynamic PGO</strong>。</p>
<p>它会在运行时收集热点路径信息,例如:</p>
<pre><code class="language-text">某个虚方法实际最常调用哪个具体类型
某个 interface call 实际指向哪个实现
某个分支哪边更常走
某些泛型/委托/循环的实际行为
</code></pre>
<p>然后 JIT 可以据此重新生成更激进的 Tier1 代码,例如:</p>
<pre><code class="language-csharp">interface IFoo
{
    int Run();
}

IFoo foo = new RealFoo();
foo.Run();
</code></pre>
<p>冷的时候可能是普通 interface dispatch。</p>
<p>热了之后,如果 runtime 发现这里 99% 都是 <code>RealFoo</code>,可能会做类似:</p>
<pre><code class="language-text">if (foo.GetType() == typeof(RealFoo))
    直接调用 RealFoo.Run()
else
    fallback 到 interface dispatch
</code></pre>
<p>这样可以减少间接调用,提高 inline 机会。</p>
<p>在 ASP.NET Core 场景里,Dynamic PGO 可能优化:</p>
<pre><code class="language-text">中间件 pipeline
Endpoint routing
JSON 序列化路径
日志判断路径
DI 调用路径
泛型集合访问
Kestrel 请求处理路径
</code></pre>
<p>所以你的猜测方向是对的:<strong>PGO 可能是原因之一,但它不是唯一原因。</strong></p>
<hr>
<h2 id="4-aspnet-core-本身也有大量应用级预热"><strong>4. ASP.NET Core 本身也有大量应用级预热</strong></h2>
<p>除了 runtime,ASP.NET Core 框架自身也会在第一次请求时初始化很多东西。</p>
<p>例如:</p>
<pre><code class="language-text">Endpoint 路由匹配结构
Controller / Minimal API delegate
模型绑定缓存
JSON 序列化 metadata
反射结果缓存
表达式树编译结果
DI 解析路径缓存
日志 category/cache
TLS/HTTP 连接状态
</code></pre>
<p>如果你的接口返回 JSON,那么 <code>System.Text.Json</code> 第一次序列化某个类型时,也可能建立 metadata cache。</p>
<p>例如:</p>
<pre><code class="language-csharp">public record UserDto(long Id, string Name);
</code></pre>
<p>第一次序列化 <code>UserDto</code> 时,runtime 会准备类型元数据、属性访问器等。<br>
第二次开始就大量复用缓存。</p>
<hr>
<h2 id="5-threadpool-也可能是关键因素"><strong>5. ThreadPool 也可能是关键因素</strong></h2>
<p>第一次压测时,ThreadPool 可能还没有达到稳定线程数量。</p>
<p>尤其如果你的接口中有:</p>
<pre><code class="language-csharp">await
Task.Run
同步阻塞
.Result
.Wait()
数据库/Redis/HTTP IO
日志 IO
</code></pre>
<p>那么第一次压测可能经历 ThreadPool hill-climbing 扩容过程。</p>
<p>大概是:</p>
<pre><code class="language-text">初始 worker threads 较少
压测突然打进来
请求排队
ThreadPool 逐步判断是否需要增加线程
吞吐逐渐上升
</code></pre>
<p>第二次压测时,线程池已经处于更合适的状态,所以 QPS 会更高。</p>
<p>不过你说的是“单核 QPS”,如果确实严格限制到 1 个 CPU core,那么 ThreadPool 扩容的影响可能小一些,但仍然会影响调度、队列和异步 continuation。</p>
<hr>
<h2 id="6-连接复用也可能造成巨大差异"><strong>6. 连接复用也可能造成巨大差异</strong></h2>
<p>压测工具的行为也很重要。</p>
<p>比如使用:</p>
<pre><code class="language-bash">wrk
bombardier
h2load
ab
hey
</code></pre>
<p>第一次压测时可能包含:</p>
<pre><code class="language-text">TCP 连接建立
TLS 握手
HTTP/2 连接初始化
连接池建立
客户端侧连接预热
服务端 socket accept 路径预热
</code></pre>
<p>第二次压测时,如果客户端和服务端连接状态、内核缓存、端口状态、HTTP/2 stream 状态更稳定,吞吐可能明显上升。</p>
<p>不过如果你的两次压测是两个完全独立进程,连接复用影响会小一些;如果是同一个压测进程多轮跑,影响可能更明显。</p>
<hr>
<h2 id="7-这个差距是否正常"><strong>7. 这个差距是否正常?</strong></h2>
<p><code>15k → 49k qps</code>,差距偏大,但在下面条件下是合理的:</p>
<pre><code class="language-text">压测时间较短,例如 5s、10s
服务刚启动后立刻压测
接口路径涉及较多 framework pipeline
Dynamic PGO / Tiered JIT 尚未完成
第一次压测包含 JIT 和缓存初始化
单核限制导致 JIT 开销更明显
</code></pre>
<p>如果你第一次压测时间很短,例如:</p>
<pre><code class="language-bash">wrk -t1 -c100 -d10s
</code></pre>
<p>那第一次结果非常容易被冷启动污染。</p>
<p>更合理的 benchmark 应该是:</p>
<pre><code class="language-text">启动服务
先 warmup 30s ~ 120s
丢弃 warmup 结果
再正式压测 30s ~ 120s
重复多轮取稳定值
</code></pre>
<hr>
<h2 id="8-建议你做几个对照实验"><strong>8. 建议你做几个对照实验</strong></h2>
<h3 id="实验-1关闭-tiered-compilation"><strong>实验 1:关闭 Tiered Compilation</strong></h3>
<pre><code class="language-bash">DOTNET_TieredCompilation=0
</code></pre>
<p>再压测。</p>
<p>如果第一次和第二次差距明显缩小,说明 Tiered JIT 是主要因素之一。</p>
<hr>
<h3 id="实验-2关闭-dynamic-pgo"><strong>实验 2:关闭 Dynamic PGO</strong></h3>
<pre><code class="language-bash">DOTNET_TieredPGO=0
</code></pre>
<p>再压测。</p>
<p>如果第二次 QPS 明显下降,说明 Dynamic PGO 对你的服务路径有贡献。</p>
<hr>
<h3 id="实验-3打开-quickjitforloops"><strong>实验 3:打开 QuickJitForLoops</strong></h3>
<p>通常现代 .NET 默认策略已经比较合理,但你可以显式观察:</p>
<pre><code class="language-bash">DOTNET_TC_QuickJitForLoops=1
</code></pre>
<p>它影响循环方法是否先 quick JIT。</p>
<hr>
<h3 id="实验-4延长第一次压测时间"><strong>实验 4:延长第一次压测时间</strong></h3>
<p>例如:</p>
<pre><code class="language-bash">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/
</code></pre>
<p>观察第一次压测内部是不是逐渐升高。</p>
<p>如果 120 秒的第一次压测平均 QPS 接近第二次,说明主要是预热问题。</p>
<hr>
<h3 id="实验-5使用-warmup-脚本"><strong>实验 5:使用 warmup 脚本</strong></h3>
<p>例如:</p>
<pre><code class="language-bash"># warmup
wrk -t1 -c64 -d60s http://127.0.0.1:5000/

# real benchmark
wrk -t1 -c64 -d60s http://127.0.0.1:5000/
</code></pre>
<p>只记录第二段。</p>
<hr>
<h2 id="9-可以用-eventpipe--dotnet-trace-直接验证"><strong>9. 可以用 EventPipe / dotnet-trace 直接验证</strong></h2>
<p>你可以抓 runtime 事件,看 JIT 和 Tiered Compilation 行为。</p>
<p>例如:</p>
<pre><code class="language-bash">dotnet-trace collect \
-p &lt;pid&gt; \
--providers Microsoft-Windows-DotNETRuntime:0x10:4
</code></pre>
<p><code>0x10</code> 是 JIT keyword。</p>
<p>也可以更粗暴一点收 runtime profile:</p>
<pre><code class="language-bash">dotnet-trace collect \
-p &lt;pid&gt; \
--profile cpu-sampling \
--duration 00:00:30 \
-o trace.nettrace
</code></pre>
<p>然后转 speedscope:</p>
<pre><code class="language-bash">dotnet-trace convert trace.nettrace --format speedscope
</code></pre>
<p>在第一次压测期间,如果看到很多类似:</p>
<pre><code class="language-text">JIT
Compiler
clrjit
System.Private.CoreLib
Microsoft.AspNetCore.*
System.Text.Json.*
</code></pre>
<p>第二次明显减少,就能验证 JIT/预热因素。</p>
<hr>
<h2 id="10-也可以用-counters-看-threadpool--jit"><strong>10. 也可以用 counters 看 ThreadPool / JIT</strong></h2>
<pre><code class="language-bash">dotnet-counters monitor -p &lt;pid&gt; System.Runtime
</code></pre>
<p>重点看:</p>
<pre><code class="language-text">% Time in GC
ThreadPool Thread Count
ThreadPool Queue Length
JIT Method Count
Time spent in JIT
GC Heap Size
Allocation Rate
</code></pre>
<p>如果第一次压测时:</p>
<pre><code class="language-text">JIT Method Count 持续增长
Time spent in JIT 增长明显
ThreadPool Queue Length 偏高
</code></pre>
<p>那基本就坐实了。</p>
<hr>
<h2 id="11-对生产环境的建议"><strong>11. 对生产环境的建议</strong></h2>
<p>如果你的目标是稳定低延迟/高 QPS,建议做显式 warmup。</p>
<p>例如服务启动后主动请求关键 endpoint:</p>
<pre><code class="language-csharp">app.Lifetime.ApplicationStarted.Register(() =&gt;
{
    _ = Task.Run(async () =&gt;
    {
      using var client = new HttpClient();

      for (int i = 0; i &lt; 10000; i++)
      {
            await client.GetAsync("http://127.0.0.1:5000/your-hot-path");
      }
    });
});
</code></pre>
<p>但更好的做法是在外部 readiness 流程中做:</p>
<pre><code class="language-text">启动服务
warmup 热路径
readiness probe 才返回 ready
开始接真实流量
</code></pre>
<p>不要让真实用户流量承担 JIT / PGO / cache 初始化成本。</p>
<hr>
<h2 id="结论"><strong>结论</strong></h2>
<p>你的判断基本正确,但更完整的解释是:</p>
<pre><code class="language-text">第一次压测低:
    冷 JIT + Tier0 代码 + runtime/framework 缓存初始化 + ThreadPool/连接预热

第二次压测高:
    Tier1 optimized code + Dynamic PGO 优化 + framework cache 命中 + 运行状态稳定
</code></pre>
<p>其中最值得重点验证的是:</p>
<pre><code class="language-text">Tiered Compilation
Dynamic PGO
ASP.NET Core 路由/JSON/DI 缓存
ThreadPool 状态
压测工具连接复用
</code></pre>
<p>建议你先做这两个环境变量对照:</p>
<pre><code class="language-bash">DOTNET_TieredCompilation=0
DOTNET_TieredPGO=0
</code></pre>
<p>再配合:</p>
<pre><code class="language-bash">dotnet-counters monitor -p &lt;pid&gt; System.Runtime
</code></pre>
<p>基本就能判断到底是 JIT/TieredPGO 主导,还是 ThreadPool/连接/框架缓存主导。</p><br><br>
来源:https://www.cnblogs.com/ahfuzhang/p/19947604
頁: [1]
查看完整版本: 【笔记】asp.net 中,为什么第二次压测的单核性能是第一次压测的 3.2 倍