为 .NET 10 GC(DATAS)做准备

原作者:maoni 原文链接:https://maoni0.medium.com/preparing-for-the-net-10-gc-88718b261ef2

在 .NET 9 中,我们默认启用了 DATAS。但 .NET 9 并不是长期支持(LTS)版本,因此很多人会在升级到 .NET 10 时首次获得 DATAS。这是一个很艰难的决定,因为 GC 功能通常是不需要用户干预的 ------ 但 DATAS 有些不一样。这也是为什么本文标题是"做准备",而不是单纯的"新功能介绍"😊。

如果你在使用 Server GC,你可能会注意到相比以往的运行时升级,性能特征差异更为明显。内存使用可能会显著不同(很可能更小)------ 这未必是你想要的。这取决于这种取舍对你来说是否明显,以及它是否符合你的优化目标。我建议你至少快速查看一下应用性能指标,看看是否对这种变化满意。很多人会绝对欢迎此变化 ------ 但如果你不是其中之一,不必慌张。我建议继续阅读,看是否可以简单地关闭 DATAS,或者稍微调优让它对你有好处。

我将介绍我们通常如何决定添加哪些性能功能,为什么 DATAS 与典型 GC 功能有很大不同,以及自我上次撰写 DATAS 文章以来引入的调优变化。我还会分享两个我在首方场景中调优 DATAS 的实例。

如果你主要是想知道 DATAS 不适用的场景以帮助判断是否要关闭它,可以直接跳到相关部分。

术语表

在深入内容前,先列出本文中使用的缩写:

  • GC:垃圾回收器,负责管理应用程序的内存分配与释放
  • DATAS:动态适应应用程序大小(Dynamic Adaptation To Application Sizes)
  • TCP:吞吐成本百分比(Throughput Cost Percentage)------ 测量 GC 开销,包括 GC 暂停和分配等待
  • BCD:通过 DATAS 计算的预算(Budget Computed via DATAS)------ 代际 0 分配预算的上限
  • LDS:活动数据大小(Live Data Size),即应用程序在最强 GC 下占用的内存大小
  • UOH:用户旧代堆(User Old Heap),旧代中用户代码分配的部分,包括 LOH 和 POH
  • LOH:大对象堆(Large Object Heap),用于存储 ≥85,000 字节的对象,可通过 GCLOHThreshold 配置修改
  • POH:固定对象堆(Pinned Object Heap),专门用于存储在分配时标记为固定的对象的堆区域

添加 GC 性能功能的一般策略

大多数 GC 性能功能 ------ 无论是新的 GC 类型、新的机制,还是优化现有机制 ------ 通常在你升级到新的运行时版本时自动启用。我们不要求用户采取操作,因为这些功能旨在改善广泛的场景。这也是我们选择实现它们的原因:我们分析许多场景以找出常见问题,确定解决它们的方法,然后优先设计并实现可带来最大影响的功能。

当然,任何性能变化都有引入回退的风险 ------ 对于一个拥有数百万用户的框架,你几乎可以肯定会让某些人退步。这种退步在微基准测试中尤其明显,因为微基准测试的行为高度极端,甚至细微变化都会让结果大幅波动。

一个近期的例子是我们改变了处理 UOH(即 LOH + POH)代的可用区域的方式。我们从基于预算的裁剪策略改为基于"年龄"的策略,因为它整体更稳健(这样我们不会快速释放内存再重新提交,或在长时间后仍保留大量可用区域,因为一直没有消耗几乎所有的 UOH 预算)。但这会完全改变一个原本在一次 GC.Collect() 后主内存降到很低值的微基准测试,使其必须调用 3 次 GC.Collect()(因为我们需等待 UOH 可用区域在两次 gen2 GC 中"变老",第三次才会把它放入释放列表)。

但对于 DATAS,我们知道它天生并不适用于广泛场景。正如我在上篇博客文章中所述,DATAS 针对两类特定场景。我在这里重申:

  1. 在受内存限制的环境中运行的突发性负载。DATAS 旨在当应用不需要那么多内存时收缩堆大小,而在需要更多时扩展堆大小。这对运行在有内存限制的容器中的应用尤其重要。
  2. 使用 Server GC 的小型负载 ------ 例如有人想试试一个小型 asp.net core 应用在 .NET 中的体验,DATAS 旨在提供与小型应用实际需求更加匹配的堆大小。

