Skip to content

[perf][telemetry] 归因 CallContext、Metrics 与 Tracing 的每调用成本 #124

Description

@SunSi12138

总跟踪:#121
正式 macro gate 前置:#122
类型:测量/归因 issue。本 issue 允许增加 benchmark、test-only instrumentation 和 throwaway prototype,但不预设 production optimization 一定存在。
实施基线:开始工作时读取 dev 实际 HEAD,并重新核对本文列出的代码事实。

目标

把“CallContext / Telemetry 很贵”拆成三个彼此独立的问题:

A. CallContext snapshot + AsyncLocal push/restore
B. Metrics enabled path
C. Activity/Tracing propagation + recording path

每个问题必须独立得到 GoNo-Go。不能因为 tracing 很贵就顺手重写 CallContext,也不能因为 metrics 有 allocation 就默认 Activity 是同一个原因。

只有某一个子问题通过立项门槛后,才为那个具体机制另开 implementation issue。


当前 dev 必须先理解的事实

执行时重新核对,不能盲信 issue 文本。

CallContext

当前公开 API:

SharpLinkCallContext.Current

通过 AsyncLocal<SharpLinkCallContextSnapshot?> 暴露当前 server invocation context。

Push 会:

previous = AsyncLocal.Value
AsyncLocal.Value = snapshot
return Scope(previous)

Scope.Dispose 恢复 previous。

这意味着即使没有 interceptor,业务 service 本身也可能直接读取 SharpLinkCallContext.Current。因此:

“没有 interceptor 就不 Push AsyncLocal”不是合法的透明优化。

除非另行设计明确的 public opt-in/compatibility 变更,否则不能删除这项可观察语义。

当前 ServerConnectionState 已经对最常见场景做了一层缓存:没有 per-call deadline/metadata 时可复用 connection-level SharpLinkCallContextSnapshot。因此不要重复提出“缓存默认 context”作为新优化,先确认现状。

Metrics/Tracing

SharpLinkTelemetry 当前已有 fast guards,例如:

ActivitySource.HasListeners()
instrument.Enabled

当既没有 Activity listener、也没有 call metrics listener 时,call scope 可以直接是 default。

启用 Activity 时当前会创建 Activity 并设置 RPC tags/status/error fields。要特别区分:

ActivitySamplingResult.None
PropagationData
AllData
AllDataAndRecorded

1% recorded 不等于 1% 创建 Activity。如果未采样的 99% 返回 PropagationData,它们仍可能需要 Activity 进行 context propagation,只是不需要完整 recording data。

历史 P2 evidence 曾显示 metrics 有稳定额外 allocation,且某个 client tracing 配置中 1%/100% tracing 都非常昂贵;这些数据只能作为“值得重新测”的方向性证据。正式 Go/No-Go 必须在 #122 的新 formal recorder 下重跑。


第一性原理

1. 诊断功能不是“免费才算正确”

Metrics/Tracing 被启用时,本来就会产生额外工作。目标不是把 enabled path 变成 off path,而是消除不必要的 allocation、tag construction、duplicate work、AsyncLocal/context work。

2. Off-path 最重要的是不付 enabled-path 成本

Telemetry off 的核心要求:

没有 listener/provider -> 不创建 Activity,不构造 tags,不启动 duration measurement,不创建额外对象

如果当前已经满足,就不要为了“优化”增加新的 feature flag/service lookup。

3. Public CallContext 是语义,不是内部缓存

业务代码可以捕获/读取 SharpLinkCallContext.Current;interceptor/logger 也可能观察 context。因此禁止池化或跨调用复用可变 public invocation context。

4. 先归因,再选方案

如果某 benchmark 多 600 B/op,必须回答 allocation stack 来自:

  • Activity object;
  • tags/TagList;
  • Meter listener path;
  • SharpLinkServerInvocationContext
  • metadata;
  • interceptor pipeline;
  • AsyncLocal/ExecutionContext;
  • logging scope;
  • benchmark harness。

