Performance Improvements (#319)

* feat: add performance testing framework foundation

Implements foundation for comprehensive performance testing and user
diagnostics for beads databases at 10K-20K scale.

Components added:
- Fixture generator (internal/testutil/fixtures/) for realistic test data
  * LargeSQLite/XLargeSQLite: 10K/20K issues with epic hierarchies
  * LargeFromJSONL/XLargeFromJSONL: test JSONL import path
  * Realistic cross-linked dependencies, labels, assignees
  * Reproducible with seeded RNG

- User diagnostics (bd doctor --perf) for field performance data
  * Collects platform info (OS, arch, Go/SQLite versions)
  * Measures key operation timings (ready, list, show, search)
  * Generates CPU profiles for bug reports
  * Clean separation in cmd/bd/doctor/perf.go

Test data characteristics:
- 10% epics, 30% features, 60% tasks
- 4-level hierarchies (Epic → Feature → Task → Subtask)
- 20% cross-epic blocking dependencies
- Realistic status/priority/label distributions

Supports bd-l954 (Performance Testing Framework epic)
Closes bd-6ed8, bd-q59i

* perf: optimize GetReadyWork with compound index (20x speedup)

Add compound index on dependencies(depends_on_id, type, issue_id) to
eliminate performance bottleneck in GetReadyWork recursive CTE query.

Performance improvements (10K issue database):
- GetReadyWork: 752ms → 36.6ms (20.5x faster)
- Target: <50ms ✓ ACHIEVED
- 20K database: ~1500ms → 79.4ms (19x faster)

Benchmark infrastructure enhancements:
- Add dataset caching in /tmp/beads-bench-cache/ to avoid regenerating
  10K-20K issues on every benchmark run (first run: ~2min, subsequent: <5s)
- Add progress logging during fixture generation (shows 10%, 20%... completion)
- Add database size logging (17.5 MB for 10K, 35.1 MB for 20K)
- Document rationale for only benchmarking large datasets (>10K issues)
- Add CPU/trace profiling with --profile flag for performance debugging

Schema changes:
- internal/storage/sqlite/schema.go: Add idx_dependencies_depends_on_type_issue

New files:
- internal/storage/sqlite/bench_helpers_test.go: Reusable benchmark setup with caching
- internal/storage/sqlite/sqlite_bench_test.go: Comprehensive benchmarks for critical operations
- Makefile: Convenient benchmark execution (make bench-quick, make bench)

Related:
- Resolves bd-5qim (optimize GetReadyWork performance)
- Builds on bd-6ed8 (fixture generator), bd-q59i (bd doctor --perf)

* perf: add WASM compilation cache to eliminate cold-start overhead

Configure wazero compilation cache for ncruces/go-sqlite3 to avoid
~220ms JIT compilation on every process start.

Cache configuration:
- Location: ~/.cache/beads/wasm/ (platform-specific via os.UserCacheDir)
- Automatic version management: wazero keys entries by its version
- Fallback: in-memory cache if directory creation fails
- No cleanup needed: old versions are harmless (~5-10MB each)

Performance impact:
- First run: ~220ms (populate cache)
- Subsequent runs: ~20ms (load from cache)
- Savings: ~200ms per cold start

Cache invalidation:
- Automatic when wazero version changes (upgrades use new cache dir)
- Manual cleanup: rm -rf ~/.cache/beads/wasm/ (safe to delete anytime)

This complements daemon mode:
- Daemon mode: eliminates startup cost by keeping process alive
- WASM cache: reduces startup cost for one-off commands or daemon restarts

Changes:
- internal/storage/sqlite/sqlite.go: Add init() with cache setup

* refactor: improve maintainability of performance testing code

Extract common patterns and eliminate duplication across benchmarks, fixture generation, and performance diagnostics. Replace magic numbers with explicit configuration to improve readability and make it easier to tune test parameters.

* docs: clarify profiling behavior and add missing documentation

Add explanatory comments for profiling setup to clarify why --profile
forces direct mode (captures actual database operations instead of RPC
overhead) and document the stopCPUProfile function's role in flushing
profile data to disk. Also fix gosec G104 linter warning by explicitly
ignoring Close() error during cleanup.

* fix: prevent bench-quick from running indefinitely

Added //go:build bench tags and skipped timeout-prone benchmarks to
prevent make bench-quick from running for hours.

Changes:
- Add //go:build bench tag to cycle_bench_test.go and compact_bench_test.go
- Skip Dense graph benchmarks (documented to timeout >120s)
- Fix compact benchmark prefix: bd- → bd (validation expects prefix without trailing dash)

Before: make bench-quick ran for 3.5+ hours (12,699s) before manual interrupt
After: make bench-quick completes in ~25 seconds

The Dense graph benchmarks are known to timeout and represent rare edge
cases that don't need optimization for typical workflows.
This commit is contained in:
Ryan
2025-11-15 12:46:13 -08:00
committed by GitHub
parent 944ed1033d
commit 690c73fc31
13 changed files with 1501 additions and 1 deletions

View File

@@ -0,0 +1,245 @@
//go:build bench
package sqlite
import (
"context"
"fmt"
"io"
"os"
"runtime/pprof"
"sync"
"testing"
"time"
"github.com/steveyegge/beads/internal/storage"
"github.com/steveyegge/beads/internal/testutil/fixtures"
)
var (
profileOnce sync.Once
profileFile *os.File
benchCacheDir = "/tmp/beads-bench-cache"
)
// startBenchmarkProfiling starts CPU profiling for the entire benchmark run.
// Uses sync.Once to ensure it only runs once per test process.
// The profile is saved to bench-cpu-<timestamp>.prof in the current directory.
func startBenchmarkProfiling(b *testing.B) {
b.Helper()
profileOnce.Do(func() {
profilePath := fmt.Sprintf("bench-cpu-%s.prof", time.Now().Format("2006-01-02-150405"))
f, err := os.Create(profilePath)
if err != nil {
b.Logf("Warning: failed to create CPU profile: %v", err)
return
}
profileFile = f
if err := pprof.StartCPUProfile(f); err != nil {
b.Logf("Warning: failed to start CPU profiling: %v", err)
f.Close()
return
}
b.Logf("CPU profiling enabled: %s", profilePath)
// Register cleanup to stop profiling when all benchmarks complete
b.Cleanup(func() {
pprof.StopCPUProfile()
if profileFile != nil {
profileFile.Close()
b.Logf("CPU profile saved: %s", profilePath)
b.Logf("View flamegraph: go tool pprof -http=:8080 %s", profilePath)
}
})
})
}
// Benchmark setup rationale:
// We only provide Large (10K) and XLarge (20K) setup functions because
// small databases don't exhibit the performance characteristics we need to optimize.
// See sqlite_bench_test.go for full rationale.
//
// Dataset caching:
// Datasets are cached in /tmp/beads-bench-cache/ to avoid regenerating 10K-20K
// issues on every benchmark run. Cached databases are ~10-30MB and reused across runs.
// getCachedOrGenerateDB returns a cached database or generates it if missing.
// cacheKey should be unique per dataset type (e.g., "large", "xlarge").
// generateFn is called only if the cached database doesn't exist.
func getCachedOrGenerateDB(b *testing.B, cacheKey string, generateFn func(context.Context, storage.Storage) error) string {
b.Helper()
// Ensure cache directory exists
if err := os.MkdirAll(benchCacheDir, 0755); err != nil {
b.Fatalf("Failed to create benchmark cache directory: %v", err)
}
dbPath := fmt.Sprintf("%s/%s.db", benchCacheDir, cacheKey)
// Check if cached database exists
if stat, err := os.Stat(dbPath); err == nil {
sizeMB := float64(stat.Size()) / (1024 * 1024)
b.Logf("Using cached benchmark database: %s (%.1f MB)", dbPath, sizeMB)
return dbPath
}
// Generate new database
b.Logf("===== Generating benchmark database: %s =====", dbPath)
b.Logf("This is a one-time operation that will be cached for future runs...")
b.Logf("Expected time: ~1-3 minutes for 10K issues, ~2-6 minutes for 20K issues")
store, err := New(dbPath)
if err != nil {
b.Fatalf("Failed to create storage: %v", err)
}
ctx := context.Background()
// Initialize database with prefix
if err := store.SetConfig(ctx, "issue_prefix", "bd-"); err != nil {
store.Close()
b.Fatalf("Failed to set issue_prefix: %v", err)
}
// Generate dataset using provided function
if err := generateFn(ctx, store); err != nil {
store.Close()
os.Remove(dbPath) // cleanup partial database
b.Fatalf("Failed to generate dataset: %v", err)
}
store.Close()
// Log completion with final size
if stat, err := os.Stat(dbPath); err == nil {
sizeMB := float64(stat.Size()) / (1024 * 1024)
b.Logf("===== Database generation complete: %s (%.1f MB) =====", dbPath, sizeMB)
}
return dbPath
}
// copyFile copies a file from src to dst.
func copyFile(src, dst string) error {
srcFile, err := os.Open(src)
if err != nil {
return err
}
defer srcFile.Close()
dstFile, err := os.Create(dst)
if err != nil {
return err
}
defer dstFile.Close()
if _, err := io.Copy(dstFile, srcFile); err != nil {
return err
}
return dstFile.Sync()
}
// setupLargeBenchDB creates or reuses a cached 10K issue database.
// Returns configured storage instance and cleanup function.
// Uses //go:build bench tag to avoid running in normal tests.
// Automatically enables CPU profiling on first call.
//
// Note: Copies the cached database to a temp location for each benchmark
// to prevent mutations from affecting subsequent runs.
func setupLargeBenchDB(b *testing.B) (*SQLiteStorage, func()) {
b.Helper()
// Start CPU profiling (only happens once per test run)
startBenchmarkProfiling(b)
// Get or generate cached database
cachedPath := getCachedOrGenerateDB(b, "large", fixtures.LargeSQLite)
// Copy to temp location to prevent mutations
tmpPath := b.TempDir() + "/large.db"
if err := copyFile(cachedPath, tmpPath); err != nil {
b.Fatalf("Failed to copy cached database: %v", err)
}
// Open the temporary copy
store, err := New(tmpPath)
if err != nil {
b.Fatalf("Failed to open database: %v", err)
}
return store, func() {
store.Close()
}
}
// setupXLargeBenchDB creates or reuses a cached 20K issue database.
// Returns configured storage instance and cleanup function.
// Uses //go:build bench tag to avoid running in normal tests.
// Automatically enables CPU profiling on first call.
//
// Note: Copies the cached database to a temp location for each benchmark
// to prevent mutations from affecting subsequent runs.
func setupXLargeBenchDB(b *testing.B) (*SQLiteStorage, func()) {
b.Helper()
// Start CPU profiling (only happens once per test run)
startBenchmarkProfiling(b)
// Get or generate cached database
cachedPath := getCachedOrGenerateDB(b, "xlarge", fixtures.XLargeSQLite)
// Copy to temp location to prevent mutations
tmpPath := b.TempDir() + "/xlarge.db"
if err := copyFile(cachedPath, tmpPath); err != nil {
b.Fatalf("Failed to copy cached database: %v", err)
}
// Open the temporary copy
store, err := New(tmpPath)
if err != nil {
b.Fatalf("Failed to open database: %v", err)
}
return store, func() {
store.Close()
}
}
// setupLargeFromJSONL creates or reuses a cached 10K issue database via JSONL import path.
// Returns configured storage instance and cleanup function.
// Uses //go:build bench tag to avoid running in normal tests.
// Automatically enables CPU profiling on first call.
//
// Note: Copies the cached database to a temp location for each benchmark
// to prevent mutations from affecting subsequent runs.
func setupLargeFromJSONL(b *testing.B) (*SQLiteStorage, func()) {
b.Helper()
// Start CPU profiling (only happens once per test run)
startBenchmarkProfiling(b)
// Get or generate cached database with JSONL import path
cachedPath := getCachedOrGenerateDB(b, "large-jsonl", func(ctx context.Context, store storage.Storage) error {
tempDir := b.TempDir()
return fixtures.LargeFromJSONL(ctx, store, tempDir)
})
// Copy to temp location to prevent mutations
tmpPath := b.TempDir() + "/large-jsonl.db"
if err := copyFile(cachedPath, tmpPath); err != nil {
b.Fatalf("Failed to copy cached database: %v", err)
}
// Open the temporary copy
store, err := New(tmpPath)
if err != nil {
b.Fatalf("Failed to open database: %v", err)
}
return store, func() {
store.Close()
}
}

View File

@@ -1,3 +1,5 @@
//go:build bench
package sqlite
import (
@@ -124,6 +126,9 @@ func setupBenchDB(tb testing.TB) (*SQLiteStorage, func()) {
}
ctx := context.Background()
if err := store.SetConfig(ctx, "issue_prefix", "bd"); err != nil {
tb.Fatalf("Failed to set issue_prefix: %v", err)
}
if err := store.SetConfig(ctx, "compact_tier1_days", "30"); err != nil {
tb.Fatalf("Failed to set config: %v", err)
}

View File

@@ -1,3 +1,5 @@
//go:build bench
package sqlite
import (
@@ -48,11 +50,13 @@ func BenchmarkCycleDetection_Linear_5000(b *testing.B) {
// BenchmarkCycleDetection_Dense_100 tests dense graph: each issue depends on 3-5 previous issues
func BenchmarkCycleDetection_Dense_100(b *testing.B) {
b.Skip("Dense graph benchmarks timeout (>120s). Known issue, no optimization needed for rare use case.")
benchmarkCycleDetectionDense(b, 100)
}
// BenchmarkCycleDetection_Dense_1000 tests dense graph with 1000 issues
func BenchmarkCycleDetection_Dense_1000(b *testing.B) {
b.Skip("Dense graph benchmarks timeout (>120s). Known issue, no optimization needed for rare use case.")
benchmarkCycleDetectionDense(b, 1000)
}

View File

@@ -47,6 +47,7 @@ CREATE TABLE IF NOT EXISTS dependencies (
CREATE INDEX IF NOT EXISTS idx_dependencies_issue ON dependencies(issue_id);
CREATE INDEX IF NOT EXISTS idx_dependencies_depends_on ON dependencies(depends_on_id);
CREATE INDEX IF NOT EXISTS idx_dependencies_depends_on_type ON dependencies(depends_on_id, type);
CREATE INDEX IF NOT EXISTS idx_dependencies_depends_on_type_issue ON dependencies(depends_on_id, type, issue_id);
-- Labels table
CREATE TABLE IF NOT EXISTS labels (

View File

@@ -14,8 +14,10 @@ import (
// Import SQLite driver
"github.com/steveyegge/beads/internal/types"
sqlite3 "github.com/ncruces/go-sqlite3"
_ "github.com/ncruces/go-sqlite3/driver"
_ "github.com/ncruces/go-sqlite3/embed"
"github.com/tetratelabs/wazero"
)
// SQLiteStorage implements the Storage interface using SQLite
@@ -25,6 +27,53 @@ type SQLiteStorage struct {
closed atomic.Bool // Tracks whether Close() has been called
}
// setupWASMCache configures WASM compilation caching to reduce SQLite startup time.
// Returns the cache directory path (empty string if using in-memory cache).
//
// Cache behavior:
// - Location: ~/.cache/beads/wasm/ (platform-specific via os.UserCacheDir)
// - Version management: wazero automatically keys cache by its version
// - Cleanup: Old versions remain harmless (~5-10MB each); manual cleanup if needed
// - Fallback: Uses in-memory cache if filesystem cache creation fails
//
// Performance impact:
// - First run: ~220ms (compile + cache)
// - Subsequent runs: ~20ms (load from cache)
func setupWASMCache() string {
cacheDir := ""
if userCache, err := os.UserCacheDir(); err == nil {
cacheDir = filepath.Join(userCache, "beads", "wasm")
}
var cache wazero.CompilationCache
if cacheDir != "" {
// Try file-system cache first (persistent across runs)
if c, err := wazero.NewCompilationCacheWithDir(cacheDir); err == nil {
cache = c
// Optional: log cache location for debugging
// fmt.Fprintf(os.Stderr, "WASM cache: %s\n", cacheDir)
}
}
// Fallback to in-memory cache if dir creation failed
if cache == nil {
cache = wazero.NewCompilationCache()
cacheDir = "" // Indicate in-memory fallback
// Optional: log fallback for debugging
// fmt.Fprintln(os.Stderr, "WASM cache: in-memory only")
}
// Configure go-sqlite3's wazero runtime to use the cache
sqlite3.RuntimeConfig = wazero.NewRuntimeConfig().WithCompilationCache(cache)
return cacheDir
}
func init() {
// Setup WASM compilation cache to avoid 220ms JIT compilation overhead on every process start
_ = setupWASMCache()
}
// New creates a new SQLite storage backend
func New(path string) (*SQLiteStorage, error) {
// Build connection string with proper URI syntax

View File

@@ -0,0 +1,145 @@
//go:build bench
package sqlite
import (
"context"
"testing"
"github.com/steveyegge/beads/internal/types"
)
// Benchmark size rationale:
// We only benchmark Large (10K) and XLarge (20K) databases because:
// - Small databases (<1K issues) perform acceptably without optimization
// - Performance issues only manifest at scale (10K+ issues)
// - Smaller benchmarks add code weight without providing optimization insights
// - Target users manage repos with thousands of issues, not hundreds
// runBenchmark sets up a benchmark with consistent configuration and runs the provided test function.
// It handles store setup/cleanup, timer management, and allocation reporting uniformly across all benchmarks.
func runBenchmark(b *testing.B, setupFunc func(*testing.B) (*SQLiteStorage, func()), testFunc func(*SQLiteStorage, context.Context) error) {
b.Helper()
store, cleanup := setupFunc(b)
defer cleanup()
ctx := context.Background()
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
if err := testFunc(store, ctx); err != nil {
b.Fatalf("benchmark failed: %v", err)
}
}
}
// BenchmarkGetReadyWork_Large benchmarks GetReadyWork on 10K issue database
func BenchmarkGetReadyWork_Large(b *testing.B) {
runBenchmark(b, setupLargeBenchDB, func(store *SQLiteStorage, ctx context.Context) error {
_, err := store.GetReadyWork(ctx, types.WorkFilter{})
return err
})
}
// BenchmarkGetReadyWork_XLarge benchmarks GetReadyWork on 20K issue database
func BenchmarkGetReadyWork_XLarge(b *testing.B) {
runBenchmark(b, setupXLargeBenchDB, func(store *SQLiteStorage, ctx context.Context) error {
_, err := store.GetReadyWork(ctx, types.WorkFilter{})
return err
})
}
// BenchmarkSearchIssues_Large_NoFilter benchmarks searching all open issues
func BenchmarkSearchIssues_Large_NoFilter(b *testing.B) {
openStatus := types.StatusOpen
filter := types.IssueFilter{
Status: &openStatus,
}
runBenchmark(b, setupLargeBenchDB, func(store *SQLiteStorage, ctx context.Context) error {
_, err := store.SearchIssues(ctx, "", filter)
return err
})
}
// BenchmarkSearchIssues_Large_ComplexFilter benchmarks complex filtered search
func BenchmarkSearchIssues_Large_ComplexFilter(b *testing.B) {
openStatus := types.StatusOpen
filter := types.IssueFilter{
Status: &openStatus,
PriorityMin: intPtr(0),
PriorityMax: intPtr(2),
}
runBenchmark(b, setupLargeBenchDB, func(store *SQLiteStorage, ctx context.Context) error {
_, err := store.SearchIssues(ctx, "", filter)
return err
})
}
// BenchmarkCreateIssue_Large benchmarks issue creation in large database
func BenchmarkCreateIssue_Large(b *testing.B) {
runBenchmark(b, setupLargeBenchDB, func(store *SQLiteStorage, ctx context.Context) error {
issue := &types.Issue{
Title: "Benchmark issue",
Description: "Test description",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
}
return store.CreateIssue(ctx, issue, "bench")
})
}
// BenchmarkUpdateIssue_Large benchmarks issue updates in large database
func BenchmarkUpdateIssue_Large(b *testing.B) {
// Setup phase: get an issue to update (not timed)
store, cleanup := setupLargeBenchDB(b)
defer cleanup()
ctx := context.Background()
openStatus := types.StatusOpen
issues, err := store.SearchIssues(ctx, "", types.IssueFilter{
Status: &openStatus,
})
if err != nil || len(issues) == 0 {
b.Fatalf("Failed to get issues for update test: %v", err)
}
targetID := issues[0].ID
// Benchmark phase: measure update operations
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
updates := map[string]interface{}{
"status": types.StatusInProgress,
}
if err := store.UpdateIssue(ctx, targetID, updates, "bench"); err != nil {
b.Fatalf("UpdateIssue failed: %v", err)
}
// reset back to open for next iteration
updates["status"] = types.StatusOpen
if err := store.UpdateIssue(ctx, targetID, updates, "bench"); err != nil {
b.Fatalf("UpdateIssue failed: %v", err)
}
}
}
// BenchmarkGetReadyWork_FromJSONL benchmarks ready work on JSONL-imported database
func BenchmarkGetReadyWork_FromJSONL(b *testing.B) {
runBenchmark(b, setupLargeFromJSONL, func(store *SQLiteStorage, ctx context.Context) error {
_, err := store.GetReadyWork(ctx, types.WorkFilter{})
return err
})
}
// Helper function
func intPtr(i int) *int {
return &i
}