Repository navigation
Sequences #72
Description
Here's a dump of what I've been thinking about sequences so far. This is informed generally by the experimenting I've been doing in my polyfill/interpreter. This isn't so much an "I have answers" so much as it is a "here's why I'm thinking this is tricky"
Core use cases (in order of importance)
- Passing buffers of bytes efficiently
- buffers of larger data elements should be efficient too
- Supporting collections of arbitrary data
- including structured data
- including non-scalar data (strings, and sequences)
- Translating data between different representations of an ordered list
Sample sequences
C++ alone has three immediate options:
T*buffer (plus length separately)std::vector<T>std::list<T>
Assume vector is implemented as a struct with a pointer to a buffer, a length, and a current capacity.
Assume list is implemented as a doubly-linked list, with pointers to the front and back.
I'm using C++ here because it can represent this in a variety of ways. For any given language I expect we only need to idiomatically support a single style. So for C++ we can have compiler support to autogenerate buffer adapters, but not linked lists, whereas in Scheme we could natively support linked lists but not necessarily anything else.
Complications
Assume we model a buffer of arbitray bytes as seq<u8>. To support a maximally-efficient copy, we need to have primitives that can be optimized into a single memcpy.
For the C-style simple buffer, we can do something similar to memory-to-string/string-to-memory, which takes ptr + length and produces a seq, and vice-versa (open question: does this handle only u8, or is it parameterized, and what type is it parameterized on, interface types or wasm types?).
For the linked list case, this is fundamentally impossible (and that's ok). We do need to be able to build a sequence from a linked list, so we will at minimum need another pair of primitives to do so. In my polyfill I tried fold-seq and repeat-until (using helper functions), to go from seq to list and vice-versa.
The vector case is surprisingly tricky to handle. On the one hand we can reuse the buffer-copying logic to marshal the actual data over. On the other hand, we need to maintain the vector invariants as we go (length, capacity). We either need to use a fold primitive and call in to the C++ code to push_back every element (and let C++ maintain the invariants), use the mem-to-seq instructions and fix up the invariants at the end, or move the invariant-maintaining logic in to the body of the folds. The only one of those that is amenable to optimization is to defer fixing up the internal state. I can't quite imagine how to express that without hand-writing the adapter, however (although for std::vector, as part of the standard library, we are technically able to do so).
For simplicity of specification it may be nice to just have a single kind of general primitive, some form of fold or loop, and have clear rules for where it is optimized away into a memcpy. It is also probably better to start with the more general primitive, and add alternative more-optimal ones later on.
Activity
lukewagner commentedon Sep 24, 2019
One idea I've been pondering is to have a single, fairly-general
memory-to-sequencelifting instruction that takes a function immediate with signature:[i32]→[T,i32,bool]where: the incomingi32is the pointer to the element which is to be turned into aTelement (for aseq<T>), theboolresult indicates whether there is a next element and, if so, thei32result is a pointer to it.For a
std::vector<uint8_t>this would look like (using Nick'swasmidea, and pulling inletfrom function-references) [Edit: take out explicitwasm...end]:Inlining the lambda function into the
memory-to-sequenceinstruction's implied for loop, this adapter could be compiled fairly easily into a pretty-efficient loop in machine code. Optimizing this further into amemcpywould involve some fairly simple analysis, but it's possible that this would feel arbitrary enough to motivate a more-specialized instruction that was guaranteed to map to amemcpy.The neat thing though is that the same
memory-to-sequence+lambda design would allow mapping astd::list<uint8_t>to a sequence just as easily [Edit: take out explicitwasm...end]:Lowering is a separate topic, but WDYT about this lifting?
fgmccabe commentedon Sep 24, 2019
lukewagner commentedon Sep 24, 2019
Yeah, the
wasm ... endblocks aren't especially beautiful; the alternative is simply to allow the wasm instructions inline without the surrounding block; the high-order bit, I think, is not redefining core wasm instructions to be similar-but-slightly-different.So for the looping block idea: is the idea that the
memory-to-sequenceinstruction itself defines the block? That could certainly work, I think. I liked reusing the lambdas b/c I think we'd need them for independent reasons, but perhaps the essential difference here is how they are treated by the rewrite rules; lifting/lowering bodies get rewritten/fused; allocator functions don't.Agreed that lifting/lowering have to be designed in tandem, but I was trying to keep the post at least somewhat byte-sized and digestible.
jgravelle-google commentedon Sep 24, 2019
What I wound up doing for the wasm instructions in my polyfill was just reimplement them as
addandload. Not elegant either but was the quickest thing to do.We could also potentially have the
wasm ... endblocks be an encoding detail, where for any instr that's part of the subset we support, we duplicate the text format, and then encode it in the binary as somestart_wasmsentinel.Ok that's compelling enough that I'll take it to that thread.
Yeah, in the same way that string-to-mem + mem-to-string is very clearly optimizable to memcpy (ish), seq-to-mem + mem-to-seq would be a way of making it clear that that works. We just also need a more general primitive. Which order we ship them in is less important so long as we design both to the point where we're confident.
This sounds like a great document for the working notes :D
Which I'd thought about doing this as, but figured we should get something like consensus first. It sounds like we have some vaguely converging ideas though. But yeah I predict we will wind up with a note document with something like 5-10 examples here and that should help.
jgravelle-google commentedon Sep 25, 2019
A question I have is, are there other data structures that should map to sequences?
Stacks and queues should, but their internal memory layout is (probably) similar to a vector. (Hash)Maps are a collection, but need to store a key associated with each element.
Do we want to pass sets across an interface? Does it make sense to convert from set to (say) vector?
If yes, the same principles apply as a linked list, where we may not expect things to be fast and can always fall back to constructing a value by repeatedly calling an
add_elemexport. A sample implementation of a set could model the data as a tree. If the tree is stored as explicitly-modeled nodes with pointers to their children, the data is spread out and we can't expect any magic. If the tree is stored as implicitly-modeled nodes, then we may be able to read the data out (in some order) with a contiguous memcpy, but in order to maintain the tree invariants if we write data back in, we need to call exports.Writing this out, I think the question of "should we let people publish an API that takes collections and call it with the wrong kind of thing?" is answered with "how could we even stop them in theory?"
Are there other ways of laying out data in memory aside from contiguously, or spread out with pointers?
fgmccabe commentedon Sep 25, 2019
jgravelle-google commentedon Sep 25, 2019
Right, my point there is not that we need to add anything for non-sequential collections, but that the primitives we have are already enough for people to sequence their non-sequential data. We can (and should) continue to not care about them, but people will use them, and they will mostly work.
A related question is what are the essential properties of a sequence. Well-ordered is probably one. Isomorphic across representations could be one? i.e. for all X,Y where X is a type of sequence and Y is a type of sequence, for all n where n is a collection of values, then X(Y(X(n))) == X(n).
For example let n = [1, 2, 2, 3], X = Vector, Y = List, then Vector(List(Vector([1, 2, 2, 3]))) == Vector([1, 2, 2, 3]), whereas if Y = Set then we would drop the duplicated 2 so the isomorphism fails to hold.
I don't think we have any mechanism to disallow a user from mapping a set to a sequence. But we don't have to care about what happens in that case.
fgmccabe commentedon Sep 25, 2019