关于第 1 点,我需要进一步解释。突发性负载非常常见。一个处理请求的应用在某个时间段的用户数自然可能远超一天中其他时间段。但关键在于随后的动作 ------ 如果在非高峰时释放了内存,你会如何利用这些内存?事实证明,有时人们并没有真正的计划 ------ 他们只是希望看到内存占用下降,但并不打算使用这些内存。而有的团队也不需要降低内存占用,因为他们已经为应用预留了全部内存。我最近就和一个客户交流,当我问他们"如果 DATAS 为你释放了内存,你会用来做什么?"时,对方回答:"这是个好问题,我们从没想过。"

对于那些希望利用释放内存的人,一个常见的方法是使用编排环境。DATAS 让此场景更稳健,因为堆大小更可预测,从而帮助设定合理的内存限制。例如在 k8s 中,你可以为非高峰和高峰负载分别确定合适的 request 和 limit 值,更好利用 HPA。我还见过有团队安排任务在机器/虚拟机有空闲内存时运行 ------ 这通常更复杂(而且这些团队通常配有专门的性能工程师),但控制力更强。

另外,也有很多团队拥有专用机器集群,想在高峰时尽最大可能提高吞吐量。他们不愿容忍任何形式的性能下降。他们显然不是 DATAS 的目标用户,因为 DATAS 几乎总会降低其吞吐量 ------ 性能问题很少是"全有或全无",我会在下文讨论如何决定是否该关闭 DATAS。

所有这些因素让我们很难将 DATAS 设为默认开启,因为我们知道有很多团队不愿牺牲吞吐量,或者不会利用释放的内存。

我将在下文详细讨论,如果你想比较性能差异并判断 DATAS 是否适用(或者看到内存减少后想到利用这些空闲内存),该如何分析。

DATAS 与传统 Server GC 的性能差异

DATAS 是一个我花了比其它任何 GC 功能更多时间向同事解释的功能------由于它是高度显性的用户可感知特性,自然比我添加的几乎任何其它 GC 功能收到更多的提问。而且,围绕它存在许多误解。有些人认为 DATAS 只影响启动阶段;有些人假设它只是"内存减少 x%,吞吐量降低 y%";还有些人期待它能"神奇地减少内存占用,而不会带来其他性能差异"(好吧,"神奇地"是我加上的 😆);等等。

要正确理解它的不同,我们需要先理解两者的策略差异。首先也是最重要的,Server GC 并不会根据应用大小进行自适应------这从来就不是它的设计目标。Server GC 主要观察每一代的存活率,并根据这一指标来决定何时进行 GC(当然,还有其他因素影响 GC 的触发时机,但存活率是其中最重要的因素之一)。在我上一篇关于 DATAS 的文章中,我谈到了堆的数量会显著影响堆大小,尤其是在分配了大量临时数据的负载场景下。由于 Server GC 会创建与进程可使用核心数相同数量的堆,这意味着同一个应用在不同核心数的机器上运行,或者同一台机器上限制可用核心数量运行,都会表现出非常不同的堆大小。

而 DATAS 的目标,则是适应应用的大小,这意味着即使核心数差异很大,你的堆大小也应该是相近的。因此,没有什么"DATAS 会比 Server GC 减少 X% 的内存占用"的固定结论。

如果我们看 asp.net 基准测试的"最大堆大小"指标,可以明显看到 Server GC 在 28 核机器(28c)和 12 核机器(12c)上的行为差异------

细心的读者会注意到图中的颜色顺序并不一致。比如在 MultipleQueriesPlatform 场景中,最大堆大小在 12c 情况下比 28c 更大。仔细查看数据可以发现,在 12c 情况下最大堆大小其实发生在测试一开始------

(Heap size (before) 指在某次 GC 之前的堆大小,即 GC 还未来得及缩减堆时的大小。因此 "最大堆大小" 是此指标的最大值)

这是因为在刚开始阶段,28c 配置下由于有 28 个堆,在第一次 GC 发生前进行了更多的内存分配。于是第一次 GC 后,观察到的存活率较小,从而使 gen0 的分配预算(budget)比 12c 更小。12c 很快就进入了稳定状态,并且堆大小显著低于 28c。在稳定状态下,这些基准测试在 28c 下的堆大小始终高于 12c。

