| // Copyright 2017 The Go Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style |
| // license that can be found in the LICENSE file. |
| |
| package test |
| |
| import ( |
| "bufio" |
| "bytes" |
| "fmt" |
| "internal/profile" |
| "internal/testenv" |
| "io" |
| "os" |
| "path/filepath" |
| "regexp" |
| "strings" |
| "testing" |
| ) |
| |
| const profFile = "inline_hot.pprof" |
| const preProfFile = "inline_hot.pprof.node_map" |
| |
| func buildPGOInliningTest(t *testing.T, dir string, gcflag string) []byte { |
| const pkg = "example.com/pgo/inline" |
| |
| // Add a go.mod so we have a consistent symbol names in this temp dir. |
| goMod := fmt.Sprintf(`module %s |
| go 1.19 |
| `, pkg) |
| if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte(goMod), 0644); err != nil { |
| t.Fatalf("error writing go.mod: %v", err) |
| } |
| |
| exe := filepath.Join(dir, "test.exe") |
| args := []string{"test", "-c", "-o", exe, "-gcflags=" + gcflag} |
| cmd := testenv.Command(t, testenv.GoToolPath(t), args...) |
| cmd.Dir = dir |
| cmd = testenv.CleanCmdEnv(cmd) |
| t.Log(cmd) |
| out, err := cmd.CombinedOutput() |
| if err != nil { |
| t.Fatalf("build failed: %v, output:\n%s", err, out) |
| } |
| return out |
| } |
| |
| // testPGOIntendedInlining tests that specific functions are inlined. |
| func testPGOIntendedInlining(t *testing.T, dir string, profFile string) { |
| testenv.MustHaveGoRun(t) |
| t.Parallel() |
| |
| const pkg = "example.com/pgo/inline" |
| |
| want := []string{ |
| "(*BS).NS", |
| } |
| |
| // The functions which are not expected to be inlined are as follows. |
| wantNot := []string{ |
| // The calling edge main->A is hot and the cost of A is large |
| // than inlineHotCalleeMaxBudget. |
| "A", |
| // The calling edge BenchmarkA" -> benchmarkB is cold and the |
| // cost of A is large than inlineMaxBudget. |
| "benchmarkB", |
| } |
| |
| must := map[string]bool{ |
| "(*BS).NS": true, |
| } |
| |
| notInlinedReason := make(map[string]string) |
| for _, fname := range want { |
| fullName := pkg + "." + fname |
| if _, ok := notInlinedReason[fullName]; ok { |
| t.Errorf("duplicate func: %s", fullName) |
| } |
| notInlinedReason[fullName] = "unknown reason" |
| } |
| |
| // If the compiler emit "cannot inline for function A", the entry A |
| // in expectedNotInlinedList will be removed. |
| expectedNotInlinedList := make(map[string]struct{}) |
| for _, fname := range wantNot { |
| fullName := pkg + "." + fname |
| expectedNotInlinedList[fullName] = struct{}{} |
| } |
| |
| // Build the test with the profile. Use a smaller threshold to test. |
| // TODO: maybe adjust the test to work with default threshold. |
| gcflag := fmt.Sprintf("-m -m -pgoprofile=%s -d=pgoinlinebudget=160,pgoinlinecdfthreshold=90", profFile) |
| out := buildPGOInliningTest(t, dir, gcflag) |
| |
| scanner := bufio.NewScanner(bytes.NewReader(out)) |
| curPkg := "" |
| canInline := regexp.MustCompile(`: can inline ([^ ]*)`) |
| haveInlined := regexp.MustCompile(`: inlining call to ([^ ]*)`) |
| cannotInline := regexp.MustCompile(`: cannot inline ([^ ]*): (.*)`) |
| for scanner.Scan() { |
| line := scanner.Text() |
| t.Logf("child: %s", line) |
| if strings.HasPrefix(line, "# ") { |
| curPkg = line[2:] |
| splits := strings.Split(curPkg, " ") |
| curPkg = splits[0] |
| continue |
| } |
| if m := haveInlined.FindStringSubmatch(line); m != nil { |
| fname := m[1] |
| delete(notInlinedReason, curPkg+"."+fname) |
| continue |
| } |
| if m := canInline.FindStringSubmatch(line); m != nil { |
| fname := m[1] |
| fullname := curPkg + "." + fname |
| // If function must be inlined somewhere, being inlinable is not enough |
| if _, ok := must[fullname]; !ok { |
| delete(notInlinedReason, fullname) |
| continue |
| } |
| } |
| if m := cannotInline.FindStringSubmatch(line); m != nil { |
| fname, reason := m[1], m[2] |
| fullName := curPkg + "." + fname |
| if _, ok := notInlinedReason[fullName]; ok { |
| // cmd/compile gave us a reason why |
| notInlinedReason[fullName] = reason |
| } |
| delete(expectedNotInlinedList, fullName) |
| continue |
| } |
| } |
| if err := scanner.Err(); err != nil { |
| t.Fatalf("error reading output: %v", err) |
| } |
| for fullName, reason := range notInlinedReason { |
| t.Errorf("%s was not inlined: %s", fullName, reason) |
| } |
| |
| // If the list expectedNotInlinedList is not empty, it indicates |
| // the functions in the expectedNotInlinedList are marked with caninline. |
| for fullName, _ := range expectedNotInlinedList { |
| t.Errorf("%s was expected not inlined", fullName) |
| } |
| } |
| |
| // TestPGOIntendedInlining tests that specific functions are inlined when PGO |
| // is applied to the exact source that was profiled. |
| func TestPGOIntendedInlining(t *testing.T) { |
| wd, err := os.Getwd() |
| if err != nil { |
| t.Fatalf("error getting wd: %v", err) |
| } |
| srcDir := filepath.Join(wd, "testdata/pgo/inline") |
| |
| // Copy the module to a scratch location so we can add a go.mod. |
| dir := t.TempDir() |
| |
| for _, file := range []string{"inline_hot.go", "inline_hot_test.go", profFile} { |
| if err := copyFile(filepath.Join(dir, file), filepath.Join(srcDir, file)); err != nil { |
| t.Fatalf("error copying %s: %v", file, err) |
| } |
| } |
| |
| testPGOIntendedInlining(t, dir, profFile) |
| } |
| |
| // TestPGOPreprocessInlining tests that specific functions are inlined when PGO |
| // is applied to the exact source that was profiled. |
| func TestPGOPreprocessInlining(t *testing.T) { |
| wd, err := os.Getwd() |
| if err != nil { |
| t.Fatalf("error getting wd: %v", err) |
| } |
| srcDir := filepath.Join(wd, "testdata/pgo/inline") |
| |
| // Copy the module to a scratch location so we can add a go.mod. |
| dir := t.TempDir() |
| |
| for _, file := range []string{"inline_hot.go", "inline_hot_test.go", preProfFile} { |
| if err := copyFile(filepath.Join(dir, file), filepath.Join(srcDir, file)); err != nil { |
| t.Fatalf("error copying %s: %v", file, err) |
| } |
| } |
| |
| testPGOIntendedInlining(t, dir, preProfFile) |
| } |
| |
| // TestPGOIntendedInliningShiftedLines tests that specific functions are inlined when PGO |
| // is applied to the modified source. |
| func TestPGOIntendedInliningShiftedLines(t *testing.T) { |
| wd, err := os.Getwd() |
| if err != nil { |
| t.Fatalf("error getting wd: %v", err) |
| } |
| srcDir := filepath.Join(wd, "testdata/pgo/inline") |
| |
| // Copy the module to a scratch location so we can modify the source. |
| dir := t.TempDir() |
| |
| // Copy most of the files unmodified. |
| for _, file := range []string{"inline_hot_test.go", profFile} { |
| if err := copyFile(filepath.Join(dir, file), filepath.Join(srcDir, file)); err != nil { |
| t.Fatalf("error copying %s : %v", file, err) |
| } |
| } |
| |
| // Add some comments to the top of inline_hot.go. This adjusts the line |
| // numbers of all of the functions without changing the semantics. |
| src, err := os.Open(filepath.Join(srcDir, "inline_hot.go")) |
| if err != nil { |
| t.Fatalf("error opening src inline_hot.go: %v", err) |
| } |
| defer src.Close() |
| |
| dst, err := os.Create(filepath.Join(dir, "inline_hot.go")) |
| if err != nil { |
| t.Fatalf("error creating dst inline_hot.go: %v", err) |
| } |
| defer dst.Close() |
| |
| if _, err := io.WriteString(dst, `// Autogenerated |
| // Lines |
| `); err != nil { |
| t.Fatalf("error writing comments to dst: %v", err) |
| } |
| |
| if _, err := io.Copy(dst, src); err != nil { |
| t.Fatalf("error copying inline_hot.go: %v", err) |
| } |
| |
| dst.Close() |
| |
| testPGOIntendedInlining(t, dir, profFile) |
| } |
| |
| // TestPGOSingleIndex tests that the sample index can not be 1 and compilation |
| // will not fail. All it should care about is that the sample type is either |
| // CPU nanoseconds or samples count, whichever it finds first. |
| func TestPGOSingleIndex(t *testing.T) { |
| for _, tc := range []struct { |
| originalIndex int |
| }{{ |
| // The `testdata/pgo/inline/inline_hot.pprof` file is a standard CPU |
| // profile as the runtime would generate. The 0 index contains the |
| // value-type samples and value-unit count. The 1 index contains the |
| // value-type cpu and value-unit nanoseconds. These tests ensure that |
| // the compiler can work with profiles that only have a single index, |
| // but are either samples count or CPU nanoseconds. |
| originalIndex: 0, |
| }, { |
| originalIndex: 1, |
| }} { |
| t.Run(fmt.Sprintf("originalIndex=%d", tc.originalIndex), func(t *testing.T) { |
| wd, err := os.Getwd() |
| if err != nil { |
| t.Fatalf("error getting wd: %v", err) |
| } |
| srcDir := filepath.Join(wd, "testdata/pgo/inline") |
| |
| // Copy the module to a scratch location so we can add a go.mod. |
| dir := t.TempDir() |
| |
| originalPprofFile, err := os.Open(filepath.Join(srcDir, profFile)) |
| if err != nil { |
| t.Fatalf("error opening %v: %v", profFile, err) |
| } |
| defer originalPprofFile.Close() |
| |
| p, err := profile.Parse(originalPprofFile) |
| if err != nil { |
| t.Fatalf("error parsing %v: %v", profFile, err) |
| } |
| |
| // Move the samples count value-type to the 0 index. |
| p.SampleType = []*profile.ValueType{p.SampleType[tc.originalIndex]} |
| |
| // Ensure we only have a single set of sample values. |
| for _, s := range p.Sample { |
| s.Value = []int64{s.Value[tc.originalIndex]} |
| } |
| |
| modifiedPprofFile, err := os.Create(filepath.Join(dir, profFile)) |
| if err != nil { |
| t.Fatalf("error creating %v: %v", profFile, err) |
| } |
| defer modifiedPprofFile.Close() |
| |
| if err := p.Write(modifiedPprofFile); err != nil { |
| t.Fatalf("error writing %v: %v", profFile, err) |
| } |
| |
| for _, file := range []string{"inline_hot.go", "inline_hot_test.go"} { |
| if err := copyFile(filepath.Join(dir, file), filepath.Join(srcDir, file)); err != nil { |
| t.Fatalf("error copying %s: %v", file, err) |
| } |
| } |
| |
| testPGOIntendedInlining(t, dir, profFile) |
| }) |
| } |
| } |
| |
| func copyFile(dst, src string) error { |
| s, err := os.Open(src) |
| if err != nil { |
| return err |
| } |
| defer s.Close() |
| |
| d, err := os.Create(dst) |
| if err != nil { |
| return err |
| } |
| defer d.Close() |
| |
| _, err = io.Copy(d, s) |
| return err |
| } |
| |
| // TestPGOHash tests that PGO optimization decisions can be selected by pgohash. |
| func TestPGOHash(t *testing.T) { |
| testenv.MustHaveGoRun(t) |
| t.Parallel() |
| |
| const pkg = "example.com/pgo/inline" |
| |
| wd, err := os.Getwd() |
| if err != nil { |
| t.Fatalf("error getting wd: %v", err) |
| } |
| srcDir := filepath.Join(wd, "testdata/pgo/inline") |
| |
| // Copy the module to a scratch location so we can add a go.mod. |
| dir := t.TempDir() |
| |
| for _, file := range []string{"inline_hot.go", "inline_hot_test.go", profFile} { |
| if err := copyFile(filepath.Join(dir, file), filepath.Join(srcDir, file)); err != nil { |
| t.Fatalf("error copying %s: %v", file, err) |
| } |
| } |
| |
| pprof := filepath.Join(dir, profFile) |
| // build with -trimpath so the source location (thus the hash) |
| // does not depend on the temporary directory path. |
| gcflag0 := fmt.Sprintf("-pgoprofile=%s -trimpath %s=>%s -d=pgoinlinebudget=160,pgoinlinecdfthreshold=90,pgodebug=1", pprof, dir, pkg) |
| |
| // Check that a hash match allows PGO inlining. |
| const srcPos = "example.com/pgo/inline/inline_hot.go:81:19" |
| const hashMatch = "pgohash triggered " + srcPos + " (inline)" |
| pgoDebugRE := regexp.MustCompile(`hot-budget check allows inlining for call .* at ` + strings.ReplaceAll(srcPos, ".", "\\.")) |
| hash := "v1" // 1 matches srcPos, v for verbose (print source location) |
| gcflag := gcflag0 + ",pgohash=" + hash |
| out := buildPGOInliningTest(t, dir, gcflag) |
| if !bytes.Contains(out, []byte(hashMatch)) || !pgoDebugRE.Match(out) { |
| t.Errorf("output does not contain expected source line, out:\n%s", out) |
| } |
| |
| // Check that a hash mismatch turns off PGO inlining. |
| hash = "v0" // 0 should not match srcPos |
| gcflag = gcflag0 + ",pgohash=" + hash |
| out = buildPGOInliningTest(t, dir, gcflag) |
| if bytes.Contains(out, []byte(hashMatch)) || pgoDebugRE.Match(out) { |
| t.Errorf("output contains unexpected source line, out:\n%s", out) |
| } |
| } |