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.
124 lines
3.9 KiB
TypeScript
124 lines
3.9 KiB
TypeScript
// 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);
|
|
});
|
|
});
|