Ohhnews

分类导航

$ cd ..
foojay原文

MongoDB Java 驱动程序优化:微小改动如何带来巨大性能提升

#mongodb#java#性能优化#驱动程序#jvm

目录

Donald Knuth 被广泛誉为“算法分析之父”,他曾警告不要过早优化——即把精力花费在看似低效但并不在关键路径上的代码上。他观察到,程序员往往关注代码库中错误的 97%。真正的性能提升来自于识别并优化那关键的 3%。但是,如何识别这关键的 3% 呢?这就引出了“永远不要猜测,始终要测量”的哲学。

在这篇博客中,我们分享了 Java 开发者体验团队如何通过严格遵循这一原则来优化 MongoDB Java 驱动程序。我们发现,性能问题很少出现在我们认为的地方。本文解释了我们如何在特定工作负载下实现 20% 到超过 90% 的吞吐量提升。我们将涵盖具体的技术,包括使用 SWAR(寄存器内 SIMD)进行空终止符检测、缓存 BSON 数组索引以及消除冗余的不变量检查。

这些是我们将微优化转化为巨大收益的经验教训。我们的发现可能会让你感到惊讶——它们肯定也让我们感到惊讶——因此我们鼓励你读到最后。

获取正确的指标

开发团队通常认为他们知道瓶颈在哪里,但直觉往往不可靠。在这次实践中,MongoDB Java 团队发现性能问题通常不在团队的预期之处。

Donald Knuth 在他关于过早优化的论文中强调了这一概念:

程序员在思考程序非关键部分的速度上浪费了大量的时间,而这些提高效率的尝试对调试和维护产生了强烈的负面影响。我们应该忘记微小的效率,大约 97% 的情况下:过早优化是万恶之源。然而,我们不应放弃那关键 3% 的优化机会。

为了避免“过早优化”——即改进看起来很慢但不在关键路径上的代码——我们遵循一条严格的规则:永远不要猜测,始终要测量

我们应用帕累托法则(也称为 80/20 法则)来定位负责大部分执行时间的特定代码路径。为了进行此分析,我们使用了 async-profiler。其低开销、基于采样的方法使我们能够以可忽略的性能影响捕获可操作的 CPU 和内存配置文件。

我们如何衡量性能

我们基于吞吐量(MB/s)标准化了性能测试,简化了所有场景下的比较。我们的方法侧重于最小化外部变量的影响并确保实际相关性。

测试方法:

  • 测试具有代表性的工作负载: 测试侧重于通过 MongoClient API 执行的具有代表性的驱动程序操作(例如,小型、大型和批量文档插入),而不是孤立的方法微基准测试。
  • 隔离测试环境: 我们在多个隔离的云机器上进行了性能测试,以尽量减少外部变化并防止资源争用(即“嘈杂邻居”)导致的性能下降。每台机器上多次运行每个测试,并将中位数吞吐量分数作为该机器的最终结果。
  • 统计验证: 接下来,我们汇总了中位数结果,并直接比较优化分支的平均吞吐量与稳定主线的平均吞吐量。
    • 我们通过百分比提升和 z-score 分析验证了统计显著性。

通过这次练习,我们意识到真正重要的是性能提升必须出现在 MongoClient API 层面。内部的微基准测试可能显示出显著收益,但由于用户仅通过 API 进行交互,任何未在 API 层面体现的收益都不会转化为应用程序性能的显著改善。

有关这些测试的更详细描述,请参阅性能基准驱动程序规范

下面,我们将解释用于优化 MongoDB Java 驱动程序的六种技术,同时坚持我们的指导原则:“永远不要猜测,始终要测量”。

1. 缓存 BSON 数组索引

BSON 中,数组索引不仅仅是整数;它们被编码为以 null 结尾的字符串,即 CStrings。例如,索引 0 变为 '0' (U+0030) 的 UTF-8 字节序列,后跟 NULL (U+0000) 的 UTF-8 字节序列。编码数组涉及将每个数字索引转换为字符串,将其编码为 UTF-8 字节,然后附加一个空终止符:

