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

perf(logging): prevent unnecessary processing for debug logging #828

Open
wants to merge 6 commits into
base: master
Choose a base branch
from

Conversation

jayzhudev
Copy link
Contributor

@jayzhudev jayzhudev commented May 1, 2023

The debug logging calls can consume significant amount of CPU resources at scale because many of them use function call returns directly as arguments. This resulted in expensive functions being called anyway regardless of the value of debugLibrary. This commit prevents such unnecessary processing.

This commit also includes performance improvements from reducing expensive string split calls and using more performant int/uint to ascii conversions.

Here are some rough reference numbers from e2e testing sending 2000 leaf-list updates in a SetRequest:

Version Time
Before #828 ~2,300 ms
After #828 ~1,600 ms

The inline processing (while calling debug logging functions) can
consume significant amount of CPU resources at scale even when debug
logging is disabled. This commit prevents such unnecessary processing.

This commit also includes performance improvements from reducing
expensive string split calls.
@google-cla
Copy link

google-cla bot commented May 1, 2023

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

@jayzhudev
Copy link
Contributor Author

Note: I'm working on getting my username added to our CLA group. Will keep you posted : )

@jayzhudev
Copy link
Contributor Author

Update: the CLA check is passing now.

@coveralls
Copy link

coveralls commented May 2, 2023

Coverage Status

Coverage: 88.89% (-1.2%) from 90.12% when pulling ad55b2f on jayzhudev:logging-perf into cbef8ea on openconfig:master.

util/path.go Outdated Show resolved Hide resolved
util/reflect.go Show resolved Hide resolved
ygot/render.go Outdated Show resolved Hide resolved
Comment on lines -330 to 340
rv := reflect.ValueOf(root)
switch {
case schema.Key == "":
return nil, status.Errorf(codes.InvalidArgument, "unkeyed list can't be traversed, type %T, path %v", root, path)
case len(path.GetElem()) == 0:
return nil, status.Errorf(codes.InvalidArgument, "path length is 0, schema %v, root %v", schema, root)
case !util.IsValueMap(rv):
}

rv := reflect.ValueOf(root)
if !util.IsValueMap(rv) {
return nil, status.Errorf(codes.InvalidArgument, "root has type %T, expect map", root)
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

can change the last case to

case !util.IsValueMap(reflect.ValueOf(root)):

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 I understand the context correctly, rvis used in the code block after the switch and if statements:

	switch {
	case schema.Key == "":
		return nil, status.Errorf(codes.InvalidArgument, "unkeyed list can't be traversed, type %T, path %v", root, path)
	case len(path.GetElem()) == 0:
		return nil, status.Errorf(codes.InvalidArgument, "path length is 0, schema %v, root %v", schema, root)
	}

	rv := reflect.ValueOf(root)
	if !util.IsValueMap(rv) {
		return nil, status.Errorf(codes.InvalidArgument, "root has type %T, expect map", root)
	}

	var matches []*TreeNode

	listKeyT := rv.Type().Key()
	listElemT := rv.Type().Elem()
	for _, k := range rv.MapKeys() {
		listElemV := rv.MapIndex(k)

...

I think the thought process when this change was made was to reuse rv so reflect.ValueOf is not called twice.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I see, thanks for pointing it out.

Comment on lines +158 to 170

// Early return for performance.
if !strings.Contains(pathAnnotation, "|") && !strings.Contains(pathAnnotation, "/") {
return pathAnnotation, nil
}

paths := strings.Split(pathAnnotation, "|")

for _, pth := range paths {
if len(strings.Split(pth, "/")) == 1 {
if !strings.Contains(pth, "/") {
return pth, nil
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

Does this actually improve performance? Split and Contains both do a full scan of the string right? I'm ok with keeping this, just it does make the code slightly harder to read.

Copy link
Contributor Author

@jayzhudev jayzhudev May 22, 2023

Choose a reason for hiding this comment

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

Thanks for the question. Yes, strings.Split is much more expensive than Contains since it does heap allocation. Here's the result from a simple benchmark test:

goos: linux
goarch: amd64
pkg: perf_testing/string_split
BenchmarkSplitString
BenchmarkSplitString-4      	 4926440	       256.6 ns/op	     160 B/op	       1 allocs/op
BenchmarkContainsString
BenchmarkContainsString-4   	243211302	         4.925 ns/op	       0 B/op	       0 allocs/op
PASS
ok  	perf_testing/string_split	3.246s
func splitString(s string) {
	_ = len(strings.Split(s, "/"))
}

func containsString(s string) {
	_ = strings.Contains(s, "/")
}
var testStr = "abc/def/abc/def/abc/def/abc/def/abc/def"

func BenchmarkSplitString(b *testing.B) {
	for i := 0; i < b.N; i++ {
		splitString(testStr)
	}
}

func BenchmarkContainsString(b *testing.B) {
	for i := 0; i < b.N; i++ {
		containsString(testStr)
	}
}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Here's the genSplit code. It also must pass through the entire string as oppose to strings.Contains can early stop at the first occurrence:

// Generic split: splits after each instance of sep,
// including sepSave bytes of sep in the subarrays.
func genSplit(s, sep string, sepSave, n int) []string {
	if n == 0 {
		return nil
	}
	if sep == "" {
		return explode(s, n)
	}
	if n < 0 {
		n = Count(s, sep) + 1
	}

	if n > len(s)+1 {
		n = len(s) + 1
	}
	a := make([]string, n)
	n--
	i := 0
	for i < n {
		m := Index(s, sep)
		if m < 0 {
			break
		}
		a[i] = s[:m+sepSave]
		s = s[m+len(sep):]
		i++
	}
	a[i] = s
	return a[:i+1]
}

Copy link
Collaborator

@wenovus wenovus May 22, 2023

Choose a reason for hiding this comment

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

I was considering the case where no separators "|" or "/" were present, since that is the only case where the early return does save time. In this case I think not much heap allocations need to be made for adding new path elements, but I agree it still saves that allocation. So I'm good with keeping it. LGTM

@wenovus
Copy link
Collaborator

wenovus commented May 22, 2023

@robshakir for any comments before merge. The coverage decreased because the the debugging statements inside the if statements are not covered. Since these are debugging statements I'm ok with them being uncovered.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants