1

When configured for mutability, boost::heap::d_ary_heap uses a std::list in addition to the vector that holds the values of the heap nodes. I realize that the handles which are being provided for making the mutable_heap_interface work are in fact iterators of this list, but I'm wondering why such an expensive solution was chosen, and if there's a leaner way to achieve mutability with boost::heap::d_ary_heap.

Mutability requires a way to find the index of a node in the heap vector, given the node itself. Some kind of backward pointer needs to be maintained. Can't this be achieved by storing this backwards pointer in the node, and maintain it by the move/copy constructors/assignment-operators of the value type?

Is there a good reason why it needs to be as expensive as a doubly-linked list?

sh-
  • 941
  • 6
  • 13
  • 2
    Counter-question: Have you profiled the alternative you sketched to show that it is really so (comparatively) expensive? (Have you looked at e.g.[ intrusive treap](https://www.boost.org/doc/libs/1_73_0/doc/html/intrusive/treap_set_multiset.html)?) – sehe Jun 21 '20 at 21:17
  • 1
    I haven't looked in depth at this, but I see a superficial resemblance between maintaining "some kind of backward pointer" and maintaining a singly-linked list, in that both involve maintaining some kind of pointer for each node. Could you (briefly, succinctly) demonstrate how your proposed approach would soundly outperform a singly-linked list? (Shifting from that comparison to one with a doubly-linked list should be reasonably routine.) Not necessarily in all cases, but enough to be persuasive, if not convincing. – JaMiT Jun 22 '20 at 04:32
  • 1
    The list used by boost is a std::list, i.e. a doubly linked list. So it seems clear from the outset that it must use more memory than a single backwards pointer. So, no, I didn't profile it just yet. Before I start implementing the alternative I have in mind, I wanted to understand a bit better what motivated the boost design, hence my question. Thanks for the pointer to the treap, I had missed that! I will have a closer look. – sh- Jun 22 '20 at 07:10
  • @sh- Given Boost's reputation, I suspect that at least one of heap's member functions was made an order of magnitude faster by using a doubly-linked list instead of a singly-linked list. However, I don't know a more efficient way to confirm or deny this suspicion than to try to implement an alternative. ;) I've used this approach in other circumstances and found the effort enlightening even when my alternative was not better, but of course your mileage may vary. Assuming you do give it a try, don't forget that you can post an answer to your own question. – JaMiT Jun 22 '20 at 21:59
  • @JaMiT- From the copyright date of the heap library I guess that they were assuming C++03, so it was supposed to work without rvalue references, which may explain their architecture. I am currently experimenting with an implementation based on std::priority_queue that uses the move assignment operator of the values in the heap to maintain the backward pointer in the managed object. That's quite lean and doesn't need an extra list. It forces me to invalidate heap entries instead of removing or updating them, however, as std::priority_queue isn't mutable. I may post the result here soon. – sh- Jun 24 '20 at 06:40

1 Answers1

0

This is kind of an answer to my own question that only speculates why the boost design is as it is, and presents a partial solution to what I would have liked to get with the boost data structure. I'm still interested in receiving further insight into the rationale behind the boost implementation, and of course also feedback on the solution I present below.

Let me first explain the piece of code below, before going on to discuss its merits and problems, and then comment on the boost.heap implementation, why it presumably is like it is, and why I don't like it.

The code below is based on the venerable std::priority_queue. It splits the node managed by the priority queue into a handle and a body. The handle goes into the heap at the core of the priority_queue, and therefore moves around in the underlying vector as entries are added or removed. The handle only contains the priority value and a pointer to the body, in order to make it cheap to move it around. The body is a potentially large object that remains stationary in memory. It holds a backpointer to the handle, because the handle must be invalidated when the body's priority changes, or the body disappears.

Since the handle moves around in the heap, the backpointer in the body must be updated each time the handle changes location. This is done in the move constructor and the move assignment operator of the handle. If a handle gets invalidated, both the pointer in it and the backpointer pointing at it are nulled.

#include <queue>

//! Priority queue that works with handles to managed objects.
template<typename Prio, typename Object> struct PriorityQueue {
    struct Entry;

    //! Each heap entry is a handle, consisting of a pointer to the managed object and a priority value.
    struct Entry {
        Object *obj_;
        Prio val_;

        Entry(Entry const &) =delete;
        Entry &operator=(Entry const &) =delete;

        ~Entry() {
            if(obj_)
                obj_->setLink(nullptr);
        }

        Entry(Object &obj, Prio val)
            : obj_{&obj}
            , val_{val}
        {
            if(obj_)
                obj_->setLink(this);
        }

        Entry(Entry &&v)
            : obj_{v.obj_}
            , val_{v.val_}
        {
            if(obj_)
                obj_->setLink(this);
            v.obj_ = nullptr;
        }

        Entry &operator=(Entry &&v) {
            if(&v != this) {
                val_ = v.val_;
                if(obj_)
                    obj_->setLink(nullptr);
                obj_ = v.obj_;
                if(obj_)
                    obj_->setLink(this);
                v.obj_ = nullptr;
            }
            return *this;
        }

        friend bool operator<(Entry const &a, Entry const &b) {
            return a.val_ < b.val_;
        }

    };

    Prio add(Object &obj, Prio val) {
        while(!heap_.empty() && !heap_.top().obj_)
            heap_.pop();
        heap_.emplace(obj, val);
        return heap_.top().val_;
    }

    Prio remove(Object &obj) {
        // We can't remove the entry straight away, so we null the pointer
        // and leave the entry in the heap, where it will eventually bubble
        // up to the root position, from where it can be removed.
        if(obj.getLink()) {
            obj.getLink()->obj_ = nullptr;
            obj.setLink(nullptr);
        }
        while(!heap_.empty() && !heap_.top().obj_)
            heap_.pop();
        return heap_.empty() ? INT64_MAX : heap_.top().val_;
    }

    Prio update(Object &obj, Prio val) {
        remove(obj);
        return add(obj, val);
    }

    std::priority_queue<Entry> heap_;
};

//! Example of a managed object.
struct MyObject {
    MyObject(MyObject const &) =delete;
    MyObject &operator=(MyObject const &) =delete;

    PriorityQueue<int, MyObject>::Entry *getLink() const {
        return link_;
    }
    
    void setLink(PriorityQueue<int, MyObject>::Entry *link) {
        link_ = link;
    }
    
    PriorityQueue<int, MyObject>::Entry *link_;
};

Unfortunately, std::priority_queue doesn't support mutability, i.e. you can't remove entries except the root entry, so the fallback is to leave handles in the heap, but invalidate them by breaking the relationship with the body. They will eventually bubble up towards the root, where they can be removed. Obviously, that means that they inflate the size of the heap needlessly, consuming some additional memory and CPU time, which may or may not be significant. If std::priority_queue would expose the internal heap maintenance functions, it would be possible to delete or update entries directly.

It would be possible to reduce the handle size even more by holding the priority in the body rather than the handle, but then the body would need to be consulted for each priority comparison, which would destroy locality of reference. The chosen approach avoids this by holding everything in the handle that is relevant for heap maintenance. The updating of the backpointer in the body by the move constructor and move assignment operator is a write-only operation, which needn't hinder performance, since there typically are write buffers in modern processors that can swallow the associated latency.

For optimizing cache performance, one would wish to use a d-ary heap instead of a binary heap, so that all children of a node (i.e. their handles), which are adjacent in the vector, occupy one cache line. Alas, that's not supported by std::priority_queue, either.

The latter would be supported by boost.heap, but in order to also support mutability, they introduce an additional std::list for the management of the backpointers, which I suspect is rooted in the age of the library. It dates back to before C++11, when move support wasn't yet available in the language. Presumably, only minimal maintenance has been done to it since. I'd welcome them bringing the library up to date and use the opportunity to provide leaner implementations.

So, the bottom line is that I have at least a suspicion that answers my original question, and a design that addresses some of my goals, leaving me with a workable but not yet optimal solution based on the standard library.

Thanks go to the commenters, and remember if you have additional insight to add, you're most welcome.

sh-
  • 941
  • 6
  • 13