12-02-性能-数据结构性能调查案例1-5

数据结构性能调查案例 1--5:从症状到可证伪结论

系列 :C# 与常用数据结构源码剖析 · 性能优化实战篇

阅读时间 :约 90 分钟

环境口径 :服务端示例以 .NET 8 为参考;Unity 结论必须绑定编辑器完整版本、Mono/IL2CPP、平台和构建配置。本篇不提供缺少原始报告的时间或倍率。

调查原则:先证明症状,再比较多个假设;每个修复同时满足正确性、资源上界和回归测试。代码是业务示例,不冒充框架源码。


一、性能调查不是给可疑代码定罪

看到 List.RemoveAt、字符串 Dictionary、LINQ、foreach 或对象池,不应直接宣布它是瓶颈。一次可信调查至少包含:

复制代码
症状:用户/帧/请求实际受损的指标
假设:至少两个能解释症状的竞争原因
测量:能区分假设的 CPU、分配、锁、堆或事件证据
根因:从数据结构不变量到观测结果的因果链
方案:保持语义、改变语义和数据布局方案的权衡
验证:正确性、性能、内存峰值和退化输入回归
边界:运行时、平台、规模与输入分布

微基准只能隔离操作成本,不能替代生产 profile;生产相关性也不能证明因果。应先用采样/Timeline 找热点,再用最小实验验证机制,最后回到完整场景做 A/B。

下面五案故意不给"优化后快多少"。读者应按提供的方法生成自己的原始报告。

五个案例的容器和症状不同,但调查闭环相同。下图特意保留"证据否定假设"的回路:实验失败不是调查失败,而是排除了一个竞争解释。

闭环中的 oracle 与资源上限和时间数字同等重要:少处理一批数据、破坏顺序或无限保留池容量都可能让局部数字更好看,却不是合格修复。


案发一:List 中间删除拖慢帧,但嫌疑不只是一条 Copy

1.1 症状

实体列表在战斗高峰出现 CPU 尖峰,采样栈中 List<T>.RemoveAt、数组复制或清引用路径占比上升;同时死亡实体越多,尖峰越明显。也可能观察到列表容量与存活实体数量长期偏离。

1.2 竞争假设

  1. 正向循环每次中间删除都会搬移尾部,多个删除累计接近二次复制。
  2. 循环删除后索引处理错误,跳过元素,导致"幸存垃圾"继续参与后续系统。
  3. T 是大结构体,移动的字节远多于元素个数暗示。
  4. T 是引用/含引用类型,删除后的清槽和 GC 存活集是额外成本。
  5. 真正热点在 IsDead、事件回调或组件析构,RemoveAt 只是调用栈邻居。

1.3 先测什么

记录每帧初始 Count、删除数、删除索引分布、T 大小/是否含引用、移动元素估算量、Capacity 和最终 Count。CPU profile 要展开 RemoveAt 调用者;Unity 用 ProfilerMarker 把"判定"和"删除提交"分开,服务端可用 trace/BenchmarkDotNet 做隔离实验。

构造相同数据与死亡位图,分别测试稀疏删除、连续前缀、连续后缀、随机一半和全部删除。测试代码必须消费最终序列并校验顺序,不能让错误算法因漏删而显得更快。

1.4 根因与不变量

List<T> 有效区间为 [0,Count)RemoveAt(i) 要把 (i,Count) 左移一格,并在 T 为引用或含引用时清除旧尾槽。单次移动 O(Count-i-1),从前向后连续删中部会重复搬运。

如果业务要求稳定顺序,输出必须等于原序列中过滤掉删除项后的稳定子序列。若顺序不重要,不变量可放宽为"每个保留实体恰好出现一次"。两个问题对应不同最优算法。

1.5 方案 A:反向 RemoveAt,简单但仍可能多次搬移

复制代码
// 业务示例:保持剩余元素顺序,修复正向索引跳过。
for (int i = entities.Count - 1; i >= 0; i--)
{
    if (entities[i].IsDead)
        entities.RemoveAt(i);
}

反向删除不会因左移跳过尚未检查的元素,对删除集中在尾部很有效。但随机大量中间删除仍会多次移动;不要把"倒序"推广成所有过滤任务的最优方案。

1.6 方案 B:稳定压缩,一次线性写回

复制代码
// 业务示例:保序 O(n) 压缩;RemoveRange 负责最终清槽。
static void RemoveDeadStable(List<Entity> items)
{
    int write = 0;
    for (int read = 0; read < items.Count; read++)
    {
        Entity item = items[read];
        if (!item.IsDead)
            items[write++] = item;
    }

    if (write < items.Count)
        items.RemoveRange(write, items.Count - write);
}

这会给存活项赋值,即使元素原本就在正确位置;可用 FindIndex 找首个删除后再开始压缩。大结构体复制成本仍需测量。标准 List<T>.RemoveAll(predicate) 也表达稳定过滤,但 predicate/闭包与 Unity 类库实现要在目标后端检查。

1.7 方案 C:swap-back,牺牲顺序换 O(1) 删除

复制代码
// 业务示例:仅在顺序无业务意义时使用。
static void RemoveAtSwapBack<T>(List<T> items, int index)
{
    int last = items.Count - 1;
    items[index] = items[last];
    items.RemoveAt(last);
}

它改变顺序,并会改变"同帧遍历下一项"的索引处理。若其他结构保存 List 索引,交换后还必须更新被搬实体的反向索引。这个方案不能用于渲染排序、确定性回放、网络序号或任何顺序可观察场景。

1.8 回归测试

Where(!dead).ToArray() 作为测试参考,对随机列表与删除位图验证稳定压缩;swap-back 则比较多重集合与唯一 ID 集合,不比较顺序。覆盖空、单项、首尾、相邻、全删、无删、重复值和大结构体。性能门槛同时报告删除分布、移动字节、分配和尾帧。

Unity 主线程更关注单帧最坏值;服务端批处理可能更关注总吞吐。服务端可容忍一次大批压缩,游戏可把销毁提交限定在阶段末,并给每帧工作量设预算。


案发二:Dictionary 查找变慢------键、哈希还是重复工作

2.1 症状

配置加载、缓存或实体索引中 TryGetValue/GetHashCode/Equals 占比升高;桶冲突比较次数增加;有时条目"明明在表里却找不到",或内存随重复字符串键增长。

2.2 竞争假设

  1. 键插入后被修改,缓存哈希所在桶与当前哈希不一致。
  2. 自定义 comparer 返回低熵/恒定哈希,碰撞链退化。
  3. 代码 ContainsKey 后又用索引器,重复计算哈希与遍历。
  4. 长字符串键的哈希/比较确实是热点,但换 int 的映射构建反而更贵或破坏可读契约。
  5. 容量估计不足导致构建阶段 Resize,而稳态查找并不慢。
  6. 多线程无同步访问普通 Dictionary,症状不是性能而是数据竞争。

2.3 可变键反例

复制代码
sealed class MutableKey
{
    public int Region;
    public int Id;

    public override int GetHashCode() => HashCode.Combine(Region, Id);
    public override bool Equals(object? obj) =>
        obj is MutableKey other && Region == other.Region && Id == other.Id;
}

var key = new MutableKey { Region = 1, Id = 7 };
var map = new Dictionary<MutableKey, string> { [key] = "player" };
key.Region = 2; // 破坏:键的哈希/等价状态在表内发生变化。

Dictionary 不会自动搬迁该条目。后续用 key 查找可能沿新哈希桶失败。根因不变量是:键留在表内期间,参与 Equals/GetHashCode 的状态稳定;相等键必须同哈希。

2.4 测量设计

用代理 comparer 统计 GetHashCode/Equals 调用次数;构造正常分布、恒定哈希、热点键、命中/未命中和长短字符串。将构建与稳态查找分开测,预生成操作轨迹,测量区不生成随机键。

