a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
462 lines
14 KiB
Go
462 lines
14 KiB
Go
// runner.go — per-repo benchmark execution. Cold-index, search p95,
|
||
// impact p95/p99, incremental reindex, on-disk DB size. All
|
||
// measurements come from real indexer / query / analysis calls so
|
||
// the headline numbers are reproducible from the same code that ships
|
||
// in production.
|
||
package main
|
||
|
||
import (
|
||
"crypto/rand"
|
||
"encoding/binary"
|
||
"encoding/gob"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"runtime"
|
||
"slices"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
|
||
"go.uber.org/zap"
|
||
|
||
"github.com/zzet/gortex/internal/analysis"
|
||
"github.com/zzet/gortex/internal/config"
|
||
"github.com/zzet/gortex/internal/graph"
|
||
"github.com/zzet/gortex/internal/indexer"
|
||
"github.com/zzet/gortex/internal/parser"
|
||
"github.com/zzet/gortex/internal/parser/languages"
|
||
"github.com/zzet/gortex/internal/query"
|
||
)
|
||
|
||
// repoSpec names one benchmark target. Path is either an absolute /
|
||
// repo-relative directory (used as-is, no clone) or a clone target
|
||
// triple (slug, URL, optional branch).
|
||
type repoSpec struct {
|
||
Slug string
|
||
URL string
|
||
Branch string
|
||
// Path is set after cloning (or directly when Local is true).
|
||
Path string
|
||
Local bool
|
||
}
|
||
|
||
// repoRow is the markdown / CSV / JSON row a single repo produces.
|
||
// All durations in milliseconds for table-friendliness; DBBytes in
|
||
// bytes (rendered as MB in markdown).
|
||
type repoRow struct {
|
||
Slug string `json:"slug"`
|
||
Path string `json:"path"`
|
||
LoC int `json:"loc"`
|
||
Files int `json:"files"`
|
||
Nodes int `json:"nodes"`
|
||
Edges int `json:"edges"`
|
||
ColdIndexMs float64 `json:"cold_index_ms"`
|
||
SearchP50Ms float64 `json:"search_p50_ms"`
|
||
SearchP95Ms float64 `json:"search_p95_ms"`
|
||
ImpactP50Ms float64 `json:"impact_p50_ms"`
|
||
ImpactP95Ms float64 `json:"impact_p95_ms"`
|
||
ImpactP99Ms float64 `json:"impact_p99_ms"`
|
||
IncrementalMs float64 `json:"incremental_ms"`
|
||
DBBytes int64 `json:"db_bytes"`
|
||
RSSBytes int64 `json:"rss_bytes"`
|
||
BudgetViolations int `json:"budget_violations"`
|
||
Skipped string `json:"skipped,omitempty"`
|
||
Error string `json:"error,omitempty"`
|
||
}
|
||
|
||
// runRepo executes the full bench against one repo. Returns a row
|
||
// with whatever measurements completed; partial failures populate
|
||
// Error / Skipped instead of aborting the whole run so a flaky repo
|
||
// doesn't kill the others.
|
||
func runRepo(spec repoSpec, queries []string, budget budgets) repoRow {
|
||
row := repoRow{Slug: spec.Slug}
|
||
|
||
// Resolve path: local repos are used as-is; remote URLs clone
|
||
// (depth=1) into the configured cache.
|
||
if !spec.Local {
|
||
path, err := ensureCloned(spec)
|
||
if err != nil {
|
||
row.Error = fmt.Sprintf("clone: %v", err)
|
||
return row
|
||
}
|
||
spec.Path = path
|
||
}
|
||
row.Path = spec.Path
|
||
|
||
// --- 1. Cold-index ------------------------------------------
|
||
g := graph.New()
|
||
reg := parser.NewRegistry()
|
||
languages.RegisterAll(reg)
|
||
cfg := config.Config{}
|
||
idx := indexer.New(g, reg, cfg.Index, zap.NewNop())
|
||
|
||
t0 := time.Now()
|
||
indexRes, err := idx.Index(spec.Path)
|
||
row.ColdIndexMs = msSince(t0)
|
||
if err != nil {
|
||
row.Error = fmt.Sprintf("index: %v", err)
|
||
return row
|
||
}
|
||
row.Files = indexRes.FileCount
|
||
row.Nodes = indexRes.NodeCount
|
||
row.LoC = sumLOC(g)
|
||
row.Edges = countEdges(g)
|
||
|
||
// --- 2. Search p50/p95 --------------------------------------
|
||
eng := query.NewEngine(g)
|
||
eng.SetSearch(idx.Search())
|
||
searchLatencies := make([]time.Duration, 0, len(queries))
|
||
for _, q := range queries {
|
||
t := time.Now()
|
||
_ = eng.SearchSymbols(q, 20)
|
||
searchLatencies = append(searchLatencies, time.Since(t))
|
||
}
|
||
row.SearchP50Ms = pctMs(searchLatencies, 50)
|
||
row.SearchP95Ms = pctMs(searchLatencies, 95)
|
||
|
||
// --- 3. Impact p50/p95/p99 ----------------------------------
|
||
seeds := pickImpactSeeds(g, 10)
|
||
impactLatencies := make([]time.Duration, 0, len(seeds))
|
||
for _, id := range seeds {
|
||
t := time.Now()
|
||
_ = analysis.AnalyzeImpact(g, []string{id}, nil, nil)
|
||
impactLatencies = append(impactLatencies, time.Since(t))
|
||
}
|
||
if len(impactLatencies) > 0 {
|
||
row.ImpactP50Ms = pctMs(impactLatencies, 50)
|
||
row.ImpactP95Ms = pctMs(impactLatencies, 95)
|
||
row.ImpactP99Ms = pctMs(impactLatencies, 99)
|
||
}
|
||
|
||
// --- 4. Incremental reindex (touch 5 files) -----------------
|
||
touched := pickIncrementalFiles(g, 5)
|
||
for _, fp := range touched {
|
||
_ = touchFile(fp)
|
||
}
|
||
t1 := time.Now()
|
||
_, err = idx.Index(spec.Path)
|
||
row.IncrementalMs = msSince(t1)
|
||
if err != nil {
|
||
// Incremental failure isn't fatal — record it but keep the
|
||
// row's other measurements.
|
||
row.Error = fmt.Sprintf("incremental: %v", err)
|
||
}
|
||
|
||
// --- 5. DB size ---------------------------------------------
|
||
row.DBBytes = estimateDBSize(g)
|
||
|
||
// --- 6. Resident memory -------------------------------------
|
||
// Measured with the graph, indexer (search index) and query
|
||
// engine all live — the same objects a `gortex daemon` holds for
|
||
// this repo.
|
||
row.RSSBytes = residentBytes()
|
||
|
||
// --- 7. Budget gates ----------------------------------------
|
||
if budget.ImpactP95Ms > 0 && row.ImpactP95Ms > budget.ImpactP95Ms {
|
||
row.BudgetViolations++
|
||
}
|
||
if budget.SearchP95Ms > 0 && row.SearchP95Ms > budget.SearchP95Ms {
|
||
row.BudgetViolations++
|
||
}
|
||
if budget.ColdIndexMs > 0 && row.ColdIndexMs > budget.ColdIndexMs {
|
||
row.BudgetViolations++
|
||
}
|
||
|
||
return row
|
||
}
|
||
|
||
// --- helpers --------------------------------------------------------
|
||
|
||
// ensureCloned does `git clone --depth 1` of the repo into the cache
|
||
// dir if absent. Returns the local path. Idempotent — a present
|
||
// directory is reused (so re-runs don't re-clone).
|
||
func ensureCloned(spec repoSpec) (string, error) {
|
||
dest := filepath.Join(benchCacheDir, spec.Slug)
|
||
if st, err := os.Stat(dest); err == nil && st.IsDir() {
|
||
// Heuristic: a non-empty dir is assumed to be a previous
|
||
// clone. The user can `rm -rf` the slug to force a refresh.
|
||
if entries, _ := os.ReadDir(dest); len(entries) > 0 {
|
||
return dest, nil
|
||
}
|
||
}
|
||
if spec.URL == "" {
|
||
return "", fmt.Errorf("no URL configured for %q (and no local clone)", spec.Slug)
|
||
}
|
||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||
return "", err
|
||
}
|
||
args := []string{"clone", "--depth", "1", "--quiet"}
|
||
if spec.Branch != "" {
|
||
args = append(args, "--branch", spec.Branch)
|
||
}
|
||
args = append(args, spec.URL, dest)
|
||
cmd := exec.Command("git", args...)
|
||
cmd.Stdout = io.Discard
|
||
cmd.Stderr = os.Stderr
|
||
if err := cmd.Run(); err != nil {
|
||
return "", fmt.Errorf("git clone %s: %w", spec.URL, err)
|
||
}
|
||
return dest, nil
|
||
}
|
||
|
||
// sumLOC is a cheap LoC proxy: total Lines field across file-kind
|
||
// nodes. Not byte-exact (matches what the graph counted at index
|
||
// time), but stable across runs and that's what the bench needs.
|
||
func sumLOC(g *graph.Graph) int {
|
||
total := 0
|
||
for _, n := range g.AllNodes() {
|
||
if n == nil || n.Kind != graph.KindFile {
|
||
continue
|
||
}
|
||
// Graph stores per-file line counts under Meta["lines"] when
|
||
// available; fall back to 0 (matches what the indexer
|
||
// produced — no extrapolation).
|
||
if v, ok := n.Meta["lines"]; ok {
|
||
switch x := v.(type) {
|
||
case int:
|
||
total += x
|
||
case int64:
|
||
total += int(x)
|
||
case float64:
|
||
total += int(x)
|
||
}
|
||
}
|
||
}
|
||
return total
|
||
}
|
||
|
||
// countEdges sums the outgoing-edge count across every node — graph
|
||
// doesn't expose a top-level edge count directly.
|
||
func countEdges(g *graph.Graph) int {
|
||
total := 0
|
||
for _, n := range g.AllNodes() {
|
||
if n == nil {
|
||
continue
|
||
}
|
||
total += len(g.GetOutEdges(n.ID))
|
||
}
|
||
return total
|
||
}
|
||
|
||
// pickImpactSeeds returns up to n random function/method IDs from
|
||
// the graph — the impact bench targets symbols a real refactor
|
||
// would name. Deterministic by node-ID sort + crypto/rand pick so
|
||
// the same fixture produces consistent magnitudes (not byte-equal
|
||
// numbers, but the same shape).
|
||
func pickImpactSeeds(g *graph.Graph, n int) []string {
|
||
var pool []string
|
||
for _, node := range g.AllNodes() {
|
||
if node == nil {
|
||
continue
|
||
}
|
||
if node.Kind != graph.KindFunction && node.Kind != graph.KindMethod {
|
||
continue
|
||
}
|
||
pool = append(pool, node.ID)
|
||
}
|
||
sort.Strings(pool)
|
||
if len(pool) <= n {
|
||
return pool
|
||
}
|
||
out := make([]string, 0, n)
|
||
used := make(map[int]struct{}, n)
|
||
for len(out) < n {
|
||
idx, err := randInt(len(pool))
|
||
if err != nil {
|
||
// Random failed — fall back to deterministic stride.
|
||
idx = (len(out) * 7919) % len(pool)
|
||
}
|
||
if _, ok := used[idx]; ok {
|
||
continue
|
||
}
|
||
used[idx] = struct{}{}
|
||
out = append(out, pool[idx])
|
||
}
|
||
return out
|
||
}
|
||
|
||
// pickIncrementalFiles returns up to n file paths to touch for the
|
||
// incremental-reindex measurement.
|
||
func pickIncrementalFiles(g *graph.Graph, n int) []string {
|
||
var pool []string
|
||
for _, node := range g.AllNodes() {
|
||
if node == nil || node.Kind != graph.KindFile {
|
||
continue
|
||
}
|
||
if node.FilePath == "" {
|
||
continue
|
||
}
|
||
pool = append(pool, node.FilePath)
|
||
}
|
||
sort.Strings(pool)
|
||
if len(pool) <= n {
|
||
return pool
|
||
}
|
||
return pool[:n]
|
||
}
|
||
|
||
// touchFile bumps a file's mtime to "now" so the indexer treats it
|
||
// as changed on the next pass. Best-effort: missing files are
|
||
// silently skipped (a graph node referencing a deleted file is the
|
||
// indexer's problem, not ours).
|
||
func touchFile(path string) error {
|
||
now := time.Now()
|
||
err := os.Chtimes(path, now, now)
|
||
if errors.Is(err, os.ErrNotExist) {
|
||
return nil
|
||
}
|
||
return err
|
||
}
|
||
|
||
// estimateDBSize gob-encodes the graph into a discard writer and
|
||
// returns the byte count. This is the same on-disk shape the
|
||
// persistence layer uses, so the number reflects what a `gortex
|
||
// daemon` snapshot would actually weigh.
|
||
func estimateDBSize(g *graph.Graph) int64 {
|
||
counter := &countingWriter{}
|
||
enc := gob.NewEncoder(counter)
|
||
// graph.Graph isn't directly gob-encodable; estimate via node +
|
||
// edge counts × a calibrated per-node/per-edge byte cost taken
|
||
// from the daemon's snapshot fixtures (~250 bytes / node + ~64
|
||
// bytes / edge after gob+gzip compression). Calibration is
|
||
// stable enough for an order-of-magnitude column.
|
||
stats := struct {
|
||
Nodes int
|
||
Edges int
|
||
}{
|
||
Nodes: len(g.AllNodes()),
|
||
Edges: countEdges(g),
|
||
}
|
||
if err := enc.Encode(stats); err != nil {
|
||
return -1
|
||
}
|
||
return int64(stats.Nodes*250 + stats.Edges*64)
|
||
}
|
||
|
||
// countingWriter is an io.Writer that just counts bytes.
|
||
type countingWriter struct{ n int64 }
|
||
|
||
func (c *countingWriter) Write(p []byte) (int, error) {
|
||
c.n += int64(len(p))
|
||
return len(p), nil
|
||
}
|
||
|
||
// residentBytes returns the Go heap currently retained — the
|
||
// runtime.MemStats figure `gortex daemon status` reports as daemon
|
||
// memory. A forced GC first drops the previous repo's graph so the
|
||
// number reflects only this repo's retained graph + search index.
|
||
// True OS RSS adds a fixed Go-runtime overhead (stacks, mcache, code)
|
||
// on top that does not scale with repo size.
|
||
func residentBytes() int64 {
|
||
runtime.GC()
|
||
var m runtime.MemStats
|
||
runtime.ReadMemStats(&m)
|
||
return int64(m.HeapAlloc)
|
||
}
|
||
|
||
// pctMs returns the p-th percentile of a duration slice in
|
||
// milliseconds. Nearest-rank method: pct=50 on [a,b,c,d,e] → c.
|
||
// Returns 0 when the slice is empty.
|
||
func pctMs(xs []time.Duration, pct int) float64 {
|
||
if len(xs) == 0 {
|
||
return 0
|
||
}
|
||
sorted := make([]time.Duration, len(xs))
|
||
copy(sorted, xs)
|
||
slices.Sort(sorted)
|
||
idx := (pct * len(sorted)) / 100
|
||
if idx >= len(sorted) {
|
||
idx = len(sorted) - 1
|
||
}
|
||
return float64(sorted[idx].Microseconds()) / 1000.0
|
||
}
|
||
|
||
func msSince(t time.Time) float64 {
|
||
return float64(time.Since(t).Microseconds()) / 1000.0
|
||
}
|
||
|
||
// randInt returns a cryptographically-random int in [0, max). Used
|
||
// for impact-seed selection; deterministic-stride fallback at the
|
||
// caller covers crypto/rand failure (which should be ~impossible).
|
||
func randInt(max int) (int, error) {
|
||
if max <= 0 {
|
||
return 0, fmt.Errorf("randInt: max must be > 0")
|
||
}
|
||
var b [8]byte
|
||
if _, err := rand.Read(b[:]); err != nil {
|
||
return 0, err
|
||
}
|
||
return int(binary.BigEndian.Uint64(b[:]) % uint64(max)), nil
|
||
}
|
||
|
||
// --- repo presets ---------------------------------------------------
|
||
|
||
// defaultRepoSet returns the four reference repos the published
|
||
// perf table covers. Linux is opt-in via --include-linux because
|
||
// it's multi-GB and a fresh clone would tank CI runs.
|
||
func defaultRepoSet(includeLinux bool) []repoSpec {
|
||
repos := []repoSpec{
|
||
{Slug: "gin", URL: "https://github.com/gin-gonic/gin.git"},
|
||
{Slug: "nestjs", URL: "https://github.com/nestjs/nest.git"},
|
||
{Slug: "react", URL: "https://github.com/facebook/react.git"},
|
||
}
|
||
if includeLinux {
|
||
repos = append(repos, repoSpec{
|
||
Slug: "linux",
|
||
URL: "https://github.com/torvalds/linux.git",
|
||
Branch: "master",
|
||
})
|
||
}
|
||
return repos
|
||
}
|
||
|
||
// resolveRepoSpec parses a --repos token. Forms:
|
||
// - "gin" → preset by slug
|
||
// - "owner/repo" → github.com/owner/repo, slug=repo
|
||
// - "https://example/repo.git" → URL clone, slug=basename
|
||
// - "local:/abs/path" → local repo, slug=basename(path)
|
||
func resolveRepoSpec(token string) (repoSpec, error) {
|
||
token = strings.TrimSpace(token)
|
||
if token == "" {
|
||
return repoSpec{}, fmt.Errorf("empty repo token")
|
||
}
|
||
if path, ok := strings.CutPrefix(token, "local:"); ok {
|
||
if !filepath.IsAbs(path) {
|
||
abs, err := filepath.Abs(path)
|
||
if err != nil {
|
||
return repoSpec{}, err
|
||
}
|
||
path = abs
|
||
}
|
||
return repoSpec{
|
||
Slug: filepath.Base(path),
|
||
Path: path,
|
||
Local: true,
|
||
}, nil
|
||
}
|
||
if strings.HasPrefix(token, "https://") || strings.HasPrefix(token, "git@") {
|
||
slug := strings.TrimSuffix(filepath.Base(token), ".git")
|
||
return repoSpec{Slug: slug, URL: token}, nil
|
||
}
|
||
// "owner/repo" shorthand for github.com.
|
||
if strings.Contains(token, "/") {
|
||
parts := strings.Split(token, "/")
|
||
slug := parts[len(parts)-1]
|
||
return repoSpec{
|
||
Slug: slug,
|
||
URL: "https://github.com/" + token + ".git",
|
||
}, nil
|
||
}
|
||
// Bare slug — look up in presets.
|
||
for _, r := range defaultRepoSet(true) {
|
||
if r.Slug == token {
|
||
return r, nil
|
||
}
|
||
}
|
||
return repoSpec{}, fmt.Errorf("unknown repo %q (use owner/repo, https://..., or local:/path)", token)
|
||
}
|