Skip to content

Conversation

@brancz
Copy link

@brancz brancz commented Mar 25, 2023

In profiling data from production environments showed that ~20% of all CPU time is spent in the readUint function.

https://pprof.me/b97e475/

Upon closer examination we found that a fair amount of time was spent in the runtime.makeslice functions. Which suggested that reducing allocations of byte buffers used to read files from disk should yield an reduction in CPU usage.

This patch implements reusing byte buffers for reading files from the cgroupfs into memory, which yielded a ~13% decrease.

$ benchstat old.txt new.txt
name           old time/op    new time/op    delta
Readuint64-10    7.93µs ± 2%    6.89µs ± 1%  -13.16%  (p=0.008 n=5+5)

name           old alloc/op   new alloc/op   delta
Readuint64-10      840B ± 0%      120B ± 0%  -85.71%  (p=0.008 n=5+5)

name           old allocs/op  new allocs/op  delta
Readuint64-10      5.00 ± 0%      3.00 ± 0%  -40.00%  (p=0.008 n=5+5)

And a ~13% decrease of what was originally ~20%, means this should reduce the CPU cycles spent by about 2% overall, but since this also removes some more pressure from garbage collection, the real saving is likely higher, but will only be able to be observed when rolled out in production environments.

We did this as part of a series of live streams where we attempt to optimize popular and widely deployed open-source software in an attempt to reduce the world wide CPU cycles. This was episode number 4: https://www.youtube.com/watch?v=7uz1Zecv1HM

@brancz brancz force-pushed the readuint branch 2 times, most recently from 8ab6621 to 150eee2 Compare March 25, 2023 10:35
In profiling data from production environments showed that ~20% of all
CPU time is spent in the `readUint` function.

https://pprof.me/b97e475/

Upon closer examination we found that a fair amount of time was spent in
the `runtime.makeslice` functions. Which suggested that reducing
allocations of byte buffers used to read files from disk should yield an
reduction in CPU usage.

This patch implements reusing byte buffers for reading files from the
cgroupfs into memory, which yielded a ~13% decrease.

```
$ benchstat old.txt new.txt
name           old time/op    new time/op    delta
Readuint64-10    7.93µs ± 2%    6.89µs ± 1%  -13.16%  (p=0.008 n=5+5)

name           old alloc/op   new alloc/op   delta
Readuint64-10      840B ± 0%      120B ± 0%  -85.71%  (p=0.008 n=5+5)

name           old allocs/op  new allocs/op  delta
Readuint64-10      5.00 ± 0%      3.00 ± 0%  -40.00%  (p=0.008 n=5+5)
```

And a ~13% decrease of what was originally ~20%, means this should
reduce the CPU cycles spent by about 2% overall, but since this also
removes some more pressure from garbage collection, the real saving is
likely higher, but will only be able to be observed when rolled out in
production environments.