对生产问题记录 Count/Capacity(公开可得路径有限时不反射依赖私有字段)、键长度分布、命中率和 comparer 类型。可变键问题用插入前后 hash 日志与最小复现证明;不要因为改成 int 后症状消失就跳过根因。

2.5 方案权衡

复制代码
// 一次查找表达存在性和值。
if (map.TryGetValue(key, out Config? config))
    Use(config);

这比 ContainsKey 后索引器更清晰且通常少一次查找,但如果只关心存在性,ContainsKey 本身合理。插入可用 TryAdd 表达冲突,更新用索引器或 CollectionsMarshal(仅目标支持且能遵守 ref 失效契约)。

字符串键应显式选择业务 comparer,例如协议 ID 常用 StringComparer.Ordinal,但默认 string comparer 本身不等于"文化比较且频繁分配";不能用旧稿的假设定罪。改用整数 ID 可缩短键,却要维护字符串到 ID 的规范化表、碰撞/版本和存档兼容。

容量已知时构造 new Dictionary<TKey,TValue>(expected, comparer) 可减少构建 Resize,代价是过估常驻内存。差哈希应修复 comparer/键设计,扩容只会暂时稀释,不能创造哈希熵。

多线程读写要用外部锁、ConcurrentDictionary 或单写者快照。ConcurrentDictionary 不是普通 Dictionary 的无成本替换,且 GetOrAdd 委托可能多次执行。

2.6 回归测试

属性测试 comparer 的自反、对称、传递以及相等蕴含同哈希;插入随机等价键后验证查找/删除。故意恒定哈希作为退化输入,验证正确性不变并记录比较次数随 n 增长。迁移字符串到 ID 时做双实现差分,覆盖大小写、Unicode、空值策略、重复和旧存档。

Unity 需区分 Mono/IL2CPP 的字符串与字典实现,并在真机测;服务端还要考虑不可信键的哈希拒绝服务和缓存上界。任何"字符串固定慢若干倍"的数字都没有跨环境意义。


案发三:LINQ 热点------延迟、闭包还是全量缓冲

3.1 症状

Update 或请求热路径出现 iterator、display class、List/数组分配;查询被多次执行;首项延迟很高;或 Take(1) 仍读取大量输入。把这些都归咎于"LINQ 链分配三次"无法指导修复。

3.2 竞争假设

  1. 捕获 lambda 每次创建 closure;无捕获委托可能缓存,但 iterator 仍可能创建。
  2. Where/Select 延迟查询被 Count 与 foreach 重复枚举。
  3. ToList/ToArray 是主要分配,而非 Where 本身。
  4. OrderBy/GroupBy 首项前必须缓冲全源;Join 缓冲 inner。
  5. 源静态类型为 IEnumerable<T>,枚举器/接口路径增加对象或装箱。
  6. 真正分配在 selector 内的字符串、日志或对象构造。

3.3 调查时序

复制代码
float radius = CurrentRadius;
IEnumerable<Enemy> query = enemies
    .Where(e => e.Distance < radius)
    .Select(e => e);

int count = query.Count();      // 第一次枚举
foreach (Enemy enemy in query)  // 第二次枚举
    enemy.Tick();

用可观察源统计 GetEnumerator/MoveNext/Dispose,用两个 ProfilerMarker 分开"构建 query"和"消费 query"。查看 IL 中 closure/display class 和 delegate 创建;Unity 再查 IL2CPP C++ 与 Player GC.Alloc。

3.4 根因不变量

延迟查询每次枚举都重新从源获取枚举器,selector/predicate 可再次执行;它不是快照。缓冲算子则必须持有元素/键到阶段结束。查询正确性需要 selector 可重复或明确只枚举一次;若源会变化,两次枚举可能得到不同结果。

3.5 多种方案

只需消费一次且不需要结果集合时,手写单循环最明确:

