Repository: numba/numba

Issue:
Dicts with tuple keys not working in 0.52.0
I’m using a dict with a tuple as the key which worked fine in 0.51.2, but does not work in 0.52.0:

```
import numpy as np
import numba

@numba.njit
def testdict(tuptype):
    tdict = numba.typed.Dict.empty(tuptype, numba.core.types.float32)
    t1 = np.array([3], dtype=np.uint64)
    t2 = np.array([5.67], dtype=np.float32)
    v1 = np.array([10.23], dtype=np.float32)
    tdict[(t1[0], t2[0])] = v1[0]
    # 0.51.2 prints True as expected; 0.52.0 prints False
    print((t1[0], t2[0]) in tdict)

testdict(
  numba.core.types.Tuple((numba.core.types.uint64, numba.core.types.float32)))
```
When I try to check membership of an element that I just stored, 0.52.0 returns False.


Discussion:
Thanks for the report. When I run this I see:
```
NumbaTypeSafetyWarning: unsafe cast from Tuple(int64, float64) to Tuple(uint64, float32). Precision may be lost.
```
which hints at the problem, and I'm afraid it's somewhat subtle. The issue is on this line:
```
tdict[(t1[0], t2[0])] = v1[0]
```
when Numba indexes into the arrays, the returned type is a python scalar, not a NumPy scalar. There's numerous issues about this (e.g. https://github.com/numba/numba/issues/5990) and it's waiting on a somewhat involved fix. The above results in a dictionary key being a tuple of an `int64` and a `float64` which then leads to type casting and ends up with something invalid.

A way to get what I think you are looking for is this:
```python
import numpy as np
import numba

kty = numba.core.types.Tuple((numba.core.types.uint64, numba.core.types.float32))

@numba.njit
def testdict():
    tdict = numba.typed.Dict.empty(kty, numba.core.types.float32)
    t1 = np.array([3], dtype=np.uint64)
    t2 = np.array([5.67], dtype=np.float32)
    v1 = np.array([10.23], dtype=np.float32)
    tdict[(np.uint64(t1[0]), np.float32(t2[0]))] = v1[0]
    print((np.uint64(t1[0]), np.float32(t2[0])) in tdict)

testdict()
```

Thanks for the explanation @stuartarchibald. Unfortunately the workaround doesn't seem to work for me -- it still prints False (and I didn't get the NumbaTypeSafetyWarning message with the original code). I'm wondering if I'm running a different version than you are. I installed numpy and numba via pip install. Here are the versions on my system (Fedora 32):

# np.version.version
numpy 1.19.4

# numba._version.get_versions()
{'version': '0.52.0', 'full': '18825058a6e32bb92f3941f3c8d14a818f19279f'}

Looks like this has to do with the new refprune pass
Yup, running the reproducer under `NUMBA_LLVM_REFPRUNE_PASS=0` stops it from failing.
Update: I'm ruling out this bug being caused by the new refprune pass. I am finding that on 0.51.0 (3a14664a), the reproducer shows:

```bash
$ NUMBA_OPT=0 python iss6570.py
False
$ NUMBA_OPT=1 python iss6570.py
False
$ NUMBA_OPT=3 python iss6570.py
True
```

I have also disabled the old refpruner. Result changing due to optimization seems to imply that we relying on an undefined behavior.
I have found the true cause of this issue---a problem with garbage value in alignment padding bytes. The key type is a `{i64, float32}`, which has an 8-byte alignment. The true data is an 8-byte (i64) + a 4-byte (float32) resulting in 12-byte data structure. So the compiler puts 4-byte padding to make it 8-byte aligned. The dict container C code is told the key type is 16-byte long (from sizeof) and does `memcmp()` to find matching keys. The failure was caused by that 4-byte containing random garbage.

Resolving patch:
diff --git a/numba/core/cgutils.py b/numba/core/cgutils.py
--- a/numba/core/cgutils.py
+++ b/numba/core/cgutils.py
@@ -373,20 +373,28 @@ def alloca_once(builder, ty, size=None, name='', zfill=False):
     with builder.goto_entry_block():
         ptr = builder.alloca(ty, size=size, name=name)
         # Always zero-fill at init-site.  This is safe.
