diff --git a/README.md b/README.md index d92dcda..7c113f6 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ # Introduction Hypermatch is a high-performance Go library that enables rapid matching of a large number of rules against events. Designed for speed and efficiency, hypermatch handles thousands of events per second with low latency, making it ideal for real-time systems. -- **Fast Matching**: Matches events to a large set of rules in-memory with minimal delay. +- **Fast Matching**: Matches events to a large set of rules in-memory with minimal delay ... [it's really fast! (Benchmark)](_benchmark/benchmark.md) - **Readable Rule Format**: Serialize rules into human-readable JSON objects. - **Flexible Rule Syntax**: Supports various matching conditions, including equals, prefix, suffix, wildcard, anything-but, all-of, and any-of. @@ -285,4 +285,4 @@ If the attribute value is type of: Nevertheless, there are a few things to consider to get maximum performance: - Shorten the number of fields inside the rules, the fewer conditions, the shorter is the path to find them out. - Try to make the **paths** as diverse as possible in events and rules. The more heterogeneous fields, the higher the performance. -- Reduce the number of **anyOf** conditions wherever possible +- Reduce the number of **anyOf** conditions wherever possible \ No newline at end of file diff --git a/_benchmark/benchmark.md b/_benchmark/benchmark.md new file mode 100644 index 0000000..8cfa44e --- /dev/null +++ b/_benchmark/benchmark.md @@ -0,0 +1,32 @@ +# Benchmarks + +To run the benchmark suite, navigate to the same folder and execute the main.go file. +This suite will test the performance of hypermatch with plain Go objects, hypermatch with JSON objects, and [quamina](https://github.com/timbray/quamina) against each other. +Simply run the command go run main.go to start the benchmarks and compare the results. + +Results as of Aug, 29th, 2024 with Go 1.23.0 on MacBook Pro M1 Max, 32GB RAM with 100,000 rules: + +``` +---Starting with hypermatch +adding 100000 rules took 0.51862s +processed 51753 events with 517530 matches in 1.00001s -> 51752.42425 evt/s +processed 103199 events with 1031990 matches in 2.00004s -> 51598.36053 evt/s +processed 156249 events with 1562490 matches in 3.00009s -> 52081.45563 evt/s +processed 209523 events with 2095230 matches in 4.00013s -> 52379.05586 evt/s +processed 262257 events with 2622570 matches in 5.00016s -> 52449.76793 evt/s + +---Starting with hypermatch-json +adding 100000 rules took 1.95150s +processed 39732 events with 397320 matches in 1.00000s -> 39731.90564 evt/s +processed 80432 events with 804320 matches in 2.00003s -> 40215.31718 evt/s +processed 121915 events with 1219150 matches in 3.00006s -> 40637.55840 evt/s +processed 164093 events with 1640930 matches in 4.00009s -> 41022.33768 evt/s +processed 206235 events with 2062350 matches in 5.00013s -> 41245.96473 evt/s + +---Starting with quamina +adding 100000 rules took 4.54697s +processed 4 events with 0 matches in 1.24154s -> 3.22181 evt/s +processed 7 events with 0 matches in 2.31263s -> 3.02685 evt/s +processed 11 events with 0 matches in 3.50818s -> 3.13552 evt/s +processed 15 events with 0 matches in 4.70148s -> 3.19049 evt/s +``` \ No newline at end of file diff --git a/_benchmark/candidates/hypermatch_lib.go b/_benchmark/candidates/hypermatch_lib.go new file mode 100644 index 0000000..d0c002b --- /dev/null +++ b/_benchmark/candidates/hypermatch_lib.go @@ -0,0 +1,78 @@ +package candidates + +import ( + "fmt" + "github.com/SchwarzIT/hypermatch" + "log" +) + +type Hypermatch struct { + h *hypermatch.HyperMatch +} + +func NewHypermatch() *Hypermatch { + return &Hypermatch{h: hypermatch.NewHyperMatch()} +} + +func (h *Hypermatch) Name() string { + return "hypermatch" +} + +func (h *Hypermatch) AddRule(number int, modulo int) { + err := h.h.AddRule(hypermatch.RuleIdentifier(number), hypermatch.ConditionSet{ + {Path: "name", Pattern: hypermatch.Pattern{Type: hypermatch.PatternWildcard, Value: "*-myapp-*"}}, + {Path: "env", Pattern: hypermatch.Pattern{Type: hypermatch.PatternEquals, Value: "prod"}}, + {Path: "number", Pattern: hypermatch.Pattern{Type: hypermatch.PatternEquals, Value: fmt.Sprintf("%d", number%modulo)}}, + {Path: "tags", Pattern: hypermatch.Pattern{ + Type: hypermatch.PatternAllOf, Sub: []hypermatch.Pattern{ + {Type: hypermatch.PatternEquals, Value: "tag1"}, + {Type: hypermatch.PatternEquals, Value: "tag2"}, + }, + }}, + {Path: "region", Pattern: hypermatch.Pattern{ + Type: hypermatch.PatternAnythingBut, Sub: []hypermatch.Pattern{ + {Type: hypermatch.PatternEquals, Value: "moon"}, + }, + }}, + {Path: "type", Pattern: hypermatch.Pattern{ + Type: hypermatch.PatternAnyOf, Sub: []hypermatch.Pattern{ + {Type: hypermatch.PatternEquals, Value: "app"}, + {Type: hypermatch.PatternEquals, Value: "database"}, + }, + }}, + }) + if err != nil { + log.Panicln(err) + } +} + +func (h *Hypermatch) Match(number int, modulo int) int { + event := []hypermatch.Property{ + { + Path: "name", + Values: []string{fmt.Sprintf("app-myapp-%d", number)}, + }, + { + Path: "env", + Values: []string{"prod"}, + }, + { + Path: "number", + Values: []string{fmt.Sprintf("%d", number%modulo)}, + }, + { + Path: "tags", + Values: []string{"tag1", "tag2"}, + }, + { + Path: "region", + Values: []string{"earth"}, + }, + { + Path: "type", + Values: []string{"app"}, + }, + } + matches := h.h.Match(event) + return len(matches) +} diff --git a/_benchmark/candidates/hypermatchjson_lib.go b/_benchmark/candidates/hypermatchjson_lib.go new file mode 100644 index 0000000..515772d --- /dev/null +++ b/_benchmark/candidates/hypermatchjson_lib.go @@ -0,0 +1,60 @@ +package candidates + +import ( + "encoding/json" + "fmt" + "github.com/SchwarzIT/hypermatch" +) + +type HypermatchJson struct { + h *hypermatch.HyperMatch +} + +func NewHypermatchJson() *HypermatchJson { + return &HypermatchJson{h: hypermatch.NewHyperMatch()} +} + +func (h *HypermatchJson) Name() string { + return "hypermatch-json" +} + +func (h *HypermatchJson) AddRule(number int, modulo int) { + jsonStr := fmt.Sprintf(` + { + "name": {"wildcard": "*-myapp-*"}, + "env": {"equals": "prod"}, + "number": {"equals": "%d"}, + "tags": {"allOf": [{"equals": "tag1"}, {"equals": "tag2"}]}, + "region": {"anythingBut": [{"equals": "moon"}]}, + "type": {"anyOf": [{"equals": "app"}, {"equals": "database"}]} + } + `, number%modulo) + var conditionSet hypermatch.ConditionSet + if err := json.Unmarshal([]byte(jsonStr), &conditionSet); err != nil { + panic(err) + } + if err := h.h.AddRule(hypermatch.RuleIdentifier(number), conditionSet); err != nil { + panic(err) + } +} + +func (h *HypermatchJson) Match(number int, modulo int) int { + eventStr := fmt.Sprintf(` + [ + {"Path": "name", "Values": ["app-myapp-%d"]}, + {"Path": "env", "Values": ["prod"]}, + {"Path": "number", "Values": ["%d"]}, + {"Path": "tags", "Values": ["tag1", "tag2"]}, + {"Path": "region", "Values": ["earth"]}, + {"Path": "type", "Values": ["app"]} + ] + `, number, number%modulo) + + var properties []hypermatch.Property + if err := json.Unmarshal([]byte(eventStr), &properties); err != nil { + panic(err) + } + + matches := h.h.Match(properties) + return len(matches) +} diff --git a/_benchmark/candidates/quamina.go b/_benchmark/candidates/quamina.go new file mode 100644 index 0000000..8abbdd3 --- /dev/null +++ b/_benchmark/candidates/quamina.go @@ -0,0 +1,55 @@ +package candidates + +import ( + "fmt" + "log" + "quamina.net/go/quamina" +) + +type Quamina struct { + q *quamina.Quamina +} + +func NewQuamina() *Quamina { + q, err := quamina.New(quamina.WithMediaType("application/json")) + if err != nil { + panic(err) + } + return &Quamina{q: q} +} + +func (q *Quamina) Name() string { + return "quamina" +} + +func (q *Quamina) AddRule(number int, modulo int) { + str := fmt.Sprintf(` + { + "name": [{"shellstyle": "*-myapp-*"}], + "env": ["prod"], + "nunmber": ["%d"], + "tags": ["tag1", "tag2"], + "region": [{"anything-but": ["moon"]}], + "type": ["app", "database"] + } + `, number%modulo) + err := q.q.AddPattern(number, str) + if err != nil { + log.Panicln(err) + } +} + +func (q *Quamina) Match(number int, modulo int) int { + event := fmt.Sprintf(` + { + "name": "app-myapp-%d", + "env": "prod", + "number": "%d", + "tags": ["tag1", "tag2"], + "region": "earth", + "type": "app" + } + `, number, number%modulo) + r, _ := q.q.MatchesForEvent([]byte(event)) + return len(r) +} diff --git a/_benchmark/go.mod b/_benchmark/go.mod new file mode 100644 index 0000000..fca2cf4 --- /dev/null +++ b/_benchmark/go.mod @@ -0,0 +1,8 @@ +module benchmark + +go 1.23.0 + +require ( + github.com/SchwarzIT/hypermatch v0.1.0 + quamina.net/go/quamina v1.3.0 +) diff --git a/_benchmark/go.sum b/_benchmark/go.sum new file mode 100644 index 0000000..9287b89 --- /dev/null +++ b/_benchmark/go.sum @@ -0,0 +1,8 @@ +github.com/SchwarzIT/hypermatch v0.1.0 h1:ytJivIAFP++88WaiIFD/7+yl6Yz4gPfVNrodMe6Tmqc= +github.com/SchwarzIT/hypermatch v0.1.0/go.mod h1:H/WStKuHk4FprRLaR6nBC2PY1oKNqIsDysiKREZLLcY= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= +gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= +quamina.net/go/quamina v1.3.0 h1:8CI8InbNYbswmnda70fU2YItHxEb4cmq0p0mttBKL2w= +quamina.net/go/quamina v1.3.0/go.mod h1:EJ1teLWOcAHYfOUE+w2B6OQq5sAxEiwE0EDlcRxx+TQ= diff --git a/_benchmark/main.go b/_benchmark/main.go new file mode 100644 index 0000000..11bbb9a --- /dev/null +++ b/_benchmark/main.go @@ -0,0 +1,57 @@ +package main + +import ( + "benchmark/candidates" + "context" + "log" + "time" +) + +const ( + numberOfRules = 100000 + eventCheckDuration = 5 * time.Second +) + +type Candidate interface { + Name() string + AddRule(number int, modulo int) + Match(number int, modulo int) int +} + +func main() { + cs := []Candidate{candidates.NewHypermatch(), candidates.NewHypermatchJson(), candidates.NewQuamina()} + + for _, c := range cs { + log.Printf("---Starting with %s\n", c.Name()) + beforeAddingRules := time.Now() + + for i := 0; i < numberOfRules; i++ { + c.AddRule(i, numberOfRules/10) + } + log.Printf("adding %d rules took %.5fs\n", numberOfRules, time.Since(beforeAddingRules).Seconds()) + runEvents(c) + } +} + +func runEvents(c Candidate) { + numberOfEvents := 0 + numberOfMatches := 0 + beforeCheckingEvents := time.Now() + lastPrint := beforeCheckingEvents + ctx, cancel := context.WithTimeout(context.Background(), eventCheckDuration) + defer cancel() + for { + select { + case <-ctx.Done(): + return + default: + numberOfMatches += c.Match(numberOfMatches, numberOfRules/10) + numberOfEvents += 1 + + if time.Since(lastPrint).Seconds() >= 1 { + log.Printf("processed %d events with %d matches in %.5fs -> %.5f evt/s\n", numberOfEvents, numberOfMatches, time.Since(beforeCheckingEvents).Seconds(), float64(numberOfEvents)/time.Since(beforeCheckingEvents).Seconds()) + lastPrint = time.Now() + } + } + } +}