$ java
for (int i = 0; i < arraySize; i++) {
    encodeCString(Integer.toString(i));;
   }

为每个索引调用 toString() 并对结果进行编码显然不是最优的,因为它对相同的索引一遍又一遍地重复相同的转换工作:每次调用都会重建 i 的字符串表示,然后将该字符串转换为 byte[]。这涉及不必要的拷贝,尽管结果保持不变。

我们的第一个改进是预先计算并缓存这些不可变的字节数组,以便在紧密循环中重用。

$ java
private static final byte[][] PRE_ENCODED_INDICES = new byte[1000][];

for (int i = 0; i < 1000; i++) {
    PRE_ENCODED_INDICES[i] = (Integer.toString(i) + '\u0000').getBytes("UTF-8");
}
$ java
for (int i = 0; i < arraySize; i++) {
    if (i < PRE_ENCODED_INDICES.length) {
        buffer.put(PRE_ENCODED_INDICES[i]);
    } else {
        encodeCString(Integer.toString(i));
    }
}

这个缓存步骤已经非常有效。我们还将缓存布局从锯齿状 byte[][](即许多小的 byte[] 对象)更改为扁平化 byte[] 表示。

$ java
private static final byte[] PRE_ENCODED_INDICES;

扁平化 byte[] 布局仍然是首选,因为它使用的堆对象更少,并且由于空间局部性,随着缓存的增长扩展效率更高。我们的基准测试显示,与较小的缓存中的锯齿状 byte[][] 结构相比,吞吐量没有显著差异;这种平等源于 HotSpot 的线程本地分配缓冲区 (TLAB)分配器,它将小的行在内存中放置得很近。即使使用强制频繁提升到老年代的垃圾回收 (GC) 设置,我们也经常观察到相同的效果。因为这种行为是特定于 JVM 和 GC 的而不是有保证的,所以我们使用扁平数组作为更稳健的解决方案。

为了量化缓存本身的影响,我们针对包含大量数组的文档调整了性能规范中的“Small Doc insertOne”工作负载。每个文档现在包含 A 个长度为 B 的数组(即 100×100 和 100×1000),因此每个文档编码的数组索引总数为 A × B。我们每个文档使用的数组越大,差异就越显著,因为“insertOne”操作中的数组编码部分对于较大的数组来说更大。

图 1. 下图显示了 100x100 和 100x1000 数组文档的性能测试前后结果。较大的数组看到了最大的性能改进。 [LOADING...]

2. Java 虚拟机 (JVM) 内建函数

作为 Java 开发人员,我们编写许多抽象的代码,这使得代码更易于维护和理解;然而,这些抽象也可能导致严重的性能问题。人类容易阅读的内容并不总是机器喜欢执行的内容,这就是为什么拥有机制同理心可能是有益的。

例如,在 BSON 中,数字必须使用小端序编码。在我们的原始代码中,将 int 编码为 BSON 时,我们将每个字节单独写入 ByteBuffer,进行手动移位和屏蔽以产生小端字节序。

$ java
write(value >> 0);
write(value >> 8);
write(value >> 16);
write(value >> 24);

然而,这种方法效率不高。它需要对每个写入的字节进行单独的边界检查和手动字节混洗,这在配置文件中显示为一个热点。我们选择采用 ByteBuffer 的方法——例如 putIntputLongputDouble。这种方法将四个单独的字节操作折叠成一个自动处理字节顺序的调用(putInt)。

在底层,现代 JIT(例如 HotSpot 中的)可以使用内建函数编译这些方法——例如 Unsafe.putIntInteger.reverseBytes——通常将它们映射到高效的机器指令。有关更多背景信息,请参阅内建函数

