fitzgen opened PR #12621 from fitzgen:impl-oom-handling-secondary-map-from-scratch to bytecodealliance:main:
I realized we need to adjust its
V: Clonebound intoV: TryClonewhich means
that we can no longer actually just wrap an inner
cranelift_entity::SecondaryMap<K, V>and need to instead implement our
own. This also made me realize that we needremoveto be fallible because,
when the entry being removed is in bounds, it overwrites the entry with the
default value, but that default value needs to beTryCloned now which is, of
course, a fallible operation.Depends on
fitzgen requested alexcrichton for a review on PR #12621.
fitzgen requested wasmtime-fuzz-reviewers for a review on PR #12621.
fitzgen requested wasmtime-compiler-reviewers for a review on PR #12621.
fitzgen requested wasmtime-core-reviewers for a review on PR #12621.
fitzgen edited PR #12621:
I realized we need to adjust its
V: Clonebound intoV: TryClonewhich means
that we can no longer actually just wrap an inner
cranelift_entity::SecondaryMap<K, V>and need to instead implement our
own. This also made me realize that we needremoveto be fallible because,
when the entry being removed is in bounds, it overwrites the entry with the
default value, but that default value needs to beTryCloned now which is, of
course, a fallible operation.Depends on
alexcrichton commented on PR #12621:
I'll be honest in that I continue to not really understand
SecondaryMapas a data structure as it seems so niche to almost never be applicable... Do we actually have any usages of the map which need thisTryClonebound? Could we, for example, switch this aCopybound on the key?
github-actions[bot] added the label fuzzing on PR #12621.
github-actions[bot] added the label wasmtime:ref-types on PR #12621.
github-actions[bot] added the label cranelift on PR #12621.
github-actions[bot] added the label wasmtime:api on PR #12621.
github-actions[bot] commented on PR #12621:
Subscribe to Label Action
cc @fitzgen
<details>
This issue or pull request has been labeled: "cranelift", "fuzzing", "wasmtime:api", "wasmtime:ref-types"Thus the following users have been cc'd because of the following labels:
- fitzgen: fuzzing, wasmtime:ref-types
To subscribe or unsubscribe from this label, edit the <code>.github/subscribe-to-label.json</code> configuration file.
Learn more.
</details>
fitzgen commented on PR #12621:
I'll be honest in that I continue to not really understand
SecondaryMapas a data structure as it seems so niche to almost never be applicable...It is for whenever you want to associate extra data with a
PrimaryMap's key on the side (sort of struct-of-arrays style), and it also supports use cases where potentially not every key has extra data / there is a default value for the extra data.For example, in the
TypeRegistrywe (morally) have aPrimaryMap<VMSharedTypeIndex, Arc<WasmSubType>>but we also want to be able to map from a type to its GC layout, if any, so we have aSecondaryMap<VMSharedTypeIndex, Option<GcLayout>>on the side as well.Do we actually have any usages of the map which need this
TryClonebound? Could we, for example, switch this aCopybound on the key?The key is already bounded by
EntityRef, which impliesCopy. TheTryClonebound is for values. And yes, we do have non-Copyvalues inSecondaryMaps that need to betry_cloned (such asGcLayoutin the previous example).
cfallin commented on PR #12621:
It is for whenever you want to associate extra data with a PrimaryMap's key on the side (sort of struct-of-arrays style), and it also supports use cases where potentially not every key has extra data / there is a default value for the extra data.
To say it another way that may also help (I've had this question too in the past):
PrimaryMaps manage/allocate the ID space, and are dense (Vecinternally);SecondaryMaps are given existing IDs, and are dense;HashMaps, the other corner in common use, are given existing IDs and are sparse.So in Cranelift we tend to use them where we know most IDs will have an entry. Using a
PrimaryMapwould be incorrect as it would allocate different IDs.
fitzgen updated PR #12621.
alexcrichton commented on PR #12621:
Setting aside my reservations about
SecondaryMapfor a second, my main concern here is duplication of data structures. I think it's fine and reasonable to have API duplication as we do today but implementation duplication feels a step too far. One possible way to resolve this is to implement today'sSecondaryMapin terms ofKSecondaryMap(assuming we go withK*), but I realize that this wouldn't be trivial given the lack of relationship betweenCloneandTryClone.Bringing back my reservations about
SecondaryMap, the main thing I find weird about it is that it's sort of trying to be aHashMap<K, V>without exposing the fact that it's actually a dense map under the hood. The API surface area is focused around insert/remove/etc which gives it a feeling, in my opinion at least, of being more efficient than it actually is. I would personally prefer to see Wasmtime'sSecondaryMaps modeled as aPrimaryMapinstead with perhaps methods onPrimaryMapto do helper-like things such as (fill with N values of T then push this other value of T at the specified index). That, to me, would appropriately model the runtime behavior here whereinsertis O(N), not O(1).The reason I also bring this up is that I don't feel this extra data structure implementation is necessarily justified. In practice the default value for
Vis alwaysNoneorPackedOption::none()for allSecondaryMaps in theTypeRegistry. That means that while I agree conceptually the bounds onKSecondaryMapshould beTryClonewe wouldn't actually benefit from such a generalization anywhere. Or did you run in to a case preexisting in Wasmtime whereV: TryClonewas needed since an allocation could fail? My assumption would be that if we modeled things asPrimaryMapit would more naturally lend itself where the secondary-map-like-helper-methods would takeV: TryCloneand work appropriately.
cfallin commented on PR #12621:
Bringing back my reservations about SecondaryMap, the main thing I find weird about it is that it's sort of trying to be a HashMap<K, V> without exposing the fact that it's actually a dense map under the hood. The API surface area is focused around insert/remove/etc which gives it a feeling, in my opinion at least, of being more efficient than it actually is.
It might be good to understand whether this is the case or not more objectively. Picking one use-site in the type interner, for example, here (
ModuleTypes::trampoline_types), it appears that there are entries for function types but not other (GC-related) types, is that right? If so we expect this to be dense-ish for core modules and non-GC-using components, but maybe not otherwise? Can we measure this? What other use-sites are there and how do they fare? (cc @fitzgen on this I guess)More broadly: I'm somewhat concerned that we're discussing the existential fate of
SecondaryMapalongside and intertwined with questions of implementation strategy. It exists for good reason in Cranelift; the places where it's used, it is dense in practice (e.g.: a map of result value-lists for everyInst; every instruction has one of those, but the map is not in charge of ID allocation), and I would hope it's used for similar reasons inwasmtime-environ.And we should be clear about both asymptotic and implementation efficiency: (i) the
Vecis much more efficient than aHashMapat such use-cases because it doesn't need to store the explicit keys, and access patterns are less random and involve less logic; (ii) the statement "That, to me, would appropriately model the runtime behavior here where insert is O(N), not O(1)." is incorrect for any key space whose size is linear in the input program: there can only be at most O(#keys) pushes onto the end of theVec; new-key inserts are thus amortized O(1) and existing-key updates are deterministic O(1).I think we should satisfy ourselves that we do need the data structure, get everyone on that page, and then move discussion on toward only how to implement it.
cfallin commented on PR #12621:
Also: @alexcrichton in addition to thoughts on the above, could you clarify what you mean by "model things as PrimaryMap"? A SecondaryMap is fundamentally different in that it associates values with existing IDs, while a PrimaryMap allocates new IDs for values that are inserted, so one can't be replaced with the other.
alexcrichton commented on PR #12621:
Sorry I'm doing my best to disentangle my bias against
SecondaryMapfrom my concerns here, and I'm not doing a great job. At a base level my main concern is this is (a) duplicating a data structure that (b) I don't think is necessary. Specifically theV: TryClonewhile semantically correct I don't think is ever exercised in practice because the default for these maps is alwaysNoneor something whereCloneat runtime is just shuffling bytes and doing no allocation. I don't think it's worth having an entire duplicate implementation of this data structure for a feature that's not actually used, personally.I still want to clarify, though, what's the imapct of not having this PR at all? Does this actively block some OOM-handling work for example? My current understanding is that this fixes a footgun with
KSecondaryMap, but it's not a footgun that we're currently able to fire. If this actively unblocks something then the situation is different.
Orthogonally it's probably most productive for me to just keep my concerns about
SecondaryMapto myself. I understand its theoretical use as a data structure and I'm not advocating for its removal from Cranelift. I'd like to minimize/remove its usage from Wasmtime personally, but that's orthogonal to this PR itself.
cfallin commented on PR #12621:
I'd like to minimize/remove its usage from Wasmtime personally, but that's orthogonal to this PR itself.
Well, I guess that's why I'm asking about value-presence density for the specific use-cases in Wasmtime above. The one that I linked for func-type trampolines should be dense for most modules today, so using a
HashMapwould be a performance regression (andPrimaryMapis not an option; it's semantically different). So I guess that's what I'm trying to understand: we want to remove it from Wasmtime because of some subjective feeling, or because we believe other uses of it are actually less efficient at runtime?
alexcrichton commented on PR #12621:
I'm not actaully sure what the best data structure for Wasmtime is, but my assumption is that we'd replace it with
PrimaryMap. My understanding is that aSecondaryMap<K, V>is actually aPrimaryMap<K, V>whereVhas some sort of sentinel meaning "none" (e.g.Option<T>orPackedOption<I>). Given that I don't understand whyPrimaryMapis not an option, because that's already more-or-less the implementation details ofSecondaryMap. If I'm not misunderstanding, this is why I findSecondaryMapconfusing, it's aPrimaryMapin a thin veil.As for the best data structure, we can construct things every which way: modules with the
SecondaryMapempty, modules withSecondaryMapbeing dense, and modules withSecondaryMaphaving a huge gap followed by a single entry. The rough saving grace today is that mostSecondaryMaps(I think) are only needed for GC-related which is still off-by-default meaning most of this isn't used, so it doesn't end up mattering in practice currently as it's rarely used. In all of these situations though I don't think there's an obvious answer -- hash maps would excel in some use cases and fall down in others.SecondaryMapshines in some use caes and falls down in others too. Without changing the algorithmic constraints of lookup (e.g. going to O(log(n))) I don't think we have great options.
cfallin commented on PR #12621:
but my assumption is that we'd replace it with
PrimaryMap. My understanding is that aSecondaryMap<K, V>is actually aPrimaryMap<K, V>whereVhas some sort of sentinel meaning "none" (e.g.Option<T>orPackedOption<I>). Given that I don't understand whyPrimaryMapis not an option, because that's already more-or-less the implementation details ofSecondaryMap. If I'm not misunderstanding, this is why I findSecondaryMapconfusing, it's aPrimaryMapin a thin veil.OK, so there's the root of the misunderstanding: the above is not true; a
SecondaryMapis not aPrimaryMapwith presence. The difference is in the API: as described above, aSecondaryMapdoes not allocate keys. You use aPrimaryMapwhen you want to create a collection of things and have the container give you IDs as handles. You use aSecondaryMapwhen you already have IDs (handles) and want to associate some other data with them. Using aPrimaryMapin that case makes no sense, because all you can do is add a newV, and the map will allocate some other arbitraryKfor you.Given all that, a
SecondaryMapcan be compared to aHashMap. This is the difference between a map and a vector, basically, at the abstract datatype API level.
alexcrichton commented on PR #12621:
To avoid getting sidetracked too much, I do want to say again that my primary concern above I've tried to disentangle from my thoughts on
SecondaryMap-- specifically this is duplicating a data structure for a feature that I don't believe we use. I understand the "close the footgun" motivation but I don't think that alone is enough to motivate the duplication.
Otherwise personally I'm not really sure how the detail of allocating IDs or not leads to a misunderstanding here. There's nothing stopping us from adding APIs to
PrimaryMapwhich make it operate like aSecondaryMapin some situations. That IDs are allocated feels to be but a side effect of an operation already happening. To me the underlying data structure implementation and big-O of methods is way more important than the conceptual operation happening. For example I don't think it makes sense to sayPrimaryMapis entirely unsuitable for doingSecondaryMap's job when it's 1-2 methods away from being able to do that.My overall point is that
SecondaryMapruns the risk of being a very badHashMap. It operates reasonably enough as aHashMapin specific situations and can spectacularly fall down in others. Papering over this, in my opinion very important, detail is doing the collection a disservice. For example a more apt name would be something likeDenseMapwhich emphasizes that it is only really intended when the key space is particularly dense.
cfallin commented on PR #12621:
A few thoughts:
- Sure, we can add methods to
PrimaryMapthat implementSecondaryMap's semantics. I had thought you were saying above to replace uses ofSecondaryMapwithPrimaryMapdirectly, which is what I was responding to. But there's nothing wrong with adding an "insert this value at this specified ID" method, which fills out the array as necessary to ensure the slot for that ID exists.- I'd agree that building a fallible
SecondaryMapon top of a falliblePrimaryMapcould be a nice way of factoring things. I don't see any such thing in the tree right now but I haven't been tracking the full stream of these PRs in flight so correct me if I'm wrong and it does exist somewhere...- Re: "for a feature that I don't believe we use." -- I believe that fitzgen gave an example here of a value-type that is not
Copy, and so needsClone(henceTryClonein the fallible universe).- Re: "the underlying data structure implementation and big-O of methods is way more important than the conceptual operation happening." -- sure, and this is why I asked for information about the use-cases and density of the maps' key-spaces here. As mentioned in that comment,
SecondaryMaphas overall time and space bounded by the size of the keyspace; so if we expect a number of entries to be O(n) forn-sized program, then it has the same asymptotic complexity as a hashmap (amortized O(1) per operation), and better (possibly much better) constant factors. If the keyspace is sparse, then of course this isn't true anymore. I suspect the use-cases are driven by observation that those constant factors are important, and we want the performance, and reverting to a hashmap would be a real perf loss; but we should evaluate that as part of an effort to understand whether we need this data structure.
alexcrichton commented on PR #12621:
I understand, yeah, that
V: Copyisn't possible, but my point is that the reason for reimplementing the collection is because the default value is cloned and that's what needs to be fallible. What I have been saying is that the default value I don't believe ever needs a fallible clone in practice, only conceptually.Also, yeah, I understand the consequences of sparse-ness, and the point I was making is that we are not equipped to say whether something is sparse or not ahead of time. An engine could register hundreds of modules that don't use GC types, and then all of a sudden registering a GC one takes a nontrivial amount of time because it's got to grow a bunch of secondary maps a very large amount before it's done. Alternatively an engine could entirely have GC modules and the key space could be super dense and have no real cost at all. My point is Wasmtime as an engine cannot assume dense or not, we just simply don't know ahead of time.
cfallin commented on PR #12621:
OK, yeah, that's a good point regarding the non-deterministic latency (despite amortized bound linear with total size of all modules). Maybe that's enough reason to favor HashMaps generally in module metadata.
I suppose that should be done in a separate PR if so. @fitzgen, what do you think?
fitzgen commented on PR #12621:
@alexcrichton
What I have been saying is that the default value I don't believe ever needs a fallible clone in practice, only conceptually.
FWIW, we could diverge from
cranelift_entity::SecondaryMap's API here and only allow usingDefault::default()as the default value and document thatDefault::default()should never allocate.@cfallin
non-deterministic latency (despite amortized bound linear with total size of all modules). Maybe that's enough reason to favor HashMaps generally in module metadata.
HashMaps will also have non-deterministic latency if you hit the resize path on insertion. I don't think they will actually change anything about latency determinism (and they potentially make it worse because it isn't just realloc with a potential memcpy if the realloc didn't happen in place, it is redistributing (and potentially rehashing) each entry across the hash map's new buffer).Also
HashMaps don't give us as strong of guarantees around OOM and fallible allocation on insertion. We already use one, but it doesn't seem like a great idea to further entrench more of them here.The other alternative would be to move towards an OOM-handling
BTreeMap, which we will eventually need anyways, but that will move us from $O(1)$ to $O(log(n))$.But backing up and regarding sparseness in general: I view our struct-of-arrays representation as a strict improvement over the equivalent array-of-structs representation (which is what I would consider the default/baseline way to implement this stuff):
struct Type { ty: Arc<WasmSubType>, rec_group: RecGroupEntry, supertypes: Option<Box<[VMSharedTypeIndex]>>, trampoline: Option<VMSharedTypeIndex>, gc_layout: Option<GcLayout>, }; struct TypeRegistryInner { hash_consing_map: HashSet<RecGroupEntry>, types: Slab<Type>, drop_stack: Vec<RecGroupEntry>, }Our struct-of-arrays implementation today is an optimization over that baseline because it allows us to avoid the memory overhead of supertypes, trampolines, and GC layouts when GC stuff isn't used at all. That's already a win.
(Ignoring OOM handling and the insertion latency discussion for a second) switching to a
HashMapor something else sparse would give use a further space-saving optimization for when GC stuff is used but only rarely. That would be nice, but like any other random optimization that we haven't implemented yet, it is a "nice to have" not a hard requirement IMO.@alexcrichton
I still want to clarify, though, what's the imapct of not having this PR at all? Does this actively block some OOM-handling work for example? My current understanding is that this fixes a footgun with
KSecondaryMap, but it's not a footgun that we're currently able to fire. If this actively unblocks something then the situation is different.There was a bug in the previous implementation of
Serialize/Deserializewhere it didn't matchcranelift_entity::SecondaryMap's behavior (which trims trailing default entries) which was triggering some panics once later PRs that depend on this one were applied.So it is blocking those others from landing, but we could also make a new PR that just reimplements serialization in order to unblock them.
alexcrichton commented on PR #12621:
How about this:
- Update this PR (or a sibling one) to fixing the
Serialize/Deserializebug- Have a separate issue discussing possible data structure changes to
TypeRegistry- For now documenting the default value in a
wasmtime_environ::collections::SecondaryMapshouldn't allocate-on-clone.- Determine the final fate of OOM-capable
SecondaryMapdepending on the outcome of (2)
Last updated: Feb 24 2026 at 04:36 UTC