-
Notifications
You must be signed in to change notification settings - Fork 1.1k
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
add Dynarray
to the stdlib.
#11563
add Dynarray
to the stdlib.
#11563
Conversation
Bikeshedding: Dynarray is fine: it's a consonant followed by a vowel, and the whole thing is pronounceable, so no need for the underscore. |
Personally I'm not very fond about the idea. For the following reasons:
That being said I had to implement more than once a form of This leads me to the following suggestions:
|
As stated earlier, I think that extensible/dynamic arrays are an important data structure and would warmly welcome their addition to the standard library. It's a difficult API to design because of this requirement to chose a value to put in the "empty" slots of the array -- in particular when growing the underlying array or popping values from the array. The problem is that the lifetime of this "filler" value gets extended to the lifetime of the array, which may be a memory leak in some scenarios. I see three approaches that make sense:
The current PR implements a variant of (1) where we add an extra write after If you want to go that route, I would be tempted to suggest a in any case, I think that the module should carefully document the way it may (or not) retain values provided by the user, and ideally there should exist a (documented) way to use the module to guarantee the absence of leaks. |
@johnwhitington I think it can make sense. I'll wait for agreement on it before doing the renaming though. :) @gasche thank you for the summary! I think it's accurate. I'm not convinced anymore that 3) can work in a domain-safe way without terrible performance (basically, a full mutex to protect push/pop/…, because the slot has to be filled/erased at the same time as the length is adjusted; or read synchronisation as well, meaning The suggestion to have an optional dummy element is interesting. If the type was
As for your suggestions:
I think I write more imperative OCaml than you do, which is simply indicative of different styles. In the more imperative side, the lack of dynamic arrays has always been clear to me. I've had them in containers for at least 8 years by now :) |
@Octachron and myself discussed ways to do (3) last week. I'm not sure whether you synchronized with him since :-) I'm also not sure whether we should try any harder to use unsafe code in the stdlib; it's dubious and it gives people excuses to write ugly code on their own. ("But Stdlib.Queue does something similar!") let dummy : 'a = Obj.magic (ref ())
let check x =
if (x == dummy) then invalid_arg "Dynarray: empty value"
let pop v =
let new_size = v.size - 1 in
v.size <- new_size;
let x = v.arr.(new_size) in
fill_ v.arr new_size dummy;
check x; x
let get v i =
let x = v.(arr).i in
check x; x We would need to do benchmarks to know whether this approach is actually worth it. |
Benchmarking could be good, but I'm never too happy about additional overhead on Also I'm worried it might be even slightly uglier because of the case of |
Would you then be willing to update the PR to a version with an optional filler element? (This could be provided at creation time and/or set later.) I don't know if you want to keep your current logic of picking a filler in the other cases, or gain the speed benefits of just leaking elements. |
This might have some problems with marshaling: if you unmarshal a dynarray, your check functions won't work. |
The dummy object could be part of the dynarray:
This said, I'm not convinced we need to go to such lengths to avoid potential memory leaks. Using the first element of the array as the filler is already pretty safe, at least when the dynarray is used as a stack. |
Fully agreed. I find underscores in module names hard to read, so let's avoid them when possible. A precedent: |
In general I see the (3) solution as implementing the dynarray using an option array as storage, and using a magic version of the option array. This is 'type safe' in the multicore sense as long as you don't implement If you want some reference for an efficient option array that I think handles all the strange cases, you can have a look at https://github.com/chambart/ocaml-nullable-array/blob/master/lib/nullable_array.ml If this were the preferred choice, I would suggest to use |
Of course I am not a fan of the Java concurrency model, but given that this is the general direction chosen by OCaml and even if it makes dynamic arrays slower than expected, trying to hack now a different model than the one advertised with OCaml multicore could make it look disingenuous. Unless one decides that such a data structure made for imperative programming are not made for OCaml, compared to other structures (e.g. what Daniel says). ¹: of course in Java this is worse because it suffers from conflating the various notions of NULL (Option vs. unshared, uninitialized value); there is no reason to have the same confusion in OCaml so lectures about how bad NULL is in Java are off-topic for this PR. However, lectures about how bad the Java concurrency model is are welcome! |
Our messages crossed with @chambart, and I see that his example at https://github.com/chambart/ocaml-nullable-array/blob/master/lib/nullable_array.ml also mentions the solution of making the runtime understand NULL as a special value. |
As per my work presented at the OCaml workshop, adding a null check during marking would not result in an observable effect on performance, even for the prefetching marking loop. |
I think you're correct, in case another thread modifies the underlying array while the size check is ongoing (e.g. by calling
Maybe. There was not a clear promise of memory safety in case of race condition, I believe :). This has never been intended to be thread-safe, nor should it be; I suppose the change with OCaml 5 is that now we have to provide a basic promise of memory safety even for very wrong™ code.
Does that mean every modification needs to do additional work so its changes can be detected by an existing iterator? Perhaps additional storage, too? If so, I think it's better to just have a tiny overhead on the iteration itself.
I think @chambart 's idea is nice, if I understood it correctly: reserve a slot at the beginning for the sentinel value (or perhaps in the record around it, which works the same wrt marshal), and compare to it for detecting invalid memory. So I'm open to moving to option (3), possibly reusing @chambart's code, if it has reasonable chances to be accepted despite the low level magic. On the other hand, using
I don't know what the maintainers think, but banning floats would also be an option in my view. Floats belong in bigarrays in general, so a specialized resizable bigarray might be an idea for another PR. |
It is a "may" not a "must", so no extra work. Instead you allow yourself to raise an exception if you meet an unexpected sentinel value (instead of stopping the iteration silently, say). I have not looked deeply at ArrayList to see what extra work they do, it might be worth having a look. I like @chambart's approach avoiding the flat float array representation. For the rest of your replies to my comments, sounds good to me. |
After some discussion with @Ekdohibs we thought of another way of creating a NULL that does not require any change to the GC with all the good properties. I leave you this teasing while we produce an example. |
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 know it's still a draft but here's a first round of comments on the interface.
stdlib/dynarray.mli
Outdated
|
||
val shrink_capacity : 'a t -> unit | ||
(** Shrink internal array to fit the size of the array. This can be useful | ||
to make sure there is no memory wasted on a long-held array. *) |
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 think this name is confusing. Didn't think long about it but make_tight
, fit_capacity
, tighten_capacity
, make_capacity_tight
perhaps ?
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.
Possibly, fit_capacity
sounds good I think.
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.
The new version is memory-safe by virtue of not using any unsafe operation. However I believe one can squeeze a bit more out of it.
@@ -217,36 +211,36 @@ let shrink_capacity v : unit = | |||
let iter k v = | |||
let n = v.size in | |||
for i = 0 to n-1 do | |||
k (Array.unsafe_get v.arr i) | |||
k (Array.get v.arr i) |
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 was rather considering something safely using unsafe_get
as follows:
let iter k v =
let n = v.size in
let arr = v.arr in
if n > Array.length arr then raise ConcurrentModification ;
for i = 0 to n-1 do
let x = Array.unsafe_get arr i in
if is_null x then raise ConcurrentModification ;
k x
done
(where is_null
is always false in your version but should be checked in a version with null checks.)
Idem for other iterators.
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 think it's very reasonable, but I'll re-add them after we get a good implementation for null
, so that I can really think through each case.
When the time comes, should ConcurrentModification
be added to Dynarray
, or directly as one of the main exceptions in Stdlib
? One could imagine Hashtbl
starting to use it too, if it's helpful.
|
||
let[@inline] unsafe_get v i = | ||
Array.unsafe_get v.arr i | ||
Array.get v.arr i | ||
|
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.
Dynarray.unsafe_get/set
are now removed but they make sense in controlled situations (however they are a bit more tricky to use than Array.unsafe_get/set
due to races). I do not have strong opinions on their inclusion/removal.
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 think they'd probably be extremely dangerous given the OCaml 5 semantics. However, if the contract is that unsafe_get
doesn't promise anything wrt memory safety, I suppose the old versions could just work?
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.
Could we have a underlying_array : 'a dynarray -> 'a array
operator instead? This is safe, and it lets people use unsafe_get
on the resulting array. (For unsafe_set
it's unclear that users can reason at all on whether the dynarray will update its backing array in the meantime, so probably not a good idea.)
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.
Do you mean underlying_array
would copy? Otherwise, since the array is partially uninitialized/filled with junk, this seems dangerous to me.
Also, if we do that, we should reopen #10982 ? 🙄
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.
Right, unsafe_to_array
then I guess. (We already have to_array
that copies in the API, no need for that.)
Re. #10982, I wondered about this as well. I think it's a matter of how confident we are that the implementation will remain a single continuous bytes/array, or may move to another design later. My intuition is that you want to document the fact that dynamic arrays are backed by a single array, and that Dynarray.get
is morally just as efficient as Array.get
. Then having a function that breaks this "abstraction" is probably okay. In contract, for buffers it's much less clear that wouldn't want to move to a chunked structure later.
But I'm not insisting on unsafe_to_array
, I just thought of it as a preferable alternative to unsafe_get
.
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 tricky part here is that unsafe_to_array
might return an array with nullable slots. It's all brand new :). For #10982 it was fine because bytes
are always valid…
I looked at Java's approach and it is indeed interesting (for this approach to thread-safety): essentially increment a This raises an interesting question regarding the memory model and how to leverage its guarantees in practice (@kayceesrk !).
Note that you lose in additional checks but you also gain in making some optimisations to iterators valid. Essentially you could argue that the optimisation I propose above for |
One take-away of the discussion at #11583 is that you can use any atom with tag <> 0 for a null value, and it should work without modification to the runtime IIUC. That's how I understand the other PR being closed. Coming back to what Java does, I think using a |
Sure, why not? We don't currently try to do any runtime race checking for mutable datastructures, but it's no excuse to not do the right thing. Do you have a pointer to how that looks like? Can we expect the performance cost to be neglectible? (Ideally we would only add memory writes on resize events, not get/sete operations.) |
I expect it to be negligible (uncontended non-atomic integer increment). You guess correctly that only the changes to the structure counts, not to the contents. The equivalent Java type is called ArrayList: https://github.com/openjdk/jdk/blob/master/src/java.base/share/classes/java/util/ArrayList.java. You can grep for |
I'm not against the modification counter, I'll look into it. The only sad part is that with both a fake "null" value, and a modification counter, that's a whole 2 words (more if unmarshalled) wasted per dynarray. |
That's the nice thing with |
I don't know the runtime tags by heart :-). What is tag 1? Is it
documented somewhere outside the code of the GC?
|
The tag in that case is not really important, as long as it is not |
So is that really different than implementing #11583 using, say, |
It's similar indeed, with the small difference that it is easier for someone else to use a similar trick and get unfortunate collisions if multiple implementations choose the same tag for their internal "null" value. |
and also <= 243. Sorry if I confused you, I thought If you use it I guess it will be nice to document it somewhere in the runtime. And the problem is not re-using the same tag, it is leaking the (non-)value to the outside world. It is fine for everyone to use the atom with the same tag if they keep it to themselves; in particular the person implementing the other option_array should simply not insert null inside a Dynarray (the idea is that null is not a valid value for any type, so the contract is respected). |
If such a solution is implemented, it could be good to specify that tags less (or higher) than some value are reserved by the compiler for future extensions, so that we can ensure no-one will conflict with it. Even if |
For what it's worth, occurrences of atoms in the opam repository are with tags |
I would much prefer a |
How is this not using |
OK, it's still using |
assert (not (array_is_empty_ a)); | ||
let new_array = Array.make newcapacity filler in | ||
Array.blit a.arr 0 new_array 0 a.size; | ||
fill_with_junk_ new_array a.size (newcapacity-a.size) ~filler; |
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 call to fill_with_junk_
seems redundant.
|
||
val of_array : 'a array -> 'a t | ||
(** [of_array a] returns a array corresponding to the array [a]. | ||
Operates in [O(n)] time by making a copy. *) |
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.
The two occurrences of "array" are confusing. Also, I find the mention of the time complexity oddly redundant with the "copy". So, I suggest something along the lines of "[of_array a] returns a dynamic array whose content is a copy of the array [a]."
done | ||
]} *) | ||
|
||
val fit_capacity : 'a t -> unit |
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.
Since there is no prior art in OCaml's standard library yet, it might be worth using the same function name as in some other language rather than inventing yet another new name users will have to remember. Java uses trim_to_size
, while C++ and Rust use shrink_to_fit
provides the good behavior of amortized O(1) number of allocations | ||
without wasting too much memory in the worst case. *) | ||
let[@inline] next_grow_ n = | ||
min Sys.max_array_length (1 + n + n lsr 1) |
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 the user has called fit_capacity
on a very small array, some degenerate behavior will happen next, since this function will return a grown size smaller than the minimal size (i.e., 4). This can be avoided by prepending max 4
, or by changing the formula a bit, e.g., n + (n + 5) lsr 1
.
By the way, it is folklore that, with a non-compacting garbage collector, the optimal growth ratio is the golden ratio. So, choosing 1.5 in practice is less arbitrary than it looks like.
I rebased this PR on top of trunk and wrote follow-up commits to turn it into a boxed implementation and side-step completely the question of which |
closing since @gasche picked up the torch. |
(I'm still curious about your "impact of boxing" |
Overview
This is a (work in progress) PR to add dynamic arrays ("vectors") to the stdlib. The module name is
Dyn_array
, which, as some people pointed out, is more correct thanvector
. For now the implementation is pure OCaml. I discussed with @Octachron about ways to implement some filling functions in C, but I now think it might not be worth it after he pointed out some design constraints newly imposed by multicore.A lot of the API mimics
Array
, when it does not change the length of the dynamic array.Rationale
In OCaml code that veers into the imperative side, lists are not enough. A lot of performance oriented languages, such as Rust, C++, Zig, etc. use dynamic arrays as a very common data structure for accumulating items in a given order; as a integer-indexed map; and as a stack. OCaml has the hashtbl structure, which is extremely useful, but so far it has lacked the dynamic array.
Design
A dyn array is a pair
It is initialized with the empty array (see discussion of alternative designs below). The first element pushed is used to fill unused slots. At a given point in time, the array might look like:
The
filler
element is normally the very first element that was pushed into the vector.The resize factor is 1.5 and is not configurable. This is in the interest of simplicity and should be good enough. It's at least used in some C++ STL implementations, to my knowledge. Compared to the traditional ×2 it wastes a bit less space, at the expense of slightly more frequent resizings.
Alternative designs that this does not implement
There are a few design choice points related to the specifics of resizable arrays in OCaml; here are some we considered but dropped.
dummy element
Some of the existing libraries (
res
, vector) require a dummy element at initialization. While this proposal does so forensure_capacity_with
, I believe that requiring a dummy element to create an empty vector is inconvenient and not good enough. Sometimes there are types that are complicated to create, or just quite large, and coming out with adummy
value is a massive pain. I've experienced that in the context of SAT solvers (which can use a lot of dynamic arrays), and switched to the current design with no performance issue.unused slots
With @Octachron we discussed a design where dummy values (potentially invalid, although valid to the GC) could be used to fill the array. That's what is done in containers. However, due to the contract that multicore OCaml must not observe invalid values, even in the presence of data races, this design is unsound; a race condition between
push
andpop
, orget
andpop
, might be able to observe the (invalid) filler value.customizable resizing policies
The strategy for resizing the vector when it's too full (or too empty, when we downsize) can be implemented in many ways. For example
res
and batteries parametrize over it.Here I picked a single strategy and sticked to it. Rust and C++ do not offer this capability either. The upside is that vectors are simpler. If there is evidence of alternative strategies being critical for performance, this PR could be amended to include a resizer strategy.
The current strategy also tries to reset the underlying array when the dyn array becomes empty, so it doesn't hold on to the dummy element anymore. Some functions, like
shrink_to_size
, can also be used to cut off the unused portion of the array to free the dummy element, and to eliminate the memory overhead.Related discussions
Recent comments about the lack of this data structure:
Prior attempts: