Skip to content

Commit d19c364

Browse files
authored
Merge pull request #6718 from projectdiscovery/dwisiswant0/perf/generators/optimize-MergeMaps-to-reduce-allocs
perf(generators): optimize `MergeMaps` to reduce allocs
2 parents 664a01e + 0c125e2 commit d19c364

10 files changed

Lines changed: 607 additions & 17 deletions

File tree

lib/sdk.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"github.com/projectdiscovery/nuclei/v3/pkg/output"
2020
"github.com/projectdiscovery/nuclei/v3/pkg/progress"
2121
"github.com/projectdiscovery/nuclei/v3/pkg/protocols"
22+
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/generators"
2223
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/hosterrorscache"
2324
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/interactsh"
2425
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolinit"
@@ -238,6 +239,9 @@ func (e *NucleiEngine) closeInternal() {
238239
if e.tmpDir != "" {
239240
_ = os.RemoveAll(e.tmpDir)
240241
}
242+
if e.opts != nil {
243+
generators.ClearOptionsPayloadMap(e.opts)
244+
}
241245
}
242246

243247
// Close all resources used by nuclei engine

pkg/protocols/common/generators/maps.go

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,20 +44,51 @@ func MergeMapsMany(maps ...interface{}) map[string][]string {
4444
return m
4545
}
4646

47-
// MergeMaps merges two maps into a new map
47+
// MergeMaps merges multiple maps into a new map.
48+
//
49+
// Use [CopyMap] if you need to copy a single map.
50+
// Use [MergeMapsInto] to merge into an existing map.
4851
func MergeMaps(maps ...map[string]interface{}) map[string]interface{} {
49-
merged := make(map[string]interface{})
52+
mapsLen := 0
53+
for _, m := range maps {
54+
mapsLen += len(m)
55+
}
56+
57+
merged := make(map[string]interface{}, mapsLen)
5058
for _, m := range maps {
5159
maps0.Copy(merged, m)
5260
}
61+
5362
return merged
5463
}
5564

