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

cmd/compile: must flush dead but modified arguments back to stack before each call #15277

Closed
rsc opened this issue Apr 13, 2016 · 11 comments
Closed
Milestone

Comments

@rsc
Copy link
Contributor

rsc commented Apr 13, 2016

I've been working on timely reclamation of some large buffers in a local program using Go 1.6. One source of retention is function arguments (including receiver). We've marked the arguments as permanently live, which means that for the purposes of garbage collection it is important to be able to nil them out and have that take effect. This works fine in Go 1.6, but it looks like the SSA backend does not flush back writes to arguments that SSA believes are dead. For the current liveness analysis, they're never dead, at least in the sense that the GC looks at them, so they should always be flushed back.

Consider:

package main

func f(x []byte) {
    println("hi")
    x = nil
    println("hi again")
    panic(1)
}

func main() {
    f(make([]byte, 100))
}
$ go run -gcflags -ssa=0 /tmp/x.go
hi
hi again
panic: 1

goroutine 1 [running]:
panic(0x498c0, 0xc82000a118)
    /Users/rsc/go/src/runtime/panic.go:500 +0x18a
main.f(0x0, 0x0, 0x0)                               <<<<<<< CLEARED
    /tmp/x.go:7 +0xbe
main.main()
    /tmp/x.go:11 +0x74
exit status 2
$ go run /tmp/x.go
hi
hi again
panic: 1

goroutine 1 [running]:
panic(0x49880, 0xc82000a118)
    /Users/rsc/go/src/runtime/panic.go:500 +0x18a
main.f(0xc82003bee4, 0x64, 0x64)                    <<<<<<< NOT CLEARED
    /tmp/x.go:7 +0xab
main.main()
    /tmp/x.go:11 +0x4b
exit status 2
$ 

@dr2chase mentioned that perhaps local variables can get flushed into "dead" argument slots too. If that's true, that needs to be fixed too. It might be fixed automatically by making SSA understand args are always live, but if not it needs a separate fix. Because the args are always live, it's especially important not to put local variables there. Local variables are how programmers can switch to something with more limited liveness, as in:

func f(x []byte) {
    x1 := x
    x = nil
    // now x1 will be dropped by GC once it is no longer needed
    ...
}

It would be bad here if x1 simply got allocated to x's slot and ended up pinned live.

There is a separate question of whether the args should be permanently live, but that ship may have sailed. Changing that would get into issues about finalizers and early reclamation that we've basically avoided by having a mechanism for something that stays live even though the compiler can't quite see why. Abandoning that would require a lot of testing and care. For Go 1.7 we just need to make sure that SSA understands the historical liveness rules and lets programmers keep programming against them.

Here is a variant of the above program suitable for testing:

package main

import "runtime"

func f(x []byte, start int64) {
    if delta := inuse() - start; delta < 9<<20 {
        println("after alloc: expected delta at least 9MB, got: ", delta)
    }
    x = nil
    if delta := inuse() - start; delta > 1<<20 {
        println("after alloc: expected delta below 1MB, got: ", delta)
    }
}

func main() {
    x := inuse()
    f(make([]byte, 10<<20), x)
}

func inuse() int64 {
    runtime.GC()
    var st runtime.MemStats
    runtime.ReadMemStats(&st)
    return int64(st.Alloc)
}
$ go run -gcflags=-ssa=0 /tmp/x.go
$ go run /tmp/x.go
after alloc: expected delta below 1MB, got:  10494512
$ 

/cc @randall77 @dr2chase

@rsc rsc added this to the Go1.7Early milestone Apr 13, 2016
@rsc
Copy link
Contributor Author

rsc commented Apr 13, 2016

FWIW, adding

defer func() { println(x) }()

at the top of func f in the test program makes the test pass (because then SSA knows x is needed at various points in the function).

Another variant on the test program, making sure that writes are flushed even when they're about to be overwritten, if code between the write and the overwrite might panic.

package main

import "runtime"

func f(x []byte, start int64) {
    defer func() {
        recover()
        if delta := inuse() - start; delta > 1<<20 {
            println("after alloc: expected delta below 1MB, got: ", delta)
        }
    }()
    if delta := inuse() - start; delta < 9<<20 {
        println("after alloc: expected delta at least 9MB, got: ", delta)
    }
    n1 := runtime.GOMAXPROCS(-1)
    n2 := runtime.GOMAXPROCS(-1)
    x = nil
    y := 1 / (n1 - n2)
    x = make([]byte, 10<<20)
    println(y)
}