这说明了两点:

  1. 如果只测量"最大堆大小",很容易被非稳定状态的行为影响;
  2. 堆大小会因为测试运行的机器不同而有非常大的变化。

需要注意的是,这些效应在小型基准测试里会被放大,但其原理同样适用于真实应用。

使用 DATAS 时,我们看到的情况是------

28c 与 12c 的最大堆大小非常接近,这正是 DATAS 的设计目的------它适应应用的大小。

如果我使用 Workstation GC,需要关注 DATAS 吗?

答案取决于你使用 Workstation GC 的原因。如果你使用 Workstation GC 是因为你的工作负载根本不需要 Server GC,例如应用是单线程的,或分配压力很小,你完全可以接受只有一个线程做垃圾回收,那么 Workstation GC 不仅足够,而且就是正确的选择。

但如果你使用它只是因为 Server GC 内存占用太大,而改用 Workstation GC 来限制内存占用,那么你可能会觉得 DATAS 很有吸引力,因为它既可以限制内存占用,也可以让更多的 GC 线程参与回收,从而减少 GC 暂停时间。

DATAS 是如何工作的

如果你理解了 DATAS 的工作原理,就会自然地得出下面这些建议,帮助你判断它是否适合你的场景。你也可以跳过这一部分,但我个人更喜欢去理解事物的运作机制,而不是单纯记下一些经验规则。这样我能自己得出结论,而不是机械地照搬配置。在上一篇博客中,我提到过当时(.NET 8)的 DATAS 一些实现细节,并指出它很可能会发生重大改变 ------ 事实确实如此,在设计和实现上都有较大改动。我们在 .NET 8 中的实现更多是功能性验证,几乎没有花时间在优化调优上,而主要的调优工作是在 .NET 8 之后进行的。

DATAS 的目标是根据应用规模(即 LDS,Live Data Size,存活数据大小)进行自适应调整。因此,需要有一种方法去适配它。由于 .NET GC 是代际垃圾回收,它并不会频繁回收整个堆。而且大多数完整 GC 都是后台 GC,并且不会进行压缩,因此我们可以近似地通过旧年代对象占用的空间(即总大小减去碎片)来估计 LDS。在做性能分析时,另一个方便的数值是查看一次完整 GC 时的晋升大小(promoted size)。

在上一篇博客中我提到过 conserve memory 配置是 DATAS 实现的一部分 ------ 这一点没有变化。但是 conserve memory 只影响何时触发完整 GC。对于分配非常频繁的应用,除非它们主要分配的是临时 UOH(大对象堆)对象,否则大部分触发的都是瞬时代(ephemeral)GC。而瞬时代的大小在小堆场景中可能占据整个堆的一大部分。

在尝试不同方法之后,我最终确定了一个兼顾适应应用大小保持合理性能的策略,包含两个核心部分:

  1. 引入了一个概念------DATAS 计算的预算(BCD, Budget Computed via DATAS),它是基于应用规模计算出来的 gen0 最大预算上限。这个值可以近似 gen0 的代大小(考虑到有对象会被固定,实际 gen0 的大小可能会略有不同)。
  2. 在上述预算上限之内,如果能保持合理性能,我们还会进一步减少内存占用。我们用目标吞吐量成本百分比 (TCP, Throughput Cost Percentage)来定义"合理性能"。TCP 考虑了 GC 暂停时间以及分配线程等待时间,不过在稳定状态下,使用 GC 暂停时间百分比近似 TCP 已足够。目标是在可能的情况下将 TCP 控制在这个目标值左右。这意味着当负载减轻时,我们会缩小 gen0 预算,从而使 gen0 在下一次 GC 前的大小更小,最终导致堆大小变小。默认目标 TCP 为 2%,可以通过 GCDTargetTCP 配置进行修改。

我们来看两个例子,看看这如何在不同场景下体现出来。为了简化说明,我忽略了后台 GC,并用 GC 暂停时间百分比近似 TCP。

场景 A ------ 一个电商应用将完整商品目录存储在内存中,并在整个进程生命周期保持不变,这就是我们的 LDS。进程开始处理请求,每个请求都会分配一些内存,并在请求完成后释放。

