perf(bench): bench harness for receive/send chains (RoadMap #5 baseline)
vitest-driven benches at `packages/bridge/bench/recv.bench.ts` and
`packages/onebot/bench/send.bench.ts`. Picks up proton's vite plugin
so call sites get the inlined codecs (running via tsx + proton
runtime-fallback would throw without a pre-built runtime map).
Run via `pnpm -F @snowluma/bridge bench` and
`pnpm -F @snowluma/onebot bench`.
What's measured
---------------
Receive chain:
- parseMsgPush: pure decode + dispatch (no SQLite, no event emit)
* group_message text ~ 0.29 μs/op (3.4M ops/sec)
* group_message 5 mixed ~ 0.50 μs/op (2.0M ops/sec)
* group_member_join ~ 0.25 μs/op (4.1M ops/sec)
- IncomingPacketPipeline.process: full pipeline (with SQLite via
rememberGroupMemberIdentity, BridgeEventBus emit)
* group_message text ~ 0.42 μs/op (2.4M ops/sec)
* group_message 5 mixed ~ 0.67 μs/op (1.5M ops/sec)
* group_member_join ~17.5 μs/op ( 57K ops/sec) ⚠
Send chain:
- parseMessage (OneBot input → MessageElement[])
* CQ string text ~ 0.31 μs/op
* CQ string mixed ~ 1.22 μs/op
* segment array text ~ 0.32 μs/op
* segment array 5 ~ 1.07 μs/op
- messageStore SQLite writes:
* storeMeta ~17.0 μs/op ( 59K ops/sec) ⚠
* storeEvent (~250B JSON) ~23.2 μs/op ( 43K ops/sec) ⚠
- messageStore reads:
* findMeta hit ~ 5.4 μs/op (186K ops/sec)
* findMeta miss ~ 3.7 μs/op (272K ops/sec)
* findEvent hit ~ 3.8 μs/op (262K ops/sec)
Headline finding
----------------
Pure decode/encode is already cheap (sub-μs). The real cost per
sent message is the SQLite write pair (storeMeta + storeEvent =
~40 μs). On receive, group_message itself does NOT trigger
identity writes; only join/leave/admin events do — but those go
through 4-6 db.prepare(sql).run/get(...) calls each, all re-parsing
SQL on every invocation (the canonical SQLite anti-pattern).
The follow-up commits in this series target:
1. Replace `node:sqlite` (experimental, API may shift) with
`better-sqlite3` (stable, sync, ~30-50% faster wire) + cache
prepared statements as readonly fields. Single commit, both
wins together — better-sqlite3's API encourages stmt caching
by design. Expected: storeMeta / storeEvent drop to ~3-5 μs,
group_member_join drops to ~3-5 μs.
2. Skip rememberGroupMemberIdentity DB write when the in-memory
uidByUin / uinByUid cache already has the mapping unchanged.
3. Drop the `Buffer.from(pkt.body)` allocation in msg-push
context (pkt.body is already Uint8Array; proton accepts it).
4. Make `elementsToJson` sync-fast-path when no async resolvers
are wired (saves per-element microtask overhead).
Each commit re-runs these benches and the message should quote the
before/after numbers so the deltas are auditable.
Note: tsx is NOT involved — vitest's bench mode owns the run, and
the proton vite plugin is configured in each `vitest.config.ts`.
Absolute numbers reflect the production code path.
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
// Receive-chain micro-benchmark (RoadMap #5).
|
||||
//
|
||||
// Measured paths:
|
||||
// 1. `parseMsgPush` — pure decode + decoder dispatch (no SQLite, no
|
||||
// event emit). The protobuf workload only.
|
||||
// 2. `IncomingPacketPipeline.process` — full receive pipeline:
|
||||
// decode + decoder + handleSideEffects (identity-service SQLite
|
||||
// writes) + event emit through BridgeEventBus.
|
||||
//
|
||||
// Run via: pnpm -F @snowluma/bridge bench
|
||||
//
|
||||
// Output is `tinybench` ops/sec — vitest's built-in `bench()` is just a
|
||||
// thin wrapper. Run before AND after a perf commit; the delta is what
|
||||
// matters (RoadMap #5 commit messages quote the % shift).
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { bench, describe } from 'vitest';
|
||||
import { protobuf_encode } from '@snowluma/proton';
|
||||
import type { PacketInfo } from '@snowluma/common/protocol-types';
|
||||
import type { PushMsg } from '@snowluma/proto-defs/message';
|
||||
import type { GroupChange } from '@snowluma/proto-defs/notify';
|
||||
import { parseMsgPush, MSG_PUSH_CMD } from '../src/msg-push/index';
|
||||
import { IdentityService } from '../src/identity-service';
|
||||
import { BridgeEventBus } from '../src/event-bus';
|
||||
import { IncomingPacketPipeline } from '../src/packet-pipeline';
|
||||
|
||||
const SELF_UIN = '10001';
|
||||
const GROUP_ID = 123456789;
|
||||
const SENDER_UIN = 22222;
|
||||
const SENDER_UID = 'u_sender';
|
||||
|
||||
// ─── Synthetic packet builders ───
|
||||
|
||||
function makeGroupTextPacket(): PacketInfo {
|
||||
const body = protobuf_encode<PushMsg>({
|
||||
message: {
|
||||
responseHead: { fromUin: SENDER_UIN, fromUid: SENDER_UID },
|
||||
contentHead: {
|
||||
msgType: 82,
|
||||
subType: 1,
|
||||
sequence: 1234,
|
||||
timestamp: 1710000000,
|
||||
msgId: 999999,
|
||||
},
|
||||
body: {
|
||||
richText: {
|
||||
elems: [{ text: { str: 'hello world from bench' } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
status: 0,
|
||||
grpInfo: { groupUin: GROUP_ID },
|
||||
} as any);
|
||||
return {
|
||||
pid: 1, uin: SELF_UIN, serviceCmd: MSG_PUSH_CMD,
|
||||
seqId: 1, retCode: 0, fromClient: false, body,
|
||||
};
|
||||
}
|
||||
|
||||
function makeGroupMixedPacket(): PacketInfo {
|
||||
const body = protobuf_encode<PushMsg>({
|
||||
message: {
|
||||
responseHead: { fromUin: SENDER_UIN, fromUid: SENDER_UID },
|
||||
contentHead: {
|
||||
msgType: 82,
|
||||
subType: 1,
|
||||
sequence: 1235,
|
||||
timestamp: 1710000000,
|
||||
msgId: 999998,
|
||||
},
|
||||
body: {
|
||||
richText: {
|
||||
elems: [
|
||||
{ text: { str: '@some_user ' } },
|
||||
{ face: { index: 76 } },
|
||||
{ text: { str: 'hello ' } },
|
||||
{ srcMsg: { origSeqs: [4242] } },
|
||||
{ text: { str: ' world' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
status: 0,
|
||||
grpInfo: { groupUin: GROUP_ID },
|
||||
} as any);
|
||||
return {
|
||||
pid: 1, uin: SELF_UIN, serviceCmd: MSG_PUSH_CMD,
|
||||
seqId: 2, retCode: 0, fromClient: false, body,
|
||||
};
|
||||
}
|
||||
|
||||
function makeGroupJoinPacket(): PacketInfo {
|
||||
const content = protobuf_encode<GroupChange>({
|
||||
groupUin: GROUP_ID,
|
||||
memberUid: 'u_newcomer',
|
||||
});
|
||||
const body = protobuf_encode<PushMsg>({
|
||||
message: {
|
||||
responseHead: { fromUin: GROUP_ID },
|
||||
contentHead: { msgType: 33, timestamp: 1710000000 },
|
||||
body: { msgContent: content },
|
||||
},
|
||||
status: 0,
|
||||
} as any);
|
||||
return {
|
||||
pid: 1, uin: SELF_UIN, serviceCmd: MSG_PUSH_CMD,
|
||||
seqId: 3, retCode: 0, fromClient: false, body,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Setup helpers ───
|
||||
|
||||
// In-memory identity (no SQLite writes) — used for the "pure decode"
|
||||
// bench to isolate proton + decoder cost from DB cost.
|
||||
function makeIdentity(): IdentityService {
|
||||
return IdentityService.memory(SELF_UIN);
|
||||
}
|
||||
|
||||
// SQLite-backed identity in a temp file — used for the full-pipeline
|
||||
// bench so we capture per-event identity writes. Temp dir is cleaned
|
||||
// implicitly on process exit (OS tmp policy); we don't need to unlink
|
||||
// because each `bench()` recreates it.
|
||||
function makeIdentityWithDb(): IdentityService {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'snowluma-bench-recv-'));
|
||||
return new IdentityService(SELF_UIN, path.join(dir, 'identity.db'));
|
||||
}
|
||||
|
||||
function makePipeline(identity: IdentityService): IncomingPacketPipeline {
|
||||
const events = new BridgeEventBus();
|
||||
events.on('group_message', () => { /* drain */ });
|
||||
events.on('group_member_join', () => { /* drain */ });
|
||||
const pipeline = new IncomingPacketPipeline({
|
||||
identity,
|
||||
events,
|
||||
refreshMemberCache: async () => false,
|
||||
});
|
||||
pipeline.registerCmd(MSG_PUSH_CMD, parseMsgPush);
|
||||
return pipeline;
|
||||
}
|
||||
|
||||
// ─── Benches ───
|
||||
|
||||
const groupText = makeGroupTextPacket();
|
||||
const groupMixed = makeGroupMixedPacket();
|
||||
const groupJoin = makeGroupJoinPacket();
|
||||
|
||||
describe('parseMsgPush — pure decode + dispatch (no SQLite, no emit)', () => {
|
||||
const identityPure = makeIdentity();
|
||||
bench('group_message (text only)', () => {
|
||||
parseMsgPush(groupText, identityPure);
|
||||
});
|
||||
bench('group_message (5 mixed elements)', () => {
|
||||
parseMsgPush(groupMixed, identityPure);
|
||||
});
|
||||
bench('group_member_join', () => {
|
||||
parseMsgPush(groupJoin, identityPure);
|
||||
});
|
||||
});
|
||||
|
||||
describe('IncomingPacketPipeline.process — full pipeline (with SQLite identity writes)', () => {
|
||||
const pipelineText = makePipeline(makeIdentityWithDb());
|
||||
const pipelineMixed = makePipeline(makeIdentityWithDb());
|
||||
const pipelineJoin = makePipeline(makeIdentityWithDb());
|
||||
|
||||
bench('group_message (text only)', () => {
|
||||
pipelineText.process(groupText);
|
||||
});
|
||||
bench('group_message (5 mixed elements)', () => {
|
||||
pipelineMixed.process(groupMixed);
|
||||
});
|
||||
bench('group_member_join', () => {
|
||||
pipelineJoin.process(groupJoin);
|
||||
});
|
||||
});
|
||||
@@ -113,6 +113,7 @@
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bench": "vitest bench --run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -122,6 +123,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.12.2",
|
||||
"typescript": "catalog:"
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vitest": "^4.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// Vitest config — only used by `pnpm bench` to drive the perf
|
||||
// micro-benchmarks under `bench/`. There are no `*.test.ts` files in
|
||||
// this package (bridge tests live in @snowluma/core's test suite by
|
||||
// historical accident); if/when they migrate over, point
|
||||
// `test.include` at them here.
|
||||
import protobufVitePlugin from '@snowluma/proton/vite';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
// proton's vite plugin inlines `protobuf_encode<T>` / `protobuf_decode<T>`
|
||||
// call sites at build time. Required for tinybench-driven benches to
|
||||
// hit the production code path rather than proton's runtime-fallback
|
||||
// (which throws unless a runtime-map JSON is loaded).
|
||||
plugins: [protobufVitePlugin()],
|
||||
test: {
|
||||
benchmark: {
|
||||
include: ['bench/**/*.bench.ts'],
|
||||
},
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
// Send-chain micro-benchmark (RoadMap #5).
|
||||
//
|
||||
// Measured paths:
|
||||
// 1. `parseMessage` — OneBot input (CQ string / segment array) →
|
||||
// MessageElement[]. The parse cost only.
|
||||
// 2. `buildSendElems` — MessageElement[] → wire-proto Elem[]. The
|
||||
// transform cost (skipped: image/video/record because they hit
|
||||
// OIDB upload + need a Bridge; we only measure text-family).
|
||||
// 3. `messageStore.storeMeta` — single SQLite write per send. The
|
||||
// per-send DB cost.
|
||||
// 4. `messageStore.storeEvent` — synthetic self-sent event store.
|
||||
// Bigger DB write (includes JSON payload).
|
||||
//
|
||||
// Run via: pnpm -F @snowluma/onebot bench
|
||||
//
|
||||
// Output is `tinybench` ops/sec. Run before AND after a perf commit.
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { bench, describe } from 'vitest';
|
||||
import { parseMessage } from '../src/message-parser';
|
||||
import { MessageStore } from '../src/message-store';
|
||||
import type { JsonObject } from '@snowluma/common/json';
|
||||
|
||||
// ─── parseMessage inputs ───
|
||||
|
||||
const CQ_TEXT = 'hello world from bench';
|
||||
const CQ_MIXED = '[CQ:at,qq=123456] hello [CQ:face,id=76] world [CQ:reply,id=4242]';
|
||||
|
||||
const SEGMENTS_TEXT = [
|
||||
{ type: 'text', data: { text: 'hello world from bench' } },
|
||||
];
|
||||
const SEGMENTS_MIXED = [
|
||||
{ type: 'at', data: { qq: '123456' } },
|
||||
{ type: 'text', data: { text: ' hello ' } },
|
||||
{ type: 'face', data: { id: 76 } },
|
||||
{ type: 'text', data: { text: ' world ' } },
|
||||
{ type: 'reply', data: { id: 4242 } },
|
||||
];
|
||||
|
||||
// ─── messageStore setup ───
|
||||
|
||||
function makeMessageStore(): MessageStore {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'snowluma-bench-send-'));
|
||||
return new MessageStore(path.join(dir, 'messages.db'));
|
||||
}
|
||||
|
||||
const store = makeMessageStore();
|
||||
|
||||
const META = {
|
||||
isGroup: true,
|
||||
targetId: 123456789,
|
||||
sequence: 1000,
|
||||
eventName: 'message.group',
|
||||
clientSequence: 12345,
|
||||
random: 67890,
|
||||
timestamp: 1710000000,
|
||||
};
|
||||
|
||||
const SAMPLE_EVENT: JsonObject = {
|
||||
time: 1710000000,
|
||||
self_id: 10001,
|
||||
post_type: 'message_sent',
|
||||
message_type: 'group',
|
||||
sub_type: 'normal',
|
||||
message_id: 999999,
|
||||
message_seq: 1000,
|
||||
group_id: 123456789,
|
||||
user_id: 10001,
|
||||
message: [{ type: 'text', data: { text: 'hello world' } }],
|
||||
raw_message: 'hello world',
|
||||
font: 0,
|
||||
sender: { user_id: 10001, nickname: '', sex: 'unknown', age: 0 },
|
||||
};
|
||||
|
||||
// ─── Benches ───
|
||||
|
||||
describe('parseMessage — OneBot input → MessageElement[]', () => {
|
||||
bench('CQ string (text only)', async () => {
|
||||
await parseMessage(CQ_TEXT, false);
|
||||
});
|
||||
bench('CQ string (mixed 4 segments)', async () => {
|
||||
await parseMessage(CQ_MIXED, false);
|
||||
});
|
||||
bench('segment array (text only)', async () => {
|
||||
await parseMessage(SEGMENTS_TEXT as any, false);
|
||||
});
|
||||
bench('segment array (5 segments)', async () => {
|
||||
await parseMessage(SEGMENTS_MIXED as any, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('messageStore — SQLite writes per send', () => {
|
||||
// Use a monotonically-incrementing messageId so each call hits the
|
||||
// upsert path uniquely; otherwise we'd be testing the ON-CONFLICT
|
||||
// branch which is a different cost profile.
|
||||
let id1 = 100_000;
|
||||
let id2 = 200_000;
|
||||
bench('storeMeta', () => {
|
||||
store.storeMeta(id1++, META);
|
||||
});
|
||||
bench('storeEvent (JSON payload ~250 bytes)', () => {
|
||||
store.storeEvent(id2++, true, META.targetId, META.sequence, META.eventName, SAMPLE_EVENT);
|
||||
});
|
||||
});
|
||||
|
||||
describe('messageStore — read paths', () => {
|
||||
// Seed a small set of records so findEvent / findMeta have hits.
|
||||
for (let i = 0; i < 100; i++) {
|
||||
store.storeMeta(500_000 + i, META);
|
||||
store.storeEvent(600_000 + i, true, META.targetId, META.sequence + i, META.eventName, SAMPLE_EVENT);
|
||||
}
|
||||
bench('findMeta (hit)', () => {
|
||||
store.findMeta(500_050);
|
||||
});
|
||||
bench('findMeta (miss)', () => {
|
||||
store.findMeta(9_999_999);
|
||||
});
|
||||
bench('findEvent (hit, JSON.parse on read)', () => {
|
||||
store.findEvent(600_050);
|
||||
});
|
||||
});
|
||||
@@ -82,6 +82,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"bench": "vitest bench --run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -14,6 +14,12 @@ export default defineConfig({
|
||||
plugins: [protobufVitePlugin()],
|
||||
test: {
|
||||
include: ['tests/**/*.test.ts'],
|
||||
// Per-RoadMap-#5 bench files live in `bench/*.bench.ts`. Use the
|
||||
// vitest `--mode bench` (via `pnpm bench`) to pick them up; default
|
||||
// `test` runs leave them inert.
|
||||
benchmark: {
|
||||
include: ['bench/**/*.bench.ts'],
|
||||
},
|
||||
environment: 'node',
|
||||
// Suppress the logger's file transport during tests so the suite
|
||||
// doesn't litter cwd with logs/snowluma-*.log files. Tests that
|
||||
|
||||
Generated
+6
@@ -66,6 +66,12 @@ importers:
|
||||
typescript:
|
||||
specifier: 'catalog:'
|
||||
version: 6.0.2
|
||||
vite:
|
||||
specifier: 'catalog:'
|
||||
version: 8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(sass@1.99.0)(tsx@4.21.0)
|
||||
vitest:
|
||||
specifier: ^4.1.4
|
||||
version: 4.1.4(@types/node@24.12.2)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(sass@1.99.0)(tsx@4.21.0))
|
||||
|
||||
packages/common:
|
||||
devDependencies:
|
||||
|
||||
Reference in New Issue
Block a user