golang.org/x/tools/gopls@v0.15.3/internal/test/integration/bench/bench_test.go (about) 1 // Copyright 2020 The Go Authors. All rights reserved. 2 // Use of this source code is governed by a BSD-style 3 // license that can be found in the LICENSE file. 4 5 package bench 6 7 import ( 8 "bytes" 9 "compress/gzip" 10 "context" 11 "flag" 12 "fmt" 13 "io" 14 "log" 15 "os" 16 "os/exec" 17 "path/filepath" 18 "strings" 19 "sync" 20 "testing" 21 "time" 22 23 "golang.org/x/tools/gopls/internal/cmd" 24 "golang.org/x/tools/gopls/internal/hooks" 25 "golang.org/x/tools/gopls/internal/protocol/command" 26 "golang.org/x/tools/gopls/internal/test/integration" 27 "golang.org/x/tools/gopls/internal/test/integration/fake" 28 "golang.org/x/tools/gopls/internal/util/bug" 29 "golang.org/x/tools/internal/event" 30 "golang.org/x/tools/internal/fakenet" 31 "golang.org/x/tools/internal/jsonrpc2" 32 "golang.org/x/tools/internal/jsonrpc2/servertest" 33 "golang.org/x/tools/internal/pprof" 34 "golang.org/x/tools/internal/tool" 35 ) 36 37 var ( 38 goplsPath = flag.String("gopls_path", "", "if set, use this gopls for testing; incompatible with -gopls_commit") 39 40 installGoplsOnce sync.Once // guards installing gopls at -gopls_commit 41 goplsCommit = flag.String("gopls_commit", "", "if set, install and use gopls at this commit for testing; incompatible with -gopls_path") 42 43 cpuProfile = flag.String("gopls_cpuprofile", "", "if set, the cpu profile file suffix; see \"Profiling\" in the package doc") 44 memProfile = flag.String("gopls_memprofile", "", "if set, the mem profile file suffix; see \"Profiling\" in the package doc") 45 allocProfile = flag.String("gopls_allocprofile", "", "if set, the alloc profile file suffix; see \"Profiling\" in the package doc") 46 trace = flag.String("gopls_trace", "", "if set, the trace file suffix; see \"Profiling\" in the package doc") 47 48 // If non-empty, tempDir is a temporary working dir that was created by this 49 // test suite. 50 makeTempDirOnce sync.Once // guards creation of the temp dir 51 tempDir string 52 ) 53 54 // if runAsGopls is "true", run the gopls command instead of the testing.M. 55 const runAsGopls = "_GOPLS_BENCH_RUN_AS_GOPLS" 56 57 func TestMain(m *testing.M) { 58 bug.PanicOnBugs = true 59 if os.Getenv(runAsGopls) == "true" { 60 tool.Main(context.Background(), cmd.New(hooks.Options), os.Args[1:]) 61 os.Exit(0) 62 } 63 event.SetExporter(nil) // don't log to stderr 64 code := m.Run() 65 if err := cleanup(); err != nil { 66 fmt.Fprintf(os.Stderr, "cleaning up after benchmarks: %v\n", err) 67 if code == 0 { 68 code = 1 69 } 70 } 71 os.Exit(code) 72 } 73 74 // getTempDir returns the temporary directory to use for benchmark files, 75 // creating it if necessary. 76 func getTempDir() string { 77 makeTempDirOnce.Do(func() { 78 var err error 79 tempDir, err = os.MkdirTemp("", "gopls-bench") 80 if err != nil { 81 log.Fatal(err) 82 } 83 }) 84 return tempDir 85 } 86 87 // shallowClone performs a shallow clone of repo into dir at the given 88 // 'commitish' ref (any commit reference understood by git). 89 // 90 // The directory dir must not already exist. 91 func shallowClone(dir, repo, commitish string) error { 92 if err := os.Mkdir(dir, 0750); err != nil { 93 return fmt.Errorf("creating dir for %s: %v", repo, err) 94 } 95 96 // Set a timeout for git fetch. If this proves flaky, it can be removed. 97 ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute) 98 defer cancel() 99 100 // Use a shallow fetch to download just the relevant commit. 101 shInit := fmt.Sprintf("git init && git fetch --depth=1 %q %q && git checkout FETCH_HEAD", repo, commitish) 102 initCmd := exec.CommandContext(ctx, "/bin/sh", "-c", shInit) 103 initCmd.Dir = dir 104 if output, err := initCmd.CombinedOutput(); err != nil { 105 return fmt.Errorf("checking out %s: %v\n%s", repo, err, output) 106 } 107 return nil 108 } 109 110 // connectEditor connects a fake editor session in the given dir, using the 111 // given editor config. 112 func connectEditor(dir string, config fake.EditorConfig, ts servertest.Connector) (*fake.Sandbox, *fake.Editor, *integration.Awaiter, error) { 113 s, err := fake.NewSandbox(&fake.SandboxConfig{ 114 Workdir: dir, 115 GOPROXY: "https://proxy.golang.org", 116 }) 117 if err != nil { 118 return nil, nil, nil, err 119 } 120 121 a := integration.NewAwaiter(s.Workdir) 122 const skipApplyEdits = false 123 editor, err := fake.NewEditor(s, config).Connect(context.Background(), ts, a.Hooks(), skipApplyEdits) 124 if err != nil { 125 return nil, nil, nil, err 126 } 127 128 return s, editor, a, nil 129 } 130 131 // newGoplsConnector returns a connector that connects to a new gopls process, 132 // executed with the provided arguments. 133 func newGoplsConnector(args []string) (servertest.Connector, error) { 134 if *goplsPath != "" && *goplsCommit != "" { 135 panic("can't set both -gopls_path and -gopls_commit") 136 } 137 var ( 138 goplsPath = *goplsPath 139 env []string 140 ) 141 if *goplsCommit != "" { 142 goplsPath = getInstalledGopls() 143 } 144 if goplsPath == "" { 145 var err error 146 goplsPath, err = os.Executable() 147 if err != nil { 148 return nil, err 149 } 150 env = []string{fmt.Sprintf("%s=true", runAsGopls)} 151 } 152 return &SidecarServer{ 153 goplsPath: goplsPath, 154 env: env, 155 args: args, 156 }, nil 157 } 158 159 // profileArgs returns additional command-line arguments to use when invoking 160 // gopls, to enable the user-requested profiles. 161 // 162 // If wantCPU is set, CPU profiling is enabled as well. Some tests may want to 163 // instrument profiling around specific critical sections of the benchmark, 164 // rather than the entire process. 165 // 166 // TODO(rfindley): like CPU, all of these would be better served by a custom 167 // command. Very rarely do we care about memory usage as the process exits: we 168 // care about specific points in time during the benchmark. mem and alloc 169 // should be snapshotted, and tracing should be bracketed around critical 170 // sections. 171 func profileArgs(name string, wantCPU bool) []string { 172 var args []string 173 if wantCPU && *cpuProfile != "" { 174 args = append(args, fmt.Sprintf("-profile.cpu=%s", qualifiedName(name, *cpuProfile))) 175 } 176 if *memProfile != "" { 177 args = append(args, fmt.Sprintf("-profile.mem=%s", qualifiedName(name, *memProfile))) 178 } 179 if *allocProfile != "" { 180 args = append(args, fmt.Sprintf("-profile.alloc=%s", qualifiedName(name, *allocProfile))) 181 } 182 if *trace != "" { 183 args = append(args, fmt.Sprintf("-profile.trace=%s", qualifiedName(name, *trace))) 184 } 185 return args 186 } 187 188 func qualifiedName(args ...string) string { 189 return strings.Join(args, ".") 190 } 191 192 // getInstalledGopls builds gopls at the given -gopls_commit, returning the 193 // path to the gopls binary. 194 func getInstalledGopls() string { 195 if *goplsCommit == "" { 196 panic("must provide -gopls_commit") 197 } 198 toolsDir := filepath.Join(getTempDir(), "gopls_build") 199 goplsPath := filepath.Join(toolsDir, "gopls", "gopls") 200 201 installGoplsOnce.Do(func() { 202 log.Printf("installing gopls: checking out x/tools@%s into %s\n", *goplsCommit, toolsDir) 203 if err := shallowClone(toolsDir, "https://go.googlesource.com/tools", *goplsCommit); err != nil { 204 log.Fatal(err) 205 } 206 207 log.Println("installing gopls: building...") 208 bld := exec.Command("go", "build", ".") 209 bld.Dir = filepath.Join(toolsDir, "gopls") 210 if output, err := bld.CombinedOutput(); err != nil { 211 log.Fatalf("building gopls: %v\n%s", err, output) 212 } 213 214 // Confirm that the resulting path now exists. 215 if _, err := os.Stat(goplsPath); err != nil { 216 log.Fatalf("os.Stat(%s): %v", goplsPath, err) 217 } 218 }) 219 return goplsPath 220 } 221 222 // A SidecarServer starts (and connects to) a separate gopls process at the 223 // given path. 224 type SidecarServer struct { 225 goplsPath string 226 env []string // additional environment bindings 227 args []string // command-line arguments 228 } 229 230 // Connect creates new io.Pipes and binds them to the underlying StreamServer. 231 // 232 // It implements the servertest.Connector interface. 233 func (s *SidecarServer) Connect(ctx context.Context) jsonrpc2.Conn { 234 // Note: don't use CommandContext here, as we want gopls to exit gracefully 235 // in order to write out profile data. 236 // 237 // We close the connection on context cancelation below. 238 cmd := exec.Command(s.goplsPath, s.args...) 239 240 stdin, err := cmd.StdinPipe() 241 if err != nil { 242 log.Fatal(err) 243 } 244 stdout, err := cmd.StdoutPipe() 245 if err != nil { 246 log.Fatal(err) 247 } 248 cmd.Stderr = os.Stderr 249 cmd.Env = append(os.Environ(), s.env...) 250 if err := cmd.Start(); err != nil { 251 log.Fatalf("starting gopls: %v", err) 252 } 253 254 go func() { 255 // If we don't log.Fatal here, benchmarks may hang indefinitely if gopls 256 // exits abnormally. 257 // 258 // TODO(rfindley): ideally we would shut down the connection gracefully, 259 // but that doesn't currently work. 260 if err := cmd.Wait(); err != nil { 261 log.Fatalf("gopls invocation failed with error: %v", err) 262 } 263 }() 264 265 clientStream := jsonrpc2.NewHeaderStream(fakenet.NewConn("stdio", stdout, stdin)) 266 clientConn := jsonrpc2.NewConn(clientStream) 267 268 go func() { 269 select { 270 case <-ctx.Done(): 271 clientConn.Close() 272 clientStream.Close() 273 case <-clientConn.Done(): 274 } 275 }() 276 277 return clientConn 278 } 279 280 // startProfileIfSupported checks to see if the remote gopls instance supports 281 // the start/stop profiling commands. If so, it starts profiling and returns a 282 // function that stops profiling and records the total CPU seconds sampled in the 283 // cpu_seconds benchmark metric. 284 // 285 // If the remote gopls instance does not support profiling commands, this 286 // function returns nil. 287 // 288 // If the supplied userSuffix is non-empty, the profile is written to 289 // <repo>.<userSuffix>, and not deleted when the benchmark exits. Otherwise, 290 // the profile is written to a temp file that is deleted after the cpu_seconds 291 // metric has been computed. 292 func startProfileIfSupported(b *testing.B, env *integration.Env, name string) func() { 293 if !env.Editor.HasCommand(command.StartProfile.ID()) { 294 return nil 295 } 296 b.StopTimer() 297 stopProfile := env.StartProfile() 298 b.StartTimer() 299 return func() { 300 b.StopTimer() 301 profFile := stopProfile() 302 totalCPU, err := totalCPUForProfile(profFile) 303 if err != nil { 304 b.Fatalf("reading profile: %v", err) 305 } 306 b.ReportMetric(totalCPU.Seconds()/float64(b.N), "cpu_seconds/op") 307 if *cpuProfile == "" { 308 // The user didn't request profiles, so delete it to clean up. 309 if err := os.Remove(profFile); err != nil { 310 b.Errorf("removing profile file: %v", err) 311 } 312 } else { 313 // NOTE: if this proves unreliable (due to e.g. EXDEV), we can fall back 314 // on Read+Write+Remove. 315 name := qualifiedName(name, *cpuProfile) 316 if err := os.Rename(profFile, name); err != nil { 317 b.Fatalf("renaming profile file: %v", err) 318 } 319 } 320 } 321 } 322 323 // totalCPUForProfile reads the pprof profile with the given file name, parses, 324 // and aggregates the total CPU sampled during the profile. 325 func totalCPUForProfile(filename string) (time.Duration, error) { 326 protoGz, err := os.ReadFile(filename) 327 if err != nil { 328 return 0, err 329 } 330 rd, err := gzip.NewReader(bytes.NewReader(protoGz)) 331 if err != nil { 332 return 0, fmt.Errorf("creating gzip reader for %s: %v", filename, err) 333 } 334 data, err := io.ReadAll(rd) 335 if err != nil { 336 return 0, fmt.Errorf("reading %s: %v", filename, err) 337 } 338 return pprof.TotalTime(data) 339 } 340 341 // closeBuffer stops the benchmark timer and closes the buffer with the given 342 // name. 343 // 344 // It may be used to clean up files opened in the shared environment during 345 // benchmarking. 346 func closeBuffer(b *testing.B, env *integration.Env, name string) { 347 b.StopTimer() 348 env.CloseBuffer(name) 349 env.AfterChange() 350 b.StartTimer() 351 }