feat: Implement optimization plan for Markov Discord bot

- Added `optimization-plan.md` detailing strategies to reduce response latency and improve training throughput.
- Enhanced performance analysis in `performance-analysis.md` with identified bottlenecks and completed optimizations.
- Created `productContext.md` summarizing project goals, user scenarios, and implementation priorities.
- Developed `markov-store.ts` for high-performance serialized chain storage with alias method sampling.
- Implemented database performance indexes in `1704067200000-AddPerformanceIndexes.ts`.
- Introduced `markov-worker.ts` for handling CPU-intensive operations in separate threads.
- Established a worker pool in `worker-pool.ts` to manage multiple worker threads efficiently.
This commit is contained in:
pacnpal
2025-09-25 13:39:22 -04:00
parent 239ded1669
commit 1f0a2573c4
15 changed files with 4082 additions and 335 deletions

402
bench/load_test.ts Normal file
View File

@@ -0,0 +1,402 @@
#!/usr/bin/env node
/**
* Markov Discord Load Testing Script
*
* This script performs load testing on the Markov Discord bot to measure
* performance under various loads and configurations.
*/
import 'source-map-support/register';
import { performance } from 'perf_hooks';
import { MarkovStore } from '../src/markov-store';
import { getWorkerPool } from '../src/workers/worker-pool';
import fs from 'fs/promises';
import path from 'path';
// Configuration
interface LoadTestConfig {
duration: number; // Duration in seconds
concurrency: number; // Number of concurrent requests
warmupTime: number; // Warmup time in seconds
guildId: string;
testDataSize: number; // Number of test messages to use
outputFile: string;
useOptimized: boolean; // Whether to use optimized components
}
// Test result interface
interface TestResult {
config: LoadTestConfig;
summary: {
totalRequests: number;
successfulRequests: number;
failedRequests: number;
requestsPerSecond: number;
averageLatency: number;
minLatency: number;
maxLatency: number;
p95Latency: number;
p99Latency: number;
};
latencies: number[];
errors: string[];
memoryUsage: {
start: NodeJS.MemoryUsage;
end: NodeJS.MemoryUsage;
peak: NodeJS.MemoryUsage;
};
timestamp: string;
}
// Default configuration
const defaultConfig: LoadTestConfig = {
duration: 60,
concurrency: 10,
warmupTime: 5,
guildId: 'load-test-guild',
testDataSize: 1000,
outputFile: `load_test_${new Date().toISOString().replace(/:/g, '-')}.json`,
useOptimized: true
};
// Test data generator
class TestDataGenerator {
private words: string[] = [
'hello', 'world', 'this', 'is', 'a', 'test', 'message', 'for', 'performance',
'testing', 'with', 'many', 'different', 'words', 'and', 'phrases', 'that',
'simulate', 'real', 'conversation', 'patterns', 'in', 'discord', 'channels',
'where', 'people', 'talk', 'about', 'various', 'topics', 'like', 'gaming',
'programming', 'music', 'movies', 'books', 'sports', 'technology', 'science'
];
generateMessage(): string {
const length = Math.floor(Math.random() * 15) + 3; // 3-17 words
const message: string[] = [];
for (let i = 0; i < length; i++) {
message.push(this.words[Math.floor(Math.random() * this.words.length)]);
}
return message.join(' ');
}
generateTrainingData(count: number): Array<{ message: string }> {
const data: Array<{ message: string }> = [];
for (let i = 0; i < count; i++) {
data.push({ message: this.generateMessage() });
}
return data;
}
generatePrefixes(count: number): string[] {
const prefixes: string[] = [];
for (let i = 0; i < count; i++) {
const length = Math.floor(Math.random() * 2) + 1; // 1-2 words
const prefix: string[] = [];
for (let j = 0; j < length; j++) {
prefix.push(this.words[Math.floor(Math.random() * this.words.length)]);
}
prefixes.push(prefix.join(' '));
}
return prefixes;
}
}
// Load tester class
class LoadTester {
private config: LoadTestConfig;
private generator: TestDataGenerator;
private results: number[] = [];
private errors: string[] = [];
private startTime: number = 0;
private endTime: number = 0;
private memoryStart: NodeJS.MemoryUsage;
private memoryPeak: NodeJS.MemoryUsage;
constructor(config: LoadTestConfig) {
this.config = config;
this.generator = new TestDataGenerator();
this.memoryStart = process.memoryUsage();
this.memoryPeak = { ...this.memoryStart };
}
// Update memory peak
private updateMemoryPeak(): void {
const current = process.memoryUsage();
if (current.heapUsed > this.memoryPeak.heapUsed) {
this.memoryPeak = current;
}
}
// Generate training data
private async setupTrainingData(): Promise<Array<{ prefix: string; suffix: string; weight: number }>> {
console.log(`Generating ${this.config.testDataSize} training messages...`);
const messages = this.generator.generateTrainingData(this.config.testDataSize);
const trainingData: Array<{ prefix: string; suffix: string; weight: number }> = [];
for (const msg of messages) {
const words = msg.message.split(' ');
for (let i = 0; i < words.length - 1; i++) {
trainingData.push({
prefix: words[i],
suffix: words[i + 1],
weight: 1
});
}
}
console.log(`Generated ${trainingData.length} training pairs`);
return trainingData;
}
// Build chains (training phase)
private async buildChains(): Promise<void> {
console.log('Building Markov chains...');
if (this.config.useOptimized) {
const workerPool = getWorkerPool(2);
const trainingData = await this.setupTrainingData();
// Split data into chunks for workers
const chunkSize = Math.ceil(trainingData.length / 2);
const chunk1 = trainingData.slice(0, chunkSize);
const chunk2 = trainingData.slice(chunkSize);
const [result1, result2] = await Promise.all([
workerPool.buildChains(this.config.guildId, chunk1, true, 2),
workerPool.buildChains(this.config.guildId, chunk2, false, 2)
]);
console.log(`Chains built: ${result1.processedCount + result2.processedCount} entries`);
} else {
// Fallback to basic implementation
const store = new MarkovStore(this.config.guildId);
await store.load();
store.clear();
const trainingData = await this.setupTrainingData();
for (const item of trainingData) {
store.addPrefix(item.prefix, item.suffix, item.weight);
}
await store.save();
console.log('Basic training completed');
}
}
// Run generation load test
private async runGenerationTest(): Promise<void> {
console.log(`Starting load test: ${this.config.duration}s duration, ${this.config.concurrency} concurrency`);
const prefixes = this.generator.generatePrefixes(1000);
const endTime = Date.now() + (this.config.duration * 1000);
this.startTime = performance.now();
// Warmup phase
if (this.config.warmupTime > 0) {
console.log(`Warmup phase: ${this.config.warmupTime} seconds`);
await new Promise(resolve => setTimeout(resolve, this.config.warmupTime * 1000));
}
// Load test phase
const promises: Promise<void>[] = [];
for (let i = 0; i < this.config.concurrency; i++) {
promises.push(this.generateLoad(i, prefixes, endTime));
}
await Promise.all(promises);
this.endTime = performance.now();
console.log('Load test completed');
}
// Generate load for a single worker
private async generateLoad(
workerId: number,
prefixes: string[],
endTime: number
): Promise<void> {
const latencies: number[] = [];
while (Date.now() < endTime) {
const start = performance.now();
try {
if (this.config.useOptimized) {
// Use worker pool
const workerPool = getWorkerPool(2);
const prefix = prefixes[Math.floor(Math.random() * prefixes.length)];
await workerPool.generateResponse(this.config.guildId, prefix, 30, 1.0, 1);
} else {
// Use basic store
const store = new MarkovStore(this.config.guildId);
await store.load();
const prefix = prefixes[Math.floor(Math.random() * prefixes.length)];
store.generate(prefix, 30);
}
const latency = performance.now() - start;
latencies.push(latency);
this.results.push(latency);
this.updateMemoryPeak();
} catch (error) {
this.errors.push(`Worker ${workerId}: ${error instanceof Error ? error.message : String(error)}`);
}
// Small delay to avoid overwhelming the system
await new Promise(resolve => setTimeout(resolve, 10));
}
console.log(`Worker ${workerId}: completed ${latencies.length} requests`);
}
// Calculate statistics
private calculateStats(): TestResult['summary'] {
if (this.results.length === 0) {
return {
totalRequests: 0,
successfulRequests: 0,
failedRequests: this.errors.length,
requestsPerSecond: 0,
averageLatency: 0,
minLatency: 0,
maxLatency: 0,
p95Latency: 0,
p99Latency: 0
};
}
const sortedLatencies = [...this.results].sort((a, b) => a - b);
const totalTime = this.endTime - this.startTime;
const p95Index = Math.floor(sortedLatencies.length * 0.95);
const p99Index = Math.floor(sortedLatencies.length * 0.99);
return {
totalRequests: this.results.length,
successfulRequests: this.results.length,
failedRequests: this.errors.length,
requestsPerSecond: (this.results.length / totalTime) * 1000,
averageLatency: this.results.reduce((sum, lat) => sum + lat, 0) / this.results.length,
minLatency: sortedLatencies[0],
maxLatency: sortedLatencies[sortedLatencies.length - 1],
p95Latency: sortedLatencies[p95Index] || 0,
p99Latency: sortedLatencies[p99Index] || 0
};
}
// Run complete load test
async run(): Promise<TestResult> {
console.log('=== Markov Discord Load Test ===');
console.log('Configuration:', JSON.stringify(this.config, null, 2));
try {
// Build chains
await this.buildChains();
// Run load test
await this.runGenerationTest();
// Calculate results
const summary = this.calculateStats();
const memoryEnd = process.memoryUsage();
const result: TestResult = {
config: this.config,
summary,
latencies: this.results,
errors: this.errors,
memoryUsage: {
start: this.memoryStart,
end: memoryEnd,
peak: this.memoryPeak
},
timestamp: new Date().toISOString()
};
// Save results
await fs.writeFile(
path.join(process.cwd(), this.config.outputFile),
JSON.stringify(result, null, 2)
);
console.log('\n=== Load Test Results ===');
console.log(`Total Requests: ${summary.totalRequests}`);
console.log(`Requests/sec: ${summary.requestsPerSecond.toFixed(2)}`);
console.log(`Average Latency: ${summary.averageLatency.toFixed(2)}ms`);
console.log(`Min Latency: ${summary.minLatency.toFixed(2)}ms`);
console.log(`Max Latency: ${summary.maxLatency.toFixed(2)}ms`);
console.log(`95th Percentile: ${summary.p95Latency.toFixed(2)}ms`);
console.log(`99th Percentile: ${summary.p99Latency.toFixed(2)}ms`);
console.log(`Failed Requests: ${summary.failedRequests}`);
console.log(`Memory Usage: ${((memoryEnd.heapUsed - this.memoryStart.heapUsed) / 1024 / 1024).toFixed(2)}MB`);
console.log(`Results saved to: ${this.config.outputFile}`);
return result;
} catch (error) {
console.error('Load test failed:', error);
throw error;
}
}
}
// CLI interface
async function main() {
const args = process.argv.slice(2);
// Parse command line arguments
const config: LoadTestConfig = { ...defaultConfig };
for (let i = 0; i < args.length; i += 2) {
const key = args[i].replace('--', '');
const value = args[i + 1];
if (value !== undefined) {
switch (key) {
case 'duration':
config.duration = parseInt(value);
break;
case 'concurrency':
config.concurrency = parseInt(value);
break;
case 'warmup':
config.warmupTime = parseInt(value);
break;
case 'guild':
config.guildId = value;
break;
case 'data-size':
config.testDataSize = parseInt(value);
break;
case 'output':
config.outputFile = value;
break;
case 'optimized':
config.useOptimized = value === 'true';
break;
}
}
}
// Run load test
const tester = new LoadTester(config);
await tester.run();
}
// Handle CLI execution
if (require.main === module) {
main().catch(console.error);
}
export { LoadTester, TestDataGenerator, LoadTestConfig, TestResult };

319
bench/trace.sh Normal file
View File

@@ -0,0 +1,319 @@
#!/bin/bash
# Markov Discord Performance Tracing Script
# Usage: ./bench/trace.sh [baseline|optimized] [iterations]
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
MODE="${1:-baseline}"
ITERATIONS="${2:-10}"
GUILD_ID="test-guild-123"
OUTPUT_DIR="$PROJECT_DIR/bench/results"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
echo "=== Markov Discord Performance Tracing ==="
echo "Mode: $MODE"
echo "Iterations: $ITERATIONS"
echo "Guild ID: $GUILD_ID"
echo "Output: $OUTPUT_DIR"
echo "Timestamp: $TIMESTAMP"
echo
# Create output directory
mkdir -p "$OUTPUT_DIR"
# Generate test data if it doesn't exist
TEST_DATA_FILE="$PROJECT_DIR/test-data.json"
if [ ! -f "$TEST_DATA_FILE" ]; then
echo "Generating test data..."
node -e "
const fs = require('fs');
const messages = [];
const words = ['hello', 'world', 'this', 'is', 'a', 'test', 'message', 'for', 'performance', 'testing', 'with', 'many', 'different', 'words', 'and', 'phrases'];
for (let i = 0; i < 10000; i++) {
const sentence = [];
for (let j = 0; j < Math.floor(Math.random() * 10) + 3; j++) {
sentence.push(words[Math.floor(Math.random() * words.length)]);
}
messages.push({ message: sentence.join(' ') });
}
fs.writeFileSync('$TEST_DATA_FILE', JSON.stringify(messages, null, 2));
console.log('Generated 10,000 test messages');
"
fi
# Function to run training benchmark
run_training_benchmark() {
local mode=$1
local output_file="$OUTPUT_DIR/training_${mode}_${TIMESTAMP}.json"
echo "Running training benchmark ($mode)..."
# Set environment variables based on mode
if [ "$mode" = "optimized" ]; then
export USE_MARKOV_STORE=true
export USE_WORKER_THREADS=true
else
export USE_MARKOV_STORE=false
export USE_WORKER_THREADS=false
fi
# Run with Node.js profiling
node --prof --trace-deopt --track_gc_object_stats \
--log-timer-events \
-e "
const startTime = process.hrtime.bigint();
const startMemory = process.memoryUsage();
// Simulate training
const fs = require('fs');
const data = JSON.parse(fs.readFileSync('$TEST_DATA_FILE', 'utf8'));
console.log('Processing', data.length, 'messages');
// Simple training simulation
let chain = new Map();
for (const msg of data) {
const words = msg.message.split(' ');
for (let i = 0; i < words.length - 1; i++) {
const prefix = words[i];
const suffix = words[i + 1];
if (!chain.has(prefix)) chain.set(prefix, new Map());
const suffixMap = chain.get(prefix);
suffixMap.set(suffix, (suffixMap.get(suffix) || 0) + 1);
}
}
const endTime = process.hrtime.bigint();
const endMemory = process.memoryUsage();
console.log('Training completed');
console.log('Time:', Number(endTime - startTime) / 1000000, 'ms');
console.log('Memory:', (endMemory.heapUsed - startMemory.heapUsed) / 1024 / 1024, 'MB');
console.log('Chains:', chain.size);
" 2>&1 | tee "$output_file"
echo "Training benchmark completed: $output_file"
}
# Function to run generation benchmark
run_generation_benchmark() {
local mode=$1
local output_file="$OUTPUT_DIR/generation_${mode}_${TIMESTAMP}.json"
echo "Running generation benchmark ($mode)..."
# Set environment variables based on mode
if [ "$mode" = "optimized" ]; then
export USE_MARKOV_STORE=true
export USE_WORKER_THREADS=true
else
export USE_MARKOV_STORE=false
export USE_WORKER_THREADS=false
fi
# Run with Node.js profiling
node --prof --trace-deopt --track_gc_object_stats \
--log-timer-events \
-e "
const startTime = process.hrtime.bigint();
const startMemory = process.memoryUsage();
// Simple generation simulation
const fs = require('fs');
const data = JSON.parse(fs.readFileSync('$TEST_DATA_FILE', 'utf8'));
// Build a simple chain
let chain = new Map();
for (const msg of data.slice(0, 1000)) { // Use subset for chain building
const words = msg.message.split(' ');
for (let i = 0; i < words.length - 1; i++) {
const prefix = words[i];
const suffix = words[i + 1];
if (!chain.has(prefix)) chain.set(prefix, new Map());
const suffixMap = chain.get(prefix);
suffixMap.set(suffix, (suffixMap.get(suffix) || 0) + 1);
}
}
// Generate responses
const responses = [];
for (let i = 0; i < 100; i++) {
const prefixes = Array.from(chain.keys());
const startWord = prefixes[Math.floor(Math.random() * prefixes.length)];
let current = startWord;
let response = [current];
for (let j = 0; j < 20; j++) {
const suffixMap = chain.get(current);
if (!suffixMap || suffixMap.size === 0) break;
const suffixes = Array.from(suffixMap.entries());
const total = suffixes.reduce((sum, [, count]) => sum + count, 0);
let random = Math.random() * total;
for (const [suffix, count] of suffixes) {
random -= count;
if (random <= 0) {
response.push(suffix);
current = suffix;
break;
}
}
}
responses.push(response.join(' '));
}
const endTime = process.hrtime.bigint();
const endMemory = process.memoryUsage();
console.log('Generation completed');
console.log('Generated', responses.length, 'responses');
console.log('Time:', Number(endTime - startTime) / 1000000, 'ms');
console.log('Memory:', (endMemory.heapUsed - startMemory.heapUsed) / 1024 / 1024, 'MB');
" 2>&1 | tee "$output_file"
echo "Generation benchmark completed: $output_file"
}
# Function to run memory usage benchmark
run_memory_benchmark() {
local mode=$1
local output_file="$OUTPUT_DIR/memory_${mode}_${TIMESTAMP}.json"
echo "Running memory benchmark ($mode)..."
# Set environment variables based on mode
if [ "$mode" = "optimized" ]; then
export USE_MARKOV_STORE=true
export USE_WORKER_THREADS=true
else
export USE_MARKOV_STORE=false
export USE_WORKER_THREADS=false
fi
# Run memory profiling
node --expose-gc --max-old-space-size=4096 \
-e "
const fs = require('fs');
const data = JSON.parse(fs.readFileSync('$TEST_DATA_FILE', 'utf8'));
console.log('Starting memory benchmark...');
let chain = new Map();
let memoryUsage = [];
// Build chain incrementally and measure memory
for (let i = 0; i < Math.min(data.length, 5000); i += 100) {
const batch = data.slice(i, i + 100);
for (const msg of batch) {
const words = msg.message.split(' ');
for (let j = 0; j < words.length - 1; j++) {
const prefix = words[j];
const suffix = words[j + 1];
if (!chain.has(prefix)) chain.set(prefix, new Map());
const suffixMap = chain.get(prefix);
suffixMap.set(suffix, (suffixMap.get(suffix) || 0) + 1);
}
}
if (global.gc) global.gc();
const mem = process.memoryUsage();
memoryUsage.push({
messagesProcessed: i + 100,
heapUsed: mem.heapUsed,
heapTotal: mem.heapTotal,
external: mem.external,
rss: mem.rss
});
}
console.log('Memory benchmark completed');
console.log('Final chains:', chain.size);
console.log('Memory samples:', memoryUsage.length);
fs.writeFileSync('$output_file', JSON.stringify({
mode: '$mode',
memoryUsage,
finalChainSize: chain.size,
timestamp: '$TIMESTAMP'
}, null, 2));
console.log('Memory benchmark data saved to: $output_file');
" 2>&1 | tee "${output_file}.log"
echo "Memory benchmark completed: $output_file"
}
# Main execution
case "$MODE" in
"baseline")
echo "Running baseline benchmarks..."
run_training_benchmark "baseline"
run_generation_benchmark "baseline"
run_memory_benchmark "baseline"
;;
"optimized")
echo "Running optimized benchmarks..."
run_training_benchmark "optimized"
run_generation_benchmark "optimized"
run_memory_benchmark "optimized"
;;
"both")
echo "Running both baseline and optimized benchmarks..."
run_training_benchmark "baseline"
run_training_benchmark "optimized"
run_generation_benchmark "baseline"
run_generation_benchmark "optimized"
run_memory_benchmark "baseline"
run_memory_benchmark "optimized"
;;
*)
echo "Usage: $0 [baseline|optimized|both] [iterations]"
echo " baseline - Run benchmarks without optimizations"
echo " optimized - Run benchmarks with optimizations enabled"
echo " both - Run both baseline and optimized benchmarks"
echo " iterations - Number of iterations to run (default: 10)"
exit 1
;;
esac
# Generate comparison report if both modes were run
if [ "$MODE" = "both" ]; then
echo
echo "Generating comparison report..."
# Simple comparison report
cat > "$OUTPUT_DIR/comparison_${TIMESTAMP}.txt" << EOF
=== Markov Discord Performance Comparison ===
Timestamp: $TIMESTAMP
Iterations: $ITERATIONS
Benchmark Results Summary:
- Baseline and optimized modes compared
- See individual benchmark files for detailed metrics
- Check $OUTPUT_DIR for all result files
Files generated:
- training_baseline_*.json
- training_optimized_*.json
- generation_baseline_*.json
- generation_optimized_*.json
- memory_baseline_*.json
- memory_optimized_*.json
EOF
echo "Comparison report: $OUTPUT_DIR/comparison_${TIMESTAMP}.txt"
fi
echo
echo "=== Benchmarking Complete ==="
echo "Results saved to: $OUTPUT_DIR"
echo "Check individual files for detailed performance metrics"