不知道来源时不能写 production candidate。

5. Sampling 与 propagation 是不同语义

一个 trace 可能“不记录 span”但仍必须传播 TraceId/SpanId。不能为了 1% tracing 的数字,让 99% 请求失去 distributed context propagation。

6. 每个子问题单独决定

合法结果示例:

A CallContext: No-Go(成本可测但由 public AsyncLocal semantics 决定)
B Metrics: Go(发现每 call 重复 tag object allocation,可无语义变化消除)
C Tracing: Go(PropagationData path 做了 AllData-only work)

这比一个“大 telemetry refactor”更容易验证和回退。


建议分支与 commit

测量分支:

feature/telemetry-callcontext-evidence

推荐 commit:

  1. bench: isolate call context push and snapshot costs
  2. bench: isolate metrics and activity states
  3. bench: add telemetry allocation attribution scenarios
  4. perf-test: add formal telemetry macro matrix
  5. docs: record call-context telemetry go-no-go evidence

throwaway candidate 必须单独 commit,例如:

experiment: skip all-data-only activity enrichment for propagation-only spans

如果不达门槛,revert experiment,benchmark/evidence 保留。


A. CallContext 调查

A1. 先建立纯 microbenchmark

至少保留/新增以下 cases:

NoContextWork
ReuseSnapshot_NoPush
CreateDefaultSnapshot
CreateDeadlineSnapshot
CreateMetadataSnapshot
PushCachedSnapshot
PushAndReadCurrent
PushNestedAndRestore

每个记录:

  • ns/op;
  • B/op;
  • Gen0;
  • ExecutionContext/AsyncLocal attribution(可用 profiler/trace)。

NoContextWork 必须存在,否则不知道 Push 成本相对什么。

benchmark harness 注意

不要在 benchmark method 自己创建 Task/closure 来制造异步边界,然后把它的 allocation 归到 AsyncLocal。

先测同步 push/restore;再单独测一个真实 async service continuation 场景。

A2. 真实 server invocation A/B

构造以下 server workloads:

1. no auth / no metadata / no deadline / no interceptor
2. auth only
3. metadata only
4. deadline only
5. interceptor enabled but interceptor does nothing
6. service reads SharpLinkCallContext.Current
7. service does not read Current

注意:6/7 只用于归因读取成本,不是允许 runtime 猜业务会不会读取。

正式 macro 使用 #122 formal recorder。

concurrency:

1 / 8 / 32 / 128

至少 TCP;如果 SharedMemory 能放大 runtime CPU 占比,再补 SharedMemory。

A3. Allocation stack

如果 default case 有 per-call object allocation,确认是不是:

SharpLinkCallContextSnapshot
SharpLinkServerInvocationContext
AsyncLocalValueChangedArgs / ExecutionContext related
interceptor context
metadata

不要只看类型名;记录 call stack。

A4. A 的 Go/No-Go 门槛

Go 只允许在以下情况

找到一个不改变 SharpLinkCallContext.Current 可观察语义的 candidate,并满足至少一个:

  • default server workload CPU/op 降 >=3%;或
  • 可归因 per-call allocation 明显下降,且 end-to-end throughput/P99 有稳定收益;或
  • AsyncLocal/context 相关成本占目标 server workload总 CPU >=5%,candidate 可无语义变化消掉其中显著部分。

No-Go

如果主要成本就是“每个 invocation 必须建立正确 AsyncLocal scope”,且没有透明低风险替代,则明确记录:

cost is real but semantic

然后关闭 A,不发明 pooling/global static/current-thread hack。


B. Metrics 调查

B1. 固定 provider/listener

测试必须明确启用了哪些 instruments。不能写“metrics on”但不同运行注册不同 MeterListener/OpenTelemetry provider。

至少:

MetricsOff
CallCountersOnly
DurationHistogramOnly
CountersAndDuration
RepresentativeFullMetrics