在高峰时段,它同时处理大量请求。这时我们达到了最大的预算 BCD。假设这个预算是 1GB,这意味着每分配 1GB 就会发生一次 GC。如果用 GC 暂停时间百分比近似 TCP,假设每秒分配 1GB,会进行一次 GC,暂停时间为 20ms。那么 GC 时间占比为 2%,正好等于目标 TCP。

在非高峰时段,同时请求减少,假设每秒只分配 ~200MB,如果仍用 1GB 预算,就会每 5 秒一次 GC,此时 GC 时间占比为 (20ms / 5s = 0.4%),远低于 2%。为了达到目标 TCP,我们希望减少预算,更早触发 GC。如果预算减少到 200MB,并假设 GC 暂停时间仍为 20ms(实际上可能会更短,因为存活率少),那么 TCP 再次达到 2%。

在这种情况下,非高峰时段的堆大小减少了约 800MB。根据总堆大小,这会是非常显著的提升。

场景 B ------ 基于场景 A,但我们增加了一个缓存,该缓存是 LDS 的一部分,并在轻负载时缩小,因为不需缓存过多。由于 LDS 变小,BCD 也会变小,此时 gen0 预算会进一步减少,再次体现了 DATAS"根据规模自适应"的特性。同时,conserve memory 机制仍然生效,它会相应调节旧年代的预算和大小。

注意到在以上例子中,我们完全没有提到堆的数量!这是 DATAS 自己处理的事情,因此你无需手动指定。以前有客户会通过 GCHeapCount 配置来指定 Server GC 堆的数量。而 DATAS 更加稳健,可以在需要时利用更多堆(通常意味着更短的单次暂停时间),并在 LDS 下降时减少堆大小,而无需你自己设置。

DATAS 有专门的事件来表示实际的 TCP 和 LDS,但获取这些数据需要通过 TraceEvent 库编程获取。对于几乎所有性能分析,使用上述近似值已经足够。

在哪些情况下不适合使用 DATAS

如果你读过前面的内容,下面这些判断应该很容易理解。

  1. 如果你不需要释放的内存 这很明显 ------ 如果释放的内存对你毫无用途,就没必要改动任何东西。你可以通过 GCDynamicAdaptationMode 配置关闭 DATAS。 我遇到过一些内部团队,他们的进程运行在专用机器上,不会运行其他程序,因此无需额外空闲内存。他们会关闭 DATAS。但如果他们将来希望在非高峰期利用这部分内存,可以再启用。
  2. 如果启动性能至关重要 DATAS 启动时总是从 1 个堆开始,因为我们无法预测你的负载压力,且 DATAS 优化的是大小,所以初始堆数最小。如果你的应用启动性能非常重要,DATAS 会导致在扩展到多堆的过程中有性能回退。
  3. 如果你不能接受任何吞吐量回退 包括启动吞吐量。对于不关心启动的场景,可根据情况选择是否用 DATAS。例如,如果 Server GC 的 GC 暂停时间占比为 1%,你可以设置 GCDTargetTCP 为 1。如果之前限制堆数,DATAS 可能会带来性能提升,因为暂停时间会更短。如果适应应用大小对你有帮助,DATAS 可能是更好的选择。但如第 1 点所述,如果你完全用不到释放出来的内存,就没必要花时间。
  4. 如果你的场景主要发生 gen2 GC 如果你的场景几乎总是 gen2 GC(这几乎总是因为大量分配临时大对象),DATAS 并未在这种情况上进行深入调优。如果你试用 DATAS 后不满意,可以关闭它。如果有足够理由花时间调优,也可参考后面的调优部分进行尝试。

如果需要,如何调优 DATAS

我在一些内部重要负载上试过 DATAS,总体效果很好。但在少数场景下,默认参数效果一般,稍微调整一两个配置就能让它工作得很好。

客户案例 1

这是一个运行在专用机器上的服务器应用。但他们正计划将其容器化,因此使用 DATAS 的确有一定价值。启用 DATAS 后,他们观察到吞吐量下降了 6.8%,同时工作集减少了 10%。目前他们已经禁用了 DATAS ------ 我会解释我是如何调试的,并确定在他们未来想重新启用 DATAS 时应使用的配置。

由于 DATAS 会根据 LDS 限制最大的 gen0 预算,我们需要查看是否触及了这个上限。最简单的方法是分别在启用 DATAS 和未启用 DATAS 的情况下捕获 GC 跟踪。如果你发现触发的 GC 次数更多,那么很可能就是达到了这个限制。

