-
Notifications
You must be signed in to change notification settings - Fork 249
cgroup1: Reduce allocations when reading uints from files #275
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
Conversation
8ab6621 to
150eee2
Compare
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) { |
There was a problem hiding this comment.
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
FAILThere was a problem hiding this comment.
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
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 escapeEvery so often we must be reminded of why io.Reader's signature is the way it is 😆
There was a problem hiding this comment.
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)), |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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
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]>
|
Closing in favor of #278 |
In profiling data from production environments showed that ~20% of all CPU time is spent in the
readUintfunction.https://pprof.me/b97e475/
Upon closer examination we found that a fair amount of time was spent in the
runtime.makeslicefunctions. 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.
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