func main() {
    x := inuse()
    f(make([]byte, 10<<20), x)
}

func inuse() int64 {
    runtime.GC()
    var st runtime.MemStats
    runtime.ReadMemStats(&st)
    return int64(st.Alloc)
}

@randall77 randall77 self-assigned this Apr 18, 2016
@randall77
Copy link
Contributor

https://go-review.googlesource.com/22365 is a start at this.
This CL illuminates two problems with the "keep value live until return" strategy.

The first is this:

func f(p *int) {
    p = nil
    foo()
}

So the nil value is the one we want to keep live until the end of the function. But nil is rematerializeable, so we will never generate a spill for it. Since there is no spill, we never get a chance to decide to spill back to p.

So spill nil anyway if it is known to be assigned to p, you say? Then what about

func g(p, q *int) {
    p = nil
    q = nil
    foo()
}

The nils get CSEd, so there's only one value to spill. It must get spilled to two places.

Long story short, this is tricky. Continuing to think about it.

Note that the defer trick Russ added does work, but only because it (implicitly) takes the address of the argument. I don't think we want to mark all input args as addrtaken to fix this problem.

By the way, we don't spill other variables to PARAM slots (any more). The only variables that can share a stack slot are two AUTO variables.

@bradfitz bradfitz modified the milestones: Go1.7Maybe, Go1.7Early May 5, 2016
@randall77
Copy link
Contributor

Here's another tricky case:

func f(a int, p *int) int {
    t := a
    a = *p
    g()
    return t + *p + a
}

The t := a assignment is a no-op for SSA, it just records that t takes on the value of a at the start of the function. The a=*p assignment turns into a load. The value loaded must be spilled across the g() call. SSA knows the original value of a is still live at this point, so it spills the loaded value to a temporary location. It is not until after the call that it issues the load from the argument section (to evaluate t) and the load from the temporary (to evaluate 'a').

Forcing the compiler to write back the value of a to the argument section at the call means we can't wait until after the call to load the original a value.

Not sure what to do about this either. Yet more trickiness...

@randall77
Copy link
Contributor

Another fun example

type big [1 << 20]byte
type T struct {
    x, y *big
}
var p *big
func f(t T) *big {
    t = T{p, nil}
    p = nil
    runtime.GC()
    return t.x
}

t is decomposed by SSA into its individual parts. t.y is never used, so it isn't spilled. That leaves the old value of t.y on the stack when GC is called. (The legacy compiler overwrote the old value of t.y with nil.)

This is similar to the p = nil example I mentioned a few comments ago, but adds the decomposition problem to the mix.

@dr2chase
Copy link
Contributor

Do we want a special gc.NilForEffect "value".

type big [1 << 20]byte
type T struct {
    x, y *big
}
var p *big
func f(t T) *big {
    t = T{p, gc.NilForEffect}
    p = nil
    runtime.GC()
    return t.x
}

NFE would be a minor pain to implement because of its interactions with CSE, register allocation, and deadcode, but at least we would be communicating our intent clearly. There's also need to be some sort of a "don't get cute" rule (a go vet check?) for use of gc.NilForEffect passed as a parameter or flowing into a conditional assignment.

@ianlancetaylor
Copy link
Contributor

For Go 1.7, what if you just treat nil specially? Always flush stores of nil to argument values before every function call. Seems like that would address 99% of the cases for 1.7, and for 1.8 we can perhaps not keep arguments live through the function.

@rsc
Copy link
Contributor Author

rsc commented May 17, 2016

This is not a maybe. This is a critical bug that needs to be fixed for Go 1.7.

@rsc rsc modified the milestones: Go1.7, Go1.7Maybe May 17, 2016
@rsc
Copy link
Contributor Author

rsc commented May 17, 2016

I don't think we should special-case nils. There are other writes that could happen too.

What is the downside to marking all the arguments and results addrtaken for Go 1.7?

@randall77
Copy link
Contributor

I just mailed a potential fix for this. It will need some discussion.

Basically, it implements runtime.KeepAlive internally and calls it for pointer arguments after each call and at each return.

