第一章:模型编排Runtime的GC问题全景洞察
在模型编排Runtime(如KServe、Kubeflow Pipelines、BentoML Serving或自研调度器)中,垃圾回收并非仅关乎内存泄漏——它深度耦合于模型生命周期管理、推理请求突发性、多版本模型热加载及GPU显存复用等关键场景。频繁的GC暂停(尤其是Full GC)会直接导致P95延迟飙升、请求排队积压,甚至触发Kubernetes liveness probe失败引发Pod重启。
常见GC异常模式识别
- 堆内存震荡:Prometheus监控中
jvm_memory_used_bytes{area="heap"}呈现高频锯齿状波动(周期jvm_gc_pause_seconds_count{action="end of major GC"}陡增; - 元空间持续增长:
jvm_memory_used_bytes{area="nonheap",id="Metaspace"}单向爬升且不回落,指向动态类加载未卸载(如PyTorch JIT ScriptModule反复注册、ONNX Runtime Session热更新); - G1 Mixed GC失效:日志中持续出现
G1 Evacuation Pause (Mixed)但G1 Old Gen使用率仍超85%,表明大对象(如模型权重TensorBuffer)未被及时晋升或回收。
JVM参数调优关键实践
针对模型服务典型负载(高吞吐、长生命周期、大堆),推荐以下启动配置:
# 示例:16GB堆 + G1GC优化(适配模型权重缓存场景)
java -Xms12g -Xmx12g \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-XX:G1HeapRegionSize=4M \ # 匹配常见模型权重块大小(避免跨区碎片)
-XX:G1ReservePercent=25 \ # 预留空间应对突发Tensor分配
-XX:+ExplicitGCInvokesConcurrent \ # 允许System.gc()触发并发GC(谨慎启用)
-jar model-runtime.jar
注:
G1HeapRegionSize需根据模型权重文件平均分片大小调整;过小导致Region数量爆炸,过大则加剧内部碎片。可通过jstat -gc <pid>观察EC(Eden Capacity)与OC(Old Capacity)比值,理想值应维持在0.3–0.6区间。
运行时诊断工具链
| 工具 | 用途 | 快速命令示例 |
|---|---|---|
jcmd |
触发堆直方图分析 | jcmd <pid> VM.native_memory summary |
jmap |
生成堆快照(慎用于生产) | jmap -histo:live <pid> \| head -20 |
async-profiler |
无侵入式GC热点追踪 | ./profiler.sh -e alloc -d 30 -f alloc.svg <pid> |
模型编排Runtime的GC稳定性,本质是计算资源调度策略与JVM内存模型的协同结果——需将GC行为纳入SLO保障体系,而非孤立优化。
第二章:模型加载慢的GC根源与优化实践
2.1 Go runtime.MemStats在模型加载阶段的内存行为建模
模型加载阶段常引发非预期的堆内存尖峰,runtime.MemStats 是观测该行为的核心接口。
关键指标捕获时机
在 model.Load() 前后各调用一次 runtime.ReadMemStats(),重点关注:
HeapAlloc(当前已分配堆内存)HeapSys(操作系统保留的堆内存)PauseNs(GC 暂停时间分布)
内存快照对比示例
var m1, m2 runtime.MemStats
runtime.ReadMemStats(&m1)
model.Load("llama3.bin") // 触发 mmap + 解析 + 张量反序列化
runtime.ReadMemStats(&m2)
fmt.Printf("ΔHeapAlloc: %v MB\n", (m2.HeapAlloc-m1.HeapAlloc)/1024/1024)
此代码捕获加载前后的净堆增长。注意
HeapAlloc不含mmap映射的只读权重页(计入Sys),需结合MmapSys分析真实内存占用。
| 指标 | 加载前 | 加载后 | 含义 |
|---|---|---|---|
HeapAlloc |
12 MB | 892 MB | 活跃对象堆内存 |
MmapSys |
0 | 3.2 GB | 权重文件 mmap 区域 |
GC 行为关联
graph TD
A[Load start] --> B[触发首次 GC]
B --> C[HeapAlloc 短暂回落]
C --> D[权重解压 → HeapAlloc 持续攀升]
D --> E[GC 频率上升 → PauseNs 累积增加]
2.2 pacer触发时机偏差导致STW延长的实证分析与pprof复现
数据同步机制
Go GC 的 pacer 依赖于后台统计(如堆增长速率、上一轮GC周期)动态估算下一次GC启动时机。当突增分配(如批量JSON解析)打破统计平稳性,pacer 会延迟触发GC,最终在 heap_live 接近 GOGC 阈值时强制进入 STW —— 此时 mark termination 阶段被显著拉长。
复现实验关键代码
func BenchmarkPacerDrift(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
// 模拟突发分配:绕过逃逸分析,压满mcache与heap
data := make([]byte, 8<<20) // 8MB
runtime.GC() // 强制同步GC,扰动pacer统计
_ = data
}
}
make([]byte, 8<<20)触发大量span分配,runtime.GC()中断pacer自适应节奏;连续执行使gcController.heapLive采样失真,导致pacer低估增长斜率,延迟触发GC,最终在STW中集中处理大量对象。
pprof抓取链路
| 工具 | 命令 | 关键指标 |
|---|---|---|
go tool pprof |
go tool pprof -http=:8080 mem.pprof |
runtime.gcMarkTermination耗时占比 >65% |
go trace |
go tool trace trace.out |
GC STW duration spike(>12ms) |
graph TD
A[突增分配] --> B{pacer统计滞后}
B -->|heap_live增速误估| C[GC触发延迟]
C --> D[mark termination堆积]
D --> E[STW延长]
2.3 GC标记阶段对象逃逸对模型权重加载路径的隐式阻塞
当JVM执行CMS或G1的并发标记时,若模型权重加载线程正将FloatBuffer封装为WeightTensor并注册至全局缓存,而该对象因逃逸分析失败被分配在堆中,GC线程会将其标记为活跃——即使权重已加载完成、仅待推理调用。
数据同步机制
权重加载与GC标记共享同一内存屏障:Unsafe.putObjectVolatile()触发的store-store屏障,使weightLoaded = true写入延迟可见,导致GC误判对象存活。
关键代码片段
// 权重加载核心逻辑(逃逸敏感)
WeightTensor tensor = new WeightTensor(buffer); // 若未逃逸,本可栈分配
globalCache.put(modelId, tensor); // 触发堆引用写入
此处
tensor因被globalCache持有而逃逸;JVM无法优化为标量替换,强制堆分配。GC标记阶段遍历globalCache引用链时,将该对象及其大块buffer内存持续视为“不可回收”,阻塞后续权重加载线程的内存申请。
| 阻塞环节 | 表现 | 触发条件 |
|---|---|---|
| GC标记暂停 | loadWeights()平均延迟↑300ms |
tensor堆分配+缓存强引用 |
| 内存碎片化 | DirectByteBuffer分配失败 |
大对象长期驻留新生代 |
graph TD
A[权重加载线程] -->|new WeightTensor| B(对象逃逸)
B --> C[堆分配+全局缓存引用]
C --> D[GC并发标记遍历引用链]
D --> E[误标为活跃对象]
E --> F[权重加载内存申请被阻塞]
2.4 基于mmap预分配+runtime.GC()手动协同的零拷贝加载补丁
传统补丁加载需完整读入内存再解压/校验,带来冗余拷贝与GC压力。本方案通过 mmap 将补丁文件直接映射为只读内存区域,跳过 read() → malloc() → copy 链路。
mmap预分配关键逻辑
fd, _ := os.Open(patchPath)
defer fd.Close()
data, _ := unix.Mmap(int(fd.Fd()), 0, int(size),
unix.PROT_READ, unix.MAP_PRIVATE)
// 参数说明:PROT_READ确保不可写;MAP_PRIVATE避免脏页回写;size需对齐系统页边界(通常4KB)
该映射使补丁内容以虚拟内存形式“即刻可用”,无物理内存复制开销。
GC协同时机控制
- 补丁解析完成后立即调用
runtime.GC() - 避免后续大对象分配触发非预期GC,干扰映射区生命周期管理
| 协同动作 | 触发时机 | 目的 |
|---|---|---|
mmap() |
加载初始阶段 | 零拷贝获取原始字节流 |
runtime.GC() |
解析完成、映射引用释放后 | 回收临时解析对象,防止OOM |
graph TD
A[Open patch file] --> B[Mmap read-only region]
B --> C[Parse header/metadata]
C --> D[Release Go heap refs]
D --> E[Trigger runtime.GC()]
E --> F[Apply patch via direct memory access]
2.5 在线模型热加载场景下的GOGC动态调优策略与AB测试验证
动态GOGC调控机制
模型热加载时,内存陡增易触发高频GC,导致延迟毛刺。需在runtime/debug基础上封装自适应调节器:
func AdjustGOGC(modelSizeMB int) {
base := 100 // 默认值
if modelSizeMB > 500 {
debug.SetGCPercent(int(float64(base) * 0.6)) // 大模型:降为60,换空间换停顿
} else {
debug.SetGCPercent(int(float64(base) * 1.5)) // 小模型:升至150,减少GC频次
}
}
逻辑分析:SetGCPercent控制堆增长阈值;参数modelSizeMB来自热加载前的模型元数据解析,确保调节有据可依;系数0.6/1.5经压测收敛得出,平衡吞吐与P99延迟。
AB测试验证设计
| 组别 | GOGC值 | 样本量 | P99延迟(ms) | GC暂停次数/分钟 |
|---|---|---|---|---|
| A(对照) | 100 | 5000 | 42.3 | 8.7 |
| B(动态) | 60–150 | 5000 | 28.1 | 6.2 |
流程协同示意
graph TD
A[热加载请求] --> B{解析模型size}
B -->|>500MB| C[设GOGC=60]
B -->|≤500MB| D[设GOGC=150]
C & D --> E[启动模型服务]
E --> F[上报延迟/GC指标]
F --> G[AB分流决策]
第三章:上下文丢失的GC关联性故障诊断
3.1 goroutine栈收缩与context.Context取消信号丢失的竞态链路还原
栈收缩触发时机
Go 运行时在 goroutine 栈增长后,若检测到连续空闲且栈使用率低于 25%,可能触发异步栈收缩(runtime.stackShrink)。此过程需暂停目标 goroutine(STW 子集),但不保证原子性地捕获 context.cancelCtx 的 done channel 关闭状态。
竞态关键链路
func worker(ctx context.Context) {
select {
case <-ctx.Done(): // A:读取 done channel
return
default:
// B:执行业务逻辑,可能触发栈分配
buf := make([]byte, 64*1024) // 触发栈增长 → 后续可能收缩
_ = buf[0]
}
}
逻辑分析:
select语句在编译期生成非阻塞 channel 检查逻辑;- 若
ctx.Done()尚未关闭,goroutine 继续执行default分支;- 此时若发生栈收缩(如 GC 周期中
stackShrink调用),而ctx.cancelCtx.cancel正在另一 goroutine 中执行(关闭donechannel),则因栈收缩期间 G 状态切换与donechannel 关闭无内存屏障,导致select下次轮询仍错过信号。
竞态窗口示意
| 阶段 | Goroutine A(worker) | Goroutine B(cancel) |
|---|---|---|
| T1 | 进入 select,检查 done 未关闭 |
— |
| T2 | 执行 make,栈增长 |
调用 cancel() |
| T3 | 栈收缩中(G 状态:Gwaiting) | close(done) 完成 |
| T4 | 恢复执行,重试 select 仍读缓存/未刷新 channel 状态 |
— |
graph TD
A[worker: select ←ctx.Done] -->|T1| B[done 未关闭]
B --> C[分配大栈 → 触发 shrink]
C --> D[G 暂停于 shrink]
D --> E[cancel goroutine close done]
E --> F[worker 恢复 but select 缓存 stale]
3.2 finalizer误绑定导致context.Value引用悬空的Go 1.21兼容性陷阱
Go 1.21 引入了更严格的 runtime.SetFinalizer 绑定规则:finalizer 只能绑定到堆分配对象,且目标必须是该对象的直接指针(非字段指针)。若误将 finalizer 绑定到 context.WithValue 返回的 *valueCtx 中嵌套的 val 字段地址,会导致:
val被提前回收,而context.Value(key)仍返回已失效的内存地址;- 表现为随机 panic 或读取垃圾值(尤其在 GC 触发后)。
失效绑定示例
func badFinalizerSetup(ctx context.Context, key, val any) {
// ❌ 错误:对字段取址并设 finalizer
vctx := ctx.(*valueCtx)
runtime.SetFinalizer(&vctx.val, func(_ *any) { /* ... */ }) // 悬空!
}
&vctx.val是栈上临时地址(字段偏移计算结果),非堆对象首地址;Go 1.21 拒绝该绑定或静默失效,但旧版可能“侥幸”运行——造成兼容性陷阱。
正确实践要点
- ✅ finalizer 仅绑定
*valueCtx本身(若需清理其关联资源); - ✅ 避免在 context 值中存储需 finalizer 管理的可变状态;
- ✅ 优先使用
sync.Pool或显式Close()替代 finalizer。
| Go 版本 | finalizer 绑定 &vctx.val 行为 |
|---|---|
| ≤1.20 | 可能“成功”但语义未定义 |
| ≥1.21 | SetFinalizer 返回 false,日志警告 |
3.3 基于trace.GoCreate、trace.GoStart事件的上下文生命周期可视化追踪补丁
Go 运行时在 runtime/trace 中暴露了 GoCreate(goroutine 创建)与 GoStart(goroutine 首次调度执行)两个关键 trace 事件,为精准刻画 goroutine 上下文生命周期提供了原始信号源。
核心补丁逻辑
// patch: 在 runtime.newproc1 中插入 trace.GoCreate 并携带 parentCtxID
trace.GoCreate(goparentid, goid, pc) // goparentid 来自调用方 context.Context.Value("traceID")
// 同时在 schedule() 调度入口注入 GoStart,绑定 spanID 与 start time
trace.GoStart(goid, spanID, nanotime())
该补丁将 context.Context 的追踪标识(如 spanID)透传至 trace 事件元数据,使 goroutine 生命周期与分布式追踪上下文强关联。
关键字段映射表
| trace 事件 | 携带字段 | 用途 |
|---|---|---|
| GoCreate | parentSpanID |
构建调用树父子关系 |
| GoStart | startNanos |
计算调度延迟(start−create) |
生命周期状态流转
graph TD
A[GoCreate] -->|延迟 Δt| B[GoStart]
B --> C[GoEnd 或 GoSched]
C --> D[Context Done?]
第四章:GPU资源争抢背后的GC调度失衡
4.1 GC辅助线程抢占CUDA流调度器CPU配额的cgroup实测验证
为验证GC线程对CUDA流调度器CPU资源的竞争影响,我们在cpu.cfs_quota_us=-1(无硬限制)与cpu.cfs_quota_us=50000(50ms/100ms)两种cgroup配置下进行对比压测。
实验环境配置
- CUDA 12.3 + Ubuntu 22.04
nvidia-smi -q -d UTILIZATION持续采样GPU活动pidstat -t -p $(pgrep python) 1监控线程级CPU占用
关键监控脚本
# 启动GC线程并绑定至同一cgroup
echo $$ > /sys/fs/cgroup/cpu/gc-test/cgroup.procs
# 强制触发并发GC(Python示例)
python3 -c "
import gc, threading
def stress_gc(): [gc.collect() for _ in range(1000)]
[threading.Thread(target=stress_gc).start() for _ in range(8)]
"
该脚本启动8个GC线程持续调用gc.collect(),模拟高频率内存回收压力;cgroup.procs写入确保所有线程受同一CPU配额约束。
调度干扰观测结果
| cgroup配额 | CUDA流平均延迟(ms) | GC线程CPU占用率 | 流调度器失效率 |
|---|---|---|---|
| unlimited | 0.82 | 12% | 0.03% |
| 50ms/100ms | 3.67 | 49% | 8.2% |
graph TD
A[主线程提交CUDA流] --> B{cgroup CPU配额充足?}
B -->|Yes| C[流调度器低延迟分发]
B -->|No| D[GC线程抢占CPU时间片]
D --> E[流注册/同步延迟上升]
E --> F[GPU空闲周期增加]
上述数据表明:当cgroup施加严格CPU配额时,GC辅助线程显著挤占调度器所需的确定性CPU时间,直接导致流调度延迟激增与失效。
4.2 GOMAXPROCS配置不当引发的GPU kernel launch延迟毛刺归因
Go 运行时调度器与 CUDA 上下文生命周期存在隐式耦合:当 GOMAXPROCS < runtime.NumCPU() 且 GPU 调用密集时,P(Processor)争用会导致 M 频繁挂起/唤醒,中断 CUDA context 的连续性。
数据同步机制
CUDA kernel launch 是轻量级 CPU 操作,但依赖当前线程绑定的 CUDA context。若 Go 协程在不同 OS 线程间迁移(因 P 不足),将触发 context 切换开销(~10–50 μs 毛刺)。
典型误配示例
func init() {
runtime.GOMAXPROCS(2) // ⚠️ 仅设2,而物理核数为16,GPU调用线程易被抢占
}
GOMAXPROCS(2) 强制所有 goroutine 争抢 2 个 P,导致 GPU launch 协程频繁阻塞于 runtime.park_m,延迟抖动放大。
| 配置 | 平均 launch 延迟 | P99 毛刺幅度 |
|---|---|---|
GOMAXPROCS(2) |
8.3 μs | 42 μs |
GOMAXPROCS(16) |
2.1 μs | 5.7 μs |
调度路径影响
graph TD
A[GPU Launch Goroutine] --> B{P 可用?}
B -- 否 --> C[进入 global runq 等待]
B -- 是 --> D[绑定 M 执行 CUDA API]
C --> E[上下文切换 + TLS 查找]
E --> F[延迟毛刺]
4.3 runtime.ReadMemStats与nvidia-smi时序对齐的多维资源争抢定位工具链
数据同步机制
为消除采样抖动,采用单调时钟锚定双源采集:Go 运行时内存指标通过 runtime.ReadMemStats 获取,GPU 显存与计算占用由 nvidia-smi --query-gpu=memory.used,utilization.gpu --format=csv,noheader,nounits 实时拉取,二者均绑定 time.Now().UnixNano() 时间戳。
对齐核心代码
func samplePair() (memStats runtime.MemStats, gpuLine string, ts int64) {
ts = time.Now().UnixNano()
runtime.ReadMemStats(&memStats)
gpuLine, _ = exec.Command("nvidia-smi",
"--query-gpu=memory.used,utilization.gpu",
"--format=csv,noheader,nounits").Output()
return
}
逻辑分析:
UnixNano()提供纳秒级统一时间基线;ReadMemStats无锁快照避免 GC 干扰;nvidia-smi调用需控制超时(建议<100ms),防止阻塞主线程。
关键字段映射表
| Go MemStats 字段 | nvidia-smi 字段 | 争抢表征 |
|---|---|---|
Sys |
memory.used |
内存带宽/显存带宽竞争 |
HeapAlloc |
utilization.gpu |
GPU 计算单元被 CPU 内存分配抢占 |
争抢判定流程
graph TD
A[采集 memStats + gpuLine + ts] --> B{Δt < 5ms?}
B -->|Yes| C[写入时序对齐样本]
B -->|No| D[丢弃,触发重采样]
C --> E[滑动窗口内 correlation(HeapAlloc, utilization.gpu) > 0.8?]
E -->|Yes| F[标记“CPU-GPU 内存分配争抢”事件]
4.4 面向GPU密集型任务的GC暂停窗口主动避让机制(GCPauseWindow)补丁实现
为避免JVM GC停顿干扰CUDA kernel执行,GCPauseWindow在G1垃圾收集器中引入运行时感知调度钩子:
核心注入点
G1ConcurrentMark::mark_from_roots()前插入GPU负载探针G1EvacuateCollectionSet::evacuate_collection_set()中动态延迟触发
关键数据结构
| 字段 | 类型 | 说明 |
|---|---|---|
gpu_util_ema |
double | 指数移动平均GPU利用率(α=0.15) |
next_safe_pause_ns |
int64_t | 下一允许GC暂停的时间戳(纳秒级单调时钟) |
// hotspot/src/share/vm/gc/g1/g1CollectorPolicy.cpp
bool G1CollectorPolicy::should_skip_gc_pause() {
return gpu_util_ema > 0.85 &&
os::elapsed_counter() < next_safe_pause_ns; // 主动让出窗口
}
该逻辑在每次GC决策前检查:若GPU当前负载超阈值且未到达安全窗口,则跳过本次STW,转而触发并发标记增量步进。
执行流程
graph TD
A[GC触发请求] --> B{GPU利用率 > 85%?}
B -->|是| C[查询next_safe_pause_ns]
B -->|否| D[正常执行STW]
C --> E[当前时间 < next_safe_pause_ns?]
E -->|是| F[跳过暂停,记录deferred_gc_count++]
E -->|否| D
第五章:构建面向AI基础设施的Go Runtime治理范式
运行时可观测性增强实践
在某头部AI平台的推理服务集群中,团队将runtime/metrics与OpenTelemetry Go SDK深度集成,通过每5秒采集/debug/pprof/heap、/debug/pprof/goroutine?debug=2及自定义指标(如go_goroutines_per_model),构建了实时运行时健康画像。观测数据经OTLP导出至Prometheus+Grafana栈,并配置动态告警规则:当Goroutines > 10k && GC Pause P99 > 8ms连续3个周期触发时,自动调用pprof.Lookup("goroutine").WriteTo()生成快照并存入S3归档桶。该机制在一次大模型批量预填充任务中提前17分钟捕获goroutine泄漏,定位到sync.Pool误用导致的*bytes.Buffer未回收问题。
GC调优与内存隔离策略
针对多租户LLM推理场景,采用分层GC参数治理:基础容器统一启用GOGC=50;对Qwen2-7B类中型模型实例,额外设置GOMEMLIMIT=4Gi并绑定cgroup v2 memory.max;对Llama3-70B推理Pod则启用GODEBUG=gctrace=1,madvdontneed=1,配合madvise(MADV_DONTNEED)显式释放页表。实测显示,后者使长连接场景下的RSS峰值下降38%,且避免了因内存碎片引发的OOMKilled事件。下表为三类负载的GC行为对比:
| 模型规模 | GOGC | GOMEMLIMIT | 平均GC频率 | P95 STW时间 |
|---|---|---|---|---|
| Phi-3 | 75 | 1.5Gi | 12.3/s | 1.2ms |
| Qwen2-7B | 50 | 4Gi | 3.1/s | 2.8ms |
| Llama3-70B | 30 | 24Gi | 0.8/s | 4.7ms |
并发模型重构:从goroutine泛滥到结构化调度
原推理API采用http.HandlerFunc内直接go handleRequest()模式,导致单节点goroutine峰值超20k。重构后引入轻量级调度器aiexec.Scheduler,内置FIFO队列与基于模型复杂度的权重调度(weight = tokens_in * layers / 1000),每个Worker Pool按GPU显存容量动态伸缩。关键代码片段如下:
type Scheduler struct {
queue *priority.Queue[Task]
workers []*worker
}
func (s *Scheduler) Submit(task Task) {
s.queue.Push(task, task.Weight())
}
安全边界强化:受限执行环境部署
所有模型加载器进程均以unshare(CLONE_NEWUSER|CLONE_NEWPID)启动,结合seccomp-bpf过滤非必要系统调用(禁用openat, execve, ptrace等),并通过/proc/sys/kernel/unprivileged_userns_clone关闭用户命名空间逃逸路径。CI/CD流水线中嵌入gosec -exclude=G104,G204静态扫描,并对unsafe.Pointer使用率超过阈值的模块强制人工审计。
混合部署下的CPU拓扑感知
在AMD EPYC 9654与NVIDIA H100共置节点上,通过读取/sys/devices/system/cpu/cpu*/topology/core_siblings_list生成NUMA亲和映射,使Go runtime的GOMAXPROCS与模型推理线程池绑定至同一NUMA域。实测显示,跨NUMA内存访问延迟降低62%,runtime.LockOSThread()调用成功率提升至99.99%。
运行时热修复能力落地
基于github.com/uber-go/atomic与go.uber.org/zap构建热配置通道,支持在线修改runtime/debug.SetGCPercent()及net/http.DefaultTransport.MaxIdleConnsPerHost。某次突发流量高峰中,运维人员通过curl -X POST http://localhost:8080/runtime/config -d '{"gc_percent":35}'将GC触发阈值从50降至35,在不重启服务前提下将内存利用率稳定在72%以下。
flowchart LR
A[HTTP Config Endpoint] --> B{Validate JSON Schema}
B -->|Valid| C[Update atomic.Value]
B -->|Invalid| D[Return 400]
C --> E[Trigger runtime.SetGCPercent]
C --> F[Reconfigure HTTP Transport]
E --> G[Observe GC Stats via Metrics]
F --> G 