JVM 可以用非常少的机器指令序列替换这些辅助方法,有时甚至是一条指令。例如,在 x86 上,Integer.reverseBytes(int i) 方法可能使用 BSWAP 指令;在 ARM 上,JVM 可能使用 REV 指令。

| **额外提示:**热路径中重复的不变量检查在计算上是昂贵的。在 BsonOutput 序列化器的原始代码中,每次单字节写入也会重新检查不变量,例如“这个 BsonOutput 是否仍然打开?”。如果你已经在其他地方验证了不变量,你可以安全地跳过循环内的重复不变量检查。 |

实施我们的更改并测试后,我们意识到一个影响代码库不到 0.03% 的简单更改导致大型文档批量插入的吞吐量提高了近 39%

图 2. 下图显示了每个插入命令的吞吐量提升。“Large Doc Bulk Insert” 获得了最显著的收益,因为处理较大的负载最大化了优化最热执行路径的影响。 [LOADING...]

3. 反复检查

如前所述,关键路径中 ByteBuffer 的大小检查是昂贵的。然而,我们在许多其他地方也执行了类似的不变量检查。**。**编码 BSON 时,我们在每次写入时通过索引从列表中获取当前缓冲区:

$ java
ByteBuffer currentBuffer = bufferList.get(currentBufferIndex);

//其他代码
currentBuffer.put(value);

这个 get() 调用很快,但执行多次就会累积——特别是因为每次调用都包括范围检查和方法间接调用(因为路径太深;JVM 可能并不总是内联它)。

| 顿悟时刻: 如果同一个缓冲区在切换之前要用于数千次操作,为什么要一直重新检查?通过在字段中缓存缓冲区并仅在切换缓冲区时更新它,我们消除了至少三个冗余的范围检查。方法如下: private ByteBuffer currentByteBuffer; // 仅在更改缓冲区时更新 currentByteBuffer.put(value); |

这个微小的更改导致批量插入的吞吐量增加了 16%。这不是唯一可以消除冗余检查的领域;当我们测试从其他抽象中删除类似的不变量检查时,我们观察到批量插入的吞吐量额外提高了 15%

| **教训:**从最热的路径中删除不必要的检查。因为这些检查运行得如此频繁,它们很快成为拖累性能的瓶颈。 |

4. 使用 SWAR 检测 BSON 空终止符

每个 BSON 文档的结构都是三元组列表:一个类型字节、一个字段名和一个值。关键是,每个字段名都是一个以 null 结尾的字符串——CString——而不是长度前缀字符串。虽然这种设计每个字段节省了四个字节,但它引入了性能权衡:提取 CString 现在需要线性扫描而不是常数时间查找。

我们的原始实现逐字节处理缓冲区,搜索终止零:

$ java
boolean checkNext = true;

while (checkNext) {
    if (!buffer.hasRemaining()) {
        throw new BsonSerializationException("Found a BSON string that is not null-terminated");
    }
    checkNext = buffer.get() != 0;
}

这种方法的主要问题是,对于相同的工作量,它需要更多的比较。对于大型文档,该过程调用 buffer.get() 数十亿次。单独处理每个字节每次都需要加载、检查和条件跳转,这迅速增加了总指令数。

为了提高性能,我们使用了一种经典的优化技术:SWAR(寄存器内 SIMD 向量化)。SWAR 允许我们通过单次 64 位加载和一些按位运算同时检查八个字节,而不是分别检查每个字节。这看起来像这样:

$ java
long chunk = buffer.getLong(pos);
long mask = chunk - 0x0101010101010101L;
mask &= ~chunk;
mask &= 0x8080808080808080L;
if (mask != 0) {
    int offset = Long.numberOfTrailingZeros(mask) >>> 3;
    return (pos - prevPos) + offset + 1;
}

这些“魔法数字”并非任意:0x0101010101010101L 重复字节 1,而 0x8080808080808080L 重复字节 128。通过从每个字节减去 1,与反转值进行 AND 运算,并应用高位掩码,你可以立即检测是否存在零。然后,简单地计算尾随零就可以计算出精确的字节偏移量。此方法与 CPU 流水线配合非常有效。