复制代码
int aliveNear = 0;
for (int i = 0; i < enemies.Count; i++)
{
    Enemy enemy = enemies[i];
    if (enemy.IsAlive && enemy.Distance < radius)
    {
        aliveNear++;
        enemy.Tick();
    }
}

需要同一快照多次读取时,ToList 一次可能比重复枚举更正确,虽有分配。热路径可复用有清晰所有者的结果 List,但调用方不能跨帧持有它。只需要计数/Any 时使用相应终结操作可短路或走 Count 快路。

高频按队伍筛选可维护 Dictionary<Team,List>,把查询成本移到实体变更点;代价是一致性、移除和内存。初始化/编辑器工具中的 LINQ 可读性收益可能远高于微小成本,不应普遍禁用。

static lambda 防捕获,但不保证整条查询零分配;缓存完整 IEnumerable 可能绑定旧源并延长生命周期。字符串拼接改用专用 API也要验证语义。

3.6 回归测试

将 LINQ 结果与参考循环逐项比较,覆盖空源、异常 selector、重复、源在两次枚举间变化。用调用计数断言预期枚举次数;对 OrderBy/GroupBy/Join 测首项消费范围。性能矩阵区分数组/List/IEnumerable、捕获/无捕获、流式/ToList/缓冲,并报告源规模与匹配率。

Unity 关注每帧分配与尾帧;ASP.NET 服务端还要关注每请求分配、线程池和 query 的数据库 provider。本文结论只适用于 Enumerable;IQueryable 可能把表达式翻译到远端,不能用手写循环机械替换。


案发四:foreach 出现装箱------不是关键字的罪

4.1 症状

Profiler 出现枚举器对象或 box,接口热路径产生小对象;编辑器与 IL2CPP Player 结果不同。旧结论"IL2CPP 的 List foreach 一定装箱"会误导排查。

4.2 竞争假设

  1. 具体 List<T> foreach 直接使用 struct Enumerator,并未装箱;分配来自循环体。
  2. List 被传成 IEnumerable<T>,struct Enumerator 返回接口时装箱。
  3. 使用非泛型 IEnumerable/IEnumerator,Current 为 object,值类型元素还可能装箱。
  4. 自定义 GetEnumerator 返回 class,本来就创建对象。
  5. 泛型方法约束允许 pattern/constrained 调用,实际路径与接口参数不同。
  6. Profiler/Deep Profile 本身或日志污染结果。

4.3 IL 对照

复制代码
static int SumConcrete(List<int> source)
{
    int sum = 0;
    foreach (int item in source)
        sum += item;
    return sum;
}

static int SumInterface(IEnumerable<int> source)
{
    int sum = 0;
    foreach (int item in source)
        sum += item;
    return sum;
}

编译后查看局部枚举器类型、GetEnumerator 返回类型、boxconstrained.、接口 callvirt 和 finally Dispose。具体 List 路径通常保留结构体枚举器;接口路径可能装箱。最终可观察分配再由 Mono/IL2CPP 真机验证,不能只读 C# 关键字。

4.4 不变量与方案

迭代必须恰好按容器契约访问每项,枚举器必须在异常/提前退出时 Dispose。List Enumerator 还通过版本检查发现结构修改;改成 for 可能让"边遍历边 Add"表现不同,优化不能顺手改变失败语义。

方案包括:保留接口以获得组合性;热路径增加具体 List/数组重载;用泛型 pattern 设计避免擦除;直接 for;或重构数据结构。API 泛化的维护价值可能大于一个尚未证实的盒对象。

不要仅为避免枚举器而使用 CollectionsMarshal.AsSpan:目标 Unity 可能没有 API,且 List 结构修改会使视图失效。数组 foreach 通常按索引降低,但循环体仍可分配。

4.5 回归测试

建立静态类型矩阵:数组、List、IEnumerable 指向 List、IList、自定义 struct/class enumerator、非泛型 IEnumerable。正确性检查顺序、异常 Dispose 和修改时行为;分配用预热后的线程分配计数/Unity Profiler,IL 与生成 C++ 作为机制证据。

服务端 CoreCLR 的 JIT 可能去虚拟化部分泛型路径;IL2CPP AOT 与 Unity Mono 的结果不同。报告必须附 SDK/Unity、backend、架构和构建,不写"foreach 固定比 for 慢"。


案发五:对象池压住 GC,却制造所有权事故和峰值留存

5.1 症状

启用池后瞬时分配下降,但常驻内存持续在历史峰值;对象出现脏状态、重复使用、跨请求数据泄露或 use-after-return;池在突发后保留大量大数组。也可能发现池的锁竞争高于原分配成本。

5.2 竞争假设

  1. 池无容量上限,峰值对象永久由池根保持。
  2. 归还前未清除引用字段,整个对象图继续存活或泄露敏感数据。
  3. 同一对象重复归还、借用方归还后仍使用,破坏独占所有权。
  4. ArrayPool 返回长度大于请求,调用方错误处理整个数组而非有效区间。
  5. 大小分桶不合理,小请求保留大缓冲;峰值污染常规工作集。
  6. 多线程池锁/清零成本使优化收益消失。
  7. 对象很便宜且不在热点,池化增加复杂度却没有真实收益。

5.3 所有权状态机

复制代码
InPool -> Rent -> InUse -> Return -> Reset -> InPool
                    |                 |
                    +-- 不得再访问 --+

若池满:Return -> Dispose/丢弃(按资源类型)

每个实例同一时刻只能有一个 owner。异常路径必须 finally 归还,但资源若已经转交给异步消费者,就不能由原调用方提前归还。池对象如果包装原生句柄,还要区分"重置复用"和"真正 Dispose"。

5.4 ArrayPool 安全用法

复制代码
byte[] rented = ArrayPool<byte>.Shared.Rent(requiredLength);
try
{
    Span<byte> active = rented.AsSpan(0, requiredLength);
    active.Clear(); // 若算法要求确定初值或数据敏感。
    Process(active);
}
finally
{
    ArrayPool<byte>.Shared.Return(rented, clearArray: true);
}

clearArray:true 会增加归还成本,但对敏感数据/引用残留可能必要;也可只清有效区,但要确保池契约与异常路径。归还后任何 Span、Memory、异步写或原生指针都必须停止使用。

5.5 容量和淘汰方案

对象池应有最大保留数/按尺寸桶上限;超限对象丢弃或 Dispose。根据近期稳定分位数而非历史绝对峰值调整,并设冷却期避免扩缩震荡。大缓冲可不入公共池,或使用专用上限。

服务端还需租户隔离、敏感数据清零、并发公平和进程内缓存 SizeLimit/过期;Unity 则要考虑场景卸载时释放预制体/Native 资源、禁用域重载导致静态池跨 Play 保留,以及 UnityEngine.Object 原生/托管双生命周期。

对象池不是缓存:缓存按键复用有淘汰与一致性,池只管理无身份的可重置实例。静态 ConcurrentDictionary 无界保存对象既不是安全池,也不会因并发类型自动限制内存。

5.6 测量与回归

同时记录 rent/return 数、miss/new、池内保留、借出中、尺寸分布、峰值后回落、清零时间、锁等待和 GC。用阶跃负载:稳定低水位---短突发---长时间低水位,验证内存能按设计回落,而不是只测峰值吞吐。

为每个对象加入测试代次/状态(生产可按成本选择),随机异常与取消,验证无双重归还、无借出泄漏、归还后状态重置。异步任务用 Barrier 强制"归还与继续使用"竞态,确保 owner 协议阻止它。敏感缓冲做跨租户模式测试。

正确的无池基线必须使用相同算法;池方案若漏处理数据会假快。若分配 profile 显示对象不在瓶颈,就删除池比调参更安全。


六、跨案件调查矩阵