Pointer arguments only, because those are the ones that matter for finalizers, and compound objects (slices, structs, ...) are a much more invasive change.

The only extra code this CL will end up generating is spills for otherwise dead values. It does not explicitly write back nil to argument slots, instead it marks the input argument slot as not live at the appropriate safe points. This requires turning off the "inputs are always live everywhere" code you will see commented out in the CL. We need to decide whether that code matters. (The comment says it is there to help with unnamed input parameters, not sure how much we care about those.)

@gopherbot
Copy link

CL https://golang.org/cl/22365 mentions this issue.

@rsc
Copy link
Contributor Author

rsc commented May 26, 2016

I am still seeing memory leaks in the production code from which my first test case was distilled. Here is a slightly different distillation that still demonstrates the leaks.

// run

// Copyright 2016 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package main

import "runtime"

type big [10 << 20]byte

func f(x interface{}, start int64) {
    x1, x := x, nil
    if delta := inuse() - start; delta < 9<<20 {
        println("after alloc: expected delta at least 9 MB, got", delta>>20, "MB")
    }
    g(x1)
    if delta := inuse() - start; delta > 1<<20 {
        println("after drop: expected delta below 1 MB, got", delta>>20, "MB")
    }
}

func main() {
    x := inuse()
    f(new(big), x)
}

func inuse() int64 {
    runtime.GC()
    var st runtime.MemStats
    runtime.ReadMemStats(&st)
    return int64(st.Alloc)
}

//go:noinline
func g(interface{}) {}

This program runs fine with go run -gcflags=-ssa=0 but with go run it complains about 10 MB in use when it expects 1 MB.

The code is trying to allocate a local variable x1 initialized from the parameter x and then nil out x, under the theory that, in contrast to x, the local variable will not be kept alive for the lifetime of the function. In particular, after the call to g, x1 is no longer needed and should stop being live, allowing the underlying allocation to be reclaimed. The theory works in the old back end.

In the SSA back end, there's a leak. Looking at the liveness analysis that runs on the SSA-generated assembly:

$ go tool compile -live /tmp/x.go
/tmp/x.go:13: live at entry to f: x
/tmp/x.go:15: live at call to inuse: x
/tmp/x.go:16: live at call to printlock: x
/tmp/x.go:16: live at call to printstring: x
/tmp/x.go:16: live at call to printsp: x
/tmp/x.go:16: live at call to printint: x
/tmp/x.go:16: live at call to printsp: x
/tmp/x.go:16: live at call to printstring: x
/tmp/x.go:16: live at call to printnl: x
/tmp/x.go:16: live at call to printunlock: x
/tmp/x.go:18: live at call to g: x
/tmp/x.go:19: live at call to inuse: x
/tmp/x.go:20: live at call to printlock: x
/tmp/x.go:20: live at call to printstring: x
/tmp/x.go:20: live at call to printsp: x
/tmp/x.go:20: live at call to printint: x
/tmp/x.go:20: live at call to printsp: x
/tmp/x.go:20: live at call to printstring: x
/tmp/x.go:20: live at call to printnl: x
/tmp/x.go:20: live at call to printunlock: x

All those live x are supposed to be OK because I set x to nil. But it appears that even though SSA needs x1 across the initial calls to inuse and printxxx, it does not put x1 itself on the stack. Instead it chooses not to nil x out and simply reload x1 from x later. I infer this from the fact that x1 must be saved somewhere yet does not appear in the liveness bitmaps, and therefore x1 is being reloaded from x rather than being saved. But that has the effect of never nilling out x, and then since x is live for the entire function, so is the allocation I'm trying to make available for garbage collection.

I don't believe we can reasonably drop the whole-function-lifetime liveness of function parameters at this point in the Go 1.7 cycle, but it's clear we probably need to do that at the start of the Go 1.8 cycle. This is too confusing. (On the other hand, I expect subtle keepalive problems in Go 1.8.)

I can fix my program by inserting _ = &x at the top of the function. That records x as having its address taken, at which point SSA is no longer comfortable assuming that x1 can be reloaded from x later in the function nor that the store of nil can be elided, and then all the "right" things start happening. I will do that, taking this off any kind of critical path and giving us a pattern to suggest to others who run into this.

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

No branches or pull requests

6 participants