Files
DrSmoothl f3f5d27c65 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.
2026-05-21 10:26:29 +08:00

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);
});
});