Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

462 lines
14 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)
}