-
Notifications
You must be signed in to change notification settings - Fork 2.6k
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
[stdlib] [proposal] Safe Pointer trait #3728
Conversation
Signed-off-by: martinvuyk <[email protected]>
Signed-off-by: martinvuyk <[email protected]>
Signed-off-by: martinvuyk <[email protected]>
For an allocator API, I'd prefer we take inspiration from hwloc, so we can handle requests like "allocate pinned interweaved memory from NUMA nodes 3 and 4 on a hugepage and DMA map it for GPUs 3,4 and 5". We can also probably take inspiration from the DPDK memzone API. Allocation is actually kind of hard, and I think Zig was better than most, but missed a lot of important things. |
Hi @owenhilyard as usual I had to do some homework to even answer you 😆, but I don't think implementing this proposal would be mutually exclusive to those use cases. The main goal here is to abstract stack, heap, arena, etc. pointers and build smarter ones on top. And this is specifically intended for collection types, be it from stdlib or an external lib, so that we don't need to differentiate between weak and strong pointers (and the method of allocation) in the type system itself, which allows them to interact with each other since they'll be considered the same type. We'd have to actually try to implement the following to be sure, but I think it's possible: If there is no way to make that specific configuration with the proposed API, and there is some serious pressure to allow it, we could try to extend it to allow passing some sort of generic configuration struct (having the safe pointer trait itself be parametrized on the allocator and config_type it uses). WDYT?
Totally, and I think we'll need to make some compromises as well. |
If the goal is to build a struct OwnedPointerInner[T: AnyType, Alloc: Allocator]:
var item: T
var allocator: Alloc Then the allocator type can either be a smart pointer to an allocator (once we have a repr transparent equivalent), an empty type if we can fully devirtualize it, or something else. Once we have linear types, more exotic allocators can be modeled by destroying OwnedPointers passed to them, forcing you to free them. I'd love to hear from @VerdagonModular on whether using linear types to model objects that use custom allocators and ensure memory safety is a good idea. For things which look like malloc/calloc/realloc/free, we can probably keep the "automagical" experience, but I think that allocators which can't store all their state in a global somewhere tend to get messy if forced to look like malloc. If we make the allocator an object which lives alongside the allocation, that should work fine, since each allocator can determine what metadata, if any, needs to live there. For hwloc, which is fairly advanced, you would just need allocation size and a pointer to the topology, which is trivial to store. Arena allocators and similar can use the later solution with linear types, since I've never written a program where it would be an actual issue to have linear types for an arena allocator. |
Because each of those examples is "safe" only in a given context, all pointers are.
Benefits
Disadvantages
That is one of the points I made in the proposal, you can have your struct ColosseumPointer[
is_mutable: Bool, //,
type: AnyType,
origin: Origin[is_mutable].type,
address_space: AddressSpace = AddressSpace.GENERIC,
]:
"""Colosseum Pointer (Arena Owner Pointer) that deallocates the arena when
deleted."""
var _free_slots: UnsafePointer[Byte]
"""Bits indicating whether the slot is free."""
var _len: Int
"""The amount of bits set in the _free_slots pointer."""
alias _P = UnsafePointer[type, address_space]
var _ptr: Self._P
"""The data."""
alias _S = ArcPointer[UnsafePointer[OpaquePointer], origin, address_space]
var _self_ptr: Self._S
"""A self pointer."""
alias _G = GladiatorPointer[type, origin, address_space]
... The
All in all what I'm proposing is to hide the complexity of allocators to be the responsibility of the implementation of each kind of pointer. An arena pointer is similar to an
I guess one of the main questions is this: how much do we want to fork the concepts into different types vs. unifying them inside fields at runtime?
Yes I was very intrigued with the possibilities after watching the community meeting. A bit of a side-note, I purposely left out C++ terminology like smart pointers, this is an approach where Pointers are now also responsible for their allocator logic. Where pointers are safe in their promised use case. And not using words like "shared" where it can only be shared in certain ways (Arc pointers don't give you Mutex safety for example). |
We have a borrow checker, so OwnedPointer is thread safe since a mutable borrow of the internal item mutably borrows the OwnedPointer.
OwnedPointer doesn't do ref counting. My understanding is that
Except that
I've been treating
So does this mean parameterizing String over the type of backing pointer? I think that could be interesting.
I'm not sure we want to have all of these extra checks in the hot path of every std data structure.
Part of the point of the stack versions is the cache friendliness. If I can store an entire collection inline, you don't have to go through a pointer to get at the data.
This is a fairly large problem, for some types of programs the majority of the memory is used by pointers. Having to do branchy checks before every pointer deref is also not great. I'd prefer to leverage compile-time guarentees like
It does, you have 7 bytes of padding per object if you make an array of your Pointer type, that's 43% wasted memory per allocation.
But what if I want to use linear types for my memory safety so I don't need to store a bunch of extra pointers in my objects? If I have a pool of 50 million objects allocated, adding 8 bytes to each costs 400 MB of memory, which would be enough to store another 3 million objects if we assume 128 byte objects. Memory costs on that level MUST be opt-in.
I agree that we want pointers to be responsible for managing the allocation type, but I don't know if having their destructor be the way you free them is always the best option. There are substantial costs to that approach that linear types let us avoid if we are willing to have more manual but still safe object management. For usability, it's better, but if you start to scale up the problem by a few million objects you see substantial impacts to memory usage. I think that ARM would also see issues from not being able to do offsets as part of a load, meaning that GladiatorPointer would require 2 instructions to dereference each time. We also lose the ability to place the Colosseum into a global and ditch the need to store a pointer to it in each object or pass it around.
I think we want to do as much as we possibly can at compile time. My thought is that mixing many objects allocated in different ways inside of a collection is somewhat rare, and someone who does that can either use trait objects (once they work), or create their own runtime solution. If a "best runtime solution" exists and is figured out by the community, we can talk about adding it to the standard library. I can't think a time where I had a large enough number of mixed pointer objects that cloning them all when putting them in a collection wasn't an option. Keep in mind that we still have references to unify everything, since all pointers should be able to produce immutable references, which covers logging and other common places where you might want to mix allocation types.
Arc is only supposed to hand out immutable references, so there's no mutation and they are safe. If you want mutability from an Arc you do |
There are no globals in Mojo, _malloc is just a function that gets executed by UnsafePointer.alloc(). So it's kind of already ready to switch to anything if we wanted to parametrize the allocator function.
Yep, that's one of the choices people would have to make according to their use case
Yeah that would be useful, but I think you could achieve that by using a pointer which has no flags padding it.
Yes my main goal with this proposal is defining a trait that all pointers (except
Totally, it should only be the default where it makes sense for the use case, not everywhere. We could still keep pointers which have clear ownership schemes and give comp time guarantees. That is the motivation for defining a trait and injecting the type of pointer that a type works with.
I'm not so sure of how the Mojo compiler internally handles stack allocated
Yes it's not good, we could leave them as
Yes the current design kind of leaves linear pointers out. We could also make it so that the trait requires a linear sink function
|
I'll backpedal the proposal a bit:
|
Signed-off-by: martinvuyk <[email protected]>
Line 412 in 4cd0762
I'm talking about the libc malloc or the tcmalloc used in the Mojo runtime. I can
I think that we should leave a destructor off of it then, so we can use linear types and pointers. The most general version of pointer I can define is "thing for which
I thought you were referring to getting rid of InlineArray, which I can store on the stack if I want to, or use as part of another collection without indirection.
This is why I want to move the ecosystem towards constructs which are free at runtime. We can eat small hits to compile time if it means that we don't have runtime costs that may enter hot loops. I think that making sure that people only go to pointer types with runtime costs if they have no compile-time option should be a priority.
This is why I don't want freeing to be a part of the base trait. Having
Given a
Sorry if it feels like I'm arguing against all of your proposals. I think that adding a pointer with some runtime checks to be more flexible is a good idea, but the allocator API is really hard and is one of those things that is very hard to get right. Any language which didn't have several months of arguments over the API is going to make a lot of mistakes which I think they will regret. I think you're working in the right areas, but we're running into very hard language design problems and they deserve very careful consideration, as well as thinking about what language features would have major effects on the design of the API and whether we want those features in Mojo. It's possible I'm letting perfect be the enemy of good enough, but I'm afraid that if we make a temporary solution we won't be able to retract it later if the ecosystem builds on top of it. We want to avoid another DTypePointer if we can. |
Ok I thought keeping track of the allocs was the responsibility of the OS so I was thinking along the lines of "global allocator function that gets called whenever you call 'malloc' " (yes I'm very new to these concepts).
Yes I actually was, but if users like you want to store it inline 🤷♂️. But my main issue with it is how limited the API is and it needs to duplicate every method from List if we want to make it as ergonomic. And it's also happening for InlineString as well, it goes on and on for every heap collection type.
Agree
Makes sense (I also agree with the goal of reduced traits and making composition of simple base traits the default), but how do you differentiate between "safe pointer behavior" and
Don't worry I actually like this (much better than getting no answer for weeks). It's in discussions like this that better alternatives/good compromises are found IMO. This is an exploratory proposal and I have no problem if it gets rejected, I wanted to get the idea some exposition since I thought it's worth considering. And yes I agree that this will be fundamental infrastructure and it needs careful consideration. |
It's technically allowed to be, but nobody does that except for RTOSes. Most OSes will only hand out 4k, 16k, 2M or 1G blocks to userspace programs, and then the allocator subdivides those blocks. If the OS had to keep track of every single allocation that would mean that the kernel would need to be aware of and track every single JS object in your browser. This is something far better managed from user space, both due to the extra information a program has making allocations easier to pool, the cost of system calls, and the need for things like valgrind and asan.
The ability to have fixed-sized collections of things is fairly important as a primitive, even if most users don't interact with it. For instance, you could implement a BTree node as a something like this: struct BTreeNode[T: AnyType, W: Width]:
var parent: UnsafePointer[Self]
var left: UnsafePointer[Self]
var right: UnsafePointer[Self]
var size: UInt
var items: InlineArray[T, W] This makes your life a lot easier. Or, you can build a
So, once we have trait composition (which is a fairly important part of an algebraic type system), you should be able to do something like: trait Pointer[T: AnyType]:
"""A pointer-like thing which can be dereferenced into an immutable reference."""
fn __deref__[origin: ImmutableOrigin](ref [origin] self) -> ref [origin] T:
...
# Probably just be a marker trait, making a unified constructor is hard.
trait AlwaysInitPointer[T: AnyType]:
"""A pointer which initializes its pointee before returning a reference to the user.""
...
trait OriginPointer[origin: Origin]:
"""A pointer which is confined to a particular origin."""
...
alias SafePointer[T, origin] = Pointer[T] + AlwaysInitPointer[T] + OriginPointer[origin]
trait MutablePointer[T: AnyType]:
"""A pointer which can produce a mutable reference."""
fn __deref__[is_mutable: Bool, //, origin: Origin[is_mutable]](ref [origin] self) -> ref [origin] T
...
alias SafeMutablePointer[T, origin] = SafePointer[T, origin] + MutablePointer[T]
trait DestructablePointer:
"""A pointer which can free itself."""
fn __del__(owned self):
...
alias SafeDestructablePointer[T, origin] = SafePointer[T, origin] + DestrucatablePointer[T]
alias SafeMutableDestructablePointer[T, origin] = SafeDestructablePointer[T, origin] + MutablePointer[T]
# No idea what this looks like internally
trait LinearPointer:
"""A pointer that is a linear type."""
...
alias SafeLinearPointer[T, origin] = SafePointer[T, origin] + LinearPointer[T]
alias SafeMutableLinearPointer[T, origin] = SafeLinearPointer[T, origin] + MutablePointer[T] We may have to add a few more mixins, but you can see that by heavily decomposing the functionality, you can easily express what you want. For instance, a linear type Pointer which isn't always initialized but can produce mutable values and is tied to an origin is I think that we probably want to avoid having UnsafePointer directly implement these interfaces, and instead encourage people to make their own "compile time only" wrappers for whatever behavior they guarantee.
That's good to hear! I'm trying to push for solutions I think will be able to handle all of the weird things I've ever done or heard of someone doing (within reason), since I want to avoid Mojo 1.0 launching with new stability guarantees to find out that some community can't use a chunk of the standard library because we messed up the API. I know it's hubris to try to say we will get everything right on the first try, but we can at least aim for it and have an edition/epoch/etc mechanism to fall back on. |
We kinda already have it (the union part). I think the biggest feature we need ASAP are parametrized traits.
This is the way to go. It's definitely too ambitious to try to unify all of this into a single API. A bit on the keyword bikeshedding spirit 😆, I actually prefer Thanks for taking the time to explain things to me 😄. |
Losing fear of pointers
This would no longer be a cause of any fear, unsafety, or memory leaks
I would not try to sell this as "fearless pointers" since there are many ways
one can make mistakes here. But it is a lot safer than
UnsafePointer
.