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:
DrSmoothl
2026-05-21 10:26:29 +08:00
parent 070e39090a
commit f3f5d27c65
7 changed files with 337 additions and 1 deletions
+176
View File
@@ -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);
});
});
+4 -1
View File
@@ -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"
}
}
+21
View File
@@ -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',
},
});
+123
View File
@@ -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);
});
});
+1
View File
@@ -82,6 +82,7 @@
},
"scripts": {
"test": "vitest run",
"bench": "vitest bench --run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
+6
View File
@@ -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
+6
View File
@@ -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: