[Proposal] Upgrading Trivial

Discussion thread for the linked proposal.

Author: @clattner

I think that Trivial should be broken up into at least three parts, TriviallyMovable, TriviallyCopyable, and TriviallyDestructible.

I think it would expand how much benefit the idea of “trivial operations” can provide to Mojo as a whole. For instance, almost every type which is Movable and isn’t self-referential (ex: String and InlineList) is trivially movable. Since the old location is invalidated, pointers can be memcpy’d along with everything else. TriviallyCopyable includes almost every type which doesn’t contain pointers or references and is Copyable. TriviallyDestructable is what most values are right now, and I think a large number of structs, especially in numeric code, would have a trivial destructor. We can also have combinations, such as a type which contains a Pointer. That pointer might be mutable, and thus not “copyable”, but it might be perfectly happy to move via memcpy and be ignored in the destructor.

I think that there may also be space for a TriviallyDefaultable, mainly to be used for integer types to enable using memset to zero-initialize collections, but it should generalize to any type which can be default constructed to a specific byte pattern.

2 Likes

trait Trivial(Copyable, Movable): For file handles, they’re Trivial but maybe we don’t want them to be copyable?

Also maybe we’ll want some types that can’t be moved but can be trivially destructed? Or am I missing something?

I’m not sure how I feel about these synthesizers being a trait, I would imagine this sort of metaprogramming is what decorators are for.

For one thing, it seems inconsistent to have traits you implement but don’t actually implement, at least not explicitly. Does it mean that there now conceptually exists two types of traits - explicit and implicit? If so, how do you know which is which.

1 Like

I think the goal is to eventually convert them into default impls which use reflection, which removes all of the magic.

Trivial has a special thing you need to add to conform to it, which means that you only implicitly conform to it if you do a very specific thing you wouldn’t do for any other reason. We’ll have a few more of these on marker traits until implicit trait conformance goes away.

1 Like

Yes, but what you’re required to do in other to conform is different from what you would be required to do to conform to any other trait, which makes this particular trait special.

Personally I feel like things should be unbolted from the compiler rather than more things being bolted to it. Modular aims to reduce compiler magic after all.

Well, they’re only super special until we get default impls, then it goes back to where it was before.

1 Like

I strongly agree with @owenhilyard: I believe we need at least three orthogonal traits that describe “trivial” memory operations:

  • trivial copyability
  • trivial movability
  • trivial destructibility

I proposed this on Discord a while back.

As a separate concern, I think we should consider a more descriptive (and more concise) name than TriviallyCopyable. Alternatives:

  • MemCopyable, MemMovable, MemErasable
  • BitCopyable, BitMovable, BitErasable
  • ByteCopyable, ByteMovable, ByteErasable

The term “erasable” is just a placeholder. We should find a term that makes sense both with and without the Mem/Bit/Byte prefix. Without the prefix, the term should mean “implicit destructibility”, i.e. the trait associated with the dunder __del__.

IMO we should also consider renaming Mojo’s __del__ method, because it behaves very differently to Python’s version of __del__ (i.e. GC reclamation of an object). Mojo’s __del__ doesn’t actually need to delete anything. It can return a resource to a pool, or decrement the refcount of a shared resource. The most we can say is that Mojo’s __del__ clears/resets/uninitializes a variable. This is not the same thing as “destroying” a resource, especially for reference-semantic types. By choosing a different name for __del__, we might be able to avoid a bunch of confusion.

In summary:

  • We should be considering six traits simultaneously:
    • Copyable, Movable, ImplicitlyDestructible
    • ByteCopyable, ByteMovable, Byte?ImplicitlyDestructible
  • We should find a consistent (and self-explanatory) naming convention for these traits.

See also: @Verdagon’s Github issue concerning types that are not ImplicitlyDestructible.

Links to discord inside of the forum are a bit annoying, can you please expand on the meanings for each of the traits?

The Discord discussion is just a series of comments wherein I come to the realization that all six of the above traits are necessary. In short: for any subset of the three Byte... traits, you can imagine a useful struct that (only) conforms to that subset. So the three traits are orthogonal; they cannot be subsumed by a single trait, such as the Trivial trait that Chris has proposed.

The traits with the Mem/Bit/Byte prefix are just the traits for “trivial” copyability/movability/destructibility. These are established concepts in C++. A trivially copyable type is one that can be copied using memcpy, a trivially movable type is one that can be moved using memcpy, and a trivially destructible type is one that can be destroyed without running any cleanup code. In all cases, this means that when you have a buffer full of such values, you don’t need to iterate over the values one-by-one in order to copy/move/destroy them.