让我们举一个简单的例子。假设为了简单起见我们使用一个 int(4 字节)。值:0x7a000aFF 包含一个零字节。我们将演示 SWAR 技术如何检测它:

Step                  | Value (Hex)   | Value (Binary, per byte)
------------------------|---------------|-----------------------------
chunk                   | 0x7a000aFF    | 01111010 00000000 00001010 11111111
ones                    | 0x01010101    | 00000001 00000001 00000001 00000001
mask (high bit mask)    | 0x80808080    | 10000000 10000000 10000000 10000000

Subtraction:
chunk      = 01111010 00000000 00001010 11111111
- ones     = 00000001 00000001 00000001 00000001
------------------------------------------------
           01111000 11111111 00001001 11111110
                       underflow
                       (0-1=FF)

Bitwise AND with inverted chunk:
prevResult = 01111001 11111111 00001001 11111110
& ~chunk   = 10000101 11111111 11110101 00000000
------------------------------------------------
           00000001 11111111 00000001 00000000

Bitwise AND with mask (high bits):
prevResult = 00000001 11111111 00000001 00000000
& mask     = 10000000 10000000 10000000 10000000
------------------------------------------------
            00000000 10000000 00000000 00000000

最终结果:

  • 结果在字节 2 中设置了高位 (10000000),表明该位置有一个零字节。

  • 隔离一个字节后,我们可以使用 Integer.numberOfTrailingZeros(mask) >>> 3 来获取零字节的偏移量。此方法通常是内建函数内置在 JVM 中,产生高效的单指令。

因为循环体现在由一小部分可预测的算术指令组成,所以它与现代 CPU 流水线无缝集成。SWAR 的效率源于其减少的指令计数、没有逐字节分支以及每八个字节一次内存加载。## 5. 避免冗余拷贝和分配

在使用 SWAR 优化 CString 检测时,我们也发现了一个减少字符串解码路径上分配和拷贝的机会。

早期版本的驱动程序将底层的 ByteBuffer 包装在只读视图中,以防止意外写入,但这种选择迫使每次 CString 解码都要执行两次拷贝:

  1. 从缓冲区拷贝到临时的 'byte[]'。
  2. 从该 'byte[]' 拷贝到支持 'String' 的内部 'byte[]'。

通过验证缓冲区内容在解码期间保持不可变,我们能够安全地移除限制性的只读包装器。这允许我们直接访问底层数组并在没有中间拷贝的情况下解码字符串。

$ java
if (buffer.isBackedByArray()) {
    int position = buffer.position();
    int arrayOffset = buffer.arrayOffset();
    return new String(array, arrayOffset + position, bsonStringSize - 1, StandardCharsets.UTF_8);
}

对于直接缓冲区(非 Java 堆数组支持),我们无法将支持数组传递给 String 构造函数。我们仍然需要从缓冲区拷贝字节,但可以避免为每个字符串分配新的 byte[]

为实现这一点,解码器维护一个可复用的 byte[] 缓冲区。首次调用时分配它(如果遇到更大的字符串则增长),随后的调用复用同一内存区域。这有两个好处:

  • 更少的分配、更低的 GC 压力和内存归零: 我们不再为每个 CString 创建新的临时 byte[],这减少了分配器和垃圾回收器在每个文档上必须做的工作量。
  • 更好的缓存行为: JVM 反复读写同一小块内存,这倾向于在 CPU 缓存中保持热度。我们使用 async-profiler 的 cache-misses 事件检查了 "FindMany and empty cursor" 工作负载上的 CPU 缓存行为。Async-profiler 对 CPU 的 性能监控单元 (PMU) 暴露的硬件性能计数器进行采样,PMU 是跟踪缓存未命中、分支未命中和周期等事件的硬件块。对于 readString(),旧实现和新实现之间,缓存未命中的采样下降了大约 13--28%,因为我们每个 CString 接触的缓存行更少了。我们仍然将 PMU 数据视为方向性而非决定性的 —— 计数器和采样语义因 CPU 和内核而异 —— 因此主要信号仍然是用户实际观察到的端到端吞吐量收益 (MB/s)。

