mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
410 lines
15 KiB
TypeScript
410 lines
15 KiB
TypeScript
/**
|
|
* End-to-end benchmark for shuakami/qq-chat-exporter V4.
|
|
*
|
|
* Compares the existing production path (slim preprocess + pure TS parser),
|
|
* direct pure TS parsing, the direct Rust kernel/boundary breakdown, and the
|
|
* real production native-first ParseEvent wrapper. Every mode runs in an
|
|
* isolated process so peak RSS is comparable.
|
|
*
|
|
* Usage:
|
|
* node --import tsx scripts/bench-shuakami-qq-parser.mts [messageCount]
|
|
*
|
|
* Requires a local native build:
|
|
* cd packages/parser-native && ../../node_modules/.bin/napi build --platform --release
|
|
*/
|
|
|
|
import { once } from 'node:events'
|
|
import { createWriteStream, mkdtempSync, rmSync, statSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { basename, join } from 'node:path'
|
|
import { spawnSync } from 'node:child_process'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
import type { NativeMember, NativeMessage } from '@openchatlab/parser-native'
|
|
import type { ParseEvent, ParsedMember, ParsedMessage, ParsedMeta } from '../packages/parser/src/types'
|
|
import { parseShuakamiQqV4, parseShuakamiQqV4Accelerated } from '../packages/parser/src/formats/shuakami-qq-exporter'
|
|
import { shuakamiQqPreprocessor } from '../packages/parser/src/formats/shuakami-qq-preprocessor'
|
|
import { loadNativeParser } from '../packages/parser/src/native/loader'
|
|
import { shuakamiQqAdapter } from '../packages/parser/src/native/shuakami-qq-native'
|
|
|
|
type Mode = 'ts-production' | 'ts-raw' | 'native-direct' | 'native-production'
|
|
|
|
interface BenchResult {
|
|
mode: Mode
|
|
totalMs: number
|
|
preprocessMs?: number
|
|
parserMs?: number
|
|
kernelMs?: number
|
|
boundaryMs?: number
|
|
messages: number
|
|
members: number
|
|
hash: string
|
|
baselineRssMb: number
|
|
peakRssMb: number
|
|
peakDeltaRssMb: number
|
|
phasePeakRssMb: Record<string, number>
|
|
}
|
|
|
|
const MEMBER_COUNT = 200
|
|
const TEXTS = [
|
|
'今天天气不错,我们出去玩吧!',
|
|
'哈哈哈哈哈哈这也太好笑了',
|
|
'[图片]',
|
|
'好的,收到,明天见。',
|
|
'This is a mixed language message 和中文混排。',
|
|
'周末有人一起打球吗?地点老地方。',
|
|
]
|
|
const HTML_PADDING = 'H'.repeat(96)
|
|
const RAW_PADDING = 'R'.repeat(96)
|
|
|
|
async function generateFixture(filePath: string, count: number): Promise<void> {
|
|
const stream = createWriteStream(filePath, { encoding: 'utf-8' })
|
|
const write = async (chunk: string) => {
|
|
if (!stream.write(chunk)) await once(stream, 'drain')
|
|
}
|
|
|
|
const senders = Array.from({ length: MEMBER_COUNT }, (_, index) => ({
|
|
uid: `uid_${index}`,
|
|
name: `成员${index}号`,
|
|
messageCount: Math.ceil(count / MEMBER_COUNT),
|
|
percentage: 1 / MEMBER_COUNT,
|
|
}))
|
|
await write(
|
|
JSON.stringify({
|
|
metadata: { name: 'QQChatExporter V6', version: '6.0.3', exportTime: '2026-07-30T00:00:00.000Z' },
|
|
chatInfo: { name: 'QQ Rust 性能测试群', type: 'group', avatar: 'data:image/png;base64,GROUP' },
|
|
statistics: { totalMessages: count, senders },
|
|
messages: [],
|
|
}).slice(0, -2) + '\n'
|
|
)
|
|
|
|
const batch: string[] = []
|
|
for (let index = 0; index < count; index++) {
|
|
const member = index % MEMBER_COUNT
|
|
const resourceType =
|
|
index % 25 === 0 ? ['image', 'video', 'voice', 'file', 'location'][Math.floor(index / 25) % 5] : undefined
|
|
const message = {
|
|
messageId: `msg_${index}`,
|
|
timestamp: index % 2 === 0 ? '2026-07-10T12:00:00.123Z' : '2026-07-10T20:00:00.123+08:00',
|
|
sender: { uin: String(10_000 + member), uid: `uid_${member}`, name: `成员${member}号` },
|
|
messageType: index % 41 === 0 ? 9 : 2,
|
|
system: index % 20_000 === 3,
|
|
isSystemMessage: index % 10_000 === 1,
|
|
recalled: index % 20_000 === 4,
|
|
isRecalled: index % 10_000 === 2,
|
|
content: {
|
|
text: `${TEXTS[index % TEXTS.length]} #${index}`,
|
|
...(resourceType ? { resources: [{ type: resourceType, url: `https://invalid.local/${index}` }] } : {}),
|
|
...(index % 37 === 0 ? { emojis: [{ type: 'face', data: 'ignored' }] } : {}),
|
|
...(index % 997 === 0 ? { reply: { referencedMessageId: `msg_${Math.max(0, index - 1)}` } } : {}),
|
|
html: `<div data-index="${index}">${HTML_PADDING}</div>`,
|
|
raw: { payload: RAW_PADDING, sequence: index },
|
|
},
|
|
rawMessage: {
|
|
sendNickName: `QQ昵称${member}`,
|
|
...(index % 3 === 0 ? { sendMemberName: `群昵称${member}` } : {}),
|
|
},
|
|
}
|
|
batch.push(JSON.stringify(message))
|
|
if (batch.length === 2_000) {
|
|
await write((index + 1 > batch.length ? ',' : '') + batch.join(','))
|
|
batch.length = 0
|
|
}
|
|
}
|
|
if (batch.length > 0) {
|
|
await write((count > batch.length ? ',' : '') + batch.join(','))
|
|
}
|
|
|
|
const avatars = Object.fromEntries(
|
|
Array.from({ length: MEMBER_COUNT }, (_, index) => [
|
|
String(10_000 + index),
|
|
`data:image/jpeg;base64,${String(index).padStart(4, '0')}${'A'.repeat(128)}`,
|
|
])
|
|
)
|
|
await write(`\n],"avatars":${JSON.stringify(avatars)}}`)
|
|
stream.end()
|
|
await once(stream, 'finish')
|
|
}
|
|
|
|
class OutputHash {
|
|
private value = 0x811c9dc5
|
|
|
|
add(value: unknown): void {
|
|
const text = value === undefined ? '<undefined>' : value === null ? '<null>' : String(value)
|
|
for (let index = 0; index < text.length; index++) {
|
|
this.value ^= text.charCodeAt(index)
|
|
this.value = Math.imul(this.value, 0x01000193)
|
|
}
|
|
this.value ^= 0xff
|
|
this.value = Math.imul(this.value, 0x01000193)
|
|
}
|
|
|
|
digest(): string {
|
|
return (this.value >>> 0).toString(16).padStart(8, '0')
|
|
}
|
|
}
|
|
|
|
function hashMeta(hash: OutputHash, meta: ParsedMeta): void {
|
|
hash.add(meta.name)
|
|
hash.add(meta.platform)
|
|
hash.add(meta.type)
|
|
hash.add(meta.groupAvatar)
|
|
}
|
|
|
|
function hashMember(hash: OutputHash, member: ParsedMember): void {
|
|
hash.add(member.platformId)
|
|
hash.add(member.accountName)
|
|
hash.add(member.groupNickname)
|
|
hash.add(member.avatar)
|
|
}
|
|
|
|
function hashMessage(hash: OutputHash, message: ParsedMessage): void {
|
|
hash.add(message.platformMessageId)
|
|
hash.add(message.senderPlatformId)
|
|
hash.add(message.senderAccountName)
|
|
hash.add(message.senderGroupNickname)
|
|
hash.add(message.timestamp)
|
|
hash.add(message.type)
|
|
hash.add(message.content)
|
|
hash.add(message.replyToMessageId)
|
|
}
|
|
|
|
function startRssSampler() {
|
|
let phase = 'setup'
|
|
const phasePeaks: Record<string, number> = {}
|
|
const sample = () => {
|
|
const rssMb = process.memoryUsage().rss / 1024 / 1024
|
|
phasePeaks[phase] = Math.max(phasePeaks[phase] ?? 0, rssMb)
|
|
}
|
|
sample()
|
|
const timer = setInterval(sample, 5)
|
|
return {
|
|
setPhase(next: string) {
|
|
phase = next
|
|
sample()
|
|
},
|
|
stop() {
|
|
sample()
|
|
clearInterval(timer)
|
|
return phasePeaks
|
|
},
|
|
}
|
|
}
|
|
|
|
async function consumeEvents(events: AsyncGenerator<ParseEvent, void, unknown>, hash: OutputHash) {
|
|
let messages = 0
|
|
let members = 0
|
|
for await (const event of events) {
|
|
if (event.type === 'meta') hashMeta(hash, event.data)
|
|
if (event.type === 'members') {
|
|
members += event.data.length
|
|
for (const member of event.data) hashMember(hash, member)
|
|
}
|
|
if (event.type === 'messages') {
|
|
messages += event.data.length
|
|
for (const message of event.data) hashMessage(hash, message)
|
|
}
|
|
}
|
|
return { messages, members }
|
|
}
|
|
|
|
function mapAndHashNativeMember(hash: OutputHash, member: NativeMember): void {
|
|
const [mapped] = shuakamiQqAdapter.mapMembers([member], {})
|
|
hashMember(hash, mapped)
|
|
}
|
|
|
|
function mapAndHashNativeMessage(hash: OutputHash, message: NativeMessage, meta: unknown): void {
|
|
hashMessage(hash, shuakamiQqAdapter.mapMessage(message, meta))
|
|
}
|
|
|
|
async function runWorker(mode: Mode, filePath: string): Promise<BenchResult> {
|
|
global.gc?.()
|
|
const baselineRssMb = process.memoryUsage().rss / 1024 / 1024
|
|
const sampler = startRssSampler()
|
|
const hash = new OutputHash()
|
|
let messages = 0
|
|
let members = 0
|
|
let preprocessMs: number | undefined
|
|
let parserMs: number | undefined
|
|
let kernelMs: number | undefined
|
|
let boundaryMs: number | undefined
|
|
const totalStart = performance.now()
|
|
|
|
if (mode === 'ts-production') {
|
|
process.env.CHATLAB_DISABLE_NATIVE_PERF = '1'
|
|
sampler.setPhase('preprocess')
|
|
const preprocessStart = performance.now()
|
|
const slimPath = await shuakamiQqPreprocessor.preprocess(filePath)
|
|
preprocessMs = performance.now() - preprocessStart
|
|
try {
|
|
sampler.setPhase('parse-and-deliver')
|
|
const parseStart = performance.now()
|
|
;({ messages, members } = await consumeEvents(parseShuakamiQqV4({ filePath: slimPath, batchSize: 5_000 }), hash))
|
|
parserMs = performance.now() - parseStart
|
|
} finally {
|
|
shuakamiQqPreprocessor.cleanup(slimPath)
|
|
}
|
|
} else if (mode === 'ts-raw') {
|
|
process.env.CHATLAB_DISABLE_NATIVE_PERF = '1'
|
|
sampler.setPhase('parse-and-deliver')
|
|
const parseStart = performance.now()
|
|
;({ messages, members } = await consumeEvents(parseShuakamiQqV4({ filePath, batchSize: 5_000 }), hash))
|
|
parserMs = performance.now() - parseStart
|
|
} else if (mode === 'native-direct') {
|
|
delete process.env.CHATLAB_DISABLE_NATIVE_PERF
|
|
const native = loadNativeParser()
|
|
if (!native) throw new Error('Native module is unavailable')
|
|
const parser = new native.NativeParser('shuakami-qq-exporter', filePath)
|
|
sampler.setPhase('kernel')
|
|
const kernelStart = performance.now()
|
|
await parser.parse()
|
|
kernelMs = performance.now() - kernelStart
|
|
|
|
sampler.setPhase('boundary-and-adapter')
|
|
const boundaryStart = performance.now()
|
|
const metaJson: unknown = JSON.parse(parser.metaJson())
|
|
hashMeta(hash, shuakamiQqAdapter.mapMeta(metaJson))
|
|
const nativeMembers = parser.takeMembers()
|
|
members = nativeMembers.length
|
|
for (const member of nativeMembers) mapAndHashNativeMember(hash, member)
|
|
while (true) {
|
|
const batch = parser.takeBatch(5_000)
|
|
if (!batch) break
|
|
messages += batch.length
|
|
for (const message of batch) mapAndHashNativeMessage(hash, message, metaJson)
|
|
await new Promise((resolve) => setImmediate(resolve))
|
|
}
|
|
boundaryMs = performance.now() - boundaryStart
|
|
} else {
|
|
delete process.env.CHATLAB_DISABLE_NATIVE_PERF
|
|
sampler.setPhase('parse-and-deliver')
|
|
const parseStart = performance.now()
|
|
;({ messages, members } = await consumeEvents(parseShuakamiQqV4Accelerated({ filePath, batchSize: 5_000 }), hash))
|
|
parserMs = performance.now() - parseStart
|
|
}
|
|
|
|
const totalMs = performance.now() - totalStart
|
|
const phasePeakRssMb = sampler.stop()
|
|
const peakRssMb = Math.max(...Object.values(phasePeakRssMb))
|
|
return {
|
|
mode,
|
|
totalMs,
|
|
preprocessMs,
|
|
parserMs,
|
|
kernelMs,
|
|
boundaryMs,
|
|
messages,
|
|
members,
|
|
hash: hash.digest(),
|
|
baselineRssMb,
|
|
peakRssMb,
|
|
peakDeltaRssMb: peakRssMb - baselineRssMb,
|
|
phasePeakRssMb,
|
|
}
|
|
}
|
|
|
|
function runIsolated(mode: Mode, filePath: string): BenchResult {
|
|
const scriptPath = fileURLToPath(import.meta.url)
|
|
const child = spawnSync(
|
|
process.execPath,
|
|
['--expose-gc', '--import', 'tsx', scriptPath, '--worker', mode, filePath],
|
|
{
|
|
cwd: process.cwd(),
|
|
encoding: 'utf-8',
|
|
maxBuffer: 10 * 1024 * 1024,
|
|
}
|
|
)
|
|
if (child.status !== 0) {
|
|
throw new Error(`${mode} worker failed:\n${child.stdout}\n${child.stderr}`)
|
|
}
|
|
const line = child.stdout
|
|
.trim()
|
|
.split('\n')
|
|
.findLast((candidate) => candidate.startsWith('{'))
|
|
if (!line) throw new Error(`${mode} worker returned no JSON: ${child.stdout}`)
|
|
return JSON.parse(line) as BenchResult
|
|
}
|
|
|
|
function runMedian(mode: Mode, filePath: string, runs: number): BenchResult {
|
|
const results = Array.from({ length: runs }, () => runIsolated(mode, filePath))
|
|
const first = results[0]
|
|
if (
|
|
results.some(
|
|
(result) => result.hash !== first.hash || result.messages !== first.messages || result.members !== first.members
|
|
)
|
|
) {
|
|
throw new Error(`${mode} produced inconsistent output across benchmark runs`)
|
|
}
|
|
return results.toSorted((left, right) => left.totalMs - right.totalMs)[Math.floor(results.length / 2)]
|
|
}
|
|
|
|
function formatResult(result: BenchResult): string {
|
|
const stages = [
|
|
result.preprocessMs === undefined ? '' : `preprocess=${result.preprocessMs.toFixed(0)}ms`,
|
|
result.parserMs === undefined ? '' : `parser=${result.parserMs.toFixed(0)}ms`,
|
|
result.kernelMs === undefined ? '' : `kernel=${result.kernelMs.toFixed(0)}ms`,
|
|
result.boundaryMs === undefined ? '' : `boundary=${result.boundaryMs.toFixed(0)}ms`,
|
|
]
|
|
.filter(Boolean)
|
|
.join(', ')
|
|
return (
|
|
`${result.mode.padEnd(13)} total=${result.totalMs.toFixed(0).padStart(6)}ms (${stages}) | ` +
|
|
`peak=${result.peakRssMb.toFixed(0)}MB (+${result.peakDeltaRssMb.toFixed(0)}MB) | ` +
|
|
`${result.messages.toLocaleString()} messages, ${result.members} members, hash=${result.hash}`
|
|
)
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
if (process.argv[2] === '--worker') {
|
|
const mode = process.argv[3] as Mode
|
|
const filePath = process.argv[4]
|
|
console.log(JSON.stringify(await runWorker(mode, filePath)))
|
|
return
|
|
}
|
|
|
|
const messageCount = Number(process.argv[2] ?? 500_000)
|
|
const runs = Number(process.argv[3] ?? 1)
|
|
if (!Number.isInteger(runs) || runs < 1 || runs % 2 === 0) {
|
|
throw new Error('Benchmark runs must be a positive odd integer')
|
|
}
|
|
const directory = mkdtempSync(join(tmpdir(), 'chatlab-shuakami-qq-bench-'))
|
|
const filePath = join(directory, 'shuakami-qq-v4-benchmark.json')
|
|
try {
|
|
console.log(`Generating ${messageCount.toLocaleString()}-message shuakami/qq-chat-exporter V4 fixture...`)
|
|
await generateFixture(filePath, messageCount)
|
|
console.log(`Fixture: ${basename(filePath)}, ${(statSync(filePath).size / 1024 / 1024).toFixed(1)} MB`)
|
|
if (runs > 1) console.log(`Reporting the median of ${runs} isolated runs per mode.`)
|
|
|
|
const results = [
|
|
runMedian('ts-raw', filePath, runs),
|
|
runMedian('ts-production', filePath, runs),
|
|
runMedian('native-direct', filePath, runs),
|
|
runMedian('native-production', filePath, runs),
|
|
]
|
|
for (const result of results) console.log(formatResult(result))
|
|
|
|
const [tsRaw, tsProduction, nativeDirect, nativeProduction] = results
|
|
for (const native of [nativeDirect, nativeProduction]) {
|
|
if (tsRaw.hash !== native.hash || tsRaw.messages !== native.messages || tsRaw.members !== native.members) {
|
|
throw new Error(`${native.mode} output does not match the pure TS raw-file reference`)
|
|
}
|
|
if (
|
|
tsProduction.hash !== native.hash ||
|
|
tsProduction.messages !== native.messages ||
|
|
tsProduction.members !== native.members
|
|
) {
|
|
throw new Error(`${native.mode} output does not match the production preprocess + TS path`)
|
|
}
|
|
}
|
|
|
|
console.log(`Speedup vs current production path: ${(tsProduction.totalMs / nativeProduction.totalMs).toFixed(2)}x`)
|
|
console.log(
|
|
`Peak RSS delta ratio (TS/native): ${(tsProduction.peakDeltaRssMb / nativeProduction.peakDeltaRssMb).toFixed(2)}x`
|
|
)
|
|
} finally {
|
|
rmSync(directory, { recursive: true, force: true })
|
|
}
|
|
}
|
|
|
|
await main()
|