案件 主要不变量 首选证据 最容易误优化的点
List 删除 顺序是否可观察、每项恰好一次 删除分布、移动量、尾帧 无条件 swap-back
Dictionary 键稳定、相等蕴含同哈希 comparer 调用、碰撞输入、构建/查找分离 所有字符串改 int
LINQ 枚举次数、缓冲范围、selector 可重放 IL、分配调用栈、可观察源 所有 LINQ 改 for
foreach static type、枚举器形态、Dispose IL box/constrained、Player 分配 认为关键字必装箱
池化 单 owner、归还后禁用、保留上限 pool 指标、堆快照、阶跃负载 无界池压低瞬时 GC

这五案经常叠加:LINQ 产生临时 List,接口 foreach 枚举它,结果缓冲被放进无界池;或 Dictionary 值保存池对象,删除遗漏让对象永不归还。调查要沿所有权和调用链,而不是只优化采样栈叶子。


七、统一的实验与报告模板

7.1 正确性基线

先用简单可信实现生成结果,优化实现做差分。随机/属性测试覆盖空、最小、最大、重复、退化哈希、全部删除、异常/取消和并发交错。业务允许改变顺序时,也要验证集合身份和确定性要求。

7.2 性能隔离

预生成输入,构建与稳态分开,预热并消费结果。至少报告中位/尾部、分配字节、GC、CPU、峰值内存;并发还报告锁等待,Unity 报告帧分位数。不要在测量区日志、随机生成或创建测试 Task。

7.3 环境记录

复制代码
代码提交与基准源码
.NET SDK/runtime 或 Unity 完整版本
Mono/IL2CPP/Burst 与构建配置
OS、CPU、架构、设备、功耗模式
GC 配置、线程数、容量、T 大小
输入规模、命中率、删除/键分布、活跃版本
原始 trace/capture 与统计方法

没有这些信息,不报告固定毫秒、百分比或倍数。一次机器上的排序也不能推广到所有规模;用参数曲线寻找交叉点。

7.4 完整场景回归

微基准通过后,在 Unity 代表关卡/真机和服务端回放流量中 A/B。检查用户指标、内存峰值、正确性告警和故障恢复。优化若让代码复杂,应记录触发阈值与回退开关;运行时升级后重新验证。


八、总结:能被反驳的结论才有价值

List 中删的根因可能是搬移,也可能是顺序错误或大结构体;先确定顺序不变量,再选反向删除、稳定压缩或 swap-back。Dictionary 问题要拆成可变键、哈希质量、重复查找、容量和并发,不能把字符串一律判罪。

LINQ 必须区分延迟 iterator、闭包、重复枚举与全量缓冲;foreach 必须查看静态类型与枚举器,不由关键字或 IL2CPP 标签定罪。池化则把分配问题转成所有权、清理、容量和峰值留存问题,只有上界明确才算修复。

每个优化结论都应形如:"在给定版本、输入分布和不变量下,证据排除了其他假设,方案改善指定指标且通过这些回归测试。"它可能在另一项目被实验推翻。可被推翻不是缺点,而是性能工程从传说变成科学调查的起点。


下一篇GC、装箱、内存池化、微优化与 SIMD 决策树

相关推荐
OKkankan1 小时前
Python常用容器与导入语法详解(二)
数据结构·python
Java的搬运工1 小时前
使用 C# 轻松管理 PDF 文件的用户权限
c#·用户权限
Cccp.1231 小时前
【leetcode】(六) 图和贪心算法
数据结构·算法·leetcode
y1su1 小时前
【Leetcode】1477. 找两个和为目标值且不重叠的子数组
数据结构·后端·算法·leetcode·职场和发展
UWA2 小时前
让UE性能分析真正穿透指标:GPU看堆栈,Texture拆到Group
性能优化·游戏开发
空空潍2 小时前
2026年软考中级软件设计师(二):数据结构
数据结构·软考·软件设计师·软设
mmmmath_312 小时前
LeetCode.541.反转字符串II
数据结构·算法·leetcode
醇氧13 小时前
MySQL 8.0 系统表损坏与引擎转换故障排查实战
数据结构·算法
Neil201314 小时前
ajax跨域请求
c#