[RFC] Malfunction-safe DenseMap/DenseSet

Background
We (SAP) need some parts of LLVM to be robust against memory leaks in case of out of memory situations. This is because we work in the context of an in-memory database, where each crash of the process can potentially lead to data loss. Therefore, we invested and continuously invest into making LLVM core data structures more robust against OOM situations. Amongst other things, we hardened DenseMap and DenseSet, which this RFC is about.

Overview
To achieve malfunction-safety, we need to keep track of the construction/destruction state of key/value. This is necessary to ensure, that the keys/values are always destructed correctly in any scenario (exceptional and unexceptional) if they were constructed beforehand. Previously, this was not guaranteed. Furthermore, we found some unfavorable combinations in the order of instructions, that led to memory leaks in case of bad_allocs being thrown.
As especially DenseMap is a very central data structure within LLVM, it is important to keep an eye on memory consumption / performance along the way.
The outside behaviour of Dense[Map|Set], especially the API, is not changed by the improvements.

Implementation
The tracking part is done by extending Dense[Map|Set]Pair with two bools. We realized this, by essentially offering two variants of Dense[Map|Set]Pair. One for the trivially_destructible case, where we don’t need the booleans and one for the non trivially_destructible, where we need the tracking.

In case of DenseMap:

// OOM safe DenseMap bucket
// flags to indicate if key and value are constructed
// this is necessary since assigning empty key or tombstone key throws for some key types

template<typename KeyT, typename ValueT, typename Enable = void>
struct DenseMapPairImpl;

template <typename KeyT, typename ValueT>
using EnableIfTriviallyDestructibleMap = typename std::enable_if_t<std::is_trivially_destructible_v<KeyT> && std::is_trivially_destructible_v<ValueT>>;
template <typename KeyT, typename ValueT>
using EnableIfNotTriviallyDestructibleMap = typename std::enable_if_t<!std::is_trivially_destructible_v<KeyT> || !std::is_trivially_destructible_v<ValueT>>;

template <typename KeyT, typename ValueT>
struct DenseMapPairImpl<KeyT, ValueT, EnableIfTriviallyDestructibleMap<KeyT, ValueT>> : public DenseMapPair<KeyT, ValueT> {
  LLVM_ATTRIBUTE_ALWAYS_INLINE void setKeyConstructed(bool) {}
  LLVM_ATTRIBUTE_ALWAYS_INLINE bool isKeyConstructed() const { return false; }
  LLVM_ATTRIBUTE_ALWAYS_INLINE void setValueConstructed(bool) {}
  LLVM_ATTRIBUTE_ALWAYS_INLINE bool isValueConstructed() const { return false; }
};

template <typename KeyT, typename ValueT>
struct DenseMapPairImpl<KeyT, ValueT, EnableIfNotTriviallyDestructibleMap<KeyT, ValueT>> : DenseMapPair<KeyT, ValueT> {
private:
  bool m_keyConstructed;
  bool m_valueConstructed;

public:
  LLVM_ATTRIBUTE_ALWAYS_INLINE void setKeyConstructed(bool constructed) { m_keyConstructed = constructed; }
  LLVM_ATTRIBUTE_ALWAYS_INLINE bool isKeyConstructed() const { return m_keyConstructed; }
  LLVM_ATTRIBUTE_ALWAYS_INLINE void setValueConstructed(bool constructed) { m_valueConstructed = constructed; }
  LLVM_ATTRIBUTE_ALWAYS_INLINE bool isValueConstructed() const { return m_valueConstructed; }
};

These two booleans will cause an increase in size of each non trivially_destructible Dense[Map|Set]Pair. We haven’t measured the impact using the LLVM internal benchmarks yet, but our battle-tested/benchmarked compiler did not show any dramatic increases in memory size or performance.

Testing
We have created unit tests that test correct behavior of all operations on Dense[Map|Set] using our own malfunction framework. We are willing to upstream a minimal version of the framework, in order to robustly test our improvements from a malfunction standpoint. Due to the fact, that our patches have no functional impact, the existing functional tests should be sufficient.

Conclusion
Feel free to comment on this thread, since we wanted to inquire about the general interest in this regard before trying to upstream our patches. Do you have improvement suggestions or objections?

Thanks in advance,
Marc

A first draft of the patch uploaded in this PR: [ADT] Make DenseMap/DenseSet more resilient agains OOM situations by marcauberer · Pull Request #107251 · llvm/llvm-project · GitHub
Feel free to comment/suggest/object anything.

Marc

#ifdef LLVM_EH_ENABLED is certainly novel, and in my opinion worth an RFC independently of the changes to DenseMap and DenseSet you’re proposing.

Yes, I agree, but I have another idea:

Let’s do this incrementally and remove the try/catch completely from the DenseMap production coding for now. These three try/catch occurrences, that are currently in DenseMap::clear() are only there to prevent LLVM from crashing in cases like when LLVMContext in its destructor calls clear() on a DenseMap. If we later want it, we can still add it again.

However, we only can perform malfunction tests when exception handling is enabled. My naive approach to solve this would be #ifdef around the respective unit test cases. What do you think?

Another question is, if the CI run with EH enabled or not?!

Best,
Marc

Seems like the CI is not running with -DLLVM_ENABLE_EH, which is not surprising, because flang does not build with -DLLVM_ENABLE_EH. How can we tackle that?

Best,
Marc

You can provide a buildbot that tests subprojects you are interested in in a configuration you need (i.e. -DLLVM_ENABLE_EH). See the documentation: How To Add Your Build Configuration To LLVM Buildbot Infrastructure — LLVM 20.0.0git documentation

As I understand it, supporting EH-safe containers is a non-goal for most project contributors. There’s room for solutions that work for everyone in LLVM, and as long as making DenseMap more exception-safe doesn’t compromise code complexity, code size, or performance for other users, those improvements are welcome.

However, as mentioned, you would probably be the primary stakeholder of the EH-enabled configuration, and be the one setting up and maintaining build bots to cover it, or living without them.

2 Likes

Alright, thanks for the feedback!
I am in the process of clarifying internally if we can provide a machine + config for testing with EH via Buildbot. This may take a while.
Until a decision is made, perhaps we can continue without malfunction tests for now. The functional tests are still in place and working. I will also delete the LLVM_EH_ENABLED define again, so we do not introduce new concepts.

Marc

+1 to what @rnk said. I commented on the PR but will also leave this here.

As a sporadic ADT contributor, the thing I really value about our implementation is that it’s much simpler than alternative hash maps that have to worry about exceptions. I think it’s safe to say that LLVM developers at large are not used to / trained to think about exception safety, and this puts burden on subsequent contributors who may not care about handling these specific OOM conditions.

I’d like to understand what is necessary to make Dense* data structures OOM-resilient. I assume we have to take care of any key/value construction/copy/move that may fail to allocate, is this correct? Will this necessarily require us to update the other ADT containers/types to make this a (recursive) property of keys/values? What is not clear to me is if this is an all-or-nothing type of project, or if there’s a measurable value of the in-between states that you’d be satisfied with.

What is not clear to me is if this is an all-or-nothing type of project, or if there’s a measurable value of the in-between states that you’d be satisfied with.

Critical things that we observed and locally built patches for are:

  • DenseMap/Set
  • User/Use for Values

In both cases, multiple complex steps need to be taken, to get from one consistent state to another. So an OOM in between leads to unwanted behaviour, as the cleanup can’t be performed correctly.

Maybe I should also add that we are only interested in making the API and all underlying structures OOM-safe. The middle- and backend is way too complex to do that. Therefore we run a separate process that is allowed to crash ungracefully in OOM scenarios without risk of data loss.

So for the class DenseMap/Set this means all-or-nothing. Since DenseMap/Set has been a hot spot of OOM problems, the full robustness of DenseMap/Set is very valuable to us.

I’d like to understand what is necessary to make Dense* data structures OOM-resilient. I assume we have to take care of any key/value construction/copy/move that may fail to allocate, is this correct?

Our basic approach for DenseMap/Set is to make use of RAII. By assigning each allocation to a place where it is found and handled by a destructor we make sure the whole class is OOM safe without thinking about every single code line.

The natural idea is to use the buckets for this. Each bucket must be either valid or empty. Unfortunately the empty state cannot be marked by assigning an EmptyKey, since EmptyKey may throw for certain key types. Therefore, the additional flag (initialized/not initialized) of the bucket is required. The flag is only required if either key or value are non trivially destructible.

Will this necessarily require us to update the other ADT containers/types to make this a (recursive) property of keys/values?

Luckily, most data structures used in the frontend are simple enough that no problems occur. We have for example tested SmallVector without issues.

If the LLVM community is interested, we are also willing to contribute our solution for User/Use. As mentioned, DenseMap/Set and User/Use are the hotspots we found.

Concerning the memory and compile time regression (@nikic):
The approach is currently limited to non trivially destructible keys and values. For trivial keys and values the newly added instructions are noops. We could further limit the approach to be only active if llvm is compiled with EH. Then the mechanism would result in noops for default (non-EH) LLVM compilation.

I’m not sure what you mean by “the API” – LLVM has a lot of APIs, and I think you mean that you only care about a tiny subset of LLVM, but it’s not clear what that subset is.

Do you mean the API for constructing an IR module? That you want it to be safe under potential OOM conditions to create a Module object, build functions/globals/etc, and then serialize it to bitcode?

Do you mean the API for constructing an IR module? That you want it to be safe under potential OOM conditions to create a Module object, build functions/globals/etc, and then serialize it to bitcode?

Exactly.

I believe a large proportion of code is written without exception-safety guarantee in mind, a lot not even with basic guarantee. This choice gives us noticeable compiler-time/complexity advantage, e.g. SmallVector.

https://llvm.org/docs/ProgrammersManual.html

  1. std::vector is exception-safe, and some implementations have pessimizations that copy elements when SmallVector would move them.

I am curious how changing DenseMap/DenseSet would give us a “good enough” subset.

What @kuhar said in [ADT] Make DenseMap/DenseSet more resilient agains OOM situations by marcauberer · Pull Request #107251 · llvm/llvm-project · GitHub is true:

I think it’s safe to say that LLVM developers at large are not used to / trained to think about exception safety, and this puts burden on subsequent contributors who may not care about handling these specific OOM conditions.

As an ADT contributo-r, I am concerned that we accept some patches, regress compile-time/memory consumption/complexity, and we still have no basic exception-safety guarantee (which is highly likely).

My involvement with LLVM 19 | MaskRay : Optimizations to the bit mixer in Hashing.h and the DenseMap code have yielded significant benefits, reducing both compile time and code size. This suggests there’s further potential for improvement in this area.
However, the reduced code size also highlights potential significant code size increase when considering faster unordered map implementations like boost::unordered_flat_map, Abseil’s Swiss Table, and Folly’s F14. While these libraries may offer better performance, they often come with a significant increase in code complexity and size.
Introducing a new container alongside DenseMap to selectively replace performance-critical instances could lead to substantial code modifications. This approach requires careful consideration to balance potential performance gains with the additional complexity.

I am concerned that a complexer DenseMap would make such improvement (which is cared more by many more contributors) challenging.

As many concerns regarding code complexity as well as memuse and compile time came up, we decided to not upstream the main change. With the preparation changes, that we already merged by now, we can at least reduce the maintenance effort on our side.
Thanks for your feedback!

Best,
Marc

2 Likes