Repository: numba/numba

Issue:
A serious performance pitfall with set in nopython mode 
The following code takes a rather long time (does not finish in a few minutes). I am using 0.26

```
@jit(nopython=True)
def test():
     x=set([1])  
     for i in range(1000):  
        x.add(5)  
        x.discard(5)  
test()


```

In contrast,

```
@jit(nopython=True)
def test():
    x=set([1])
    for i in range(1000):
        x.add(5)
test()

```

takes  12.6 µs per loop in ipython.

```
@jit(nopython=True)
def test():
    x=set([1])
    for i in range(1000):
        x.discard(5)
test()


```

takes 10.7 µs per loop in ipython.

Finally,

```
def test():
    x=set([1])
    for i in range(1000):
        x.add(5)
        x.discard(5)
test()


```

takes 144 µs per loop in ipython.

So what is going on here?



Discussion:
@pitrou: Can you take a look at this?

Ok, it's not a performance issue, it's a bug in the key insertion logic in the face of deleted entries. Thank you for reporting this, we will be submitting a fix soon.


Resolving patch:
diff --git a/numba/targets/setobj.py b/numba/targets/setobj.py
--- a/numba/targets/setobj.py
+++ b/numba/targets/setobj.py
@@ -167,7 +167,7 @@ def get_entry(self, idx):
                                                ref=entry_ptr)
         return entry
 
-    def _lookup(self, item, h):
+    def _lookup(self, item, h, for_insert=False):
         """
         Lookup the *item* with the given hash values in the entries.
 
@@ -175,7 +175,7 @@ def _lookup(self, item, h):
         - If found is true, <entry index> points to the entry containing
           the item.
         - If found is false, <entry index> points to the empty entry that
-          the item can be written to.
+          the item can be written to (only if *for_insert* is true)
         """
         context = self._context
         builder = self._builder
@@ -195,6 +195,10 @@ def _lookup(self, item, h):
         # The index of the entry being considered: start with (hash & mask)
         index = cgutils.alloca_once_value(builder,
                                           builder.and_(h, mask))
+        if for_insert:
+            # The index of the first deleted entry in the lookup chain
+            free_index_sentinel = mask.type(-1)  # highest unsigned index
+            free_index = cgutils.alloca_once_value(builder, free_index_sentinel)
 
         bb_body = builder.append_basic_block("lookup.body")
         bb_found = builder.append_basic_block("lookup.found")
@@ -218,6 +222,14 @@ def check_entry(i):
             with builder.if_then(is_hash_empty(context, builder, entry_hash)):
                 builder.branch(bb_not_found)
 
+            if for_insert:
+                # Memorize the index of the first deleted entry
+                with builder.if_then(is_hash_deleted(context, builder, entry_hash)):
+                    j = builder.load(free_index)
+                    j = builder.select(builder.icmp_unsigned('==', j, free_index_sentinel),
+                                       i, j)
+                    builder.store(j, free_index)
+
         # First linear probing.  When the number of collisions is small,
         # the lineary probing loop achieves better cache locality and
         # is also slightly cheaper computationally.
@@ -249,6 +261,15 @@ def check_entry(i):
             builder.branch(bb_body)
 
         with builder.goto_block(bb_not_found):
+            if for_insert:
+                # Not found => for insertion, return the index of the first
+                # deleted entry (if any), to avoid creating an infinite
+                # lookup chain (issue #1913).
+                i = builder.load(index)
+                j = builder.load(free_index)
+                i = builder.select(builder.icmp_unsigned('==', j, free_index_sentinel),
+                                   i, j)
+                builder.store(i, index)
             builder.branch(bb_end)
 
         with builder.goto_block(bb_found):
@@ -398,7 +419,7 @@ def _add_key(self, payload, item, h, do_resize=True):
         context = self._context
         builder = self._builder
 
-        found, i = payload._lookup(item, h)
+        found, i = payload._lookup(item, h, for_insert=True)
         not_found = builder.not_(found)
 
         with builder.if_then(not_found):
@@ -579,7 +600,7 @@ def symmetric_difference(self, other):
             h = loop.entry.hash
             # We must reload our payload as it may be resized during the loop
             payload = self.payload
-            found, i = payload._lookup(key, h)
+            found, i = payload._lookup(key, h, for_insert=True)
             entry = payload.get_entry(i)
             with builder.if_else(found) as (if_common, if_not_common):
                 with if_common:


Test patch:
diff --git a/numba/tests/test_sets.py b/numba/tests/test_sets.py
--- a/numba/tests/test_sets.py
+++ b/numba/tests/test_sets.py
@@ -76,6 +76,13 @@ def discard_usecase(a, b):
         s.discard(v)
     return list(s)
 
+def add_discard_usecase(a, u, v):
+    s = set(a)
+    for i in range(1000):
+        s.add(u)
+        s.discard(v)
+    return list(s)
+
 def pop_usecase(a):
     s = set(a)
     l = []
@@ -430,6 +437,17 @@ def test_discard(self):
         b = self.sparse_array(50)
         check(a, b)
 
+    def test_add_discard(self):
+        """
+        Check that the insertion logic does not create an infinite lookup
+        chain with deleted entries (insertion should happen at the first
+        deleted entry, not at the free entry at the end of the chain).
+        See issue #1913.
+        """
+        pyfunc = add_discard_usecase
+        check = self.unordered_checker(pyfunc)
+        check((1,), 5, 5)
+
     @tag('important')
     def test_pop(self):
         pyfunc = pop_usecase