你可以用 "% Pause Time" 列来近似计算 TCP,用 "Gen0 Alloc MB" 列来近似计算 gen0 预算。你需要找到 % 暂停时间最高的阶段,并查看此时是否触发了更多的 GC。

对于这个特定客户,下面是他们的一些 GC 摘录(我已裁剪了 GCStats 视图的列)------

未启用 DATAS

GC index Trigger reason Gen % pause time Gen0 Alloc (MB) Promoted (MB)
7017 AllocSmall 0N 0.7 4,243.75 382.855
7018 AllocSmall 1N 1.2 4,157.85 1,074.82
7019 AllocSmall 0N 0.8 4,218.46 484.276
7020 AllocSmall 1N 2.0 4,249.86 1,072.56
7021 AllocSmall 0N 1.5 4,258.12 453.534
7022 AllocSmall 1N 1.8 4,244.21 1,026.41
7023 AllocSmall 0N 1.0 4,254.77 461.702
7024 AllocSmall 1N 1.4 4,239.38 992.243
7025 AllocSmall 0N 1.0 4,252.54 465.904
7026 AllocSmall 1N 2.5 4,252.47 1,153.60
7027 AllocSmall 0N 1.7 4,216.14 442.233
7028 AllocSmall 2B 0.3 0 15,039.20
7029 AllocSmall 0N 0.6 4,166.23 411.238
7030 AllocSmall 1N 1.0 4,104.28 681.430
7031 AllocSmall 0N 1.4 4,229.11 582.256
7032 AllocSmall 1N 1.1 4,222.06 963.817
7033 AllocSmall 0N 1.5 4,248.45 463.555
7034 AllocSmall 1N 1.1 4,230.40 889.286
7035 AllocSmall 0N 0.8 4,255.81 467.854
7036 AllocSmall 1N 1.4 4,254.73 926.103
7037 AllocSmall 0N 2.3 4,220.31 448.918
7038 AllocSmall 1N 1.2 4,249.19 963.297

启用 DATAS

GC index Trigger reason Gen % pause time Gen0 Alloc (MB) Promoted (MB)
17632 AllocSmall 0N 2.6 1,645.46 236.155
17633 AllocSmall 1N 1.9 1,637.37 430.244
17634 AllocSmall 0N 1.4 1,648.58 228.611
17635 AllocSmall 1N 1.8 1,633.46 461.741
17636 AllocSmall 0N 3.8 1,644.98 257.461
17637 AllocSmall 1N 2.6 1,646.77 492.176
17638 AllocSmall 0N 1.5 1,650.46 217.604
17639 AllocSmall 1N 2.2 1,652.98 446.634
17640 AllocSmall 0N 2.0 1,647.49 176.047
17641 AllocSmall 1N 2.2 1,638.71 495.137
17642 AllocSmall 0N 1.3 1,643.52 194.353
17643 AllocSmall 1N 4.1 1,589.32 451.100
17644 AllocSmall 0N 2.8 1,645.70 220.343
17645 AllocSmall 1N 2.4 1,644.41 479.159
17646 AllocSmall 0N 1.1 1,642.08 229.877
17647 AllocSmall 1N 1.2 1,638.72 436.051
17648 AllocSmall 0N 1.2 1,653.15 158.115
17649 AllocSmall 1N 1.5 1,648.69 487.923
17650 AllocSmall 0N 1.6 1,649.91 211.391
17651 AllocSmall 1N 5.2 1,624.07 412.570
17652 AllocSmall 0N 1.9 1,644.00 213.895
17653 AllocSmall 2B 0.3 0 14,936.54

比较他们在 GC 中的 gen0 预算和 % 暂停时间 ---

指标 无 DATAS 有 DATAS 无 DATAS / 有 DATAS
gen0 预算 (GB) 4.22 1.64 2.6
% 暂停时间 1.2 2.1 0.6

因此,无 DATAS 时的 gen0 预算是有 DATAS 时的 2.6 倍。另一个有用的观察是 % 暂停时间几乎正好等于目标 TCP------2%。这表明从 DATAS 的角度来看它完全按设计工作。但无 DATAS 时我们有 2.6 倍的预算,自然触发 GC 的频率降低,% 暂停时间从 2.1 降到了 1.2。

如果我们想启用 DATAS 且在这一阶段不降低吞吐量,就需要让 DATAS 使用更大的 gen0 预算。要做到这一点,我们必须理解 DATAS 是如何确定 BCD 的。既然是适配内存大小,我们希望将大小乘以一个系数。但这个乘数不能是常量,因为当内存很小时,乘数应该非常大------如果 LDS 只有 2MB(对于小型应用这是完全可能的),我们不希望每分配 0.2MB 就触发 GC------这样开销太高。假设我们希望在触发 GC 前允许分配 20MB,这意味着乘数是 10。但如果 LDS 是 20GB,我们也不希望分配 200GB 才 GC,这意味着乘数要小得多。这就意味着需要一个幂函数,同时在最小值和最大值之间进行限制 ------

复制代码
m = constant / sqrt(LDS);   
// max_m 默认值是 10  
m = min (max_m, m);   
// min_m 默认值是 0.1  
m = max (min_m, m);

幂函数的实际公式是 ---

复制代码
m = (20 - conserve_memory) / sqrt (LDS / 1000 / 1000);

可以简化为 ---

复制代码
m = (20 - conserve_memory) * 1000 / sqrt (LDS);   
m = (20 - 5) * 1000 / sqrt (LDS);   
m = 15000 / sqrt (LDS);

因此常量是 15000,或者如果以 MB 为单位,可以说常量是 15。以下是不同 LDS 值的一些示例 ---

LDS (MB) m m 限制后 BCD (MB)
1 15.00 10.00 10
5 6.71 6.71 34
10 4.74 4.74 47
50 2.12 2.12 106
100 1.50 1.50 150
500 0.67 0.67 335
1,000 0.47 0.47 474
5,000 0.21 0.21 1,061
10,000 0.15 0.15 1,500
15,000 0.12 0.12 1,837
30,000 0.09 0.10 3,000
50,000 0.07 0.10 5,000

https://gist.github.com/Maoni0/15064a505db2d06189a875d4b7e9e211/raw/2153a4f57c1d5c30401dd796573e553bb8f4cb36/bcd.csv

这个常量、max_m 和 min_m 都可以通过配置调整,请查阅配置页面的详细说明。

现在很明显 DATAS 是如何得出 gen0 预算以及我们如何调整它的。如果我们想让预算接近无 DATAS 时的数值,应使用 GCDGen0GrowthPercent 配置将常量增加到 2.6 倍,并使用 GCDGen0GrowthMinFactor 配置提高 min_m,使其不被限制为 0.1------不需要非常精确,只要确保它不是限制因素即可。在此案例中,如果用 15GB 近似 LDS(gen2 GC 的 "Promoted (mb)" 列都显示约 15GB),而无 DATAS 时 gen0 预算是 4.22GB,那么 min_m 应该设为 (4.22 / 15 = 0.28)。我们可以将 min_m 设置为 300,这就相当于 LDS 的 0.3。

客户案例 2

这是客户在预备服务器上的一个 asp.net 应用,代表了他们的一个关键场景。我用压测工具生成了可变的工作负载。

团队已经在使用一些 GC 配置:

如果指定了 GCHeapCount,DATAS 会被禁用,因为它告诉 GC 不要修改堆数量。而修改堆数量是 DATAS 调整性能的关键机制之一,所以这是关闭 DATAS 的信号。

由于该进程与其他许多进程共存于同一台机器,在没有 DATAS 之前,他们选择使用 2 个堆来限制内存使用,同时保持合理的吞吐量。但这种方式不够灵活------当负载升高时,2 个堆的吞吐量会下降,并且 GC 暂停时间会明显增加,因为只有 2 个 GC 线程运行收集。另外,他们可以手动调整堆数量,但这样工作量很大,且服务器 GC 对于减少内存使用并不积极,当负载较轻时可能会出现堆过大的情况。

