golangci-lint/test/bench/bench_test.go
dependabot[bot] a62f1f13aa
build(deps): bump github.com/moricho/tparallel from 0.3.1 to 0.3.2 (#4849)
Co-authored-by: Fernandez Ludovic <ldez@users.noreply.github.com>
2024-06-26 09:28:50 +02:00

412 lines
7.8 KiB
Go

package bench
import (
"bytes"
"errors"
"fmt"
"go/build"
"log"
"os"
"os/exec"
"path/filepath"
"slices"
"strconv"
"strings"
"testing"
"time"
gops "github.com/mitchellh/go-ps"
"github.com/shirou/gopsutil/v3/process"
"github.com/stretchr/testify/require"
"github.com/golangci/golangci-lint/pkg/config"
"github.com/golangci/golangci-lint/pkg/lint/lintersdb"
)
const binName = "golangci-lint-bench"
type repo struct {
name string
dir string
}
type metrics struct {
peakMemMB int
duration time.Duration
}
func Benchmark_linters(b *testing.B) {
savedWD, err := os.Getwd()
require.NoError(b, err)
b.Cleanup(func() {
// Restore WD to avoid side effects when during all the benchmarks.
err = os.Chdir(savedWD)
require.NoError(b, err)
})
installGolangCILint(b)
repos := getAllRepositories(b)
linters := getLinterNames(b, false)
for _, linter := range linters {
b.Run(linter, func(b *testing.B) {
args := []string{
"run",
"--issues-exit-code=0",
"--timeout=30m",
"--no-config",
"--disable-all",
"--enable", linter,
}
for _, repo := range repos {
b.Run(repo.name, func(b *testing.B) {
_ = exec.Command(binName, "cache", "clean").Run()
err = os.Chdir(repo.dir)
require.NoErrorf(b, err, "can't chdir to %s", repo.dir)
lc := countGoLines(b)
b.ResetTimer()
result := launch(b, run, args)
b.Logf("%s on %s (%d kLoC): time: %s, memory: %dMB",
linter, repo.name, lc/1000, result.duration, result.peakMemMB)
})
}
})
}
}
func Benchmark_golangciLint(b *testing.B) {
savedWD, err := os.Getwd()
require.NoError(b, err)
b.Cleanup(func() {
// Restore WD to avoid side effects when during all the benchmarks.
err = os.Chdir(savedWD)
require.NoError(b, err)
})
installGolangCILint(b)
_ = exec.Command(binName, "cache", "clean").Run()
cases := getAllRepositories(b)
args := []string{
"run",
"--issues-exit-code=0",
"--timeout=30m",
"--no-config",
"--disable-all",
}
linters := getLinterNames(b, false)
for _, linter := range linters {
args = append(args, "--enable", linter)
}
for _, c := range cases {
b.Run(c.name, func(b *testing.B) {
err = os.Chdir(c.dir)
require.NoErrorf(b, err, "can't chdir to %s", c.dir)
lc := countGoLines(b)
b.ResetTimer()
result := launch(b, run, args)
b.Logf("%s (%d kLoC): time: %s, memory: %dMB",
c.name, lc/1000, result.duration, result.peakMemMB)
})
}
}
func getAllRepositories(tb testing.TB) []repo {
tb.Helper()
benchRoot := os.Getenv("GCL_BENCH_ROOT")
if benchRoot == "" {
benchRoot = tb.TempDir()
}
return []repo{
{
name: "golangci/golangci-lint",
dir: cloneGithubProject(tb, benchRoot, "golangci", "golangci-lint"),
},
{
name: "goreleaser/goreleaser",
dir: cloneGithubProject(tb, benchRoot, "goreleaser", "goreleaser"),
},
{
name: "gohugoio/hugo",
dir: cloneGithubProject(tb, benchRoot, "gohugoio", "hugo"),
},
{
name: "pact-foundation/pact-go", // CGO inside
dir: cloneGithubProject(tb, benchRoot, "pact-foundation", "pact-go"),
},
{
name: "kubernetes/kubernetes",
dir: cloneGithubProject(tb, benchRoot, "kubernetes", "kubernetes"),
},
{
name: "moby/buildkit",
dir: cloneGithubProject(tb, benchRoot, "moby", "buildkit"),
},
{
name: "go source code",
dir: filepath.Join(build.Default.GOROOT, "src"),
},
}
}
func cloneGithubProject(tb testing.TB, benchRoot, owner, name string) string {
tb.Helper()
dir := filepath.Join(benchRoot, owner, name)
if _, err := os.Stat(dir); os.IsNotExist(err) {
repo := fmt.Sprintf("https://github.com/%s/%s.git", owner, name)
err = exec.Command("git", "clone", "--depth", "1", "--single-branch", repo, dir).Run()
if err != nil {
tb.Fatalf("can't git clone %s/%s: %s", owner, name, err)
}
}
return dir
}
func launch(tb testing.TB, run func(testing.TB, string, []string), args []string) *metrics {
tb.Helper()
doneCh := make(chan struct{})
peakMemCh := trackPeakMemoryUsage(tb, doneCh)
startedAt := time.Now()
run(tb, binName, args)
duration := time.Since(startedAt)
close(doneCh)
peakUsedMemMB := <-peakMemCh
return &metrics{
peakMemMB: peakUsedMemMB,
duration: duration,
}
}
func run(tb testing.TB, name string, args []string) {
tb.Helper()
cmd := exec.Command(name, args...)
if os.Getenv("PRINT_CMD") == "1" {
log.Print(strings.Join(cmd.Args, " "))
}
out, err := cmd.CombinedOutput()
if err != nil {
tb.Fatalf("can't run golangci-lint: %s, %s", err, out)
}
if os.Getenv("PRINT_OUTPUT") == "1" {
tb.Log(string(out))
}
}
func countGoLines(tb testing.TB) int {
tb.Helper()
cmd := exec.Command("bash", "-c", `find . -type f -name "*.go" | grep -F -v vendor | xargs wc -l | tail -1`)
out, err := cmd.CombinedOutput()
if err != nil {
tb.Log(string(out))
tb.Fatalf("can't run go lines counter: %s", err)
}
parts := bytes.Split(bytes.TrimSpace(out), []byte(" "))
n, err := strconv.Atoi(string(parts[0]))
if err != nil {
tb.Log(string(out))
tb.Fatalf("can't parse go lines count: %s", err)
}
return n
}
func getLinterMemoryMB(tb testing.TB) (int, error) {
tb.Helper()
processes, err := gops.Processes()
if err != nil {
tb.Fatalf("can't get processes: %s", err)
}
var progPID int
for _, p := range processes {
// The executable name can be shorter than the binary name.
if strings.HasPrefix(binName, p.Executable()) {
progPID = p.Pid()
break
}
}
if progPID == 0 {
return 0, errors.New("no process")
}
allProgPIDs := []int{progPID}
for _, p := range processes {
if p.PPid() == progPID {
allProgPIDs = append(allProgPIDs, p.Pid())
}
}
var totalProgMemBytes uint64
for _, pid := range allProgPIDs {
p, err := process.NewProcess(int32(pid))
if err != nil {
continue // subprocess could die
}
mi, err := p.MemoryInfo()
if err != nil {
continue
}
totalProgMemBytes += mi.RSS
}
return int(totalProgMemBytes / 1024 / 1024), nil
}
func trackPeakMemoryUsage(tb testing.TB, doneCh <-chan struct{}) chan int {
tb.Helper()
resCh := make(chan int)
go func() {
var peakUsedMemMB int
t := time.NewTicker(time.Millisecond * 5)
defer t.Stop()
for {
select {
case <-doneCh:
resCh <- peakUsedMemMB
close(resCh)
return
case <-t.C:
}
m, err := getLinterMemoryMB(tb)
if err != nil {
continue
}
if m > peakUsedMemMB {
peakUsedMemMB = m
}
}
}()
return resCh
}
func installGolangCILint(tb testing.TB) {
tb.Helper()
if os.Getenv("GOLANGCI_LINT_INSTALLED") == "true" {
return
}
parentPath := findMakefile(tb)
cmd := exec.Command("make", "-C", parentPath, "build")
output, err := cmd.CombinedOutput()
if err != nil {
tb.Log(string(output))
}
require.NoError(tb, err, "can't build golangci-lint")
gclBench := filepath.Join(build.Default.GOPATH, "bin", binName)
_ = os.Remove(gclBench)
abs, err := filepath.Abs(filepath.Join(parentPath, "golangci-lint"))
require.NoError(tb, err)
err = os.Symlink(abs, gclBench)
tb.Cleanup(func() {
_ = os.Remove(gclBench)
})
require.NoError(tb, err, "can't create symlink: %s", gclBench)
}
func findMakefile(tb testing.TB) string {
tb.Helper()
wd, err := os.Getwd()
require.NoError(tb, err)
for wd != "/" {
_, err = os.Stat(filepath.Join(wd, "Makefile"))
if err != nil {
wd = filepath.Dir(wd)
continue
}
break
}
here, _ := os.Getwd()
rel, err := filepath.Rel(here, wd)
require.NoError(tb, err)
return rel
}
func getLinterNames(tb testing.TB, fastOnly bool) []string {
tb.Helper()
// add linter names here if needed.
var excluded []string
linters, err := lintersdb.NewLinterBuilder().Build(config.NewDefault())
require.NoError(tb, err)
var names []string
for _, lc := range linters {
if lc.IsDeprecated() {
continue
}
if fastOnly && lc.IsSlowLinter() {
continue
}
if slices.Contains(excluded, lc.Name()) {
continue
}
names = append(names, lc.Name())
}
return names
}