Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

topdown/object: Rework object.union_n to use in-place merge algorithm. #5127

Merged

Conversation

philipaconrad
Copy link
Contributor

@philipaconrad philipaconrad commented Sep 12, 2022

This PR fixes a performance regression for the object.union_n builtin, discovered in issue #4985.

The original logic for the builtin did pairwise mergeWithOverwrite calls between the input Objects, resulting in many wasted intermediate result Objects that were almost immediately discarded. The updated builtin uses a new algorithm to do a single pass across the input Objects, respecting the "last assignment wins, with merges" semantics of the original builtin implementation.

In the included benchmarks, this provides a 2x speed and 2-3x memory efficiency improvement over using a pure-Rego comprehension to do the same job, and a 6-30x improvement over the original implementation on all metrics as input Object arrays grow larger.

Fixes #4985

The 3x relevant benchmarks are shown below:

  • object.union_n on main branch
  • Pure Rego implementation (Object comprehension)
  • object.union_n on this PR

Benchmarks

Benchmark of object.union_n on main branch:

Running tool: /usr/local/go/bin/go test -benchmem -run=^$ -bench ^BenchmarkObjectUnionN$ github.com/open-policy-agent/opa/topdown

goos: linux
goarch: amd64
pkg: github.com/open-policy-agent/opa/topdown
cpu: Intel(R) Core(TM) i7-8650U CPU @ 1.90GHz
BenchmarkObjectUnionN/10x10-8         	    4965	    236921 ns/op	   79700 B/op	    1441 allocs/op
BenchmarkObjectUnionN/10x100-8        	     526	   2305226 ns/op	  759100 B/op	   11524 allocs/op
BenchmarkObjectUnionN/10x250-8        	     128	  11340810 ns/op	 1946964 B/op	   28243 allocs/op
BenchmarkObjectUnionN/100x10-8        	      34	  32815013 ns/op	 7083399 B/op	  104789 allocs/op
BenchmarkObjectUnionN/100x100-8       	       4	 267571459 ns/op	74272772 B/op	 1025478 allocs/op
BenchmarkObjectUnionN/100x250-8       	       2	 778593630 ns/op	182422912 B/op	 2562647 allocs/op
BenchmarkObjectUnionN/250x10-8        	       7	 171022894 ns/op	44684044 B/op	  641897 allocs/op
BenchmarkObjectUnionN/250x100-8       	       1	1771797807 ns/op	453937248 B/op	 6368408 allocs/op
BenchmarkObjectUnionN/250x250-8       	       1	4780766324 ns/op	1205083376 B/op	15958999 allocs/op
PASS
ok  	github.com/open-policy-agent/opa/topdown	20.584s

Benchmark of Pure Rego implementation:

Running tool: /usr/local/go/bin/go test -benchmem -run=^$ -bench ^BenchmarkObjectUnionNSlow$ github.com/open-policy-agent/opa/topdown

goos: linux
goarch: amd64
pkg: github.com/open-policy-agent/opa/topdown
cpu: Intel(R) Core(TM) i7-8650U CPU @ 1.90GHz
BenchmarkObjectUnionNSlow/10x10-8         	    9382	    130836 ns/op	   40207 B/op	     818 allocs/op
BenchmarkObjectUnionNSlow/10x100-8        	    1122	   1202061 ns/op	  323580 B/op	    5348 allocs/op
BenchmarkObjectUnionNSlow/10x250-8        	     535	   2209046 ns/op	  744128 B/op	   12876 allocs/op
BenchmarkObjectUnionNSlow/100x10-8        	    1210	   1025966 ns/op	  368944 B/op	    6788 allocs/op
BenchmarkObjectUnionNSlow/100x100-8       	     100	  10117234 ns/op	 3093754 B/op	   51983 allocs/op
BenchmarkObjectUnionNSlow/100x250-8       	      42	  26226170 ns/op	 7340888 B/op	  127626 allocs/op
BenchmarkObjectUnionNSlow/250x10-8        	     452	   2805731 ns/op	  865134 B/op	   16867 allocs/op
BenchmarkObjectUnionNSlow/250x100-8       	      46	  25466460 ns/op	 7417977 B/op	  130189 allocs/op
BenchmarkObjectUnionNSlow/250x250-8       	      18	  69078588 ns/op	20508967 B/op	  319094 allocs/op
PASS
ok  	github.com/open-policy-agent/opa/topdown	15.404s

Benchmark of object.union_n on this PR:

Running tool: /usr/local/go/bin/go test -benchmem -run=^$ -bench ^BenchmarkObjectUnionN$ github.com/open-policy-agent/opa/topdown

