Skip to content

variable: return native Go pointers to bpf map memory #1607

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

Open
wants to merge 2 commits into
base: main
Choose a base branch
from

Conversation

ti-mo
Copy link
Collaborator

@ti-mo ti-mo commented Nov 7, 2024

Pending the proposal in golang/go#70224, here's the current API proposal for returning Go pointers to 'off-heap' bpf map memory:

func MemoryPointer[T any](mm *Memory, off uint64) (*T, error)
func VariablePointer[T any](v *Variable) (*T, error)

We need a bit of help from the Go runtime to manage the lifecycle of the underlying memory mapping. Currently, this mmaps over a piece of Go heap to allow the GC to track pointers into the bpf map memory, obviating the need for tying the mmap's lifetime to an ebpf.Memory. Instead, any Go pointers into the mmap will keep it alive, removing the risk of use-after-free.

Clearly, this involves some dark arts, so we're not comfortable pushing this on users since Collection.Variables is populated by default. Any change in the runtime or allocator may break this, and the fallout means Go programs segfaulting without any hint as to why, which is not acceptable.

--- Edit ---

Since the runtime.AddManualMemoryCleanup proposal may take a while to implement, land and ship in a Go version we target, I've revived the patch set and put the heap-mapping behaviour behind CollectionOptions.UnsafeVariableExperiment. MemoryPointer is yet unexported, since Map.Memory() is typically called directly by the user and we'd need to store the feature flag in *Map, which doesn't feel ideal.

Alternatively, we could go for an env feature flag instead of CollectionOptions (EBPFUNSAFEVARIABLES=true?), which would allow us to export MemoryPointer and transparantly switch between implementations.

@ti-mo ti-mo mentioned this pull request Feb 18, 2025
@ti-mo ti-mo changed the title [WIP] variable: return native Go pointers to underlying bpf map values variable: return native Go pointers to underlying bpf map values Feb 25, 2025
@ti-mo ti-mo changed the title variable: return native Go pointers to underlying bpf map values variable: return native Go pointers to bpf map memory Feb 27, 2025
@ti-mo ti-mo force-pushed the tb/atomic-memory branch 3 times, most recently from 2d43b45 to 821aa80 Compare February 27, 2025 15:13
@ti-mo ti-mo marked this pull request as ready for review February 27, 2025 15:48
@ti-mo ti-mo requested a review from a team as a code owner February 27, 2025 15:48
Copy link
Collaborator

@lmb lmb left a comment

Choose a reason for hiding this comment

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

Can either of you refresh my mind again what the killer feature for this is? Seems like @mejedi is interested in using this for slices?

memory_unsafe.go Outdated
}

var t T
size := binary.Size(t)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why is this binary.Size and not unsafe.Sizeof?

Copy link
Collaborator

Choose a reason for hiding this comment

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

This also doesn't take unsafe.Alignof into account.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Why is this binary.Size and not unsafe.Sizeof?

unsafe.Sizeof() tolerates variable-sized types like untyped ints and also returns the size of the slice descriptor if the argument is a slice. binary.Size() rejects both. I think it's safer to reject variable-sized for now to avoid potential surprises.

This also doesn't take unsafe.Alignof into account.

Don't the next few lines take care of ensuring aligned access?

Copy link
Collaborator

Choose a reason for hiding this comment

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

I think it's safer to reject variable-sized for now to avoid potential surprises.

Unfortunately binary.Size ignores trailing padding, which means that it is not safe to use that function here.

struct foo {
  uint64_t a;
  uint32_t b;
  // implicit 4 byte padding
}

I agree with you that these types shouldn't be allowed, but I think it might be better to do that in the caller of this function. Then we have a separation of concerns:

  • this function ensures that we only transmute memory that is "safe" per the memory model, respecting full size and alignment.
  • the caller can further restrict this to sized types, maybe ones that embed structs.HostLayout, etc.

Don't the next few lines take care of ensuring aligned access?

The alignment of a type can be smaller than its size, so I think that the check below is incorrect in the sense that it rejects correctly aligned structs. For example:

struct foo {
  uint64_t a, b;
}

This must be aligned to 8 bytes, not 16.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Unfortunately binary.Size ignores trailing padding, which means that it is not safe to use that function here.

I see. I guess that's the reasoning behind unsafeBackingMemory as well; there you're using reflect.Type.Size(), which is analogous to unsafe.Sizeof(). Then, you compare it to binary.Size() to detect padding.

Thanks for the feedback! I'll see what I can come up with. Looks like we also need better tests. 🙂

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This should now be addressed, PTAL.

@ti-mo ti-mo force-pushed the tb/atomic-memory branch from 821aa80 to fe8e5b1 Compare March 13, 2025 11:36
@ti-mo
Copy link
Collaborator Author

ti-mo commented Mar 13, 2025

Can either of you refresh my mind again what the killer feature for this is? Seems like @mejedi is interested in using this for slices?

I think the most pressing use case is atomics, which is currently not supported at all. Array variables currently work through Get() and Set(), but there's always a marshaling cost. Also, simply having the convenience of manipulating BPF variables like they are Go variables is nice.