Signed-off-by: Frederic Branczyk <[email protected]>
v, err := os.ReadFile(path)
// readUint reads a uint64 from the given file and uses the given buffer as
// scratch space. The buffer is intended to be reused across calls.
func readUint(path string, buf *bytes.Buffer) (uint64, error) {
Copy link
Member

@dcantah dcantah Mar 26, 2023

Choose a reason for hiding this comment

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

Is this threadsafe? If you called any of the methods that now invoke this in a couple goroutines, there's no guarantee we'll get back the value we're expecting as another read might finish before, you may end up with interleaved data as shown below, or another thread might hit the buf.Reset before another gets to calling buf.String() and you'd end up trying to parse an empty string.

func readUintHelper(path string, expected uint64, buf *bytes.Buffer) error {
	actual, err := readUint(path, buf)
	if err != nil {
		return err
	}
	if expected != actual {
		return fmt.Errorf("expected %d, got %d", expected, actual)
	}
	return nil
}

func TestReadUint(t *testing.T) {
	var (
		buf = bytes.NewBuffer(nil)
		eg  errgroup.Group
	)
	eg.Go(func() error {
		return readUintHelper("testdata/uint100", 100, buf)
	})
	eg.Go(func() error {
		return readUintHelper("testdata/uint200", 200, buf)
	})
	eg.Go(func() error {
		return readUintHelper("testdata/uint300", 300, buf)
	})

	if err := eg.Wait(); err != nil {
		t.Fatal(err)
	}
}

Yields:

dcantah@dcantah ~/go/src/github.com/containerd/cgroups ((HEAD detached at brancz/readuint)) $ go test ./cgroup1/ -v -run TestReadUint
=== RUN   TestReadUint
    utils_test.go:66: expected 100, got 300100
--- FAIL: TestReadUint (0.00s)
FAIL
FAIL    github.com/containerd/cgroups/v3/cgroup1        0.003s
FAIL
dcantah@dcantah ~/go/src/github.com/containerd/cgroups ((HEAD detached at brancz/readuint)) $ go test ./cgroup1/ -v -run TestReadUint
=== RUN   TestReadUint
    utils_test.go:66: expected 100, got 200100
--- FAIL: TestReadUint (0.00s)
FAIL
FAIL    github.com/containerd/cgroups/v3/cgroup1        0.002s
FAIL
dcantah@dcantah ~/go/src/github.com/containerd/cgroups ((HEAD detached at brancz/readuint)) $ go test ./cgroup1/ -v -run TestReadUint
=== RUN   TestReadUint
    utils_test.go:66: strconv.ParseUint: parsing "": invalid syntax
--- FAIL: TestReadUint (0.00s)
FAIL
FAIL    github.com/containerd/cgroups/v3/cgroup1        0.002s
FAIL

Copy link
Member

Choose a reason for hiding this comment

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

Oh nice, I forgot the cg1 code has a lock around most of these for the main *cgroup operations. Let me see if there's any that the above might be a concern

Copy link
Member

@dcantah dcantah Mar 26, 2023

Choose a reason for hiding this comment

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

At the very least if you created individual controllers (e.g. cgroup1.NewMemory() -> mem.Stat()) you could run into this

Copy link
Author

Choose a reason for hiding this comment

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

Yeah I was briefly contemplating using a sync.Pool, but the way I understood the flow of the code there is no concurrency in the controllers. It’s entirely possible I missed the code path that does, so if that’s the case I’d happily change it to a pool to make it concurrency safe.

Copy link
Member

Choose a reason for hiding this comment

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

In the controllers themselves there's not, but the library doesn't dissuade you from doing this yourself. How containerd uses this library is mainly through manipulating the main Cgroup interface from New or Load (https://github.com/containerd/cgroups/blob/main/cgroup1/cgroup.go#L37), and Stat and a bunch of others for this object lock as they collect the data from each controller/subsystem, so containerd's usage may be fine.

The library offers you the ability to Create individual controllers however with New{Controller} functions (NewMemory for example). These don't have any locking around them, so if anyone using this library relied on making an individual controller and then spinning up some goroutines to grab/update data, they may run into this now.

Copy link
Author

Choose a reason for hiding this comment

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

You’re absolutely right. How do you propose to move forward? I would say we can either analyze usages of the library and if there are no current concurrent usages we add a code comment saying they are not concurrency safe, or we go straight to the hammer and use a buffer pool?

I generally prefer to not add synchronization if I don’t absolutely have to, but ultimately your call as a maintainer.

Copy link
Member

@dcantah dcantah Mar 27, 2023

Choose a reason for hiding this comment

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

You wanna benchmark the buffer pool idea? It'd be a shame if this caused us to not be able to freely call these however we want, so anything to keep that alive would be nice. Thanks for the quick follow ups! This is awesome to see btw, very cool stream idea

Copy link

@Kern-- Kern-- Mar 29, 2023

Choose a reason for hiding this comment

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

I think the problem before was that os.ReadFile returns a byte slice which forces an allocation because it escapes the stack.

You can make this much simpler and still thread safe by creating a slice with make([]byte, 64) and f.Read to read the data. Since that slice isn't returned from the function, it stays on the stack.

Copy link
Member

@dcantah dcantah Mar 29, 2023

Choose a reason for hiding this comment

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

Oh fantastic point duh, can confirm it shouldn't escape:

package main

import (
	"log"
	"os"
)

func readFile() error {
	f, err := os.Open("howdy.txt")
	if err != nil {
		return err
	}
	defer f.Close()

	howdy := make([]byte, 64)
	_, err = f.Read(howdy)
	if err != nil {
		return err
	}
	return nil
}

func main() {
	if err := readFile(); err != nil {
		log.Fatal(err)
	}
}
alloc go build -gcflags '-m -l'
# github.com/dcantah/fun/alloc
./main.go:15:15: make([]byte, 64) does not escape
./main.go:25:12: ... argument does not escape

Every so often we must be reminded of why io.Reader's signature is the way it is 😆

Copy link
Member

Choose a reason for hiding this comment

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

@brancz Want to take this for a spin?

func NewCpuacct(root string) *cpuacctController {
return &cpuacctController{
root: filepath.Join(root, string(Cpuacct)),
buf: bytes.NewBuffer(make([]byte, 0, 32)),
Copy link
Member

Choose a reason for hiding this comment

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

I like the idea!

I'm not sure how many users are using single subsystem. If we agree with this change, we should add comment about Stat function with it is not thread-safety as warning.

cc @AkihiroSuda

Copy link
Member

Choose a reason for hiding this comment

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

It'd be any of the methods that call readUint not just Stat, but yea either a warning or some way to avoid this #275 (comment)

Copy link
Contributor

Choose a reason for hiding this comment

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

I wanted to get try this.
See: #277

manugupt1 added a commit to manugupt1/cgroups that referenced this pull request Apr 1, 2023
ReadUint is modified to take a byteBuffer and the file contents
are copied into it. The buf sync pool is created at the controller
level so that it controller determines if it needs one.

Ran a benchmark on pids test.

Earlier:

goos: linux
goarch: amd64
pkg: github.com/containerd/cgroups/v3/cgroup1
cpu: 11th Gen Intel(R) Core(TM) i7-1165G7 @ 2.80GHz
BenchmarkPids-8   	   59758	     19905 ns/op	    2064 B/op	      15 allocs/op
PASS
ok  	github.com/containerd/cgroups/v3/cgroup1	1.405s

With this change:
goos: linux
goarch: amd64
pkg: github.com/containerd/cgroups/v3/cgroup1
cpu: 11th Gen Intel(R) Core(TM) i7-1165G7 @ 2.80GHz
BenchmarkPids-8   	   57368	     21665 ns/op	    1345 B/op	      13 allocs/op
PASS
ok  	github.com/containerd/cgroups/v3/cgroup1	1.467s

Inspired from: containerd#275

Signed-off-by: Manu Gupta <[email protected]>
@brancz
Copy link
Author

brancz commented Apr 6, 2023

Closing in favor of #278

@brancz brancz closed this Apr 6, 2023
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.

5 participants