client/server 分开测,再测 both enabled。

B2. 直接测 SharpLinkTelemetry scope

需要一个低噪声 microbenchmark,隔离:

StartServerCall + Complete success
StartServerCall + Complete SharpLinkException
StartClientCall + Complete

每种在 off/on 状态测 B/op 和 ns/op。

同时测 instrument Enabled guard 本身,不要把 provider export/background worker 成本和 runtime instrumentation 成本混成一个数字。

B3. Provider export 分层

至少区分:

listener consumes synchronously / no exporter
in-memory exporter
realistic exporter if repo already有标准配置

本 issue 首先优化 SharpLink instrumentation cost,不把网络 exporter 性能问题算给 SharpLink。

B4. Metrics correctness

任何 candidate 必须保持:

  • calls.started/completed/failed exact;
  • active call delta 最终归零;
  • duration count 与完成 call 一致;
  • status/error tags;
  • client/server side tags;
  • ResourceExhausted reason;
  • retry/attempt 等现有指标定义不漂移。

要新增 deterministic metric listener tests,而不是只人工看 Grafana。

B5. Metrics Go 门槛

正式 #122 macro 下:

  • telemetry off 相对完全移除 instrumentation 的 control cost目标 <=1%;
  • representative metrics enabled 的 SharpLink instrumentation 增量目标 <=3%,但这个是目标,不是为了通过而改变 metric semantics
  • 如果当前 >3%,只有 profiler 能明确归因到可消除 runtime work 才 Go;
  • candidate 至少稳定降低目标成本 >=20% 或使 end-to-end target workload改善 >=3%,同时字段完整。

如果主要成本来自 provider/exporter 而不是 SharpLink instrumentation:No-Go,记录边界。


C. Tracing 调查

C1. Sampling matrix 必须正确

建立一个可控制的 ActivityListener,分别返回:

None
PropagationData
AllData
AllDataAndRecorded

再建立真实采样组合:

1% AllDataAndRecorded + 99% None
1% AllDataAndRecorded + 99% PropagationData
100% AllDataAndRecorded

这两个 1% 场景必须分开,因为语义完全不同。

C2. 记录的指标

  • Activity allocations/call;
  • tag enrichment allocations;
  • CPU/op;
  • QPS;
  • P99/P99.9;
  • Activity.Current / trace id propagation correctness;
  • exporter off/on 分层。

C3. 优先验证的假设

当前值得优先验证:

PropagationData Activity 是否仍执行只对 IsAllDataRequested 有价值的完整 tag/status enrichment?

throwaway prototype 可以类似:

activity = source.StartActivity(...)
if activity is null -> default

if activity.IsAllDataRequested:
    SetTag(method/contract/...)

// propagation 必需的 Activity 生命周期继续存在

但哪些 tag 在 PropagationData 下属于现有 contract,必须先用 tests/文档确认。不能看到 IsAllDataRequested 就机械包住所有代码。

另一个假设:error path 的昂贵 error tags 只在 activity 真正记录时创建;仍需保持 error/status metric semantics。

C4. 禁止的“优化”

禁止:

  • sampled=false 时直接不创建 Activity,但当前 listener 要求 PropagationData;
  • 使用自定义 ThreadStatic 代替 Activity.Current;
  • 复用/池化 Activity;
  • 让 1% trace 丢掉 W3C propagation;
  • 修改 trace IDs/span parent 关系来换性能;
  • 把 exporter batching 改动混进 Runtime candidate。

C5. Tracing Go 门槛

先把目标按语义分开:

None sampling

应接近 telemetry off;如果不接近,说明 guard 有问题。

1% recorded + 99% None

SharpLink tracing overhead 目标 <=5%。

1% recorded + 99% PropagationData

