diff --git a/cli/commands.go b/cli/commands.go index 458cbf1..4da4b2a 100644 --- a/cli/commands.go +++ b/cli/commands.go @@ -96,11 +96,12 @@ func createProfManual() *cobra.Command { Args: cobra.MinimumNArgs(1), Example: fmt.Sprintf("prof %s --tag tagName cpu.prof memory.prof block.prof mutex.prof", internal.MANUALCMD), RunE: func(_ *cobra.Command, args []string) error { - return collector.RunCollector(args, tag) + return collector.RunCollector(args, tag, groupByPackage) }, } manualCmd.Flags().StringVar(&tag, tagFlag, "", "The tag is used to organize the results") + manualCmd.Flags().BoolVar(&groupByPackage, "group-by-package", false, "Group profile data by package/module and save as organized text file") _ = manualCmd.MarkFlagRequired(tagFlag) return manualCmd @@ -116,7 +117,7 @@ func createProfAuto() *cobra.Command { Use: internal.AUTOCMD, Short: "Wraps `go test` and `pprof` to benchmark code and gather profiling data for performance investigations.", RunE: func(_ *cobra.Command, _ []string) error { - return benchmark.RunBenchmarks(benchmarks, profiles, tag, count) + return benchmark.RunBenchmarks(benchmarks, profiles, tag, count, groupByPackage) }, Example: example, } @@ -125,6 +126,7 @@ func createProfAuto() *cobra.Command { cmd.Flags().StringSliceVar(&profiles, profileFlag, []string{}, `Profiles to use (e.g., "cpu,memory,mutex")`) cmd.Flags().StringVar(&tag, tagFlag, "", "The tag is used to organize the results") cmd.Flags().IntVar(&count, countFlag, 0, "Number of runs") + cmd.Flags().BoolVar(&groupByPackage, "group-by-package", false, "Group profile data by package/module and save as organized text file") _ = cmd.MarkFlagRequired(benchFlag) _ = cmd.MarkFlagRequired(profileFlag) diff --git a/cli/constants_vars.go b/cli/constants_vars.go index 1b03d7f..6f80a36 100644 --- a/cli/constants_vars.go +++ b/cli/constants_vars.go @@ -15,6 +15,9 @@ var ( outputFormat string failOnRegression bool regressionThreshold float64 + + // Profile organization flags. + groupByPackage bool ) const ( diff --git a/cli/tui.go b/cli/tui.go index 99d9f7b..e330d83 100644 --- a/cli/tui.go +++ b/cli/tui.go @@ -66,7 +66,7 @@ func runTUI(_ *cobra.Command, _ []string) error { return err } - if err = benchmark.RunBenchmarks(selectedBenches, selectedProfiles, tagStr, runCount); err != nil { + if err = benchmark.RunBenchmarks(selectedBenches, selectedProfiles, tagStr, runCount, false); err != nil { return err } diff --git a/engine/benchmark/api.go b/engine/benchmark/api.go index 051d4c6..c954e6b 100644 --- a/engine/benchmark/api.go +++ b/engine/benchmark/api.go @@ -8,7 +8,7 @@ import ( "github.com/AlexsanderHamir/prof/internal" ) -func RunBenchmarks(benchmarks, profiles []string, tag string, count int) error { +func RunBenchmarks(benchmarks, profiles []string, tag string, count int, groupByPackage bool) error { if len(benchmarks) == 0 { return errors.New("benchmarks flag is empty") } @@ -37,7 +37,7 @@ func RunBenchmarks(benchmarks, profiles []string, tag string, count int) error { internal.PrintConfiguration(benchArgs, cfg.FunctionFilter) - if err = runBenchAndGetProfiles(benchArgs, cfg.FunctionFilter); err != nil { + if err = runBenchAndGetProfiles(benchArgs, cfg.FunctionFilter, groupByPackage); err != nil { return err } diff --git a/engine/benchmark/pipeline.go b/engine/benchmark/pipeline.go index 7847e6d..09d62ce 100644 --- a/engine/benchmark/pipeline.go +++ b/engine/benchmark/pipeline.go @@ -7,7 +7,7 @@ import ( "github.com/AlexsanderHamir/prof/internal" ) -func runBenchAndGetProfiles(benchArgs *internal.BenchArgs, benchmarkConfigs map[string]internal.FunctionFilter) error { +func runBenchAndGetProfiles(benchArgs *internal.BenchArgs, benchmarkConfigs map[string]internal.FunctionFilter, groupByPackage bool) error { slog.Info("Starting benchmark pipeline...") var functionFilter internal.FunctionFilter @@ -23,7 +23,7 @@ func runBenchAndGetProfiles(benchArgs *internal.BenchArgs, benchmarkConfigs map[ } slog.Info("Processing profiles", "Benchmark", benchmarkName) - if err := processProfiles(benchmarkName, benchArgs.Profiles, benchArgs.Tag); err != nil { + if err := processProfiles(benchmarkName, benchArgs.Profiles, benchArgs.Tag, groupByPackage); err != nil { return fmt.Errorf("failed to process profiles for %s: %w", benchmarkName, err) } diff --git a/engine/benchmark/profiles.go b/engine/benchmark/profiles.go index 1ac17b8..d727c20 100644 --- a/engine/benchmark/profiles.go +++ b/engine/benchmark/profiles.go @@ -48,7 +48,7 @@ func getProfilePaths(tag, benchmarkName, profile string) ProfilePaths { } // processProfiles collects all pprof info for a specific benchmark and its specified profiles. -func processProfiles(benchmarkName string, profiles []string, tag string) error { +func processProfiles(benchmarkName string, profiles []string, tag string, groupByPackage bool) error { tagDir := filepath.Join(internal.MainDirOutput, tag) binDir := filepath.Join(tagDir, internal.ProfileBinDir, benchmarkName) textDir := filepath.Join(tagDir, internal.ProfileTextDir, benchmarkName) @@ -70,6 +70,14 @@ func processProfiles(benchmarkName string, profiles []string, tag string) error return fmt.Errorf("failed to generate text profile for %s: %w", profile, err) } + // Generate grouped profile data if requested + if groupByPackage { + groupedOutputFile := filepath.Join(textDir, fmt.Sprintf("%s_%s_grouped.%s", benchmarkName, profile, internal.TextExtension)) + if err := generateGroupedProfileData(profileFile, groupedOutputFile, internal.FunctionFilter{}); err != nil { + return fmt.Errorf("failed to generate grouped profile for %s: %w", profile, err) + } + } + pngDesiredFilePath := filepath.Join(profileFunctionsDir, fmt.Sprintf("%s_%s.png", benchmarkName, profile)) if err := collector.GetPNGOutput(profileFile, pngDesiredFilePath); err != nil { return fmt.Errorf("failed to generate PNG visualization for %s: %w", profile, err) @@ -81,6 +89,18 @@ func processProfiles(benchmarkName string, profiles []string, tag string) error return nil } +// generateGroupedProfileData generates profile data organized by package/module using the new parser function +func generateGroupedProfileData(binaryFile, outputFile string, functionFilter internal.FunctionFilter) error { + // Import the parser package to use OrganizeProfileByPackageV2 + groupedData, err := parser.OrganizeProfileByPackageV2(binaryFile, functionFilter) + if err != nil { + return fmt.Errorf("failed to organize profile by package: %w", err) + } + + // Write the grouped data to the output file + return os.WriteFile(outputFile, []byte(groupedData), internal.PermFile) +} + // CollectProfileFunctions collects all pprof information for each function, according to configurations. func collectProfileFunctions(args *internal.CollectionArgs) error { for _, profile := range args.Profiles { diff --git a/engine/benchmark/tests/api_unit_test.go b/engine/benchmark/tests/api_unit_test.go index 5a39ded..1a695f6 100644 --- a/engine/benchmark/tests/api_unit_test.go +++ b/engine/benchmark/tests/api_unit_test.go @@ -52,7 +52,7 @@ func TestRunBenchmarks(t *testing.T) { t.Run(tt.name, func(t *testing.T) { defer cleanupBenchDirectories() - err := benchmark.RunBenchmarks(tt.benchmarks, tt.profiles, tt.tag, tt.count) + err := benchmark.RunBenchmarks(tt.benchmarks, tt.profiles, tt.tag, tt.count, false) if tt.wantErr { if err == nil { diff --git a/engine/collector/api.go b/engine/collector/api.go index 0d0906b..0d9a81b 100644 --- a/engine/collector/api.go +++ b/engine/collector/api.go @@ -4,21 +4,19 @@ import ( "fmt" "os" "os/exec" - "path" "path/filepath" "github.com/AlexsanderHamir/prof/internal" ) // RunCollector handles data organization without wrapping go test. -func RunCollector(files []string, tag string) error { +func RunCollector(files []string, tag string, groupByPackage bool) error { if err := ensureDirExists(internal.MainDirOutput); err != nil { return err } tagDir := filepath.Join(internal.MainDirOutput, tag) - err := internal.CleanOrCreateTag(tagDir) - if err != nil { + if err := internal.CleanOrCreateTag(tagDir); err != nil { return fmt.Errorf("CleanOrCreateTag failed: %w", err) } @@ -27,35 +25,11 @@ func RunCollector(files []string, tag string) error { cfg = &internal.Config{} } - var functionFilter internal.FunctionFilter - globalFilter, hasGlobalFilter := cfg.FunctionFilter[internal.GlobalSign] - if hasGlobalFilter { - functionFilter = globalFilter - } + globalFilter, _ := getGlobalFunctionFilter(cfg) - var profileDirPath string for _, fullBinaryPath := range files { - fileName := getFileName(fullBinaryPath) - profileDirPath, err = createProfileDirectory(tagDir, fileName) - if err != nil { - return fmt.Errorf("createProfileDirectory failed: %w", err) - } - - if !hasGlobalFilter { - functionFilter = internal.FunctionFilter{} // clean previous one - localFilter, hasLocalFilter := cfg.FunctionFilter[fileName] - if hasLocalFilter { - functionFilter = localFilter - } - } - - outputTextFilePath := path.Join(profileDirPath, fileName+"."+internal.TextExtension) - if err = GetProfileTextOutput(fullBinaryPath, outputTextFilePath); err != nil { - return err - } - - if err = collectFunctions(profileDirPath, fullBinaryPath, functionFilter); err != nil { - return fmt.Errorf("collectFunctions failed: %w", err) + if processErr := processBinaryFile(fullBinaryPath, tagDir, cfg, globalFilter, groupByPackage); processErr != nil { + return processErr } } return nil diff --git a/engine/collector/helpers.go b/engine/collector/helpers.go index dbd6950..8a8a5d3 100644 --- a/engine/collector/helpers.go +++ b/engine/collector/helpers.go @@ -61,3 +61,75 @@ func collectFunctions(profileDirPath, fullBinaryPath string, functionFilter inte return nil } + +// getGlobalFunctionFilter extracts the global function filter from config +func getGlobalFunctionFilter(cfg *internal.Config) (internal.FunctionFilter, bool) { + globalFilter, hasGlobalFilter := cfg.FunctionFilter[internal.GlobalSign] + return globalFilter, hasGlobalFilter +} + +// processBinaryFile handles the processing of a single binary file +func processBinaryFile(fullBinaryPath, tagDir string, cfg *internal.Config, globalFilter internal.FunctionFilter, groupByPackage bool) error { + fileName := getFileName(fullBinaryPath) + + profileDirPath, createErr := createProfileDirectory(tagDir, fileName) + if createErr != nil { + return fmt.Errorf("createProfileDirectory failed: %w", createErr) + } + + functionFilter := determineFunctionFilter(cfg, fileName, globalFilter) + + if genErr := generateProfileOutputs(fullBinaryPath, profileDirPath, fileName, functionFilter, groupByPackage); genErr != nil { + return genErr + } + + if collectErr := collectFunctions(profileDirPath, fullBinaryPath, functionFilter); collectErr != nil { + return fmt.Errorf("collectFunctions failed: %w", collectErr) + } + + return nil +} + +// determineFunctionFilter determines which function filter to use for a given file +func determineFunctionFilter(cfg *internal.Config, fileName string, globalFilter internal.FunctionFilter) internal.FunctionFilter { + _, hasGlobalFilter := cfg.FunctionFilter[internal.GlobalSign] + if hasGlobalFilter { + return globalFilter + } + + localFilter, hasLocalFilter := cfg.FunctionFilter[fileName] + if hasLocalFilter { + return localFilter + } + + return internal.FunctionFilter{} +} + +// generateProfileOutputs generates all profile outputs for a binary file +func generateProfileOutputs(fullBinaryPath, profileDirPath, fileName string, functionFilter internal.FunctionFilter, groupByPackage bool) error { + outputTextFilePath := path.Join(profileDirPath, fileName+"."+internal.TextExtension) + if err := GetProfileTextOutput(fullBinaryPath, outputTextFilePath); err != nil { + return err + } + + if groupByPackage { + groupedOutputPath := path.Join(profileDirPath, fileName+"_grouped."+internal.TextExtension) + if err := generateGroupedProfileData(fullBinaryPath, groupedOutputPath, functionFilter); err != nil { + return fmt.Errorf("generateGroupedProfileData failed: %w", err) + } + } + + return nil +} + +// generateGroupedProfileData generates profile data organized by package/module using the new parser function +func generateGroupedProfileData(binaryFile, outputFile string, functionFilter internal.FunctionFilter) error { + // Import the parser package to use OrganizeProfileByPackageV2 + groupedData, err := parser.OrganizeProfileByPackageV2(binaryFile, functionFilter) + if err != nil { + return fmt.Errorf("failed to organize profile by package: %w", err) + } + + // Write the grouped data to the output file + return os.WriteFile(outputFile, []byte(groupedData), internal.PermFile) +} diff --git a/engine/collector/tests/api_unit_test.go b/engine/collector/tests/api_unit_test.go index 31373f0..1fa2916 100644 --- a/engine/collector/tests/api_unit_test.go +++ b/engine/collector/tests/api_unit_test.go @@ -177,7 +177,7 @@ func TestRunCollector(t *testing.T) { defer cleanupBenchDirectory(t) // Test the function - err = collector.RunCollector(binaryFiles, "test_tag") + err = collector.RunCollector(binaryFiles, "test_tag", false) // The function might fail if go tool pprof is not available // or if the binary files are not valid profiles @@ -233,7 +233,7 @@ func TestRunCollectorWithInvalidFiles(t *testing.T) { defer cleanupBenchDirectory(t) // Test the function - it should fail - err = collector.RunCollector(invalidFiles, "test_tag") + err = collector.RunCollector(invalidFiles, "test_tag", false) if err == nil { t.Error("Expected error when running collector with invalid files, got nil") } else if !strings.Contains(err.Error(), "pprof command failed") { @@ -256,7 +256,7 @@ func TestRunCollectorWithEmptyFileList(t *testing.T) { defer cleanupBenchDirectory(t) // Test the function - it should succeed with no files to process - err = collector.RunCollector(emptyFiles, "test_tag") + err = collector.RunCollector(emptyFiles, "test_tag", false) if err != nil { t.Errorf("Expected no error when running collector with empty file list, got: %v", err) } @@ -286,7 +286,7 @@ func TestRunCollectorWithMockFiles(t *testing.T) { } // Test the function - it should fail due to invalid binary files - err = collector.RunCollector(mockFiles, "test_tag") + err = collector.RunCollector(mockFiles, "test_tag", false) // The function should fail because the mock files are not valid Go profiles if err == nil { diff --git a/prof_web_doc/docs/index.md b/prof_web_doc/docs/index.md index 2cd0d8d..ed2387c 100644 --- a/prof_web_doc/docs/index.md +++ b/prof_web_doc/docs/index.md @@ -27,6 +27,12 @@ The `auto` command wraps `go test` and `pprof` to run benchmarks, collect all pr prof auto --benchmarks "BenchmarkGenPool" --profiles "cpu,memory,mutex,block" --count 10 --tag "baseline" ``` +**With package grouping:** + +```bash +prof auto --benchmarks "BenchmarkGenPool" --profiles "cpu,memory,mutex,block" --count 10 --tag "baseline" --group-by-package +``` + This single command replaces dozens of manual steps and creates a complete, organized profiling dataset ready for analysis or comparison. **Output Structure:** @@ -79,6 +85,32 @@ This creates a configuration file with the following structure: - `include_prefixes`: Only collect functions whose names start with these prefixes. - `ignore_functions`: Exclude specific functions from collection, even if they match the include prefixes. +## Package Grouping + +The `--group-by-package` flag organizes functions by package and saves results under `bench/tag/text/benchmarkname`: + +```text +#### **sync/atomic** +- `CompareAndSwapPointer` → 31.21% +- `Load` → 9.78% +- `Add` → 2.00% +- `CompareAndSwap` → 0.12% + +**Subtotal (sync/atomic)**: ≈43.2% + +#### **github.com/AlexsanderHamir/GenPool/pool** +- `Put` → 19.43% +- `Get` → 16.14% + +**Subtotal (github.com/AlexsanderHamir/GenPool/pool)**: ≈35.6% + +#### **github.com/AlexsanderHamir/GenPool/test** +- `cleaner` → 6.12% +- `func1` → 1.53% + +**Subtotal (com/AlexsanderHamir/GenPool/test)**: ≈7.7% +``` + ## TUI - Interactive Selection The `tui` command provides an interactive terminal interface that automatically discovers benchmarks in your project and guides you through the selection process: @@ -111,6 +143,12 @@ The `manual` command processes existing pprof files (`.out` or `.prof`) without prof manual --tag "external-profiles" BenchmarkGenPool_cpu.out memory.out block.out ``` +**With package grouping:** + +```bash +prof manual --tag "external-profiles" --group-by-package BenchmarkGenPool_cpu.out memory.out block.out +``` + This organizes your existing profile files into a flatter structure based on the profile filename: **Manual Output Structure:** diff --git a/readme.md b/readme.md index e0b0f47..7cbded7 100644 --- a/readme.md +++ b/readme.md @@ -65,6 +65,24 @@ prof track auto --base "baseline" --current "PR" --profile-type "cpu" --bench-na All profiling data is automatically organized under `bench//` directories with clear structure. +### 📦 Package-Level Grouping + +Organize profile data by package/module for better analysis and collaboration: + +```bash +# Group profile data by package when collecting +prof auto --benchmarks "BenchmarkName" --profiles "cpu,memory" --count 5 --tag "baseline" --group-by-package + +# Group profile data from existing files +prof manual --tag "external-profiles" --group-by-package cpu.prof memory.prof +``` + +When enabled, this creates additional `*_grouped.txt` files that organize functions by their package/module, making it easier to: + +- Identify which packages consume the most resources +- Share package-level performance insights with team members +- Focus optimization efforts on specific modules + ## Interactive TUI Don't want to remember benchmark names or commands? Use the interactive terminal interface: