-
Notifications
You must be signed in to change notification settings - Fork 32
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
Unboxed types (version 2) #34
base: master
Are you sure you want to change the base?
Conversation
I have two small questions:
would, if I'm not mistaken, fail to typecheck because by default,
|
That's correct, this example should have: type unboxed_t : value * value in the signature.
See Stephen's comment on the unboxed constructors RFC. Essentially there are two optimisations proposed in that RFC: "inlining" and "disjointness" as Stephen calls them. The unboxed variants described in this RFC give you inlining, but this RFC doesn't say anything about disjointness. I think that @gasche has separately been making progress towards some implementation of disjointness. One area where they could interact is that disjointness requires information about the representation of abstract value types in order to ensure that they are suitably disjoint. This information is also a form of kinding, although a more fine-grained one than you want for unboxing. It should cause no problems to allow a finer notion of kind on abstract types than on type variables, which would allow these two proposals to interact seamlessly. |
Thanks @Gbury. You're right about abstract types, and you're right that my example was wrong. I've fixed it. The unboxed constructor work is definitely related, but not the same thing, as @lpw25 points out. That said, I'm hoping to update this soon with a new section that will allow a non-allocating |
Current status of unboxed constructorsIn case it can help, here is all the information one could want on the current status of unboxed constructors:
General interaction between the two proposalsI think globally the two strands of work are complementary in a good way. (This was already said before, let's summarize again.) In our work on unboxed constructors transform the representation of values (and thus the compilation strategy) based on type-directed representation information, an abstraction/approximation of types that we call the "head shape". We worked out how to compute the head shape of a type expression, but of course we cannot assume anything on the shape of type parameters/variables and abstract types The work on unboxed types is, in part, about introducing explicit kinds ("layouts") in the language to classify types depending on their representation properties, and performing kind-checking and kind-inference as one would expect. This is precious to make our unboxed-constructor work more modular: if we suddenly get to specify the head shape of a type parameter, or an abstract type, the user can express more constructor-unboxing across abstraction boundaries. This assumes that there is a close connection between our abstraction of type (head shapes) and the kinds (layouts) in the unboxed-types work, and my understanding is that there could very well be. (Our head shapes are a bit more fine-grained than the unboxed layouts right now, one could extend unboxed layouts or just accept coarser-grained information across abstraction boundaries.) Removing constructors in both proposalsBoth proposals allow to "remove" some constructors from the value representation, but in disjoint ways. In our proposal (1) of unboxed constructors, this removal is "unboxing" (turning a boxing operation into a no-op):
In the unboxed-types proposal (2), this removal is "inlining" or "packing":
The different approach in different settings make a lot of sense and both are useful.
To me this suggests that both features are useful and we could consider having both at the same time in the language. |
@gasche Regarding #10041 in particular, I'm still planning to help with this. We also updated and included it in the prototype of the first bits of this document (ocaml-flambda/ocaml-jst#48). That prototype is occupying all my time until I have it in a place where it can be reviewed internally, but I think that's only about a week away, and I'm then happy to update #10041 and make a new PR (or review yours, if you get there first). |
Sounds great, thanks! |
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.
Here is some feedback. The main questions are about possible simplifications to the design of mutable fields (or about things that I am missing).
(Note: the comments appear in the order in which I wrote them initially, not as they are meant to be read; the "files changed" tab shows them in the intended order.)
|
||
In general, we reserve the right to reorder components in an unboxed tuple or | ||
record in order to reduce padding. With demand, we could imagine introducing | ||
a way for programmers to request that we do not reorder. (An unboxed type that |
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.
Comes to mind, specifying a C-like memory layout, having in mind the use-case of interoperability with foreign structs (see #[repr(C)]
in Rust).
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.
Yes, I expect we'll need this.
```ocaml | ||
type #t = #( K1 of ty1 | K2 of ty2 | ... ) | ||
type t = #t box | ||
``` |
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.
How does this equation work for constructors without payload? Is there a conversion between the unboxed tag and the boxed tag/immediate? (Concretely I imagine that type ('a : any) option = None | Some 'a
uses tag 0
for None
and 1
for Some
for the unboxed version, but tag 0
for Some
and immediate 0
for None
in the boxed version—if we leave aside for now the niche optimization whereby you could encode None
as NULL in certain cases.)
Is there special treatment for the void
layout? Concretely for the equality type:
type (_,_) eq = Refl : ('a,'a) eq
we most likely would like #eq : void
(compile-time erasure for equality types) while perhaps still enjoying eq : immediate
.
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.
Hmmmm...... maybe this is suggesting that e.g. void + void + void <= immediate
? I like that. We could do even better, with something like void + void + bits32 <= immediate
. Or we could just say that enumerations are immediate
, but I like the sublayout approach more, as it's more general. Not sure yet whether it plays too poorly with type inference, though.
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.
How does this equation work for constructors without payload? Is there a conversion between the unboxed tag and the boxed tag/immediate? (Concretely I imagine that type ('a : any) option = None | Some 'a uses tag 0 for None and 1 for Some for the unboxed version, but tag 0 for Some and immediate 0 for None in the boxed version—if we leave aside for now the niche optimization whereby you could encode None as NULL in certain cases.)
Just for reference, in flambda2, when we locally unbox a value of some kind, and particularly a variant, we actually generate an is_int
variable along with the tag
and the payloads. This makes the conversion between boxed and unboxed really simple, at the price of using more variables to hold the variant. It's probably not ideal in this situation, but at least it avoids the complication of having different tags between the boxed and unboxed versions.
We could imagine a restriction in the language stopping such copies from | ||
happening, or requiring extra syntax to signal them. However, we hope that there | ||
will not be too many wide unboxed records, and copying a few fields really isn't | ||
so slow. So, on balance, we do not think this is necessary. |
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.
One data point is that unintended and silent deep copies were a bane of old C++, which would tend to go in favour of having a loud syntax for copying wide unboxed types.
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.
Yes. There has been considerable discomfort around this point internally as well. But putting e.g. #<-
on every mutation seems pretty unpleasant, too. I really don't know what's best.
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.
A comparison with C++ is in fact somewhat unfair, as the silent copies in C++ could be recursive (e.g. copying a vector of vectors) so the problem is much worse there. A better comparison is with Rust, so it can be interesting to read what they say about it (e.g. rust-lang/rust#45683).
Rust has unboxed fixed-size arrays, unlike this proposal, which make it easier to make copies costly. However, I argue that this difference is not important. Indeed, fixed-size arrays are useful, and with your proposal, users can emulate them by writing them explicitly with something like t * t * ... * t
, so I believe that code like this with large structs are going to be written regardless.
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.
My sense reading that thread is that they are just about as puzzled as we are here.
In my opinion, we should not add extra annotations to the language for this. Instead, the goal we are after is this: we want to make sure the programmer knows what they are doing. I can think of at least three ways of accomplishing this goal:
- Add required annotations to the language
- Add a warning; we can then debate whether the warning should be on by default. And we have to figure out a way of locally suppressing the warning, likely with some kind of annotation.
- Add magic to merlin to alert the user.
I'm strongly in favor of 3, if it can be made to work. That is, imagine if an expensive <-
is highlighted in yellow, say. The programmer can see this and account for it. Maybe we also add an annotation to suppress the highlighting? Not sure there. It would be very cool if an editor with merlin enabled rendered #<-
as <-
with a little #
over it, reducing visual clutter. Actually, maybe this is the same as (2), but with special treatment in merlin.
To be clear, my "if it can be made to work" is a hedge against the possibility that people don't actually use merlin. My OCaml experience is very skewed: I'm in an environment where everybody uses merlin, because it's all set up for them. I'm not sure it's as pervasive outside Jane Street's walls.
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 sounds to me like you are advocating for a lint, like in this Rust thread.
I do not have a definite opinion or an alternative solution to propose, but tooling is less mature in OCaml and has fewer contributors, and if the problem is deemed worthy of language design considerations then one should be careful about not relegating the problem to future/incomplete/third-party support.
foo (); | ||
t.(.pt.x) <- 7; | ||
foo () | ||
``` |
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.
Here I find it clearer to see the assignment as a shorthand (as you mention, and with the relaxation to the tearing restriction I proposed) for:
t.pt <- #{ t.pt with x = 7 };
plus some optimizations to the resulting code (maybe we do not need the shorthand in a first time). Without your shorthand notation, it is apparent that there is no aliasing between pt.x
and t.pt.x
(syntactically it works just like in current OCaml).
Now when trying to see this as a direct mutation of the field x
, perhaps this also misses a loud syntax for an operation that breaks aliasing from this angle (the copy let pt = t.pt
). With functional update syntax this could look something like let pt = #{ t.pt }
). But I find it clearer to avoid the shorthand notation and the loud syntax altogether.
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 our internal design debates, we eventually settled on a medium-volume notation: t.(.pt.x) <- 7
, where the parentheses and extra dot are the extra volume. Originally, it was just t.pt.x <- 7
, but that indeed seemed too confusing. Yet I sense from your comments that you think the design is not quite loud enough. Perhaps you're right.
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.
To clarify, there are two points in my comment that can be discussed separately:
- Maybe one can skip the artificially-loud notation altogether, by writing something like
t.pt <- #{ t.pt with x = 7 }
instead oft.(.pt.x) <- 7
if it is made possible (after relaxing the tearing restriction: this is discussed in another comment). The compiler should be smart enough to compile it the same. Then all possible confusions about aliasing disappear. The notation is still loud, but not in way that has to be introduced artificially. (In my current understanding of this proposal, this is my preference compared to the next point.) - If nevertheless something like
t.pt.x <- 7
is kept, I pointed out that the confusing part (to me at least) is that the notationlet pt = t.pt
suggests an aliasing of thex
fields that does not exist, so perhaps the latter operation is what deserves to be louder/clearer (e.g.let pt = #{ t.pt }
).
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.
Subsumed by the discussion in the main thread, feel free to mark as resolved.
|
||
Because the closure for `foo` captures that unboxed record `p` (by copying `p`), | ||
this code prints 4 twice, rather confusingly. Yet the current design does not | ||
fully rule such a confusion out, as we see here: |
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 really enjoy reading about this kind of considerations in design.
I wonder if the confusion is not due to having a very quiet syntax that breaks aliasing. In current OCaml, you need loud syntax to break aliasing, e.g. record creation syntax or functional update syntax.
I understand your proposal regarding mutability as follows. Conceptually, mutable
is attached to the surrounding box (as is the physical equality relevant for this mutable field); with this in mind the mutable
keyword loses its meaning when inside an unboxed record on the stack. In this case I am convinced that this is the appropriate solution, although the leftover (and needed) mutable keyword can be confusing.
However for the fact that it always makes sense to mutate a field within a mutable unboxed record, what is going on is clearer by seeing it as the functional update of the record together with the optimization of not overwriting values with themselves, see below.
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.
Subsumed by the discussion in the main thread, feel free to mark as resolved.
should not come as a surprise: `t1.t2` is copied as part of the functional | ||
record update, and thus mutating `t1.t2` (as part of `t1.(.t2.x) <- #10l`) changes | ||
it. If, on the other hand, we did not have the parentheses, users might expect | ||
`other_t1`'s `x` to be changed when `t1`'s `x` gets changed. |
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.
Indeed, it is similar to what already happens in OCaml with type t1 = { mutable t2 : int; other : int }
, so it is not worse; the functional update is understood as breaking aliasing for t1.t2
. (Now this example would be more confusing if x
was declared mutable and the programmer not expecting that this mutable
is disregarded and the mutable location considered by the update is instead t1.t2
.) Perhaps this becomes clearer again if written without the shorthand notation as follows:
t1.t2 <- #{ t1.t2 with x = #10l };
This makes clear that in terms of aliasing the update happens at location t1.t2
and not t1.t2.x
, so the result is expected.
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.
Subsumed by the discussion in the main thread, feel free to mark as resolved.
The design in this section upholds the following principle: | ||
|
||
* Even in the presence of data races, a programmer can never observe an | ||
intermediate state of a single assignment (unless they write `[@non_atomic]`). |
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.
Memory safety should be ensured somehow, but it is not the case that the OCaml memory model ensures type safety. Indeed it is already the case that data races can break invariants of abstract data types (e.g. Stdlib.Stack
which can be in an inconsistent state following something similar to tearing, for an simple example). How is this different here? I get that some abstract data types are programmed to be thread-safe and this tearing adds something else to think about, but there could simply be an additional responsibility for programmers of unboxed abstract types to have tearing in mind before exporting the unboxed representation, if their goal is to have their type thread-safe.
I think the following approach, if it works, would be simpler:
- Principle: even in the presence of data races, memory safety is preserved (however a programmer can observe an intermediate state of a single wide assignment).
- No
[@writable]
annotation (writeability is the default, as above except for sum and existential types for thread-safety, using the samewriteable
predicate). - If a programmer wants their abstract data type to be thread-safe, then they avoid exposing the unboxed layout in the first place, or they must take tearing into account when proving thread-safety.
- If a programmer wants their abstract data type to use memory-unsafe features, and they want to expose the unboxed layout, then they must take tearing into account when proving memory-safety.
(For one additional simplification this affords, see further below.)
Am I missing anything? Is there a compelling use-case where you want a thread-safe abstract data type to be unboxed, where thread-safety is ensured by forbidding the overwriting? To say it differently, boxing is already one workaround in this case, and I am looking for some motivation to have a second workaround in the form of a [@non-writable]
annotation to prevent tearing.
The, at this point the [@non_atomic]
escape hatch is only useful to force-mutate sums and existentials (for which there is another escape hatch, namely boxing). This is defeating the narrative of the OCaml memory model, that it can let you do enough without endangering memory-safety. In terms of memory model it would be similar to allowing to mutate the tags of blocks. Are there compelling use-cases for [@non_atomic]
? (To say it differently, again, boxing is also a workaround in this case, and I am looking for some motivation to have a second workaround in the form of the [@non_atomic]
annotation.)
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'm afraid I don't understand your opening paragraph: when you say "How is this different here?", what is "this"? It seems you're suggesting that some aspect of the design is off (please do criticize and brainstorm!), but I'm not exactly sure what it is.
In the meantime, I will react to the counter-proposal:
- If writability is the default, is it still possible to abstract over an unboxed type with an existential variable? It would seem not -- there would have to be some way to say that a certain product is not writable.
- This effectively limits the availability of unboxed types, either preventing the abstract export of some types or placing performance-sapping burdens on the implementation of a type.
- I agree that boxing is one workaround. Yet within some teams within Jane Street, it's an unacceptable one: they need to write code that works without latency-inducing garbage collection. Indeed, the internal feedback I've gotten on this proposal already had two people essentially requesting
[@non_atomic]
(which perhaps they missed).
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.
Subsumed by the discussion in the main thread, feel free to mark as resolved.
write `t.center <- {t.center with x = #3l}` instead (modulo the | ||
tearing restriction). In effect, `t.(.center.x) <- #3l` can be seen as | ||
syntactic sugar for `t.center <- #{ t.center with x = #3l }`, but allowed | ||
even if `t.center` is too wide for the tearing restriction. |
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.
This is indeed what came to mind when reading the examples at the end; however by relaxing the tearing restriction as I described above, then it is actually possible to write t.center <- #{ t.center with x = #3l }
(the compiler should recognize that only x
needs to be written in this case, and compile it down to a single assignment).
I come back to this at the end where I noticed that writing it like this avoids the confusions you mention in the examples there.
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.
Subsumed by the discussion in the main thread, feel free to mark as resolved.
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.
Thanks for the thorough read! @gadmm
The design in this section upholds the following principle: | ||
|
||
* Even in the presence of data races, a programmer can never observe an | ||
intermediate state of a single assignment (unless they write `[@non_atomic]`). |
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'm afraid I don't understand your opening paragraph: when you say "How is this different here?", what is "this"? It seems you're suggesting that some aspect of the design is off (please do criticize and brainstorm!), but I'm not exactly sure what it is.
In the meantime, I will react to the counter-proposal:
- If writability is the default, is it still possible to abstract over an unboxed type with an existential variable? It would seem not -- there would have to be some way to say that a certain product is not writable.
- This effectively limits the availability of unboxed types, either preventing the abstract export of some types or placing performance-sapping burdens on the implementation of a type.
- I agree that boxing is one workaround. Yet within some teams within Jane Street, it's an unacceptable one: they need to write code that works without latency-inducing garbage collection. Indeed, the internal feedback I've gotten on this proposal already had two people essentially requesting
[@non_atomic]
(which perhaps they missed).
|
||
In general, we reserve the right to reorder components in an unboxed tuple or | ||
record in order to reduce padding. With demand, we could imagine introducing | ||
a way for programmers to request that we do not reorder. (An unboxed type that |
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.
Yes, I expect we'll need this.
```ocaml | ||
type #t = #( K1 of ty1 | K2 of ty2 | ... ) | ||
type t = #t box | ||
``` |
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.
Hmmmm...... maybe this is suggesting that e.g. void + void + void <= immediate
? I like that. We could do even better, with something like void + void + bits32 <= immediate
. Or we could just say that enumerations are immediate
, but I like the sublayout approach more, as it's more general. Not sure yet whether it plays too poorly with type inference, though.
We could imagine a restriction in the language stopping such copies from | ||
happening, or requiring extra syntax to signal them. However, we hope that there | ||
will not be too many wide unboxed records, and copying a few fields really isn't | ||
so slow. So, on balance, we do not think this is necessary. |
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.
Yes. There has been considerable discomfort around this point internally as well. But putting e.g. #<-
on every mutation seems pretty unpleasant, too. I really don't know what's best.
foo (); | ||
t.(.pt.x) <- 7; | ||
foo () | ||
``` |
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 our internal design debates, we eventually settled on a medium-volume notation: t.(.pt.x) <- 7
, where the parentheses and extra dot are the extra volume. Originally, it was just t.pt.x <- 7
, but that indeed seemed too confusing. Yet I sense from your comments that you think the design is not quite loud enough. Perhaps you're right.
It seems a bit confusing that in Also do I understand correctly that |
I assume there is some kind of check on type recursion to forbid things like |
Maybe. It's hard to see how this could ever be a good idea, though. It wouldn't be hard to give a very informative error message in this case, pointing out that mutation of unboxed types is bizarre.
No, unfortunately. They behave the same at runtime, but there are subtle differences at compile time. For example, the former is constructed with
There's no detailed description of this, but the check is simple in essence: that Thanks for the questions! |
Re. layout computation and invalid recursive references: we have the same issue in our computation of "head shapes" for unboxed type constructors. There are "malign" cycles that clearly must be rejected type t = Foo of t [@unboxed] | Bar
type t = #{ x : t; y : bool } and also "benign" cycles that look odd (the types have no value inhabitants), but could in fact be accepted type f = Foo of t [@unboxed]
type t = #{ x : t } One difficulty pointed out by @stedolan is that we want to use a criterion that works well with type abstraction, and in particular this may require accepting the benign cycles. (Our first impulse was to reject all cycles, because benign cycles are not programs you want to save from rejection very hard, and it is easier to implement the reject-all criterion.) If we have type t = Foo of abs [@unboxed]
type t = #{ x : abs } where |
This being said, the comparison may not go all the way, because if we look at the case of "potentially-malign cycle hidden under an abstraction", type t = Foo of abs [@unboxed] | Bar
type t = #{ x : abs; y : bool } then it is clear that we will reject the unboxed-construct example (an abstract type has a "full" head shape, that conflicts with the shape of the |
(The issue where we discussed this, in particular Stephen's excellent observation, is ocaml/ocaml#10485 .) |
Wouldn't |
Exactly so. If we have type t = #{ x : abs; y : bool } then |
Would it make sense to eventually have unboxed arrays? type state =
{ final : bool;
next : state array }
let rec loop s i st =
let st' = st.next.(Char.code s.[i]) in
if st'.final then st' else loop s (i + 1) st' Saving one pointer dereferencing would make this code twice as fast. I have experimented with manual unboxing in RE (using |
Let me try to break my comment down into independent points. You need a "writable" predicate due to wide existentials (and unboxed variants as a special case). Indeed tearing can cause memory unsafety in this case. Beyond that, you worry more generally about tearing being able to break assumptions about abstract types (tearing a concrete type is OK, an abstract type is not). You introduce a writability restriction to avoid tearing abstract types, and then offer facilities to work around this restriction. However, a similar hazard already exists with abstract data types without unboxed types. For instance,
Perhaps at each step I might be missing something simple. As for |
Yes, this is in our general plan, but we have not yet written up a design. It is definitely something we're hoping to attack. |
One aspect of my approach to all of this that is surely leaking through is that I don't consider type safety to be all that interesting a criterion. (Blasphemous, I know.) That is, what I care about is whether my program behaves correctly at runtime. More accurately, I care that my programs have predictable semantics. Whether a tearing is potentially type-unsafe (as in an existential) or potentially invariant-breaking (as in an abstract type) doesn't seem to matter: any problem of this sort can be catastrophic to my program's behavior. And I argue that tearing an abstract type is just as type-unsafe as tearing an existential, because the abstract type's invariants might be relied upon in order to make a call to e.g. More concretely:
|
@goldfirere I do not think that your first point is controversial, though I would say things differently. Type-safety should encompass abstract types and their invariants; in this sense the OCaml memory model make programs memory-safe without making them type-safe in the presence of races. (Claiming that OCaml 5 ensures type safety in the presence of races, at the cost of changing the meaning of every abstract data type in existence in OCaml 4, to me is not a useful way of seeing things—and I feel like you are trying to say something similar.) So, OCaml has already chosen a way to deal with such issues, and in particular it is already the case that programmers of an abstract type have the responsibility of avoiding memory-unsafety around unsafe operations (reserved to people who can prove safety)—this is not a new aspect. I am guessing that one purpose of doing things your way might be as a general safeguard mechanism—make the default safer, and the risky configuration opt-in. I find that this is a good goal, and I would like to clarify that to me there is already such a mechanism: the unboxed version of an abstract type is not exported in the interface of the module by default, and the programmer of the abstract type already has to do something explicit in order to make the new races appear. To say it differently, boiling it down to the technical differences:
So in practice it is like having the opposite from your
(Thanks for your careful replies, this forced me to read the proposal better and I came out of it with a better understanding. I am now satisfied by how we have explored this aspect; I think the two above points in bold summarize my remark.) Please do not hesitate to point out if there are points of the proposal which I might have misunderstood, I still do not claim to have it fully in my head. The next important point to discuss is the weird syntax |
Aha -- I think I now understand the crux of the issue: defaulting. Defaulting is always hard. My syntax suggests unwriteable as the default, while users can opt-in to writeable. I have no evidence this is the better design. Indeed, another possibility is to have no default, requiring e.g. Agreed about the poor |
Another aspect that is worth clarifying in the meanwhile (implicit in my text)—the no-tear principle (the intermediate steps of single assignments must never be observed): is it something you throw away when you introduce |
Another good point: that principle is poorly stated, in that the design does not uphold the principle as stated -- precisely because of
This captures @gadmm's worry about joint responsibility. Either the type author or the type user can cause an exception to the rule. I think that this is the right design (though I'm quite up for debating concrete syntax and defaults!) because it is the most expressive. A library author can declare that they don't care about tearing (because they protect against the possibility somehow) or a library user can declare that they don't (because they use external synchronization techniques). Both of these scenarios happen in practice, so it should be possible to accommodate both. |
But then this principle is no longer what programmers need to hear to understand the unboxed design. If we agreed leave this principle aside, I agree that it is (finally) a matter of defaults:
But precisely we do not want to ask type authors in general to care about or protect themselves against tearing (except for unsafe operations). In addition to existential and unsafe operations, you seem to envision making a type non-writable as part of a wider arrangement for enforcing some thread-safety guarantee in some situation. I agree that this is a good reason to use it (example: unboxing mutexes). But again those thread-safe-yet-unboxed data structures are the exception rather than the rule.
Our deconstruction and rephrasing efforts made me eventually understand this point better. For programmers of an abstract type |
I agree! This is why I thought non-writable should be the default, so library authors who don't think about it are protected from the possibility of tearing. Only those authors who opt in will care. Library users who use |
But do you understand why with the same starting point I conclude in favour of having the opposite default, or is there still something I should clarify? |
Syntax for mutable unboxed record updateAs discussed above, I understand that with Let's see how it goes in the original text: type point = { x : #int32; y : #int32 }
type t = { radius : #int32; mutable center : #point } [...] Fields within a mutable unboxed record within a boxed record. [...] We thus introduce a new syntax for updates of individual fields of a Nested partial unboxed records are allowed, which can be used to update a sub-sub-field: [...] Further discussion: mutability within unboxed records [...] type point = { x : int; y : int }
type t = { mutable pt : #point }
let () =
let a : t = { pt = #{ x = 5; y = 6 } } in
let pt = a.pt in
let foo () = print_int pt.x in
foo ();
a.pt <- #{ x = 7 };
foo () Here it is clear that you do not mutate type t2 = { x : #int32; y : #int32 }
type t1 = { mutable z : #t2; other : int }
fun (a1 : t1) ->
let other_a1 : t1 = {a1 with other = 5} in
a1.z <- #{ x = #10l };
other_t1.z.x = a1.z.x Again no confusion happens: you mutate [End of RFC text] Slightly-more-than-informal descriptionPartial unboxed records can be allowed following a simple syntactic criterion. You define recursively a notion of partial R-value (PR-value) where:
A single assignment of a PR-value is elaborated by the compiler as a sequence of assignments of the form (in your syntax) t.(.x.a) <- e1;
t.(.x.b.c1) <- e2;
t.(.x.b.c2) <- e3;
... As an implementation detail, this has to rely on re-using type-directed disambiguation to detect partial records. I am not familiar with this part of the compiler, so I will refrain from making comments about how easy to implement it is supposed to be. (I am ok with a reply along the lines: “that looks complicated to implement!”) (My guiding intuition was that partial fields have a sub-layout where the location of the holes are replaced with primitive kind FAQ
|
``` | ||
|
||
Because the closure for `foo` captures that unboxed record `p` (by copying `p`), | ||
this code prints 4 twice, rather confusingly. Yet the current design does not |
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.
Let's come back to this example, after having come to the conclusion that what happens here is eventually unrelated to the other examples below it.
I think the surprising choice here is to copy by value when the user could have written more explicitly:
let x = p.x in
let foo () = print_uint32 x in
if they wanted a copy.
Besides interfering with mutations, you note elsewhere that this copy-on-capture rule can also be surprising because of the cost of hidden copies (and AFAIU this is the only place where the copy is really hidden by the syntax).
I would expect instead:
p
is captured by reference.- The program is rejected if the reference to the stack can escape (e.g. by forcing to annotate the function with a
local
keyword). - Mutations on the stack are allowed.
If not possible (for the time being), I would rather those captures and hidden copies to be forbidden (and mutations on the stack allowed). (To be able to consider this, it was important for me to realize that the subsequent examples are unrelated to this one.)
In general in type systems, the interaction between kinds and closures is a delicate aspect that is not arbitrary; and I worry that this proposal commits to an easy-to-implement but not-so-useful solution.
(edit: Separately, I am not saying that having unboxing values inside closures is not useful, but an explicit syntax for it would be better.)
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.
(and AFAIU this is the only place where the copy is really hidden by the syntax)
I'm not sure what's the problem with that. There's no real limit to how much a closure can capture. Even if you write fun x -> foo x
, it may seem that you only capture foo
but if foo
gets expanded before closure conversion (for instance, by the [@local]
optimisation) then you could have an arbitrary large number of variables captured.
I'm not saying we should encourage that, and I have actually proposed a restriction to the [@local]
optimisation that would prevent that from occurring, but capturing the whole layout of an unboxed record doesn't seem particularly surprising to me.
p
is captured by reference.
I think that the "reference" keyword is a red flag. We don't have any concept of passing by reference in OCaml (except for the ref
type), and I don't think we need to introduce it to make sense of unboxed records.
In C terms, an unboxed record is basically a struct
. When you pass it as argument, you pass it by value, copying the whole of it. If you want to pass it by reference, there's already a natural way to do so: use the boxed version (which is basically a pointer to a struct
).
The fact that this applies to closure capture too shouldn't be considered particularly harmful, in my opinion. And it's relatively easy for an optimising compiler to trim the closure if needed.
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.
If anything, C gives a bad comparison precisely because it makes it easy to silently spill it on the stack and mutate it by reference. I agree that there can be value in more clearly separating the notions of unboxing and stack allocation than in those languages.
I'm afraid to say I don't. The starting point of "library authors should not need to care about the possibility of tearing" would seem to lead directly to "don't allow tearing by default", whereas I understand you are advocating for allowing (non-existential) tearing by default.
This alternative scheme indeed seems (to me) more complicated than what I proposed. Syntax debates are challenging, because they often come down to taste. I expect this all to get refined over the course of time, as we roll out these features within Jane Street and then engage in further discussions with the broader OCaml community. In all honesty, I don't think engaging in this debate now will yield much fruit. Instead, let's wait to see this all in practice, recalling that it is easy within Jane Street to choose one syntax and then change it later. Let's take advantage of this to learn from experience.
Hm. Interesting. I think you're suggesting that we needn't copy an unboxed value in a closure capture if the closure is local (as a great many are). I think that's a good idea. I will check in with my colleagues about this one. |
If @gadmm is referring to functions annotated with the I think there's room for discussing how closure capture works for non-value layouts though. The normal, straight-forward way is to capture variables, so |
@lthls I was indeed thinking about Stephen and Leo's |
I'm quite convinced that there's no reason to ever allow unboxed mutable records. I'm even of the opinion that trying to do so should be a typing error, not just define an immutable version. |
I find your position very consistent, and it addresses differently what bugged me about unboxed types with mutable fields. I agree that from this point of view, we want to understand better why the proposal tries to make sense of them. |
It's exactly as @lthls guesses: We still want (of course) to have |
This is a complete update from @stedolan's proposal for unboxed types (#10), built from that proposal, and written in collaboration with the type-systems team here at Jane Street: @lpw25, @stedolan, @antalsz, and @ccasin.
We're still at the early stages of implementation (in https://github.com/ocaml-flambda/ocaml-jst), so all feedback is warmly welcomed.
Rendered version