不要硬套 <=5%。PropagationData 本身要求创建/传播 Activity context。这里的 Go 条件是:

  • profiler 证明额外 enrichment 是显著部分;
  • candidate 保持 propagation;
  • candidate 对该场景 end-to-end 至少有稳定 >=5% throughput/CPU 改善,或显著 allocation 减少且 P99 不恶化。

100% recorded

主要作为 correctness/upper-bound control,不要求和 off 接近。


统一的正式 macro matrix

#122 完成后至少跑:

Server

Default
ContextRead
Metadata
Deadline
Metrics
Trace_None
Trace_1pct_None
Trace_1pct_Propagation
Trace_All

Client

Default
Metrics
Trace_None
Trace_1pct_None
Trace_1pct_Propagation
Trace_All

每个至少:

c1 / c32 / c128

固定 tiny unary workload 优先,因为 payload/serialization 太大时会掩盖 per-call diagnostics overhead。

base/head 同机交替 >=5 轮。


必须新增/强化的 correctness tests

CallContext

  1. Current 在 service invocation 内可见。
  2. invocation 后恢复 previous/null。
  3. nested scope 恢复正确。
  4. async continuation 后 Current 仍正确。
  5. 两个并发 call 不串 context。
  6. auth/default snapshot 不串 connection。
  7. deadline/metadata per-call 值正确。
  8. interceptor 与 service 看见同一个语义 context。

Metrics

  1. success started/completed/active delta。
  2. failure code/status。
  3. cancellation。
  4. ResourceExhausted reason。
  5. duration exactly once。
  6. telemetry disabled 时不产生 metric callback。

Tracing

  1. None -> 无 Activity。
  2. PropagationData -> Activity context 可传播。
  3. AllData/Recorded -> required tags 完整。
  4. parent/child relation。
  5. retry attempt span 不重复 logical call counters。
  6. error status/type。
  7. cancellation status。
  8. 1% sampler 不破坏 propagation policy。

风险

风险:把公开 context 改成 pool/reuse

业务代码、interceptor、logger、AsyncLocal continuation 可能捕获它。跨调用重用会产生极难复现的数据串扰。禁止。

风险:错误理解 ActivitySamplingResult

PropagationData 不是 None。先写行为测试,再做 tag enrichment candidate。

风险:把 exporter 成本归因给 Runtime

所有实验必须有 no-export/listener-only control。

风险:Telemetry test 使用 process-global listener 导致并行串扰

#83 完成前/期间按现有 global-test 约束隔离;不要为了 perf test 随意恢复 assembly-wide serial,也不要让 listener 泄漏到其他测试。

风险:candidate 只改善 microbenchmark

最终 Go 必须有 #122 formal macro evidence。


回退规则

每个 A/B/C candidate 独立 commit。

立即 revert candidate,如果出现:

  • SharpLinkCallContext.Current 缺失/串扰;
  • trace parent/propagation 改变;
  • metrics count/tag 丢失;
  • error diagnostics 字段缺失;
  • default/off workload稳定回退 >1%-2%;
  • enabled target 的收益低于预先 Go 门槛;
  • 只是把 allocation 移到 exporter/其他层。

benchmark、correctness tests、No-Go evidence 可保留。


本 issue 的完成定义

  • A/B/C 三个子问题都有独立 profiler/allocation attribution。
  • 正式 macro 使用 [perf][tooling] 修复负载测试延迟记录干扰并建立可信性能基线 #122 新 recorder/schema。
  • CallContext public/AsyncLocal semantics 有明确测试。
  • Metrics off/on 和 provider/export 分层完成。
  • Activity None/PropagationData/AllData/Recorded 分层完成。
  • 1% None 与 1% Propagation 场景不再混为一个“1% tracing”。
  • 每个子问题明确写出 Go 或 No-Go,以及证据。
  • No-Go 不创建 production implementation issue。
  • Go 才创建具体机制的实现 issue,并复制对应 correctness/merge/revert gate。

本 issue 可以在 A/B/C 全部 No-Go 时正常关闭;“没有值得做的优化”是有效结论。

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions