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

448 lines
13 KiB
Go

// Command daemon-latency measures per-tool MCP dispatch latency.
// Builds an in-process MCP server against a target corpus, fires N
// `CallTool` invocations per tool, reports p50 / p95 / p99 per tool
// and a top-line summary.
//
// What it measures: tool-handler latency end-to-end through the
// real MCP dispatch path (`Handler.CallTool` invoked via the same
// `server.MCPServer` the production stdio / HTTP / daemon
// front-ends use). What it does NOT measure: stdio framing,
// daemon socket dispatch, JSON-RPC envelope overhead. Those add a
// small constant per call (typically <1 ms on a warm pipe); the
// handler latency dominates the user-perceived response time.
//
// The bench therefore reflects "daemon-mode handler cost", which
// is the load-bearing number for the daemon-latency publication.
package main
import (
"context"
"encoding/csv"
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"slices"
"sort"
"strings"
"time"
"go.uber.org/zap"
"github.com/zzet/gortex/internal/config"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/indexer"
gortexmcp "github.com/zzet/gortex/internal/mcp"
"github.com/zzet/gortex/internal/parser"
"github.com/zzet/gortex/internal/parser/languages"
"github.com/zzet/gortex/internal/query"
internalserver "github.com/zzet/gortex/internal/server"
)
// toolCall is one synthetic MCP request the bench fires per
// iteration. ArgsFn lets the bench vary the args across iterations
// (e.g. different query strings) so the dispatch path isn't
// trivially memoised by an upstream cache.
type toolCall struct {
Tool string
ArgsFn func(iter int) map[string]any
WarmupN int
IterN int
// SkipIfMissing lets a tool opt out when its substrate isn't
// in the indexed graph (e.g. nothing to call get_callers on).
SkipIfMissing func(g *graph.Graph) bool
}
// result captures the per-tool aggregate the bench publishes.
type result struct {
Tool string `json:"tool"`
Iters int `json:"iters"`
P50Ms float64 `json:"p50_ms"`
P95Ms float64 `json:"p95_ms"`
P99Ms float64 `json:"p99_ms"`
MeanMs float64 `json:"mean_ms"`
MaxMs float64 `json:"max_ms"`
ErrorRate float64 `json:"error_rate"`
Skipped string `json:"skipped,omitempty"`
Started time.Time `json:"-"`
}
func main() {
repo := flag.String("repo", ".", "corpus to index for the bench")
iter := flag.Int("iter", 200, "iterations per tool (warm-up of iter/10 is added on top)")
out := flag.String("out", "", "primary output path (default stdout)")
jsonOut := flag.String("json", "", "companion JSON metrics output")
csvOut := flag.String("csv", "", "companion CSV output")
format := flag.String("format", "markdown", "markdown | json | csv")
tools := flag.String("tools", "", "comma-separated subset (default: all known tools)")
flag.Parse()
absRepo, err := filepath.Abs(*repo)
if err != nil {
die("repo path: %v", err)
}
fmt.Fprintf(os.Stderr, "[daemon-latency] indexing %s...\n", absRepo)
g, srv := buildInProcessServer(absRepo)
fmt.Fprintf(os.Stderr, "[daemon-latency] indexed %d nodes\n", len(g.AllNodes()))
handler := internalserver.NewHandler(srv.MCPServer(), g, "bench", zap.NewNop())
// Build the call set against the freshly indexed graph so each
// synthetic request has at least some structural validity (a
// real symbol id, an extant file path).
calls := defaultCalls(g, *iter)
if *tools != "" {
calls = filterCalls(calls, strings.Split(*tools, ","))
}
rows := make([]result, 0, len(calls))
for _, c := range calls {
if c.SkipIfMissing != nil && c.SkipIfMissing(g) {
rows = append(rows, result{Tool: c.Tool, Iters: 0, Skipped: "no eligible substrate in indexed graph"})
fmt.Fprintf(os.Stderr, "[daemon-latency] %-22s skipped (no substrate)\n", c.Tool)
continue
}
row := runOne(handler, c)
rows = append(rows, row)
fmt.Fprintf(os.Stderr, "[daemon-latency] %-22s p50=%6.2fms p95=%6.2fms p99=%6.2fms iters=%d errs=%.0f%%\n",
c.Tool, row.P50Ms, row.P95Ms, row.P99Ms, row.Iters, row.ErrorRate*100)
}
var primary []byte
switch strings.ToLower(*format) {
case "markdown", "md":
primary = []byte(renderMarkdown(rows, absRepo, g))
case "csv":
primary = []byte(renderCSV(rows))
case "json":
primary = mustMarshalJSON(rows)
default:
die("unknown --format %q", *format)
}
if err := writeOutput(*out, primary); err != nil {
die("write output: %v", err)
}
if *csvOut != "" {
if err := writeOutput(*csvOut, []byte(renderCSV(rows))); err != nil {
die("write csv: %v", err)
}
}
if *jsonOut != "" {
if err := writeOutput(*jsonOut, mustMarshalJSON(rows)); err != nil {
die("write json: %v", err)
}
}
}
// --- in-process server ---------------------------------------------
// buildInProcessServer wires the same Server the production stdio /
// daemon front-ends use, against a fresh in-process graph of repoRoot.
// Identical wiring to `cmd/gortex/eval_recall.go`'s indexed-server
// path so the bench reflects production handler arithmetic.
func buildInProcessServer(repoRoot string) (*graph.Graph, *gortexmcp.Server) {
g := graph.New()
reg := parser.NewRegistry()
languages.RegisterAll(reg)
cfg := config.Config{}
idx := indexer.New(g, reg, cfg.Index, zap.NewNop())
if _, err := idx.Index(repoRoot); err != nil {
die("index %s: %v", repoRoot, err)
}
eng := query.NewEngine(g)
eng.SetSearch(idx.Search())
srv := gortexmcp.NewServer(eng, g, idx, nil, zap.NewNop(), cfg.Guards.Rules)
srv.RunAnalysis()
return g, srv
}
// --- call set -------------------------------------------------------
// defaultCalls returns the canonical bench surface. We focus on
// tools agents actually call in production (the headline savings
// drivers) — covering both cheap (graph_stats) and expensive
// (smart_context) shapes so the published table shows the spread.
func defaultCalls(g *graph.Graph, iter int) []toolCall {
if iter <= 0 {
iter = 200
}
warmup := max(iter/10, 5)
// Pick representative symbol IDs / file paths from the indexed
// graph so the synthetic requests have real targets.
var sampleFnID, sampleFilePath string
for _, n := range g.AllNodes() {
if n == nil {
continue
}
if sampleFnID == "" && (n.Kind == graph.KindFunction || n.Kind == graph.KindMethod) {
sampleFnID = n.ID
}
if sampleFilePath == "" && n.Kind == graph.KindFile && n.FilePath != "" {
sampleFilePath = n.FilePath
}
if sampleFnID != "" && sampleFilePath != "" {
break
}
}
queries := []string{
"validateToken", "Indexer", "search", "newServer",
"handler", "config", "graph", "rerank", "query", "savings",
}
return []toolCall{
{
Tool: "graph_stats",
ArgsFn: func(_ int) map[string]any { return map[string]any{} },
WarmupN: warmup, IterN: iter,
},
{
Tool: "search_symbols",
ArgsFn: func(i int) map[string]any {
return map[string]any{
"query": queries[i%len(queries)],
"limit": float64(20),
}
},
WarmupN: warmup, IterN: iter,
},
{
Tool: "get_symbol_source",
ArgsFn: func(_ int) map[string]any {
return map[string]any{"id": sampleFnID}
},
WarmupN: warmup, IterN: iter,
SkipIfMissing: func(g *graph.Graph) bool { return sampleFnID == "" },
},
{
Tool: "get_callers",
ArgsFn: func(_ int) map[string]any {
return map[string]any{"id": sampleFnID, "limit": float64(50)}
},
WarmupN: warmup, IterN: iter,
SkipIfMissing: func(g *graph.Graph) bool { return sampleFnID == "" },
},
{
Tool: "find_usages",
ArgsFn: func(_ int) map[string]any {
return map[string]any{"id": sampleFnID}
},
WarmupN: warmup, IterN: iter,
SkipIfMissing: func(g *graph.Graph) bool { return sampleFnID == "" },
},
{
Tool: "get_file_summary",
ArgsFn: func(_ int) map[string]any {
return map[string]any{"path": sampleFilePath}
},
WarmupN: warmup, IterN: iter,
SkipIfMissing: func(g *graph.Graph) bool { return sampleFilePath == "" },
},
{
Tool: "smart_context",
ArgsFn: func(i int) map[string]any {
return map[string]any{"task": "find " + queries[i%len(queries)]}
},
// smart_context is heavy — fewer iterations so the
// whole bench stays reasonable. Still produces a
// credible p50/p95 with 30-50 samples.
WarmupN: 3, IterN: iter / 5,
},
{
Tool: "get_repo_outline",
ArgsFn: func(_ int) map[string]any {
return map[string]any{}
},
WarmupN: warmup, IterN: iter,
},
}
}
func filterCalls(calls []toolCall, names []string) []toolCall {
want := map[string]bool{}
for _, n := range names {
want[strings.TrimSpace(n)] = true
}
out := make([]toolCall, 0, len(calls))
for _, c := range calls {
if want[c.Tool] {
out = append(out, c)
}
}
return out
}
// --- run loop -------------------------------------------------------
func runOne(handler *internalserver.Handler, c toolCall) result {
ctx := context.Background()
// Warm-up: prime any lazy initialisation in the handler /
// graph so the measured iterations are steady-state.
for i := range c.WarmupN {
_, _ = handler.CallToolStrict(ctx, c.Tool, c.ArgsFn(i))
}
latencies := make([]time.Duration, 0, c.IterN)
errors := 0
for i := range c.IterN {
t := time.Now()
_, err := handler.CallToolStrict(ctx, c.Tool, c.ArgsFn(i))
latencies = append(latencies, time.Since(t))
if err != nil {
errors++
}
}
r := result{
Tool: c.Tool,
Iters: c.IterN,
}
if len(latencies) > 0 {
r.P50Ms = pctMs(latencies, 50)
r.P95Ms = pctMs(latencies, 95)
r.P99Ms = pctMs(latencies, 99)
r.MaxMs = pctMs(latencies, 100)
r.MeanMs = meanMs(latencies)
r.ErrorRate = float64(errors) / float64(len(latencies))
}
return r
}
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 meanMs(xs []time.Duration) float64 {
if len(xs) == 0 {
return 0
}
var sum time.Duration
for _, x := range xs {
sum += x
}
avg := sum / time.Duration(len(xs))
return float64(avg.Microseconds()) / 1000.0
}
// --- rendering ------------------------------------------------------
func renderMarkdown(rows []result, repoRoot string, g *graph.Graph) string {
var b strings.Builder
fmt.Fprintln(&b, "# Daemon-mode MCP-tool latency")
fmt.Fprintln(&b)
fmt.Fprintf(&b, "_Corpus: `%s` (%d nodes). In-process handler dispatch — measures `Handler.CallToolStrict` end-to-end. Daemon socket overhead adds typically <1 ms on a warm pipe; the handler latency below dominates user-perceived response time._\n",
repoRoot, len(g.AllNodes()))
fmt.Fprintln(&b)
fmt.Fprintln(&b, "| tool | iters | p50 | p95 | p99 | mean | max | errors |")
fmt.Fprintln(&b, "|------|------:|----:|----:|----:|-----:|----:|-------:|")
for _, r := range rows {
if r.Skipped != "" {
fmt.Fprintf(&b, "| %s | — | — | — | — | — | — | skipped: %s |\n", r.Tool, r.Skipped)
continue
}
fmt.Fprintf(&b, "| %s | %d | %s | %s | %s | %s | %s | %.0f%% |\n",
r.Tool, r.Iters,
fmtMs(r.P50Ms), fmtMs(r.P95Ms), fmtMs(r.P99Ms),
fmtMs(r.MeanMs), fmtMs(r.MaxMs),
r.ErrorRate*100,
)
}
fmt.Fprintln(&b)
fmt.Fprintln(&b, summary(rows))
return b.String()
}
func renderCSV(rows []result) string {
var b strings.Builder
w := csv.NewWriter(&b)
_ = w.Write([]string{"tool", "iters", "p50_ms", "p95_ms", "p99_ms", "mean_ms", "max_ms", "error_rate", "skipped"})
for _, r := range rows {
_ = w.Write([]string{
r.Tool,
fmt.Sprintf("%d", r.Iters),
fmt.Sprintf("%.3f", r.P50Ms),
fmt.Sprintf("%.3f", r.P95Ms),
fmt.Sprintf("%.3f", r.P99Ms),
fmt.Sprintf("%.3f", r.MeanMs),
fmt.Sprintf("%.3f", r.MaxMs),
fmt.Sprintf("%.4f", r.ErrorRate),
r.Skipped,
})
}
w.Flush()
return b.String()
}
func mustMarshalJSON(rows []result) []byte {
b, err := json.MarshalIndent(rows, "", " ")
if err != nil {
die("marshal json: %v", err)
}
return append(b, '\n')
}
func summary(rows []result) string {
ran := 0
var p95s, p99s []float64
for _, r := range rows {
if r.Skipped != "" {
continue
}
ran++
p95s = append(p95s, r.P95Ms)
p99s = append(p99s, r.P99Ms)
}
if ran == 0 {
return "_no tools ran (all skipped)_"
}
sort.Float64s(p95s)
sort.Float64s(p99s)
medianP95 := p95s[len(p95s)/2]
medianP99 := p99s[len(p99s)/2]
return fmt.Sprintf("**Summary:** %d/%d tools ran. Median p95 across tools: %s. Median p99: %s.",
ran, len(rows), fmtMs(medianP95), fmtMs(medianP99))
}
func fmtMs(v float64) string {
switch {
case v == 0:
return "—"
case v < 1.0:
return fmt.Sprintf("%.2fms", v)
case v < 1000:
return fmt.Sprintf("%.1fms", v)
default:
return fmt.Sprintf("%.2fs", v/1000.0)
}
}
// --- helpers --------------------------------------------------------
func die(format string, args ...any) {
fmt.Fprintf(os.Stderr, "daemon-latency: "+format+"\n", args...)
os.Exit(1)
}
func writeOutput(path string, body []byte) error {
if path == "" {
_, err := os.Stdout.Write(body)
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
return os.WriteFile(path, body, 0o644)
}