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
222 changes: 222 additions & 0 deletions central/detection/lifecycle/indicator_filter_benchmark_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
package lifecycle

import (
"context"
"fmt"
"os"
"runtime"
"runtime/pprof"
"testing"
"time"

deploymentDatastore "github.com/stackrox/rox/central/deployment/datastore"
processIndicatorDatastore "github.com/stackrox/rox/central/processindicator/datastore"
"github.com/stackrox/rox/generated/storage"
"github.com/stackrox/rox/pkg/logging"
"github.com/stackrox/rox/pkg/postgres/pgtest"
"github.com/stackrox/rox/pkg/process/filter"
"github.com/stackrox/rox/pkg/protocompat"
"github.com/stackrox/rox/pkg/sac"
"github.com/stackrox/rox/pkg/utils"
"github.com/stackrox/rox/pkg/uuid"
"github.com/stretchr/testify/require"
"go.uber.org/zap/zapcore"
)

// BenchmarkConfig holds configuration for benchmark tests
type BenchmarkConfig struct {
NumDeployments int
NumPodsPerDeployment int
NumProcessesPerPod int
}

// defaultBenchmarkConfig is the standard configuration used for all benchmarks
var defaultBenchmarkConfig = BenchmarkConfig{
NumDeployments: 1000,
NumPodsPerDeployment: 10,
NumProcessesPerPod: 10,
} // 30,000 total processes

// benchmarkDBSetup holds the database setup for benchmarks
type benchmarkDBSetup struct {
deploymentDS deploymentDatastore.DataStore
processDS processIndicatorDatastore.DataStore
testDB *pgtest.TestPostgres
}

// setupBenchmarkDB creates a fresh database, applies schema, and seeds test data.
// The setup time is excluded from benchmark measurements.
func setupBenchmarkDB(b *testing.B, config BenchmarkConfig) *benchmarkDBSetup {
ctx := sac.WithAllAccess(context.Background())

testDB := pgtest.ForT(b)
deploymentDS, err := deploymentDatastore.GetTestPostgresDataStore(b, testDB)
require.NoError(b, err)
processDS := processIndicatorDatastore.GetTestPostgresDataStore(b, testDB)

seedBenchmarkData(ctx, b, deploymentDS, processDS, config)

return &benchmarkDBSetup{
deploymentDS: deploymentDS,
processDS: processDS,
testDB: testDB,
}
}

// seedBenchmarkData populates the database with test data
func seedBenchmarkData(
ctx context.Context,
tb testing.TB,
deploymentDS deploymentDatastore.DataStore,
processDS processIndicatorDatastore.DataStore,
config BenchmarkConfig,
) {
ctx = sac.WithAllAccess(ctx)
clusterID := uuid.NewV4().String()

for i := 0; i < config.NumDeployments; i++ {
deploymentID := uuid.NewV4().String()
deployment := &storage.Deployment{
Id: deploymentID,
Name: fmt.Sprintf("deployment-%d", i),
ClusterId: clusterID,
Namespace: fmt.Sprintf("namespace-%d", i%10),
Containers: []*storage.Container{
{
Name: "main-container",
Image: &storage.ContainerImage{Name: &storage.ImageName{FullName: "nginx:latest"}},
},
},
}

if err := deploymentDS.UpsertDeployment(ctx, deployment); err != nil {
tb.Fatalf("Failed to create deployment %d: %v", i, err)
}

for j := 0; j < config.NumPodsPerDeployment; j++ {
podID := uuid.NewV4().String()
containerID := uuid.NewV4().String()

for k := 0; k < config.NumProcessesPerPod; k++ {
indicator := &storage.ProcessIndicator{
Id: uuid.NewV4().String(),
DeploymentId: deploymentID,
PodId: podID,
ContainerName: "main-container",
ClusterId: clusterID,
Namespace: deployment.GetNamespace(),
Signal: &storage.ProcessSignal{
Id: uuid.NewV4().String(),
ContainerId: containerID,
Time: protocompat.TimestampNow(),
Name: fmt.Sprintf("process-%d", k),
ExecFilePath: fmt.Sprintf("/usr/bin/process-%d", k%10),
Args: fmt.Sprintf("--arg1=value%d --arg2=value%d", k, j),
Pid: uint32(1000 + k),
Uid: 0,
Gid: 0,
LineageInfo: []*storage.ProcessSignal_LineageInfo{
{
ParentExecFilePath: "/usr/bin/init",
ParentUid: 0,
},
},
},
}

if err := processDS.AddProcessIndicators(ctx, indicator); err != nil {
tb.Fatalf("Failed to create process indicator %d-%d-%d: %v", i, j, k, err)
}
}
}
}
}

// createTestManager creates a manager instance with the standard filter configuration
func createTestManager(deploymentDS deploymentDatastore.DataStore, processDS processIndicatorDatastore.DataStore) *managerImpl {
return &managerImpl{
deploymentDataStore: deploymentDS,
processesDataStore: processDS,
processFilter: filter.NewFilter(
1000,
10000,
[]int{100, 50, 25, 10, 5},
),
}
}

// BenchmarkBuildIndicatorFilterPerformance benchmarks the buildIndicatorFilter function
// across multiple iterations to measure filter building performance.
// Usage:
//
// go test -bench=BenchmarkBuildIndicatorFilterPerformance
func BenchmarkBuildIndicatorFilterPerformance(b *testing.B) {
// Suppress logs for clean benchmark output
logging.SetGlobalLogLevel(zapcore.PanicLevel)

// Create and seed a fresh database (excluded from timing)
setup := setupBenchmarkDB(b, defaultBenchmarkConfig)

// Create manager
manager := createTestManager(setup.deploymentDS, setup.processDS)

// Start timing the actual benchmark
for b.Loop() {
// Reset filter to empty state before each iteration
manager.processFilter = filter.NewFilter(
1000,
10000,
[]int{100, 50, 25, 10, 5},
)

// Build the filter (this is what we're measuring)
manager.buildIndicatorFilter()
}

}

// BenchmarkBuildIndicatorFilterMemory benchmarks memory usage of buildIndicatorFilter.
// This benchmark runs buildIndicatorFilter exactly once and capture a memory profile of it.
// The profile is written to "indicator_filter_memory.prof" in the current directory.
// Usage:
//
// go test -bench=BenchmarkBuildIndicatorFilterMemory
// go tool pprof indicator_filter_memory.prof
func BenchmarkBuildIndicatorFilterMemory(b *testing.B) {
// Note that we're not messing with timers like in above test
// because the sole purpose of this test is to write a heap profile
setup := setupBenchmarkDB(b, defaultBenchmarkConfig)
defer setup.testDB.Close()
// Create manager
manager := createTestManager(setup.deploymentDS, setup.processDS)

manager.processFilter = filter.NewFilter(
1000,
10000,
[]int{100, 50, 25, 10, 5},
)

manager.buildIndicatorFilter()

// Force multiple garbage collections to get accurate heap profile
for i := 0; i < 10; i++ {
runtime.GC()
time.Sleep(500 * time.Millisecond)
}

// Write memory profile
profileFile := "indicator_filter_memory.prof"
f, err := os.Create(profileFile)
if err != nil {
b.Fatal(err)
}
defer utils.Must(f.Close())

err = pprof.Lookup("heap").WriteTo(f, 0)
if err != nil {
b.Fatalf("could not write memory profile: %v", err)
}

// we need to reference manager here otherwise the GC will remove it before writing the profile
b.Logf("%T", manager.processFilter)
}
58 changes: 51 additions & 7 deletions pkg/process/filter/filter.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
package filter

import (
"hash"
"strings"
"unsafe"

"github.com/cespare/xxhash"
"github.com/stackrox/rox/generated/storage"
"github.com/stackrox/rox/pkg/containerid"
"github.com/stackrox/rox/pkg/set"
"github.com/stackrox/rox/pkg/stringutils"
"github.com/stackrox/rox/pkg/sync"
)