65+
// CopyMap creates a shallow copy of a single map.
66+
func CopyMap(m map[string]interface{}) map[string]interface{} {
67+
if m == nil {
68+
return nil
69+
}
70+
71+
result := make(map[string]interface{}, len(m))
72+
maps0.Copy(result, m)
73+
74+
return result
75+
}
76+
77+
// MergeMapsInto copies all entries from src maps into dst (mutating dst).
78+
//
79+
// Use when dst is a fresh map the caller owns and wants to avoid allocation.
80+
func MergeMapsInto(dst map[string]interface{}, srcs ...map[string]interface{}) {
81+
for _, src := range srcs {
82+
maps0.Copy(dst, src)
83+
}
84+
}
85+
5686
// ExpandMapValues converts values from flat string to string slice
5787
func ExpandMapValues(m map[string]string) map[string][]string {
5888
m1 := make(map[string][]string, len(m))
5989
for k, v := range m {
6090
m1[k] = []string{v}
6191
}
92+
6293
return m1
6394
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package generators
2+
3+
import (
4+
"fmt"
5+
"testing"
6+
)
7+
8+
func BenchmarkMergeMaps(b *testing.B) {
9+
map1 := map[string]interface{}{
10+
"key1": "value1",
11+
"key2": "value2",
12+
"key3": "value3",
13+
"key4": "value4",
14+
"key5": "value5",
15+
}
16+
map2 := map[string]interface{}{
17+
"key6": "value6",
18+
"key7": "value7",
19+
"key8": "value8",
20+
"key9": "value9",
21+
"key10": "value10",
22+
}
23+
map3 := map[string]interface{}{
24+
"key11": "value11",
25+
"key12": "value12",
26+
"key13": "value13",
27+
}
28+
29+
for i := 1; i <= 3; i++ {
30+
b.Run(fmt.Sprintf("%d-maps", i), func(b *testing.B) {
31+
b.ReportAllocs()
32+
for b.Loop() {
33+
switch i {
34+
case 1:
35+
_ = MergeMaps(map1)
36+
case 2:
37+
_ = MergeMaps(map1, map2)
38+
case 3:
39+
_ = MergeMaps(map1, map2, map3)
40+
}
41+
}
42+
})
43+
}
44+
}
45+
46+
func BenchmarkCopyMap(b *testing.B) {
47+
map1 := map[string]interface{}{
48+
"key1": "value1",
49+
"key2": "value2",
50+
"key3": "value3",
51+
"key4": "value4",
52+
"key5": "value5",
53+
}
54+
55+
for i := 1; i <= 1; i++ {
56+
b.Run(fmt.Sprintf("%d-maps", i), func(b *testing.B) {
57+
b.ReportAllocs()
58+
for b.Loop() {
59+
switch i {
60+
case 1:
61+
_ = CopyMap(map1)
62+
}
63+
}
64+
})
65+
}
66+
}
67+
68+
func BenchmarkMergeMapsInto(b *testing.B) {
69+
map1 := map[string]interface{}{
70+
"key1": "value1",
71+
"key2": "value2",
72+
"key3": "value3",
73+
"key4": "value4",
74+
"key5": "value5",
75+
}
76+
map2 := map[string]interface{}{
77+
"key6": "value6",
78+
"key7": "value7",
79+
"key8": "value8",
80+
"key9": "value9",
81+
"key10": "value10",
82+
}
83+
map3 := map[string]interface{}{
84+
"key11": "value11",
85+
"key12": "value12",
86+
"key13": "value13",
87+
}
88+
map4 := map[string]interface{}{
89+
"key14": "value14",
90+
"key15": "value15",
91+
"key16": "value16",
92+
}
93+
94+
for i := 1; i <= 3; i++ {
95+
b.Run(fmt.Sprintf("%d-maps", i), func(b *testing.B) {
96+
b.ReportAllocs()
97+
for b.Loop() {
98+
switch i {
99+
case 1:
100+
MergeMapsInto(map1, map2)
101+
case 2:
102+
MergeMapsInto(map1, map2, map3)
103+
case 3:
104+
MergeMapsInto(map1, map2, map3, map4)
105+
}
106+
}
107+
})
108+
}
109+
}
Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,32 @@
11
package generators
22

33
import (
4+
"sync"
5+
46
"github.com/projectdiscovery/nuclei/v3/pkg/types"
57
)
68

7-
// BuildPayloadFromOptions returns a map with the payloads provided via CLI
9+
// optionsPayloadMap caches the result of BuildPayloadFromOptions per options
10+
// pointer. This supports multiple SDK instances with different options running
11+
// concurrently.
12+
var optionsPayloadMap sync.Map // map[*types.Options]map[string]interface{}
13+
14+
// BuildPayloadFromOptions returns a map with the payloads provided via CLI.
15+
//
16+
// The result is cached per options pointer since options don't change during a run.
17+
// Returns a copy of the cached map to prevent concurrent modification issues.
18+
// Safe for concurrent use with multiple SDK instances.
819
func BuildPayloadFromOptions(options *types.Options) map[string]interface{} {
20+
if options == nil {
21+
return make(map[string]interface{})
22+
}
23+
24+
if cached, ok := optionsPayloadMap.Load(options); ok {
25+
return CopyMap(cached.(map[string]interface{}))
26+
}
27+
928
m := make(map[string]interface{})
29+
1030
// merge with vars
1131
if !options.Vars.IsEmpty() {
1232
m = MergeMaps(m, options.Vars.AsMap())
@@ -16,5 +36,18 @@ func BuildPayloadFromOptions(options *types.Options) map[string]interface{} {
1636
if options.EnvironmentVariables {
1737
m = MergeMaps(EnvVars(), m)
1838
}
19-
return m
39+
40+
actual, _ := optionsPayloadMap.LoadOrStore(options, m)
41+
42+
// Return a copy to prevent concurrent writes to the cached map
43+
return CopyMap(actual.(map[string]interface{}))
44+
}
45+
46+
// ClearOptionsPayloadMap clears the cached options payload.
47+
// SDK users should call this when disposing of a NucleiEngine instance
48+
// to prevent memory leaks if creating many short-lived instances.
49+
func ClearOptionsPayloadMap(options *types.Options) {
50+
if options != nil {
51+
optionsPayloadMap.Delete(options)
52+
}
2053
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package generators
2+
3+
import (
4+
"testing"
5+
6+
"github.com/projectdiscovery/goflags"
7+
"github.com/projectdiscovery/nuclei/v3/pkg/types"
8+
)
9+
10+
func BenchmarkBuildPayloadFromOptions(b *testing.B) {
11+
// Setup options with vars and env vars
12+
vars := goflags.RuntimeMap{}
13+
_ = vars.Set("key1=value1")
14+
_ = vars.Set("key2=value2")
15+
_ = vars.Set("key3=value3")
16+
_ = vars.Set("key4=value4")
17+
_ = vars.Set("key5=value5")
18+
19+
opts := &types.Options{
20+
Vars: vars,
21+
EnvironmentVariables: true, // This adds more entries
22+
}
23+
24+
b.Run("Sequential", func(b *testing.B) {
25+
ClearOptionsPayloadMap(opts)
26+
27+
b.ReportAllocs()
28+
for b.Loop() {
29+
_ = BuildPayloadFromOptions(opts)
30+
}
31+
})
32+
33+
b.Run("Parallel", func(b *testing.B) {
34+
ClearOptionsPayloadMap(opts)
35+
36+
b.ReportAllocs()
37+
b.RunParallel(func(pb *testing.PB) {
38+
for pb.Next() {
39+
m := BuildPayloadFromOptions(opts)
40+
// Simulate typical usage - read a value
41+
_ = m["key1"]
42+
}
43+
})
44+
})
45+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package generators
2+
3+
import (
4+
"sync"
5+
"testing"
6+
7+
"github.com/projectdiscovery/goflags"
8+
"github.com/projectdiscovery/nuclei/v3/pkg/types"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func TestBuildPayloadFromOptionsConcurrency(t *testing.T) {
13+
// Test that BuildPayloadFromOptions is safe for concurrent use
14+
// and returns independent copies that can be modified without races
15+
vars := goflags.RuntimeMap{}
16+
_ = vars.Set("key=value")
17+
18+
opts := &types.Options{
19+
Vars: vars,
20+
}
21+
22+
const numGoroutines = 100
23+
var wg sync.WaitGroup
24+
wg.Add(numGoroutines)
25+
26+
// Each goroutine gets a map and modifies it
27+
for i := 0; i < numGoroutines; i++ {
28+
go func(id int) {
29+
defer wg.Done()
30+
31+
// Get the map (should be a copy of cached data)
32+
m := BuildPayloadFromOptions(opts)
33+
34+
// Modify it - this should not cause races
35+
m["goroutine_id"] = id
36+
m["test_key"] = "test_value"
37+
38+
// Verify original cached value is present
39+
require.Equal(t, "value", m["key"])
40+
}(i)
41+
}
42+
43+
wg.Wait()
44+
}
45+
46+
func TestBuildPayloadFromOptionsCaching(t *testing.T) {
47+
// Test that caching actually works
48+
vars := goflags.RuntimeMap{}
49+
_ = vars.Set("cached=yes")
50+
51+
opts := &types.Options{
52+
Vars: vars,
53+
EnvironmentVariables: false,
54+
}
55+
56+
// First call - builds and caches
57+
m1 := BuildPayloadFromOptions(opts)
58+
require.Equal(t, "yes", m1["cached"])
59+
60+
// Second call - should return copy of cached result
61+
m2 := BuildPayloadFromOptions(opts)
62+
require.Equal(t, "yes", m2["cached"])
63+
64+
// Modify m1 - should not affect m2 since they're copies
65+
m1["modified"] = "in_m1"
66+
require.NotContains(t, m2, "modified")
67+
68+
// Modify m2 - should not affect future calls
69+
m2["modified"] = "in_m2"
70+
m3 := BuildPayloadFromOptions(opts)
71+
require.NotContains(t, m3, "modified")
72+
}
73+
74+
func TestClearOptionsPayloadMap(t *testing.T) {
75+
vars := goflags.RuntimeMap{}
76+
_ = vars.Set("temp=data")
77+
78+
opts := &types.Options{
79+
Vars: vars,
80+
}
81+
82+
// Build and cache
83+
m1 := BuildPayloadFromOptions(opts)
84+
require.Equal(t, "data", m1["temp"])
85+
86+
// Clear the cache
87+
ClearOptionsPayloadMap(opts)
88+
89+
// Verify it still works (rebuilds)
90+
m2 := BuildPayloadFromOptions(opts)
91+
require.Equal(t, "data", m2["temp"])
92+
}

0 commit comments

Comments
 (0)