@ti-mo ti-mo requested a review from lmb March 13, 2025 11:43
memory_unsafe.go Outdated
}

var t T
size := binary.Size(t)
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think it's safer to reject variable-sized for now to avoid potential surprises.

Unfortunately binary.Size ignores trailing padding, which means that it is not safe to use that function here.

struct foo {
  uint64_t a;
  uint32_t b;
  // implicit 4 byte padding
}

I agree with you that these types shouldn't be allowed, but I think it might be better to do that in the caller of this function. Then we have a separation of concerns:

  • this function ensures that we only transmute memory that is "safe" per the memory model, respecting full size and alignment.
  • the caller can further restrict this to sized types, maybe ones that embed structs.HostLayout, etc.

Don't the next few lines take care of ensuring aligned access?

The alignment of a type can be smaller than its size, so I think that the check below is incorrect in the sense that it rejects correctly aligned structs. For example:

struct foo {
  uint64_t a, b;
}

This must be aligned to 8 bytes, not 16.


// Bump the value by 1 using a bpf program.
want := uint32(1338)
a32, err := VariablePointer[atomic.Uint32](obj.Atomic)
Copy link
Collaborator

Choose a reason for hiding this comment

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

I should've said: it also only contains unexported fields. Passing the type to binary.Read or Write will cause an error. Is that the correct choice to make here?

@ti-mo ti-mo force-pushed the tb/atomic-memory branch from fe8e5b1 to 7b7052b Compare March 27, 2025 14:30
Copy link
Collaborator

@lmb lmb left a comment

Choose a reason for hiding this comment

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

Thanks for adding all those tests, they look excellent to me! Left one thought around how to enable / disable this, feel free to deal with that as you wish. I have concerns around what types should be allowed, where I'd appreciate it if we can be even more conservative for a start.

// Experimental: enabling this may cause segfaults or compromise the memory
// integrity of your Go application or the bpf maps representing Variables.
// Use at your own risk.
UnsafeVariableExperiment bool
Copy link
Collaborator

Choose a reason for hiding this comment

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

I remember that you mentioned using an environment variable for this. You ended up deciding against it? I kind of came to like that idea.

Using an env variable has the nice property that the author of the application has to opt in to enabling this. It would discourage / prevent use of this feature in libraries. The downside is that applications have to os.Setenv in main.

Another way to tackle that would be using a (much hated by you) build tag. That has similar behaviour (the author of the binary gets to decide) without having to much with the environment.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yep, didn't manage to get that done last week. Initially, I was tempted to extend the experiment to MemoryPointer() as well, but while implementing the type checker, I realized MemoryPointer() for arenas would be relatively useless without allowing pointers, so that needs a bit more work.

// Frozen maps can't be mapped rw either.
frozen := mustMmapableArray(t, 0)
qt.Assert(t, qt.IsNil(frozen.Freeze()))
fz, err := frozen.Memory()
Copy link
Collaborator

Choose a reason for hiding this comment

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

Out of curiosity: What happens when doing Memory() and then Freeze()? Does freezing fail?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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


// Bump the value by 1 using a bpf program.
want := uint32(1338)
a32, err := VariablePointer[atomic.Uint32](obj.Atomic)
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'd argue yes; it would be quite hamstrung otherwise.

There are atomic free functions that you can use without relying on the atomic types.

but if someone wants to stuff a mutex in there, it feels far-fetched to try and stop them.

Why would they want to though? The shape and internal semantics of a Go mutex are not documented, so the only way to share one between C and Go is to look at the implementation details of the Go runtime. That seems like a place of hurt to me. Makes a lot more sense to me the other way around, where C exposes a "stable" API for a mutex that is shared between kernel and user space. We'd then provide a blessed implementation of that (using free atomic functions for example).

I saw that you added structs.HostLayout to one of the tests and that made me think it would make a lot of sense to add this as a requirement for any struct passed via this mechanism. Because in a world where layout is reordered it doesn't make sense to pass anything else. In turn, I realised that this would mean atomic.Bool etc. don't work anymore, since they don't have a guaranteed layout. That's a shame but not without a work around, and I'm convinced that allowing sync.Mutex is a bad idea.

Playing that out in my head has me pretty convinced that we should adopt the same rules as package reflect or binary has:

  • Only structs without unexported fields
  • Containing structs.HostLayout

Maybe we can add special cases for atomic.Bool etc. on the basis that they are always the size of the basic element and therefore no reordering is possible?

//
// Doesn't check for loops since it rejects pointers. Should that ever change, a
// visited set would be needed.
func checkType(name string, t reflect.Type) error {
Copy link
Collaborator

Choose a reason for hiding this comment

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

I have a hunch that you'll have to allow uintptr because that is the natural way to represent a C pointer in Go. That only works as long as user space and kernel space are both 64 bit of course xD

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Good point, we can allow uintptr if it's 8 bytes.

Reminder: we still treat btf.Pointer as 64 bits #963.

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