我将演示使用 DATAS 如何让这个过程更稳健。当我提高负载时,可以看到 GC 中的 % 暂停时间很高------这在只有 2 个堆时并不意外。所以我通过删除 GCHeapCount 配置启用了 DATAS(我保留了 GCNoAffinitize 配置,因为我仍然希望 GC 线程不进行亲和绑定)。我发现即使有 BCD,GC 中的 % 暂停时间仍然很高,因为我们仍频繁触发 GC。于是我决定用 GCDGen0GrowthPercent 配置将 BCD 增加到默认值的 2 倍(我不需要用 GCDGen0GrowthMinFactor,因为 2 倍仍在 max_m/min_m 的限制范围内)。这样该进程就表现得更理想,具有以下特点:

  • GC 的 % 暂停时间显著降低。使用默认 DATAS 时 % 暂停时间也相当低,且堆大小明显更小。根据优化目标,这可能正是你想要的效果。DATAS 可以通过较小的预算和更多的 GC 线程来完成收集工作。但对于该客户来说,这样的 % 暂停时间会影响吞吐量,他们不希望它这么高。我也可以让 DATAS 使用更小的目标 TCP,但在此情况下默认 TCP 已足够。
  • 单次 GC 暂停时间显著缩短,因为有更多的 GC 线程在运行收集任务。
  • 当负载变轻(并发客户端线程数从 200 降到 100)时,堆也会变小。同时我们依然保持较低的 GC % 暂停时间和单次 GC 暂停时间。

希望这些对你的 DATAS 调优有所帮助,如果你有需要的话。

DATAS 事件

我预计大多数用户都不需要查看这些事件,所以我会简要说明。前面提到的那些近似值应该已经足够。对于少数希望出于某种原因进行详细分析的人,DATAS 会触发一个事件,该事件准确表示了我们讨论过的指标。需要注意的是,我们仅在程序中使用这些事件,因此它们不会显示在 PerfView 的 Events 视图中(在那里面你只能看到 GC/DynamicTraceEvent 的事件名,而不是该事件的各个字段)。请参考这篇博客文章,了解如何在程序中从跟踪中获取 GC 信息列表(以 TraceGC 对象的形式)。

LDS 和 TCP 会在 SizeAdaptationTuning 事件中体现,假设你有一个类型为 TraceGC 的 gc 对象 ---

复制代码
// LDS  
gc.DynamicEvents().SizeAdaptationTuning?.TotalSOHStableSize  
// TCP  
gc.DynamicEvents().SizeAdaptationTuning?.TcpToConsider

该事件不会在每次 GC 都触发,因为我们只是每隔几个 GC 才检查是否需要调整 DATAS 的调优参数。

译者:文章总结

1. DATAS 核心思想

  • DATAS = Dynamic Adaptation To Application Sizes(动态适配应用大小)
  • 目标:让 GC 内存预算(特别是 Server GC)随着应用实时 Live Data Size(LDS)变化而调整
  • 好处:
    • 内存 峰值需求下降后 可以收缩堆大小
    • 处理容器 / 内存限制环境更稳健
    • 适配不同机器核心数的情况下,堆大小更一致
  • 机制:
    1. 计算 BCD(Gen0 Budget 上限)
      公式:

      复制代码
      m = (20 - conserveMemory) * 1000 / sqrt(LDS)  
      m = clamp(min_m, max_m, m)
      BCD = LDS × m
      BCD *= (GCDGen0GrowthPercent / 100)
    2. 保持 TCP(GC 暂停时间占比) 接近目标值(默认 2%)

2. DATAS 适用场景

  1. 容器/内存限制环境中的突发型业务
    • 高峰分配大,低峰分配少,需要收缩内存
  2. 小型 Server GC 应用
    • 例如测试 ASP.NET Core 小应用,由于 Server GC 默认堆太大,DATAS 可以自动缩减