goos: linux
goarch: amd64
pkg: github.com/open-policy-agent/opa/topdown
cpu: Intel(R) Core(TM) i7-8650U CPU @ 1.90GHz
BenchmarkObjectUnionN/10x10-8         	   25534	     44702 ns/op	   19468 B/op	     374 allocs/op
BenchmarkObjectUnionN/10x100-8        	    4594	    458595 ns/op	  158814 B/op	    2203 allocs/op
BenchmarkObjectUnionN/10x250-8        	    1677	    848999 ns/op	  339319 B/op	    5232 allocs/op
BenchmarkObjectUnionN/100x10-8        	    2611	    570072 ns/op	  161686 B/op	    2293 allocs/op
BenchmarkObjectUnionN/100x100-8       	     340	   3914881 ns/op	 1446257 B/op	   20485 allocs/op
BenchmarkObjectUnionN/100x250-8       	     153	   7588882 ns/op	 3294123 B/op	   51133 allocs/op
BenchmarkObjectUnionN/250x10-8        	    1686	    636962 ns/op	  347029 B/op	    5472 allocs/op
BenchmarkObjectUnionN/250x100-8       	     163	   7328428 ns/op	 3298907 B/op	   51283 allocs/op
BenchmarkObjectUnionN/250x250-8       	      57	  20065940 ns/op	10388723 B/op	  127676 allocs/op
PASS
ok  	github.com/open-policy-agent/opa/topdown	17.998s

Summary of Results

As mentioned above, and as shown in the benchmark results, this PR's changes provide dramatic performance improvements on all metrics, and result in the builtin having around 2-3x better performance overall than a pure Rego equivalent.

2022-09-12_object-union-n-optimization-v2

@philipaconrad philipaconrad self-assigned this Sep 12, 2022
@philipaconrad philipaconrad force-pushed the fix-object-union-n-builtin branch 2 times, most recently from e544e93 to 05f71b9 Compare September 12, 2022 21:04
@philipaconrad
Copy link
Contributor Author

philipaconrad commented Sep 12, 2022

While none of our current tests are erroring, I may have found an edge case for the reworked algorithm as it is right now. I'll write up some test cases for it, and if my suspicions are founded, I'll go back and re-benchmark after patching.

EDIT: There was indeed a subtle issue in iterating backwards through the list of objects. Details about the solution are covered in the comment thread below. I've added unit tests to catch some of the edge cases that were missed earlier.

Interestingly, the corrected code has better performance on large objects in the benchmarks than before, because entire sub-trees of objects can be skipped for recursive merging once they've been "frozen". 😄

Comment on lines +42 to +46
// Example:
// Input: [{"a": {"b": 2}}, {"a": 4}, {"a": {"c": 3}}]
// Want Output: {"a": {"c": 3}}
result := ast.NewObject()
frozenKeys := map[*ast.Term]struct{}{}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need a way to know when a key has been "frozen" by an assignment, such that any objects ahead of it are not allowed to be used for recursive merging while we iterate backwards through the list of objects.

The above example is illustrative of the issue. The middle assignment "blocks" the first assignment of the key "a", so we need some way to ensure we don't accidentally try to merge the value {"b": 2} into the final result object.

Luckily, because pointers to distinct keys in the result object will be unique, we can create a set of the keys in the result Object that should be "frozen" by using a map as a set-like data structure. This set of frozen keys is passed down into the recursive merging function.

Comment on lines +199 to +207
if ok1 && ok2 {
// Check to make sure that this key isn't frozen before merging.
if _, ok := frozenKeys[v2]; !ok {
mergewithOverwriteInPlace(originalValueObj, updateValueObj, frozenKeys)
}
} else {
// Else, original value wins. Freeze the key.
frozenKeys[v2] = struct{}{}
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we see two objects, and the key isn't in the "frozen" set, we can merge them recursively.

However, if one of the values did not correspond to an object type, we should freeze the key because we've found a "blocking" assignment, such that it would have overwritten everything that came before it.

srenatus
srenatus previously approved these changes Sep 13, 2022
topdown/object.go Outdated Show resolved Hide resolved
This commit fixes a performance regression for the object.union_n builtin,
discovered in issue open-policy-agent#4985.

The original logic for the builtin did pairwise mergeWithOverwrite calls
between the input Objects, resulting in many wasted intermediate result
Objects that were almost immediately discarded. The updated builtin uses
a new algorithm to do a single pass across the input Objects, respecting
the "last assignment wins, with merges" semantics of the original builtin
implementation.

In the included benchmarks, this provides a 2x speed and 2-3x memory
efficiency improvement over using a pure-Rego comprehension to do the
same job, and a 6x or greater improvement over the original implementation
on all metrics as input Object arrays grow larger.

Fixes open-policy-agent#4985

Signed-off-by: Philip Conrad <philipaconrad@gmail.com>
Copy link
Contributor

@srenatus srenatus left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

@srenatus srenatus merged commit 5fe4a86 into open-policy-agent:main Sep 14, 2022
@philipaconrad philipaconrad deleted the fix-object-union-n-builtin branch September 14, 2022 20:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

object.union_n() is superlinear on the number of objects to merge
2 participants