Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions cli/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
}
Expand All @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions cli/constants_vars.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ var (
outputFormat string
failOnRegression bool
regressionThreshold float64

// Profile organization flags.
groupByPackage bool
)

const (
Expand Down
2 changes: 1 addition & 1 deletion cli/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
4 changes: 2 additions & 2 deletions engine/benchmark/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down Expand Up @@ -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
}

Expand Down
4 changes: 2 additions & 2 deletions engine/benchmark/pipeline.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}

Expand Down
22 changes: 21 additions & 1 deletion engine/benchmark/profiles.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion engine/benchmark/tests/api_unit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
36 changes: 5 additions & 31 deletions engine/collector/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand All @@ -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
Expand Down
72 changes: 72 additions & 0 deletions engine/collector/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
8 changes: 4 additions & 4 deletions engine/collector/tests/api_unit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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") {
Expand All @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand Down
38 changes: 38 additions & 0 deletions prof_web_doc/docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:**
Expand Down
18 changes: 18 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,24 @@ prof track auto --base "baseline" --current "PR" --profile-type "cpu" --bench-na

All profiling data is automatically organized under `bench/<tag>/` 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:
Expand Down
Loading