Performance Optimization Methodology for Valkey - Part 1

Valkey 性能优化方法论 - 第 1 部分

2025-05-27 · 朱立鹏, 郭望洋

性能优化是一个多方面的领域,尤其对于 Valkey 这样的高性能系统而言。虽然整体系统性能取决于众多因素,包括硬件规格、操作系统配置、网络条件和部署架构,但我们的工作重点是优化 Valkey 在 CPU 级别的性能。

引言

在探讨软件 CPU 优化方法时,通常会认识到两种基本策略

策略 1:最大化并行度

该策略涉及重新设计软件架构,以充分利用多个 CPU 核心。通过在可用计算资源之间有效分配工作负载,应用程序可以显著提高吞吐量——随着处理器核心数量持续增加,这是一个关键优势。

Valkey 中的 I/O 线程模型就是这种方法的例证。正如 Valkey 博客文章“解锁百万 RPS:体验 Valkey 三倍速度”中描述的那样,这种架构使 Valkey 能够将操作卸载到专用线程,从而更好地利用可用的 CPU 核心。该模型不是在单个线程中处理所有操作,而是智能地将任务委托给多个线程,从而减少瓶颈并提高吞吐量。这种增强功能已展示出令人印象深刻的可扩展性,实现了随着核心增加近乎线性的性能扩展。

策略 2:提升 CPU 效率

该策略侧重于通过两种互补的方法在有限的 CPU 资源内最大化性能

  1. 减少指令数量:消除冗余代码和不必要的操作,以减少 CPU 必须执行的总工作量。

  2. 提高 IPC:通过解决微架构瓶颈,如缓存未命中、分支预测错误和内存访问模式,优化处理器执行指令的效率。

通过我们的分析和优化工作,我们确定了几种能显著提高性能的关键方法:消除冗余代码、减少锁竞争和解决伪共享。我们还探索了其他技术,包括异步处理、批量操作以及利用特定于 CPU 的指令,例如 SIMD 向量化。

并行性使我们能够“更有效地利用更多资源”,而效率优化则使我们能够“用相同的资源做更多事情”。这两种方法在全面的优化策略中都至关重要,特别是对于 Valkey 这样的关键任务系统。

本文主要关注第二种策略,通过我们在 Valkey 代码库中的贡献的具体示例,探讨提高 CPU 效率的系统方法。

基准测试:可靠性能优化的基础

可靠的性能优化需要一致且可重现的测量,以评估代码更改是否真正提高了性能。如果在受控环境中没有系统化的基准测试,就不可能准确量化改进或确定优化是否在其他方面引入了性能下降。

为了隔离 CPU 性能因素并消除可能影响测量的变量,我们实施了以下限制

  1. 裸机服务器而非虚拟机,以消除虚拟机管理程序开销和资源争用,
  2. 使用 taskset 进行进程绑定,将 Valkey 绑定到特定核心,防止线程迁移开销,
  3. 客户端-服务器通信使用本地网络接口(回环),以最大程度地减少网络可变性,
  4. 高 CPU 利用率基准参数,以确保我们测量的是真正的 CPU 性能限制。

这种受控环境使我们能够准确地将改进归因于特定的代码优化,从而提供吞吐量和延迟改进的可靠测量。对于每一次优化尝试,我们都会建立基线性能指标,独立实施更改,然后重新测量以量化影响。这种严谨的方法确保了我们的优化能够带来真实的收益,而不是在生产环境中可能消失的虚假改进。

优化方法

在接下来的章节中,我们将分享应用于 Valkey 的优化技术的实际示例。这些见解代表了我们微薄的贡献,可能对从事类似性能挑战的其他人有所帮助。

1. 消除冗余代码

通过删除冗余操作来简化执行路径是一种直接的优化方法,尽管识别真正不必要的代码需要仔细分析。

如何识别冗余代码

发现冗余代码的关键在于拥有

  1. 代表性工作负载:反映实际使用模式的测试工作负载,
  2. 合适的分析工具:如 perfIntel® VTune™ Profiler 等工具,用于识别热代码路径,
  3. 系统化代码审查:人工检查热路径以发现自动化工具可能遗漏的冗余,
  4. 基于跟踪的分析:突出显示重复操作的执行跟踪。

实际案例

通过详细的 CPU 周期热点分析,我们识别出 Valkey 准备客户端连接进行写入操作时的冗余逻辑。通过分析高吞吐量基准测试期间的执行模式,我们发现了在关键路径中消除不必要函数调用的机会。

示例 1:优化客户端写入准备

PR #670 中,我们发现在连续执行多个 addReply 操作时,存在对 prepareClientToWrite() 的冗余调用。通过重构代码,仅在必要时才调用此函数,我们消除了热路径中的冗余操作。

示例 2:提高列表命令效率

类似地,在 PR #860 中,我们将 prepareClientToWrite() 调用移出了 lrange 命令中的循环,从而避免了对每个列表元素重复调用该函数。

这些相对简单的代码更改带来了可衡量的性能提升,因为它们影响了在正常操作期间执行极其频繁的代码路径。

2. 减少锁竞争

讨论锁开销时,我们考虑两个方面

  1. 受保护代码范围:临界区内部操作的成本,
  2. 锁实现开销:同步机制本身的成本。

由于 Valkey 的单主线程设计,不存在许多复杂的互斥锁保护的临界区。因此,我们关注同步原语本身的开销,当受保护的工作量很小时,这种开销变得非常显著。

在 Valkey 中,原子操作用于更新全局变量和共享数据。虽然原子操作比互斥锁快,但与非原子操作相比,它们仍然引入了相当大的开销——特别是在这些操作每秒发生数百万次的高吞吐量场景中。

实际案例

用于内存跟踪的线程局部存储

PR #674 中,我们引入了线程局部存储变量来优化内存跟踪。以前,Valkey 使用原子操作来更新跟踪所有线程内存分配的全局 used_memory 变量。

我们的分析发现,对该变量的大多数操作都是在同一线程内发生的写入,系统仅偶尔读取所有线程的总内存使用量。

通过为每个线程实现线程局部变量来跟踪其自身的内存使用情况,我们消除了频繁写入时的原子操作。现在,每个线程都使用常规(非原子)操作更新其本地计数器,而全局值仅在需要时通过汇总线程局部值来计算。

这种优化模式在以下情况下特别有效

  1. 大多数操作发生在单个线程内,
  2. 值写入频繁但读取不频繁,
  3. 与受保护的工作相比,同步开销显著。

3. 消除伪共享

伪共享发生在不同线程访问位于同一 CPU 缓存行(通常为 64 字节)内的不同变量时。即使线程操作的是不同的变量,硬件仍将这些访问视为冲突,因为它们共享同一个缓存行。

当一个线程修改其变量时,整个缓存行对所有其他核心都变得无效,迫使它们重新加载该缓存行,即使它们自己的变量并未改变。这会产生不必要的缓存一致性流量,并可能显著降低性能。

识别伪共享

伪共享很难检测,因为它不会导致功能性问题。迹象包括

  1. 无法解释的性能扩展问题:尽管线程独立工作,但扩展性不佳,
  2. 高缓存一致性流量:监控显示高频率的缓存行失效,
  3. 线程依赖的性能变化:在不同线程数量下出现异常的性能模式。

有助于识别伪共享的工具包括

  • perf c2c - 一个专门用于检测缓存行争用的 Linux 性能工具,
  • 具备内存访问分析功能的 Intel® VTune™ Profiler
  • 跟踪缓存一致性事件的性能计数器监控工具。

这些工具已将伪共享从一个难以诊断的问题,转变为一个可以高效定位和解决的问题。

缓解策略

在解决伪共享时,有几种可用方法

  1. 数据结构填充:在不同线程访问的变量之间添加填充,
  2. 缓存行对齐:将线程特有数据对齐到缓存行边界,
  3. 线程局部存储:使用线程局部变量而不是共享数组,
  4. 数据结构重新设计:重新组织数据结构以将线程特有数据分组。

实际案例

策略性伪共享缓解

PR #1179 中,我们遇到了主线程和 I/O 线程使用的内存跟踪计数器中的伪共享问题。

我们没有完全消除所有伪共享(这在某些情况下会导致性能下降),而是实施了一个细致的解决方案

Diagram of used memory thread layout showing false sharing mitigation

“我们之所以没有采用结构体填充的方式,是因为会导致性能下降。因为在调用像 zmalloc_used_memory() 这样的函数(主要在主线程中调用)时,主线程将花费更多的周期来获取更多的缓存行(3 对 16 个缓存行)。根据我们的基准测试,这将导致大约 3% 的性能下降,具体取决于特定的基准测试。”

我们的解决方案侧重于消除主线程和 I/O 线程之间的伪共享,同时允许其保留在 I/O 线程之间

“我们做了一些权衡,只修复了主线程和 I/O 线程之间的伪共享,同时保留了 I/O 线程中用于内存聚合的伪共享,因为主线程的资源是瓶颈,而 I/O 线程的资源可以扩展。”

这个例子表明,性能优化并非总是寻找理论上完美的解决方案,而是在实际约束和优先级的基础上进行明智的权衡。通过专注于系统的实际瓶颈(主线程),我们尽管保留了一些伪共享,但仍实现了更好的整体性能。

结论

本文介绍的方法论表明,有效的性能优化需要系统分析和周密实施。通过专注于消除冗余、减少同步开销和解决伪共享,我们显著提升了 Valkey 的性能。

这些优化表明,即使在成熟、精心设计的系统中,只要有细致的测量和分析指导,仍存在性能提升的机会。关键的经验是,理解硬件特性和系统瓶颈能够实现有针对性的优化,即使实现上的改动相对较小,也能产生显著影响。