-        builder.store(ty(None), ptr)
+        builder.store(ptr.type.pointee(None), ptr)
     # Also zero-fill at the use-site
     if zfill:
-        builder.store(ty(None), ptr)
+        builder.store(ptr.type.pointee(None), ptr)
     return ptr
 
 
-def alloca_once_value(builder, value, name=''):
+def sizeof(builder, ptr_type):
+    """Compute sizeof using GEP
+    """
+    null = ptr_type(None)
+    offset = null.gep([int32_t(1)])
+    return builder.ptrtoint(offset, intp_t)
+
+
+def alloca_once_value(builder, value, name='', zfill=False):
     """
     Like alloca_once(), but passing a *value* instead of a type.  The
     type is inferred and the allocated slot is also initialized with the
     given value.
     """
-    storage = alloca_once(builder, value.type)
+    storage = alloca_once(builder, value.type, zfill=zfill)
     builder.store(value, storage)
     return storage
 
@@ -907,6 +915,18 @@ def memset(builder, ptr, size, value):
     builder.call(fn, [ptr, value, size, bool_t(0)])
 
 
+def memset_padding(builder, ptr):
+    """
+    Fill padding bytes of the pointee with zeros.
+    """
+    # Load existing value
+    val = builder.load(ptr)
+    # Fill pointee with zeros
+    memset(builder, ptr, sizeof(builder, ptr.type), 0)
+    # Store value back
+    builder.store(val, ptr)
+
+
 def global_constant(builder_or_module, name, value, linkage='internal'):
     """
     Get or create a (LLVM module-)global constant with *name* or *value*.
diff --git a/numba/typed/dictobject.py b/numba/typed/dictobject.py
--- a/numba/typed/dictobject.py
+++ b/numba/typed/dictobject.py
@@ -343,6 +343,8 @@ def codegen(context, builder, sig, args):
         data_val = dm_val.as_data(builder, val)
 
         ptr_key = cgutils.alloca_once_value(builder, data_key)
+        cgutils.memset_padding(builder, ptr_key)
+
         ptr_val = cgutils.alloca_once_value(builder, data_val)
         # TODO: the ptr_oldval is not used.  needed for refct
         ptr_oldval = cgutils.alloca_once(builder, data_val.type)
@@ -435,6 +437,7 @@ def codegen(context, builder, sig, args):
 
         data_key = dm_key.as_data(builder, key)
         ptr_key = cgutils.alloca_once_value(builder, data_key)
+        cgutils.memset_padding(builder, ptr_key)
 
         ll_val = context.get_data_type(td.value_type)
         ptr_val = cgutils.alloca_once(builder, ll_val)


Test patch:
diff --git a/numba/tests/test_dictobject.py b/numba/tests/test_dictobject.py
--- a/numba/tests/test_dictobject.py
+++ b/numba/tests/test_dictobject.py
@@ -930,6 +930,24 @@ def foo():
 
         self.assertEqual(foo(), foo.py_func())
 
+    def test_issue6570_alignment_padding(self):
+        # Create a key type that is 12-bytes long on a 8-byte aligned system
+        # so that the a 4-byte padding is needed.
+        # If the 4-byte padding is not zero-filled, it will have garbage data
+        # that affects key matching in the lookup.
+        keyty = types.Tuple([types.uint64, types.float32])
+
+        @njit
+        def foo():
+            d = dictobject.new_dict(keyty, float64)
+            t1 = np.array([3], dtype=np.uint64)
+            t2 = np.array([5.67], dtype=np.float32)
+            v1 = np.array([10.23], dtype=np.float32)
+            d[(t1[0], t2[0])] = v1[0]
+            return (t1[0], t2[0]) in d
+
+        self.assertTrue(foo())
+
 
 class TestDictTypeCasting(TestCase):
     def check_good(self, fromty, toty):