在我们的 "FindMany and empty cursor" 工作负载中,消除 readString 中冗余的中间拷贝将吞吐量提高了约 22.5%。在内部数组不可用的情况下,引入可复用缓冲区带来了约 5% 的改进。

6. 字符串编码,移除方法间接调用和冗余检查

在对代码进行基准测试时,我们观察到将字符串编码为 BSON 使用的 UTF-8 格式消耗了大量时间。BSON 文档包含许多字符串,包括作为 CStrings 的属性名称和具有不同长度和 Unicode 码点的各种字符串值。将字符串编码为 UTF-8 的过程被识别为热路径,促使我们对其进行调查并提出潜在的改进。我们的初始实现使用自定义 UTF-8 编码,以避免使用标准 JDK 库创建额外的数组。

但在循环内部,每个字符都涉及几个内部检查:

  • 验证 ByteBuffer 容量
  • 针对不同 Unicode 范围的分支
  • 重复调用抽象和方法

如果缓冲区已满,我们会从缓冲区池中获取另一个(我们池化 ByteBuffers 以减少垃圾回收 (GC) 压力:

$ java
for (int i = 0; i < len;) {
    int c = Character.codePointAt(str, i);
    if (checkForNullCharacters && c == 0x0) {
        throw new BsonSerializationException(...);
    }

    if (c < 0x80) {
        //check if ByteBuffer has capacity and write one byte
    } else if (c < 0x800) {
        //check if ByteBuffer has capacity and write two bytes
    } else if (c < 0x10000) {
       //check if ByteBuffer has capacity and write three bytes
       total += 3;
    } else 
      //check if ByteBuffer has capacity and write four bytes
  }
  i += Character.charCount(c);
}

实际上,现代 JVM 可以展开紧凑的计数循环,在适当条件下减少回退分支并增强指令流水线效率。然而,在检查 JIT 为该方法生成的汇编代码时,我们观察到循环展开在此实例中并发生。这强调了保持热路径尽可能笔直的重要性,尽量减少分支、检查和方法间接调用,尤其是对于大型工作负载。

我们的第一个优化基于这样一个假设:大多数工作负载主要使用 ASCII 字符。基于这个假设,我们开发了一个对 JIT 更友好的新循环。

$ java
for (; sp < str.length(); sp++, pos++) {
    char c = str.charAt(sp);
    if (checkNullTermination && c == 0) {
        //throw exception
    }

    if (c >= 0x80) {
        break;
    }
    dst[pos] = (byte) c;
}

在进入循环之前,我们验证了 String.length() < ByteBuffer 的容量,并从 ByteBuffer 获取底层数组(我们的 ByteBuffer 是 JDK 或 Netty 缓冲区的包装器)。

通过预先验证这个不变量,我们消除了在循环内部重复进行容量检查或方法间接调用的需要。相反,我们直接使用 ByteBuffer 的内部数组。

我们还添加了一个保护措施:如果要编码的字符大于 0x80,我们遇到了非 ASCII 字符,必须回退到带有额外分支的较慢、更通用的循环。

通过这种设置,JIT 通常会展开循环体,将其替换为几个连续的副本。这种修改减少了回退分支的数量并提高了流水线性能效率。如果我们放大查看编译后的汇编,可以看到 C2 已经将循环展开了 4 倍。热路径不再每次迭代处理一个字符,而是处理四个连续的字符 (sp, sp+1, sp+2, sp+3),然后 sp 增加 4。例如,AArch64 上的 HotSpot C2 可能会生成以下汇编代码,为了简洁起见,删除了一些簿记工作,只显示了 4 个展开副本中的 2 个:

循环体:

