每周性能技巧 #52:配置旋钮是有害的

本节阅读量:

本文翻译自 Abseil 官网的 Performance Tip of the Week #52: Configuration knobs considered harmful

原文发布于 2021 年 9 月 30 日,作者 Chris Kennelly,更新于 2025 年 10 月 3 日。

flag、选项和其他覆盖默认行为的机制,在迁移期间或为短期满足特殊需求时很有用。但长期看,它们会过时,不再给用户带来真实收益,并阻碍集中化一致性和优化工作。本篇讨论新增可配置性时,在技术债和优化速度之间的权衡。

理想的 flag 生命周期

开发新功能时,用 flag 保护它通常很直接,也经常被推荐。feature flag 可以把“把代码推到生产”和“打开新功能”解耦,因为新功能可能有未知正确性 bug 或不同资源需求。

对常用库来说,flag 还允许早期用户 opt in。当默认值切换后,flag 也提供逃生门,让用户临时回到旧行为。

例如,TCMalloc 的 hugepage 感知分配器优化 rollout 就成功使用了这种方式:许多应用早期 opt in,即使经过大量测试,少数应用仍然看到资源需求变化。它们可以 opt out,同时大多数 TCMalloc 用户继续获得效率收益。

这些经验让 flag 在理论上看起来总是好事,但实践完全不同。flag 好不好,取决于预计会有多少用户使用该特性:

  • 如果某个 flag 的用户数量预计总是很少,它会阻碍未来演进。
  • 如果用户数量中等,这种复杂度可能合理,但很难把 flag 设置到最优。一些团队观察到,通常只有特性作者有足够上下文知道何时设置、以及应该设置成什么。
  • 如果用户数量接近 100%,那很可能是在迁移到新默认值,flag 只是用来提供 opt out。这是 flag 的好用途,但 rollout 完成后要清理它,避免无限期存在。否则它会变成技术债,阻碍未来变化,或者成为“名字奇怪的标准旋钮”。

flag 不能传达意图

很多 flag 的单位完全不透明,并且常有二阶或三阶影响,用户不一定能直观看懂。

Titus Winters 在 2021 CppCon 演讲中提到一个现实例子:微波炉的“爆米花按钮”并不应该用于微波爆米花,因为这个按钮并不匹配真正需要的设置。

在 Google C++ 代码库中,Abseil 的高性能哈希表 SwissMap 没有实现 max_load_factor flag。迁移到 SwissMap 时我们发现 max_load_factor 实用性很低。更糟的是,在很多设置了它的地方,值都是错的。

即使正确理解了 max_load_factor 的作用,用户也经常错误配置它来达成期望目标。max_load_factor(0.25) 可能表达“用 RAM 换速度”的意图,但这个设置可能同时让 CPU 性能更差、RAM 使用更多,背离用户意图。

不同实现可能 API 兼容,但行为不能有效迁移。开放寻址哈希表典型 load factor 小于 1,而链式哈希表典型 load factor 大于等于 1。二者之间切换会让 max_load_factor 产生出人意料的不同效果。

这些经验促使 SwissMap 作者把 max_load_factor 做成 no-op,只为 API 兼容而提供它。

过时的配置参数

调优配置也是一种不会优雅老化的优化。

对于常用库中的 flag,默认值本身可能已经演进:某个特性发布了,或者某个优化落地了。Google 生产配置语言的性质意味着,一旦服务硬编码了某个 flag 值,它就会覆盖默认值。

这原本正是选择非默认值的原因。但随着代码库高速演进,很容易忽略底层基础设施已经改善,而现在的覆盖值已经比默认值更差。

关键做法是谨慎使用自定义 flag,并定期重新审视。设计新选项时,优先选择好的默认值,或者让参数尽可能自调优。自调优可以根据工作负载自动适应,而不是要求通过 flag 精细调参。

降低长期速度

Titus Winters 指出:如果 99% 用户通过默认设置理解一个 API 的行为,那么改变设置的 1% 用户会处于风险中。更高层 API 很可能假设默认行为,从而让这 1% 用户处于半支持状态。

可配置性短期可以很有帮助,但长期是一把双刃剑。选项会增加未来每次变更必须考虑的状态空间,使推理、测试和在生产中成功落地新特性更困难。除了优化成本,这种复杂度也会阻碍更好的业务目标:延迟产品体验改进的额外复杂度,是一种不明显的外部性。

例如,TCMalloc 有许多调优选项和定制点,但几个优化最终来自打磨掉额外配置复杂度。很少使用的 malloc hooks API 要求 TCMalloc fast path 精心组织,以便不使用 hooks 的多数用户不为可能存在的 hooks 付费。另一个例子中,移除 sbrk 分配器让 TCMalloc 可以仔细组织虚拟地址空间,从而启用若干增强。

超越旋钮

虽然上面的讨论主要聚焦 knobs 和 tunables,API 与库也面临同样挑战。

已有库 X 可能不足或表达力不够,于是我们想沿某些维度构建更好的替代 Y。使用 Y 的收益依赖用户发现 Y,并正确地在 X 和 Y 之间选择;在长寿命代码库中,还要随着时间保持选择最优。

某些用途下,这种策略不可行。my::super_fast_string 可能永远无法替代 std::string,因为后者太根深蒂固,而独立字符串生态带来的阻抗失配超过收益。多个词汇类型都会受阻抗失配影响,昂贵互转可能吞掉整体收益。迁移世界的成本也必须预先考虑。没有主动迁移,我们最终会得到两套东西。

有时确实需要新库或 API。SwissMap 需要按实例打破 std::unordered_map 提供的稳定性保证,避免等待每个有问题用法都先被修复。不过,它提供的性能收益只有通过主动迁移才能实现。能够以完整迁移为目标,也能降低维护和教育成本。

把性能论证简化为“直接用 SwissMap”,避免了每个使用点都做精细 benchmark,而这些地方的最优选择还可能过时。

最佳实践

新增定制点时,要考虑它长期如何演进。

  • 用 flag gate 最终会默认启用的新功能时,要计划移除 opt out,让 flag 本身最终消失,而不是成为技术债。
  • flag 是调优和优化的强大工具,但定制点作者最了解如何有效使用它。选择好的默认值,或让特性自调优,通常对整个代码库更好。
  • 可发现性已经很难,更不用说始终做出最优选择。

上一篇:每周性能技巧 #64:用更好的 API 设计延续摩尔定律

上一节

下一篇:每周性能技巧 #7:面向应用生产效率进行优化

下一节