Hi all,

I really appreciate the thoughts and feedback here!

One of the extremely debatable arts of language design is how far and how fast to take changes. The intent of this step is to break the notion of trivial from register passable. This seems (to me) to be a clearly valuable step forward.

Once you accept that step, it’s a very reasonable question of how far to take it. We could split it into infinitely precise atoms instead of keeping a molecule. The benefit of that is more control, but the tradeoff is more complexity.

My feeling on this is that we should take this one step at a time. If we fix register_passable (the intent of the proposal) we could then decide whether further splitting Trivial up is a good thing or not. I would want to understand much more precisely the use-cases for it to understand the complexity vs utility tradeoffs of doing that.

An important note: the proposal talks about desired benefit (e.g. use parameter if to check if a T conforms to Trivial) but we don’t have that yet. It is a theory that we can express that, and until we do, I don’t think we should introduce complexity into the model with the hope that it could be useful someday.

-Chris

3 Likes

I can understand that incremental improvement is a lot easier to implement on Modular’s side, but, we are also still paying off the cost of CollectionElement, which I place into a similar category of not breaking down a trait into fundamentally inseparable parts. The reason I want to break it down is because by only having trivial, we may force APIs to ask for behavior they don’t need. For instance, I think it’s fairly logical that we have some safe version of memmove which takes input and output spans of TriviallyMovable. There’s no reason for that to care about whether the type is TriviallyCopyable, but if it’s all one trait it has to. To me, union of all of these concepts in Trivial, drastically reduces the number of things which can benefit. I’m perfectly fine with having alias Trivial = TriviallyMovable + TrivallyCopyable + TriviallyDestructible for the “I just want a bag of bytes” things. The other benefit of having TriviallyMovable as a separate trait is that Rust only has trivially movable types, which means that the Rust team has put a lot of work into LLVM around copy/move elision, since Rust moves data a lot in part due to lack of placement new or NVRO.

To generalize on my thoughts on breaking up traits, the reason I do this is because of the number of time’s I’ve had to implement a hacky piece of behavior in Rust in order to use an API, only to find out that the author of the API never actually used that behavior. For instance, one library that I eventually rewrote required the equivalent of random access iterators for everything because that was tied to being able to get the index the iterator was currently at in the iterator extension traits library they used, but they did a linear scan over the collection, so it really wasn’t necessary, all that really needed to happen was for an iterator to track the current index. I’m perfectly happy to satisfy requirements or use a different API when the implementation actually uses what it requests, but I don’t think that unnecessary hoop jumping leads to good UX. For a trait like Trivial, we’d essentially be stuck with SIMD and types without references or pointers that have no special dtor. That’s a fairly short list. To me, every time a description of a trait requires you to say and, it means that there are likely two traits you could break it into, and right now Trivial has can be described as “is movable via memcpy, and is copyable via memcpy, and has a no-op destructor”.

To continue your metaphor, I’m not asking to split the atom (which would need reflection), I’m asking for us to use atoms instead of a molecule. TrivallyMovable is, as far as I can see, an irreducible concept. The only thing it says is that, if you move the top-level bytes of a type from one place to another, and invalidate the old location, the new location is a valid instance of the type. The others are similar. I have no objection to providing the higher level Trivial to users for use in defining their types, but I think we should encourage libraries to only ask for the capabilities they need.

6 Likes

I would bet serious :dollar_banknote: that Mojo will end up with three separate traits eventually. It’s a no-brainer: they are required to specify performant + flexible libraries. It’s easy to come up with examples. So from my perspective, the only question is whether we introduce the three traits now, or start with Trivial, and then deprecate Trivial at some point in the future. I will let Modular make that call. Perhaps Trivial is a good first step.

3 Likes

By the way, it’s worth considering whether the triviality of copying/moving/destruction should be modelled as an observable property of the existing Copyable/Movable/Destructible traits (possibly an “associated alias”), rather than being a wholly-separate trait. For example:

fn foo[T: Movable](items: List[T]):
    if is_trivial(T.__moveinit__):  # or maybe:  if T.move_is_trivial
        <do a memcpy>
    else:
        <do an elementwise copy>

fn bar[T: Movable](items: List[T]) requires is_trivial(T.__moveinit__):
    ...

I agree, this is one place where I think C++ got it right. Trivial as a half step is fine, but I want to see all of those advantages later.

On syntax, what if we have something like “parameterized decorators”. structs can be defined like so:

@trivial[Copyable]
struct SomeTriviallyCopiableStruct:
    pass
2 Likes