3. 不适用的场景(可考虑禁用)

  • 无法利用释放的内存(如专用机器只跑单进程)(GCDynamicAdaptationMode=0
  • 吞吐量完全优先,不能容忍回退
  • 启动性能极其关键(DATAS 启动只有 1 heap → 多 heap 需要时间)
  • 主要是 Gen2 GC 场景(常见于大量临时大对象分配)
  • 业务自己固定堆数(GCHeapCount 会直接禁用 DATAS)

4. 关键可调参数表(DATAS 相关)

配置项 默认值 影响范围 / 对应指标 公式关系 适用场景 调优方向
GCDGen0GrowthPercent 100%(常数=15) 改变 BCD 常数部分,直接增加/减少 gen0 最大分配预算 BCD × (GCDGen0GrowthPercent / 100) % Pause Time 高于目标 TCP 且预算太小导致频繁 GC 提高 → 减少 GC 次数、提高吞吐;降低 → 更频繁 GC,减少内存占用
GCDGen0GrowthMinFactor 0.1 控制 min_m(预算乘数下限),防止 LDS 较小时预算过低 clamp m 下限(m ≥ min_m),最终 BCD = LDS × m LDS 较小但不希望 GC 过频 提高 → 给小 LDS 场景更多预算;降低 → 更积极收缩内存
GCDGen0GrowthMaxFactor 10 控制 max_m(预算乘数上限) clamp m 上限(m ≤ max_m),最终 BCD = LDS × m LDS 极小时预算过大浪费内存 降低 → 限制预算上限
GCDTargetTCP 0.02(2%) 目标 TCP(GC 暂停时间占比) 调整 GC 频率以接近目标 TCP 对暂停时间敏感的低延迟业务 降低 → 减少暂停;提高 → 容忍更多暂停换取更小内存
conserveMemory 5 影响 BCD 的基数 (20 - conserveMemory) m = (20 - conserveMemory) × 1000 / sqrt(LDS) 对内存敏感(容器环境) 提高 → 缩小预算更积极收缩内存;降低 → 增加预算减少 GC 次数
GCDynamicAdaptationMode 1 是否启用 DATAS 1=启用,0=禁用 不希望内存缩放或要固定吞吐量 设为 0 → 回退为传统 Server GC
GCHeapCount 默认=核心数 固定堆数,禁用 DATAS 堆自适应机制 如果设置则 DATAS 禁用 需要固定堆数测试或控制内存 不设置 → 让 DATAS 自动调整堆数
GCNoAffinitize false 控制 GC 线程是否绑定固定 CPU 核心 与 DATAS 共存,不影响内存大小但影响线程调度 多进程共用 CPU 场景 true → 让 GC 线程自由调度

5. 调优步骤建议

  1. 收集 GC 性能数据
    • % Pause Time(近似 TCP)
    • Gen0 Alloc (MB)(近似 BCD)
    • Promoted (MB)(近似 LDS)
  2. 判断瓶颈原因
    • 如果 GC 触发太频繁 → 提高 GCDGen0GrowthPercentGCDGen0GrowthMinFactor
    • 如果内存占用过高 → 提高 conserveMemory 或降低 GCDGen0GrowthPercent
  3. 调整 TCP
    • 吞吐优先 → 调低 GCDTargetTCP(减少 GC 次数)
    • 内存优先 → 调高 GCDTargetTCP(更频繁 GC 收缩内存)
  4. 验证回归
    • 高峰 & 低峰数据对比,确保在不同负载下曲线符合预期

C# .NET 交流群

相信大家在开发中经常会遇到一些性能问题,苦于没有有效的工具去发现性能瓶颈,或者是发现瓶颈以后不知道该如何优化。之前一直有读者朋友询问有没有技术交流群,但是由于各种原因一直都没创建,现在很高兴的在这里宣布,我创建了一个专门交流.NET 性能优化经验的群组,主题包括但不限于:

希望能有更多志同道合朋友加入,分享一些工作中遇到的.NET 问题和宝贵的分析优化经验。目前一群已满,现在开放二群。 可以加我 vx,我拉你进群: ls1075 另外也创建了 QQ Group: 687779078,欢迎大家加入。

相关推荐
程序员小凯2 小时前
Spring Boot性能优化详解
spring boot·后端·性能优化
曹牧3 小时前
C#:可选参数
开发语言·c#
武子康4 小时前
AI-调查研究-104-具身智能 从模型训练到机器人部署:ONNX、TensorRT、Triton全流程推理优化详解
人工智能·gpt·ai·性能优化·机器人·tensorflow·具身智能
关关长语5 小时前
(一) Dotnet使用MCP的Csharp SDK
网络·.net·mcp
阿兰哥6 小时前
【调试篇5】TransactionTooLargeException 原理解析
android·性能优化·源码
Sunsets_Red6 小时前
差分操作正确性证明
java·c语言·c++·python·算法·c#
Aevget7 小时前
DevExpress WPF中文教程:Data Grid - 如何使用虚拟源?(一)
c#·wpf·界面控件·devexpress·ui开发
桦说编程7 小时前
CompletableFuture 异常处理常见陷阱——非预期的同步异常
后端·性能优化·函数式编程
weixin_456904278 小时前
以太网与工业以太网通信C#开发
开发语言·c#