第1697篇:AI应用的性能基准测试——JMH在推理吞吐量测量中的应用
第1697篇:AI应用的性能基准测试——JMH在推理吞吐量测量中的应用
"这个接口响应很快" "我测了挺OK的" "在我机器上没问题"
这三句话是我做性能测试最怕听到的。
不是说没有性能问题,而是说这些测量没有价值。在本地用Postman发一两个请求,不算性能测试。线上跑一段时间没崩,不算性能基准。
真正的性能基准需要回答具体的问题:这个Embedding计算方法,在64个并发下,P99延迟是多少?换一种向量相似度算法,吞吐量会提升还是下降?增加了缓存层之后,整体延迟分布发生了什么变化?
JMH(Java Microbenchmark Harness)是Java生态里做微基准测试最权威的工具,由JVM工程师开发,能正确处理JIT编译、GC、预热等干扰因素,给出可信赖的测量结果。
今天这篇,我来讲JMH在AI服务性能测试中的具体应用。
为什么AI场景的性能测试特别难
先说难点,再讲解法。
难点一:JIT预热影响
Java应用刚启动时,代码还没被JIT编译,执行速度比稳定状态慢几倍到几十倍。如果你在应用刚启动后就测性能,结果是没意义的。
AI服务有额外的预热:大模型客户端初始化、向量索引加载进内存、连接池建立,这些在第一次调用时会有额外耗时。
难点二:I/O延迟的统计陷阱
大模型API的响应时间受很多外部因素影响:网络状况、API服务器负载、并发限速等。同样的代码,不同时间测出来的结果可能差很多。
难点三:长尾延迟的重要性
AI应用的用户体验取决于P99或P999延迟,而不是平均值。平均延迟2秒看起来不错,但如果P99是20秒,1%的用户体验极差,这是不可接受的。
难点四:资源竞争
高并发测试时,多个测试请求会竞争同一个连接池、同一个GPU(如果本地推理),测出来的是资源竞争下的结果,不一定代表单请求的真实性能。
JMH基础:正确的测量方式
先搭建JMH环境:
<!-- pom.xml -->
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
<version>1.37</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>1.37</version>
<scope>test</scope>
</dependency>最简单的JMH测试结构:
@BenchmarkMode(Mode.AverageTime) // 测量平均执行时间
@OutputTimeUnit(TimeUnit.MILLISECONDS) // 输出单位:毫秒
@State(Scope.Thread) // 每个线程独立的状态
@Warmup(iterations = 3, time = 5) // 预热3轮,每轮5秒
@Measurement(iterations = 5, time = 10) // 正式测量5轮,每轮10秒
@Fork(2) // Fork 2个JVM进程测试(隔离JVM状态)
public class EmbeddingBenchmark {
private EmbeddingService embeddingService;
private String testText;
@Setup(Level.Trial) // 每次完整测试前执行一次
public void setup() {
embeddingService = new EmbeddingService();
testText = "这是一段用于测试Embedding计算性能的文本," +
"包含一定长度以模拟真实的RAG场景文档片段。";
}
@Benchmark
public float[] benchmarkEmbedding() {
// 用返回值防止JIT的死码消除
return embeddingService.embed(testText);
}
public static void main(String[] args) throws Exception {
Options options = new OptionsBuilder()
.include(EmbeddingBenchmark.class.getSimpleName())
.build();
new Runner(options).run();
}
}JMH会处理JIT预热的问题——Warmup阶段让JIT有时间编译热路径,正式测量阶段的结果才是稳定状态下的性能数据。
测量模式的选择
JMH有几种测量模式,适用于不同的性能问题:
// AverageTime:每次操作的平均时间(常用)
@BenchmarkMode(Mode.AverageTime)
// Throughput:每秒能做多少次操作(吞吐量测试)
@BenchmarkMode(Mode.Throughput)
// SampleTime:采样统计,能看到P50/P90/P99等百分位数(AI场景推荐)
@BenchmarkMode(Mode.SampleTime)
// All:同时测量所有模式(最全面,但输出较多)
@BenchmarkMode(Mode.All)对于AI应用,我最常用 Mode.SampleTime,因为它能给出延迟分布,P99延迟才是用户体验的关键指标。
实战:AI服务关键路径的基准测试
测试1:Embedding计算的性能基准
这是RAG系统里最频繁的操作,值得仔细测量。
@BenchmarkMode(Mode.SampleTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Benchmark)
@Warmup(iterations = 3, time = 5)
@Measurement(iterations = 5, time = 20) // 采样模式要更长的测量时间
@Fork(1)
public class EmbeddingPerformanceBenchmark {
private LocalEmbeddingModel localModel;
private APIEmbeddingModel apiModel;
private Cache<String, float[]> embeddingCache;
// 不同长度的测试文本
@Param({"50", "200", "500", "1000"}) // 文本长度(字符数)
private int textLength;
private String testText;
@Setup(Level.Trial)
public void setup() {
localModel = new LocalEmbeddingModel("text2vec-base-chinese");
apiModel = new APIEmbeddingModel(System.getenv("OPENAI_API_KEY"));
embeddingCache = Caffeine.newBuilder().maximumSize(1000).build();
// 生成指定长度的测试文本
testText = generateTestText(textLength);
}
@Benchmark
public float[] localEmbedding() {
return localModel.embed(testText);
}
@Benchmark
public float[] cachedEmbedding() {
return embeddingCache.get(testText, localModel::embed);
}
// 注意:API调用的基准测试要谨慎,会产生实际费用!
// 通常只在Mock场景下测试,而不是真实API
@Benchmark
public float[] mockApiEmbedding() {
// 使用Mock来测试API调用路径(不包括真实网络延迟)
return apiModel.embedWithMockedHttp(testText);
}
private String generateTestText(int length) {
String base = "这是一段用于测试性能的文本内容,包含中文字符和一些技术词汇。";
StringBuilder sb = new StringBuilder();
while (sb.length() < length) {
sb.append(base);
}
return sb.substring(0, length);
}
}测试2:向量相似度计算的算法对比
不同的相似度算法,在相同数据规模下性能差异很大:
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.SECONDS)
@State(Scope.Thread)
@Warmup(iterations = 5)
@Measurement(iterations = 10)
@Fork(2)
public class SimilaritySearchBenchmark {
// 测试不同规模的向量库
@Param({"100", "1000", "10000"})
private int vectorCount;
@Param({"384", "768", "1536"}) // 常见Embedding维度
private int dimension;
private float[][] vectorStore;
private float[] queryVector;
@Setup(Level.Trial)
public void setup() {
Random random = new Random(42); // 固定随机种子,保证可复现
vectorStore = new float[vectorCount][dimension];
for (int i = 0; i < vectorCount; i++) {
for (int j = 0; j < dimension; j++) {
vectorStore[i][j] = random.nextFloat();
}
}
queryVector = new float[dimension];
for (int i = 0; i < dimension; i++) {
queryVector[i] = random.nextFloat();
}
}
@Benchmark
public int cosineSimBruteForce() {
// 暴力计算余弦相似度,返回最相似的索引
float maxSim = Float.NEGATIVE_INFINITY;
int maxIdx = 0;
for (int i = 0; i < vectorCount; i++) {
float sim = cosineSimilarity(queryVector, vectorStore[i]);
if (sim > maxSim) {
maxSim = sim;
maxIdx = i;
}
}
return maxIdx;
}
@Benchmark
public int dotProductBruteForce() {
// 点积(对于归一化向量,等价于余弦相似度,但更快)
float maxDot = Float.NEGATIVE_INFINITY;
int maxIdx = 0;
for (int i = 0; i < vectorCount; i++) {
float dot = dotProduct(queryVector, vectorStore[i]);
if (dot > maxDot) {
maxDot = dot;
maxIdx = i;
}
}
return maxIdx;
}
@Benchmark
public int vectorApiSearch() {
// 使用Java 19+ Vector API(SIMD指令加速)
return vectorApiSimilaritySearch(queryVector, vectorStore);
}
private float cosineSimilarity(float[] a, float[] b) {
float dot = 0, normA = 0, normB = 0;
for (int i = 0; i < a.length; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
return dot / ((float) Math.sqrt(normA) * (float) Math.sqrt(normB));
}
private float dotProduct(float[] a, float[] b) {
float dot = 0;
for (int i = 0; i < a.length; i++) {
dot += a[i] * b[i];
}
return dot;
}
// Java Vector API版本(利用SIMD指令)
@SuppressWarnings("preview")
private int vectorApiSimilaritySearch(float[] query, float[][] store) {
var species = FloatVector.SPECIES_256;
float maxDot = Float.NEGATIVE_INFINITY;
int maxIdx = 0;
for (int i = 0; i < store.length; i++) {
float dot = 0;
int j = 0;
for (; j <= query.length - species.length(); j += species.length()) {
var va = FloatVector.fromArray(species, query, j);
var vb = FloatVector.fromArray(species, store[i], j);
dot += va.mul(vb).reduceLanes(VectorOperators.ADD);
}
// 处理剩余元素
for (; j < query.length; j++) {
dot += query[j] * store[i][j];
}
if (dot > maxDot) {
maxDot = dot;
maxIdx = i;
}
}
return maxIdx;
}
}测试3:Prompt构建的性能
这个测试经常被忽视,但在高并发下Prompt构建的性能真的有差异:
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.SECONDS)
@State(Scope.Thread)
@Warmup(iterations = 3)
@Measurement(iterations = 5)
@Fork(1)
public class PromptBuildingBenchmark {
private List<Message> history;
private List<String> contextDocs;
private String userInput;
@Setup(Level.Trial)
public void setup() {
history = generateHistory(10); // 10轮对话历史
contextDocs = generateDocs(5); // 5条RAG检索结果
userInput = "请解释一下RAG系统的工作原理";
}
@Benchmark
public String stringConcatenation() {
// 测试:字符串拼接
String result = "你是一个专业的AI助手。\n\n";
result += "参考资料:\n";
for (String doc : contextDocs) {
result += doc + "\n";
}
result += "\n对话历史:\n";
for (Message msg : history) {
result += msg.getRole() + ": " + msg.getContent() + "\n";
}
result += "用户: " + userInput;
return result;
}
@Benchmark
public String stringBuilder() {
// 测试:StringBuilder(预估容量)
int estimatedSize = 200 +
contextDocs.stream().mapToInt(String::length).sum() + contextDocs.size() +
history.stream().mapToInt(m -> m.getContent().length() + 20).sum() +
userInput.length();
StringBuilder sb = new StringBuilder(estimatedSize);
sb.append("你是一个专业的AI助手。\n\n");
sb.append("参考资料:\n");
for (String doc : contextDocs) {
sb.append(doc).append('\n');
}
sb.append("\n对话历史:\n");
for (Message msg : history) {
sb.append(msg.getRole()).append(": ").append(msg.getContent()).append('\n');
}
sb.append("用户: ").append(userInput);
return sb.toString();
}
@Benchmark
public String stringJoiner() {
// 测试:String.join / StringJoiner
String context = String.join("\n", contextDocs);
String historyStr = history.stream()
.map(m -> m.getRole() + ": " + m.getContent())
.collect(Collectors.joining("\n"));
return String.format("你是一个专业的AI助手。\n\n参考资料:\n%s\n\n对话历史:\n%s\n用户: %s",
context, historyStr, userInput);
}
@Benchmark
public String templateEngine() {
// 测试:使用Mustache模板引擎
return promptTemplate.render(Map.of(
"context", contextDocs,
"history", history,
"userInput", userInput
));
}
}读懂JMH输出
JMH的输出包含很多信息,需要知道怎么读:
Benchmark (textLength) Mode Cnt Score Error Units
EmbeddingBench.localModel 50 thrpt 10 1245.678 ± 23.456 ops/s
EmbeddingBench.localModel 200 thrpt 10 987.234 ± 18.123 ops/s
EmbeddingBench.localModel 500 thrpt 10 654.321 ± 12.789 ops/s
EmbeddingBench.cachedModel 50 thrpt 10 8934.567 ± 45.678 ops/s关键字段含义:
Score:测量结果(吞吐量ops/s,或延迟ms)Error:±表示95%置信区间,这个值越小说明测试越稳定Cnt:测量次数
如何判断两个测试结果是否有显著差异?
如果两个Score的误差区间有重叠,差异不显著,不能下结论说哪个更快。
例如:
- 方案A:1000 ± 50 ops/s
- 方案B:1030 ± 60 ops/s
这两个结果有重叠,不能说B比A快。
- 方案A:1000 ± 50 ops/s
- 方案B:1200 ± 60 ops/s
这两个结果没有重叠,B显著快于A。
并发测试:模拟真实AI服务的并发场景
单线程基准测试不够,AI服务需要在并发下的性能数据:
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.SECONDS)
@State(Scope.Benchmark) // Benchmark scope:所有线程共享同一个实例
@Warmup(iterations = 3, time = 10)
@Measurement(iterations = 5, time = 30)
@Fork(1)
@Threads(16) // 16个并发线程
public class ConcurrentAIServiceBenchmark {
private AIService aiService;
private List<String> testPrompts;
// 用来分配不同的prompt给不同线程
private static final AtomicInteger promptIndex = new AtomicInteger(0);
@Setup(Level.Trial)
public void setup() {
aiService = createAIServiceWithMockedLLM();
testPrompts = generateTestPrompts(100);
}
@Benchmark
public String concurrentChat() {
// 每次调用轮流使用不同的prompt(模拟真实请求多样性)
int idx = promptIndex.getAndIncrement() % testPrompts.size();
return aiService.chat(testPrompts.get(idx));
}
// 测试不同缓存策略下的并发性能
@Benchmark
public String concurrentChatWithL1Cache() {
int idx = promptIndex.getAndIncrement() % testPrompts.size();
return aiService.chatWithL1Cache(testPrompts.get(idx));
}
@Benchmark
public String concurrentChatWithL2Cache() {
int idx = promptIndex.getAndIncrement() % testPrompts.size();
return aiService.chatWithL2Cache(testPrompts.get(idx));
}
}用Profiler找到热点
JMH可以集成分析器,定位性能瓶颈:
Options options = new OptionsBuilder()
.include(EmbeddingBenchmark.class.getSimpleName())
// 添加异步分析器(需要安装async-profiler)
.addProfiler("async:output=flamegraph;dir=/tmp/flamegraph")
// 或者使用内置的GC分析器
.addProfiler(GCProfiler.class)
// 堆内存分配分析器
.addProfiler(MemoryProfiler.class)
.build();GC分析器的输出很有用:
Benchmark GC.alloc.rate GC.alloc.rate.norm GC.count GC.time
EmbeddingBench.stringConcat 2456.789 MB/s 2456789.000 B/op 12 45 ms
EmbeddingBench.stringBuilder 123.456 MB/s 123456.000 B/op 1 2 ms这直观地告诉你:字符串拼接版本每次操作分配了2.4MB内存,StringBuilder版本只分配了120KB,GC压力差了20倍。
测试陷阱:AI场景的常见误导
陷阱一:测试了Mock但上线用真实API
很多AI服务的性能测试是用Mock的LLM客户端做的,Mock直接返回假数据,延迟是0。上线后真实API需要网络RTT和推理时间,性能完全不同。
区分开来:
- 单元性能测试(JMH):测Mock LLM,测应用层逻辑的性能
- 集成性能测试(k6/JMeter):真实环境,测端到端性能
陷阱二:缓存污染测试结果
如果你的测试反复请求相同的内容,缓存命中率会很高,测出来的性能数据是缓存的性能,而不是真实的处理性能。
要么测试时禁用缓存,要么用足够多样的测试数据使缓存命中率反映真实场景。
陷阱三:忽略了Blackhole
JMH有个叫Blackhole的机制,用来防止JIT把没有副作用的代码优化掉。如果你的基准测试结果被JIT优化掉了,测出来的时间接近0,那数据就是假的。
@Benchmark
public void badBenchmark() {
float[] result = embeddingService.embed(text);
// result没有被使用,JIT可能把这行优化掉!
}
@Benchmark
public float[] goodBenchmark() {
return embeddingService.embed(text);
// 通过返回值,JMH的Blackhole会消费这个结果,防止被优化
}
// 或者用显式的Blackhole参数
@Benchmark
public void goodBenchmarkWithBlackhole(Blackhole bh) {
float[] result = embeddingService.embed(text);
bh.consume(result);
}把JMH集成到CI/CD
性能基准测试一次性做完没用,要持续监控性能变化,防止性能退化:
// 在JMH结果里输出JSON格式,供CI/CD解析
Options options = new OptionsBuilder()
.include(EmbeddingBenchmark.class.getSimpleName())
.result("target/jmh-results.json")
.resultFormat(ResultFormatType.JSON)
.build();在GitHub Actions里:
# .github/workflows/benchmark.yml
- name: Run JMH Benchmarks
run: mvn test -Pbenchmark
- name: Compare with baseline
uses: benchmark-action/github-action-benchmark@v1
with:
tool: 'jmh'
output-file-path: target/jmh-results.json
# 超过基线10%就告警
alert-threshold: '110%'
fail-on-alert: true这样每次PR合并,如果性能退化超过10%,CI会自动失败。
总结
JMH在AI服务里的价值:
- 算法对比:不同向量相似度算法、不同Prompt构建方式,用数字说话
- 缓存收益验证:加了缓存到底提升了多少,有了数据才能判断是否值得
- 并发边界探测:找出在哪个并发级别下性能开始下降
- 性能回归检测:每次代码变更后验证没有性能退化
不要靠感觉判断性能,靠数字。JMH给你可复现的、有统计意义的数字。