// BinaryHash represents a 64-bit hash for memory-efficient key storage.
// Using uint64 directly avoids conversion overhead and provides faster map operations.
// This follows the pattern from network flow dedupers (PR #17040).
type BinaryHash uint64

// This filter is a rudimentary filter that prevents a container from spamming Central
//
// Parameters:
Expand Down Expand Up @@ -43,12 +51,12 @@ type Filter interface {

type level struct {
hits int
children map[string]*level
children map[BinaryHash]*level
}

func newLevel() *level {
return &level{
children: make(map[string]*level),
children: make(map[BinaryHash]*level),
}
}

Expand All @@ -59,6 +67,10 @@ type filterImpl struct {

containersInDeployment map[string]map[string]*level
rootLock sync.Mutex

// Hash instance for computing BinaryHash keys
// Reused across Add() calls to avoid allocations
h hash.Hash64
}

func (f *filterImpl) siftNoLock(level *level, args []string, levelNum int) bool {
Expand All @@ -72,15 +84,22 @@ func (f *filterImpl) siftNoLock(level *level, args []string, levelNum int) bool
return true
}
// Truncate the current argument to the max size to avoid large arguments taking up a lot of space
currentArg := stringutils.Truncate(args[0], maxArgSize)
nextLevel := level.children[currentArg]

truncated := stringutils.Truncate(args[0], maxArgSize)

// Hash the truncated arguments to solve 2 problems:
// 1. Holding references to the original string data received from the DB scan
// 2. Using BinaryHash as map key is reducing memory requirements for the filter
argHash := hashString(f.h, truncated)

nextLevel := level.children[argHash]
if nextLevel == nil {
// If this level has already hit its max fan out then return false
if len(level.children) >= f.maxFanOut[levelNum] {
return false
}
nextLevel = newLevel()
level.children[currentArg] = nextLevel
level.children[argHash] = nextLevel
}

return f.siftNoLock(nextLevel, args[1:], levelNum+1)
Expand All @@ -94,6 +113,7 @@ func NewFilter(maxExactPathMatches, maxUniqueProcesses int, fanOut []int) Filter
maxFanOut: fanOut,

containersInDeployment: make(map[string]map[string]*level),
h: xxhash.New(),
}
}

Expand All @@ -119,14 +139,20 @@ func (f *filterImpl) Add(indicator *storage.ProcessIndicator) bool {

rootLevel := f.getOrAddRootLevelNoLock(indicator)

execFilePath := indicator.GetSignal().GetExecFilePath()
// Hash the execFilePath to solve 2 problems:
// 1. Holding references to the original string data received from the DB scan
// 2. Using BinaryHash as map key is reducing memory requirements for the filter
execFilePathHash := hashString(f.h, execFilePath)

// Handle the process level independently as we will never reject a new process
processLevel := rootLevel.children[indicator.GetSignal().GetExecFilePath()]
processLevel := rootLevel.children[execFilePathHash]
if processLevel == nil {
if len(rootLevel.children) >= f.maxUniqueProcesses {
return false
}
processLevel = newLevel()
rootLevel.children[indicator.GetSignal().GetExecFilePath()] = processLevel
rootLevel.children[execFilePathHash] = processLevel
}

return f.siftNoLock(processLevel, strings.Fields(indicator.GetSignal().GetArgs()), 0)
Expand Down Expand Up @@ -183,3 +209,21 @@ func (f *filterImpl) DeleteByPod(pod *storage.Pod) {
}
}
}

// hashString creates a hash from a single string.
// Convenience wrapper for hashStrings with a single argument.
func hashString(h hash.Hash64, s string) BinaryHash {
if len(s) == 0 {
return BinaryHash(0)
}

h.Reset()
// Use zero-copy conversion from string to []byte using unsafe to avoid allocation.
// This is safe because:
// 1. h.Write() doesn't modify data (io.Writer contract)
// 2. xxhash doesn't retain references
// 3. string s remains alive during the call
//#nosec G103 -- Audited: zero-copy string-to-bytes conversion for performance
_, _ = h.Write(unsafe.Slice(unsafe.StringData(s), len(s)))
return BinaryHash(h.Sum64())
}
Loading
Loading