; ----- char 0: value[sp], dst[pos] -----

    ldrsb   w5,  [x12,#16]          ; load value[sp]

    and     w11, w5, #0xff          ; w11 = (unsigned) c0

    cbz     w11, null_trap          ; if (c0 == 0) -> slow path (null terminator check)

    cmp     w11, #0x80              ; if (c0 >= 0x80) -> slow path (non-ASCII)

    b.ge    non_ascii1_path

    strb    w5,  [x10,#16]          ; dst[pos] = (byte)c0

    ; ----- char 1: value[sp+1], dst[pos+1] -----

    ldrsb   w4,  [x12,#17]          ; load value[sp+1]

    and     w11, w4, #0xff          ; w11 = (unsigned) c1

    cbz     w11, null_trap          ; if (c1 == 0) -> slow path (null terminator check)

    cmp     w11, #0x80              ; if (c1 >= 0x80) -> slow path (non-ASCII)

    b.ge    non_ascii1_path

    strb    w4,  [x10,#17]          ; dst[pos+1] = (byte)c1

我们所做的改进:

  • 移除了引入额外边界检查的内部方法间接调用(如 write() 包装器)。
  • 在写入 ASCII 时,如果容量允许,我们直接写入底层的 ByteBuffer 数组,跳过额外的边界和范围检查。
  • 对于多字节码点,我们通过在热路径中缓存 position 和 limit 值,避免了对 ensureOpen()、hasRemaining() 和相关检查的重复调用。

此优化提高了所有相关基准测试的插入吞吐量。例如:

  • UTF-8 多字节字符的批量写入插入吞吐量提高了近 31%
  • ASCII 的批量写入插入吞吐量提高了近 33%

您可以在 性能基准测试规范 中查看具体的测试条件。

经验教训

  • 在热路径上缓存不可变数据: 在我们的例子中,将 BSON 索引 CStrings 预编码一次为紧凑的扁平 byte[],消除了重复的 int 到 byte[] 的转换,并减少了来自数千个微小的 byte[] 对象的堆开销。
  • JVM 会给你带来惊喜: 尽可能使用内建函数和硬件特性。在实施更改并测试后,我们发现一个影响代码库不到 0.03% 的简单修改,将大文档批量插入的吞吐量提高了近 39%。
  • 彻底分析你的代码: 在重要的地方进行优化。热路径中微小、聪明的改变可能比重写冷代码带来更多好处。通过在字段中缓存缓冲区并仅在切换到新缓冲区时更新它,我们将批量插入吞吐量提高了 16%。
  • 即使是廉价的检查也会积少成多: 热路径中的边界检查和分支可能会出奇地昂贵 —— 将几个周期乘以数十亿次,你会注意到真正的影响。尽可能将检查移到内部循环之外,不要重复已经验证过的不变量。
  • 向量化 (SIMD): 使用向量化方法(例如 SWAR)重写关键代码路径,可以通过每条指令同时处理多个数据元素来显著提高吞吐量。
  • 移除方法间接调用和冗余检查: 优化低级操作需要移除 write(...)/put(...) 包装器,以消除额外的方法间接调用层和重复的不变量检查。通过直接写入底层的 ByteBuffer 数组(针对 ASCII)并在热路径中缓存位置值(针对多字节码点),我们绕过了重复的验证调用,如 ensureOpen() 和 hasRemaining()。这使得 ASCII 的批量写入插入吞吐量提高了 33%。

图 3. 下图显示了根据 性能基准测试规范,如上所述优化驱动程序后的吞吐量改进(以 MB/s 为单位)的最终结果。“Large doc Collection BulkWrite insert” 获得了最高的性能提升 +96.46%。

[LOADING...]

查看我们的 开发者博客 以了解我们如何解决不同的工程问题,或者考虑加入我们的 工程团队

本文 Optimizing the MongoDB Java Driver: How minor optimizations led to macro gains 首次出现在 foojay 上。